- 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>
226 lines
8.4 KiB
Templ
226 lines
8.4 KiB
Templ
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">← 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">←</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>
|
|
</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">← { 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"}
|
|
}
|