Files
nidus/internal/web/templates/calendar.templ
T
arnefandCopilot 9f8bfba420 Improve mobile layout of the dashboard, contacts, and calendar pages
- Layout nav wraps gracefully on narrow screens instead of overflowing
  (username truncates, links/logout reflow).
- Dashboard resource cards and share rows wrap instead of clipping
  when names/usernames are long.
- Contacts list hides secondary columns (organization/email below
  md, phone below sm) so the name and actions stay usable on phones.
- Calendar month/week grids get a horizontally scrollable wrapper
  with a sane minimum width and smaller cell padding on mobile so the
  7-day grid stays legible instead of being squeezed unreadably thin.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 09:16:01 +02:00

472 lines
18 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package templates
import "fmt"
import "strconv"
import "strings"
// 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
Virtual bool // true for computed calendars (e.g. birthdays) with no import/export
}
// 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
// LinkURL overrides the default "/web/calendar/{CalRef}/{ID}/edit"
// link target, used by virtual/read-only calendars (e.g. birthdays)
// that don't have an editable event object of their own.
LinkURL string
}
// 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
WeekURL string // link to switch to the week view
}
// WeekDay is one day column in the week grid.
type WeekDay struct {
Date string // "YYYY-MM-DD", used for links and the "new event" date
Weekday string // full weekday name, e.g. "Monday"
Day int // day-of-month number shown in the column header
Month string // short month abbreviation, e.g. "Aug" (for cross-month weeks)
IsToday bool
Events []EventSummary
}
// WeekViewData is everything the week grid template needs to render one
// 7-day week across every calendar (own + shared) visible to the viewer.
type WeekViewData struct {
Calendars []CalendarSummary
HasWritable bool
RangeLabel string // e.g. "Aug 18 24, 2026"
Days []WeekDay
PrevWeekURL string
NextWeekURL string
TodayURL string
MonthURL string // link to switch to the month view
}
// 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>
@viewSwitcher("month", "/web/calendar", data.WeekURL)
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="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0 mb-6">
<div class="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden text-sm min-w-[640px] sm:min-w-0">
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>
<div class="bg-white rounded-lg border border-gray-200 divide-y divide-gray-200">
for _, c := range data.Calendars {
@calendarLegendRow(c)
}
</div>
}
}
}
// viewSwitcher renders the Month/Week toggle shown in both views'
// headers. active is "month" or "week"; monthURL/weekURL are the
// destinations for switching to each view.
templ viewSwitcher(active, monthURL, weekURL string) {
<div class="inline-flex rounded-md border border-gray-300 overflow-hidden ml-2">
<a
href={ templ.URL(monthURL) }
class={ "px-3 py-1.5 text-sm font-medium", templ.KV("bg-indigo-600 text-white", active == "month"), templ.KV("bg-white text-gray-700 hover:bg-gray-50", active != "month") }
>
Month
</a>
<a
href={ templ.URL(weekURL) }
class={ "px-3 py-1.5 text-sm font-medium border-l border-gray-300", templ.KV("bg-indigo-600 text-white", active == "week"), templ.KV("bg-white text-gray-700 hover:bg-gray-50", active != "week") }
>
Week
</a>
</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 ring-1 ring-inset ring-black/10" 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>
if !c.Virtual {
<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-[4.5rem] sm:min-h-[6rem] p-1 sm: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(eventLinkURL(ev)) }
class="block truncate rounded px-1.5 py-0.5 text-xs hover:opacity-80"
style={ "background-color: " + colorOrDefault(ev.Color) + "22; color: " + eventTextColor(ev.Color) }
title={ ev.Summary }
>
<span class="inline-block w-1.5 h-1.5 rounded-full mr-1 ring-1 ring-inset ring-black/10" style={ "background-color: " + eventTextColor(ev.Color) }></span>
if !ev.AllDay && ev.TimeText != "" {
<span class="font-medium">{ ev.TimeText }</span>
}
{ " " + ev.Summary }
</a>
}
</div>
}
// WeekView renders a single 7-day week across every calendar (own +
// shared) visible to the viewer, one column per day.
templ WeekView(username string, data WeekViewData) {
@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.PrevWeekURL) } 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.NextWeekURL) } 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.RangeLabel }</span>
@viewSwitcher("week", data.MonthURL, "/web/calendar/week")
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="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0 mb-6">
<div class="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden text-sm min-w-[640px] sm:min-w-0">
for _, d := range data.Days {
@weekDayHeaderCell(d)
}
for _, d := range data.Days {
@weekDayCell(d)
}
</div>
</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 weekDayHeaderCell(day WeekDay) {
<div class={ "bg-gray-50 px-2 py-1.5 text-xs text-center", templ.KV("text-indigo-600 font-semibold", day.IsToday), templ.KV("text-gray-500 font-medium", !day.IsToday) }>
{ day.Weekday } <span class="text-gray-400">{ day.Month }</span> { fmt.Sprint(day.Day) }
</div>
}
templ weekDayCell(day WeekDay) {
<div class="bg-white min-h-[24rem] p-1.5 flex flex-col gap-1 align-top">
<a
href={ templ.URL("/web/calendar/new?date=" + day.Date) }
class="self-start text-xs text-gray-400 hover:text-indigo-600 mb-1"
title="New event"
>
+ new
</a>
for _, ev := range day.Events {
<a
href={ templ.URL(eventLinkURL(ev)) }
class="block truncate rounded px-1.5 py-0.5 text-xs hover:opacity-80"
style={ "background-color: " + colorOrDefault(ev.Color) + "22; color: " + eventTextColor(ev.Color) }
title={ ev.Summary }
>
<span class="inline-block w-1.5 h-1.5 rounded-full mr-1 ring-1 ring-inset ring-black/10" style={ "background-color: " + eventTextColor(ev.Color) }></span>
if !ev.AllDay && ev.TimeText != "" {
<span class="font-medium">{ ev.TimeText }</span>
}
{ " " + ev.Summary }
</a>
}
</div>
}
func eventLinkURL(ev EventSummary) string {
if ev.LinkURL != "" {
return ev.LinkURL
}
return "/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit"
}
// eventTextColor returns a color derived from hex, darkened if needed so
// it stays legible as text on monthDayCell's very light (~13% opacity)
// tinted event background. Without this, a light calendar color like
// white or pale yellow renders its own event text as invisible or
// near-invisible, since the same color is used for both the tint and the
// text. Colors that are already dark enough are returned unchanged.
func eventTextColor(hex string) string {
c := colorOrDefault(hex)
r, g, b, ok := parseHexColor(c)
if !ok {
return c
}
// Perceived luminance (ITU-R BT.601).
luminance := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
const maxReadableLuminance = 170
if luminance <= maxReadableLuminance {
return c
}
// Scale each channel down so the color darkens towards black while
// keeping its hue, ending up comfortably below the threshold.
scale := maxReadableLuminance / luminance * 0.75
return fmt.Sprintf("#%02x%02x%02x", clampByte(float64(r)*scale), clampByte(float64(g)*scale), clampByte(float64(b)*scale))
}
// parseHexColor parses a "#rrggbb" string into its red/green/blue
// components. ok is false for anything else (including 3-digit shorthand
// hex, which colorOrDefault/hexColorRe never produce).
func parseHexColor(s string) (r, g, b int, ok bool) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseInt(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v >> 16 & 0xff), int(v >> 8 & 0xff), int(v & 0xff), true
}
func clampByte(v float64) int {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return int(v)
}
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"}
}