update settings page, move admin-panel to tailwind/daisy

This commit is contained in:
Evan Jarrett
2026-02-06 11:23:12 -06:00
parent 834bb8d36c
commit ef0161fb0e
49 changed files with 1690 additions and 1585 deletions
+3 -2
View File
@@ -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
+3 -3
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
+374
View File
@@ -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
}
+22
View File
@@ -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)
}
+3 -3
View File
@@ -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
View File
@@ -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);
@@ -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);
-21
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
+18 -17
View File
@@ -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
}
+43 -23
View File
@@ -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
View File
@@ -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

+7
View File
@@ -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)
+1
View File
@@ -6,6 +6,7 @@
/* Content sources for class detection */
@source "../../templates/**/*.html";
@source "../../public/js/**/*.js";
@source "../../../hold/admin/templates/**/*.html";
@plugin "@tailwindcss/typography";
+357 -226
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-697
View File
@@ -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
+47
View File
@@ -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

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+140
View File
@@ -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}}
+10 -8
View File
@@ -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}}
+73 -60
View File
@@ -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}}
+81 -126
View File
@@ -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}}
+69 -63
View File
@@ -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}}
+42 -32
View File
@@ -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}}
+9 -9
View File
@@ -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}}
+32 -31
View File
@@ -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>
+68 -64
View File
@@ -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}}
+16 -5
View File
@@ -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`);
+1
View File
@@ -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',