Users can now subscribe to a remote ICS/webcal feed from the dashboard (name + color), the same way they set up a real calendar or the virtual Birthdays calendar. Subscriptions are read-only, per-user, and share the "/cal/home/<name>/" namespace with real calendars and "birthdays" (name collisions are rejected in both directions). - internal/db: new ics_subscriptions table + CRUD (internal/db/ics.go); CreateCalendarWithColor checks for a colliding subscription name. - internal/icssub: shared HTTP-fetch + TTL cache (15 min) for remote ICS calendars, with webcal:// -> https:// rewriting and stale-on-error fallback, used by both the web UI and the CalDAV backend. - internal/web: dashboard "Subscribe to an ICS/webcal calendar" form, color picker, delete button (internal/web/ics.go, templates/dashboard.templ); month view renders subscription events in their chosen color, read-only (internal/web/calendar.go). - internal/caldav: subscriptions are exposed as read-only calendars (internal/caldav/ics.go) - listed in PROPFIND, events served via GET/REPORT, PUT/DELETE on individual events rejected with 403, but DELETE on the calendar itself unsubscribes; calendar-color is injected the same way as for real calendars and Birthdays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
284 lines
9.2 KiB
Templ
284 lines
9.2 KiB
Templ
package templates
|
|
|
|
// ShareRow is a single share grant shown in the UI, for either a calendar
|
|
// or an address book (Kind distinguishes them for form targets).
|
|
type ShareRow struct {
|
|
ResourceName string
|
|
SharedWith string
|
|
Permission string // "read" or "write"
|
|
}
|
|
|
|
// ResourceCard describes one of the user's own calendars/address books
|
|
// plus who it's currently shared with.
|
|
type ResourceCard struct {
|
|
Kind string // "calendar", "addressbook", or "ics" (read-only ICS subscription)
|
|
Name string
|
|
Color string // hex color like "#3b82f6"; only used for calendars/ics
|
|
URL string // remote ICS/webcal URL; only used for kind "ics"
|
|
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
|
|
// current user.
|
|
type SharedWithMeItem struct {
|
|
Kind string
|
|
Owner string
|
|
Name string
|
|
Permission string
|
|
}
|
|
|
|
templ Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedWithMeItem) {
|
|
@Layout("Dashboard", username) {
|
|
<h1 class="text-2xl font-semibold mb-6">Your calendars & address books</h1>
|
|
|
|
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
|
<form
|
|
class="flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4"
|
|
hx-post="/web/resources/calendar"
|
|
hx-target="#resources"
|
|
hx-swap="outerHTML"
|
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
|
>
|
|
<div class="flex-1">
|
|
<label class="block text-xs text-gray-500 mb-1">New calendar</label>
|
|
<input name="name" type="text" required pattern="[a-zA-Z0-9_-]{1,64}"
|
|
placeholder="e.g. work"
|
|
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">Color</label>
|
|
<input name="color" type="color" value="#3b82f6"
|
|
class="w-12 h-9 rounded-md border-gray-300 border p-0.5"/>
|
|
</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">
|
|
Add
|
|
</button>
|
|
</form>
|
|
<form
|
|
class="flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4"
|
|
hx-post="/web/resources/addressbook"
|
|
hx-target="#resources"
|
|
hx-swap="outerHTML"
|
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
|
>
|
|
<div class="flex-1">
|
|
<label class="block text-xs text-gray-500 mb-1">New address book</label>
|
|
<input name="name" type="text" required pattern="[a-zA-Z0-9_-]{1,64}"
|
|
placeholder="e.g. contacts"
|
|
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
|
</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">
|
|
Add
|
|
</button>
|
|
</form>
|
|
<form
|
|
class="flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4"
|
|
hx-post="/web/resources/ics"
|
|
hx-target="#resources"
|
|
hx-swap="outerHTML"
|
|
hx-on::after-request="if(event.detail.successful) this.reset()"
|
|
>
|
|
<div class="flex-1">
|
|
<label class="block text-xs text-gray-500 mb-1">Subscribe to an ICS/webcal calendar</label>
|
|
<input name="name" type="text" required pattern="[a-zA-Z0-9_-]{1,64}"
|
|
placeholder="e.g. holidays"
|
|
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm mb-2"/>
|
|
<input name="url" type="text" required
|
|
placeholder="https://example.com/calendar.ics or webcal://..."
|
|
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">Color</label>
|
|
<input name="color" type="color" value="#10b981"
|
|
class="w-12 h-9 rounded-md border-gray-300 border p-0.5"/>
|
|
</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">
|
|
Add
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
@ResourceList(resources)
|
|
|
|
if len(sharedWithMe) > 0 {
|
|
<h2 class="text-xl font-semibold mt-10 mb-4">Shared with you</h2>
|
|
<ul class="divide-y divide-gray-200 bg-white rounded-lg border border-gray-200">
|
|
for _, item := range sharedWithMe {
|
|
<li class="px-4 py-3 flex items-center justify-between text-sm">
|
|
<span>
|
|
<span class="font-medium">{ item.Owner }</span> / { item.Name }
|
|
<span class="text-gray-400">({ item.Kind })</span>
|
|
</span>
|
|
<span class="text-xs uppercase tracking-wide text-gray-500">{ item.Permission }</span>
|
|
</li>
|
|
}
|
|
</ul>
|
|
}
|
|
}
|
|
}
|
|
|
|
// ResourceList renders the #resources container. It's re-rendered as a
|
|
// whole after a create/delete (which changes the set of cards), whereas a
|
|
// share update only swaps a single ResourceCardView.
|
|
templ ResourceList(resources []ResourceCard) {
|
|
<div id="resources" class="space-y-6">
|
|
for _, r := range resources {
|
|
@ResourceCardView(r)
|
|
}
|
|
if len(resources) == 0 {
|
|
<p class="text-sm text-gray-400">
|
|
You don't have any calendars or address books yet — add one above.
|
|
</p>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
templ ResourceCardView(r ResourceCard) {
|
|
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h2 class="font-medium flex items-center gap-2">
|
|
if r.Kind == "calendar" || r.Kind == "ics" {
|
|
<form
|
|
hx-post={ colorEndpoint(r) }
|
|
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
|
hx-swap="outerHTML"
|
|
hx-trigger="change"
|
|
>
|
|
<input type="hidden" name="name" value={ r.Name }/>
|
|
<input
|
|
name="color"
|
|
type="color"
|
|
value={ colorOrDefault(r.Color) }
|
|
title="Calendar color"
|
|
class="w-6 h-6 rounded border border-gray-300 p-0 align-middle"
|
|
/>
|
|
</form>
|
|
}
|
|
{ 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>
|
|
}
|
|
if r.Kind == "ics" {
|
|
<span class="text-xs text-gray-400">(read-only subscription)</span>
|
|
}
|
|
</h2>
|
|
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>
|
|
|
|
if r.Kind == "ics" {
|
|
<p class="text-xs text-gray-500 mb-4 break-all">{ r.URL }</p>
|
|
}
|
|
|
|
if !r.Virtual && r.Kind != "ics" {
|
|
<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>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
func colorEndpoint(r ResourceCard) string {
|
|
if r.Virtual {
|
|
return "/web/resources/birthdays/color"
|
|
}
|
|
if r.Kind == "ics" {
|
|
return "/web/resources/ics/color"
|
|
}
|
|
return "/web/resources/calendar/color"
|
|
}
|
|
|
|
func shareEndpoint(kind string) string {
|
|
if kind == "calendar" {
|
|
return "/web/shares/calendar"
|
|
}
|
|
return "/web/shares/addressbook"
|
|
}
|
|
|
|
func shareVals(resource, sharedWith string) string {
|
|
return `{"resource": "` + resource + `", "shared_with": "` + sharedWith + `"}`
|
|
}
|
|
|
|
func resourceEndpoint(kind string) string {
|
|
switch kind {
|
|
case "calendar":
|
|
return "/web/resources/calendar"
|
|
case "ics":
|
|
return "/web/resources/ics"
|
|
default:
|
|
return "/web/resources/addressbook"
|
|
}
|
|
}
|
|
|
|
func resourceVals(name string) string {
|
|
return `{"name": "` + name + `"}`
|
|
}
|
|
|
|
func colorOrDefault(c string) string {
|
|
if c == "" {
|
|
return "#3b82f6"
|
|
}
|
|
return c
|
|
}
|
|
|