initial vibes
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# sqlite (default): sqlite:///data/skeps.db
|
||||||
|
# mysql: mysql://user:password@tcp(mysql:3306)/skeps
|
||||||
|
DATABASE_URL=sqlite:///data/skeps.db
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
*.db
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
backend/skeps-backend
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<title>skeps</title>
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#16a34a" />
|
||||||
|
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Skeps" />
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var m = document.cookie.match(/(?:^|; )skeps-theme=([a-z-]+)/)
|
||||||
|
if (m) document.documentElement.dataset.theme = m[1]
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1145
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 926 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "Skeps",
|
||||||
|
"short_name": "Skeps",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#16a34a",
|
||||||
|
"theme_color": "#16a34a",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
<script setup>
|
||||||
|
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 <ul> 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)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main v-if="loading"></main>
|
||||||
|
|
||||||
|
<main v-else-if="notFound">
|
||||||
|
<h1>skeps</h1>
|
||||||
|
<p>That list doesn't exist.</p>
|
||||||
|
<a href="/">Create a new list</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else>
|
||||||
|
<h1>skeps</h1>
|
||||||
|
|
||||||
|
<form @submit.prevent="addItem">
|
||||||
|
<input v-model="newName" type="text" placeholder="Add an item" />
|
||||||
|
<span class="dot" :style="{ background: newItemSublistColor }"></span>
|
||||||
|
<select class="item-sublist" v-model="newItemSublistId">
|
||||||
|
<option value="">No sublist</option>
|
||||||
|
<option v-for="s in sublists" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template v-for="group in groups" :key="groupKey(group)">
|
||||||
|
<div
|
||||||
|
v-if="sublists.length"
|
||||||
|
class="group-header"
|
||||||
|
:data-group-header="groupKey(group)"
|
||||||
|
@click="toggleCollapse(groupKey(group))"
|
||||||
|
>
|
||||||
|
<span class="triangle" :class="{ open: !collapsed.has(groupKey(group)) }"></span>
|
||||||
|
<span class="dot" :style="{ background: group.sublist ? group.sublist.color : 'transparent' }"></span>
|
||||||
|
<span class="group-name">{{ group.sublist ? group.sublist.name : 'No sublist' }}</span>
|
||||||
|
<span class="count">{{ group.items.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-show="!sublists.length || !collapsed.has(groupKey(group))">
|
||||||
|
<li v-for="item in group.items" :key="item.id" class="item" :data-item-id="item.id">
|
||||||
|
<div class="swipe-bg">
|
||||||
|
<span class="swipe-check">✓ Done</span>
|
||||||
|
<span class="swipe-delete">Delete ✕</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="swipe-content"
|
||||||
|
:class="{ done: item.done }"
|
||||||
|
@pointerdown="onSwipeStart"
|
||||||
|
@pointermove="onSwipeMove"
|
||||||
|
@pointerup="onSwipeEnd($event, item)"
|
||||||
|
@pointercancel="onSwipeEnd($event, item)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="handle"
|
||||||
|
@pointerdown.stop="onHandleDown($event, item)"
|
||||||
|
@pointermove.stop="onHandleMove"
|
||||||
|
@pointerup.stop="onHandleEnd"
|
||||||
|
@pointercancel.stop="onHandleEnd"
|
||||||
|
>⠿</span
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" :checked="item.done" @change="toggleDone(item)" />
|
||||||
|
{{ item.name }} <span class="qty">x{{ item.quantity }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<details class="settings">
|
||||||
|
<summary>Settings</summary>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Sharing</h3>
|
||||||
|
<div class="list-url">
|
||||||
|
<code>{{ listUrl }}</code>
|
||||||
|
<button type="button" @click="copyLink">{{ copied ? 'Copied!' : 'Copy link' }}</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="openList">
|
||||||
|
<input v-model="openListInput" type="text" placeholder="lion-fancy-sharpness" />
|
||||||
|
<button type="submit">Open list</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="openListError" class="error">{{ openListError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Theme</h3>
|
||||||
|
<select class="theme-picker" v-model="theme" @change="setTheme(theme)">
|
||||||
|
<option v-for="t in themes" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Sublists</h3>
|
||||||
|
<form class="add-sublist" @submit.prevent="addSublist">
|
||||||
|
<input v-model="newSublistName" type="text" placeholder="Add sublist" />
|
||||||
|
<input v-model="newSublistColor" type="color" />
|
||||||
|
<button type="submit">Add sublist</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--fg: #111827;
|
||||||
|
--muted: #888888;
|
||||||
|
--border: #dddddd;
|
||||||
|
--card-bg: #f3f4f6;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
--bg: #16181d;
|
||||||
|
--fg: #e5e7eb;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--border: #333844;
|
||||||
|
--card-bg: #21242c;
|
||||||
|
--danger: #f87171;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--bg: #16181d;
|
||||||
|
--fg: #e5e7eb;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--border: #333844;
|
||||||
|
--card-bg: #21242c;
|
||||||
|
--danger: #f87171;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
:root[data-theme='dark-blue'] {
|
||||||
|
--bg: #0b1a33;
|
||||||
|
--fg: #dbe7ff;
|
||||||
|
--muted: #7f93b8;
|
||||||
|
--border: #1f3a63;
|
||||||
|
--card-bg: #12264a;
|
||||||
|
--danger: #ff8080;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
:root[data-theme='bright-yellow'] {
|
||||||
|
--bg: #fff275;
|
||||||
|
--fg: #2b2200;
|
||||||
|
--muted: #7a6a00;
|
||||||
|
--border: #e0c200;
|
||||||
|
--card-bg: #fff9c4;
|
||||||
|
--danger: #b30000;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
:root[data-theme='solarized-light'] {
|
||||||
|
--bg: #fdf6e3;
|
||||||
|
--fg: #073642;
|
||||||
|
--muted: #93a1a1;
|
||||||
|
--border: #eee8d5;
|
||||||
|
--card-bg: #eee8d5;
|
||||||
|
--danger: #dc322f;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
:root[data-theme='solarized-dark'] {
|
||||||
|
--bg: #002b36;
|
||||||
|
--fg: #93c2c9;
|
||||||
|
--muted: #586e75;
|
||||||
|
--border: #073642;
|
||||||
|
--card-bg: #073642;
|
||||||
|
--danger: #e5615c;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
:root[data-theme='rose'] {
|
||||||
|
--bg: #fff0f3;
|
||||||
|
--fg: #6d0f24;
|
||||||
|
--muted: #b3718a;
|
||||||
|
--border: #ffd6e0;
|
||||||
|
--card-bg: #ffe1e8;
|
||||||
|
--danger: #c2185b;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
:root[data-theme='mint'] {
|
||||||
|
--bg: #f1fbf6;
|
||||||
|
--fg: #0b3d2e;
|
||||||
|
--muted: #5fa084;
|
||||||
|
--border: #cdeee0;
|
||||||
|
--card-bg: #e0f7ee;
|
||||||
|
--danger: #d32f2f;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
:root[data-theme='charcoal'] {
|
||||||
|
--bg: #1c1c1e;
|
||||||
|
--fg: #e5e5e7;
|
||||||
|
--muted: #8e8e93;
|
||||||
|
--border: #3a3a3c;
|
||||||
|
--card-bg: #2c2c2e;
|
||||||
|
--danger: #ff6961;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
:root[data-theme='ocean'] {
|
||||||
|
--bg: #eef6fb;
|
||||||
|
--fg: #0b3350;
|
||||||
|
--muted: #5f8aa8;
|
||||||
|
--border: #cfe6f3;
|
||||||
|
--card-bg: #dcf0fb;
|
||||||
|
--danger: #c0392b;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
:root[data-theme='plum'] {
|
||||||
|
--bg: #1c0f24;
|
||||||
|
--fg: #eadcf5;
|
||||||
|
--muted: #9c7fb3;
|
||||||
|
--border: #3a2451;
|
||||||
|
--card-bg: #2a1638;
|
||||||
|
--danger: #ff7676;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
:root[data-theme='sunset'] {
|
||||||
|
--bg: #fff3e6;
|
||||||
|
--fg: #4a2600;
|
||||||
|
--muted: #b8763b;
|
||||||
|
--border: #ffdcb0;
|
||||||
|
--card-bg: #ffe8cc;
|
||||||
|
--danger: #d84315;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: env(safe-area-inset-top) 1rem env(safe-area-inset-bottom);
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
.theme-picker {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.list-url {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.list-url code {
|
||||||
|
flex: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
form > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
form input[type='text'] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.75rem 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
.group-header.drop-target {
|
||||||
|
background: var(--card-bg);
|
||||||
|
outline: 2px dashed var(--muted);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.triangle {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
border-left: 5px solid var(--muted);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.triangle.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.group-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.swipe-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(to right, #16a34a 50%, var(--danger) 50%);
|
||||||
|
}
|
||||||
|
.swipe-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
background: var(--bg);
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
.swipe-content label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.swipe-content.done label {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.handle {
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 0.6rem;
|
||||||
|
height: 0.6rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.item-sublist {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 6.5rem;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
.add-sublist input[type='text'] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.add-sublist input[type='color'] {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.add-sublist input[type='color']::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.add-sublist input[type='color']::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.add-sublist input[type='color']::-moz-color-swatch {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.qty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.settings {
|
||||||
|
margin-top: 2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.settings-section {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-section:first-of-type {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.settings-section h3 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.settings-section form {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user