From 46cc687f576861ef8137de40cbdf8d67c3752ae3 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 15:08:12 +0200 Subject: [PATCH] initial vibes --- .env.example | 3 + .gitignore | 5 + backend/Dockerfile | 12 + backend/db.go | 97 +++ backend/go.mod | 26 + backend/go.sum | 55 ++ backend/handlers.go | 340 ++++++++ backend/hub.go | 48 ++ backend/main.go | 46 ++ backend/words.go | 23 + caddy/Caddyfile | 18 + caddy/Dockerfile | 10 + docker-compose.yml | 27 + frontend/index.html | 28 + frontend/package-lock.json | 1145 ++++++++++++++++++++++++++ frontend/package.json | 18 + frontend/public/apple-touch-icon.png | Bin 0 -> 4023 bytes frontend/public/favicon.png | Bin 0 -> 926 bytes frontend/public/icon-192.png | Bin 0 -> 4299 bytes frontend/public/icon-512.png | Bin 0 -> 11358 bytes frontend/public/manifest.webmanifest | 11 + frontend/src/App.vue | 827 +++++++++++++++++++ frontend/src/main.js | 4 + frontend/vite.config.js | 11 + 24 files changed, 2754 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/db.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/handlers.go create mode 100644 backend/hub.go create mode 100644 backend/main.go create mode 100644 backend/words.go create mode 100644 caddy/Caddyfile create mode 100644 caddy/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/favicon.png create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08ad6ba --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# sqlite (default): sqlite:///data/skeps.db +# mysql: mysql://user:password@tcp(mysql:3306)/skeps +DATABASE_URL=sqlite:///data/skeps.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5ca360 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +*.db +node_modules/ +frontend/dist/ +backend/skeps-backend diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d2196f3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /skeps-backend . + +FROM alpine:3.20 +WORKDIR /app +COPY --from=build /skeps-backend /app/skeps-backend +EXPOSE 8080 +ENTRYPOINT ["/app/skeps-backend"] diff --git a/backend/db.go b/backend/db.go new file mode 100644 index 0000000..68b8376 --- /dev/null +++ b/backend/db.go @@ -0,0 +1,97 @@ +package main + +import ( + "database/sql" + "fmt" + "strings" + + _ "github.com/go-sql-driver/mysql" + _ "modernc.org/sqlite" +) + +const sqliteSchema = ` +CREATE TABLE IF NOT EXISTS lists ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS sublists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id TEXT NOT NULL REFERENCES lists(id), + name TEXT NOT NULL, + color TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id TEXT NOT NULL REFERENCES lists(id), + name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + done INTEGER NOT NULL DEFAULT 0, + position REAL NOT NULL DEFAULT 0, + sublist_id INTEGER REFERENCES sublists(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);` + +const mysqlSchema = ` +CREATE TABLE IF NOT EXISTS lists ( + id VARCHAR(64) PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS sublists ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + list_id VARCHAR(64) NOT NULL, + name VARCHAR(255) NOT NULL, + color VARCHAR(32) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (list_id) REFERENCES lists(id) +); +CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + list_id VARCHAR(64) NOT NULL, + name VARCHAR(255) NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + done BOOLEAN NOT NULL DEFAULT FALSE, + position DOUBLE NOT NULL DEFAULT 0, + sublist_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (list_id) REFERENCES lists(id), + FOREIGN KEY (sublist_id) REFERENCES sublists(id) +);` + +func openDB(databaseURL string) (*sql.DB, error) { + driver, dsn, found := strings.Cut(databaseURL, "://") + if !found { + return nil, fmt.Errorf("DATABASE_URL must be in the form driver://dsn, got %q", databaseURL) + } + + var schema string + switch driver { + case "sqlite": + schema = sqliteSchema + case "mysql": + driver = "mysql" + schema = mysqlSchema + default: + return nil, fmt.Errorf("unsupported database driver %q, expected sqlite or mysql", driver) + } + + db, err := sql.Open(driver, dsn) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("connecting to database: %w", err) + } + + if _, err := db.Exec(schema); err != nil { + return nil, fmt.Errorf("running migration: %w", err) + } + + // Best-effort: adds columns for databases created before they existed. + // Fails harmlessly if a column is already there. + db.Exec("ALTER TABLE items ADD COLUMN position REAL NOT NULL DEFAULT 0") + db.Exec("ALTER TABLE items ADD COLUMN sublist_id INTEGER") + + return db, nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c56c7fe --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,26 @@ +module skeps-backend + +go 1.23 + +require ( + github.com/coder/websocket v1.8.15 + github.com/go-sql-driver/mysql v1.8.1 + modernc.org/sqlite v1.34.4 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..e29e954 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,55 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/handlers.go b/backend/handlers.go new file mode 100644 index 0000000..762c627 --- /dev/null +++ b/backend/handlers.go @@ -0,0 +1,340 @@ +package main + +import ( + "database/sql" + "encoding/json" + "net/http" + + "github.com/coder/websocket" +) + +type List struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` +} + +type Item struct { + ID int64 `json:"id"` + Name string `json:"name"` + Quantity int `json:"quantity"` + Done bool `json:"done"` + Position float64 `json:"position"` + SublistID *int64 `json:"sublist_id"` + CreatedAt string `json:"created_at"` +} + +type Sublist struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + CreatedAt string `json:"created_at"` +} + +type api struct { + db *sql.DB + hub *hub +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func (a *api) listExists(id string) (bool, error) { + var exists bool + err := a.db.QueryRow("SELECT EXISTS(SELECT 1 FROM lists WHERE id = ?)", id).Scan(&exists) + return exists, err +} + +func (a *api) createList(w http.ResponseWriter, r *http.Request) { + for attempt := 0; attempt < 5; attempt++ { + id := generateSlug() + if _, err := a.db.Exec("INSERT INTO lists (id) VALUES (?)", id); err == nil { + writeJSON(w, http.StatusCreated, List{ID: id}) + return + } + } + writeError(w, http.StatusInternalServerError, "failed to create list") +} + +func (a *api) getList(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("listId") + + var l List + err := a.db.QueryRow("SELECT id, created_at FROM lists WHERE id = ?", id).Scan(&l.ID, &l.CreatedAt) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "list not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, l) +} + +func (a *api) listSublists(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + + rows, err := a.db.Query("SELECT id, name, color, created_at FROM sublists WHERE list_id = ? ORDER BY id", listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + sublists := []Sublist{} + for rows.Next() { + var s Sublist + if err := rows.Scan(&s.ID, &s.Name, &s.Color, &s.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + sublists = append(sublists, s) + } + + writeJSON(w, http.StatusOK, sublists) +} + +func (a *api) createSublist(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + + exists, err := a.listExists(listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !exists { + writeError(w, http.StatusNotFound, "list not found") + return + } + + var in struct { + Name string `json:"name"` + Color string `json:"color"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if in.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if in.Color == "" { + writeError(w, http.StatusBadRequest, "color is required") + return + } + + res, err := a.db.Exec("INSERT INTO sublists (list_id, name, color) VALUES (?, ?, ?)", listID, in.Name, in.Color) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + id, _ := res.LastInsertId() + + var s Sublist + err = a.db.QueryRow("SELECT id, name, color, created_at FROM sublists WHERE id = ?", id). + Scan(&s.ID, &s.Name, &s.Color, &s.CreatedAt) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + a.hub.broadcast(listID) + writeJSON(w, http.StatusCreated, s) +} + +func (a *api) listSocket(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + + exists, err := a.listExists(listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !exists { + writeError(w, http.StatusNotFound, "list not found") + return + } + + // No auth beyond the list id itself, same as the REST endpoints, so any origin may connect. + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}}) + if err != nil { + return + } + defer c.CloseNow() + + a.hub.add(listID, c) + defer a.hub.remove(listID, c) + + ctx := c.CloseRead(r.Context()) + <-ctx.Done() +} + +func (a *api) listItems(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + + rows, err := a.db.Query("SELECT id, name, quantity, done, position, sublist_id, created_at FROM items WHERE list_id = ? ORDER BY position, id", listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + items := []Item{} + for rows.Next() { + var it Item + if err := rows.Scan(&it.ID, &it.Name, &it.Quantity, &it.Done, &it.Position, &it.SublistID, &it.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + items = append(items, it) + } + + writeJSON(w, http.StatusOK, items) +} + +func (a *api) createItem(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + + exists, err := a.listExists(listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if !exists { + writeError(w, http.StatusNotFound, "list not found") + return + } + + var in struct { + Name string `json:"name"` + Quantity int `json:"quantity"` + SublistID *int64 `json:"sublist_id"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if in.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if in.Quantity <= 0 { + in.Quantity = 1 + } + + var maxPosition sql.NullFloat64 + if err := a.db.QueryRow("SELECT MAX(position) FROM items WHERE list_id = ?", listID).Scan(&maxPosition); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + position := maxPosition.Float64 + 1 + + res, err := a.db.Exec("INSERT INTO items (list_id, name, quantity, position, sublist_id) VALUES (?, ?, ?, ?, ?)", listID, in.Name, in.Quantity, position, in.SublistID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + id, _ := res.LastInsertId() + + var it Item + err = a.db.QueryRow("SELECT id, name, quantity, done, position, sublist_id, created_at FROM items WHERE id = ?", id). + Scan(&it.ID, &it.Name, &it.Quantity, &it.Done, &it.Position, &it.SublistID, &it.CreatedAt) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + a.hub.broadcast(listID) + writeJSON(w, http.StatusCreated, it) +} + +func (a *api) updateItem(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + id := r.PathValue("id") + + var in struct { + Name *string `json:"name"` + Quantity *int `json:"quantity"` + Done *bool `json:"done"` + Position *float64 `json:"position"` + SublistID *int64 `json:"sublist_id"` + MoveSublist bool `json:"move_sublist"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if in.Name != nil { + if _, err := a.db.Exec("UPDATE items SET name = ? WHERE id = ? AND list_id = ?", *in.Name, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + if in.Quantity != nil { + if _, err := a.db.Exec("UPDATE items SET quantity = ? WHERE id = ? AND list_id = ?", *in.Quantity, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + if in.Done != nil { + if _, err := a.db.Exec("UPDATE items SET done = ? WHERE id = ? AND list_id = ?", *in.Done, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + if in.Position != nil { + if _, err := a.db.Exec("UPDATE items SET position = ? WHERE id = ? AND list_id = ?", *in.Position, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + if in.MoveSublist { + if _, err := a.db.Exec("UPDATE items SET sublist_id = ? WHERE id = ? AND list_id = ?", in.SublistID, id, listID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + + var it Item + err := a.db.QueryRow("SELECT id, name, quantity, done, position, sublist_id, created_at FROM items WHERE id = ? AND list_id = ?", id, listID). + Scan(&it.ID, &it.Name, &it.Quantity, &it.Done, &it.Position, &it.SublistID, &it.CreatedAt) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "item not found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + a.hub.broadcast(listID) + writeJSON(w, http.StatusOK, it) +} + +func (a *api) deleteItem(w http.ResponseWriter, r *http.Request) { + listID := r.PathValue("listId") + id := r.PathValue("id") + + res, err := a.db.Exec("DELETE FROM items WHERE id = ? AND list_id = ?", id, listID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeError(w, http.StatusNotFound, "item not found") + return + } + + a.hub.broadcast(listID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/hub.go b/backend/hub.go new file mode 100644 index 0000000..17b26d4 --- /dev/null +++ b/backend/hub.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "sync" + + "github.com/coder/websocket" +) + +type hub struct { + mu sync.Mutex + conns map[string]map[*websocket.Conn]struct{} +} + +func newHub() *hub { + return &hub{conns: make(map[string]map[*websocket.Conn]struct{})} +} + +func (h *hub) add(listID string, c *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + if h.conns[listID] == nil { + h.conns[listID] = make(map[*websocket.Conn]struct{}) + } + h.conns[listID][c] = struct{}{} +} + +func (h *hub) remove(listID string, c *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.conns[listID], c) + if len(h.conns[listID]) == 0 { + delete(h.conns, listID) + } +} + +func (h *hub) broadcast(listID string) { + h.mu.Lock() + conns := make([]*websocket.Conn, 0, len(h.conns[listID])) + for c := range h.conns[listID] { + conns = append(conns, c) + } + h.mu.Unlock() + + for _, c := range conns { + go c.Write(context.Background(), websocket.MessageText, []byte("changed")) + } +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..a274e81 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "log" + "net/http" + "os" +) + +func main() { + databaseURL := os.Getenv("DATABASE_URL") + if databaseURL == "" { + databaseURL = "sqlite://./skeps.db" + } + + db, err := openDB(databaseURL) + if err != nil { + log.Fatalf("database: %v", err) + } + defer db.Close() + + a := &api{db: db, hub: newHub()} + + mux := http.NewServeMux() + mux.HandleFunc("POST /api/lists", a.createList) + mux.HandleFunc("GET /api/lists/{listId}", a.getList) + mux.HandleFunc("GET /api/lists/{listId}/ws", a.listSocket) + mux.HandleFunc("GET /api/lists/{listId}/sublists", a.listSublists) + mux.HandleFunc("POST /api/lists/{listId}/sublists", a.createSublist) + mux.HandleFunc("GET /api/lists/{listId}/items", a.listItems) + mux.HandleFunc("POST /api/lists/{listId}/items", a.createItem) + mux.HandleFunc("PATCH /api/lists/{listId}/items/{id}", a.updateItem) + mux.HandleFunc("DELETE /api/lists/{listId}/items/{id}", a.deleteItem) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("skeps backend listening on :%s", port) + if err := http.ListenAndServe(":"+port, mux); err != nil { + log.Fatal(err) + } +} diff --git a/backend/words.go b/backend/words.go new file mode 100644 index 0000000..c17cd94 --- /dev/null +++ b/backend/words.go @@ -0,0 +1,23 @@ +package main + +import "math/rand/v2" + +var words = []string{ + "lion", "tiger", "eagle", "falcon", "otter", "badger", "raven", "heron", "panther", "wolf", + "cobra", "viper", "shark", "whale", "dolphin", "lynx", "puma", "hawk", "sparrow", "robin", + "maple", "cedar", "willow", "birch", "aspen", "pine", "oak", "fern", "moss", "ivy", + "river", "canyon", "meadow", "valley", "summit", "glacier", "desert", "forest", "harbor", "island", + "comet", "meteor", "nebula", "galaxy", "eclipse", "aurora", "horizon", "zenith", "orbit", "cosmos", + "amber", "coral", "jade", "onyx", "opal", "pearl", "ruby", "topaz", "quartz", "granite", + "fancy", "bright", "quiet", "swift", "brave", "gentle", "clever", "curious", "eager", "fierce", + "golden", "silver", "crimson", "azure", "violet", "scarlet", "emerald", "ivory", "copper", "bronze", + "sharpness", "wisdom", "courage", "harmony", "clarity", "serenity", "wonder", "spirit", "journey", "rhythm", + "lantern", "compass", "anchor", "beacon", "voyage", "bridge", "castle", "cottage", "orchard", "meadowlark", + "thunder", "breeze", "frost", "ember", "blossom", "shadow", "whisper", "ripple", "spark", "drift", + "velvet", "marble", "linen", "cotton", "flannel", "satin", "canvas", "denim", "wool", "silk", + "cherry", "walnut", "hazel", "juniper", "olive", "cypress", "sequoia", "laurel", "magnolia", "poppy", +} + +func generateSlug() string { + return words[rand.IntN(len(words))] + "-" + words[rand.IntN(len(words))] + "-" + words[rand.IntN(len(words))] +} diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..7cc5ea0 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,18 @@ +sandbox.clearsky.dev { + handle /api/* { + reverse_proxy backend:8080 + } + + handle /assets/* { + root * /srv + header Cache-Control "public, max-age=31536000, immutable" + file_server + } + + handle { + root * /srv + header Cache-Control "no-cache" + try_files {path} /index.html + file_server + } +} diff --git a/caddy/Dockerfile b/caddy/Dockerfile new file mode 100644 index 0000000..4805b4a --- /dev/null +++ b/caddy/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine AS build +WORKDIR /src +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ . +RUN npm run build + +FROM caddy:2-alpine +COPY caddy/Caddyfile /etc/caddy/Caddyfile +COPY --from=build /src/dist /srv diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ceb7df3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + backend: + build: ./backend + environment: + DATABASE_URL: ${DATABASE_URL:-sqlite:///data/skeps.db} + volumes: + - backend-data:/data + restart: unless-stopped + + caddy: + build: + context: . + dockerfile: caddy/Dockerfile + ports: + - "80:80" + - "443:443" + volumes: + - caddy-data:/data + - caddy-config:/config + depends_on: + - backend + restart: unless-stopped + +volumes: + backend-data: + caddy-data: + caddy-config: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..28ff0a3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,28 @@ + + + + + + skeps + + + + + + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4093c10 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1145 @@ +{ + "name": "skeps-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skeps-frontend", + "version": "0.0.1", + "dependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.4.21" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..db8d817 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "skeps-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.4.21" + } +} diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1f8b0bf9284845b8b576372b4e090adf999e620d GIT binary patch literal 4023 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 aNtVaxT0ZaKAWj;{$@H|1wdx)@#ry|EVaTrl literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..1d8623899d312ad03b35f5212999d889a0c2b9ff GIT binary patch literal 926 zcmV;P17ZA$P)XQLIwMluC=(x@i%*aU%smbfLHtDuQ&;MR(nZ3q`2ls#shZ z)Rp*$f}jhjMiCWkidZOuX)I0}OB$O=X6C(jkBiqC66VdHC#~7^+uqAP-~G;azWbis z`TkSNCcPM(5RtzGNvyvgF-`L|+ z`(B`jfRViTfxGr_X!0>a>vQ(6U-;_skBs>-S6ly(xOAl3b<~S(NG+(K+B$IjZloSB~?asjXIewLkE?m<;^B?cf@&+u*i9B1agqw2*yN))sp zm)EJ|)WPG-EX?x9N}bq7I27M{gf>9cAu`y*9Ri-QxfBKHA)q({>+|W{St?${a(jiD z`Z*LOFdlE8|7heEbkJ!*kq@LO2?e2y0DmDJrth{}M<8)2+v9OSSX^7mjl}wBDg(Kp;|K(-7?#@&UYa`0 z-fa)zP%d1XTPLsHl zHy(cl-&oo%;n<$%*|&XH_o*F(Of#%lrZ0ZMTeH)Q2Q@MsLO{q}JKwm%>-%3u)p2?C zZxWZWC5Sls{Ygy8Z&{~~nqMt!UEvsZWV(NB5&`B`>Rkh_Umq1RU~KzN0l>xOO8|s6 z*l@_81IuFII~&;$D1YOdM&MQ!?qJjZjBXS77s}gzfaj9xssI2007*qoM6N<$f;NG{ AFaQ7m literal 0 HcmV?d00001 diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..33d564aaab7c943a71f35a9b566a0cf3bf69aded GIT binary patch literal 4299 zcmchbXEq`*;8D^UC<%T}B`$5C8x$>gj6TCqJA2 zUUW3%`~4{oS^$7KRZr`VSrGd7bZ`pya&YJN=IH?xi_K({RA!+O@BuB%L!8fiU1 z)VLs^)twbEFHeu$%BEHiGNk1DB22;jI1NBo{r_@2PY9z(9E2B{6mW(hFQJ66KgLf! zwf`Ad1&D&+V$ajU-aV8p>C{m!mKQGsaX|b59b0Hdq=tQ^{oVUN>dHHz;lF(9IwhCD zEngo+;4*y8mnRY-K>>>w{~$G(9g1PhZN^d=dR4G6q>2$CUf)Ubd&T0dMtda-f;VkW z&on4f_c>(^mxxGI4aIcdgg~K%)}#y{;m5H7=M5^VNBD{+Sr$BS8}y2VLhz8oSn8oGSXA z(4QSuHnmtr-sh~Mti++^&$Tu}&tol%*_l=RqyYX;edK`J1)$A+&nL_EfiB58fn439 zJ3<3V8eHLOn>kv_f>@epZ3?)URgtg;rgQX+>YAO5ZVYFs8Np_L$F~kk@im6w0VPI^ z8aih6efbv8Ph=m}B!2k_k~OLt*~>}!AfNP|R6`8huxPG_!=m|gAR?uP{U++8#Yl|2 zTR_0I{s{oXqhEEn&A_MaR<0+h6iI8h(C*3SZmN5COyud;0zKwa#>8+AiAEiQHfk$~ z{(1 z1pM8zb@YmarF)!wG^;r40}*ZjRB#ORGSov{i|o65ol$`WUx<_ zjU>P1=ox~i~p&smw{m3?a52LMF^me7;?e5FNPlvf6dp8b-@JxoX$sl5S>y9 zR-C1xhGXliQM$1Pl7@oV>!$Fl!3X+>vW?I9h*p$f<385+;mf>867`9I;sl4gEAX;S zS*c}Zv9lxtpzeaS> zi-O+b1cku|mJSb??!1`NFsgO%Ch#9f7-uInbl~6Q7or)}I|Om>K_wZ&zFM93Q`_B* zWIT|o?}l4G8~GECi&ngxLkDQ!(dO@j?KP&l(XZro^)maa={b$s`qXUFXzvhJ9p?YU zrlKz+aT&U?x3Km~sAK|iq~sZ!$X3zpr(DOJS#@_qA$`$N^Wg4>Z%|HoedEbTH!?zO z=Newc`WbWiE{X7VTu)InN0MLKTwy|=bdXBHV83N) z0jIW8;>|FqcqtX&IK{~;fEhD^(os~RXIogj@t=-~{@H7kJEv4)HyPQTYwTqDXHu;j z+KvI#;st7wrv2iKFc%D{gRca{Rx{Qu$@!e){`{f!j3ITi%vS9C&L@x zW@3TT0gc4&s!e0y^iM$u-aH7uPKY{xm`@LJbUvOk-yWYZ)_|qs8Qx z7T6H5d-q=Mo_tYh2;Nven?UP}(rjmW`nXi`8^{06FjeVvmW4!Sy>!gH4U=*8pRk=` zhX$vE=*Vk)xR?7X3Pp$Bkv0yD2| zi$$0<6_uzde@ff~?930U++GXvJ%?E4SnzP`_OylVmpUfMf0@FiW_~jN;UD^D0jUA` zrnmw~oXLI^qARw1w%Ld0De(MhtX(J?txbvfknp&~45AJ7xM9ZoXIxh0etwPf{=LOW zxOAUM+OAibez)tJ_TsvZK90LyrkfssfJf^$#}K1=71WkxZ;a2kRu1Y7-cd4S^l}jV zr+BxPHC2r&1idLd8|(2I0e|zZVa#vipoen?6_D z_J=vMZB;u@wFVo_G@Gtve*0lpE>7P}mww;OptVIZ4Zpr6Jr-la_IsnQOR2ms?%CIc zBB{A9|LXO%OgRbqX3+$B(91Z{ui=JPu61VJo>}!k2gW`Vma6o$%ig}qTx1PE7~2^R z(BxS)q19c}QJV#A8enG8KBlo-hEBbY2Rg!X1p!6f6=SxzC8@AlPq9~q%h~_XEJgP+ z-tM|WXx!oF30m_ZEwYKg_zzM1DTqB%bKAw)(SQQc3V5e|%ooP4>MwvLIWw<*IN;vM z?X$735e1R^Y3BE|y0w|_LHyMD4S?%Yu-Xv66VpDgz%@ifdogd9-?*k-#K+I$N~b!X zME@7Dkk@^|7b8Pj}FVMFr-&8m2dIFh(~_4CHfwrf~E(%GSvnJ=aWko0Zv@q-X}JZIbq6ctz~hcfB<(h z>#VnTJ>aXlNfwr&{0n*a=dc9)HdSQe1ygt!gzO&099lz19iHgl6qJ~W*BNguU7WX} zO>Jl8o!>Y!4;oGo5pb({{GsjgpV8zUr$Z~{1@rr~nRD%g6%PR4COvD~U=G6|Hydpx z-=Uyw@YTH)?MuEKUgqi8?SA0#_yKm>#2s>8ft#Mh7tHa5izg8KyvnT=(;e zk(OOzN%Zz*m1ytO{!h&n-2|a#DDNHm3GR9t=B~|-s-^#^2nJ|Z83!n<3suNwyclMC z8JAH;e|hm8lZ5KTzfM{cV!XN>rFYlwSGp2;=rHIZ(yWMIBjxJUgjjHocHdMEhBb~XEN-`Gdu zZDBJ(Yk_Ghho+@F(xtOeEjM5>Y;8X8Y02%Cv**{Hu4Lo0gclSR8sdK=G2ci?q!#Dc z<|zbfJ28@FMpsPYzC3;Dy|-;0asxlexsa_Hl=^4@Qt^=i#KSYZ$M(|PX5BwkgrYC7 zYR(-$MnRE^YDG)}OzOmu`yv-%Kb~@r1u&0UvGr$l2F{)tRi@^tulM2sBmN+(h!DlP z5@#y?PmhZv-Gtkc6j>B35#Vi7ePFQT@LHsQE1At?0j-O#t5A3C>gNzEW3-)qloX%< z&~)#1!?*x1H~+Mz*bj%qY~RCC|K-s)j%dUS98b?$ zA(CQXtuf|V+#8k-q2P1ZxBAYKOD{E7uF{$i#6)zSxQBa5d97?bNdDll%T=c(B%(Rz z0k9$WKkb{x?{oZEKK3>uY@5*ML#@|$Ew^UJYZ1FSH}7ujQ?nvjL@{OA>8^dbHZRsK zWX4h+LNp7Yx`sMXQSaaD)xPtluz%8hJy|p;W#W6C>N@-Cq6`61R>|&)%Ul(^v-3+O zS2Vzicos1V4l;x^n6V|!0vA|E`CZ{YVflwo?~)o}L)t((v*xR7kA}+W23-j9=WWRBmnv9x~ru+cw`(4EG?ysVuz6@iNE*XWS zkv9wGwYU&YK7Ni@I5v!(H9pqS+T4@@7|9O%Izs}kd z2=;Vf#C*_olFOTJKzMVn%~-D{ot$1RLuQmkO*=j$3H8$ygb|HJh1qtqn;xA{((V0J zsRe3}3UZIg(%WruEZSGzDRMz&clvYBHV`f|T%l14 zjpjpi;SZrjjyramJgAW;QOe}$??-Y#1$}-2HneX!mPw>4GS$=cc>J>BI#IY#Emzgjd-H8QFO^3ytPnUYyyRfPpB^qE|BOJ@lUeQwpo zn&J68_p`-0m-Lqlad5QxbwZrI5*OgB=3u^gS(bQOJQ(z{p|BGpym%f_MYdNVw^X9E z&Jr0)Wxbr)ty!#>PKISeyVNz4P2o^}&)2Uiz6+2oX@7vyn(`WRg1x*cC&49Zm!izi uUqE|(7HxfrbzxEPKXs=6^WKiTpyh60Bw@-Pl{~A7ow>X}pU><4THXowjCD^O<2wcb zzzKc5J7xgD0)Aux*bjq$w!%m00B~JY|IST|@SI<>@YhbE4=C#cJCTHdH*;THW#3#E zxjp-w75%R+)<(?Hqjsi+!|yVGnP$8rYP3aH-YRZ-dij2R{d7tFwwHq0IZ2jr1BcVM zL^?<;*Dg(euwaupByv|zb|nD1xZ8%rrGSl>R~wK1;k`lhv2(K~ntb=0 z)Y~wVAz&oM#RFX6@M5CTtO5XRAB7A$Uv_~RWB4jj3b;01KhvA})=}?+9+4SW+jeB} zo+H62WP*pF8N#i^ncGXJU1aW$8gOZbJ@(wtg*Cy_WY3#k4N|HLZ7Kmntro})@06MM zzgikzXjBC-$%?aWqzC)I%OYQ;IG-(4D}(?q1Kro^OV*n*0>`b3HOB7wS(M-&H8Zv= z1Sh`STC5xplgiClN%Y%FLon6@C_}ny^mh$bN5)QNzM?NTO9367?6JsNJbJ-9h`92M z+j3=e4vs0J8{wd|DyYhhi&(7L;u#wW;lkW8q3G2$>1DiA#e`6?z?i+P-^5|yJbx7+ z`=$FTwaknXI?_Af<3=%qQe2T!zm_9g&bK|3EHfEHQ3gZ%G{q=w{_qXXeyK-BGwf`@ zec#nXFWq$b(8**x%E}XbY|Q?$&uX25rVs-UI?im|8VDe5DM52i zWU8q`g;&h3uS3(4I@N)R50BX1_<;BJ2-WiuV1~gQj4Ul{=&{qrOklJgGvgI#%&(o58+fm3#diot zhE4tA^rK{z)e7fVnQkDI%0AVP#FPes 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 VYTa0UF!TWQwT +import { ref, computed, onMounted } from 'vue' + +const loading = ref(true) +const notFound = ref(false) +const listId = ref('') +const listUrl = ref('') +const copied = ref(false) + +const items = ref([]) +const newName = ref('') + +const sublists = ref([]) +const newSublistName = ref('') +const newSublistColor = ref('#16a34a') +const newItemSublistId = ref('') +const collapsed = ref(new Set()) + +function groupKey(group) { + return group.sublist ? group.sublist.id : 'none' +} + +function toggleCollapse(key) { + const next = new Set(collapsed.value) + next.has(key) ? next.delete(key) : next.add(key) + collapsed.value = next +} + +const groups = computed(() => { + if (sublists.value.length === 0) { + return [{ sublist: null, items: items.value }] + } + const bySublist = new Map(sublists.value.map((s) => [s.id, { sublist: s, items: [] }])) + const none = { sublist: null, items: [] } + for (const item of items.value) { + const bucket = item.sublist_id != null ? bySublist.get(item.sublist_id) : null + ;(bucket || none).items.push(item) + } + return [...bySublist.values(), none] +}) + +const newItemSublistColor = computed(() => { + if (!newItemSublistId.value) return 'transparent' + const s = sublists.value.find((s) => s.id === Number(newItemSublistId.value)) + return s ? s.color : 'transparent' +}) + +const openListInput = ref('') +const openListError = ref('') + +const themes = [ + { value: 'system', label: 'Auto' }, + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'dark-blue', label: 'Dark Blue' }, + { value: 'bright-yellow', label: 'Bright Yellow' }, + { value: 'solarized-light', label: 'Solarized Light' }, + { value: 'solarized-dark', label: 'Solarized Dark' }, + { value: 'rose', label: 'Rose' }, + { value: 'mint', label: 'Mint' }, + { value: 'charcoal', label: 'Charcoal' }, + { value: 'ocean', label: 'Ocean' }, + { value: 'plum', label: 'Plum' }, + { value: 'sunset', label: 'Sunset' }, +] + +const themeCookieMatch = document.cookie.match(/(?:^|; )skeps-theme=([a-z-]+)/) +const theme = ref(themeCookieMatch ? themeCookieMatch[1] : 'system') + +function setTheme(value) { + theme.value = value + if (value === 'system') { + document.documentElement.removeAttribute('data-theme') + document.cookie = 'skeps-theme=; path=/; max-age=0' + } else { + document.documentElement.dataset.theme = value + document.cookie = `skeps-theme=${value}; path=/; max-age=31536000; SameSite=Lax` + } +} + +async function init() { + const pathId = window.location.pathname.replace(/^\/+/, '') + + if (!pathId) { + const res = await fetch('/api/lists', { method: 'POST' }) + const list = await res.json() + window.location.replace('/' + list.id) + return + } + + const res = await fetch(`/api/lists/${pathId}`) + if (res.status === 404) { + notFound.value = true + loading.value = false + return + } + + listId.value = pathId + listUrl.value = window.location.origin + '/' + pathId + await refresh() + loading.value = false + connectSocket() +} + +let socket = null + +function connectSocket() { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + socket = new WebSocket(`${proto}://${window.location.host}/api/lists/${listId.value}/ws`) + socket.addEventListener('message', refresh) + socket.addEventListener('close', () => setTimeout(connectSocket, 2000)) +} + +// Mobile browsers suspend JS and can drop the socket without firing 'close' +// while backgrounded, so re-sync and reconnect whenever the app comes back. +function resync() { + if (!listId.value) return + refresh() + if (!socket || socket.readyState === WebSocket.CLOSED) { + connectSocket() + } +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') resync() +}) +window.addEventListener('pageshow', resync) +window.addEventListener('online', resync) + +async function loadItems() { + const res = await fetch(`/api/lists/${listId.value}/items`) + items.value = await res.json() +} + +async function loadSublists() { + const res = await fetch(`/api/lists/${listId.value}/sublists`) + sublists.value = await res.json() +} + +async function refresh() { + await Promise.all([loadItems(), loadSublists()]) +} + +async function addSublist() { + if (!newSublistName.value.trim()) return + const res = await fetch(`/api/lists/${listId.value}/sublists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newSublistName.value.trim(), color: newSublistColor.value }), + }) + sublists.value.push(await res.json()) + newSublistName.value = '' +} + +async function addItem() { + if (!newName.value.trim()) return + const res = await fetch(`/api/lists/${listId.value}/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newName.value.trim(), + sublist_id: newItemSublistId.value ? Number(newItemSublistId.value) : null, + }), + }) + items.value.push(await res.json()) + newName.value = '' +} + +async function toggleDone(item) { + const res = await fetch(`/api/lists/${listId.value}/items/${item.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ done: !item.done }), + }) + Object.assign(item, await res.json()) +} + +async function removeItem(item) { + await fetch(`/api/lists/${listId.value}/items/${item.id}`, { method: 'DELETE' }) + items.value = items.value.filter((i) => i.id !== item.id) +} + +const SWIPE_THRESHOLD = 70 +const SWIPE_MAX = 140 +let drag = null + +function onSwipeStart(e) { + drag = { startX: e.clientX, dx: 0, el: e.currentTarget } + drag.el.setPointerCapture(e.pointerId) + drag.el.style.transition = 'none' +} + +function onSwipeMove(e) { + if (!drag) return + const dx = e.clientX - drag.startX + drag.dx = Math.max(-SWIPE_MAX, Math.min(SWIPE_MAX, dx)) + drag.el.style.transform = `translateX(${drag.dx}px)` +} + +function onSwipeEnd(e, item) { + if (!drag) return + const { dx, el } = drag + el.style.transition = 'transform 0.2s ease' + + if (dx <= -SWIPE_THRESHOLD) { + el.style.transform = `translateX(-100%)` + setTimeout(() => removeItem(item), 150) + } else if (dx >= SWIPE_THRESHOLD) { + el.style.transform = 'translateX(0)' + toggleDone(item) + } else { + el.style.transform = 'translateX(0)' + } + drag = null +} + +let dragReorder = null + +// A flattened, visually-ordered list of every header and (visible) item on the +// page, used to find which sublist section and slot the pointer is over — +// this is what lets a drag cross from one sublist's
    into another's. +function buildAnchors() { + const anchors = [] + for (const group of groups.value) { + const key = groupKey(group) + const headerEl = document.querySelector(`[data-group-header="${key}"]`) + if (headerEl) anchors.push({ type: 'header', key, rect: headerEl.getBoundingClientRect() }) + + const isCollapsed = sublists.value.length > 0 && collapsed.value.has(key) + if (isCollapsed) continue + for (const item of group.items) { + const el = document.querySelector(`[data-item-id="${item.id}"]`) + if (el) anchors.push({ type: 'item', key, item, el, rect: el.getBoundingClientRect() }) + } + } + return anchors +} + +function findTarget(anchors, pointerY) { + let targetKey = anchors.find((a) => a.type === 'header')?.key ?? groupKey(groups.value[0]) + let insertAfterItemId = null + for (const a of anchors) { + if (a.rect.top + a.rect.height / 2 > pointerY) break + targetKey = a.key + insertAfterItemId = a.type === 'header' ? null : a.item.id + } + return { targetKey, insertAfterItemId } +} + +function setDropTarget(key) { + document.querySelectorAll('.group-header').forEach((h) => h.classList.remove('drop-target')) + const headerEl = key != null ? document.querySelector(`[data-group-header="${key}"]`) : null + if (headerEl) headerEl.classList.add('drop-target') +} + +function onHandleDown(e, item) { + const li = e.currentTarget.closest('li') + const height = li.getBoundingClientRect().height + const originalKey = item.sublist_id != null ? item.sublist_id : 'none' + const ownGroup = groups.value.find((g) => groupKey(g) === originalKey) + const ownIdx = ownGroup.items.findIndex((i) => i.id === item.id) + const originalPrecedingId = ownIdx > 0 ? ownGroup.items[ownIdx - 1].id : null + + const anchors = buildAnchors().filter((a) => !(a.type === 'item' && a.item.id === item.id)) + dragReorder = { + item, + el: li, + startY: e.clientY, + height, + anchors, + originalKey, + originalPrecedingId, + targetKey: originalKey, + insertAfterItemId: originalPrecedingId, + shiftedEls: [], + } + li.style.zIndex = 5 + li.style.position = 'relative' + li.style.boxShadow = '0 4px 10px rgba(0, 0, 0, 0.2)' + e.currentTarget.setPointerCapture(e.pointerId) +} + +// Shifts the sibling items around the current drop slot to visually open a +// gap: within one group when reordering, or close the old gap and open a +// new one across two groups when the drag crosses a sublist boundary. +function updateGap() { + const { anchors, originalKey, originalPrecedingId, targetKey, insertAfterItemId, height, shiftedEls } = dragReorder + shiftedEls.forEach((el) => { + el.style.transform = '' + el.style.transition = '' + }) + const nextShifted = [] + + const shift = (el, dir) => { + el.style.transition = 'transform 0.15s ease' + el.style.transform = `translateY(${dir * height}px)` + nextShifted.push(el) + } + + const indexOf = (groupAnchors, afterId) => (afterId == null ? 0 : groupAnchors.findIndex((a) => a.item.id === afterId) + 1) + + if (targetKey === originalKey) { + const groupAnchors = anchors.filter((a) => a.type === 'item' && a.key === originalKey) + const origIdx = indexOf(groupAnchors, originalPrecedingId) + const newIdx = indexOf(groupAnchors, insertAfterItemId) + groupAnchors.forEach((a, i) => { + if (newIdx > origIdx && i >= origIdx && i < newIdx) shift(a.el, -1) + if (newIdx < origIdx && i >= newIdx && i < origIdx) shift(a.el, 1) + }) + } else { + const origGroupAnchors = anchors.filter((a) => a.type === 'item' && a.key === originalKey) + const origIdx = indexOf(origGroupAnchors, originalPrecedingId) + origGroupAnchors.forEach((a, i) => { + if (i >= origIdx) shift(a.el, -1) + }) + + const targetGroupAnchors = anchors.filter((a) => a.type === 'item' && a.key === targetKey) + const newIdx = indexOf(targetGroupAnchors, insertAfterItemId) + targetGroupAnchors.forEach((a, i) => { + if (i >= newIdx) shift(a.el, 1) + }) + } + + dragReorder.shiftedEls = nextShifted +} + +function onHandleMove(e) { + if (!dragReorder) return + const dy = e.clientY - dragReorder.startY + dragReorder.el.style.transform = `translateY(${dy}px)` + + const { targetKey, insertAfterItemId } = findTarget(dragReorder.anchors, e.clientY) + dragReorder.targetKey = targetKey + dragReorder.insertAfterItemId = insertAfterItemId + setDropTarget(targetKey) + updateGap() +} + +function onHandleEnd() { + if (!dragReorder) return + const { el, item, targetKey, insertAfterItemId, shiftedEls } = dragReorder + el.style.transform = '' + el.style.transition = '' + el.style.zIndex = '' + el.style.position = '' + el.style.boxShadow = '' + shiftedEls.forEach((s) => { + s.style.transform = '' + s.style.transition = '' + }) + setDropTarget(null) + dragReorder = null + + const targetGroup = groups.value.find((g) => groupKey(g) === targetKey) + if (!targetGroup) return + const targetSublistId = targetGroup.sublist ? targetGroup.sublist.id : null + + const siblings = targetGroup.items.filter((i) => i.id !== item.id) + const insertIdx = insertAfterItemId == null ? 0 : siblings.findIndex((i) => i.id === insertAfterItemId) + 1 + + const prev = siblings[insertIdx - 1] + const next = siblings[insertIdx] + let position + if (prev && next) position = (prev.position + next.position) / 2 + else if (prev) position = prev.position + 1 + else if (next) position = next.position - 1 + else position = 0 + + if (position === item.position && targetSublistId === item.sublist_id) return + + item.position = position + item.sublist_id = targetSublistId + + fetch(`/api/lists/${listId.value}/items/${item.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ position, sublist_id: targetSublistId, move_sublist: true }), + }) +} + +function openList() { + const slug = openListInput.value.trim().toLowerCase() + if (!/^[a-z]+-[a-z]+-[a-z]+$/.test(slug)) { + openListError.value = 'Enter the 3 words, e.g. lion-fancy-sharpness' + return + } + window.location.href = '/' + slug +} + +async function copyLink() { + try { + await navigator.clipboard.writeText(listUrl.value) + copied.value = true + setTimeout(() => (copied.value = false), 1500) + } catch { + // clipboard access denied; nothing to do + } +} + +onMounted(init) + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..01433bc --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..728294f --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': { target: 'http://localhost:8080', ws: true }, + }, + }, +})