- 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>
278 lines
11 KiB
Templ
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">←</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>
|
|
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">← 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"}
|
|
}
|