diff --git a/README.md b/README.md index 2a2b661..21e22ec 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,8 @@ nidusctl user list nidusctl user passwd [--password PW] # Calendars -nidusctl calendar create +nidusctl calendar create [--color '#RRGGBB'] +nidusctl calendar color nidusctl calendar delete nidusctl calendar list nidusctl calendar share diff --git a/cmd/server/main.go b/cmd/server/main.go index 6949ea0..8649014 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -82,8 +82,8 @@ func main() { continue } for _, cal := range cals { - if err := st.EnsureCollection(user.Username, "cal-"+cal); err != nil { - logger.Warn("creating calendar collection", "user", user.Username, "cal", cal, "error", err) + if err := st.EnsureCollection(user.Username, "cal-"+cal.Name); err != nil { + logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err) } } books, err := dbase.ListAddressBooks(user.Username) diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index 191dfec..5c6f683 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -1,11 +1,16 @@ package caldav import ( + "bytes" "context" "fmt" + "io" "log/slog" "net/http" + "net/http/httptest" "path" + "regexp" + "strconv" "strings" "time" @@ -40,9 +45,15 @@ func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog. } // NewHandler returns an http.Handler for the /cal/ prefix. +// +// It wraps the go-webdav caldav.Handler with a small response-rewriting +// middleware that injects the non-standard (Apple/DAVx5) calendar-color +// property into PROPFIND responses for calendar collections, since +// go-webdav's caldav.Backend interface has no extension point for +// vendor-specific WebDAV properties. func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler { b := NewBackend(cfg, st, dbase, logger) - return &caldav.Handler{Backend: b} + return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}} } // -------- caldav.Backend interface -------- @@ -79,19 +90,19 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) } var cals []caldav.Calendar - for _, name := range names { - if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil { - b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err) + 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) continue } - cals = append(cals, b.calendarMeta(p.Username, name, name)) + cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name)) } // Also include any extra calendars that exist on disk but aren't registered disk, _ := b.store.ListCollections(p.Username) configured := make(map[string]bool) - for _, n := range names { - configured["cal-"+n] = true + for _, cal := range names { + configured["cal-"+cal.Name] = true } for _, dir := range disk { if strings.HasPrefix(dir, "cal-") && !configured[dir] { @@ -386,3 +397,127 @@ func hashBytes(data []byte) uint64 { } return h } + +// -------- calendar-color PROPFIND injection -------- + +// calCollectionPathRe matches a calendar collection's own path (e.g. +// "/cal/home/work/"), as opposed to a calendar object inside it (e.g. +// "/cal/home/work/abc123.ics") or the home-set/principal path. +var calCollectionPathRe = regexp.MustCompile(`^/cal/home/[^/]+/$`) + +// colorInjectingHandler wraps a caldav.Handler and post-processes PROPFIND +// responses to add the non-standard `calendar-color` property (in Apple's +// "http://apple.com/ns/ical/" namespace) that DAVx5 and other clients read +// to color-code synced calendars. go-webdav's caldav.Backend interface has +// no extension point for vendor properties like this, so the response XML +// is rewritten after the fact instead. +type colorInjectingHandler struct { + backend *Backend + next http.Handler +} + +func (h *colorInjectingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != "PROPFIND" || h.backend.dbase == nil { + h.next.ServeHTTP(w, r) + return + } + + // Only bother rewriting if the client asked for calendar-color + // specifically, or for all properties (propname/allprop requests, or + // no body at all, are treated as "all properties" by most clients). + var reqBody []byte + if r.Body != nil { + reqBody, _ = readAllAndReset(&r.Body) + } + wantsColor := len(reqBody) == 0 || bytes.Contains(reqBody, []byte("calendar-color")) || bytes.Contains(reqBody, []byte("allprop")) + if !wantsColor { + h.next.ServeHTTP(w, r) + return + } + + rec := httptest.NewRecorder() + h.next.ServeHTTP(rec, r) + + for k, vs := range rec.Header() { + for _, v := range vs { + w.Header().Add(k, v) + } + } + + body := rec.Body.Bytes() + if rec.Code == http.StatusMultiStatus && strings.Contains(rec.Header().Get("Content-Type"), "xml") { + body = h.injectCalendarColors(r.Context(), body) + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + } + w.WriteHeader(rec.Code) + _, _ = w.Write(body) +} + +// responseBlockRe matches a single ... element +// (non-greedy) inside a multistatus document, capturing its href. +var responseBlockRe = regexp.MustCompile(`(?s)]*>.*?]*>([^<]*).*?`) + +// injectCalendarColors scans a multistatus PROPFIND response body and, for +// each whose href is a calendar collection with a color set, +// inserts a +// element into its first 200 OK . +func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body []byte) []byte { + p := auth.FromContext(ctx) + if p == nil { + return body + } + + return responseBlockRe.ReplaceAllFunc(body, func(block []byte) []byte { + m := responseBlockRe.FindSubmatch(block) + if m == nil { + return block + } + href := string(m[1]) + if !calCollectionPathRe.MatchString(href) { + return block + } + localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/") + owner, realName, _, err := h.backend.resolveCalendar(p.Username, localName, false) + if err != nil { + return block + } + color, err := h.backend.dbase.GetCalendarColor(owner, realName) + if err != nil || color == "" { + return block + } + propEl := []byte(``) + idx := bytes.Index(block, propEl) + if idx < 0 { + return block + } + insertAt := idx + len(propEl) + colorEl := []byte(fmt.Sprintf(`%s`, xmlEscapeColor(color))) + out := make([]byte, 0, len(block)+len(colorEl)) + out = append(out, block[:insertAt]...) + out = append(out, colorEl...) + out = append(out, block[insertAt:]...) + return out + }) +} + +// xmlEscapeColor returns color formatted as an 8-digit ARGB/RGBA hex value +// (as Apple's calendar-color property expects), padding a plain 6-digit +// "#RRGGBB" (as produced by an HTML ) with a fully +// opaque alpha channel. +func xmlEscapeColor(color string) string { + if len(color) == 7 && color[0] == '#' { + return color + "FF" + } + return color +} + +// readAllAndReset reads body fully and replaces it with a fresh reader over +// the same bytes, so downstream handlers can still consume it. +func readAllAndReset(body *io.ReadCloser) ([]byte, error) { + data, err := io.ReadAll(*body) + if err != nil { + return nil, err + } + *body = io.NopCloser(bytes.NewReader(data)) + return data, nil +} diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go index 262f553..a98e5c0 100644 --- a/internal/caldav/backend_test.go +++ b/internal/caldav/backend_test.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "log/slog" + "net/http" + "net/http/httptest" "path/filepath" "strings" "testing" @@ -149,3 +151,65 @@ func TestUnauthorizedUserCannotAccessUnsharedCalendar(t *testing.T) { t.Fatal("expected error accessing unshared calendar, got nil") } } + +// TestPropFindEmitsCalendarColor verifies that a PROPFIND on a calendar +// with a color set returns the Apple/DAVx5 calendar-color property, and +// that a calendar without a color doesn't (since a client should fall +// back to its own default in that case). +func TestPropFindEmitsCalendarColor(t *testing.T) { + dir := t.TempDir() + st, err := store.NewStore(filepath.Join(dir, "data")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + dbase, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { dbase.Close() }) + + if err := dbase.CreateUser("alice", "pw", "", ""); err != nil { + t.Fatalf("CreateUser alice: %v", err) + } + if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil { + t.Fatalf("CreateCalendarWithColor: %v", err) + } + if err := dbase.CreateCalendar("alice", "personal"); err != nil { + t.Fatalf("CreateCalendar: %v", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := NewHandler(&config.Config{}, st, dbase, logger) + + req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader( + ``)) + req.SetBasicAuth("alice", "pw") + req.Header.Set("Content-Type", "text/xml") + req.Header.Set("Depth", "1") + req = req.WithContext(ctxFor("alice")) + + rr := httptest.NewRecorder() + req = req.WithContext(auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})) + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusMultiStatus { + t.Fatalf("expected 207, got %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, `#3b82f6FF`) { + t.Fatalf("expected calendar-color for work calendar, got: %s", body) + } + + // The "personal" calendar has no color, so its block + // shouldn't contain the property at all. + personalIdx := strings.Index(body, "/cal/home/personal/") + if personalIdx < 0 { + t.Fatalf("expected personal calendar in response, got: %s", body) + } + // 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, ", e.g. "#3b82f6". +var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) + // handleCalendarResource handles POST (create) and DELETE (remove) for // the current user's own calendars, mounted at /resources/calendar. func (s *Server) handleCalendarResource(w http.ResponseWriter, r *http.Request) { @@ -24,6 +28,47 @@ func (s *Server) handleAddressBookResource(w http.ResponseWriter, r *http.Reques s.handleResource(w, r, "addressbook") } +// handleCalendarColor updates the color of one of the current user's own +// calendars, mounted at /resources/calendar/color. It re-renders just +// that calendar's card (not the whole list), since the set of cards +// doesn't change. +func (s *Server) handleCalendarColor(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 + } + name := strings.TrimSpace(r.PostForm.Get("name")) + color := strings.TrimSpace(r.PostForm.Get("color")) + if !resourceNameRe.MatchString(name) { + http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest) + return + } + if color != "" && !hexColorRe.MatchString(color) { + http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest) + return + } + if err := s.dbase.SetCalendarColor(username, name, color); err != nil && err != db.ErrResourceNotFound { + s.logger.Error("setting calendar color", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + card, err := s.resourceCardFor(username, "calendar", name) + if err != nil { + s.logger.Error("rendering resource card", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + 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()) @@ -50,7 +95,12 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str case http.MethodPost: var err error if kind == "calendar" { - err = s.dbase.CreateCalendar(username, name) + 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 + } + err = s.dbase.CreateCalendarWithColor(username, name, color) } else { err = s.dbase.CreateAddressBook(username, name) } diff --git a/internal/web/resources_test.go b/internal/web/resources_test.go index 59f3f42..28406fb 100644 --- a/internal/web/resources_test.go +++ b/internal/web/resources_test.go @@ -32,8 +32,8 @@ func TestCreateAndDeleteCalendarViaWebUI(t *testing.T) { t.Fatalf("ListCalendars: %v", err) } found := false - for _, n := range names { - if n == "vacation" { + for _, c := range names { + if c.Name == "vacation" { found = true } } @@ -67,8 +67,8 @@ func TestCreateAndDeleteCalendarViaWebUI(t *testing.T) { if err != nil { t.Fatalf("ListCalendars: %v", err) } - for _, n := range names { - if n == "vacation" { + for _, c := range names { + if c.Name == "vacation" { t.Fatalf("expected vacation calendar to be gone, got %v", names) } } diff --git a/internal/web/server.go b/internal/web/server.go index 222d683..e1dc8b0 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -43,6 +43,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler { mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare)) 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/addressbook", s.requireLogin(s.handleAddressBookResource)) return mux diff --git a/internal/web/shares.go b/internal/web/shares.go index 1d5e3f6..8c41f62 100644 --- a/internal/web/shares.go +++ b/internal/web/shares.go @@ -95,15 +95,20 @@ func (s *Server) handleShare(w http.ResponseWriter, r *http.Request, kind string // actually configured for username, to prevent sharing arbitrary/other // users' resources via a forged form post. func (s *Server) ownsResource(username, kind, resource string) bool { - var ( - list []string - err error - ) if kind == "calendar" { - list, err = s.dbase.ListCalendars(username) - } else { - list, err = s.dbase.ListAddressBooks(username) + cals, err := s.dbase.ListCalendars(username) + if err != nil { + s.logger.Warn("checking resource ownership", "kind", kind, "error", err) + return false + } + for _, c := range cals { + if c.Name == resource { + return true + } + } + return false } + list, err := s.dbase.ListAddressBooks(username) if err != nil { s.logger.Warn("checking resource ownership", "kind", kind, "error", err) return false diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ index f922369..85e9cbe 100644 --- a/internal/web/templates/dashboard.templ +++ b/internal/web/templates/dashboard.templ @@ -13,6 +13,7 @@ type ShareRow struct { type ResourceCard struct { Kind string // "calendar" or "addressbook" Name string + Color string // hex color like "#3b82f6"; only used for calendars Shares []ShareRow } @@ -43,6 +44,11 @@ templ Dashboard(username string, resources []ResourceCard, sharedWithMe []Shared placeholder="e.g. work" class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> +
+ + +
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Your calendars & address books

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -91,7 +92,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: 79, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -104,7 +105,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: 79, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -117,7 +118,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: 80, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -130,7 +131,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: 82, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 88, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -232,211 +233,256 @@ 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: 107, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\">

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, 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: 110, Col: 12} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, 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: 111, Col: 77} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, sh := range r.Shares { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
  • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, 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: 128, Col: 26} - } - _, 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, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, 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: 130, Col: 81} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
  • ") + 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") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(r.Shares) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
  • Not shared with anyone yet.
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
  • Not shared with anyone yet.
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -466,4 +512,11 @@ func resourceVals(name string) string { return `{"name": "` + name + `"}` } +func colorOrDefault(c string) string { + if c == "" { + return "#3b82f6" + } + return c +} + var _ = templruntime.GeneratedTemplate diff --git a/tools/nidusctl/main.go b/tools/nidusctl/main.go index 968d886..bb62378 100644 --- a/tools/nidusctl/main.go +++ b/tools/nidusctl/main.go @@ -80,7 +80,8 @@ Usage: nidusctl [-config config.yaml] user list nidusctl [-config config.yaml] user passwd [--password PW] - nidusctl [-config config.yaml] calendar create + nidusctl [-config config.yaml] calendar create [--color '#RRGGBB'] + nidusctl [-config config.yaml] calendar color nidusctl [-config config.yaml] calendar delete nidusctl [-config config.yaml] calendar list nidusctl [-config config.yaml] calendar share @@ -117,12 +118,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int { } switch args[0] { case "create": - if len(args) != 3 { - fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create ") + fs := newFlagSet("calendar create") + color := fs.String("color", "", "hex color like #3b82f6 (optional)") + if err := fs.Parse(args[1:]); err != nil { return 2 } - owner, calName := args[1], args[2] - if err := dbase.CreateCalendar(owner, calName); err != nil { + if fs.NArg() != 2 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create [--color '#RRGGBB']") + return 2 + } + owner, calName := fs.Arg(0), fs.Arg(1) + if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 } @@ -132,6 +138,19 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int { fmt.Printf("created calendar %q for %s\n", calName, owner) return 0 + case "color": + if len(args) != 4 { + fmt.Fprintln(os.Stderr, "usage: nidusctl calendar color ") + return 2 + } + owner, calName, color := args[1], args[2], args[3] + if err := dbase.SetCalendarColor(owner, calName, color); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + fmt.Printf("set color of calendar %q for %s to %s\n", calName, owner, color) + return 0 + case "delete": if len(args) != 3 { fmt.Fprintln(os.Stderr, "usage: nidusctl calendar delete ") @@ -154,13 +173,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int { return 2 } owner := args[1] - names, err := dbase.ListCalendars(owner) + cals, err := dbase.ListCalendars(owner) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return 1 } - for _, n := range names { - fmt.Println(n) + for _, c := range cals { + if c.Color != "" { + fmt.Printf("%s\t%s\n", c.Name, c.Color) + } else { + fmt.Println(c.Name) + } } return 0 diff --git a/web/static/app.css b/web/static/app.css index 1ddd96b..c5480b4 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-600:oklch(57.7% .245 27.325);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mb-1{margin-bottom:var(--spacing)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.flex{display:flex}.hidden{display:none}.h-full{height:100%}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.resize{resize:both}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-red-200{border-color:var(--color-red-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-red-600{color:var(--color-red-600)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}@media (min-width:40rem){.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-200:oklch(88.5% .062 18.334);--color-red-600:oklch(57.7% .245 27.325);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mb-1{margin-bottom:var(--spacing)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-2{margin-left:calc(var(--spacing) * 2)}.block{display:block}.flex{display:flex}.hidden{display:none}.h-6{height:calc(var(--spacing) * 6)}.h-9{height:calc(var(--spacing) * 9)}.h-full{height:100%}.w-6{width:calc(var(--spacing) * 6)}.w-12{width:calc(var(--spacing) * 12)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.resize{resize:both}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-red-200{border-color:var(--color-red-200)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-white{background-color:var(--color-white)}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-8{padding-block:calc(var(--spacing) * 8)}.text-center{text-align:center}.align-middle{vertical-align:middle}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-red-600{color:var(--color-red-600)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}@media (min-width:40rem){.sm\:w-auto{width:auto}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file