Files
nidus/internal/web/templates/calendar.templ
T
arnefandCopilot adabfd22ca 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>
2026-08-20 18:58:04 +02:00

278 lines
11 KiB
Templ

package templates
import "fmt"
// CalendarSummary is one calendar (own or shared) shown in the combined
// month view's legend.
type CalendarSummary struct {
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
}
// 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 combined month grid template needs to
// render one month across every calendar (own + shared) visible to the
// viewer.
type MonthViewData struct {
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
NextMonthURL string
TodayURL string
}
// CalendarOption is one entry in the "new event" calendar <select>.
type CalendarOption struct {
Ref string
Label string
}
// 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">
<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">&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>
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>
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>
}
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 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/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/" + 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>
}
{ " " + ev.Summary }
</a>
}
</div>
}
templ EventForm(username string, data EventFormData, errMsg string) {
@Layout("Calendar", username) {
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</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>
}
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 }
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>
</fieldset>
<div class="flex items-center justify-between pt-2">
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>
<script type="module" src="/web/static/calendar.js"></script>
}
}
func eventFormAction(data EventFormData) templ.SafeURL {
if data.ID == "" {
return templ.URL("/web/calendar/new")
}
return templ.URL("/web/calendar/" + data.CalRef + "/" + data.ID + "/edit")
}
func weekdayLabels() []string {
return []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
}