Add calendar color support (DAVx5 calendar-color)
Calendars can now have a color (hex, e.g. #3b82f6) that DAVx5 and other
CalDAV clients pick up via the Apple/dav4jvm calendar-color property.
- db: add calendars.color column with migration for existing DBs;
CreateCalendarWithColor, SetCalendarColor, GetCalendarColor;
ListCalendars now returns []Calendar{Name, Color} instead of []string
- caldav: since go-webdav's caldav.Backend interface has no extension
point for vendor properties, wrap the handler with a response-rewriting
middleware that injects <calendar-color xmlns="http://apple.com/ns/ical/">
into PROPFIND responses for calendars that have a color set
- web: color picker on the "New calendar" form and an inline color swatch/
picker on each calendar card (calendars only, not address books)
- nidusctl: `calendar create --color` flag and a new `calendar color`
subcommand; `calendar list` now also prints the color if set
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -270,7 +270,8 @@ nidusctl user list
|
|||||||
nidusctl user passwd <username> [--password PW]
|
nidusctl user passwd <username> [--password PW]
|
||||||
|
|
||||||
# Calendars
|
# Calendars
|
||||||
nidusctl calendar create <owner> <calendar>
|
nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
|
||||||
|
nidusctl calendar color <owner> <calendar> <hex-color>
|
||||||
nidusctl calendar delete <owner> <calendar>
|
nidusctl calendar delete <owner> <calendar>
|
||||||
nidusctl calendar list <owner>
|
nidusctl calendar list <owner>
|
||||||
nidusctl calendar share <owner> <calendar> <user> <read|write>
|
nidusctl calendar share <owner> <calendar> <user> <read|write>
|
||||||
|
|||||||
+2
-2
@@ -82,8 +82,8 @@ func main() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, cal := range cals {
|
for _, cal := range cals {
|
||||||
if err := st.EnsureCollection(user.Username, "cal-"+cal); err != nil {
|
if err := st.EnsureCollection(user.Username, "cal-"+cal.Name); err != nil {
|
||||||
logger.Warn("creating calendar collection", "user", user.Username, "cal", cal, "error", err)
|
logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
books, err := dbase.ListAddressBooks(user.Username)
|
books, err := dbase.ListAddressBooks(user.Username)
|
||||||
|
|||||||
+142
-7
@@ -1,11 +1,16 @@
|
|||||||
package caldav
|
package caldav
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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.
|
// 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 {
|
func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler {
|
||||||
b := NewBackend(cfg, st, dbase, logger)
|
b := NewBackend(cfg, st, dbase, logger)
|
||||||
return &caldav.Handler{Backend: b}
|
return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- caldav.Backend interface --------
|
// -------- caldav.Backend interface --------
|
||||||
@@ -79,19 +90,19 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cals []caldav.Calendar
|
var cals []caldav.Calendar
|
||||||
for _, name := range names {
|
for _, cal := range names {
|
||||||
if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil {
|
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
|
||||||
b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err)
|
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
|
||||||
continue
|
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
|
// Also include any extra calendars that exist on disk but aren't registered
|
||||||
disk, _ := b.store.ListCollections(p.Username)
|
disk, _ := b.store.ListCollections(p.Username)
|
||||||
configured := make(map[string]bool)
|
configured := make(map[string]bool)
|
||||||
for _, n := range names {
|
for _, cal := range names {
|
||||||
configured["cal-"+n] = true
|
configured["cal-"+cal.Name] = true
|
||||||
}
|
}
|
||||||
for _, dir := range disk {
|
for _, dir := range disk {
|
||||||
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
|
if strings.HasPrefix(dir, "cal-") && !configured[dir] {
|
||||||
@@ -386,3 +397,127 @@ func hashBytes(data []byte) uint64 {
|
|||||||
}
|
}
|
||||||
return h
|
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 <response>...</response> element
|
||||||
|
// (non-greedy) inside a multistatus document, capturing its href.
|
||||||
|
var responseBlockRe = regexp.MustCompile(`(?s)<response[^>]*>.*?<href[^>]*>([^<]*)</href>.*?</response>`)
|
||||||
|
|
||||||
|
// injectCalendarColors scans a multistatus PROPFIND response body and, for
|
||||||
|
// each <response> whose href is a calendar collection with a color set,
|
||||||
|
// inserts a <apple:calendar-color xmlns:apple="http://apple.com/ns/ical/">
|
||||||
|
// element into its first 200 OK <prop>.
|
||||||
|
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(`<prop xmlns="DAV:">`)
|
||||||
|
idx := bytes.Index(block, propEl)
|
||||||
|
if idx < 0 {
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
insertAt := idx + len(propEl)
|
||||||
|
colorEl := []byte(fmt.Sprintf(`<calendar-color xmlns="http://apple.com/ns/ical/">%s</calendar-color>`, 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 <input type="color">) 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -149,3 +151,65 @@ func TestUnauthorizedUserCannotAccessUnsharedCalendar(t *testing.T) {
|
|||||||
t.Fatal("expected error accessing unshared calendar, got nil")
|
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(
|
||||||
|
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
|
||||||
|
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, `<calendar-color xmlns="http://apple.com/ns/ical/">#3b82f6FF</calendar-color>`) {
|
||||||
|
t.Fatalf("expected calendar-color for work calendar, got: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "personal" calendar has no color, so its <response> 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, "<calendar-color ") != 1 {
|
||||||
|
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-1
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
CREATE TABLE IF NOT EXISTS calendars (
|
CREATE TABLE IF NOT EXISTS calendars (
|
||||||
owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
|
owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (owner, name)
|
PRIMARY KEY (owner, name)
|
||||||
);
|
);
|
||||||
@@ -106,5 +107,43 @@ CREATE TABLE IF NOT EXISTS web_sessions (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at);
|
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at);
|
||||||
`
|
`
|
||||||
_, err := d.conn.Exec(schema)
|
_, err := d.conn.Exec(schema)
|
||||||
return err
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return d.migrateAddColumns()
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateAddColumns adds columns to already-existing tables that predate
|
||||||
|
// their introduction. CREATE TABLE IF NOT EXISTS above only creates a
|
||||||
|
// table's initial shape, so columns added later (like calendars.color)
|
||||||
|
// need an explicit ALTER TABLE for databases created before this change.
|
||||||
|
func (d *DB) migrateAddColumns() error {
|
||||||
|
hasColumn := func(table, column string) (bool, error) {
|
||||||
|
rows, err := d.conn.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if name == column {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := hasColumn("calendars", "color")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking calendars.color column: %w", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
if _, err := d.conn.Exec(`ALTER TABLE calendars ADD COLUMN color TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||||
|
return fmt.Errorf("adding calendars.color column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-5
@@ -160,10 +160,24 @@ func (d *DB) UserCount() (int, error) {
|
|||||||
|
|
||||||
// -------- calendars --------
|
// -------- calendars --------
|
||||||
|
|
||||||
|
// Calendar is a calendar registration, including its display color.
|
||||||
|
type Calendar struct {
|
||||||
|
Name string
|
||||||
|
Color string
|
||||||
|
}
|
||||||
|
|
||||||
// CreateCalendar registers a new calendar owned by owner. Returns
|
// CreateCalendar registers a new calendar owned by owner. Returns
|
||||||
// ErrResourceExists if it already exists.
|
// ErrResourceExists if it already exists.
|
||||||
func (d *DB) CreateCalendar(owner, name string) error {
|
func (d *DB) CreateCalendar(owner, name string) error {
|
||||||
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name) VALUES (?, ?)`, owner, name)
|
return d.CreateCalendarWithColor(owner, name, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
|
||||||
|
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isUniqueConstraintErr(err) {
|
if isUniqueConstraintErr(err) {
|
||||||
return ErrResourceExists
|
return ErrResourceExists
|
||||||
@@ -173,6 +187,30 @@ func (d *DB) CreateCalendar(owner, name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCalendarColor updates a calendar's display color. Returns
|
||||||
|
// ErrResourceNotFound if it doesn't exist.
|
||||||
|
func (d *DB) SetCalendarColor(owner, name, color string) error {
|
||||||
|
res, err := d.conn.Exec(`UPDATE calendars SET color = ? WHERE owner = ? AND name = ?`, color, owner, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("setting calendar color: %w", err)
|
||||||
|
}
|
||||||
|
return requireRowsAffected(res, ErrResourceNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCalendarColor returns a calendar's display color. Returns
|
||||||
|
// ErrResourceNotFound if it doesn't exist.
|
||||||
|
func (d *DB) GetCalendarColor(owner, name string) (string, error) {
|
||||||
|
var color string
|
||||||
|
err := d.conn.QueryRow(`SELECT color FROM calendars WHERE owner = ? AND name = ?`, owner, name).Scan(&color)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrResourceNotFound
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("getting calendar color: %w", err)
|
||||||
|
}
|
||||||
|
return color, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteCalendar removes a calendar registration (not the underlying
|
// DeleteCalendar removes a calendar registration (not the underlying
|
||||||
// files/objects — callers are responsible for also removing those via
|
// files/objects — callers are responsible for also removing those via
|
||||||
// store.Store). Returns ErrResourceNotFound if it doesn't exist. Any
|
// store.Store). Returns ErrResourceNotFound if it doesn't exist. Any
|
||||||
@@ -197,10 +235,23 @@ func (d *DB) DeleteCalendar(owner, name string) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCalendars returns the names of all calendars owner has registered,
|
// ListCalendars returns all calendars owner has registered, sorted by name.
|
||||||
// sorted.
|
func (d *DB) ListCalendars(owner string) ([]Calendar, error) {
|
||||||
func (d *DB) ListCalendars(owner string) ([]string, error) {
|
rows, err := d.conn.Query(`SELECT name, color FROM calendars WHERE owner = ? ORDER BY name`, owner)
|
||||||
return listNames(d, `SELECT name FROM calendars WHERE owner = ? ORDER BY name`, owner)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing calendars: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var cals []Calendar
|
||||||
|
for rows.Next() {
|
||||||
|
var c Calendar
|
||||||
|
if err := rows.Scan(&c.Name, &c.Color); err != nil {
|
||||||
|
return nil, fmt.Errorf("scanning calendar: %w", err)
|
||||||
|
}
|
||||||
|
cals = append(cals, c)
|
||||||
|
}
|
||||||
|
return cals, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- address books --------
|
// -------- address books --------
|
||||||
|
|||||||
@@ -55,15 +55,15 @@ func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listing calendars: %w", err)
|
return nil, fmt.Errorf("listing calendars: %w", err)
|
||||||
}
|
}
|
||||||
for _, calName := range calNames {
|
for _, cal := range calNames {
|
||||||
card := templates.ResourceCard{Kind: "calendar", Name: calName}
|
card := templates.ResourceCard{Kind: "calendar", Name: cal.Name, Color: cal.Color}
|
||||||
shares, err := s.dbase.SharesOfCalendar(username, calName)
|
shares, err := s.dbase.SharesOfCalendar(username, cal.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("listing calendar shares", "error", err)
|
s.logger.Warn("listing calendar shares", "error", err)
|
||||||
}
|
}
|
||||||
for _, sh := range shares {
|
for _, sh := range shares {
|
||||||
card.Shares = append(card.Shares, templates.ShareRow{
|
card.Shares = append(card.Shares, templates.ShareRow{
|
||||||
ResourceName: calName,
|
ResourceName: cal.Name,
|
||||||
SharedWith: sh.SharedWith,
|
SharedWith: sh.SharedWith,
|
||||||
Permission: string(sh.Permission),
|
Permission: string(sh.Permission),
|
||||||
})
|
})
|
||||||
@@ -101,6 +101,9 @@ func (s *Server) resourceCardFor(username, kind, name string) (templates.Resourc
|
|||||||
|
|
||||||
var shares []templates.ShareRow
|
var shares []templates.ShareRow
|
||||||
if kind == "calendar" {
|
if kind == "calendar" {
|
||||||
|
if color, err := s.dbase.GetCalendarColor(username, name); err == nil {
|
||||||
|
card.Color = color
|
||||||
|
}
|
||||||
rows, err := s.dbase.SharesOfCalendar(username, name)
|
rows, err := s.dbase.SharesOfCalendar(username, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return card, err
|
return card, err
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import (
|
|||||||
// are safe as both a URL path segment and a filesystem directory name.
|
// are safe as both a URL path segment and a filesystem directory name.
|
||||||
var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
|
||||||
|
|
||||||
|
// hexColorRe validates the 6-digit hex color format produced by an HTML
|
||||||
|
// <input type="color">, e.g. "#3b82f6".
|
||||||
|
var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||||
|
|
||||||
// handleCalendarResource handles POST (create) and DELETE (remove) for
|
// handleCalendarResource handles POST (create) and DELETE (remove) for
|
||||||
// the current user's own calendars, mounted at /resources/calendar.
|
// the current user's own calendars, mounted at /resources/calendar.
|
||||||
func (s *Server) handleCalendarResource(w http.ResponseWriter, r *http.Request) {
|
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")
|
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) {
|
func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) {
|
||||||
username := userFromContext(r.Context())
|
username := userFromContext(r.Context())
|
||||||
|
|
||||||
@@ -50,7 +95,12 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str
|
|||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
var err error
|
var err error
|
||||||
if kind == "calendar" {
|
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 {
|
} else {
|
||||||
err = s.dbase.CreateAddressBook(username, name)
|
err = s.dbase.CreateAddressBook(username, name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ func TestCreateAndDeleteCalendarViaWebUI(t *testing.T) {
|
|||||||
t.Fatalf("ListCalendars: %v", err)
|
t.Fatalf("ListCalendars: %v", err)
|
||||||
}
|
}
|
||||||
found := false
|
found := false
|
||||||
for _, n := range names {
|
for _, c := range names {
|
||||||
if n == "vacation" {
|
if c.Name == "vacation" {
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,8 +67,8 @@ func TestCreateAndDeleteCalendarViaWebUI(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListCalendars: %v", err)
|
t.Fatalf("ListCalendars: %v", err)
|
||||||
}
|
}
|
||||||
for _, n := range names {
|
for _, c := range names {
|
||||||
if n == "vacation" {
|
if c.Name == "vacation" {
|
||||||
t.Fatalf("expected vacation calendar to be gone, got %v", names)
|
t.Fatalf("expected vacation calendar to be gone, got %v", names)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
|
|||||||
mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare))
|
mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare))
|
||||||
mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare))
|
mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare))
|
||||||
mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource))
|
mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource))
|
||||||
|
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
|
||||||
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
|
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
|
|||||||
+12
-7
@@ -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
|
// actually configured for username, to prevent sharing arbitrary/other
|
||||||
// users' resources via a forged form post.
|
// users' resources via a forged form post.
|
||||||
func (s *Server) ownsResource(username, kind, resource string) bool {
|
func (s *Server) ownsResource(username, kind, resource string) bool {
|
||||||
var (
|
|
||||||
list []string
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if kind == "calendar" {
|
if kind == "calendar" {
|
||||||
list, err = s.dbase.ListCalendars(username)
|
cals, err := s.dbase.ListCalendars(username)
|
||||||
} else {
|
if err != nil {
|
||||||
list, err = s.dbase.ListAddressBooks(username)
|
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 {
|
if err != nil {
|
||||||
s.logger.Warn("checking resource ownership", "kind", kind, "error", err)
|
s.logger.Warn("checking resource ownership", "kind", kind, "error", err)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type ShareRow struct {
|
|||||||
type ResourceCard struct {
|
type ResourceCard struct {
|
||||||
Kind string // "calendar" or "addressbook"
|
Kind string // "calendar" or "addressbook"
|
||||||
Name string
|
Name string
|
||||||
|
Color string // hex color like "#3b82f6"; only used for calendars
|
||||||
Shares []ShareRow
|
Shares []ShareRow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,11 @@ templ Dashboard(username string, resources []ResourceCard, sharedWithMe []Shared
|
|||||||
placeholder="e.g. work"
|
placeholder="e.g. work"
|
||||||
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Color</label>
|
||||||
|
<input name="color" type="color" value="#3b82f6"
|
||||||
|
class="w-12 h-9 rounded-md border-gray-300 border p-0.5"/>
|
||||||
|
</div>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
||||||
Add
|
Add
|
||||||
@@ -106,7 +112,24 @@ templ ResourceList(resources []ResourceCard) {
|
|||||||
templ ResourceCardView(r ResourceCard) {
|
templ ResourceCardView(r ResourceCard) {
|
||||||
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
|
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h2 class="font-medium">
|
<h2 class="font-medium flex items-center gap-2">
|
||||||
|
if r.Kind == "calendar" {
|
||||||
|
<form
|
||||||
|
hx-post="/web/resources/calendar/color"
|
||||||
|
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-trigger="change"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="name" value={ r.Name }/>
|
||||||
|
<input
|
||||||
|
name="color"
|
||||||
|
type="color"
|
||||||
|
value={ colorOrDefault(r.Color) }
|
||||||
|
title="Calendar color"
|
||||||
|
class="w-6 h-6 rounded border border-gray-300 p-0 align-middle"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
{ r.Name }
|
{ r.Name }
|
||||||
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span>
|
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -195,3 +218,10 @@ func resourceVals(name string) string {
|
|||||||
return `{"name": "` + name + `"}`
|
return `{"name": "` + name + `"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func colorOrDefault(c string) string {
|
||||||
|
if c == "" {
|
||||||
|
return "#3b82f6"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type ShareRow struct {
|
|||||||
type ResourceCard struct {
|
type ResourceCard struct {
|
||||||
Kind string // "calendar" or "addressbook"
|
Kind string // "calendar" or "addressbook"
|
||||||
Name string
|
Name string
|
||||||
|
Color string // hex color like "#3b82f6"; only used for calendars
|
||||||
Shares []ShareRow
|
Shares []ShareRow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars & address books</h1><div class=\"flex flex-col sm:flex-row gap-4 mb-6\"><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/calendar\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. work\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/addressbook\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New address book</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. contacts\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars & address books</h1><div class=\"flex flex-col sm:flex-row gap-4 mb-6\"><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/calendar\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. work\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Color</label> <input name=\"color\" type=\"color\" value=\"#3b82f6\" class=\"w-12 h-9 rounded-md border-gray-300 border p-0.5\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/addressbook\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New address book</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. contacts\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -104,7 +105,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -117,7 +118,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -130,7 +131,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 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))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -232,211 +233,256 @@ func ResourceCardView(r ResourceCard) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 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)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-medium\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-medium flex items-center gap-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
if r.Kind == "calendar" {
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form hx-post=\"/web/resources/calendar/color\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 110, Col: 12}
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
var templ_7745c5c3_Var10 string
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
return templ_7745c5c3_Err
|
if templ_7745c5c3_Err != nil {
|
||||||
}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 54}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
|
}
|
||||||
if templ_7745c5c3_Err != nil {
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
return templ_7745c5c3_Err
|
if templ_7745c5c3_Err != nil {
|
||||||
}
|
return templ_7745c5c3_Err
|
||||||
var templ_7745c5c3_Var11 string
|
}
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind)
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-swap=\"outerHTML\" hx-trigger=\"change\"><input type=\"hidden\" name=\"name\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 111, Col: 77}
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
var templ_7745c5c3_Var11 string
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
||||||
return templ_7745c5c3_Err
|
if templ_7745c5c3_Err != nil {
|
||||||
}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 123, Col: 53}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span></h2><button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
}
|
||||||
if templ_7745c5c3_Err != nil {
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
return templ_7745c5c3_Err
|
if templ_7745c5c3_Err != nil {
|
||||||
}
|
return templ_7745c5c3_Err
|
||||||
var templ_7745c5c3_Var12 string
|
}
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind))
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"> <input name=\"color\" type=\"color\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 115, Col: 40}
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
var templ_7745c5c3_Var12 string
|
||||||
if templ_7745c5c3_Err != nil {
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorOrDefault(r.Color))
|
||||||
return templ_7745c5c3_Err
|
if templ_7745c5c3_Err != nil {
|
||||||
}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 127, Col: 38}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-vals=\"")
|
}
|
||||||
if templ_7745c5c3_Err != nil {
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
return templ_7745c5c3_Err
|
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>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name))
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 116, Col: 34}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 133, Col: 12}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.")
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">Delete</button></div><ul class=\"divide-y divide-gray-100 mb-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span></h2><button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
||||||
|
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))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 138, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-vals=\"")
|
||||||
|
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))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 139, Col: 34}
|
||||||
|
}
|
||||||
|
_, 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=\"")
|
||||||
|
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.")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 142, Col: 104}
|
||||||
|
}
|
||||||
|
_, 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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, sh := range r.Shares {
|
for _, sh := range r.Shares {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
|
||||||
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, "</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_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, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var17 string
|
|
||||||
templ_7745c5c3_Var17, 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: 133, Col: 40}
|
|
||||||
}
|
|
||||||
_, 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, "\" hx-vals=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 151, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-target=\"")
|
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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 135, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 153, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 137, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 156, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">Remove</button></span></li>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-vals=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 157, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 158, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 160, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Remove</button></span></li>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(r.Shares) == 0 {
|
if len(r.Shares) == 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 151, Col: 34}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 174, Col: 34}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 152, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 175, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 155, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 178, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><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, 35, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -466,4 +512,11 @@ func resourceVals(name string) string {
|
|||||||
return `{"name": "` + name + `"}`
|
return `{"name": "` + name + `"}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func colorOrDefault(c string) string {
|
||||||
|
if c == "" {
|
||||||
|
return "#3b82f6"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
|
|||||||
+31
-8
@@ -80,7 +80,8 @@ Usage:
|
|||||||
nidusctl [-config config.yaml] user list
|
nidusctl [-config config.yaml] user list
|
||||||
nidusctl [-config config.yaml] user passwd <username> [--password PW]
|
nidusctl [-config config.yaml] user passwd <username> [--password PW]
|
||||||
|
|
||||||
nidusctl [-config config.yaml] calendar create <owner> <calendar>
|
nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB']
|
||||||
|
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color>
|
||||||
nidusctl [-config config.yaml] calendar delete <owner> <calendar>
|
nidusctl [-config config.yaml] calendar delete <owner> <calendar>
|
||||||
nidusctl [-config config.yaml] calendar list <owner>
|
nidusctl [-config config.yaml] calendar list <owner>
|
||||||
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write>
|
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write>
|
||||||
@@ -117,12 +118,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
|
|||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "create":
|
case "create":
|
||||||
if len(args) != 3 {
|
fs := newFlagSet("calendar create")
|
||||||
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar>")
|
color := fs.String("color", "", "hex color like #3b82f6 (optional)")
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
owner, calName := args[1], args[2]
|
if fs.NArg() != 2 {
|
||||||
if err := dbase.CreateCalendar(owner, calName); err != nil {
|
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--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)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
return 1
|
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)
|
fmt.Printf("created calendar %q for %s\n", calName, owner)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
case "color":
|
||||||
|
if len(args) != 4 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar color <owner> <calendar> <hex-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":
|
case "delete":
|
||||||
if len(args) != 3 {
|
if len(args) != 3 {
|
||||||
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar delete <owner> <calendar>")
|
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar delete <owner> <calendar>")
|
||||||
@@ -154,13 +173,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
owner := args[1]
|
owner := args[1]
|
||||||
names, err := dbase.ListCalendars(owner)
|
cals, err := dbase.ListCalendars(owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
for _, n := range names {
|
for _, c := range cals {
|
||||||
fmt.Println(n)
|
if c.Color != "" {
|
||||||
|
fmt.Printf("%s\t%s\n", c.Name, c.Color)
|
||||||
|
} else {
|
||||||
|
fmt.Println(c.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user