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:
2026-08-20 20:09:59 +02:00
co-authored by Copilot
parent cd96b365d0
commit b451f1a76e
12 changed files with 753 additions and 353 deletions
+126
View File
@@ -0,0 +1,126 @@
// Package birthdays computes virtual "birthday events" from a user's own
// contacts, shared by the web calendar UI (internal/web) and the CalDAV
// backend (internal/caldav) so both present identical titles/dates for the
// synthetic Birthdays calendar without duplicating the parsing logic.
package birthdays
import (
"bytes"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/store"
)
// Contact holds one contact's parsed birthday.
type Contact struct {
Name string
Book string
ID string
Month time.Month
Day int
Year int // 0 if the vCard's BDAY has no year (e.g. "--08-20")
}
// fullRe matches a BDAY value that includes a year, in either
// "YYYY-MM-DD" or the older vCard 3.0 "YYYYMMDD" form.
var fullRe = regexp.MustCompile(`^(\d{4})-?(\d{2})-?(\d{2})`)
// noYearRe matches a year-less BDAY value per RFC 6350 §4.3.1,
// "--MM-DD" or "--MMDD".
var noYearRe = regexp.MustCompile(`^--(\d{2})-?(\d{2})`)
// ParseBirthday extracts month/day (and year, if present) from a vCard
// BDAY field value. Returns ok=false if v isn't a recognized date format
// or names an impossible month/day.
func ParseBirthday(v string) (month time.Month, day int, year int, ok bool) {
v = strings.TrimSpace(v)
if m := noYearRe.FindStringSubmatch(v); m != nil {
mo, _ := strconv.Atoi(m[1])
d, _ := strconv.Atoi(m[2])
if mo < 1 || mo > 12 || d < 1 || d > 31 {
return 0, 0, 0, false
}
return time.Month(mo), d, 0, true
}
if m := fullRe.FindStringSubmatch(v); m != nil {
y, _ := strconv.Atoi(m[1])
mo, _ := strconv.Atoi(m[2])
d, _ := strconv.Atoi(m[3])
if mo < 1 || mo > 12 || d < 1 || d > 31 {
return 0, 0, 0, false
}
return time.Month(mo), d, y, true
}
return 0, 0, 0, false
}
// Collect scans every one of username's own address books for contacts
// with a parseable BDAY field.
func Collect(st *store.Store, dbase *db.DB, username string) ([]Contact, error) {
books, err := dbase.ListAddressBooks(username)
if err != nil {
return nil, err
}
var contacts []Contact
for _, book := range books {
ids, err := st.ListObjects(username, "card-"+book)
if err != nil {
continue
}
for _, id := range ids {
data, err := st.GetObject(username, "card-"+book, id)
if err != nil {
continue
}
card, err := vcard.NewDecoder(bytes.NewReader(data)).Decode()
if err != nil {
continue
}
bday := card.PreferredValue(vcard.FieldBirthday)
if bday == "" {
continue
}
month, day, year, ok := ParseBirthday(bday)
if !ok {
continue
}
name := card.PreferredValue(vcard.FieldFormattedName)
if name == "" {
name = id
}
contacts = append(contacts, Contact{
Name: name, Book: book, ID: id, Month: month, Day: day, Year: year,
})
}
}
return contacts, nil
}
// OccurrenceDate returns the date c's birthday falls on in year, and false
// if that combination doesn't exist (e.g. Feb 29 in a non-leap year,
// where Go's time.Date would otherwise silently normalize to March 1/2).
func OccurrenceDate(c Contact, year int, loc *time.Location) (time.Time, bool) {
occ := time.Date(year, c.Month, c.Day, 0, 0, 0, 0, loc)
if occ.Month() != c.Month || occ.Day() != c.Day {
return time.Time{}, false
}
return occ, true
}
// Summary formats c's virtual event title for the given year:
// "🎂 Name", or "🎂 Name (Age)" if c's birth year is known.
func Summary(c Contact, year int) string {
s := "🎂 " + c.Name
if c.Year > 0 {
s += fmt.Sprintf(" (%d)", year-c.Year)
}
return s
}
+48 -10
View File
@@ -84,12 +84,14 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated")) return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
} }
// The synthetic Birthdays calendar is always present, computed from
// the requester's own contacts.
names, err := b.dbase.ListCalendars(p.Username) names, err := b.dbase.ListCalendars(p.Username)
if err != nil { if err != nil {
return nil, fmt.Errorf("listing calendars: %w", err) return nil, fmt.Errorf("listing calendars: %w", err)
} }
var cals []caldav.Calendar cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)}
for _, cal := range names { for _, cal := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil { if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err) b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
@@ -134,6 +136,10 @@ func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Cale
if err != nil { if err != nil {
return nil, err return nil, err
} }
if localName == birthdaysCalendarName {
cal := b.birthdaysCalendarMeta(requester)
return &cal, nil
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false) owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -150,6 +156,9 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca
if err != nil { if err != nil {
return nil, err return nil, err
} }
if localName == birthdaysCalendarName {
return b.birthdayCalendarObject(requester, objPath, objID)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false) owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -168,6 +177,9 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
if err != nil { if err != nil {
return nil, err return nil, err
} }
if localName == birthdaysCalendarName {
return b.listBirthdayCalendarObjects(requester)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false) owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -213,8 +225,13 @@ func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar)
// existing calendar is done via ShareCalendar, not by creating one // existing calendar is done via ShareCalendar, not by creating one
// directly in someone else's name. // directly in someone else's name.
name := path.Base(strings.TrimSuffix(calendar.Path, "/")) name := path.Base(strings.TrimSuffix(calendar.Path, "/"))
if err := b.dbase.CreateCalendar(p.Username, name); err != nil && err != db.ErrResourceExists { if err := b.dbase.CreateCalendar(p.Username, name); err != nil {
return fmt.Errorf("registering calendar: %w", err) if err == db.ErrReservedName {
return webdav.NewHTTPError(http.StatusForbidden, err)
}
if err != db.ErrResourceExists {
return fmt.Errorf("registering calendar: %w", err)
}
} }
return b.store.EnsureCollection(p.Username, "cal-"+name) return b.store.EnsureCollection(p.Username, "cal-"+name)
} }
@@ -224,6 +241,9 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
if err != nil { if err != nil {
return err return err
} }
if localName == birthdaysCalendarName {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true) owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil { if err != nil {
return err return err
@@ -239,6 +259,9 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda
if err != nil { if err != nil {
return nil, err return nil, err
} }
if localName == birthdaysCalendarName {
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true) owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -263,6 +286,9 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
if err != nil { if err != nil {
return err return err
} }
if localName == birthdaysCalendarName {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true) owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil { if err != nil {
return err return err
@@ -495,13 +521,25 @@ func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body [
return block return block
} }
localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/") localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/")
owner, realName, _, err := h.backend.resolveCalendar(p.Username, localName, false) var color string
if err != nil { var err error
return block if localName == birthdaysCalendarName {
} color, err = h.backend.dbase.GetBirthdayCalendarColor(p.Username)
color, err := h.backend.dbase.GetCalendarColor(owner, realName) if err != nil {
if err != nil || color == "" { color = ""
return block }
if color == "" {
color = defaultBirthdayColor
}
} else {
owner, realName, _, rerr := h.backend.resolveCalendar(p.Username, localName, false)
if rerr != nil {
return block
}
color, err = h.backend.dbase.GetCalendarColor(owner, realName)
if err != nil || color == "" {
return block
}
} }
// Drop any propstat block that only complains calendar-color is // Drop any propstat block that only complains calendar-color is
+8 -2
View File
@@ -209,8 +209,14 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
// Find personal's response block boundaries loosely by looking for the // Find personal's response block boundaries loosely by looking for the
// nearest calendar-color occurrence and ensuring it isn't right next to // nearest calendar-color occurrence and ensuring it isn't right next to
// the personal href (colors are per-block, checked via count instead). // the personal href (colors are per-block, checked via count instead).
if strings.Count(body, "<calendar-color ") != 1 { // Two calendar-color elements are expected: one for "work" (explicit
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body) // color) and one for the always-present synthetic "birthdays" calendar
// (default color, since it wasn't explicitly set here).
if strings.Count(body, "<calendar-color ") != 2 {
t.Fatalf("expected exactly two calendar-color elements (work + birthdays default), got: %s", body)
}
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">`+defaultBirthdayColor+`FF</calendar-color>`) {
t.Fatalf("expected default birthdays calendar-color, got: %s", body)
} }
} }
+156
View File
@@ -0,0 +1,156 @@
package caldav
import (
"fmt"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/yourusername/caldav-server/internal/birthdays"
)
// birthdaysCalendarName is the fixed, reserved local calendar name the
// synthetic Birthdays calendar is exposed under (see
// db.reservedCalendarNames, which prevents a real calendar from ever
// colliding with it). It has no owner/shared-name segment: it's always
// computed from the requester's own contacts.
const birthdaysCalendarName = "birthdays"
// defaultBirthdayColor is the display color for the virtual birthdays
// calendar when the user hasn't chosen one, matching internal/web's.
const defaultBirthdayColor = "#ec4899"
// birthdayWindowPast/birthdayWindowFuture bound how many years around the
// current one the synthetic Birthdays calendar generates events for.
// CalDAV clients sync ahead of time (unlike the web view, which only
// computes the month currently being browsed), so a fixed window is
// generated upfront instead.
const birthdayWindowPast = 2
const birthdayWindowFuture = 8
// birthdayObjIDRe parses an object ID of the form
// "<book>~<contact-id-without-ext>~<year>.ics" back into its parts.
var birthdayObjIDRe = regexp.MustCompile(`^([^~]+)~([^~]+)~(\d{4})\.ics$`)
// birthdayObjID builds the stable object ID for contact c's birthday
// occurrence in year, encoding enough information to regenerate that
// exact VEVENT on demand in GetCalendarObject.
func birthdayObjID(c birthdays.Contact, year int) string {
base := strings.TrimSuffix(c.ID, path.Ext(c.ID))
return fmt.Sprintf("%s~%s~%d.ics", c.Book, base, year)
}
// birthdaysCalendarMeta returns the caldav.Calendar metadata for
// username's synthetic Birthdays calendar.
func (b *Backend) birthdaysCalendarMeta(username string) caldav.Calendar {
return caldav.Calendar{
Path: calHomePath() + birthdaysCalendarName + "/",
Name: "Birthdays",
Description: "Birthdays computed from your contacts",
SupportedComponentSet: []string{"VEVENT"},
MaxResourceSize: 64 * 1024,
}
}
// listBirthdayCalendarObjects generates one VEVENT per (contact, year)
// pair within the fixed rolling window for every contact of username's
// that has a parseable birthday.
func (b *Backend) listBirthdayCalendarObjects(username string) ([]caldav.CalendarObject, error) {
contacts, err := birthdays.Collect(b.store, b.dbase, username)
if err != nil {
return nil, fmt.Errorf("collecting birthdays: %w", err)
}
now := time.Now()
var objs []caldav.CalendarObject
for _, c := range contacts {
for year := now.Year() - birthdayWindowPast; year <= now.Year()+birthdayWindowFuture; year++ {
obj, ok := b.encodeBirthdayObject(c, year)
if !ok {
continue
}
objs = append(objs, *obj)
}
}
return objs, nil
}
// birthdayCalendarObject regenerates a single VEVENT for the (contact,
// year) pair encoded in objID.
func (b *Backend) birthdayCalendarObject(username, objPath, objID string) (*caldav.CalendarObject, error) {
m := birthdayObjIDRe.FindStringSubmatch(objID)
if m == nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("unrecognized birthday object id"))
}
book, idBase, year := m[1], m[2], m[3]
yearNum, err := strconv.Atoi(year)
if err != nil {
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("invalid year"))
}
contacts, err := birthdays.Collect(b.store, b.dbase, username)
if err != nil {
return nil, fmt.Errorf("collecting birthdays: %w", err)
}
for _, c := range contacts {
if c.Book != book {
continue
}
if strings.TrimSuffix(c.ID, path.Ext(c.ID)) != idBase {
continue
}
obj, ok := b.encodeBirthdayObject(c, yearNum)
if !ok {
break
}
obj.Path = objPath
return obj, nil
}
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("birthday event not found"))
}
// encodeBirthdayObject builds the all-day VEVENT for contact c's birthday
// occurrence in year, returning ok=false if that (month, day) combination
// doesn't exist in year (e.g. Feb 29 in a non-leap year).
func (b *Backend) encodeBirthdayObject(c birthdays.Contact, year int) (*caldav.CalendarObject, bool) {
start, ok := birthdays.OccurrenceDate(c, year, time.UTC)
if !ok {
return nil, false
}
end := start.AddDate(0, 0, 1)
event := ical.NewEvent()
uid := birthdayObjID(c, year)
event.Props.SetText(ical.PropUID, uid)
event.Props.SetText(ical.PropSummary, birthdays.Summary(c, year))
event.Props.SetDate(ical.PropDateTimeStart, start)
event.Props.SetDate(ical.PropDateTimeEnd, end)
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
event.Props.SetText(ical.PropTransparency, "TRANSPARENT")
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropVersion, "2.0")
cal.Props.SetText(ical.PropProductID, "-//nidus//birthdays//EN")
cal.Children = append(cal.Children, event.Component)
var buf strings.Builder
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
return nil, false
}
objID := birthdayObjID(c, year)
return &caldav.CalendarObject{
Path: calObjectPath(birthdaysCalendarName, objID),
ModTime: time.Now(),
ContentLength: int64(buf.Len()),
ETag: fmt.Sprintf(`"bday-%s-%d"`, strings.TrimSuffix(c.ID, path.Ext(c.ID)), year),
Data: cal,
}, true
}
+9
View File
@@ -96,6 +96,15 @@ CREATE TABLE IF NOT EXISTS addressbook_shares (
UNIQUE (owner, addressbook_name, shared_with) UNIQUE (owner, addressbook_name, shared_with)
); );
-- Per-user display color for the synthetic "Birthdays" calendar (computed
-- from contacts' BDAY fields, not a real user-created calendar — see
-- internal/web/calendar.go and internal/caldav/backend.go). One row per
-- user; absent means "use the built-in default color".
CREATE TABLE IF NOT EXISTS birthday_calendars (
owner TEXT PRIMARY KEY REFERENCES users (username) ON DELETE CASCADE,
color TEXT NOT NULL DEFAULT ''
);
-- Web UI login sessions. Sessions are opaque random tokens stored server -- Web UI login sessions. Sessions are opaque random tokens stored server
-- side (not JWTs) so they can be revoked instantly by deleting the row. -- side (not JWTs) so they can be revoked instantly by deleting the row.
CREATE TABLE IF NOT EXISTS web_sessions ( CREATE TABLE IF NOT EXISTS web_sessions (
+45 -1
View File
@@ -24,6 +24,19 @@ var ErrResourceExists = errors.New("resource already exists")
// that doesn't exist. // that doesn't exist.
var ErrResourceNotFound = errors.New("resource not found") var ErrResourceNotFound = errors.New("resource not found")
// ErrReservedName is returned when trying to create a calendar whose name
// is reserved for a computed/virtual calendar (see reservedCalendarNames).
var ErrReservedName = errors.New("calendar name is reserved")
// reservedCalendarNames are calendar names that can't be used for a real,
// user-created calendar because they're reserved for a synthetic,
// computed calendar shown alongside real ones (e.g. "birthdays", see
// internal/web/calendar.go and internal/caldav/backend.go). Matched
// case-insensitively.
var reservedCalendarNames = map[string]bool{
"birthdays": true,
}
// User is an account stored in the database. // User is an account stored in the database.
type User struct { type User struct {
Username string Username string
@@ -175,8 +188,11 @@ func (d *DB) CreateCalendar(owner, name string) error {
// CreateCalendarWithColor registers a new calendar owned by owner with the // CreateCalendarWithColor registers a new calendar owned by owner with the
// given color (an empty string means no color is set, in which case the // given color (an empty string means no color is set, in which case the
// client picks its own default). Returns ErrResourceExists if it already // client picks its own default). Returns ErrResourceExists if it already
// exists. // exists, or ErrReservedName if name is reserved for a computed calendar.
func (d *DB) CreateCalendarWithColor(owner, name, color string) error { func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
if reservedCalendarNames[strings.ToLower(name)] {
return ErrReservedName
}
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color) _, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
if err != nil { if err != nil {
if isUniqueConstraintErr(err) { if isUniqueConstraintErr(err) {
@@ -254,6 +270,34 @@ func (d *DB) ListCalendars(owner string) ([]Calendar, error) {
return cals, rows.Err() return cals, rows.Err()
} }
// GetBirthdayCalendarColor returns owner's display color for the
// synthetic "Birthdays" calendar, or "" if they haven't set one (callers
// should fall back to a built-in default in that case).
func (d *DB) GetBirthdayCalendarColor(owner string) (string, error) {
var color string
err := d.conn.QueryRow(`SELECT color FROM birthday_calendars WHERE owner = ?`, owner).Scan(&color)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("getting birthday calendar color: %w", err)
}
return color, nil
}
// SetBirthdayCalendarColor sets (or updates) owner's display color for
// the synthetic "Birthdays" calendar.
func (d *DB) SetBirthdayCalendarColor(owner, color string) error {
_, err := d.conn.Exec(`
INSERT INTO birthday_calendars (owner, color) VALUES (?, ?)
ON CONFLICT (owner) DO UPDATE SET color = excluded.color`,
owner, color)
if err != nil {
return fmt.Errorf("setting birthday calendar color: %w", err)
}
return nil
}
// -------- address books -------- // -------- address books --------
// CreateAddressBook registers a new address book owned by owner. Returns // CreateAddressBook registers a new address book owned by owner. Returns
+20 -103
View File
@@ -16,7 +16,7 @@ import (
"time" "time"
ical "github.com/emersion/go-ical" ical "github.com/emersion/go-ical"
vcard "github.com/emersion/go-vcard" "github.com/yourusername/caldav-server/internal/birthdays"
"github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates" "github.com/yourusername/caldav-server/internal/web/templates"
) )
@@ -133,9 +133,20 @@ type calendarEntry struct {
// mistaken for an "owner~name" shared-calendar ref either). // mistaken for an "owner~name" shared-calendar ref either).
const birthdaysCalRef = "@birthdays" const birthdaysCalRef = "@birthdays"
// birthdayColor is the fixed display color for the virtual birthdays // defaultBirthdayColor is the display color for the virtual birthdays
// calendar (a pink, distinct from typical user-picked calendar colors). // calendar used until the user picks their own from the dashboard (a
const birthdayColor = "#ec4899" // pink, distinct from typical user-picked calendar colors).
const defaultBirthdayColor = "#ec4899"
// birthdayCalendarColor returns username's chosen color for the virtual
// birthdays calendar, falling back to defaultBirthdayColor if unset.
func (s *Server) birthdayCalendarColor(username string) string {
color, err := s.dbase.GetBirthdayCalendarColor(username)
if err != nil || color == "" {
return defaultBirthdayColor
}
return color
}
// listCalendarEntries returns every calendar visible to username: the // listCalendarEntries returns every calendar visible to username: the
// synthetic birthdays calendar, their own calendars (always writable), // synthetic birthdays calendar, their own calendars (always writable),
@@ -143,7 +154,7 @@ const birthdayColor = "#ec4899"
// write permission). // write permission).
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) { func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
entries := []calendarEntry{ entries := []calendarEntry{
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: birthdayColor, Writable: false, Virtual: true}, {Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
} }
own, err := s.dbase.ListCalendars(username) own, err := s.dbase.ListCalendars(username)
@@ -973,100 +984,13 @@ func (s *Server) handleCalendarImport(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther) http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther)
} }
// birthdayContact holds one contact's parsed birthday, ready to be placed
// on the month grid.
type birthdayContact struct {
Name string
Book string
ID string
Month time.Month
Day int
Year int // 0 if the vCard's BDAY has no year (e.g. "--08-20")
}
// birthdayFullRe matches a BDAY value that includes a year, in either
// "YYYY-MM-DD" or the older vCard 3.0 "YYYYMMDD" form.
var birthdayFullRe = regexp.MustCompile(`^(\d{4})-?(\d{2})-?(\d{2})`)
// birthdayNoYearRe matches a year-less BDAY value per RFC 6350 §4.3.1,
// "--MM-DD" or "--MMDD".
var birthdayNoYearRe = regexp.MustCompile(`^--(\d{2})-?(\d{2})`)
// parseBirthday extracts month/day (and year, if present) from a vCard
// BDAY field value. Returns ok=false if v isn't a recognized date format
// or names an impossible month/day.
func parseBirthday(v string) (month time.Month, day int, year int, ok bool) {
v = strings.TrimSpace(v)
if m := birthdayNoYearRe.FindStringSubmatch(v); m != nil {
mo, _ := strconv.Atoi(m[1])
d, _ := strconv.Atoi(m[2])
if mo < 1 || mo > 12 || d < 1 || d > 31 {
return 0, 0, 0, false
}
return time.Month(mo), d, 0, true
}
if m := birthdayFullRe.FindStringSubmatch(v); m != nil {
y, _ := strconv.Atoi(m[1])
mo, _ := strconv.Atoi(m[2])
d, _ := strconv.Atoi(m[3])
if mo < 1 || mo > 12 || d < 1 || d > 31 {
return 0, 0, 0, false
}
return time.Month(mo), d, y, true
}
return 0, 0, 0, false
}
// collectBirthdays scans every one of username's own address books for
// contacts with a parseable BDAY field.
func (s *Server) collectBirthdays(username string) ([]birthdayContact, error) {
books, err := s.dbase.ListAddressBooks(username)
if err != nil {
return nil, err
}
var contacts []birthdayContact
for _, book := range books {
ids, err := s.store.ListObjects(username, "card-"+book)
if err != nil {
continue
}
for _, id := range ids {
data, err := s.store.GetObject(username, "card-"+book, id)
if err != nil {
continue
}
card, err := vcard.NewDecoder(bytes.NewReader(data)).Decode()
if err != nil {
continue
}
bday := card.PreferredValue(vcard.FieldBirthday)
if bday == "" {
continue
}
month, day, year, ok := parseBirthday(bday)
if !ok {
continue
}
name := card.PreferredValue(vcard.FieldFormattedName)
if name == "" {
name = id
}
contacts = append(contacts, birthdayContact{
Name: name, Book: book, ID: id, Month: month, Day: day, Year: year,
})
}
}
return contacts, nil
}
// addBirthdayEvents places one virtual all-day event per contact // addBirthdayEvents places one virtual all-day event per contact
// birthday falling within [gridStart, gridEnd] into days, titled // birthday falling within [gridStart, gridEnd] into days, titled
// "🎂 Name" (or "🎂 Name (Age)" when the birth year is known). Each event // "🎂 Name" (or "🎂 Name (Age)" when the birth year is known). Each event
// links to the contact's edit page instead of an event edit page, since // links to the contact's edit page instead of an event edit page, since
// there is no underlying calendar object to edit. // there is no underlying calendar object to edit.
func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridStart, gridEnd time.Time, dayIndex map[string]int, days []templates.MonthDay) error { func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridStart, gridEnd time.Time, dayIndex map[string]int, days []templates.MonthDay) error {
contacts, err := s.collectBirthdays(username) contacts, err := birthdays.Collect(s.store, s.dbase, username)
if err != nil { if err != nil {
return err return err
} }
@@ -1084,11 +1008,8 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
for _, c := range contacts { for _, c := range contacts {
for _, year := range years { for _, year := range years {
occurrence := time.Date(year, c.Month, c.Day, 0, 0, 0, 0, gridStart.Location()) occurrence, ok := birthdays.OccurrenceDate(c, year, gridStart.Location())
// Guard against date normalization (e.g. Feb 29 in a if !ok {
// non-leap year rolling over into March) placing the event
// on the wrong day.
if occurrence.Month() != c.Month || occurrence.Day() != c.Day {
continue continue
} }
if occurrence.Before(gridStart) || occurrence.After(gridEnd) { if occurrence.Before(gridStart) || occurrence.After(gridEnd) {
@@ -1098,15 +1019,11 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
if !ok { if !ok {
continue continue
} }
summary := "🎂 " + c.Name
if c.Year > 0 {
summary += fmt.Sprintf(" (%d)", year-c.Year)
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{ days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: c.Book + "/" + c.ID, ID: c.Book + "/" + c.ID,
CalRef: entry.Ref, CalRef: entry.Ref,
Color: entry.Color, Color: entry.Color,
Summary: summary, Summary: birthdays.Summary(c, year),
AllDay: true, AllDay: true,
LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit", LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit",
}) })
+3 -1
View File
@@ -49,7 +49,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
// #resources list after a create/delete (since the set of cards changes, // #resources list after a create/delete (since the set of cards changes,
// unlike a share update which only changes one card's contents). // unlike a share update which only changes one card's contents).
func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error) { func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error) {
var resources []templates.ResourceCard resources := []templates.ResourceCard{
{Kind: "calendar", Name: "Birthdays", Color: s.birthdayCalendarColor(username), Virtual: true},
}
calNames, err := s.dbase.ListCalendars(username) calNames, err := s.dbase.ListCalendars(username)
if err != nil { if err != nil {
+35
View File
@@ -69,6 +69,37 @@ func (s *Server) handleCalendarColor(w http.ResponseWriter, r *http.Request) {
_ = templates.ResourceCardView(card).Render(r.Context(), w) _ = templates.ResourceCardView(card).Render(r.Context(), w)
} }
// handleBirthdayColor updates the current user's display color for the
// synthetic "Birthdays" calendar, mounted at /resources/birthdays/color.
// It re-renders just that card (not the whole list), since the set of
// cards doesn't change.
func (s *Server) handleBirthdayColor(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
color := strings.TrimSpace(r.PostForm.Get("color"))
if color != "" && !hexColorRe.MatchString(color) {
http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest)
return
}
if err := s.dbase.SetBirthdayCalendarColor(username, color); err != nil {
s.logger.Error("setting birthday calendar color", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
card := templates.ResourceCard{Kind: "calendar", Name: "Birthdays", Color: s.birthdayCalendarColor(username), Virtual: true}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ResourceCardView(card).Render(r.Context(), w)
}
func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) { func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) {
username := userFromContext(r.Context()) username := userFromContext(r.Context())
@@ -109,6 +140,10 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str
http.Error(w, "already exists", http.StatusConflict) http.Error(w, "already exists", http.StatusConflict)
return return
} }
if err == db.ErrReservedName {
http.Error(w, "this name is reserved for a computed calendar", http.StatusConflict)
return
}
s.logger.Error("creating resource", "kind", kind, "error", err) s.logger.Error("creating resource", "kind", kind, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
+1
View File
@@ -44,6 +44,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare)) mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare))
mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource)) mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource))
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor)) mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
mux.HandleFunc("/resources/birthdays/color", s.requireLogin(s.handleBirthdayColor))
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource)) mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles)) mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles))
mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome)) mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome))
+77 -62
View File
@@ -11,10 +11,11 @@ type ShareRow struct {
// ResourceCard describes one of the user's own calendars/address books // ResourceCard describes one of the user's own calendars/address books
// plus who it's currently shared with. // plus who it's currently shared with.
type ResourceCard struct { type ResourceCard struct {
Kind string // "calendar" or "addressbook" Kind string // "calendar" or "addressbook"
Name string Name string
Color string // hex color like "#3b82f6"; only used for calendars Color string // hex color like "#3b82f6"; only used for calendars
Shares []ShareRow 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 // 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"> <h2 class="font-medium flex items-center gap-2">
if r.Kind == "calendar" { if r.Kind == "calendar" {
<form <form
hx-post="/web/resources/calendar/color" hx-post={ colorEndpoint(r) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name } hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML" hx-swap="outerHTML"
hx-trigger="change" hx-trigger="change"
@@ -132,70 +133,84 @@ templ ResourceCardView(r ResourceCard) {
} }
{ r.Name } { r.Name }
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span> <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> </h2>
<button if !r.Virtual {
class="text-red-600 hover:underline text-xs" <button
hx-delete={ resourceEndpoint(r.Kind) } class="text-red-600 hover:underline text-xs"
hx-vals={ resourceVals(r.Name) } hx-delete={ resourceEndpoint(r.Kind) }
hx-target="#resources" hx-vals={ resourceVals(r.Name) }
hx-swap="outerHTML" hx-target="#resources"
hx-confirm={ "Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone." } hx-swap="outerHTML"
> hx-confirm={ "Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone." }
Delete >
</button> Delete
</button>
}
</div> </div>
<ul class="divide-y divide-gray-100 mb-4"> if !r.Virtual {
for _, sh := range r.Shares { <ul class="divide-y divide-gray-100 mb-4">
<li class="py-2 flex items-center justify-between text-sm"> for _, sh := range r.Shares {
<span>{ sh.SharedWith }</span> <li class="py-2 flex items-center justify-between text-sm">
<span class="flex items-center gap-3"> <span>{ sh.SharedWith }</span>
<span class="text-xs uppercase tracking-wide text-gray-500">{ sh.Permission }</span> <span class="flex items-center gap-3">
<button <span class="text-xs uppercase tracking-wide text-gray-500">{ sh.Permission }</span>
class="text-red-600 hover:underline text-xs" <button
hx-delete={ shareEndpoint(r.Kind) } class="text-red-600 hover:underline text-xs"
hx-vals={ shareVals(r.Name, sh.SharedWith) } hx-delete={ shareEndpoint(r.Kind) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name } hx-vals={ shareVals(r.Name, sh.SharedWith) }
hx-swap="outerHTML" hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-confirm={ "Remove access for " + sh.SharedWith + "?" } hx-swap="outerHTML"
> hx-confirm={ "Remove access for " + sh.SharedWith + "?" }
Remove >
</button> Remove
</span> </button>
</li> </span>
} </li>
if len(r.Shares) == 0 { }
<li class="py-2 text-sm text-gray-400">Not shared with anyone yet.</li> if len(r.Shares) == 0 {
} <li class="py-2 text-sm text-gray-400">Not shared with anyone yet.</li>
</ul> }
</ul>
<form <form
class="flex flex-col sm:flex-row sm:items-end gap-2" class="flex flex-col sm:flex-row sm:items-end gap-2"
hx-post={ shareEndpoint(r.Kind) } hx-post={ shareEndpoint(r.Kind) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name } hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<input type="hidden" name="resource" value={ r.Name }/> <input type="hidden" name="resource" value={ r.Name }/>
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs text-gray-500 mb-1">Username</label> <label class="block text-xs text-gray-500 mb-1">Username</label>
<input name="shared_with" type="text" required <input name="shared_with" type="text" required
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500 mb-1">Permission</label> <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"> <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="read">read</option>
<option value="write">write</option> <option value="write">write</option>
</select> </select>
</div> </div>
<button type="submit" <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"> 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 Share
</button> </button>
</form> </form>
}
</div> </div>
} }
func colorEndpoint(r ResourceCard) string {
if r.Virtual {
return "/web/resources/birthdays/color"
}
return "/web/resources/calendar/color"
}
func shareEndpoint(kind string) string { func shareEndpoint(kind string) string {
if kind == "calendar" { if kind == "calendar" {
return "/web/shares/calendar" return "/web/shares/calendar"
+225 -174
View File
@@ -19,10 +19,11 @@ type ShareRow struct {
// ResourceCard describes one of the user's own calendars/address books // ResourceCard describes one of the user's own calendars/address books
// plus who it's currently shared with. // plus who it's currently shared with.
type ResourceCard struct { type ResourceCard struct {
Kind string // "calendar" or "addressbook" Kind string // "calendar" or "addressbook"
Name string Name string
Color string // hex color like "#3b82f6"; only used for calendars Color string // hex color like "#3b82f6"; only used for calendars
Shares []ShareRow 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 // SharedWithMeItem describes a resource another user has shared with the
@@ -92,7 +93,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -105,7 +106,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -118,7 +119,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 87, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -131,7 +132,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 88, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 89, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -233,7 +234,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 114, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -244,245 +245,288 @@ func ResourceCardView(r ResourceCard) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if r.Kind == "calendar" { if r.Kind == "calendar" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form hx-post=\"/web/resources/calendar/color\" hx-target=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorEndpoint(r))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-swap=\"outerHTML\" hx-trigger=\"change\"><input type=\"hidden\" name=\"name\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 123, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 120, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"> <input name=\"color\" type=\"color\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-swap=\"outerHTML\" hx-trigger=\"change\"><input type=\"hidden\" name=\"name\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorOrDefault(r.Color)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 127, Col: 38} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 124, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" title=\"Calendar color\" class=\"w-6 h-6 rounded border border-gray-300 p-0 align-middle\"></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> <input name=\"color\" type=\"color\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorOrDefault(r.Color))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 128, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"Calendar color\" class=\"w-6 h-6 rounded border border-gray-300 p-0 align-middle\"></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 133, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 12}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span></h2><button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind)) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 138, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 135, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-vals=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string if r.Virtual {
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name)) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span class=\"text-xs text-gray-400\">(computed from contacts)</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 139, Col: 34} return templ_7745c5c3_Err
}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h2>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"") if !r.Virtual {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
return templ_7745c5c3_Err if templ_7745c5c3_Err != nil {
} return templ_7745c5c3_Err
var templ_7745c5c3_Var17 string }
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.") var templ_7745c5c3_Var16 string
if templ_7745c5c3_Err != nil { templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind))
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 142, Col: 104} if templ_7745c5c3_Err != nil {
} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 143, Col: 41}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) }
if templ_7745c5c3_Err != nil { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
return templ_7745c5c3_Err if templ_7745c5c3_Err != nil {
} return templ_7745c5c3_Err
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">Delete</button></div><ul class=\"divide-y divide-gray-100 mb-4\">") }
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-vals=\"")
return templ_7745c5c3_Err if templ_7745c5c3_Err != nil {
} return templ_7745c5c3_Err
for _, sh := range r.Shares { }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>") var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 144, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 151, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 147, Col: 105}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">Delete</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 153, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 156, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 157, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 158, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 160, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Remove</button></span></li>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
if len(r.Shares) == 0 { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string if !r.Virtual {
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind)) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<ul class=\"divide-y divide-gray-100 mb-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 174, Col: 34} return templ_7745c5c3_Err
}
for _, sh := range r.Shares {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 158, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 160, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 163, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 164, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 165, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 167, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">Remove</button></span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(r.Shares) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 181, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 182, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 185, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"><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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 175, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 178, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"><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>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -490,6 +534,13 @@ func ResourceCardView(r ResourceCard) templ.Component {
}) })
} }
func colorEndpoint(r ResourceCard) string {
if r.Virtual {
return "/web/resources/birthdays/color"
}
return "/web/resources/calendar/color"
}
func shareEndpoint(kind string) string { func shareEndpoint(kind string) string {
if kind == "calendar" { if kind == "calendar" {
return "/web/shares/calendar" return "/web/shares/calendar"