Add web UI for CalDAV calendars (web/calendar)

- List of the user's own calendars (web/calendar)
- Month view per calendar with a Monday-first 6-week grid, showing
  all-day and timed events, colored dot matching the calendar's color
- Navigation between months (prev/next/today) via query params
- Create/edit/delete events: title, description, location, all-day
  toggle, start/end date+time
- Clicking a day cell prefills a new all-day event on that date; the
  "New event" button defaults to a one-hour slot starting next full hour
- Deleting an event or invalid time ranges (end before/equal start) are
  validated with inline error messages
- Verified round-trip with the real CalDAV protocol handler (events
  created via the web UI are correctly visible to a REPORT query)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 16:58:59 +02:00
co-authored by Copilot
parent 3fa0297715
commit cd2cfa6c06
9 changed files with 1691 additions and 3 deletions
+225
View File
@@ -0,0 +1,225 @@
package templates
import "fmt"
// CalendarSummary is one of the user's own calendars, listed on the
// calendar home page.
type CalendarSummary struct {
Name string
Color string // hex color like "#3b82f6", "" if unset
}
// EventSummary is a single event shown inside a month-view day cell.
type EventSummary struct {
ID string
Summary string
TimeText string // e.g. "14:00" or "" for all-day events
AllDay bool
}
// 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
}
// MonthViewData is everything the month grid template needs to render one
// month of a single calendar.
type MonthViewData struct {
Cal string
Color string
MonthLabel string // e.g. "August 2026"
Weeks [][]MonthDay
PrevMonthURL string
NextMonthURL string
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
}
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>
}
}
}
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">&larr; 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>
<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">&larr;</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">&rarr;</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>
</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)
}
}
</div>
}
}
templ monthDayCell(cal string, 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) }
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"
>
{ fmt.Sprint(day.Day) }
</a>
</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"
title={ ev.Summary }
>
if !ev.AllDay && ev.TimeText != "" {
<span class="font-medium">{ ev.TimeText }</span>
}
{ " " + ev.Summary }
</a>
}
</div>
}
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">&larr; { data.Cal }</a>
<h1 class="text-2xl font-semibold mt-2 mb-6">
if data.ID == "" {
New event
} else {
Edit event
}
</h1>
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>
}
<form
method="POST"
action={ eventFormAction(data) }
class="bg-white rounded-lg border border-gray-200 p-6 space-y-6 max-w-xl"
>
<div>
<label class="block text-sm font-medium text-gray-700">Title</label>
<input name="summary" type="text" required value={ data.Summary }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="all-day" name="all_day" type="checkbox" value="1" checked?={ data.AllDay }/>
All-day event
</label>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Start date</label>
<input name="start_date" type="date" required value={ data.StartDate }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
<div id="start-time-field" class={ templ.KV("hidden", data.AllDay) }>
<label class="block text-sm font-medium text-gray-700">Start time</label>
<input name="start_time" type="time" value={ data.StartTime }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">End date</label>
<input name="end_date" type="date" required value={ data.EndDate }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
<div id="end-time-field" class={ templ.KV("hidden", data.AllDay) }>
<label class="block text-sm font-medium text-gray-700">End time</label>
<input name="end_time" type="time" value={ data.EndTime }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Location</label>
<input name="location" type="text" value={ data.Location }
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm"/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<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>
<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?')">
<button type="submit" class="text-red-600 hover:underline text-sm">Delete event</button>
</form>
}
</div>
</form>
<script type="module" src="/web/static/calendar.js"></script>
}
}
func eventFormAction(data EventFormData) templ.SafeURL {
if data.ID == "" {
return templ.URL("/web/calendar/" + data.Cal + "/new")
}
return templ.URL("/web/calendar/" + data.Cal + "/" + data.ID + "/edit")
}
func weekdayLabels() []string {
return []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
}