Make birthdays calendar color configurable and expose it via CalDAV
- Add a per-user birthday_calendars table (color, cascade-deletes with
the user) and GetBirthdayCalendarColor/SetBirthdayCalendarColor in
internal/db, plus a reservedCalendarNames guard ("birthdays") in
CreateCalendarWithColor so no real calendar can collide with the
synthetic one, whether created via the web UI, nidusctl, or CalDAV
MKCALENDAR.
- Add a "Birthdays" virtual resource card to the dashboard (color
picker only, no delete/share controls) backed by a new
ResourceCard.Virtual flag and POST /web/resources/birthdays/color
handler.
- Extract the birthday-parsing/generation logic shared by the web
calendar view and CalDAV into internal/birthdays (ParseBirthday,
Collect, OccurrenceDate, Summary) instead of duplicating it.
- Expose the Birthdays calendar over real CalDAV in
internal/caldav/backend.go + birthdays.go: it's always listed for
every user, generates one VEVENT per (contact, year) for a rolling
window (current year -2..+8) with "🎂 Name (Age)" titles, is
read-only (Put/Delete/DeleteCalendar all return 403), and its
Apple/DAVx5 calendar-color is injected from the same per-user
setting used by the dashboard/web view.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -11,10 +11,11 @@ type ShareRow struct {
|
||||
// ResourceCard describes one of the user's own calendars/address books
|
||||
// plus who it's currently shared with.
|
||||
type ResourceCard struct {
|
||||
Kind string // "calendar" or "addressbook"
|
||||
Name string
|
||||
Color string // hex color like "#3b82f6"; only used for calendars
|
||||
Shares []ShareRow
|
||||
Kind string // "calendar" or "addressbook"
|
||||
Name string
|
||||
Color string // hex color like "#3b82f6"; only used for calendars
|
||||
Shares []ShareRow
|
||||
Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker
|
||||
}
|
||||
|
||||
// SharedWithMeItem describes a resource another user has shared with the
|
||||
@@ -115,7 +116,7 @@ templ ResourceCardView(r ResourceCard) {
|
||||
<h2 class="font-medium flex items-center gap-2">
|
||||
if r.Kind == "calendar" {
|
||||
<form
|
||||
hx-post="/web/resources/calendar/color"
|
||||
hx-post={ colorEndpoint(r) }
|
||||
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change"
|
||||
@@ -132,70 +133,84 @@ templ ResourceCardView(r ResourceCard) {
|
||||
}
|
||||
{ r.Name }
|
||||
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span>
|
||||
if r.Virtual {
|
||||
<span class="text-xs text-gray-400">(computed from contacts)</span>
|
||||
}
|
||||
</h2>
|
||||
<button
|
||||
class="text-red-600 hover:underline text-xs"
|
||||
hx-delete={ resourceEndpoint(r.Kind) }
|
||||
hx-vals={ resourceVals(r.Name) }
|
||||
hx-target="#resources"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm={ "Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone." }
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
if !r.Virtual {
|
||||
<button
|
||||
class="text-red-600 hover:underline text-xs"
|
||||
hx-delete={ resourceEndpoint(r.Kind) }
|
||||
hx-vals={ resourceVals(r.Name) }
|
||||
hx-target="#resources"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm={ "Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone." }
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-100 mb-4">
|
||||
for _, sh := range r.Shares {
|
||||
<li class="py-2 flex items-center justify-between text-sm">
|
||||
<span>{ sh.SharedWith }</span>
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500">{ sh.Permission }</span>
|
||||
<button
|
||||
class="text-red-600 hover:underline text-xs"
|
||||
hx-delete={ shareEndpoint(r.Kind) }
|
||||
hx-vals={ shareVals(r.Name, sh.SharedWith) }
|
||||
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm={ "Remove access for " + sh.SharedWith + "?" }
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
if len(r.Shares) == 0 {
|
||||
<li class="py-2 text-sm text-gray-400">Not shared with anyone yet.</li>
|
||||
}
|
||||
</ul>
|
||||
if !r.Virtual {
|
||||
<ul class="divide-y divide-gray-100 mb-4">
|
||||
for _, sh := range r.Shares {
|
||||
<li class="py-2 flex items-center justify-between text-sm">
|
||||
<span>{ sh.SharedWith }</span>
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="text-xs uppercase tracking-wide text-gray-500">{ sh.Permission }</span>
|
||||
<button
|
||||
class="text-red-600 hover:underline text-xs"
|
||||
hx-delete={ shareEndpoint(r.Kind) }
|
||||
hx-vals={ shareVals(r.Name, sh.SharedWith) }
|
||||
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm={ "Remove access for " + sh.SharedWith + "?" }
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
if len(r.Shares) == 0 {
|
||||
<li class="py-2 text-sm text-gray-400">Not shared with anyone yet.</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<form
|
||||
class="flex flex-col sm:flex-row sm:items-end gap-2"
|
||||
hx-post={ shareEndpoint(r.Kind) }
|
||||
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="resource" value={ r.Name }/>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">Username</label>
|
||||
<input name="shared_with" type="text" required
|
||||
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Permission</label>
|
||||
<select name="permission" class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm">
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
||||
Share
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
class="flex flex-col sm:flex-row sm:items-end gap-2"
|
||||
hx-post={ shareEndpoint(r.Kind) }
|
||||
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<input type="hidden" name="resource" value={ r.Name }/>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">Username</label>
|
||||
<input name="shared_with" type="text" required
|
||||
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Permission</label>
|
||||
<select name="permission" class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm">
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
||||
Share
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
func colorEndpoint(r ResourceCard) string {
|
||||
if r.Virtual {
|
||||
return "/web/resources/birthdays/color"
|
||||
}
|
||||
return "/web/resources/calendar/color"
|
||||
}
|
||||
|
||||
func shareEndpoint(kind string) string {
|
||||
if kind == "calendar" {
|
||||
return "/web/shares/calendar"
|
||||
|
||||
Reference in New Issue
Block a user