Add ICS import/export and shared calendar support to calendar UI
- Combine own and shared calendars into a single /web/calendar month view, each event colored per its source calendar. - New event creation now uses a calendar <select> (only writable calendars offered); editing keeps the event's original calendar fixed. - Shared calendars use an "owner~name" reference in URLs, resolved via resolveCalRef which also enforces read/write permissions from calendar_shares (read-only shares can view/export but not edit/delete). - Add per-calendar ICS export (single event and export-all) and import, splitting multi-VEVENT uploads into individual stored objects while preserving VTIMEZONE definitions and source VERSION/PRODID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,16 +2,22 @@ package templates
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CalendarSummary is one of the user's own calendars, listed on the
|
||||
// calendar home page.
|
||||
// CalendarSummary is one calendar (own or shared) shown in the combined
|
||||
// month view's legend.
|
||||
type CalendarSummary struct {
|
||||
Name string
|
||||
Color string // hex color like "#3b82f6", "" if unset
|
||||
Ref string // path-safe reference: "name" or "owner~name"
|
||||
Name string
|
||||
Owner string // owner's username
|
||||
Color string // hex color like "#3b82f6", "" if unset
|
||||
Shared bool // true if owned by someone other than the viewer
|
||||
Writable bool
|
||||
}
|
||||
|
||||
// EventSummary is a single event shown inside a month-view day cell.
|
||||
type EventSummary struct {
|
||||
ID string
|
||||
CalRef string
|
||||
Color string
|
||||
Summary string
|
||||
TimeText string // e.g. "14:00" or "" for all-day events
|
||||
AllDay bool
|
||||
@@ -19,18 +25,19 @@ type EventSummary struct {
|
||||
|
||||
// MonthDay is one day cell in the month grid.
|
||||
type MonthDay struct {
|
||||
Date string // "YYYY-MM-DD", used for links and the "new event" date
|
||||
Day int // day-of-month number shown in the cell
|
||||
InMonth bool // false for the leading/trailing days of neighboring months
|
||||
IsToday bool
|
||||
Events []EventSummary
|
||||
Date string // "YYYY-MM-DD", used for links and the "new event" date
|
||||
Day int // day-of-month number shown in the cell
|
||||
InMonth bool // false for the leading/trailing days of neighboring months
|
||||
IsToday bool
|
||||
Events []EventSummary
|
||||
}
|
||||
|
||||
// MonthViewData is everything the month grid template needs to render one
|
||||
// month of a single calendar.
|
||||
// MonthViewData is everything the combined month grid template needs to
|
||||
// render one month across every calendar (own + shared) visible to the
|
||||
// viewer.
|
||||
type MonthViewData struct {
|
||||
Cal string
|
||||
Color string
|
||||
Calendars []CalendarSummary
|
||||
HasWritable bool // true if the viewer can create events in at least one calendar
|
||||
MonthLabel string // e.g. "August 2026"
|
||||
Weeks [][]MonthDay
|
||||
PrevMonthURL string
|
||||
@@ -38,81 +45,99 @@ type MonthViewData struct {
|
||||
TodayURL string
|
||||
}
|
||||
|
||||
// EventFormData pre-fills the create/edit event form.
|
||||
type EventFormData struct {
|
||||
Cal string
|
||||
ID string // empty when creating a new event
|
||||
Summary string
|
||||
Description string
|
||||
Location string
|
||||
AllDay bool
|
||||
StartDate string // "YYYY-MM-DD"
|
||||
StartTime string // "HH:MM", empty when AllDay
|
||||
EndDate string // "YYYY-MM-DD"
|
||||
EndTime string // "HH:MM", empty when AllDay
|
||||
// CalendarOption is one entry in the "new event" calendar <select>.
|
||||
type CalendarOption struct {
|
||||
Ref string
|
||||
Label string
|
||||
}
|
||||
|
||||
templ CalendarsHome(username string, calendars []CalendarSummary) {
|
||||
@Layout("Calendar", username) {
|
||||
<h1 class="text-2xl font-semibold mb-6">Calendar</h1>
|
||||
if len(calendars) == 0 {
|
||||
<p class="text-sm text-gray-500">
|
||||
You don't have any calendars yet — create one from the
|
||||
<a href="/web/" class="text-indigo-600 hover:underline">dashboard</a> first.
|
||||
</p>
|
||||
} else {
|
||||
<ul class="divide-y divide-gray-200 bg-white rounded-lg border border-gray-200">
|
||||
for _, c := range calendars {
|
||||
<li class="px-4 py-3 flex items-center gap-3 text-sm">
|
||||
<span class="w-3 h-3 rounded-full shrink-0" style={ "background-color: " + colorOrDefault(c.Color) }></span>
|
||||
<a href={ templ.URL("/web/calendar/" + c.Name) } class="font-medium text-indigo-600 hover:underline">{ c.Name }</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
// EventFormData pre-fills the create/edit event form.
|
||||
type EventFormData struct {
|
||||
CalRef string // resolved/fixed calendar reference (edit), or the selected one (new)
|
||||
CalendarLabel string // fixed, read-only display label used when editing
|
||||
Calendars []CalendarOption // populated only for new-event forms
|
||||
Writable bool // false when editing an event in a read-only shared calendar
|
||||
ID string // empty when creating a new event
|
||||
Summary string
|
||||
Description string
|
||||
Location string
|
||||
AllDay bool
|
||||
StartDate string // "YYYY-MM-DD"
|
||||
StartTime string // "HH:MM", empty when AllDay
|
||||
EndDate string // "YYYY-MM-DD"
|
||||
EndTime string // "HH:MM", empty when AllDay
|
||||
}
|
||||
|
||||
templ MonthView(username string, data MonthViewData) {
|
||||
@Layout("Calendar", username) {
|
||||
<div class="flex items-center justify-between mb-6 gap-4 flex-wrap">
|
||||
<div>
|
||||
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">← Calendars</a>
|
||||
<h1 class="text-2xl font-semibold flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full shrink-0" style={ "background-color: " + colorOrDefault(data.Color) }></span>
|
||||
{ data.Cal }
|
||||
</h1>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold">Calendar</h1>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<a href={ templ.URL(data.PrevMonthURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">←</a>
|
||||
<a href={ templ.URL(data.TodayURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">Today</a>
|
||||
<a href={ templ.URL(data.NextMonthURL) } class="bg-white border border-gray-300 rounded-md px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50">→</a>
|
||||
<span class="text-lg font-medium ml-2">{ data.MonthLabel }</span>
|
||||
<a href={ templ.URL("/web/calendar/" + data.Cal + "/new") }
|
||||
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700 ml-2">
|
||||
New event
|
||||
</a>
|
||||
if data.HasWritable {
|
||||
<a href="/web/calendar/new"
|
||||
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700 ml-2">
|
||||
New event
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden text-sm">
|
||||
for _, wd := range weekdayLabels() {
|
||||
<div class="bg-gray-50 px-2 py-1.5 font-medium text-gray-500 text-xs text-center">{ wd }</div>
|
||||
}
|
||||
for _, week := range data.Weeks {
|
||||
for _, day := range week {
|
||||
@monthDayCell(data.Cal, day)
|
||||
if len(data.Calendars) == 0 {
|
||||
<p class="text-sm text-gray-500 mb-6">
|
||||
You don't have any calendars yet — create one from the
|
||||
<a href="/web/" class="text-indigo-600 hover:underline">dashboard</a> first.
|
||||
</p>
|
||||
} else {
|
||||
<div class="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden text-sm mb-6">
|
||||
for _, wd := range weekdayLabels() {
|
||||
<div class="bg-gray-50 px-2 py-1.5 font-medium text-gray-500 text-xs text-center">{ wd }</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
for _, week := range data.Weeks {
|
||||
for _, day := range week {
|
||||
@monthDayCell(day)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg border border-gray-200 divide-y divide-gray-200">
|
||||
for _, c := range data.Calendars {
|
||||
@calendarLegendRow(c)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ monthDayCell(cal string, day MonthDay) {
|
||||
templ calendarLegendRow(c CalendarSummary) {
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm flex-wrap">
|
||||
<span class="w-3 h-3 rounded-full shrink-0" style={ "background-color: " + colorOrDefault(c.Color) }></span>
|
||||
<span class="font-medium">{ c.Name }</span>
|
||||
if c.Shared {
|
||||
<span class="text-xs text-gray-500">shared by { c.Owner }</span>
|
||||
}
|
||||
if !c.Writable {
|
||||
<span class="text-xs text-gray-400">(read-only)</span>
|
||||
}
|
||||
<span class="flex-1"></span>
|
||||
<a href={ templ.URL("/web/calendar/" + c.Ref + "/export") } class="text-indigo-600 hover:underline text-xs">Export .ics</a>
|
||||
if c.Writable {
|
||||
<form method="POST" action={ templ.URL("/web/calendar/" + c.Ref + "/import") } enctype="multipart/form-data" class="flex items-center gap-1">
|
||||
<input type="file" name="file" accept=".ics,text/calendar" required class="text-xs"/>
|
||||
<button type="submit" class="text-indigo-600 hover:underline text-xs">Import</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ monthDayCell(day MonthDay) {
|
||||
<div class={ "bg-white min-h-[6rem] p-1.5 flex flex-col gap-1", templ.KV("bg-gray-50 text-gray-400", !day.InMonth) }>
|
||||
<div class="flex items-center justify-between">
|
||||
<a
|
||||
href={ templ.URL("/web/calendar/" + cal + "/new?date=" + day.Date) }
|
||||
href={ templ.URL("/web/calendar/new?date=" + day.Date) }
|
||||
class={ "text-xs font-medium rounded-full w-5 h-5 flex items-center justify-center", templ.KV("bg-indigo-600 text-white", day.IsToday), templ.KV("hover:bg-gray-100", !day.IsToday) }
|
||||
title="New event"
|
||||
>
|
||||
@@ -121,10 +146,12 @@ templ monthDayCell(cal string, day MonthDay) {
|
||||
</div>
|
||||
for _, ev := range day.Events {
|
||||
<a
|
||||
href={ templ.URL("/web/calendar/" + cal + "/" + ev.ID + "/edit") }
|
||||
class="block truncate rounded bg-indigo-50 text-indigo-700 px-1.5 py-0.5 text-xs hover:bg-indigo-100"
|
||||
href={ templ.URL("/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit") }
|
||||
class="block truncate rounded px-1.5 py-0.5 text-xs hover:opacity-80"
|
||||
style={ "background-color: " + colorOrDefault(ev.Color) + "22; color: " + colorOrDefault(ev.Color) }
|
||||
title={ ev.Summary }
|
||||
>
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full mr-1" style={ "background-color: " + colorOrDefault(ev.Color) }></span>
|
||||
if !ev.AllDay && ev.TimeText != "" {
|
||||
<span class="font-medium">{ ev.TimeText }</span>
|
||||
}
|
||||
@@ -136,7 +163,7 @@ templ monthDayCell(cal string, day MonthDay) {
|
||||
|
||||
templ EventForm(username string, data EventFormData, errMsg string) {
|
||||
@Layout("Calendar", username) {
|
||||
<a href={ templ.URL("/web/calendar/" + data.Cal) } class="text-sm text-indigo-600 hover:underline">← { data.Cal }</a>
|
||||
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">← Calendar</a>
|
||||
<h1 class="text-2xl font-semibold mt-2 mb-6">
|
||||
if data.ID == "" {
|
||||
New event
|
||||
@@ -147,11 +174,30 @@ templ EventForm(username string, data EventFormData, errMsg string) {
|
||||
if errMsg != "" {
|
||||
<p class="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{ errMsg }</p>
|
||||
}
|
||||
if data.ID != "" && !data.Writable {
|
||||
<p class="mb-4 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
||||
This calendar was shared with you as read-only — you can view this event but not change it.
|
||||
</p>
|
||||
}
|
||||
<form
|
||||
method="POST"
|
||||
action={ eventFormAction(data) }
|
||||
class="bg-white rounded-lg border border-gray-200 p-6 space-y-6 max-w-xl"
|
||||
>
|
||||
<fieldset disabled?={ data.ID != "" && !data.Writable } class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Calendar</label>
|
||||
if data.ID == "" {
|
||||
<select name="calendar" class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm">
|
||||
for _, c := range data.Calendars {
|
||||
<option value={ c.Ref } selected?={ c.Ref == data.CalRef }>{ c.Label }</option>
|
||||
}
|
||||
</select>
|
||||
} else {
|
||||
<p class="mt-1 text-sm text-gray-600">{ data.CalendarLabel }</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Title</label>
|
||||
<input name="summary" type="text" required value={ data.Summary }
|
||||
@@ -196,16 +242,22 @@ templ EventForm(username string, data EventFormData, errMsg string) {
|
||||
<textarea name="description" rows="4"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm">{ data.Description }</textarea>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<button type="submit" class="bg-indigo-600 text-white rounded-md px-4 py-2 text-sm font-medium hover:bg-indigo-700">
|
||||
Save
|
||||
</button>
|
||||
if data.ID != "" {
|
||||
<form method="POST" action={ templ.URL("/web/calendar/" + data.Cal + "/" + data.ID + "/delete") } onsubmit="return confirm('Delete this event?')">
|
||||
if data.ID == "" || data.Writable {
|
||||
<button type="submit" class="bg-indigo-600 text-white rounded-md px-4 py-2 text-sm font-medium hover:bg-indigo-700">
|
||||
Save
|
||||
</button>
|
||||
}
|
||||
if data.ID != "" && data.Writable {
|
||||
<form method="POST" action={ templ.URL("/web/calendar/" + data.CalRef + "/" + data.ID + "/delete") } onsubmit="return confirm('Delete this event?')">
|
||||
<button type="submit" class="text-red-600 hover:underline text-sm">Delete event</button>
|
||||
</form>
|
||||
}
|
||||
if data.ID != "" {
|
||||
<a href={ templ.URL("/web/calendar/" + data.CalRef + "/" + data.ID + "/export") } class="text-indigo-600 hover:underline text-sm">Export .ics</a>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -215,9 +267,9 @@ templ EventForm(username string, data EventFormData, errMsg string) {
|
||||
|
||||
func eventFormAction(data EventFormData) templ.SafeURL {
|
||||
if data.ID == "" {
|
||||
return templ.URL("/web/calendar/" + data.Cal + "/new")
|
||||
return templ.URL("/web/calendar/new")
|
||||
}
|
||||
return templ.URL("/web/calendar/" + data.Cal + "/" + data.ID + "/edit")
|
||||
return templ.URL("/web/calendar/" + data.CalRef + "/" + data.ID + "/edit")
|
||||
}
|
||||
|
||||
func weekdayLabels() []string {
|
||||
|
||||
Reference in New Issue
Block a user