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
}
+42 -4
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"))
}
// The synthetic Birthdays calendar is always present, computed from
// the requester's own contacts.
names, err := b.dbase.ListCalendars(p.Username)
if err != nil {
return nil, fmt.Errorf("listing calendars: %w", err)
}
var cals []caldav.Calendar
cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)}
for _, cal := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
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 {
return nil, err
}
if localName == birthdaysCalendarName {
cal := b.birthdaysCalendarMeta(requester)
return &cal, nil
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -150,6 +156,9 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca
if err != nil {
return nil, err
}
if localName == birthdaysCalendarName {
return b.birthdayCalendarObject(requester, objPath, objID)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -168,6 +177,9 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
if err != nil {
return nil, err
}
if localName == birthdaysCalendarName {
return b.listBirthdayCalendarObjects(requester)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -213,9 +225,14 @@ func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar)
// existing calendar is done via ShareCalendar, not by creating one
// directly in someone else's name.
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 {
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)
}
@@ -224,6 +241,9 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
if err != nil {
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)
if err != nil {
return err
@@ -239,6 +259,9 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda
if err != nil {
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)
if err != nil {
return nil, err
@@ -263,6 +286,9 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
if err != nil {
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)
if err != nil {
return err
@@ -495,14 +521,26 @@ func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body [
return block
}
localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/")
owner, realName, _, err := h.backend.resolveCalendar(p.Username, localName, false)
var color string
var err error
if localName == birthdaysCalendarName {
color, err = h.backend.dbase.GetBirthdayCalendarColor(p.Username)
if err != nil {
color = ""
}
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)
color, err = h.backend.dbase.GetCalendarColor(owner, realName)
if err != nil || color == "" {
return block
}
}
// Drop any propstat block that only complains calendar-color is
// unknown (a 404/not-found propstat containing a bare, empty
+8 -2
View File
@@ -209,8 +209,14 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
// Find personal's response block boundaries loosely by looking for the
// nearest calendar-color occurrence and ensuring it isn't right next to
// the personal href (colors are per-block, checked via count instead).
if strings.Count(body, "<calendar-color ") != 1 {
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body)
// Two calendar-color elements are expected: one for "work" (explicit
// 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)
);
-- 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
-- side (not JWTs) so they can be revoked instantly by deleting the row.
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.
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.
type User struct {
Username string
@@ -175,8 +188,11 @@ func (d *DB) CreateCalendar(owner, name string) error {
// CreateCalendarWithColor registers a new calendar owned by owner with 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
// exists.
// exists, or ErrReservedName if name is reserved for a computed calendar.
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)
if err != nil {
if isUniqueConstraintErr(err) {
@@ -254,6 +270,34 @@ func (d *DB) ListCalendars(owner string) ([]Calendar, error) {
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 --------
// CreateAddressBook registers a new address book owned by owner. Returns
+20 -103
View File
@@ -16,7 +16,7 @@ import (
"time"
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/web/templates"
)
@@ -133,9 +133,20 @@ type calendarEntry struct {
// mistaken for an "owner~name" shared-calendar ref either).
const birthdaysCalRef = "@birthdays"
// birthdayColor is the fixed display color for the virtual birthdays
// calendar (a pink, distinct from typical user-picked calendar colors).
const birthdayColor = "#ec4899"
// defaultBirthdayColor is the display color for the virtual birthdays
// calendar used until the user picks their own from the dashboard (a
// 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
// synthetic birthdays calendar, their own calendars (always writable),
@@ -143,7 +154,7 @@ const birthdayColor = "#ec4899"
// write permission).
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
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)
@@ -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)
}
// 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
// birthday falling within [gridStart, gridEnd] into days, titled
// "🎂 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
// 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 {
contacts, err := s.collectBirthdays(username)
contacts, err := birthdays.Collect(s.store, s.dbase, username)
if err != nil {
return err
}
@@ -1084,11 +1008,8 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
for _, c := range contacts {
for _, year := range years {
occurrence := time.Date(year, c.Month, c.Day, 0, 0, 0, 0, gridStart.Location())
// Guard against date normalization (e.g. Feb 29 in a
// non-leap year rolling over into March) placing the event
// on the wrong day.
if occurrence.Month() != c.Month || occurrence.Day() != c.Day {
occurrence, ok := birthdays.OccurrenceDate(c, year, gridStart.Location())
if !ok {
continue
}
if occurrence.Before(gridStart) || occurrence.After(gridEnd) {
@@ -1098,15 +1019,11 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
if !ok {
continue
}
summary := "🎂 " + c.Name
if c.Year > 0 {
summary += fmt.Sprintf(" (%d)", year-c.Year)
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: c.Book + "/" + c.ID,
CalRef: entry.Ref,
Color: entry.Color,
Summary: summary,
Summary: birthdays.Summary(c, year),
AllDay: true,
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,
// unlike a share update which only changes one card's contents).
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)
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)
}
// 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) {
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)
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)
http.Error(w, "internal error", http.StatusInternalServerError)
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("/resources/calendar", s.requireLogin(s.handleCalendarResource))
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("/files/{path...}", s.requireLogin(s.handleFiles))
mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome))
+16 -1
View File
@@ -15,6 +15,7 @@ type ResourceCard struct {
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,7 +133,11 @@ 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>
if !r.Virtual {
<button
class="text-red-600 hover:underline text-xs"
hx-delete={ resourceEndpoint(r.Kind) }
@@ -143,8 +148,10 @@ templ ResourceCardView(r ResourceCard) {
>
Delete
</button>
}
</div>
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">
@@ -193,9 +200,17 @@ templ ResourceCardView(r ResourceCard) {
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"
+129 -78
View File
@@ -23,6 +23,7 @@ type ResourceCard struct {
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
@@ -92,7 +93,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
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))
if templ_7745c5c3_Err != nil {
@@ -105,7 +106,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
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))
if templ_7745c5c3_Err != nil {
@@ -118,7 +119,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
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))
if templ_7745c5c3_Err != nil {
@@ -131,7 +132,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
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))
if templ_7745c5c3_Err != nil {
@@ -233,7 +234,7 @@ func ResourceCardView(r ResourceCard) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, 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: 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)
if templ_7745c5c3_Err != nil {
@@ -244,245 +245,288 @@ func ResourceCardView(r ResourceCard) templ.Component {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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)
if templ_7745c5c3_Err != nil {
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.JoinStringErrs(r.Name)
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: 133, Col: 12}
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.EscapeString(templ_7745c5c3_Var13))
_, 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, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
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 {
return templ_7745c5c3_Err
}
}
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 {
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))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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 {
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 {
return templ_7745c5c3_Err
}
if r.Virtual {
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 {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !r.Virtual {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name))
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 139, Col: 34}
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_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-vals=\"")
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.")
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: 142, Col: 104}
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, 23, "\">Delete</button></div><ul class=\"divide-y divide-gray-100 mb-4\">")
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>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 {
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 {
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
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !r.Virtual {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<ul class=\"divide-y divide-gray-100 mb-4\">")
if templ_7745c5c3_Err != nil {
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.Permission)
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: 153, Col: 81}
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, 26, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
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.ResolveAttributeValue(shareEndpoint(r.Kind))
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: 156, Col: 40}
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_7745c5c3_Var20)
_, 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, 27, "\" hx-vals=\"")
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(shareVals(r.Name, sh.SharedWith))
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: 157, Col: 49}
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, 28, "\" hx-target=\"")
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("#resource-" + r.Kind + "-" + r.Name)
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: 158, Col: 55}
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, 29, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
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("Remove access for " + sh.SharedWith + "?")
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: 160, Col: 62}
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, 30, "\">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, 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=\"")
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(shareEndpoint(r.Kind))
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: 174, Col: 34}
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, 33, "\" hx-target=\"")
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("#resource-" + r.Kind + "-" + r.Name)
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: 175, Col: 51}
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, 34, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
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(r.Name)
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: 178, Col: 54}
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, 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>")
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 = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</div>")
if templ_7745c5c3_Err != nil {
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 {
if kind == "calendar" {
return "/web/shares/calendar"