mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-28 20:06:02 +00:00
update settings page, move admin-panel to tailwind/daisy
This commit is contained in:
+3
-2
@@ -2,11 +2,12 @@ root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
pre_cmd = ["go generate ./pkg/hold/..."]
|
||||
cmd = "go build -buildvcs=false -o ./tmp/atcr-hold ./cmd/hold"
|
||||
entrypoint = ["./tmp/atcr-hold" , "serve"]
|
||||
include_ext = ["go"]
|
||||
include_ext = ["go", "html", "css", "js"]
|
||||
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview", "node_modules"]
|
||||
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$"]
|
||||
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$", "public/css/style\\.css$", "public/icons\\.svg$"]
|
||||
delay = 3000
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
|
||||
@@ -6,12 +6,12 @@ tmp_dir = "tmp"
|
||||
poll = true
|
||||
poll_interval = 500
|
||||
# Pre-build: generate assets if missing (each string is a shell command)
|
||||
pre_cmd = ["go generate ./..."]
|
||||
pre_cmd = ["go generate ./pkg/appview/..."]
|
||||
cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview"
|
||||
entrypoint = ["./tmp/atcr-appview", "serve"]
|
||||
include_ext = ["go", "html", "css", "js"]
|
||||
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules"]
|
||||
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$"]
|
||||
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules", "pkg/hold"]
|
||||
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$", "public/css/style\\.css$", "public/icons\\.svg$"]
|
||||
delay = 3000
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
|
||||
@@ -24,12 +24,18 @@ RUN CGO_ENABLED=1 go build \
|
||||
-trimpath \
|
||||
-o atcr-appview ./cmd/appview
|
||||
|
||||
RUN CGO_ENABLED=0 go build \
|
||||
-ldflags="-s -w" \
|
||||
-trimpath \
|
||||
-o healthcheck ./cmd/healthcheck
|
||||
|
||||
# Minimal runtime
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY --from=builder /app/atcr-appview /atcr-appview
|
||||
COPY --from=builder /app/healthcheck /healthcheck
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
|
||||
+11
-1
@@ -7,7 +7,7 @@ ARG BILLING_ENABLED=false
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
|
||||
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev nodejs npm && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
@@ -17,6 +17,10 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build frontend assets (Tailwind CSS, JS bundle, SVG icons)
|
||||
RUN npm ci
|
||||
RUN npm run css:build && npm run css:copy-hold && npm run js:build:hold && npm run icons:build
|
||||
|
||||
# Conditionally add billing tag based on build arg
|
||||
RUN if [ "$BILLING_ENABLED" = "true" ]; then \
|
||||
echo "Building with Stripe billing support"; \
|
||||
@@ -34,6 +38,11 @@ RUN if [ "$BILLING_ENABLED" = "true" ]; then \
|
||||
-o atcr-hold ./cmd/hold; \
|
||||
fi
|
||||
|
||||
RUN CGO_ENABLED=0 go build \
|
||||
-ldflags="-s -w" \
|
||||
-trimpath \
|
||||
-o healthcheck ./cmd/healthcheck
|
||||
|
||||
# ==========================================
|
||||
# Stage 2: Minimal FROM scratch runtime
|
||||
# ==========================================
|
||||
@@ -45,6 +54,7 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
# Copy optimized binary (SQLite embedded)
|
||||
COPY --from=builder /build/atcr-hold /atcr-hold
|
||||
COPY --from=builder /build/healthcheck /healthcheck
|
||||
|
||||
# Expose default port
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
// db-migrate copies all tables and data from a local SQLite database to a
|
||||
// remote libsql database (e.g. Bunny Database, Turso). It reads the schema
|
||||
// from sqlite_master, creates tables on the remote, and inserts all rows
|
||||
// in batches. Generic — works with any SQLite DB (appview, hold, etc.).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/db-migrate --local /path/to/local.db --remote "libsql://..." --token "..."
|
||||
// go run ./cmd/db-migrate --local /path/to/local.db --remote "libsql://..." --token "..." --skip-existing
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/tursodatabase/go-libsql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
localPath := flag.String("local", "", "Path to local SQLite database file")
|
||||
remoteURL := flag.String("remote", "", "Remote libsql URL (libsql://...)")
|
||||
authToken := flag.String("token", "", "Auth token for remote database")
|
||||
skipExisting := flag.Bool("skip-existing", false, "Skip tables that already have data on remote")
|
||||
batchSize := flag.Int("batch-size", 100, "Number of rows per INSERT batch")
|
||||
dryRun := flag.Bool("dry-run", false, "Show what would be migrated without writing")
|
||||
flag.Parse()
|
||||
|
||||
if *localPath == "" || *remoteURL == "" || *authToken == "" {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Open local database read-only
|
||||
localDSN := *localPath
|
||||
if !strings.HasPrefix(localDSN, "file:") {
|
||||
localDSN = "file:" + localDSN
|
||||
}
|
||||
localDSN += "?mode=ro"
|
||||
|
||||
localDB, err := sql.Open("libsql", localDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open local database: %v", err)
|
||||
}
|
||||
defer localDB.Close()
|
||||
|
||||
if err := localDB.Ping(); err != nil {
|
||||
log.Fatalf("Failed to ping local database: %v", err)
|
||||
}
|
||||
|
||||
// Open remote database
|
||||
remoteDSN := fmt.Sprintf("%s?authToken=%s", *remoteURL, *authToken)
|
||||
remoteDB, err := sql.Open("libsql", remoteDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open remote database: %v", err)
|
||||
}
|
||||
defer remoteDB.Close()
|
||||
|
||||
if err := remoteDB.Ping(); err != nil {
|
||||
log.Fatalf("Failed to ping remote database: %v", err)
|
||||
}
|
||||
// Get all user tables from local
|
||||
tables, err := getTables(localDB)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list tables: %v", err)
|
||||
}
|
||||
|
||||
if len(tables) == 0 {
|
||||
log.Println("No tables found in local database")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d tables to migrate\n\n", len(tables))
|
||||
|
||||
start := time.Now()
|
||||
|
||||
if !*dryRun {
|
||||
// Phase 1: Create all tables first so FK references resolve
|
||||
fmt.Println("Creating tables...")
|
||||
for _, t := range tables {
|
||||
if err := createTable(remoteDB, t); err != nil {
|
||||
log.Fatalf("Failed to create table %s: %v", t.name, err)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Phase 2: Copy data
|
||||
fmt.Println("Migrating data...")
|
||||
totalRows := 0
|
||||
for _, t := range tables {
|
||||
count, err := migrateTable(localDB, remoteDB, t, *batchSize, *skipExisting, *dryRun)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to migrate table %s: %v", t.name, err)
|
||||
}
|
||||
totalRows += count
|
||||
}
|
||||
|
||||
if !*dryRun {
|
||||
// Phase 3: Create indexes after data is loaded (faster than indexing during insert)
|
||||
fmt.Println("\nCreating indexes...")
|
||||
for _, t := range tables {
|
||||
if err := createIndexes(localDB, remoteDB, t.name); err != nil {
|
||||
log.Fatalf("Failed to create indexes for %s: %v", t.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fmt.Printf("\nDone. %d total rows across %d tables in %s\n", totalRows, len(tables), time.Since(start).Round(time.Millisecond))
|
||||
if *dryRun {
|
||||
fmt.Println("(dry run — nothing was written)")
|
||||
}
|
||||
}
|
||||
|
||||
type tableInfo struct {
|
||||
name string
|
||||
ddl string
|
||||
}
|
||||
|
||||
func getTables(db *sql.DB) ([]tableInfo, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT name, sql FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name NOT LIKE '_litestream_%'
|
||||
AND name NOT LIKE 'libsql_%'
|
||||
ORDER BY name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tables []tableInfo
|
||||
for rows.Next() {
|
||||
var t tableInfo
|
||||
var ddl sql.NullString
|
||||
if err := rows.Scan(&t.name, &ddl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ddl.Valid {
|
||||
t.ddl = ddl.String
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Sort tables so those referenced by foreign keys come first.
|
||||
// Tables with FK references depend on other tables existing and
|
||||
// having data, so we insert referenced tables first.
|
||||
return topoSortTables(db, tables)
|
||||
}
|
||||
|
||||
// topoSortTables orders tables so that referenced (parent) tables come before
|
||||
// tables that reference them via foreign keys.
|
||||
func topoSortTables(db *sql.DB, tables []tableInfo) ([]tableInfo, error) {
|
||||
byName := make(map[string]tableInfo, len(tables))
|
||||
for _, t := range tables {
|
||||
byName[t.name] = t
|
||||
}
|
||||
|
||||
// Build dependency graph: table -> tables it references
|
||||
deps := make(map[string][]string)
|
||||
for _, t := range tables {
|
||||
fkRows, err := db.Query(fmt.Sprintf("PRAGMA foreign_key_list([%s])", t.name))
|
||||
if err != nil {
|
||||
// PRAGMA might not return rows for tables without FKs
|
||||
continue
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for fkRows.Next() {
|
||||
var id, seq int
|
||||
var table, from, to, onUpdate, onDelete, match string
|
||||
if err := fkRows.Scan(&id, &seq, &table, &from, &to, &onUpdate, &onDelete, &match); err != nil {
|
||||
fkRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !seen[table] {
|
||||
deps[t.name] = append(deps[t.name], table)
|
||||
seen[table] = true
|
||||
}
|
||||
}
|
||||
fkRows.Close()
|
||||
}
|
||||
|
||||
// Topological sort (Kahn's algorithm)
|
||||
visited := make(map[string]bool)
|
||||
var sorted []tableInfo
|
||||
var visit func(name string)
|
||||
visit = func(name string) {
|
||||
if visited[name] {
|
||||
return
|
||||
}
|
||||
visited[name] = true
|
||||
for _, dep := range deps[name] {
|
||||
visit(dep)
|
||||
}
|
||||
if t, ok := byName[name]; ok {
|
||||
sorted = append(sorted, t)
|
||||
}
|
||||
}
|
||||
for _, t := range tables {
|
||||
visit(t.name)
|
||||
}
|
||||
return sorted, nil
|
||||
}
|
||||
|
||||
func getIndexes(db *sql.DB, tableName string) ([]string, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT sql FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND tbl_name = ?
|
||||
AND sql IS NOT NULL
|
||||
`, tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var indexes []string
|
||||
for rows.Next() {
|
||||
var ddl string
|
||||
if err := rows.Scan(&ddl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indexes = append(indexes, ddl)
|
||||
}
|
||||
return indexes, rows.Err()
|
||||
}
|
||||
|
||||
func createTable(remoteDB *sql.DB, t tableInfo) error {
|
||||
if t.ddl == "" {
|
||||
return nil
|
||||
}
|
||||
ddl := t.ddl
|
||||
if !strings.Contains(strings.ToUpper(ddl), "IF NOT EXISTS") {
|
||||
ddl = strings.Replace(ddl, "CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1)
|
||||
}
|
||||
if _, err := remoteDB.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("create table %s: %w", t.name, err)
|
||||
}
|
||||
fmt.Printf(" %s\n", t.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createIndexes(localDB, remoteDB *sql.DB, tableName string) error {
|
||||
indexes, err := getIndexes(localDB, tableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
ddl := idx
|
||||
if !strings.Contains(strings.ToUpper(ddl), "IF NOT EXISTS") {
|
||||
ddl = strings.Replace(ddl, "CREATE INDEX", "CREATE INDEX IF NOT EXISTS", 1)
|
||||
ddl = strings.Replace(ddl, "CREATE UNIQUE INDEX", "CREATE UNIQUE INDEX IF NOT EXISTS", 1)
|
||||
}
|
||||
if _, err := remoteDB.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("create index on %s: %w", tableName, err)
|
||||
}
|
||||
}
|
||||
if len(indexes) > 0 {
|
||||
fmt.Printf(" %s: %d indexes\n", tableName, len(indexes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateTable(localDB, remoteDB *sql.DB, t tableInfo, batchSize int, skipExisting, dryRun bool) (int, error) {
|
||||
var localCount int
|
||||
if err := localDB.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM [%s]", t.name)).Scan(&localCount); err != nil {
|
||||
return 0, fmt.Errorf("count local rows: %w", err)
|
||||
}
|
||||
|
||||
if localCount == 0 {
|
||||
fmt.Printf(" %-30s %6d rows (empty)\n", t.name, 0)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Printf(" %-30s %6d rows (would migrate)\n", t.name, localCount)
|
||||
return localCount, nil
|
||||
}
|
||||
|
||||
if skipExisting {
|
||||
var remoteCount int
|
||||
if err := remoteDB.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM [%s]", t.name)).Scan(&remoteCount); err != nil {
|
||||
return 0, fmt.Errorf("count remote rows: %w", err)
|
||||
}
|
||||
if remoteCount > 0 {
|
||||
fmt.Printf(" %-30s %6d rows (skipped, %d on remote)\n", t.name, localCount, remoteCount)
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := localDB.Query(fmt.Sprintf("SELECT * FROM [%s]", t.name))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("select: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("columns: %w", err)
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(cols))
|
||||
quotedCols := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
placeholders[i] = "?"
|
||||
quotedCols[i] = fmt.Sprintf("[%s]", c)
|
||||
}
|
||||
insertPrefix := fmt.Sprintf("INSERT INTO [%s] (%s) VALUES ", t.name, strings.Join(quotedCols, ", "))
|
||||
rowPlaceholder := "(" + strings.Join(placeholders, ", ") + ")"
|
||||
|
||||
inserted := 0
|
||||
batch := make([][]any, 0, batchSize)
|
||||
|
||||
for rows.Next() {
|
||||
vals := make([]any, len(cols))
|
||||
ptrs := make([]any, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return 0, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
batch = append(batch, vals)
|
||||
|
||||
if len(batch) >= batchSize {
|
||||
if err := insertBatch(remoteDB, insertPrefix, rowPlaceholder, batch); err != nil {
|
||||
return 0, fmt.Errorf("insert batch at row %d: %w", inserted, err)
|
||||
}
|
||||
inserted += len(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
|
||||
if len(batch) > 0 {
|
||||
if err := insertBatch(remoteDB, insertPrefix, rowPlaceholder, batch); err != nil {
|
||||
return 0, fmt.Errorf("insert final batch: %w", err)
|
||||
}
|
||||
inserted += len(batch)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, fmt.Errorf("rows iteration: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf(" %-30s %6d rows migrated\n", t.name, inserted)
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
func insertBatch(db *sql.DB, prefix, rowPlaceholder string, batch [][]any) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(batch))
|
||||
var args []any
|
||||
for i, row := range batch {
|
||||
placeholders[i] = rowPlaceholder
|
||||
args = append(args, row...)
|
||||
}
|
||||
|
||||
query := prefix + strings.Join(placeholders, ", ")
|
||||
_, err := db.Exec(query, args...)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Minimal HTTP health check binary for scratch Docker images.
|
||||
// Usage: healthcheck <url>
|
||||
// Exits 0 if the URL returns HTTP 200, 1 otherwise.
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
os.Exit(1)
|
||||
}
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(os.Args[1])
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
networks:
|
||||
- atcr-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:2019/metrics"]
|
||||
test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -73,7 +73,7 @@ services:
|
||||
networks:
|
||||
- atcr-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/v2/"]
|
||||
test: ["CMD", "/healthcheck", "http://localhost:5000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
networks:
|
||||
- atcr-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
test: ["CMD", "/healthcheck", "http://localhost:8080/xrpc/_health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
+5
-1
@@ -7,8 +7,12 @@
|
||||
"css:build": "BROWSERSLIST_IGNORE_OLD_DATA=1 npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --minify",
|
||||
"css:watch": "BROWSERSLIST_IGNORE_OLD_DATA=1 npx tailwindcss -i ./pkg/appview/src/css/main.css -o ./pkg/appview/public/css/style.css --watch",
|
||||
"js:build": "esbuild pkg/appview/src/js/main.js --bundle --minify --format=esm --outfile=pkg/appview/public/js/bundle.min.js",
|
||||
"js:build:hold": "esbuild pkg/hold/admin/src/js/main.js --bundle --minify --format=esm --outfile=pkg/hold/admin/public/js/bundle.min.js",
|
||||
"js:watch": "esbuild pkg/appview/src/js/main.js --bundle --watch --format=esm --outfile=pkg/appview/public/js/bundle.min.js",
|
||||
"build": "npm run icons:build && npm run css:build && npm run js:build",
|
||||
"css:copy-hold": "cp pkg/appview/public/css/style.css pkg/hold/admin/public/css/style.css",
|
||||
"build:appview": "npm run icons:build && npm run css:build && npm run js:build",
|
||||
"build:hold": "npm run icons:build && npm run css:build && npm run css:copy-hold && npm run js:build:hold",
|
||||
"build": "npm run icons:build && npm run css:build && npm run css:copy-hold && npm run js:build && npm run js:build:hold",
|
||||
"watch": "npm run css:watch & npm run js:watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
description: Normalize hold_endpoint column to store DIDs instead of URLs
|
||||
query: |
|
||||
-- Convert any URL-formatted hold_endpoint values to DID format
|
||||
-- This ensures all hold identifiers are stored consistently as did:web:hostname
|
||||
|
||||
-- Convert HTTPS URLs to did:web: format
|
||||
-- https://hold.example.com → did:web:hold.example.com
|
||||
UPDATE manifests
|
||||
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 9)
|
||||
WHERE hold_endpoint LIKE 'https://%';
|
||||
|
||||
-- Convert HTTP URLs to did:web: format
|
||||
-- http://172.28.0.3:8080 → did:web:172.28.0.3:8080
|
||||
UPDATE manifests
|
||||
SET hold_endpoint = 'did:web:' || substr(hold_endpoint, 8)
|
||||
WHERE hold_endpoint LIKE 'http://%';
|
||||
|
||||
-- Entries already in did:web: format are left unchanged
|
||||
-- did:web:hold.example.com → did:web:hold.example.com (no change)
|
||||
@@ -1,7 +0,0 @@
|
||||
description: Add readme_url to manifests (obsolete - kept for migration history)
|
||||
query: |
|
||||
-- This migration is obsolete. The readme_url and other annotations
|
||||
-- are now stored in the repository_annotations table (see schema.sql).
|
||||
-- Backfill will populate annotation data from PDS records.
|
||||
-- This migration is kept as a no-op to maintain migration history.
|
||||
SELECT 1;
|
||||
@@ -1,11 +0,0 @@
|
||||
description: Add is_attestation column to manifest_references table
|
||||
query: |
|
||||
-- Add is_attestation column to track attestation manifests
|
||||
-- Attestation manifests have vnd.docker.reference.type = "attestation-manifest"
|
||||
ALTER TABLE manifest_references ADD COLUMN is_attestation BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Mark existing unknown/unknown platforms as attestations
|
||||
-- Docker BuildKit attestation manifests always have unknown/unknown platform
|
||||
UPDATE manifest_references
|
||||
SET is_attestation = 1
|
||||
WHERE platform_os = 'unknown' AND platform_architecture = 'unknown';
|
||||
@@ -1,18 +0,0 @@
|
||||
description: Add repo_pages table and remove readme_cache
|
||||
query: |
|
||||
-- Create repo_pages table for storing repository page metadata
|
||||
-- This replaces readme_cache with PDS-synced data
|
||||
CREATE TABLE IF NOT EXISTS repo_pages (
|
||||
did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
description TEXT,
|
||||
avatar_cid TEXT,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY(did, repository),
|
||||
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
|
||||
|
||||
-- Drop readme_cache table (no longer needed)
|
||||
DROP TABLE IF EXISTS readme_cache;
|
||||
@@ -1,8 +0,0 @@
|
||||
description: Add artifact_type column to manifests table for Helm chart support
|
||||
query: |
|
||||
-- Add artifact_type column to track manifest types (container-image, helm-chart, unknown)
|
||||
-- Default to container-image for existing manifests
|
||||
ALTER TABLE manifests ADD COLUMN artifact_type TEXT NOT NULL DEFAULT 'container-image';
|
||||
|
||||
-- Add index for filtering by artifact type
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
|
||||
@@ -1,19 +0,0 @@
|
||||
description: Add hold_crew_members table for cached crew memberships from Jetstream
|
||||
query: |
|
||||
-- Cached hold crew memberships from Jetstream
|
||||
-- Enables reverse lookup: "which holds is user X a member of?"
|
||||
CREATE TABLE IF NOT EXISTS hold_crew_members (
|
||||
hold_did TEXT NOT NULL,
|
||||
member_did TEXT NOT NULL,
|
||||
rkey TEXT NOT NULL,
|
||||
role TEXT,
|
||||
permissions TEXT, -- JSON array
|
||||
tier TEXT,
|
||||
added_at TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (hold_did, member_did)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
|
||||
+15
-9
@@ -1,10 +1,14 @@
|
||||
description: Remove annotation columns from manifests table
|
||||
description: Drop dead annotation/platform columns from manifests table
|
||||
query: |
|
||||
-- Drop annotation columns from manifests table (if they exist)
|
||||
-- Annotations are now stored in repository_annotations table
|
||||
-- SQLite doesn't support DROP COLUMN IF EXISTS, so we recreate the table
|
||||
-- Migration 0004 was supposed to drop these columns but either failed or
|
||||
-- was only recorded (not executed) on production. The 11 dead columns are:
|
||||
-- title, description, source_url, documentation_url, licenses,
|
||||
-- icon_url, readme_url, platform_os, platform_architecture,
|
||||
-- platform_variant, platform_os_version
|
||||
-- Annotations now live in repository_annotations; platform info lives in
|
||||
-- manifest_references. Recreate the table to match schema.sql.
|
||||
|
||||
-- Create new manifests table without annotation columns
|
||||
-- Create the clean table (matches schema.sql exactly)
|
||||
CREATE TABLE IF NOT EXISTS manifests_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
did TEXT NOT NULL,
|
||||
@@ -15,21 +19,23 @@ query: |
|
||||
media_type TEXT NOT NULL,
|
||||
config_digest TEXT,
|
||||
config_size INTEGER,
|
||||
artifact_type TEXT NOT NULL DEFAULT 'container-image',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
UNIQUE(did, repository, digest),
|
||||
FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Copy data (only core fields, annotation columns are dropped)
|
||||
INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at)
|
||||
SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at
|
||||
-- Copy data (only the columns we keep)
|
||||
INSERT INTO manifests_new (id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, artifact_type, created_at)
|
||||
SELECT id, did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, COALESCE(artifact_type, 'container-image'), created_at
|
||||
FROM manifests;
|
||||
|
||||
-- Swap tables
|
||||
DROP TABLE manifests;
|
||||
ALTER TABLE manifests_new RENAME TO manifests;
|
||||
|
||||
-- Recreate indexes
|
||||
-- Recreate indexes (matches schema.sql)
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_did_repo ON manifests(did, repository);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_created_at ON manifests(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_digest ON manifests(digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_manifests_artifact_type ON manifests(artifact_type);
|
||||
@@ -1551,27 +1551,6 @@ func UpsertRepositoryStats(db *sql.DB, stats *RepositoryStats) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IncrementStarCount increments the star count for a repository
|
||||
func IncrementStarCount(db *sql.DB, did, repository string) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO repository_stats (did, repository, star_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(did, repository) DO UPDATE SET
|
||||
star_count = star_count + 1
|
||||
`, did, repository)
|
||||
return err
|
||||
}
|
||||
|
||||
// DecrementStarCount decrements the star count for a repository
|
||||
func DecrementStarCount(db *sql.DB, did, repository string) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE repository_stats
|
||||
SET star_count = MAX(0, star_count - 1)
|
||||
WHERE did = ? AND repository = ?
|
||||
`, did, repository)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertStar inserts or updates a star record (idempotent)
|
||||
func UpsertStar(db *sql.DB, starrerDID, ownerDID, repository string, createdAt time.Time) error {
|
||||
_, err := db.Exec(`
|
||||
|
||||
+54
-11
@@ -273,21 +273,65 @@ func loadMigrations() ([]Migration, error) {
|
||||
}
|
||||
|
||||
// splitSQLStatements splits a SQL query into individual statements.
|
||||
// It handles semicolons as statement separators and filters out empty statements.
|
||||
// It splits on semicolons that are not inside -- line comments or 'string literals'.
|
||||
func splitSQLStatements(query string) []string {
|
||||
var statements []string
|
||||
var current strings.Builder
|
||||
inLineComment := false
|
||||
inString := false
|
||||
|
||||
// Split on semicolons
|
||||
for part := range strings.SplitSeq(query, ";") {
|
||||
// Trim whitespace
|
||||
stmt := strings.TrimSpace(part)
|
||||
for i := 0; i < len(query); i++ {
|
||||
ch := query[i]
|
||||
|
||||
// Skip empty statements (could be trailing semicolon or comment-only)
|
||||
if stmt == "" {
|
||||
if inLineComment {
|
||||
current.WriteByte(ch)
|
||||
if ch == '\n' {
|
||||
inLineComment = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip comment-only statements
|
||||
if inString {
|
||||
current.WriteByte(ch)
|
||||
if ch == '\'' {
|
||||
// Check for escaped quote ('')
|
||||
if i+1 < len(query) && query[i+1] == '\'' {
|
||||
current.WriteByte(query[i+1])
|
||||
i++
|
||||
} else {
|
||||
inString = false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case ch == '-' && i+1 < len(query) && query[i+1] == '-':
|
||||
inLineComment = true
|
||||
current.WriteByte(ch)
|
||||
case ch == '\'':
|
||||
inString = true
|
||||
current.WriteByte(ch)
|
||||
case ch == ';':
|
||||
// Statement boundary — flush if non-empty
|
||||
stmt := strings.TrimSpace(current.String())
|
||||
if stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
current.Reset()
|
||||
default:
|
||||
current.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush trailing statement
|
||||
if stmt := strings.TrimSpace(current.String()); stmt != "" {
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
|
||||
// Filter out comment-only statements
|
||||
filtered := statements[:0]
|
||||
for _, stmt := range statements {
|
||||
hasCode := false
|
||||
for line := range strings.SplitSeq(stmt, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
@@ -296,13 +340,12 @@ func splitSQLStatements(query string) []string {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasCode {
|
||||
statements = append(statements, stmt)
|
||||
filtered = append(filtered, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return statements
|
||||
return filtered
|
||||
}
|
||||
|
||||
// parseMigrationFilename extracts version and name from migration filename
|
||||
|
||||
@@ -54,6 +54,24 @@ SELECT 1;`,
|
||||
query: " \n\t ",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "semicolon inside comment",
|
||||
query: `-- Annotations live in repository_annotations; platform info lives in
|
||||
-- manifest_references.
|
||||
CREATE TABLE foo (id INTEGER);`,
|
||||
expected: []string{
|
||||
"-- Annotations live in repository_annotations; platform info lives in\n -- manifest_references.\n CREATE TABLE foo (id INTEGER)",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "semicolon inside string literal",
|
||||
query: `INSERT INTO foo VALUES ('hello; world');
|
||||
SELECT 1;`,
|
||||
expected: []string{
|
||||
"INSERT INTO foo VALUES ('hello; world')",
|
||||
"SELECT 1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migration 0005 format",
|
||||
query: `-- Add is_attestation column to track attestation manifests
|
||||
|
||||
@@ -33,27 +33,28 @@ func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create ATProto client with session provider
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
|
||||
// Get user's sailor profile to find their default hold
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err)
|
||||
h.renderError(w, "Failed to load profile")
|
||||
return
|
||||
}
|
||||
|
||||
if profile == nil || profile.DefaultHold == "" {
|
||||
// No default hold configured - can't check quota
|
||||
h.renderNoHold(w)
|
||||
return
|
||||
// Use hold_did query param if provided (for previewing other holds),
|
||||
// otherwise fall back to the user's saved default hold from their profile.
|
||||
holdDID := r.URL.Query().Get("hold_did")
|
||||
if holdDID == "" {
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err)
|
||||
h.renderError(w, "Failed to load profile")
|
||||
return
|
||||
}
|
||||
if profile == nil || profile.DefaultHold == "" {
|
||||
h.renderNoHold(w)
|
||||
return
|
||||
}
|
||||
holdDID = profile.DefaultHold
|
||||
}
|
||||
|
||||
// Resolve hold URL from DID
|
||||
holdURL := atproto.ResolveHoldURL(profile.DefaultHold)
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
if holdURL == "" {
|
||||
slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", profile.DefaultHold)
|
||||
slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID)
|
||||
h.renderError(w, "Failed to resolve hold service")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ type SubscriptionInfo struct {
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
||||
BillingInterval string `json:"billingInterval,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HideBilling bool `json:"-"` // hide entire section (no billing support)
|
||||
HoldDisplayName string `json:"-"` // human-readable hold name for display
|
||||
}
|
||||
|
||||
// TierInfo mirrors the hold's billing.TierInfo.
|
||||
@@ -48,27 +50,29 @@ type SubscriptionHandler struct {
|
||||
func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
h.renderError(w, "Unauthorized")
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's default hold
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err)
|
||||
h.renderError(w, "Failed to load profile")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine hold endpoint
|
||||
holdDID := h.DefaultHoldDID
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
holdDID = profile.DefaultHold
|
||||
// Use hold_did query param if provided (for previewing other holds),
|
||||
// otherwise fall back to the user's saved default hold from their profile.
|
||||
holdDID := r.URL.Query().Get("hold_did")
|
||||
if holdDID == "" {
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err)
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
holdDID = h.DefaultHoldDID
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
holdDID = profile.DefaultHold
|
||||
}
|
||||
}
|
||||
|
||||
if holdDID == "" {
|
||||
h.renderError(w, "No default hold configured")
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,7 +80,7 @@ func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
||||
if holdEndpoint == "" {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID)
|
||||
h.renderError(w, "Failed to resolve hold")
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,24 +89,32 @@ func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
resp, err := http.Get(subURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err)
|
||||
h.renderError(w, "Failed to connect to hold")
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Warn("Hold returned error for subscription", "status", resp.StatusCode)
|
||||
h.renderError(w, "Hold does not support billing")
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
var info SubscriptionInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
slog.Warn("Failed to decode subscription info", "error", err)
|
||||
h.renderError(w, "Invalid response from hold")
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !info.PaymentsEnabled {
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Set hold display name so users know which hold the subscription applies to
|
||||
info.HoldDisplayName = deriveDisplayName(holdDID)
|
||||
|
||||
// Format prices for display
|
||||
// Note: -1 means "has price, fetch from Stripe" (placeholder from hold)
|
||||
for i := range info.Tiers {
|
||||
@@ -135,6 +147,14 @@ func (h *SubscriptionHandler) renderInfo(w http.ResponseWriter, info Subscriptio
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) renderHidden(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
info := SubscriptionInfo{HideBilling: true}
|
||||
if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
|
||||
slog.Error("Failed to render hidden subscription template", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error"><svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#alert-circle"></use></svg> %s</div>`, message)
|
||||
@@ -148,7 +168,7 @@ type SubscriptionCheckoutHandler struct {
|
||||
func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
|
||||
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,7 +215,7 @@ func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.R
|
||||
checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint)
|
||||
reqBody := map[string]string{
|
||||
"tier": tier,
|
||||
"returnUrl": h.SiteURL + "/settings",
|
||||
"returnUrl": h.SiteURL + "/settings#storage",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
@@ -242,7 +262,7 @@ type SubscriptionPortalHandler struct {
|
||||
func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
|
||||
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,7 +300,7 @@ func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
// Call hold's portal endpoint
|
||||
portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings", holdEndpoint, h.SiteURL)
|
||||
portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings%%23storage", holdEndpoint, h.SiteURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil)
|
||||
if err != nil {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@
|
||||
<symbol id="anchor" viewBox="0 0 24 24"><path d="M12 6v16"/><path d="m19 13 2-1a9 9 0 0 1-18 0l2 1"/><path d="M9 11h6"/><circle cx="12" cy="4" r="2"/></symbol>
|
||||
<symbol id="arrow-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></symbol>
|
||||
<symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol>
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
|
||||
<symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
@@ -25,8 +26,10 @@
|
||||
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
|
||||
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
|
||||
<symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol>
|
||||
<symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
|
||||
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
|
||||
<symbol id="refresh-ccw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></symbol>
|
||||
<symbol id="save" viewBox="0 0 24 24"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></symbol>
|
||||
<symbol id="search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></symbol>
|
||||
<symbol id="server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
|
||||
<symbol id="shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
@@ -38,6 +41,7 @@
|
||||
<symbol id="trash-2" viewBox="0 0 24 24"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
|
||||
<symbol id="user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
|
||||
<symbol id="user-plus" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></symbol>
|
||||
<symbol id="x-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
|
||||
<symbol id="helm" viewBox="0 0 24 24"><path d="M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z" fill="currentColor" stroke="none"/></symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.7 KiB |
@@ -495,6 +495,13 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
"oauth_metadata", "/client-metadata.json")
|
||||
}
|
||||
|
||||
// Health check endpoint (for Docker health checks / load balancers)
|
||||
mainRouter.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
// Register credential helper version API (public endpoint)
|
||||
routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
/* Content sources for class detection */
|
||||
@source "../../templates/**/*.html";
|
||||
@source "../../public/js/**/*.js";
|
||||
@source "../../../hold/admin/templates/**/*.html";
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
|
||||
@@ -9,243 +9,286 @@
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-4xl mx-auto space-y-8">
|
||||
<h1 class="text-3xl font-bold">Settings</h1>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Settings</h1>
|
||||
|
||||
<!-- Identity Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Identity</h2>
|
||||
<div class="grid gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">Handle</span>
|
||||
<span>{{ .Profile.Handle }}</span>
|
||||
<!-- Mobile tab bar (below lg) -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6">
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="identity">
|
||||
{{ icon "fingerprint" "size-4" }} Identity
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices">
|
||||
{{ icon "terminal" "size-4" }} Devices
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
|
||||
{{ icon "hard-drive" "size-4" }} Storage
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="advanced">
|
||||
{{ icon "shield-check" "size-4" }} Advanced
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<!-- Sidebar (lg and above) -->
|
||||
<aside class="hidden lg:block w-56 shrink-0">
|
||||
<ul class="menu bg-base-200 rounded-box w-full">
|
||||
<li data-tab="identity"><a href="#identity">{{ icon "fingerprint" "size-4" }} Identity</a></li>
|
||||
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
|
||||
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
|
||||
<li data-tab="advanced"><a href="#advanced">{{ icon "shield-check" "size-4" }} Advanced</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
<!-- IDENTITY TAB -->
|
||||
<div id="tab-identity" class="settings-panel space-y-6">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Identity</h2>
|
||||
<div class="grid gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">Handle</span>
|
||||
<span>{{ .Profile.Handle }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">DID</span>
|
||||
<code class="cmd">{{ .Profile.DID }}</code>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">PDS</span>
|
||||
<span>{{ .Profile.PDSEndpoint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">DID</span>
|
||||
<code class="cmd">{{ .Profile.DID }}</code>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">PDS</span>
|
||||
<span>{{ .Profile.PDSEndpoint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Storage Usage Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Stowage</h2>
|
||||
<p class="text-base-content/70">Estimated storage usage on your default hold.</p>
|
||||
<div id="storage-stats" hx-get="/api/storage" hx-trigger="load" hx-swap="innerHTML">
|
||||
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading...</p>
|
||||
</div>
|
||||
</section>
|
||||
<!-- DEVICES TAB -->
|
||||
<div id="tab-devices" class="settings-panel hidden space-y-6">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Authorized Devices</h2>
|
||||
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Subscription</h2>
|
||||
<p class="text-base-content/70">Manage your storage tier and billing.</p>
|
||||
<div id="subscription-info" hx-get="/api/subscription" hx-trigger="load" hx-swap="innerHTML">
|
||||
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Default Hold Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Default Hold</h2>
|
||||
<p class="text-base-content/70">Select where your container images will be stored.</p>
|
||||
|
||||
<form hx-post="/api/profile/default-hold"
|
||||
hx-target="#hold-status"
|
||||
hx-swap="innerHTML"
|
||||
id="hold-form"
|
||||
class="space-y-4">
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="sr-only">Storage hold selection</legend>
|
||||
<label class="label" for="default-hold">
|
||||
<span class="label-text">Storage Hold</span>
|
||||
</label>
|
||||
<select id="default-hold" name="hold_did" class="select select-bordered w-full">
|
||||
<option value=""{{ if eq .CurrentHoldDID "" }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
|
||||
|
||||
{{ if .ShowCurrentHold }}
|
||||
<option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
|
||||
{{ end }}
|
||||
|
||||
{{ if .OwnedHolds }}
|
||||
<optgroup label="Your Holds">
|
||||
{{ range .OwnedHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .CrewHolds }}
|
||||
<optgroup label="Crew Member">
|
||||
{{ range .CrewHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .EligibleHolds }}
|
||||
<optgroup label="Open Registration">
|
||||
{{ range .EligibleHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .PublicHolds }}
|
||||
<optgroup label="Public Holds">
|
||||
{{ range .PublicHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
</select>
|
||||
<p class="text-sm text-base-content/60 mt-1">Your images will be stored on the selected hold</p>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
|
||||
<div id="hold-status"></div>
|
||||
|
||||
<!-- Hold details panel (shows when hold selected) -->
|
||||
<div id="hold-details" class="hidden mt-4 p-4 bg-base-200 rounded-lg">
|
||||
<h3 class="font-semibold mb-3">Hold Details</h3>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-base-content/70">DID:</dt>
|
||||
<dd id="hold-did" class="font-mono"></dd>
|
||||
<dt class="text-base-content/70">Region:</dt>
|
||||
<dd id="hold-region"></dd>
|
||||
<dt class="text-base-content/70">Your Access:</dt>
|
||||
<dd id="hold-access"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Authorized Devices Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Authorized Devices</h2>
|
||||
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p>
|
||||
</div>
|
||||
|
||||
<!-- Setup Instructions -->
|
||||
<div class="bg-base-200 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-semibold">First Time Setup</h3>
|
||||
<ol class="list-decimal list-inside space-y-4 text-sm">
|
||||
<li>Install credential helper:
|
||||
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre>
|
||||
</li>
|
||||
<li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>:
|
||||
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{
|
||||
<!-- Setup Instructions -->
|
||||
<div class="bg-base-200 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-semibold">First Time Setup</h3>
|
||||
<ol class="list-decimal list-inside space-y-4 text-sm">
|
||||
<li>Install credential helper:
|
||||
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre>
|
||||
</li>
|
||||
<li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>:
|
||||
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{
|
||||
"credHelpers": {
|
||||
"{{ .RegistryURL }}": "atcr"
|
||||
}
|
||||
}</code></pre>
|
||||
</li>
|
||||
<li>Run any Docker command:
|
||||
<div class="mt-2">{{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }}</div>
|
||||
</li>
|
||||
<li>Browser will open for authorization - click Approve</li>
|
||||
<li>Done! Device is automatically authorized</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Run any Docker command:
|
||||
<div class="mt-2">{{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }}</div>
|
||||
</li>
|
||||
<li>Browser will open for authorization - click Approve</li>
|
||||
<li>Done! Device is automatically authorized</li>
|
||||
</ol>
|
||||
|
||||
<div class="pt-3 border-t border-base-300 text-sm">
|
||||
<strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">app password</a> with <code class="cmd">docker login {{ .RegistryURL }}</code> for quick start (no device tracking)
|
||||
<div class="pt-3 border-t border-base-300 text-sm">
|
||||
<strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">app password</a> with <code class="cmd">docker login {{ .RegistryURL }}</code> for quick start (no device tracking)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Devices List -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold">Your Authorized Devices</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="devices-table"
|
||||
hx-get="/api/devices"
|
||||
hx-trigger="tab:devices from:body once, every 30s[isTabActive('devices')]"
|
||||
hx-swap="innerHTML">
|
||||
<tr><td colspan="5" class="text-center">{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- STORAGE TAB -->
|
||||
<div id="tab-storage" class="settings-panel hidden space-y-6">
|
||||
<!-- Default Hold Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Default Hold</h2>
|
||||
<p class="text-base-content/70">Select where your container images will be stored.</p>
|
||||
|
||||
<form hx-post="/api/profile/default-hold"
|
||||
hx-target="#hold-status"
|
||||
hx-swap="innerHTML"
|
||||
id="hold-form"
|
||||
class="space-y-4">
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="sr-only">Storage hold selection</legend>
|
||||
<label class="label" for="default-hold">
|
||||
<span class="label-text">Storage Hold</span>
|
||||
</label>
|
||||
<select id="default-hold" name="hold_did" class="select select-bordered w-full" autocomplete="off">
|
||||
<option value=""{{ if eq .CurrentHoldDID "" }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
|
||||
|
||||
{{ if .ShowCurrentHold }}
|
||||
<option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
|
||||
{{ end }}
|
||||
|
||||
{{ if .OwnedHolds }}
|
||||
<optgroup label="Your Holds">
|
||||
{{ range .OwnedHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .CrewHolds }}
|
||||
<optgroup label="Crew Member">
|
||||
{{ range .CrewHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .EligibleHolds }}
|
||||
<optgroup label="Open Registration">
|
||||
{{ range .EligibleHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .PublicHolds }}
|
||||
<optgroup label="Public Holds">
|
||||
{{ range .PublicHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
</select>
|
||||
<p class="text-sm text-base-content/60 mt-1">Your images will be stored on the selected hold</p>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
|
||||
<div id="hold-status"></div>
|
||||
|
||||
<!-- Hold details panel (shows when hold selected) -->
|
||||
<div id="hold-details" class="hidden mt-4 p-4 bg-base-200 rounded-lg">
|
||||
<h3 class="font-semibold mb-3">Hold Details</h3>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-base-content/70">DID:</dt>
|
||||
<dd id="hold-did" class="font-mono"></dd>
|
||||
<dt class="text-base-content/70">Region:</dt>
|
||||
<dd id="hold-region"></dd>
|
||||
<dt class="text-base-content/70">Your Access:</dt>
|
||||
<dd id="hold-access"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Storage Usage Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Stowage</h2>
|
||||
<p class="text-base-content/70">Estimated storage usage on your default hold.</p>
|
||||
<div id="storage-stats" hx-get="/api/storage" hx-trigger="tab:storage from:body once" hx-swap="innerHTML">
|
||||
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Subscription Section -->
|
||||
<div id="subscription-wrapper" hx-get="/api/subscription" hx-trigger="tab:storage from:body once" hx-swap="innerHTML">
|
||||
<section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Subscription</h2>
|
||||
<p class="text-base-content/70">Manage your storage tier and billing.</p>
|
||||
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ADVANCED TAB -->
|
||||
<div id="tab-advanced" class="settings-panel hidden space-y-6">
|
||||
<!-- Data Privacy Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Data Privacy</h2>
|
||||
<p class="text-base-content/70">Download a copy of all data we store about you.</p>
|
||||
|
||||
<div>
|
||||
<a href="/api/export-data" class="btn btn-secondary gap-2" download>
|
||||
{{ icon "download" "size-4" }}
|
||||
Export All My Data
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-base-content/60">
|
||||
This includes your authorized devices, sessions, and hold memberships.
|
||||
Data stored on your PDS is already under your control.
|
||||
See our <a href="/privacy" class="link link-primary">Privacy Policy</a> for details.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone Section -->
|
||||
<section class="border-2 border-error rounded-lg p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold text-error flex items-center gap-2">
|
||||
{{ icon "alert-triangle" "size-5" }}
|
||||
Danger Zone
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-semibold">Delete {{ .ClientShortName }} Data</h3>
|
||||
<p class="text-base-content/70 mt-1">Remove your data from {{ .ClientShortName }}. This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div class="alert bg-base-200">
|
||||
{{ icon "info" "size-5 shrink-0" }}
|
||||
<span><strong>This does not delete your ATProto (Bluesky, Blacksky, Tangled) account.</strong><br>Only {{ .ClientShortName }}-specific data (authorized devices, hold memberships, settings) will be removed.</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="delete-pds-records" class="checkbox checkbox-sm mt-0.5">
|
||||
<span class="text-sm">Also delete all <code class="cmd">io.atcr.*</code> records from my ATProto PDS</span>
|
||||
</label>
|
||||
<p class="text-xs text-base-content/60 ml-7">
|
||||
This removes {{ .ClientShortName }} records (manifests, tags, stars, profile) stored in your PDS.
|
||||
Other records in your account are not impacted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2">
|
||||
{{ icon "trash-2" "size-5" }}
|
||||
Delete My {{ .ClientShortName }} Data
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Devices List -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="font-semibold">Your Authorized Devices</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="devices-table"
|
||||
hx-get="/api/devices"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<tr><td colspan="5" class="text-center">{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Privacy Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Data Privacy</h2>
|
||||
<p class="text-base-content/70">Download a copy of all data we store about you.</p>
|
||||
|
||||
<div>
|
||||
<a href="/api/export-data" class="btn btn-secondary gap-2" download>
|
||||
{{ icon "download" "size-4" }}
|
||||
Export All My Data
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-base-content/60">
|
||||
This includes your authorized devices, sessions, and hold memberships.
|
||||
Data stored on your PDS is already under your control.
|
||||
See our <a href="/privacy" class="link link-primary">Privacy Policy</a> for details.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Danger Zone Section -->
|
||||
<section class="border-2 border-error rounded-lg p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold text-error flex items-center gap-2">
|
||||
{{ icon "alert-triangle" "size-5" }}
|
||||
Danger Zone
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="font-semibold">Delete {{ .ClientShortName }} Data</h3>
|
||||
<p class="text-base-content/70 mt-1">Remove your data from {{ .ClientShortName }}. This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div class="alert bg-base-200">
|
||||
{{ icon "info" "size-5 shrink-0" }}
|
||||
<span><strong>This does not delete your ATProto (Bluesky, Blacksky, Tangled) account.</strong><br>Only {{ .ClientShortName }}-specific data (authorized devices, hold memberships, settings) will be removed.</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="delete-pds-records" class="checkbox checkbox-sm mt-0.5">
|
||||
<span class="text-sm">Also delete all <code class="cmd">io.atcr.*</code> records from my ATProto PDS</span>
|
||||
</label>
|
||||
<p class="text-xs text-base-content/60 ml-7">
|
||||
This removes {{ .ClientShortName }} records (manifests, tags, stars, profile) stored in your PDS.
|
||||
Other records in your account are not impacted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2">
|
||||
{{ icon "trash-2" "size-5" }}
|
||||
Delete My {{ .ClientShortName }} Data
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -253,6 +296,87 @@
|
||||
// Hold data from server (for details panel)
|
||||
const holdData = {{ .HoldDataJSON }};
|
||||
|
||||
// Tab switching
|
||||
(function() {
|
||||
var validTabs = ['identity', 'devices', 'storage', 'advanced'];
|
||||
|
||||
function switchSettingsTab(tabId) {
|
||||
// Hide all panels
|
||||
document.querySelectorAll('.settings-panel').forEach(function(p) {
|
||||
p.classList.add('hidden');
|
||||
});
|
||||
// Show selected panel
|
||||
var panel = document.getElementById('tab-' + tabId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
|
||||
// Sidebar: toggle menu-active on <li> elements
|
||||
document.querySelectorAll('.menu li[data-tab]').forEach(function(li) {
|
||||
if (li.dataset.tab === tabId) {
|
||||
li.classList.add('menu-active');
|
||||
} else {
|
||||
li.classList.remove('menu-active');
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile: toggle btn-secondary/btn-ghost on buttons
|
||||
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
|
||||
if (btn.dataset.tab === tabId) {
|
||||
btn.classList.remove('btn-ghost');
|
||||
btn.classList.add('btn-secondary');
|
||||
} else {
|
||||
btn.classList.remove('btn-secondary');
|
||||
btn.classList.add('btn-ghost');
|
||||
}
|
||||
});
|
||||
|
||||
// Update URL hash without adding history entry
|
||||
history.replaceState(null, '', '#' + tabId);
|
||||
// Dispatch custom event for HTMX lazy loading
|
||||
document.body.dispatchEvent(new CustomEvent('tab:' + tabId));
|
||||
}
|
||||
|
||||
// Helper for HTMX conditional polling
|
||||
window.isTabActive = function(tabId) {
|
||||
var panel = document.getElementById('tab-' + tabId);
|
||||
return panel && !panel.classList.contains('hidden');
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Read initial tab from hash
|
||||
var hash = window.location.hash.replace('#', '') || 'identity';
|
||||
if (validTabs.indexOf(hash) === -1) hash = 'identity';
|
||||
|
||||
// Mobile tab click handlers
|
||||
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
switchSettingsTab(this.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Sidebar tab click handlers
|
||||
document.querySelectorAll('.menu li[data-tab] a').forEach(function(link) {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
switchSettingsTab(this.parentElement.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Activate initial tab (use requestAnimationFrame to ensure HTMX has initialized)
|
||||
requestAnimationFrame(function() {
|
||||
switchSettingsTab(hash);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('hashchange', function() {
|
||||
var hash = window.location.hash.replace('#', '') || 'identity';
|
||||
if (validTabs.indexOf(hash) !== -1) {
|
||||
switchSettingsTab(hash);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Hold Selection and Details Display
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const holdSelect = document.getElementById('default-hold');
|
||||
@@ -299,6 +423,13 @@
|
||||
holdDetails.style.display = 'block';
|
||||
});
|
||||
|
||||
// Re-fetch stowage and subscription when hold selection changes
|
||||
holdSelect.addEventListener('change', function() {
|
||||
var params = this.value ? '?hold_did=' + encodeURIComponent(this.value) : '';
|
||||
htmx.ajax('GET', '/api/storage' + params, '#storage-stats');
|
||||
htmx.ajax('GET', '/api/subscription' + params, {target: '#subscription-wrapper', swap: 'innerHTML'});
|
||||
});
|
||||
|
||||
// Trigger on page load if a hold is already selected
|
||||
if (holdSelect.value) {
|
||||
holdSelect.dispatchEvent(new Event('change'));
|
||||
|
||||
@@ -1,60 +1,65 @@
|
||||
{{ define "subscription_info" }}
|
||||
{{ if .Error }}
|
||||
<div class="alert alert-error">
|
||||
{{ icon "alert-circle" "size-5" }} {{ .Error }}
|
||||
</div>
|
||||
{{ else if not .PaymentsEnabled }}
|
||||
<div class="alert alert-info">
|
||||
{{ icon "info" "size-5" }} This hold does not support online payments. Contact the hold operator to upgrade your plan.
|
||||
</div>
|
||||
{{ if .HideBilling }}
|
||||
<!-- subscription: billing not available for this hold -->
|
||||
{{ else }}
|
||||
<!-- Current Plan -->
|
||||
<div class="bg-base-200 p-4 rounded-lg mb-4">
|
||||
<div class="flex justify-between py-2 border-b border-base-300">
|
||||
<span class="text-base-content/70">Current Tier:</span>
|
||||
<span class="font-bold capitalize">{{ .CurrentTier }}</span>
|
||||
</div>
|
||||
{{ if and .CrewTier (ne .CrewTier .CurrentTier) }}
|
||||
<div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1">
|
||||
<span class="text-base-content/70">Crew Record Tier:</span>
|
||||
<span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .SubscriptionID }}
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-base-content/70">Billing:</span>
|
||||
<span class="font-bold">{{ .BillingInterval }}</span>
|
||||
</div>
|
||||
<a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
<section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Subscription</h2>
|
||||
<p class="text-base-content/70">Manage your storage tier and billing.{{ if .HoldDisplayName }} Storage provided by <strong>{{ .HoldDisplayName }}</strong>.{{ end }}</p>
|
||||
|
||||
<!-- Available Tiers -->
|
||||
{{ if .Tiers }}
|
||||
<h3 class="font-semibold">Available Plans</h3>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4">
|
||||
{{ range .Tiers }}
|
||||
<div class="border rounded-lg p-5 bg-base-200 relative flex flex-col{{ if .IsCurrent }} border-primary border-2{{ else }} border-base-300{{ end }}">
|
||||
{{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }}
|
||||
<div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div>
|
||||
<div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div>
|
||||
{{ if .Description }}
|
||||
<div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div>
|
||||
{{ if .Error }}
|
||||
<div class="alert alert-error">
|
||||
{{ icon "alert-circle" "size-5" }} {{ .Error }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<!-- Current Plan -->
|
||||
<div class="bg-base-200 p-4 rounded-lg mb-4">
|
||||
<div class="flex justify-between py-2 border-b border-base-300">
|
||||
<span class="text-base-content/70">Current Tier:</span>
|
||||
<span class="font-bold capitalize">{{ .CurrentTier }}</span>
|
||||
</div>
|
||||
{{ if and .CrewTier (ne .CrewTier .CurrentTier) }}
|
||||
<div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1">
|
||||
<span class="text-base-content/70">Crew Record Tier:</span>
|
||||
<span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="flex-1"></div>
|
||||
<div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div>
|
||||
{{ if not .IsCurrent }}
|
||||
{{ if or .PriceCentsMonthly .PriceCentsYearly }}
|
||||
{{ if $.SubscriptionID }}
|
||||
<a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a>
|
||||
{{ else }}
|
||||
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a>
|
||||
{{ if .SubscriptionID }}
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-base-content/70">Billing:</span>
|
||||
<span class="font-bold">{{ .BillingInterval }}</span>
|
||||
</div>
|
||||
<a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Available Tiers -->
|
||||
{{ if .Tiers }}
|
||||
<h3 class="font-semibold">Available Plans</h3>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4">
|
||||
{{ range .Tiers }}
|
||||
<div class="border rounded-lg p-5 bg-base-200 relative flex flex-col{{ if .IsCurrent }} border-primary border-2{{ else }} border-base-300{{ end }}">
|
||||
{{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }}
|
||||
<div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div>
|
||||
<div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div>
|
||||
{{ if .Description }}
|
||||
<div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div>
|
||||
{{ end }}
|
||||
<div class="flex-1"></div>
|
||||
<div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div>
|
||||
{{ if not .IsCurrent }}
|
||||
{{ if or .PriceCentsMonthly .PriceCentsYearly }}
|
||||
{{ if $.SubscriptionID }}
|
||||
<a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a>
|
||||
{{ else }}
|
||||
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ func CacheMiddleware(h http.Handler, maxAge int) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../.. && npm run build || echo 'npm not found, skipping build'"
|
||||
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../.. && npm run build:appview || echo 'npm not found, skipping build'"
|
||||
|
||||
//go:embed templates/**/*.html
|
||||
var templatesFS embed.FS
|
||||
|
||||
+10
-2
@@ -3,8 +3,7 @@
|
||||
// and usage metrics. The admin panel is embedded directly in the hold service binary.
|
||||
package admin
|
||||
|
||||
//go:generate curl -fsSL -o public/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js
|
||||
//go:generate curl -fsSL -o public/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js
|
||||
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../../.. && npm run build:hold || echo 'npm not found, skipping build'"
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -233,6 +232,15 @@ func parseTemplates() (*template.Template, error) {
|
||||
}
|
||||
return false
|
||||
},
|
||||
// icon renders an SVG icon from the sprite sheet
|
||||
// Usage: {{ icon "star" "size-4 text-amber-400" }}
|
||||
"icon": func(name, classes string) template.HTML {
|
||||
return template.HTML(fmt.Sprintf(
|
||||
`<svg class="icon %s" aria-hidden="true"><use href="/admin/public/icons.svg#%s"></use></svg>`,
|
||||
template.HTMLEscapeString(classes),
|
||||
template.HTMLEscapeString(name),
|
||||
))
|
||||
},
|
||||
}
|
||||
|
||||
tmpl := template.New("").Funcs(funcMap)
|
||||
|
||||
@@ -1,697 +0,0 @@
|
||||
/* Hold Admin Panel Styles */
|
||||
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--danger-hover: #b91c1c;
|
||||
--warning: #f59e0b;
|
||||
--success: #10b981;
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-300: #d1d5db;
|
||||
--gray-500: #6b7280;
|
||||
--gray-700: #374151;
|
||||
--gray-900: #111827;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--gray-50);
|
||||
color: var(--gray-900);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.nav {
|
||||
background: var(--gray-900);
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--gray-300);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--gray-300);
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: var(--gray-200);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--gray-300);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.375rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-icon i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-icon i {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem;
|
||||
background: var(--gray-50);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.member-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.member-info strong {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: var(--gray-50);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.table td {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.actions-header {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.member-cell {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.member-cell strong {
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.did-code {
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-500);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.permissions-cell .badge {
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tier-limit {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.date-cell {
|
||||
white-space: nowrap;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--gray-200);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.badge-tier {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-gold {
|
||||
background: #fbbf24;
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.usage-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--gray-200);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-fill.warning {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.progress-fill.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"],
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Input with lookup button */
|
||||
.input-with-lookup {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-lookup input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.handle-lookup-result {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.handle-lookup-result .success {
|
||||
color: var(--success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.handle-lookup-result .success i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.handle-lookup-result .error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.handle-lookup-result .warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.handle-lookup-result .loading {
|
||||
color: var(--gray-500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.checkbox span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkbox small {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Toggle Settings */
|
||||
.toggle-setting {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--gray-50);
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-setting input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.toggle-label strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toggle-label small {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Info List */
|
||||
.info-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.info-list dt {
|
||||
font-weight: 500;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.info-list dd {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Flash Messages */
|
||||
.flash {
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.flash-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
color: var(--gray-500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Note */
|
||||
.note {
|
||||
padding: 1rem;
|
||||
background: var(--gray-100);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--gray-500);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--gray-500);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.footer code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--gray-200);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-page {
|
||||
background: var(--gray-100);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-form .form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-note {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-500);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
color: var(--gray-500);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Error Page */
|
||||
.error-page {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--danger);
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
|
||||
/* Tier Chart */
|
||||
.tier-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tier-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--gray-100);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.tier-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tier-count {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* Code */
|
||||
code {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", monospace;
|
||||
font-size: 0.875em;
|
||||
background: var(--gray-100);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.nav {
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<symbol id="alert-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol>
|
||||
<symbol id="alert-triangle" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
|
||||
<symbol id="anchor" viewBox="0 0 24 24"><path d="M12 6v16"/><path d="m19 13 2-1a9 9 0 0 1-18 0l2 1"/><path d="M9 11h6"/><circle cx="12" cy="4" r="2"/></symbol>
|
||||
<symbol id="arrow-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></symbol>
|
||||
<symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol>
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
|
||||
<symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||
<symbol id="chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="chevron-left" viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></symbol>
|
||||
<symbol id="chevron-right" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
|
||||
<symbol id="circle-x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
|
||||
<symbol id="compass" viewBox="0 0 24 24"><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/></symbol>
|
||||
<symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol>
|
||||
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="fingerprint" viewBox="0 0 24 24"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></symbol>
|
||||
<symbol id="github" viewBox="0 0 24 24"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></symbol>
|
||||
<symbol id="hard-drive" viewBox="0 0 24 24"><line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/></symbol>
|
||||
<symbol id="info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol>
|
||||
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
|
||||
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
|
||||
<symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol>
|
||||
<symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
|
||||
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
|
||||
<symbol id="refresh-ccw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></symbol>
|
||||
<symbol id="save" viewBox="0 0 24 24"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></symbol>
|
||||
<symbol id="search" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></symbol>
|
||||
<symbol id="server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
|
||||
<symbol id="shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
<symbol id="ship" viewBox="0 0 24 24"><path d="M12 10.189V14"/><path d="M12 2v3"/><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"/><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/></symbol>
|
||||
<symbol id="star" viewBox="0 0 24 24"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></symbol>
|
||||
<symbol id="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></symbol>
|
||||
<symbol id="sun-moon" viewBox="0 0 24 24"><path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/></symbol>
|
||||
<symbol id="terminal" viewBox="0 0 24 24"><path d="M12 19h8"/><path d="m4 17 6-6-6-6"/></symbol>
|
||||
<symbol id="trash-2" viewBox="0 0 24 24"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="triangle-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
|
||||
<symbol id="user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
|
||||
<symbol id="user-plus" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></symbol>
|
||||
<symbol id="x-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
|
||||
<symbol id="helm" viewBox="0 0 24 24"><path d="M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z" fill="currentColor" stroke="none"/></symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
-12
File diff suppressed because one or more lines are too long
@@ -0,0 +1,140 @@
|
||||
// HTMX
|
||||
import htmx from 'htmx.org';
|
||||
window.htmx = htmx;
|
||||
|
||||
// ========================================
|
||||
// Theme management (system / light / dark)
|
||||
// ========================================
|
||||
function getThemePreference() {
|
||||
return localStorage.getItem('hold-admin-theme') || 'system';
|
||||
}
|
||||
|
||||
function getEffectiveTheme(pref) {
|
||||
if (pref === 'dark' || pref === 'light') return pref;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const pref = getThemePreference();
|
||||
const effective = getEffectiveTheme(pref);
|
||||
const dark = effective === 'dark';
|
||||
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
|
||||
updateThemeUI(pref);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
localStorage.setItem('hold-admin-theme', theme);
|
||||
applyTheme();
|
||||
closeThemeDropdown();
|
||||
}
|
||||
|
||||
function updateThemeUI(pref) {
|
||||
const iconMap = { system: 'sun-moon', light: 'sun', dark: 'moon' };
|
||||
document.querySelectorAll('[data-theme-icon] use').forEach(use => {
|
||||
use.setAttribute('href', `/admin/public/icons.svg#${iconMap[pref] || 'sun-moon'}`);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.theme-option').forEach(option => {
|
||||
const isSelected = option.dataset.value === pref;
|
||||
const check = option.querySelector('.theme-check');
|
||||
if (check) {
|
||||
check.style.visibility = isSelected ? 'visible' : 'hidden';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeThemeDropdown() {
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
|
||||
const details = btn.closest('details');
|
||||
if (details) details.removeAttribute('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (getThemePreference() === 'system') {
|
||||
applyTheme();
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// DID-to-handle lookup
|
||||
// ========================================
|
||||
function initDIDLookup() {
|
||||
const didInput = document.getElementById('did');
|
||||
const lookupBtn = document.getElementById('lookup-btn');
|
||||
const handleResult = document.getElementById('handle-result');
|
||||
|
||||
if (!didInput || !lookupBtn || !handleResult) return;
|
||||
|
||||
async function lookupHandle() {
|
||||
const did = didInput.value.trim();
|
||||
if (!did.startsWith('did:')) {
|
||||
handleResult.innerHTML = '<span class="text-error">Invalid DID format</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
handleResult.innerHTML = '<span class="text-base-content/50 italic">Looking up...</span>';
|
||||
|
||||
try {
|
||||
let url;
|
||||
if (did.startsWith('did:plc:')) {
|
||||
url = `https://plc.directory/${did}`;
|
||||
} else if (did.startsWith('did:web:')) {
|
||||
const host = did.replace('did:web:', '').replace(/%3A/g, ':');
|
||||
url = `https://${host}/.well-known/did.json`;
|
||||
} else {
|
||||
handleResult.innerHTML = '<span class="text-error">Unsupported DID method</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('DID not found');
|
||||
|
||||
const doc = await resp.json();
|
||||
const aka = doc.alsoKnownAs || [];
|
||||
const handleUri = aka.find(u => u.startsWith('at://'));
|
||||
if (handleUri) {
|
||||
const handle = handleUri.replace('at://', '');
|
||||
handleResult.innerHTML = `<span class="text-success flex items-center gap-1"><svg class="icon size-4" aria-hidden="true"><use href="/admin/public/icons.svg#check-circle"></use></svg> <strong>${handle}</strong></span>`;
|
||||
} else {
|
||||
handleResult.innerHTML = '<span class="text-warning">No handle found</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
handleResult.innerHTML = `<span class="text-error">Lookup failed: ${err.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
lookupBtn.addEventListener('click', lookupHandle);
|
||||
|
||||
didInput.addEventListener('blur', function() {
|
||||
if (this.value.startsWith('did:') && this.value.length > 10) {
|
||||
lookupHandle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Init
|
||||
// ========================================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyTheme();
|
||||
|
||||
// Theme dropdown setup
|
||||
document.querySelectorAll('[data-theme-menu]').forEach(themeMenu => {
|
||||
themeMenu.querySelectorAll('.theme-option').forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
setTheme(option.dataset.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// DID lookup on crew add page
|
||||
initDIDLookup();
|
||||
});
|
||||
|
||||
// Export for template onclick handlers
|
||||
window.setTheme = setTheme;
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "admin-head"}}
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Theme: apply early to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
function getEffectiveTheme(pref) {
|
||||
if (pref === 'dark') return 'dark';
|
||||
if (pref === 'light') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
var pref = localStorage.getItem('hold-admin-theme') || 'system';
|
||||
var effective = getEffectiveTheme(pref);
|
||||
document.documentElement.classList.toggle('dark', effective === 'dark');
|
||||
document.documentElement.setAttribute('data-theme', effective);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Preload critical assets -->
|
||||
<link rel="preload" href="/admin/public/icons.svg" as="image" type="image/svg+xml">
|
||||
|
||||
<!-- Tailwind CSS + DaisyUI (shared with appview) -->
|
||||
<link rel="stylesheet" href="/admin/public/css/style.css">
|
||||
|
||||
<!-- Bundled JS: HTMX + Theme + DID Lookup -->
|
||||
<script type="module" src="/admin/public/js/bundle.min.js"></script>
|
||||
{{end}}
|
||||
@@ -1,18 +1,20 @@
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
<div class="nav-brand">
|
||||
<a href="/admin">Hold Admin</a>
|
||||
<div class="navbar bg-neutral text-neutral-content px-4 shadow-md">
|
||||
<div class="flex-none">
|
||||
<a href="/admin" class="text-lg font-semibold hover:opacity-80 transition-opacity">Hold Admin</a>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<ul class="menu menu-horizontal gap-1 ml-4">
|
||||
<li><a href="/admin" class="{{if eq .ActivePage "dashboard"}}active{{end}}">Dashboard</a></li>
|
||||
<li><a href="/admin/crew" class="{{if eq .ActivePage "crew"}}active{{end}}">Crew</a></li>
|
||||
<li><a href="/admin/settings" class="{{if eq .ActivePage "settings"}}active{{end}}">Settings</a></li>
|
||||
</ul>
|
||||
<div class="flex-1"></div>
|
||||
{{if .User}}
|
||||
<div class="nav-user">
|
||||
<span>{{.User.Handle}}</span>
|
||||
<a href="/admin/auth/logout" class="btn btn-sm">Logout</a>
|
||||
<div class="flex items-center gap-3">
|
||||
{{template "admin-theme-toggle"}}
|
||||
<span class="text-sm opacity-80">{{.User.Handle}}</span>
|
||||
<a href="/admin/auth/logout" class="btn btn-sm btn-ghost">Logout</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "admin-theme-toggle"}}
|
||||
<details class="dropdown dropdown-end">
|
||||
<summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings">
|
||||
<svg class="icon size-5" data-theme-icon aria-hidden="true"><use href="/admin/public/icons.svg#sun"></use></svg>
|
||||
</summary>
|
||||
<ul data-theme-menu class="dropdown-content menu bg-base-100 text-base-content rounded-box z-50 w-40 p-2 shadow-lg">
|
||||
<li>
|
||||
<button type="button" class="theme-option" data-value="system">
|
||||
{{ icon "sun-moon" "size-4" }}
|
||||
<span>System</span>
|
||||
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="theme-option" data-value="light">
|
||||
{{ icon "sun" "size-4" }}
|
||||
<span>Light</span>
|
||||
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" class="theme-option" data-value="dark">
|
||||
{{ icon "moon" "size-4" }}
|
||||
<span>Dark</span>
|
||||
{{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
@@ -2,82 +2,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{template "admin-head"}}
|
||||
<title>{{.Title}} - Hold Admin</title>
|
||||
<script src="/admin/public/js/htmx.min.js"></script>
|
||||
<script src="/admin/public/js/lucide.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/public/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen flex flex-col bg-base-200">
|
||||
{{template "nav" .}}
|
||||
|
||||
<main class="container">
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-{{.Flash.Category}}">{{.Flash.Message}}</div>
|
||||
<div role="alert" class="alert alert-{{.Flash.Category}} mb-4">
|
||||
<span>{{.Flash.Message}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Crew Management</h1>
|
||||
<a href="/admin/crew/add" class="btn btn-primary">
|
||||
<i data-lucide="user-plus"></i>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Crew Management</h1>
|
||||
<a href="/admin/crew/add" class="btn btn-primary gap-2">
|
||||
{{ icon "user-plus" "size-4" }}
|
||||
Add Crew Member
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{if .Crew}}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Role</th>
|
||||
<th>Permissions</th>
|
||||
<th>Tier</th>
|
||||
<th>Usage</th>
|
||||
<th>Added</th>
|
||||
<th class="actions-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="crew-list">
|
||||
{{range .Crew}}
|
||||
<tr id="crew-{{.RKey}}">
|
||||
<td class="member-cell">
|
||||
{{if .Handle}}<strong>{{.Handle}}</strong><br>{{end}}
|
||||
<code class="did-code">{{.DID}}</code>
|
||||
</td>
|
||||
<td>{{.Role}}</td>
|
||||
<td class="permissions-cell">{{range .Permissions}}<span class="badge">{{.}}</span>{{end}}</td>
|
||||
<td><span class="badge badge-tier">{{.Tier}}</span><br><small class="tier-limit">{{.TierLimit}}</small></td>
|
||||
<td>
|
||||
<div class="usage-cell">
|
||||
<span>{{.UsageHuman}}</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill {{if gt .UsagePercent 90}}danger{{else if gt .UsagePercent 75}}warning{{end}}" style="width: {{.UsagePercent}}%"></div>
|
||||
</div>
|
||||
<small>{{.UsagePercent}}%</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="date-cell">{{formatTime .AddedAt}}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/crew/{{.RKey}}" class="btn btn-icon" title="Edit" aria-label="Edit crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}">
|
||||
<i data-lucide="pencil"></i>
|
||||
</a>
|
||||
<button class="btn btn-icon btn-danger" title="Delete" aria-label="Remove crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}" hx-post="/admin/crew/{{.RKey}}/delete" hx-confirm="Remove this crew member?" hx-target="#crew-{{.RKey}}" hx-swap="outerHTML">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Role</th>
|
||||
<th>Permissions</th>
|
||||
<th>Tier</th>
|
||||
<th>Usage</th>
|
||||
<th>Added</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="crew-list">
|
||||
{{range .Crew}}
|
||||
<tr id="crew-{{.RKey}}">
|
||||
<td>
|
||||
<div>
|
||||
{{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}}
|
||||
<code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{.Role}}</td>
|
||||
<td>
|
||||
{{range .Permissions}}
|
||||
<span class="badge badge-ghost badge-sm mr-1 mb-1">{{.}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-primary badge-sm">{{.Tier}}</span>
|
||||
<br><small class="text-base-content/50">{{.TierLimit}}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-col gap-1 min-w-24">
|
||||
<span class="text-sm">{{.UsageHuman}}</span>
|
||||
<progress class="progress {{if gt .UsagePercent 90}}progress-error{{else if gt .UsagePercent 75}}progress-warning{{else}}progress-primary{{end}} w-full" value="{{.UsagePercent}}" max="100"></progress>
|
||||
<small class="text-base-content/50">{{.UsagePercent}}%</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-sm text-base-content/70">{{formatTime .AddedAt}}</td>
|
||||
<td>
|
||||
<div class="flex gap-1 justify-end">
|
||||
<a href="/admin/crew/{{.RKey}}" class="btn btn-ghost btn-sm btn-square" title="Edit" aria-label="Edit crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}">
|
||||
{{ icon "pencil" "size-4" }}
|
||||
</a>
|
||||
<button class="btn btn-error btn-ghost btn-sm btn-square" title="Delete" aria-label="Remove crew member {{if .Handle}}{{.Handle}}{{else}}{{.DID}}{{end}}" hx-post="/admin/crew/{{.RKey}}/delete" hx-confirm="Remove this crew member?" hx-target="#crew-{{.RKey}}" hx-swap="outerHTML">
|
||||
{{ icon "trash-2" "size-4" }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">No crew members yet. <a href="/admin/crew/add">Add your first crew member</a>.</p>
|
||||
<div class="text-center py-12 text-base-content/60">
|
||||
<p>No crew members yet. <a href="/admin/crew/add" class="link link-primary">Add your first crew member</a>.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
|
||||
<footer class="footer"><p>Hold: <code>{{.HoldDID}}</code></p></footer>
|
||||
|
||||
<script>lucide.createIcons();</script>
|
||||
<footer class="text-center p-6 text-base-content/50 text-sm">
|
||||
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -2,151 +2,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{template "admin-head"}}
|
||||
<title>{{.Title}} - Hold Admin</title>
|
||||
<script src="/admin/public/js/htmx.min.js"></script>
|
||||
<script src="/admin/public/js/lucide.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/public/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen flex flex-col bg-base-200">
|
||||
{{template "nav" .}}
|
||||
|
||||
<main class="container">
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-{{.Flash.Category}}">{{.Flash.Message}}</div>
|
||||
<div role="alert" class="alert alert-{{.Flash.Category}} mb-4">
|
||||
<span>{{.Flash.Message}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Add Crew Member</h1>
|
||||
<a href="/admin/crew" class="btn">
|
||||
<i data-lucide="arrow-left"></i>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Add Crew Member</h1>
|
||||
<a href="/admin/crew" class="btn btn-ghost gap-2">
|
||||
{{ icon "arrow-left" "size-4" }}
|
||||
Back to Crew
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form action="/admin/crew/add" method="POST" class="form">
|
||||
<div class="form-group">
|
||||
<label for="did">DID</label>
|
||||
<div class="input-with-lookup">
|
||||
<input type="text" id="did" name="did" placeholder="did:plc:..." required>
|
||||
<button type="button" id="lookup-btn" class="btn btn-sm" title="Lookup handle" aria-label="Lookup handle from DID">
|
||||
<i data-lucide="search"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small>The member's ATProto DID</small>
|
||||
<div id="handle-result" class="handle-lookup-result"></div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<form action="/admin/crew/add" method="POST" class="max-w-lg">
|
||||
<fieldset class="fieldset mb-6">
|
||||
<label class="fieldset-label font-medium" for="did">DID</label>
|
||||
<div class="join w-full">
|
||||
<input type="text" id="did" name="did"
|
||||
class="input input-bordered join-item flex-1"
|
||||
placeholder="did:plc:..." required>
|
||||
<button type="button" id="lookup-btn" class="btn btn-ghost join-item" title="Lookup handle" aria-label="Lookup handle from DID">
|
||||
{{ icon "search" "size-4" }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="fieldset-label text-base-content/50">The member's ATProto DID</span>
|
||||
<div id="handle-result" class="mt-2"></div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">Role</label>
|
||||
<input type="text" id="role" name="role" placeholder="member" value="member">
|
||||
<small>Optional role name (e.g., member, admin)</small>
|
||||
</div>
|
||||
<fieldset class="fieldset mb-6">
|
||||
<label class="fieldset-label font-medium" for="role">Role</label>
|
||||
<input type="text" id="role" name="role"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="member" value="member">
|
||||
<span class="fieldset-label text-base-content/50">Optional role name (e.g., member, admin)</span>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Permissions</label>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="perm_read" checked>
|
||||
<span>blob:read</span>
|
||||
<small>Can pull/download blobs</small>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="perm_write" checked>
|
||||
<span>blob:write</span>
|
||||
<small>Can push/upload blobs</small>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="perm_admin">
|
||||
<span>crew:admin</span>
|
||||
<small>Can manage crew members</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="fieldset mb-6">
|
||||
<label class="fieldset-label font-medium">Permissions</label>
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="perm_read" class="checkbox checkbox-sm checkbox-primary mt-0.5" checked>
|
||||
<span class="flex flex-col">
|
||||
<span class="font-medium text-sm">blob:read</span>
|
||||
<span class="text-xs text-base-content/50">Can pull/download blobs</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="perm_write" class="checkbox checkbox-sm checkbox-primary mt-0.5" checked>
|
||||
<span class="flex flex-col">
|
||||
<span class="font-medium text-sm">blob:write</span>
|
||||
<span class="text-xs text-base-content/50">Can push/upload blobs</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="perm_admin" class="checkbox checkbox-sm checkbox-primary mt-0.5">
|
||||
<span class="flex flex-col">
|
||||
<span class="font-medium text-sm">crew:admin</span>
|
||||
<span class="text-xs text-base-content/50">Can manage crew members</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{{if .Tiers}}
|
||||
<div class="form-group">
|
||||
<label for="tier">Quota Tier</label>
|
||||
<select id="tier" name="tier">
|
||||
{{range .Tiers}}
|
||||
<option value="{{.Key}}">{{.Name}} ({{.Limit}})</option>
|
||||
{{if .Tiers}}
|
||||
<fieldset class="fieldset mb-6">
|
||||
<label class="fieldset-label font-medium" for="tier">Quota Tier</label>
|
||||
<select id="tier" name="tier" class="select select-bordered w-full">
|
||||
{{range .Tiers}}
|
||||
<option value="{{.Key}}">{{.Name}} ({{.Limit}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<span class="fieldset-label text-base-content/50">Storage quota limit for this member</span>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
</select>
|
||||
<small>Storage quota limit for this member</small>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i data-lucide="user-plus"></i>
|
||||
Add Crew Member
|
||||
</button>
|
||||
<a href="/admin/crew" class="btn">Cancel</a>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary gap-2">
|
||||
{{ icon "user-plus" "size-4" }}
|
||||
Add Crew Member
|
||||
</button>
|
||||
<a href="/admin/crew" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><p>Hold: <code>{{.HoldDID}}</code></p></footer>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// DID to handle lookup
|
||||
const didInput = document.getElementById('did');
|
||||
const lookupBtn = document.getElementById('lookup-btn');
|
||||
const handleResult = document.getElementById('handle-result');
|
||||
|
||||
async function lookupHandle() {
|
||||
const did = didInput.value.trim();
|
||||
if (!did.startsWith('did:')) {
|
||||
handleResult.innerHTML = '<span class="error">Invalid DID format</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
handleResult.innerHTML = '<span class="loading">Looking up...</span>';
|
||||
|
||||
try {
|
||||
// Use plc.directory for did:plc or did:web resolution
|
||||
let url;
|
||||
if (did.startsWith('did:plc:')) {
|
||||
url = `https://plc.directory/${did}`;
|
||||
} else if (did.startsWith('did:web:')) {
|
||||
const host = did.replace('did:web:', '').replace(/%3A/g, ':');
|
||||
url = `https://${host}/.well-known/did.json`;
|
||||
} else {
|
||||
handleResult.innerHTML = '<span class="error">Unsupported DID method</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('DID not found');
|
||||
|
||||
const doc = await resp.json();
|
||||
// Look for handle in alsoKnownAs
|
||||
const aka = doc.alsoKnownAs || [];
|
||||
const handleUri = aka.find(u => u.startsWith('at://'));
|
||||
if (handleUri) {
|
||||
const handle = handleUri.replace('at://', '');
|
||||
handleResult.innerHTML = `<span class="success"><i data-lucide="check-circle"></i> <strong>${handle}</strong></span>`;
|
||||
lucide.createIcons();
|
||||
} else {
|
||||
handleResult.innerHTML = '<span class="warning">No handle found</span>';
|
||||
}
|
||||
} catch (err) {
|
||||
handleResult.innerHTML = `<span class="error">Lookup failed: ${err.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
lookupBtn.addEventListener('click', lookupHandle);
|
||||
|
||||
// Auto-lookup on blur if DID looks valid
|
||||
didInput.addEventListener('blur', function() {
|
||||
if (this.value.startsWith('did:') && this.value.length > 10) {
|
||||
lookupHandle();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<footer class="text-center p-6 text-base-content/50 text-sm">
|
||||
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -2,88 +2,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{template "admin-head"}}
|
||||
<title>{{.Title}} - Hold Admin</title>
|
||||
<script src="/admin/public/js/htmx.min.js"></script>
|
||||
<script src="/admin/public/js/lucide.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/public/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen flex flex-col bg-base-200">
|
||||
{{template "nav" .}}
|
||||
|
||||
<main class="container">
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-{{.Flash.Category}}">{{.Flash.Message}}</div>
|
||||
<div role="alert" class="alert alert-{{.Flash.Category}} mb-4">
|
||||
<span>{{.Flash.Message}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Edit Crew Member</h1>
|
||||
<a href="/admin/crew" class="btn">
|
||||
<i data-lucide="arrow-left"></i>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Edit Crew Member</h1>
|
||||
<a href="/admin/crew" class="btn btn-ghost gap-2">
|
||||
{{ icon "arrow-left" "size-4" }}
|
||||
Back to Crew
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header member-header">
|
||||
<div class="member-info">
|
||||
{{if .MemberHandle}}<strong id="member-handle">{{.MemberHandle}}</strong>{{end}}
|
||||
<code class="did-code">{{.Member.Member}}</code>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="bg-base-200 px-6 py-4 rounded-t-2xl border-b border-base-300">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col gap-1">
|
||||
{{if .MemberHandle}}<strong id="member-handle" class="text-lg">{{.MemberHandle}}</strong>{{end}}
|
||||
<code class="text-xs text-base-content/50 break-all font-mono">{{.Member.Member}}</code>
|
||||
</div>
|
||||
{{if .IsOwner}}<span class="badge badge-warning">Owner</span>{{end}}
|
||||
</div>
|
||||
{{if .IsOwner}}<span class="badge badge-gold">Owner</span>{{end}}
|
||||
</div>
|
||||
|
||||
<form action="/admin/crew/{{.RKey}}/update" method="POST" class="form">
|
||||
<div class="form-group">
|
||||
<label for="role">Role</label>
|
||||
<input type="text" id="role" name="role" value="{{.Member.Role}}" {{if .IsOwner}}disabled{{end}}>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/admin/crew/{{.RKey}}/update" method="POST" class="max-w-lg">
|
||||
<fieldset class="fieldset mb-6">
|
||||
<label class="fieldset-label font-medium" for="role">Role</label>
|
||||
<input type="text" id="role" name="role"
|
||||
class="input input-bordered w-full"
|
||||
value="{{.Member.Role}}" {{if .IsOwner}}disabled{{end}}>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Permissions</label>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="perm_read" {{if contains .Member.Permissions "blob:read"}}checked{{end}} {{if .IsOwner}}disabled{{end}}>
|
||||
<span>blob:read</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="perm_write" {{if contains .Member.Permissions "blob:write"}}checked{{end}} {{if .IsOwner}}disabled{{end}}>
|
||||
<span>blob:write</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="perm_admin" {{if contains .Member.Permissions "crew:admin"}}checked{{end}} {{if .IsOwner}}disabled{{end}}>
|
||||
<span>crew:admin</span>
|
||||
</label>
|
||||
<fieldset class="fieldset mb-6">
|
||||
<label class="fieldset-label font-medium">Permissions</label>
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="perm_read" class="checkbox checkbox-sm checkbox-primary mt-0.5" {{if contains .Member.Permissions "blob:read"}}checked{{end}} {{if .IsOwner}}disabled{{end}}>
|
||||
<span class="font-medium text-sm">blob:read</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="perm_write" class="checkbox checkbox-sm checkbox-primary mt-0.5" {{if contains .Member.Permissions "blob:write"}}checked{{end}} {{if .IsOwner}}disabled{{end}}>
|
||||
<span class="font-medium text-sm">blob:write</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="perm_admin" class="checkbox checkbox-sm checkbox-primary mt-0.5" {{if contains .Member.Permissions "crew:admin"}}checked{{end}} {{if .IsOwner}}disabled{{end}}>
|
||||
<span class="font-medium text-sm">crew:admin</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{{if .Tiers}}
|
||||
<fieldset class="fieldset mb-6">
|
||||
<label class="fieldset-label font-medium" for="tier">Quota Tier</label>
|
||||
<select id="tier" name="tier" class="select select-bordered w-full" {{if .IsOwner}}disabled{{end}}>
|
||||
{{range .Tiers}}
|
||||
<option value="{{.Key}}" {{if eq .Key $.Member.Tier}}selected{{end}}>{{.Name}} ({{.Limit}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
|
||||
{{if .IsOwner}}
|
||||
<div class="p-4 bg-base-200 rounded-lg text-base-content/60 italic">
|
||||
Owner permissions cannot be modified.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Tiers}}
|
||||
<div class="form-group">
|
||||
<label for="tier">Quota Tier</label>
|
||||
<select id="tier" name="tier" {{if .IsOwner}}disabled{{end}}>
|
||||
{{range .Tiers}}
|
||||
<option value="{{.Key}}" {{if eq .Key $.Member.Tier}}selected{{end}}>{{.Name}} ({{.Limit}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .IsOwner}}
|
||||
<p class="note">Owner permissions cannot be modified.</p>
|
||||
{{else}}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/crew" class="btn">Cancel</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/crew" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><p>Hold: <code>{{.HoldDID}}</code></p></footer>
|
||||
|
||||
<script>lucide.createIcons();</script>
|
||||
<footer class="text-center p-6 text-base-content/50 text-sm">
|
||||
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -2,57 +2,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{template "admin-head"}}
|
||||
<title>{{.Title}} - Hold Admin</title>
|
||||
<script src="/admin/public/js/htmx.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/public/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen flex flex-col bg-base-200">
|
||||
{{template "nav" .}}
|
||||
|
||||
<main class="container">
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-{{.Flash.Category}}">{{.Flash.Message}}</div>
|
||||
<div role="alert" class="alert alert-{{.Flash.Category}} mb-4">
|
||||
<span>{{.Flash.Message}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h1>Dashboard</h1>
|
||||
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<h3>Crew Members</h3>
|
||||
<p class="stat-value">{{.Stats.TotalCrewMembers}}</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||
<div class="stats shadow bg-base-100">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Crew Members</div>
|
||||
<div class="stat-value">{{.Stats.TotalCrewMembers}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML">
|
||||
<p class="loading">Loading storage stats...</p>
|
||||
<div class="stats shadow bg-base-100" hx-get="/admin/api/stats" hx-trigger="load" hx-swap="innerHTML">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Storage</div>
|
||||
<div class="stat-value text-base-content/30 italic text-lg">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2>Tier Distribution</h2>
|
||||
{{if .Stats.TierDistribution}}
|
||||
<div class="tier-chart">
|
||||
{{range $tier, $count := .Stats.TierDistribution}}
|
||||
<div class="tier-bar">
|
||||
<span class="tier-name">{{$tier}}</span>
|
||||
<span class="tier-count">{{$count}} members</span>
|
||||
<div class="card bg-base-100 shadow-sm mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">Tier Distribution</h2>
|
||||
{{if .Stats.TierDistribution}}
|
||||
<div class="flex flex-col gap-2">
|
||||
{{range $tier, $count := .Stats.TierDistribution}}
|
||||
<div class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
|
||||
<span class="font-medium">{{$tier}}</span>
|
||||
<span class="text-base-content/60">{{$count}} members</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-center py-6 text-base-content/60">No crew members yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">No crew members yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2>Top Users by Storage</h2>
|
||||
<div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML">
|
||||
<p class="loading">Loading top users...</p>
|
||||
<div class="card bg-base-100 shadow-sm mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">Top Users by Storage</h2>
|
||||
<div hx-get="/admin/api/top-users?limit=10" hx-trigger="load" hx-swap="innerHTML">
|
||||
<p class="text-base-content/50 italic">Loading top users...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><p>Hold: <code>{{.HoldDID}}</code></p></footer>
|
||||
<footer class="text-center p-6 text-base-content/50 text-sm">
|
||||
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{template "admin-head"}}
|
||||
<title>Error - Hold Admin</title>
|
||||
<link rel="stylesheet" href="/admin/public/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen flex flex-col bg-base-200">
|
||||
{{template "nav" .}}
|
||||
|
||||
<main class="container">
|
||||
<div class="error-page">
|
||||
<h1>Error</h1>
|
||||
<p class="error-message">{{.Error}}</p>
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
|
||||
<div class="text-center py-16">
|
||||
<h1 class="text-2xl font-bold mb-4">Error</h1>
|
||||
<p class="text-error text-lg mb-6">{{.Error}}</p>
|
||||
<a href="/admin" class="btn btn-primary">Back to Dashboard</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><p>Hold: <code>{{.HoldDID}}</code></p></footer>
|
||||
<footer class="text-center p-6 text-base-content/50 text-sm">
|
||||
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -2,45 +2,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{template "admin-head"}}
|
||||
<title>Login - Hold Admin</title>
|
||||
<link rel="stylesheet" href="/admin/public/css/admin.css">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h1>Hold Admin</h1>
|
||||
<p class="login-subtitle">Sign in with your ATProto account</p>
|
||||
<body class="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div class="w-full max-w-sm p-4">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title justify-center text-2xl">Hold Admin</h1>
|
||||
<p class="text-center text-base-content/60 mb-4">Sign in with your ATProto account</p>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="flash flash-error">
|
||||
{{.Error}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form action="/admin/auth/oauth/authorize" method="GET" class="login-form">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="handle">Handle or DID</label>
|
||||
<input type="text" id="handle" name="handle"
|
||||
placeholder="alice.bsky.social"
|
||||
required autofocus>
|
||||
{{if .Error}}
|
||||
<div role="alert" class="alert alert-error mb-4">
|
||||
<span>{{.Error}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
<form action="/admin/auth/oauth/authorize" method="GET">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
|
||||
<p class="login-note">
|
||||
Only the hold owner can access the admin panel.
|
||||
</p>
|
||||
<fieldset class="fieldset mb-4">
|
||||
<label class="fieldset-label" for="handle">Handle or DID</label>
|
||||
<input type="text" id="handle" name="handle"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="alice.bsky.social"
|
||||
required autofocus>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-base-content/60 mt-4">
|
||||
Only the hold owner can access the admin panel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="login-footer">
|
||||
<p>Hold: <code>{{.HoldDID}}</code></p>
|
||||
<footer class="text-center mt-8 text-base-content/50 text-xs">
|
||||
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -2,90 +2,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{template "admin-head"}}
|
||||
<title>{{.Title}} - Hold Admin</title>
|
||||
<script src="/admin/public/js/htmx.min.js"></script>
|
||||
<script src="/admin/public/js/lucide.min.js"></script>
|
||||
<link rel="stylesheet" href="/admin/public/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="min-h-screen flex flex-col bg-base-200">
|
||||
{{template "nav" .}}
|
||||
|
||||
<main class="container">
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-{{.Flash.Category}}">{{.Flash.Message}}</div>
|
||||
<div role="alert" class="alert alert-{{.Flash.Category}} mb-4">
|
||||
<span>{{.Flash.Message}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<h1>Hold Settings</h1>
|
||||
<h1 class="text-2xl font-bold mb-6">Hold Settings</h1>
|
||||
|
||||
<form action="/admin/settings/update" method="POST" class="form settings-form">
|
||||
<section class="section">
|
||||
<h2>Access Control</h2>
|
||||
<form action="/admin/settings/update" method="POST">
|
||||
<div class="card bg-base-100 shadow-sm mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">Access Control</h2>
|
||||
|
||||
<label class="toggle-setting">
|
||||
<input type="checkbox" name="public" {{if .Settings.Public}}checked{{end}}>
|
||||
<span class="toggle-label">
|
||||
<strong>Public Hold</strong>
|
||||
<small>Allow anonymous users to read blobs (no auth required for pulls)</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-4 p-4 bg-base-200 rounded-lg mb-3 cursor-pointer">
|
||||
<input type="checkbox" name="public" class="toggle toggle-primary mt-0.5" {{if .Settings.Public}}checked{{end}}>
|
||||
<span class="flex flex-col">
|
||||
<strong>Public Hold</strong>
|
||||
<small class="text-base-content/60">Allow anonymous users to read blobs (no auth required for pulls)</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle-setting">
|
||||
<input type="checkbox" name="allow_all_crew" {{if .Settings.AllowAllCrew}}checked{{end}}>
|
||||
<span class="toggle-label">
|
||||
<strong>Open Registration</strong>
|
||||
<small>Allow any authenticated user to join as crew via requestCrew</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
<label class="flex items-start gap-4 p-4 bg-base-200 rounded-lg cursor-pointer">
|
||||
<input type="checkbox" name="allow_all_crew" class="toggle toggle-primary mt-0.5" {{if .Settings.AllowAllCrew}}checked{{end}}>
|
||||
<span class="flex flex-col">
|
||||
<strong>Open Registration</strong>
|
||||
<small class="text-base-content/60">Allow any authenticated user to join as crew via requestCrew</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2>Integrations</h2>
|
||||
<div class="card bg-base-100 shadow-sm mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">Integrations</h2>
|
||||
|
||||
<label class="toggle-setting">
|
||||
<input type="checkbox" name="enable_bluesky_posts" {{if .Settings.EnableBlueskyPosts}}checked{{end}}>
|
||||
<span class="toggle-label">
|
||||
<strong>Bluesky Posts</strong>
|
||||
<small>Post to Bluesky when images are pushed to this hold</small>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
<label class="flex items-start gap-4 p-4 bg-base-200 rounded-lg cursor-pointer">
|
||||
<input type="checkbox" name="enable_bluesky_posts" class="toggle toggle-primary mt-0.5" {{if .Settings.EnableBlueskyPosts}}checked{{end}}>
|
||||
<span class="flex flex-col">
|
||||
<strong>Bluesky Posts</strong>
|
||||
<small class="text-base-content/60">Post to Bluesky when images are pushed to this hold</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="section">
|
||||
<h2>Hold Information</h2>
|
||||
<dl class="info-list">
|
||||
<dt>Hold DID</dt>
|
||||
<dd><code>{{.Settings.HoldDID}}</code></dd>
|
||||
<dt>Owner</dt>
|
||||
<dd>
|
||||
{{if .Settings.OwnerHandle}}<strong>{{.Settings.OwnerHandle}}</strong><br>{{end}}
|
||||
<code class="did-code">{{.Settings.OwnerDID}}</code>
|
||||
</dd>
|
||||
<dt>Quotas</dt>
|
||||
<dd>
|
||||
{{if .Settings.QuotasEnabled}}
|
||||
<span class="badge badge-tier">Enabled</span>
|
||||
<small>({{.Settings.TierCount}} tiers, default: {{.Settings.DefaultTier}})</small>
|
||||
{{else}}
|
||||
<span class="badge">Disabled</span>
|
||||
{{end}}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<div class="card bg-base-100 shadow-sm mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg">Hold Information</h2>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-3 mt-2">
|
||||
<dt class="font-medium text-base-content/70">Hold DID</dt>
|
||||
<dd><code class="font-mono text-sm">{{.Settings.HoldDID}}</code></dd>
|
||||
<dt class="font-medium text-base-content/70">Owner</dt>
|
||||
<dd>
|
||||
{{if .Settings.OwnerHandle}}<strong>{{.Settings.OwnerHandle}}</strong><br>{{end}}
|
||||
<code class="text-xs text-base-content/50 break-all font-mono">{{.Settings.OwnerDID}}</code>
|
||||
</dd>
|
||||
<dt class="font-medium text-base-content/70">Quotas</dt>
|
||||
<dd>
|
||||
{{if .Settings.QuotasEnabled}}
|
||||
<span class="badge badge-primary badge-sm">Enabled</span>
|
||||
<small class="text-base-content/60">({{.Settings.TierCount}} tiers, default: {{.Settings.DefaultTier}})</small>
|
||||
{{else}}
|
||||
<span class="badge badge-ghost badge-sm">Disabled</span>
|
||||
{{end}}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i data-lucide="save"></i>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn btn-primary gap-2">
|
||||
{{ icon "save" "size-4" }}
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><p>Hold: <code>{{.HoldDID}}</code></p></footer>
|
||||
|
||||
<script>lucide.createIcons();</script>
|
||||
<footer class="text-center p-6 text-base-content/50 text-sm">
|
||||
<p>Hold: <code class="font-mono">{{.HoldDID}}</code></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
{{define "partials/top_users.html"}}
|
||||
{{if .Users}}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Usage</th>
|
||||
<th>Blobs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td class="member-cell">
|
||||
{{if .Handle}}<strong>{{.Handle}}</strong><br>{{end}}
|
||||
<code class="did-code">{{.DID}}</code>
|
||||
</td>
|
||||
<td>{{.UsageHuman}}</td>
|
||||
<td>{{.BlobCount}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
<th>Usage</th>
|
||||
<th>Blobs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
{{if .Handle}}<strong class="text-base-content">{{.Handle}}</strong><br>{{end}}
|
||||
<code class="text-xs text-base-content/50 break-all font-mono">{{.DID}}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{.UsageHuman}}</td>
|
||||
<td>{{.BlobCount}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">No usage data yet.</p>
|
||||
<p class="text-center py-6 text-base-content/60">No usage data yet.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{{define "partials/usage_stats.html"}}
|
||||
<h3>Storage</h3>
|
||||
<p class="stat-value">{{.Stats.TotalHuman}}</p>
|
||||
<p class="stat-detail">{{.Stats.UniqueDigests}} unique blobs</p>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Storage</div>
|
||||
<div class="stat-value">{{.Stats.TotalHuman}}</div>
|
||||
<div class="stat-desc">{{.Stats.UniqueDigests}} unique blobs</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -22,7 +22,10 @@ function discoverIcons() {
|
||||
|
||||
// 1. Scan templates for {{ icon "name" ... }}
|
||||
const templatePattern = /\{\{\s*icon\s+"([^"]+)"/g;
|
||||
const templates = globSync('pkg/appview/templates/**/*.html', { cwd: basePath });
|
||||
const templates = [
|
||||
...globSync('pkg/appview/templates/**/*.html', { cwd: basePath }),
|
||||
...globSync('pkg/hold/admin/templates/**/*.html', { cwd: basePath }),
|
||||
];
|
||||
templates.forEach(file => {
|
||||
const content = fs.readFileSync(path.join(basePath, file), 'utf8');
|
||||
let match;
|
||||
@@ -35,7 +38,9 @@ function discoverIcons() {
|
||||
const svgUsePattern = /icons\.svg#([a-z0-9-]+)/g;
|
||||
const allFiles = [
|
||||
...globSync('pkg/appview/templates/**/*.html', { cwd: basePath }),
|
||||
...globSync('pkg/hold/admin/templates/**/*.html', { cwd: basePath }),
|
||||
...globSync('pkg/appview/src/js/**/*.js', { cwd: basePath }),
|
||||
...globSync('pkg/hold/admin/src/js/**/*.js', { cwd: basePath }),
|
||||
];
|
||||
allFiles.forEach(file => {
|
||||
const content = fs.readFileSync(path.join(basePath, file), 'utf8');
|
||||
@@ -117,8 +122,9 @@ function getLucideIcon(iconName) {
|
||||
function generateSprite() {
|
||||
const symbols = [];
|
||||
|
||||
// Process Lucide icons
|
||||
// Process Lucide icons (skip custom icons handled below)
|
||||
for (const iconName of ICONS) {
|
||||
if (CUSTOM_ICONS[iconName]) continue;
|
||||
const icon = getLucideIcon(iconName);
|
||||
if (icon) {
|
||||
symbols.push(` <symbol id="${iconName}" viewBox="${icon.viewBox}">${icon.content}</symbol>`);
|
||||
@@ -138,12 +144,17 @@ ${symbols.join('\n')}
|
||||
}
|
||||
|
||||
// Main
|
||||
const outputPath = path.join(__dirname, '..', 'pkg', 'appview', 'public', 'icons.svg');
|
||||
const outputPaths = [
|
||||
path.join(__dirname, '..', 'pkg', 'appview', 'public', 'icons.svg'),
|
||||
path.join(__dirname, '..', 'pkg', 'hold', 'admin', 'public', 'icons.svg'),
|
||||
];
|
||||
|
||||
try {
|
||||
const sprite = generateSprite();
|
||||
fs.writeFileSync(outputPath, sprite);
|
||||
console.log(`Generated ${outputPath}`);
|
||||
for (const outputPath of outputPaths) {
|
||||
fs.writeFileSync(outputPath, sprite);
|
||||
console.log(`Generated ${outputPath}`);
|
||||
}
|
||||
console.log(`Discovered icons (${ICONS.length}): ${ICONS.join(', ')}`);
|
||||
console.log(`Custom icons (${Object.keys(CUSTOM_ICONS).length}): ${Object.keys(CUSTOM_ICONS).join(', ')}`);
|
||||
console.log(`Total: ${ICONS.length + Object.keys(CUSTOM_ICONS).length} icons`);
|
||||
|
||||
@@ -3,6 +3,7 @@ module.exports = {
|
||||
content: [
|
||||
"./pkg/appview/templates/**/*.html",
|
||||
"./pkg/appview/public/js/**/*.js",
|
||||
"./pkg/hold/admin/templates/**/*.html",
|
||||
],
|
||||
// Enable dark: variant based on 'dark' class (already toggled in head.html)
|
||||
darkMode: 'class',
|
||||
|
||||
Reference in New Issue
Block a user