nandi/gleanpublic Fork 0
e4df89f
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

feat: introduce svelte frontend and redesignUnverified

Julien Robert committed 2026-07-02T20:47:03+02:00 Browse files
e4df89f parent: 7f52a94
modified .env.example +37 -7
@@ -1,6 +1,19 @@
1+# Glean environment variables.
2+#
3+# ─────────────────────────────── BACKEND (Go API) ──────────────────────────────
4+
5+# Listen address for the Go HTTP server.
16 GLEAN_ADDR=:8080
7+# SQLite database path.
28 GLEAN_DB=glean.db
9+# HMAC key for signing session cookies. Generate with: openssl rand -hex 32
310 GLEAN_SESSION_KEY=change-me-to-a-random-string
11+# Public browser origin of the frontend. The backend uses this to validate the
12+# CSRF Origin header and to build the OAuth callback URL. MUST be the origin
13+# users see in their browser (e.g. https://glean.at), NOT the backend's own URL.
14+GLEAN_FRONTEND_URL=https://glean.at
15+
16+# ATProto / Jetstream.
417 GLEAN_JETSTREAM=wss://jetstream1.eurosky.network
518 GLEAN_PLC_URL=https://plc.eurosky.network
619 GLEAN_SYNC_INTERVAL=8h
@@ -8,22 +21,39 @@ GLEAN_CLUSTER_INTERVAL=60m
821 GLEAN_FETCH_INTERVAL=15m
922 GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription
1023 GLEAN_BACKFILL_CONCURRENCY=5
11-# Leave empty for localhost OAuth (development)
12-# GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
13-# GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback
14-# Embeddings (recommended as it powers content-based feed/article recommendations)
15-# Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.)
24+
25+# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses OAuth localhost flow).
26+# In production, set both. The callback is served by the FRONTEND at /api/auth/callback
27+# (the frontend proxies it to this backend), so it points at the frontend origin.
28+GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
29+GLEAN_OAUTH_REDIRECT_URL=https://glean.at/api/auth/callback
30+
31+# Embeddings (recommended as it powers content-based feed/article recommendations).
32+# Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.).
1633 # Without embeddings, recommendations rely only on subscription overlap and social graph.
1734 GLEAN_EMBED_BASE_URL=https://llms.example.com/v1
1835 GLEAN_EMBED_API_KEY=API-KEY-001
1936 GLEAN_EMBED_MODEL=qwen-embedding-4b
2037 GLEAN_EMBED_DIMENSION=2560
21-# LLM for article language detection and other text tasks
38+
39+# LLM for article language detection, digests, and other text tasks.
2240 # Any OpenAI-compatible /v1/chat/completions endpoint works.
23-# Without an LLM, all articles default to English and language filtering is disabled.
41+# Without an LLM, all articles default to English, language filtering is disabled,
42+# and digests are unavailable.
2443 GLEAN_LLM_BASE_URL=https://llms.example.com/v1
2544 GLEAN_LLM_API_KEY=API-KEY-001
2645 GLEAN_LLM_MODEL=qwen3.5-35b-a3b
46+
2747 # Enable Go pprof profiling server (off by default).
2848 # Set to a listen address like :6060 to enable, leave empty to disable.
2949 # GLEAN_PPROF_ADDR=:6060
50+
51+# ─────────────────────────────── FRONTEND (SvelteKit) ───────────────────────────
52+
53+# Base URL of the Go API. The SvelteKit server proxies /api/* here server-to-server.
54+GLEAN_API_URL=https://api.glean.at
55+# The public origin users see in their browser. Used by SvelteKit's adapter-node
56+# to set the canonical origin and must match GLEAN_FRONTEND_URL on the backend.
57+ORIGIN=https://glean.at
58+# Port the SvelteKit Node server listens on.
59+PORT=3000
@@ -1,6 +1,19 @@
1+# Glean environment variables.
2+#
3+# ─────────────────────────────── BACKEND (Go API) ──────────────────────────────
4+
5+# Listen address for the Go HTTP server.
1 GLEAN_ADDR=:80806 GLEAN_ADDR=:8080
7+# SQLite database path.
2 GLEAN_DB=glean.db8 GLEAN_DB=glean.db
9+# HMAC key for signing session cookies. Generate with: openssl rand -hex 32
3 GLEAN_SESSION_KEY=change-me-to-a-random-string10 GLEAN_SESSION_KEY=change-me-to-a-random-string
11+# Public browser origin of the frontend. The backend uses this to validate the
12+# CSRF Origin header and to build the OAuth callback URL. MUST be the origin
13+# users see in their browser (e.g. https://glean.at), NOT the backend's own URL.
14+GLEAN_FRONTEND_URL=https://glean.at
15+
16+# ATProto / Jetstream.
4 GLEAN_JETSTREAM=wss://jetstream1.eurosky.network17 GLEAN_JETSTREAM=wss://jetstream1.eurosky.network
5 GLEAN_PLC_URL=https://plc.eurosky.network18 GLEAN_PLC_URL=https://plc.eurosky.network
6 GLEAN_SYNC_INTERVAL=8h19 GLEAN_SYNC_INTERVAL=8h
@@ -8,22 +21,39 @@ GLEAN_CLUSTER_INTERVAL=60m
8 GLEAN_FETCH_INTERVAL=15m21 GLEAN_FETCH_INTERVAL=15m
9 GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription22 GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription
10 GLEAN_BACKFILL_CONCURRENCY=523 GLEAN_BACKFILL_CONCURRENCY=5
11-# Leave empty for localhost OAuth (development)24+
12-# GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata25+# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses OAuth localhost flow).
13-# GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback26+# In production, set both. The callback is served by the FRONTEND at /api/auth/callback
14-# Embeddings (recommended as it powers content-based feed/article recommendations)27+# (the frontend proxies it to this backend), so it points at the frontend origin.
15-# Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.)28+GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
29+GLEAN_OAUTH_REDIRECT_URL=https://glean.at/api/auth/callback
30+
31+# Embeddings (recommended as it powers content-based feed/article recommendations).
32+# Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.).
16 # Without embeddings, recommendations rely only on subscription overlap and social graph.33 # Without embeddings, recommendations rely only on subscription overlap and social graph.
17 GLEAN_EMBED_BASE_URL=https://llms.example.com/v134 GLEAN_EMBED_BASE_URL=https://llms.example.com/v1
18 GLEAN_EMBED_API_KEY=API-KEY-00135 GLEAN_EMBED_API_KEY=API-KEY-001
19 GLEAN_EMBED_MODEL=qwen-embedding-4b36 GLEAN_EMBED_MODEL=qwen-embedding-4b
20 GLEAN_EMBED_DIMENSION=256037 GLEAN_EMBED_DIMENSION=2560
21-# LLM for article language detection and other text tasks38+
39+# LLM for article language detection, digests, and other text tasks.
22 # Any OpenAI-compatible /v1/chat/completions endpoint works.40 # Any OpenAI-compatible /v1/chat/completions endpoint works.
23-# Without an LLM, all articles default to English and language filtering is disabled.41+# Without an LLM, all articles default to English, language filtering is disabled,
42+# and digests are unavailable.
24 GLEAN_LLM_BASE_URL=https://llms.example.com/v143 GLEAN_LLM_BASE_URL=https://llms.example.com/v1
25 GLEAN_LLM_API_KEY=API-KEY-00144 GLEAN_LLM_API_KEY=API-KEY-001
26 GLEAN_LLM_MODEL=qwen3.5-35b-a3b45 GLEAN_LLM_MODEL=qwen3.5-35b-a3b
46+
27 # Enable Go pprof profiling server (off by default).47 # Enable Go pprof profiling server (off by default).
28 # Set to a listen address like :6060 to enable, leave empty to disable.48 # Set to a listen address like :6060 to enable, leave empty to disable.
29 # GLEAN_PPROF_ADDR=:606049 # GLEAN_PPROF_ADDR=:6060
50+
51+# ─────────────────────────────── FRONTEND (SvelteKit) ───────────────────────────
52+
53+# Base URL of the Go API. The SvelteKit server proxies /api/* here server-to-server.
54+GLEAN_API_URL=https://api.glean.at
55+# The public origin users see in their browser. Used by SvelteKit's adapter-node
56+# to set the canonical origin and must match GLEAN_FRONTEND_URL on the backend.
57+ORIGIN=https://glean.at
58+# Port the SvelteKit Node server listens on.
59+PORT=3000
modified .gitignore +4 -2
@@ -25,9 +25,11 @@ go.work.sum
2525 # database
2626 *.db*
2727
28-# tailwind
29-static/output.css
28+# frontend
3029 node_modules/
30+web/node_modules/
31+web/build/
32+web/.svelte-kit/
3133
3234 # todo
3335 todo.md
@@ -25,9 +25,11 @@ go.work.sum
25 # database25 # database
26 *.db*26 *.db*
27 27
28-# tailwind28+# frontend
29-static/output.css
30 node_modules/29 node_modules/
30+web/node_modules/
31+web/build/
32+web/.svelte-kit/
31 33
32 # todo34 # todo
33 todo.md35 todo.md
modified Dockerfile +23 -6
@@ -7,21 +7,38 @@ WORKDIR /src
77 COPY go.mod go.sum ./
88 RUN go mod download
99
10-COPY package.json bun.lock ./
11-RUN bun install --frozen-lockfile
10+COPY web/package.json web/bun.lock* ./web/
11+RUN cd web && bun install --frozen-lockfile
1212
1313 COPY . .
14-RUN bunx tailwindcss -i ./static/input.css -o ./static/output.css --minify
1514
15+# Build the SvelteKit frontend (SSR via adapter-node).
16+RUN cd web && bun run build
17+
18+# Build the Go API binary.
1619 RUN --mount=type=cache,target=/root/.cache/go-build \
1720 CGO_CFLAGS="-I/src/internal/db/include -I$(go env GOMODCACHE)/github.com/mattn/go-sqlite3@$(grep 'mattn/go-sqlite3' go.mod | awk '{print $2}') -Du_int8_t=uint8_t -Du_int16_t=uint16_t -Du_int64_t=uint64_t" \
1821 CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o /glean .
1922
20-FROM alpine:3.21
23+FROM node:22-alpine
2124
2225 RUN apk add --no-cache ca-certificates
2326
27+# Go API binary.
2428 COPY --from=builder /glean /usr/local/bin/glean
2529
26-EXPOSE 8080
27-ENTRYPOINT ["glean"]
30+# SvelteKit SSR build output.
31+COPY --from=builder /src/web/build /app/web/build
32+COPY --from=builder /src/web/package.json /app/web/package.json
33+
34+WORKDIR /app/web
35+
36+# SvelteKit proxies /api to the Go API on localhost:8080.
37+ENV GLEAN_API_URL=http://127.0.0.1:8080
38+ENV PORT=3000
39+ENV ORIGIN=http://localhost:3000
40+
41+EXPOSE 3000
42+
43+# Run the Go API in the background, then the SvelteKit Node server in front.
44+CMD sh -c 'GLEAN_API_URL=http://127.0.0.1:8080 GLEAN_ADDR=127.0.0.1:8080 glean & node build/index.js'
@@ -7,21 +7,38 @@ WORKDIR /src
7 COPY go.mod go.sum ./7 COPY go.mod go.sum ./
8 RUN go mod download8 RUN go mod download
9 9
10-COPY package.json bun.lock ./10+COPY web/package.json web/bun.lock* ./web/
11-RUN bun install --frozen-lockfile11+RUN cd web && bun install --frozen-lockfile
12 12
13 COPY . .13 COPY . .
14-RUN bunx tailwindcss -i ./static/input.css -o ./static/output.css --minify
15 14
15+# Build the SvelteKit frontend (SSR via adapter-node).
16+RUN cd web && bun run build
17+
18+# Build the Go API binary.
16 RUN --mount=type=cache,target=/root/.cache/go-build \19 RUN --mount=type=cache,target=/root/.cache/go-build \
17 CGO_CFLAGS="-I/src/internal/db/include -I$(go env GOMODCACHE)/github.com/mattn/go-sqlite3@$(grep 'mattn/go-sqlite3' go.mod | awk '{print $2}') -Du_int8_t=uint8_t -Du_int16_t=uint16_t -Du_int64_t=uint64_t" \20 CGO_CFLAGS="-I/src/internal/db/include -I$(go env GOMODCACHE)/github.com/mattn/go-sqlite3@$(grep 'mattn/go-sqlite3' go.mod | awk '{print $2}') -Du_int8_t=uint8_t -Du_int16_t=uint16_t -Du_int64_t=uint64_t" \
18 CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o /glean .21 CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o /glean .
19 22
20-FROM alpine:3.2123+FROM node:22-alpine
21 24
22 RUN apk add --no-cache ca-certificates25 RUN apk add --no-cache ca-certificates
23 26
27+# Go API binary.
24 COPY --from=builder /glean /usr/local/bin/glean28 COPY --from=builder /glean /usr/local/bin/glean
25 29
26-EXPOSE 808030+# SvelteKit SSR build output.
27-ENTRYPOINT ["glean"]31+COPY --from=builder /src/web/build /app/web/build
32+COPY --from=builder /src/web/package.json /app/web/package.json
33+
34+WORKDIR /app/web
35+
36+# SvelteKit proxies /api to the Go API on localhost:8080.
37+ENV GLEAN_API_URL=http://127.0.0.1:8080
38+ENV PORT=3000
39+ENV ORIGIN=http://localhost:3000
40+
41+EXPOSE 3000
42+
43+# Run the Go API in the background, then the SvelteKit Node server in front.
44+CMD sh -c 'GLEAN_API_URL=http://127.0.0.1:8080 GLEAN_ADDR=127.0.0.1:8080 glean & node build/index.js'
modified Makefile +41 -24
@@ -13,46 +13,63 @@ lint:
1313 test -z "$(shell gofmt -l ./...)"
1414 golangci-lint run ./... --fix
1515
16-.PHONY: lex-lint
17-lex-lint:
18- goat lex lint
19-
20-.PHONY: lex-parse
21-lex-parse:
22- goat lex parse $(shell find lexicons -name '*.json' 2>/dev/null)
16+.PHONY: web-install
17+web-install:
18+ cd web && bun install
2319
20+# Run the Go API (backend) and the SvelteKit dev server (frontend) together.
21+# The frontend proxies /api requests to the Go server (GLEAN_API_URL).
2422 .PHONY: dev
25-dev: css htmx
26- @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 .
23+dev:
24+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi; \
25+ echo "Starting Go API on :8080 and SvelteKit on :3000 (Ctrl-C stops both)..."; \
26+ GLEAN_API_URL=http://localhost:8080 go run -tags fts5 . & \
27+ GO_PID=$$!; \
28+ trap 'kill $$GO_PID 2>/dev/null' INT TERM EXIT; \
29+ (cd web && GLEAN_API_URL=http://localhost:8080 bun run dev); \
30+ kill $$GO_PID 2>/dev/null || true
2731
28-.PHONY: build
29-build: css htmx
30- go build -tags fts5 -o glean .
32+.PHONY: dev-api
33+dev-api:
34+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 .
3135
32-.PHONY: css
33-css:
34- bunx tailwindcss -i ./static/input.css -o ./static/output.css --minify
36+.PHONY: dev-web
37+dev-web:
38+ cd web && GLEAN_API_URL=http://localhost:8080 bun run dev
3539
36-.PHONY: htmx
37-htmx:
38- curl -sL 'https://unpkg.com/htmx.org@2' -o ./static/htmx.min.js
40+.PHONY: web-build
41+web-build:
42+ cd web && bun run build
3943
40-.PHONY: css-watch
41-css-watch:
42- bunx tailwindcss -i ./static/input.css -o ./static/output.css --watch
44+.PHONY: build
45+build: web-build
46+ go build -tags fts5 -o glean .
4347
4448 .PHONY: icons
4549 icons:
46- magick static/favicon.svg -background none -density 1200 -resize 512x512 -depth 8 PNG32:static/favicon.png
47- magick static/favicon.svg -background none -density 1200 -resize 180x180 -depth 8 PNG32:static/apple-touch-icon.png
50+ magick web/static/favicon.svg -background none -density 1200 -resize 512x512 -depth 8 PNG32:web/static/favicon.png
51+ magick web/static/favicon.svg -background none -density 1200 -resize 180x180 -depth 8 PNG32:web/static/apple-touch-icon.png
52+
53+.PHONY: lex-lint
54+lex-lint:
55+ goat lex lint
56+
57+.PHONY: lex-parse
58+lex-parse:
59+ goat lex parse $(shell find lexicons -name '*.json' 2>/dev/null)
4860
4961 .PHONY: test
5062 test:
5163 go test -tags fts5 ./...
5264
65+.PHONY: check
66+check:
67+ cd web && bun run check
68+
5369 .PHONY: clean
5470 clean:
55- rm -f glean glean.db static/output.css static/htmx.min.js static/favicon.png static/apple-touch-icon.png
71+ rm -f glean glean.db
72+ rm -rf web/build web/.svelte-kit
5673
5774 .PHONY: docker-build
5875 docker-build:
@@ -13,46 +13,63 @@ lint:
13 test -z "$(shell gofmt -l ./...)"13 test -z "$(shell gofmt -l ./...)"
14 golangci-lint run ./... --fix14 golangci-lint run ./... --fix
15 15
16-.PHONY: lex-lint16+.PHONY: web-install
17-lex-lint:17+web-install:
18- goat lex lint18+ cd web && bun install
19-
20-.PHONY: lex-parse
21-lex-parse:
22- goat lex parse $(shell find lexicons -name '*.json' 2>/dev/null)
23 19
20+# Run the Go API (backend) and the SvelteKit dev server (frontend) together.
21+# The frontend proxies /api requests to the Go server (GLEAN_API_URL).
24 .PHONY: dev22 .PHONY: dev
25-dev: css htmx23+dev:
26- @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 .24+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi; \
25+ echo "Starting Go API on :8080 and SvelteKit on :3000 (Ctrl-C stops both)..."; \
26+ GLEAN_API_URL=http://localhost:8080 go run -tags fts5 . & \
27+ GO_PID=$$!; \
28+ trap 'kill $$GO_PID 2>/dev/null' INT TERM EXIT; \
29+ (cd web && GLEAN_API_URL=http://localhost:8080 bun run dev); \
30+ kill $$GO_PID 2>/dev/null || true
27 31
28-.PHONY: build32+.PHONY: dev-api
29-build: css htmx33+dev-api:
30- go build -tags fts5 -o glean .34+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 .
31 35
32-.PHONY: css36+.PHONY: dev-web
33-css:37+dev-web:
34- bunx tailwindcss -i ./static/input.css -o ./static/output.css --minify38+ cd web && GLEAN_API_URL=http://localhost:8080 bun run dev
35 39
36-.PHONY: htmx40+.PHONY: web-build
37-htmx:41+web-build:
38- curl -sL 'https://unpkg.com/htmx.org@2' -o ./static/htmx.min.js42+ cd web && bun run build
39 43
40-.PHONY: css-watch44+.PHONY: build
41-css-watch:45+build: web-build
42- bunx tailwindcss -i ./static/input.css -o ./static/output.css --watch46+ go build -tags fts5 -o glean .
43 47
44 .PHONY: icons48 .PHONY: icons
45 icons:49 icons:
46- magick static/favicon.svg -background none -density 1200 -resize 512x512 -depth 8 PNG32:static/favicon.png50+ magick web/static/favicon.svg -background none -density 1200 -resize 512x512 -depth 8 PNG32:web/static/favicon.png
47- magick static/favicon.svg -background none -density 1200 -resize 180x180 -depth 8 PNG32:static/apple-touch-icon.png51+ magick web/static/favicon.svg -background none -density 1200 -resize 180x180 -depth 8 PNG32:web/static/apple-touch-icon.png
52+
53+.PHONY: lex-lint
54+lex-lint:
55+ goat lex lint
56+
57+.PHONY: lex-parse
58+lex-parse:
59+ goat lex parse $(shell find lexicons -name '*.json' 2>/dev/null)
48 60
49 .PHONY: test61 .PHONY: test
50 test:62 test:
51 go test -tags fts5 ./...63 go test -tags fts5 ./...
52 64
65+.PHONY: check
66+check:
67+ cd web && bun run check
68+
53 .PHONY: clean69 .PHONY: clean
54 clean:70 clean:
55- rm -f glean glean.db static/output.css static/htmx.min.js static/favicon.png static/apple-touch-icon.png71+ rm -f glean glean.db
72+ rm -rf web/build web/.svelte-kit
56 73
57 .PHONY: docker-build74 .PHONY: docker-build
58 docker-build:75 docker-build:
deleted bun.lock +0 -160
deleted file mode 100644
@@ -1,160 +0,0 @@
1-{
2- "lockfileVersion": 1,
3- "configVersion": 1,
4- "workspaces": {
5- "": {
6- "dependencies": {
7- "tailwindcss": "^3.4.19",
8- },
9- },
10- },
11- "packages": {
12- "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
13-
14- "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
15-
16- "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
17-
18- "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
19-
20- "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
21-
22- "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
23-
24- "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
25-
26- "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
27-
28- "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
29-
30- "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
31-
32- "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
33-
34- "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
35-
36- "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
37-
38- "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
39-
40- "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
41-
42- "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
43-
44- "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
45-
46- "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
47-
48- "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
49-
50- "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
51-
52- "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
53-
54- "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
55-
56- "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
57-
58- "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
59-
60- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
61-
62- "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
63-
64- "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
65-
66- "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
67-
68- "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
69-
70- "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
71-
72- "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
73-
74- "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
75-
76- "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
77-
78- "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
79-
80- "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
81-
82- "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
83-
84- "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
85-
86- "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
87-
88- "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
89-
90- "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
91-
92- "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
93-
94- "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
95-
96- "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
97-
98- "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
99-
100- "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
101-
102- "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
103-
104- "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
105-
106- "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
107-
108- "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
109-
110- "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
111-
112- "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
113-
114- "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
115-
116- "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
117-
118- "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
119-
120- "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
121-
122- "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
123-
124- "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
125-
126- "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
127-
128- "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
129-
130- "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
131-
132- "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
133-
134- "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
135-
136- "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
137-
138- "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
139-
140- "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
141-
142- "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
143-
144- "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
145-
146- "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
147-
148- "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
149-
150- "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
151-
152- "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
153-
154- "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
155-
156- "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
157-
158- "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
159- }
160-}
deleted file mode 100644
@@ -1,160 +0,0 @@
1-{
2- "lockfileVersion": 1,
3- "configVersion": 1,
4- "workspaces": {
5- "": {
6- "dependencies": {
7- "tailwindcss": "^3.4.19",
8- },
9- },
10- },
11- "packages": {
12- "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
13-
14- "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
15-
16- "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
17-
18- "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
19-
20- "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
21-
22- "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
23-
24- "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
25-
26- "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
27-
28- "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
29-
30- "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
31-
32- "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
33-
34- "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
35-
36- "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
37-
38- "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
39-
40- "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
41-
42- "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
43-
44- "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
45-
46- "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
47-
48- "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
49-
50- "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
51-
52- "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
53-
54- "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
55-
56- "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
57-
58- "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
59-
60- "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
61-
62- "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
63-
64- "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
65-
66- "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
67-
68- "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
69-
70- "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
71-
72- "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
73-
74- "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
75-
76- "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
77-
78- "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
79-
80- "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
81-
82- "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
83-
84- "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
85-
86- "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
87-
88- "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
89-
90- "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
91-
92- "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
93-
94- "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
95-
96- "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
97-
98- "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
99-
100- "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
101-
102- "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
103-
104- "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
105-
106- "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
107-
108- "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
109-
110- "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
111-
112- "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
113-
114- "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
115-
116- "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
117-
118- "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
119-
120- "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
121-
122- "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
123-
124- "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
125-
126- "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
127-
128- "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
129-
130- "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
131-
132- "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
133-
134- "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
135-
136- "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
137-
138- "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
139-
140- "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
141-
142- "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
143-
144- "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
145-
146- "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
147-
148- "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
149-
150- "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
151-
152- "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
153-
154- "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
155-
156- "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
157-
158- "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
159- }
160-}
modified docs/design.md +106 -137
@@ -1,186 +1,155 @@
11 # Glean Design System
22
3-Adapted from the Starbucks-inspired green palette. Warm, confident, and grounded in a four-tier green system with a dark/light theme switch.
3+Brutalist / minimal. Monochrome base (ink + paper) with a single green accent. Monospace typography, sharp corners, thick 2px borders, hard offset shadows. Built with SvelteKit + Tailwind CSS v4.
44
55 ## 1. Visual Theme & Atmosphere
66
7-Glean is a **warm, focused reading environment**. The canvas alternates between a deep forest green (dark mode) and a warm cream (light mode), with Green Accent (`#00754A`) anchoring all CTAs, links, and brand moments. The palette is deliberately not blue, not purple — it references natural, grounded tones.
7+Glean is a **raw, structural reading environment**. The canvas is stark: near-black ink on near-white paper (light mode), inverted in dark mode. A single accent green carries the brand and all interactive highlights. No soft shadows, no rounded pills, no gradients — just borders, type, and contrast.
88
9-Typography uses **Inter** (Google Fonts) as the universal typeface, with tight `-0.01em` letter-spacing across the entire product. A single typeface, a single voice.
9+**Typography** uses **JetBrains Mono** (Google Fonts) as the universal typeface, weights 400–800. Labels and metadata are uppercase with wide tracking. The aesthetic is terminal-like: dense, precise, unornamented.
1010
11-Surfaces breathe through rounded geometry: pill buttons (`9999px`), `12px` card corners, and `50%` circular avatars. Shadows are whisper-soft dual-layers, never heavy. The system feels like a well-lit reading room.
11+**Geometry** is sharp — every radius is `0px`. Borders are 2px solid. Shadows are hard offsets (`4px 4px 0 0`), never blurred — they read as physical depth, a hallmark of brutalist UI. Interactive elements translate on hover/press (`translate(-1px,-1px)` → deeper shadow → `translate(1px,1px)` → flush), giving a tactile, mechanical feel.
1212
13-All radii are driven by CSS custom properties (`--radius-sm`, `--radius-md`, `--radius-lg`, `--radius-pill`, `--radius-full`) wired through `tailwind.config.js`. A **squared** shape mode is available via `data-shape="squared"` on the root element, which sets all radius variables to `0`. The toggle lives in the footer alongside the theme switcher and persists via `localStorage('shape')`.
13+**Layout** is a single centered column (`max-w-5xl`) under a sticky top header bar. No sidebar. The header carries the wordmark, inline nav, search, and a user dropdown. Footer is a structured multi-column band at the bottom of every page.
1414
15-**Color-block rhythm (landing page):** Cream/forest hero → white card sections → House Green (`#1E3932`) feature band with white text → cream utility zone → House Green footer.
16-
17-Logo is a stylized bee: body with horizontal stripes (like text lines on a page), semi-transparent wings that evoke open book pages, round eyes, curved antennae, and a small smile. The bee represents gleaning (collecting nectar/knowledge), social behavior (hives/communities), and reading (the striped body reads like lines of text, wings like turning pages).
15+**Logo** is the favicon glyph (the stylized bee mark) rendered as a boxed letter "G" — a square accent-green tile with the letter, paired with a heavy uppercase "Glean" wordmark.
1816
1917 ## 2. Color Palette
2018
21-### Primary Greens
22-
23-| Name | Hex | Role |
24-| ------------ | --------- | ------------------------------------------------ |
25-| Green Accent | `#00754A` | CTAs, active states, link hovers, brand accent |
26-| Green Dark | `#006241` | Headings on landing page, stronger brand moments |
27-| House Green | `#1E3932` | Dark bands, footer, feature sections |
28-| Green Uplift | `#2b5148` | Decorative accents, mid-dark green |
29-| Green Light | `#d4e9e2` | Light green utility surfaces, valid-state tints |
30-
31-### Dark Theme (default)
32-
33-| Token | Value | Use |
34-| ------------------ | ------------------------ | ----------------------------- |
35-| `--spot-bg` | `#0a1814` | Page background, sidebar |
36-| `--spot-surface` | `#152b24` | Card background |
37-| `--spot-hover` | `#1e3c33` | Hover state, input background |
38-| `--spot-text` | `#f2f2f2` | Primary text |
39-| `--spot-secondary` | `rgba(255,255,255,0.78)` | Secondary/metadata text |
40-| `--spot-body` | `rgba(255,255,255,0.92)` | Body copy, article content |
41-| `--spot-muted` | `rgba(255,255,255,0.42)` | Disabled/tertiary text |
42-| `--spot-divider` | `rgba(255,255,255,0.12)` | Borders, dividers |
43-| `--spot-outline` | `rgba(255,255,255,0.20)` | Button borders, input borders |
44-
45-### Light Theme
46-
47-| Token | Value | Use |
48-| ------------------ | ------------------ | ----------------------------- |
49-| `--spot-bg` | `#f2f0eb` | Page canvas (warm cream) |
50-| `--spot-surface` | `#ffffff` | Card background |
51-| `--spot-hover` | `#edebe9` | Hover state (ceramic) |
52-| `--spot-text` | `rgba(0,0,0,0.87)` | Primary text (warm black) |
53-| `--spot-secondary` | `rgba(0,0,0,0.58)` | Secondary/metadata text |
54-| `--spot-body` | `rgba(0,0,0,0.70)` | Body copy |
55-| `--spot-muted` | `rgba(0,0,0,0.25)` | Disabled/tertiary text |
56-| `--spot-divider` | `rgba(0,0,0,0.08)` | Borders, dividers |
57-| `--spot-outline` | `rgba(0,0,0,0.15)` | Button borders, input borders |
58-
59-### Semantic
60-
61-| Name | Hex | Use |
62-| ------ | --------- | ----------------- |
63-| Red | `#c82014` | Errors, likes |
64-| Orange | `#ffa42b` | Ratings, warnings |
65-| Blue | `#539df5` | External links |
19+The system uses CSS custom properties (defined in `web/src/app.css`) that swap via `[data-theme]`. All components reference these tokens (e.g. `var(--accent)`), never hardcoded hex.
20+
21+### Light Theme (default)
22+
23+| Token | Value | Use |
24+| -------------- | --------- | ----------------------------- |
25+| `--bg` | `#fafaf7` | Page canvas (warm paper) |
26+| `--fg` | `#0a0a0a` | Primary text, borders (ink) |
27+| `--surface` | `#f0efe9` | Card / panel background |
28+| `--border` | `#0a0a0a` | All borders (2px solid) |
29+| `--muted` | `#6b6b6b` | Secondary / metadata text |
30+| `--faint` | `#c8c8c2` | Disabled, tertiary fills |
31+| `--accent` | `#00754a` | Links, active states, brand |
32+| `--accent-ink` | `#ecfff4` | Accent-tinted surfaces |
33+| `--danger` | `#c82014` | Destructive actions, sign-out |
34+
35+### Dark Theme
36+
37+| Token | Value | Use |
38+| -------------- | --------- | ----------------------------- |
39+| `--bg` | `#0a0a0a` | Page canvas (ink) |
40+| `--fg` | `#f5f5ef` | Primary text, borders (paper) |
41+| `--surface` | `#161616` | Card / panel background |
42+| `--border` | `#f5f5ef` | All borders (inverted) |
43+| `--muted` | `#9a9a9a` | Secondary / metadata text |
44+| `--faint` | `#3a3a3a` | Disabled, tertiary fills |
45+| `--accent` | `#00754a` | Links, active states, brand |
46+| `--accent-ink` | `#062018` | Accent-tinted surfaces |
47+| `--danger` | `#ff5a4d` | Destructive actions |
48+
49+> The accent green (`#00754a`) is identical in both light and dark themes.
6650
6751 ## 3. Typography
6852
69-**Font:** Inter (Google Fonts), weights 400/500/600/700
70-
71-**Global:** `letter-spacing: -0.01em` on body
53+**Font:** JetBrains Mono (Google Fonts), weights 400 / 500 / 600 / 700 / 800.
7254
73-| Role | Size | Weight | Tailwind Class |
74-| ------------- | ---- | ------ | --------------------------------------------- |
75-| Page title | 24px | 700 | `text-2xl font-bold` |
76-| Section title | 18px | 600 | `text-lg font-semibold` |
77-| Body | 14px | 400 | `text-sm` |
78-| Small/meta | 12px | 400 | `text-xs` |
79-| Button label | 14px | 700 | `text-sm font-bold uppercase tracking-button` |
80-| Micro | 10px | 400 | `text-[10px]` |
55+| Role | Class |
56+| --------------- | ---------------------------------------------------------------------- |
57+| Page title | `text-2xl font-extrabold uppercase tracking-tight` |
58+| Section heading | `text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]` |
59+| Body | `text-sm` (base), `text-[var(--muted)]` (secondary) |
60+| Button label | `text-[0.72rem] font-bold uppercase` (via `btn`) |
61+| Tag / chip | `text-[0.68rem] font-semibold uppercase` (via `chip` / `tag`) |
62+| Micro/meta | `text-[0.7rem] text-[var(--muted)]` |
8163
8264 ## 4. Components
8365
84-### Buttons
85-
86-All buttons use full-pill radius (`rounded-pill` = `9999px`).
87-
88-**Primary Filled:**
89-
90-```
91-bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition
92-```
93-
94-Used for CTAs: "Add", "Subscribe", "Annotate", "Save", "Sign in", "Get started", "Login".
66+All interactive primitives are defined as Tailwind v4 `@utility` classes in `app.css`.
9567
96-**Primary Outlined:**
68+### Buttons
9769
98-```
99-border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition
100-```
70+| Class | Style | Use |
71+| ---------------- | ---------------------------------------------------------- | ---------------------------- |
72+| `btn` | 2px border, hard offset shadow, uppercase, press animation | Default buttons |
73+| `btn btn-accent` | `btn` + accent green background, white text | Primary CTAs (Sign in, Save) |
74+| `btn btn-ghost` | `btn` with no border/shadow until hover | Toolbar / icon buttons |
10175
102-Used for secondary actions: "Refresh feeds", "Mark all read", "Import OPML", "Export OPML", "Fetch full content", "See what's trending", dialog Cancel/Close.
76+Hover: `translate(-1px,-1px)` + deeper shadow. Active: `translate(1px,1px)` + flush.
10377
104-**Toolbar Micro:**
78+### Chips & Tags
10579
106-```
107-text-spot-text bg-spot-hover text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition
108-```
80+- **`chip`** — small uppercase pill with 1.5px border. Add `data-active="true"` for the inverted (fg/bg) active state. Used for filters, like/read toggles, counts.
81+- **`tag`** — even smaller uppercase label with 1px border, `--surface` background. Used for annotation tags, categories.
10982
110-Used in-card and in-toolbar for small actions: Like, Read, Original, Share. Default text is `text-spot-text` (never grey). Semantic hover colors override the default on certain buttons: Like → red, Read → green, Share → blue, Original stays `text-spot-text`.
83+### Panels & Cards
11184
112-### Cards
85+- **`panel`** — `--surface` background, 2px `--border` border. The base card container.
86+- **`panel-press`** — adds the hover/press translate + hard shadow animation. Used on interactive cards (articles, trending, profiles).
11387
114-`rounded-xl` (12px) radius, `shadow-spot` elevation, `bg-spot-surface` background.
88+### Forms
11589
116-```
117-bg-spot-surface rounded-xl p-4 shadow-spot hover:bg-spot-hover-50 transition
118-```
90+- **`input-brutal`** — 2px border, monospace, focus pushes `translate(-1px,-1px)` with a hard shadow.
91+- Textareas and selects reuse `input-brutal`.
11992
12093 ### Shadows
12194
122-| Token | Use |
123-| ---------------------- | ------------ |
124-| `shadow-spot` | Cards |
125-| `shadow-spot-heavy` | Modals, hero |
126-| `shadow-spot-elevated` | Dialogs, floating elements |
127-
128-### Navigation
95+| Token | Value | Use |
96+| ------------------ | --------------------------- | -------------------- |
97+| `--shadow-hard-sm` | `2px 2px 0 0 var(--border)` | Buttons, small cards |
98+| `--shadow-hard` | `4px 4px 0 0 var(--border)` | Hover lift, modals |
12999
130-- **Sidebar** (desktop): Fixed left, `w-60`, logo at top, nav links, user profile at bottom
131-- **Bottom nav** (mobile): Fixed bottom, 5-tab horizontal bar
132-- **Active link**: `bg-spot-hover text-spot-text font-bold`
100+Modal dialogs use inline `shadow-[6px_6px_0_0_var(--border)]`.
133101
134-### Forms
102+### Navigation
135103
136-- Input fields: `bg-spot-hover rounded-pill px-5 py-2 text-sm focus:ring-2 focus:ring-spot-green`
137-- Textareas: `bg-spot-hover rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-spot-green`
138-- File inputs: native browser style with pill-styled file button
104+- **Header bar** (all breakpoints): sticky top, 2px bottom border, `max-w-5xl`. Wordmark left, inline nav (desktop), search/shortcuts/user menu right.
105+- **Mobile nav row**: below the header, horizontal-scroll row of nav chips (hidden on `md+`).
106+- **Active nav item**: inverted `bg-[var(--fg)] text-[var(--bg)]`.
107+- **User menu**: dropdown panel with hard shadow (Profile, Install, Sign out).
139108
140-### Badges / Tags
109+### Article body
141110
142-- Unread count: `bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold`
143-- Category pills: `bg-spot-hover text-spot-secondary px-4 py-1.5 rounded-full font-bold`
144-- Active category: `bg-spot-active-pill-bg text-spot-active-pill-text`
111+Rendered RSS/HTML content uses the `article-body` utility: monospace base, uppercase headings, 2px borders on `pre`/`img`/`iframe`/`table`, accent-green links and blockquote borders, accent-green `<mark>`.
145112
146113 ## 5. Layout
147114
148-- **Content max-width:** `max-w-6xl` (72rem)
149-- **Sidebar:** `w-60` fixed left (desktop only)
150-- **Main content:** `lg:ml-60` offset with `px-4 lg:px-8 py-6`
151-- **Landing page:** Full-width (`w-full`) — no max-width wrapper
152-- **Footer:** `bg-spot-surface border-t border-spot-divider`
115+| Element | Spec |
116+| --------------- | -------------------------------------- |
117+| Content width | `max-w-5xl` (64rem), `px-4` |
118+| Header height | `h-14` (3.5rem) sticky |
119+| Content padding | `py-8` |
120+| Footer | `border-t-2`, multi-column, full-width |
153121
154-### Responsive Breakpoints
122+### Responsive Behavior
155123
156-| Name | Width | Nav behavior |
157-| ------- | ---------- | ------------------------------- |
158-| Mobile | < 768px | Bottom tab nav, stacked layouts |
159-| Tablet | 768–1023px | Bottom nav, wider gutters |
160-| Desktop | 1024px+ | Sidebar nav, 3-column grids |
124+| Breakpoint | Nav | Layout |
125+| ---------- | ----------------------- | ----------------------------- |
126+| `< 768px` | Mobile nav row (scroll) | Single column, stacked grids |
127+| `≥ 768px` | Inline header nav | Multi-column grids where used |
161128
162-## 6. Tailwind Config
129+## 6. Tailwind / CSS Pipeline
163130
164-All custom colors live under the `spot` namespace in `tailwind.config.js`. CSS variables provide theme switching via `[data-theme]` attribute. Build output goes to `static/output.css` via `npx tailwindcss`.
131+- **Tailwind CSS v4** via `@tailwindcss/vite` (no `tailwind.config.js`).
132+- **Source:** `web/src/app.css``@import "tailwindcss"`, `@theme` block for design tokens, `:root` / `[data-theme]` for theme variables, `@utility` blocks for component classes.
133+- **Build:** `cd web && bun run build` (Vite + SvelteKit). Output is the SvelteKit adapter-node build in `web/build/`.
134+- No separate CSS build step — Vite compiles Tailwind on the fly.
165135
166136 ## 7. Asset Pipeline
167137
168-- **CSS build:** `make css` (minified) or `make css-watch` (dev with live reload)
169-- **Source:** `static/input.css` — contains `@tailwind` directives, CSS variables for themes, `@layer components` for article-body styles, and base utilities
170-- **Output:** `static/output.css` (gitignored, rebuilt on deploy)
171-- **Favicon:** `static/favicon.svg` — bee logo (gradient green background, rounded rect). `static/favicon.png` — 512x512 raster fallback. Both linked in `base.html` `<head>`.
172-- **Logo:** `<img>` referencing `/static/favicon.svg`, defined in `partials/logo.html` (`logo-icon`, `logo-link`, `logo-text` templates).
138+- **Static assets:** `web/static/``favicon.svg` (bee logo), `manifest.json`, PNG icons, `banner.png`. Served at the root (`/favicon.svg`, etc.).
139+- **Logo component:** `web/src/lib/components/Logo.svelte` — boxed "G" tile + wordmark, sizes `sm` / `md` / `lg`.
140+- **Icons:** `web/src/lib/components/Icon.svelte` — monochrome line-icon set (stroke=currentColor, square caps), referenced by `name`.
173141
174142 ## 8. Page Structure
175143
176-| Page | Layout | Key Features |
177-| --------------- | ---------------------- | -------------------------------------------------------------- |
178-| Index (landing) | Full-width, no sidebar | Hero with mockup, feature cols, dark band, CTA |
179-| Login | Centered card | Bluesky + Atmosphere sign-in buttons |
180-| Dashboard | 2/3 + 1/3 grid | Articles + trending/recommendations sidebar |
181-| Articles | Full-width list | Keyboard nav (j/k/o/m), mark-all-read |
182-| Article Detail | `max-w-3xl` centered | Content, like/share/read buttons, annotations |
183-| Feeds | 2/3 + 1/3 grid | Feed list with categories + add/import sidebar, refresh button |
184-| Trending | Full-width list | Like/annotation counts on each article |
185-| Library | Full-width list | Liked articles and annotations |
186-| Profile | `max-w-2xl` centered | Avatar, stats, feeds, annotations |
144+| Page | Layout | Key Features |
145+| --------------- | ------------------------------ | ------------------------------------------------------------ |
146+| Index (landing) | Full-width sections, no chrome | Hero + mock dashboard panel, feature grid, accent band, CTA |
147+| Login | Centered, chromeless | Handle input + actor typeahead, OAuth start, register |
148+| Dashboard | Single column | Counts, unread articles, lazy recs/digest/trending/people |
149+| Articles | Single column list | Search, status/category chips, sort, expanded scroll-to-read |
150+| Article Detail | Single column, centered prose | Like/read/share, fetch-content, text-select annotate popover |
151+| Feeds | 2/3 list + 1/3 sidebar | Categories, add/edit/remove, OPML import/export, refresh |
152+| Trending | Single column list | Scope toggle (All / For me), sign-in prompt |
153+| Library | Two columns | Liked articles + annotations, independent pagination |
154+| Profile | Centered | Header + stats, settings (digest/expanded/languages), feeds |
155+| Stats | Single column | Metric categories as panels with monospace values |
@@ -1,186 +1,155 @@
1 # Glean Design System1 # Glean Design System
2 2
3-Adapted from the Starbucks-inspired green palette. Warm, confident, and grounded in a four-tier green system with a dark/light theme switch.3+Brutalist / minimal. Monochrome base (ink + paper) with a single green accent. Monospace typography, sharp corners, thick 2px borders, hard offset shadows. Built with SvelteKit + Tailwind CSS v4.
4 4
5 ## 1. Visual Theme & Atmosphere5 ## 1. Visual Theme & Atmosphere
6 6
7-Glean is a **warm, focused reading environment**. The canvas alternates between a deep forest green (dark mode) and a warm cream (light mode), with Green Accent (`#00754A`) anchoring all CTAs, links, and brand moments. The palette is deliberately not blue, not purple — it references natural, grounded tones.7+Glean is a **raw, structural reading environment**. The canvas is stark: near-black ink on near-white paper (light mode), inverted in dark mode. A single accent green carries the brand and all interactive highlights. No soft shadows, no rounded pills, no gradients — just borders, type, and contrast.
8 8
9-Typography uses **Inter** (Google Fonts) as the universal typeface, with tight `-0.01em` letter-spacing across the entire product. A single typeface, a single voice.9+**Typography** uses **JetBrains Mono** (Google Fonts) as the universal typeface, weights 400–800. Labels and metadata are uppercase with wide tracking. The aesthetic is terminal-like: dense, precise, unornamented.
10 10
11-Surfaces breathe through rounded geometry: pill buttons (`9999px`), `12px` card corners, and `50%` circular avatars. Shadows are whisper-soft dual-layers, never heavy. The system feels like a well-lit reading room.11+**Geometry** is sharp — every radius is `0px`. Borders are 2px solid. Shadows are hard offsets (`4px 4px 0 0`), never blurred — they read as physical depth, a hallmark of brutalist UI. Interactive elements translate on hover/press (`translate(-1px,-1px)` → deeper shadow → `translate(1px,1px)` → flush), giving a tactile, mechanical feel.
12 12
13-All radii are driven by CSS custom properties (`--radius-sm`, `--radius-md`, `--radius-lg`, `--radius-pill`, `--radius-full`) wired through `tailwind.config.js`. A **squared** shape mode is available via `data-shape="squared"` on the root element, which sets all radius variables to `0`. The toggle lives in the footer alongside the theme switcher and persists via `localStorage('shape')`.13+**Layout** is a single centered column (`max-w-5xl`) under a sticky top header bar. No sidebar. The header carries the wordmark, inline nav, search, and a user dropdown. Footer is a structured multi-column band at the bottom of every page.
14 14
15-**Color-block rhythm (landing page):** Cream/forest hero → white card sections → House Green (`#1E3932`) feature band with white text → cream utility zone → House Green footer.15+**Logo** is the favicon glyph (the stylized bee mark) rendered as a boxed letter "G" — a square accent-green tile with the letter, paired with a heavy uppercase "Glean" wordmark.
16-
17-Logo is a stylized bee: body with horizontal stripes (like text lines on a page), semi-transparent wings that evoke open book pages, round eyes, curved antennae, and a small smile. The bee represents gleaning (collecting nectar/knowledge), social behavior (hives/communities), and reading (the striped body reads like lines of text, wings like turning pages).
18 16
19 ## 2. Color Palette17 ## 2. Color Palette
20 18
21-### Primary Greens19+The system uses CSS custom properties (defined in `web/src/app.css`) that swap via `[data-theme]`. All components reference these tokens (e.g. `var(--accent)`), never hardcoded hex.
22-20+
23-| Name | Hex | Role |21+### Light Theme (default)
24-| ------------ | --------- | ------------------------------------------------ |22+
25-| Green Accent | `#00754A` | CTAs, active states, link hovers, brand accent |23+| Token | Value | Use |
26-| Green Dark | `#006241` | Headings on landing page, stronger brand moments |24+| -------------- | --------- | ----------------------------- |
27-| House Green | `#1E3932` | Dark bands, footer, feature sections |25+| `--bg` | `#fafaf7` | Page canvas (warm paper) |
28-| Green Uplift | `#2b5148` | Decorative accents, mid-dark green |26+| `--fg` | `#0a0a0a` | Primary text, borders (ink) |
29-| Green Light | `#d4e9e2` | Light green utility surfaces, valid-state tints |27+| `--surface` | `#f0efe9` | Card / panel background |
30-28+| `--border` | `#0a0a0a` | All borders (2px solid) |
31-### Dark Theme (default)29+| `--muted` | `#6b6b6b` | Secondary / metadata text |
32-30+| `--faint` | `#c8c8c2` | Disabled, tertiary fills |
33-| Token | Value | Use |31+| `--accent` | `#00754a` | Links, active states, brand |
34-| ------------------ | ------------------------ | ----------------------------- |32+| `--accent-ink` | `#ecfff4` | Accent-tinted surfaces |
35-| `--spot-bg` | `#0a1814` | Page background, sidebar |33+| `--danger` | `#c82014` | Destructive actions, sign-out |
36-| `--spot-surface` | `#152b24` | Card background |34+
37-| `--spot-hover` | `#1e3c33` | Hover state, input background |35+### Dark Theme
38-| `--spot-text` | `#f2f2f2` | Primary text |36+
39-| `--spot-secondary` | `rgba(255,255,255,0.78)` | Secondary/metadata text |37+| Token | Value | Use |
40-| `--spot-body` | `rgba(255,255,255,0.92)` | Body copy, article content |38+| -------------- | --------- | ----------------------------- |
41-| `--spot-muted` | `rgba(255,255,255,0.42)` | Disabled/tertiary text |39+| `--bg` | `#0a0a0a` | Page canvas (ink) |
42-| `--spot-divider` | `rgba(255,255,255,0.12)` | Borders, dividers |40+| `--fg` | `#f5f5ef` | Primary text, borders (paper) |
43-| `--spot-outline` | `rgba(255,255,255,0.20)` | Button borders, input borders |41+| `--surface` | `#161616` | Card / panel background |
44-42+| `--border` | `#f5f5ef` | All borders (inverted) |
45-### Light Theme43+| `--muted` | `#9a9a9a` | Secondary / metadata text |
46-44+| `--faint` | `#3a3a3a` | Disabled, tertiary fills |
47-| Token | Value | Use |45+| `--accent` | `#00754a` | Links, active states, brand |
48-| ------------------ | ------------------ | ----------------------------- |46+| `--accent-ink` | `#062018` | Accent-tinted surfaces |
49-| `--spot-bg` | `#f2f0eb` | Page canvas (warm cream) |47+| `--danger` | `#ff5a4d` | Destructive actions |
50-| `--spot-surface` | `#ffffff` | Card background |48+
51-| `--spot-hover` | `#edebe9` | Hover state (ceramic) |49+> The accent green (`#00754a`) is identical in both light and dark themes.
52-| `--spot-text` | `rgba(0,0,0,0.87)` | Primary text (warm black) |
53-| `--spot-secondary` | `rgba(0,0,0,0.58)` | Secondary/metadata text |
54-| `--spot-body` | `rgba(0,0,0,0.70)` | Body copy |
55-| `--spot-muted` | `rgba(0,0,0,0.25)` | Disabled/tertiary text |
56-| `--spot-divider` | `rgba(0,0,0,0.08)` | Borders, dividers |
57-| `--spot-outline` | `rgba(0,0,0,0.15)` | Button borders, input borders |
58-
59-### Semantic
60-
61-| Name | Hex | Use |
62-| ------ | --------- | ----------------- |
63-| Red | `#c82014` | Errors, likes |
64-| Orange | `#ffa42b` | Ratings, warnings |
65-| Blue | `#539df5` | External links |
66 50
67 ## 3. Typography51 ## 3. Typography
68 52
69-**Font:** Inter (Google Fonts), weights 400/500/600/70053+**Font:** JetBrains Mono (Google Fonts), weights 400 / 500 / 600 / 700 / 800.
70-
71-**Global:** `letter-spacing: -0.01em` on body
72 54
73-| Role | Size | Weight | Tailwind Class |55+| Role | Class |
74-| ------------- | ---- | ------ | --------------------------------------------- |56+| --------------- | ---------------------------------------------------------------------- |
75-| Page title | 24px | 700 | `text-2xl font-bold` |57+| Page title | `text-2xl font-extrabold uppercase tracking-tight` |
76-| Section title | 18px | 600 | `text-lg font-semibold` |58+| Section heading | `text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]` |
77-| Body | 14px | 400 | `text-sm` |59+| Body | `text-sm` (base), `text-[var(--muted)]` (secondary) |
78-| Small/meta | 12px | 400 | `text-xs` |60+| Button label | `text-[0.72rem] font-bold uppercase` (via `btn`) |
79-| Button label | 14px | 700 | `text-sm font-bold uppercase tracking-button` |61+| Tag / chip | `text-[0.68rem] font-semibold uppercase` (via `chip` / `tag`) |
80-| Micro | 10px | 400 | `text-[10px]` |62+| Micro/meta | `text-[0.7rem] text-[var(--muted)]` |
81 63
82 ## 4. Components64 ## 4. Components
83 65
84-### Buttons66+All interactive primitives are defined as Tailwind v4 `@utility` classes in `app.css`.
85-
86-All buttons use full-pill radius (`rounded-pill` = `9999px`).
87-
88-**Primary Filled:**
89-
90-```
91-bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition
92-```
93-
94-Used for CTAs: "Add", "Subscribe", "Annotate", "Save", "Sign in", "Get started", "Login".
95 67
96-**Primary Outlined:**68+### Buttons
97 69
98-```70+| Class | Style | Use |
99-border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition71+| ---------------- | ---------------------------------------------------------- | ---------------------------- |
100-```72+| `btn` | 2px border, hard offset shadow, uppercase, press animation | Default buttons |
73+| `btn btn-accent` | `btn` + accent green background, white text | Primary CTAs (Sign in, Save) |
74+| `btn btn-ghost` | `btn` with no border/shadow until hover | Toolbar / icon buttons |
101 75
102-Used for secondary actions: "Refresh feeds", "Mark all read", "Import OPML", "Export OPML", "Fetch full content", "See what's trending", dialog Cancel/Close.76+Hover: `translate(-1px,-1px)` + deeper shadow. Active: `translate(1px,1px)` + flush.
103 77
104-**Toolbar Micro:**78+### Chips & Tags
105 79
106-```80+- **`chip`** — small uppercase pill with 1.5px border. Add `data-active="true"` for the inverted (fg/bg) active state. Used for filters, like/read toggles, counts.
107-text-spot-text bg-spot-hover text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition81+- **`tag`** — even smaller uppercase label with 1px border, `--surface` background. Used for annotation tags, categories.
108-```
109 82
110-Used in-card and in-toolbar for small actions: Like, Read, Original, Share. Default text is `text-spot-text` (never grey). Semantic hover colors override the default on certain buttons: Like → red, Read → green, Share → blue, Original stays `text-spot-text`.83+### Panels & Cards
111 84
112-### Cards85+- **`panel`** — `--surface` background, 2px `--border` border. The base card container.
86+- **`panel-press`** — adds the hover/press translate + hard shadow animation. Used on interactive cards (articles, trending, profiles).
113 87
114-`rounded-xl` (12px) radius, `shadow-spot` elevation, `bg-spot-surface` background.88+### Forms
115 89
116-```90+- **`input-brutal`** — 2px border, monospace, focus pushes `translate(-1px,-1px)` with a hard shadow.
117-bg-spot-surface rounded-xl p-4 shadow-spot hover:bg-spot-hover-50 transition91+- Textareas and selects reuse `input-brutal`.
118-```
119 92
120 ### Shadows93 ### Shadows
121 94
122-| Token | Use |95+| Token | Value | Use |
123-| ---------------------- | ------------ |96+| ------------------ | --------------------------- | -------------------- |
124-| `shadow-spot` | Cards |97+| `--shadow-hard-sm` | `2px 2px 0 0 var(--border)` | Buttons, small cards |
125-| `shadow-spot-heavy` | Modals, hero |98+| `--shadow-hard` | `4px 4px 0 0 var(--border)` | Hover lift, modals |
126-| `shadow-spot-elevated` | Dialogs, floating elements |
127-
128-### Navigation
129 99
130-- **Sidebar** (desktop): Fixed left, `w-60`, logo at top, nav links, user profile at bottom100+Modal dialogs use inline `shadow-[6px_6px_0_0_var(--border)]`.
131-- **Bottom nav** (mobile): Fixed bottom, 5-tab horizontal bar
132-- **Active link**: `bg-spot-hover text-spot-text font-bold`
133 101
134-### Forms102+### Navigation
135 103
136-- Input fields: `bg-spot-hover rounded-pill px-5 py-2 text-sm focus:ring-2 focus:ring-spot-green`104+- **Header bar** (all breakpoints): sticky top, 2px bottom border, `max-w-5xl`. Wordmark left, inline nav (desktop), search/shortcuts/user menu right.
137-- Textareas: `bg-spot-hover rounded-lg px-4 py-2 text-sm focus:ring-2 focus:ring-spot-green`105+- **Mobile nav row**: below the header, horizontal-scroll row of nav chips (hidden on `md+`).
138-- File inputs: native browser style with pill-styled file button106+- **Active nav item**: inverted `bg-[var(--fg)] text-[var(--bg)]`.
107+- **User menu**: dropdown panel with hard shadow (Profile, Install, Sign out).
139 108
140-### Badges / Tags109+### Article body
141 110
142-- Unread count: `bg-spot-green/20 text-spot-green px-2.5 py-0.5 rounded-full font-bold`111+Rendered RSS/HTML content uses the `article-body` utility: monospace base, uppercase headings, 2px borders on `pre`/`img`/`iframe`/`table`, accent-green links and blockquote borders, accent-green `<mark>`.
143-- Category pills: `bg-spot-hover text-spot-secondary px-4 py-1.5 rounded-full font-bold`
144-- Active category: `bg-spot-active-pill-bg text-spot-active-pill-text`
145 112
146 ## 5. Layout113 ## 5. Layout
147 114
148-- **Content max-width:** `max-w-6xl` (72rem)115+| Element | Spec |
149-- **Sidebar:** `w-60` fixed left (desktop only)116+| --------------- | -------------------------------------- |
150-- **Main content:** `lg:ml-60` offset with `px-4 lg:px-8 py-6`117+| Content width | `max-w-5xl` (64rem), `px-4` |
151-- **Landing page:** Full-width (`w-full`) — no max-width wrapper118+| Header height | `h-14` (3.5rem) sticky |
152-- **Footer:** `bg-spot-surface border-t border-spot-divider`119+| Content padding | `py-8` |
120+| Footer | `border-t-2`, multi-column, full-width |
153 121
154-### Responsive Breakpoints122+### Responsive Behavior
155 123
156-| Name | Width | Nav behavior |124+| Breakpoint | Nav | Layout |
157-| ------- | ---------- | ------------------------------- |125+| ---------- | ----------------------- | ----------------------------- |
158-| Mobile | < 768px | Bottom tab nav, stacked layouts |126+| `< 768px` | Mobile nav row (scroll) | Single column, stacked grids |
159-| Tablet | 768–1023px | Bottom nav, wider gutters |127+| `≥ 768px` | Inline header nav | Multi-column grids where used |
160-| Desktop | 1024px+ | Sidebar nav, 3-column grids |
161 128
162-## 6. Tailwind Config129+## 6. Tailwind / CSS Pipeline
163 130
164-All custom colors live under the `spot` namespace in `tailwind.config.js`. CSS variables provide theme switching via `[data-theme]` attribute. Build output goes to `static/output.css` via `npx tailwindcss`.131+- **Tailwind CSS v4** via `@tailwindcss/vite` (no `tailwind.config.js`).
132+- **Source:** `web/src/app.css``@import "tailwindcss"`, `@theme` block for design tokens, `:root` / `[data-theme]` for theme variables, `@utility` blocks for component classes.
133+- **Build:** `cd web && bun run build` (Vite + SvelteKit). Output is the SvelteKit adapter-node build in `web/build/`.
134+- No separate CSS build step — Vite compiles Tailwind on the fly.
165 135
166 ## 7. Asset Pipeline136 ## 7. Asset Pipeline
167 137
168-- **CSS build:** `make css` (minified) or `make css-watch` (dev with live reload)138+- **Static assets:** `web/static/``favicon.svg` (bee logo), `manifest.json`, PNG icons, `banner.png`. Served at the root (`/favicon.svg`, etc.).
169-- **Source:** `static/input.css` — contains `@tailwind` directives, CSS variables for themes, `@layer components` for article-body styles, and base utilities139+- **Logo component:** `web/src/lib/components/Logo.svelte` — boxed "G" tile + wordmark, sizes `sm` / `md` / `lg`.
170-- **Output:** `static/output.css` (gitignored, rebuilt on deploy)140+- **Icons:** `web/src/lib/components/Icon.svelte` — monochrome line-icon set (stroke=currentColor, square caps), referenced by `name`.
171-- **Favicon:** `static/favicon.svg` — bee logo (gradient green background, rounded rect). `static/favicon.png` — 512x512 raster fallback. Both linked in `base.html` `<head>`.
172-- **Logo:** `<img>` referencing `/static/favicon.svg`, defined in `partials/logo.html` (`logo-icon`, `logo-link`, `logo-text` templates).
173 141
174 ## 8. Page Structure142 ## 8. Page Structure
175 143
176-| Page | Layout | Key Features |144+| Page | Layout | Key Features |
177-| --------------- | ---------------------- | -------------------------------------------------------------- |145+| --------------- | ------------------------------ | ------------------------------------------------------------ |
178-| Index (landing) | Full-width, no sidebar | Hero with mockup, feature cols, dark band, CTA |146+| Index (landing) | Full-width sections, no chrome | Hero + mock dashboard panel, feature grid, accent band, CTA |
179-| Login | Centered card | Bluesky + Atmosphere sign-in buttons |147+| Login | Centered, chromeless | Handle input + actor typeahead, OAuth start, register |
180-| Dashboard | 2/3 + 1/3 grid | Articles + trending/recommendations sidebar |148+| Dashboard | Single column | Counts, unread articles, lazy recs/digest/trending/people |
181-| Articles | Full-width list | Keyboard nav (j/k/o/m), mark-all-read |149+| Articles | Single column list | Search, status/category chips, sort, expanded scroll-to-read |
182-| Article Detail | `max-w-3xl` centered | Content, like/share/read buttons, annotations |150+| Article Detail | Single column, centered prose | Like/read/share, fetch-content, text-select annotate popover |
183-| Feeds | 2/3 + 1/3 grid | Feed list with categories + add/import sidebar, refresh button |151+| Feeds | 2/3 list + 1/3 sidebar | Categories, add/edit/remove, OPML import/export, refresh |
184-| Trending | Full-width list | Like/annotation counts on each article |152+| Trending | Single column list | Scope toggle (All / For me), sign-in prompt |
185-| Library | Full-width list | Liked articles and annotations |153+| Library | Two columns | Liked articles + annotations, independent pagination |
186-| Profile | `max-w-2xl` centered | Avatar, stats, feeds, annotations |154+| Profile | Centered | Header + stats, settings (digest/expanded/languages), feeds |
155+| Stats | Single column | Metric categories as panels with monospace values |
modified docs/specs.md +165 -124
@@ -14,7 +14,7 @@ The core idea: your RSS subscriptions are a strong signal about your interests.
1414 | ---------------- | ----------------------------------------------------------------------------------------------- |
1515 | Backend | Go |
1616 | Database | SQLite (3 files: users, articles, recs via `mattn/go-sqlite3` + `sqlite-vec` for vector search) |
17-| Frontend | htmx + TailwindCSS |
17+| Frontend | SvelteKit (SSR, adapter-node) + TailwindCSS v4 |
1818 | Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) |
1919 | AT Protocol role | AppView for `at.glean.*` lexicons |
2020 | Data source | AT Protocol Jetstream → SQLite index |
@@ -420,35 +420,38 @@ Beyond the clustering system, Glean also discovers new feeds from article conten
420420
421421 ## 5. System Architecture
422422
423-Glean runs as a single Go binary that fills three roles: **AppView** (indexing `at.glean.*` records from Jetstream, serving XRPC queries), **RSS reader** (fetching and storing feed content), and **web UI** (htmx frontend).
423+Glean is a two-process deployment: a **Go binary** serving a JSON API (`/api/*`) plus XRPC endpoints, and a **SvelteKit SSR server** (adapter-node) that renders the UI and proxies `/api/*` to Go. The Go binary fills two roles: **AppView** (indexing `at.glean.*` records from Jetstream, serving XRPC queries) and **RSS reader** (fetching and storing feed content). The SvelteKit server owns all page routes, HTML rendering, and SSR data loading.
424424
425425 ```
426426 Jetstream (GLEAN_JETSTREAM)
427427 │ subscribe
428428
429- ┌─────────────────────┐
430- │ Go Server (glean.at)│
431- │ │
432- Browser ──HTTP──► │ ┌────────────────┐ │ ──XRPC queries──► Other AT apps
433- (htmx + TW) │ │ Router │ │
434- │ │ ┌───────────┐ │ │
435- │ │ │ Handlers │ │ │
436- │ │ │ (UI + XRPC)│ │ │
437- │ │ └─────┬─────┘ │ │
438- │ └────────┼────────┘ │
439- │ │ │
440- │ ┌────────▼────────┐ │ ┌──────────────────┐
441- │ │ Service Layer │ │ │ Feed Scheduler │
442- │ │ │──┼──sync──►│ (goroutine) │
443- │ └────────┬────────┘ │ │ Fetcher + Parser│
444- │ │ │ └────────┬─────────┘
445- │ ┌────────▼────────┐ │ │
446- │ │ SQLite │ │ RSS/Atom/JSON feeds
447- │ │ (jetstream idx, │ │
448- │ │ articles, │ │ ┌──────────────────┐
449- │ │ read state, │ │ │ Cluster Engine │
450- │ │ clustering) │◄─┼────────►│ (periodic cron) │
451- │ └─────────────────┘ │ └──────────────────┘
429+ Browser ──HTTP──► ┌──────────────────────┐
430+ (SvelteKit UI) │ SvelteKit (glean.at) │ :3000
431+ │ SSR + /api proxy │
432+ └──────────┬───────────┘
433+ │ /api/* (JSON)
434+
435+ ┌──────────────────────┐ ┌──────────────────┐
436+ │ Go Server │ ──XRPC──► Other AT apps
437+ │ (glean.at) :8080 │
438+ │ ┌────────────────┐ │ ┌──────────────────┐
439+ │ │ chi Router │ │ │ Feed Scheduler │
440+ │ │ ┌──────────┐ │ │ │ (goroutine) │
441+ │ │ │ Handlers │ │ │ ──sync──►│ Fetcher+Parser │
442+ │ │ │API + XRPC│ │ │ └────────┬─────────┘
443+ │ │ └────┬─────┘ │ │ │
444+ │ └───────┼────────┘ │ RSS/Atom/JSON feeds
445+ │ ┌───────▼────────┐ │ ┌──────────────────┐
446+ │ │ Service Layer │ │ │ Cluster Engine │
447+ │ └───────┬────────┘ │ ────────►│ (periodic cron) │
448+ │ ┌───────▼────────┐ │ └──────────────────┘
449+ │ │ SQLite │ │
450+ │ │ (jetstream idx,│ │
451+ │ │ articles, │ │ PDS writes (on user
452+ │ │ read state, │◄─┼───────── action via UI)
453+ │ │ clustering) │ │
454+ │ └────────────────┘ │
452455 └──────────────────────┘
453456
454457 AppView responsibilities:
@@ -457,9 +460,8 @@ Glean runs as a single Go binary that fills three roles: **AppView** (indexing `
457460 • Convert at.margin.note records to annotations (displayed alongside glean.at annotations), skip if duplicate glean annotation exists
458461 • Mirror glean annotations as at.margin.note records on user PDS for interoperability
459462 • Import app.skyreader.feed.subscription records as Glean subscriptions
460- • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.)
461- • Host the web UI at glean.at
462- • Write to user PDS on behalf of user (when user acts through UI)
463+ • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.)
464+ • Write to user PDS on behalf of user (when user acts through UI)
463465 ```
464466
465467 ## 6. Database Schema (SQLite)
@@ -929,70 +931,100 @@ CREATE TABLE recs.feed_embedding_meta (
929931
930932 During cron, `ComputeArticleEmbeddings` embeds new articles in batches using `title + summary + content` (the scraped `full_content` is excluded to stay within model token limits — most embedding models cap at ~8k tokens). Text is truncated to 8000 characters as a safety net. Batches are capped at 100 inputs per API call. `ComputeFeedEmbeddings` embeds feed descriptions (`title || description`) and re-embeds when the source text changes (detected via `feed_embedding_meta`). During on-demand article recommendations, the user's liked article embeddings are averaged into an interest vector, then a vec0 KNN query finds the top-200 most semantically similar articles. For cold-start users (<5 subscriptions), their subscribed feed embeddings are averaged and a KNN query finds similar feeds.
931933
932-## 8. HTTP API / htmx Endpoints
933-
934-The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend.
935-
936-### 8.1 Pages
937-
938-| Route | Method | Description |
939-| ------------------------------ | ------ | ---------------------------------------------------------------------- |
940-| `/` | GET | Landing page / auth redirect |
941-| `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds |
942-| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) |
943-| `/feeds/list` | GET | Feed list fragment (htmx partial) |
944-| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) |
945-| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) |
946-| `/feeds/add` | POST | Add a single feed URL |
947-| `/feeds/remove` | DELETE | Remove a feed |
948-| `/feeds/refresh` | POST | Refresh all subscribed feeds |
949-| `/feeds/retry` | POST | Retry a failed feed |
950-| `/feeds/clear` | POST | Clear all subscriptions |
951-| `/feeds/dismiss` | POST | Dismiss a feed recommendation |
952-| `/articles` | GET | Read articles (paginated, filterable by feed) |
953-| `/articles/new-count` | GET | Get count of new articles (for badge updates) |
954-| `/articles/{id}` | GET | Article detail view |
955-| `/articles/{id}/read` | POST | Mark article as read |
956-| `/articles/{id}/unread` | POST | Mark article as unread |
957-| `/articles/{id}/like` | POST | Like an article |
958-| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL |
959-| `/articles/mark-all-read` | POST | Mark all articles as read |
960-| `/articles/dismiss` | POST | Dismiss an article recommendation |
961-| `/trending` | GET | Community feed: articles ranked by likes (public) |
962-| `/library` | GET | Liked articles and annotations |
963-| `/library/create` | POST | Create annotation on an article |
964-| `/library/{id}/delete` | POST | Delete an annotation |
965-| `/stats` | GET | Application metrics and performance data (Prometheus, public) |
966-| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |
967-| `/settings/languages/{code}` | POST | Toggle a preferred recommendation language (htmx, requires auth) |
968-| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) |
969-| `/settings/digest-enabled` | POST | Toggle daily digest setting (htmx, requires auth) |
970-| `/digest` | GET | Daily digest fragment (LLM summary of unread articles, htmx partial) |
971-| `/digest/mark-read` | POST | Mark digest articles as read |
972-| `/auth/login` | GET | Login page |
973-| `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |
974-| `/auth/resolve` | GET | Resolve handle to DID |
975-| `/auth/start` | POST | Start OAuth authorization flow |
976-| `/auth/callback` | GET | OAuth callback |
977-| `/terms` | GET | Terms of service |
978-| `/sitemap.xml` | GET | XML sitemap for search engines |
979-
980-### 8.2 htmx Patterns
981-
982-- **Feed list**: `<div hx-get="/feeds/list" hx-trigger="load">` renders the subscription list as a fragment
983-- **Infinite scroll articles**: `<div hx-get="/articles?page=2" hx-trigger="intersect">` for pagination
984-- **Like button**: `<button hx-post="/articles/{id}/like" hx-swap="outerHTML">` self-updates the button state
985-- **OPML upload**: `<form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" hx-target="#feed-list">`
934+## 8. HTTP API
935+
936+The Go server exposes a JSON API under `/api/*`. The SvelteKit frontend (see `web/`) owns all HTML page routes and consumes these endpoints via `web/src/lib/api.ts`; `web/src/hooks.server.ts` proxies `/api/*` to Go, forwarding cookies and headers. All state-changing requests require a double-submit CSRF token (`X-CSRF-Token` header or `csrf_token` form field) matching the `glean_csrf` cookie.
937+
938+### 8.1 JSON endpoints
939+
940+All endpoints below return JSON. Those marked 🔒 require authentication (session cookie). Form fields are sent `application/x-www-form-urlencoded`; file uploads are `multipart/form-data`.
941+
942+| Route | Method | Auth | Description |
943+| ---------------------------------- | ------ | ---- | ------------------------------------------------------------------------------ |
944+| `/api/me` | GET | | Current user, CSRF token, feature flags (`has_llm`, `client_id`) |
945+| `/api/dashboard` | GET | 🔒 | Dashboard: article recs, unread articles, trending, digest flag |
946+| `/api/feeds` | GET | 🔒 | Subscriptions (paginated), categories, dead feeds |
947+| `/api/feeds/add` | POST | 🔒 | Add a feed URL (fields: `feed_url`, `category`) |
948+| `/api/feeds/edit` | POST | 🔒 | Edit a subscription category |
949+| `/api/feeds/opml/upload` | POST | 🔒 | Bulk-import subscriptions from OPML (field: `opml` file) |
950+| `/api/feeds/opml/download` | GET | 🔒 | Export subscriptions as OPML |
951+| `/api/feeds/refresh` | POST | 🔒 | Refresh all subscribed feeds |
952+| `/api/feeds/retry` | POST | 🔒 | Retry a failed feed |
953+| `/api/feeds/list` | GET | 🔒 | Flat subscription list (optional `?category=`) |
954+| `/api/feeds/clear` | POST | 🔒 | Remove all subscriptions |
955+| `/api/articles` | GET | 🔒 | Read articles (paginated; `?feed=`, `?status=`, `?q=`, `?sort=`, `?category=`) |
956+| `/api/articles/new-count` | GET | 🔒 | Count of articles newer than `?since=` (unix seconds) — banner polling |
957+| `/api/articles/{id}` | GET | 🔒 | Article detail + annotations (marks read as a side effect) |
958+| `/api/articles/{id}/read` | POST | 🔒 | Mark article read |
959+| `/api/articles/{id}/unread` | POST | 🔒 | Mark article unread |
960+| `/api/articles/{id}/like` | POST | 🔒 | Toggle like (writes/deletes on PDS) |
961+| `/api/articles/{id}/fetch-content` | POST | 🔒 | Scrape full article content from the source URL |
962+| `/api/articles/mark-all-read` | POST | 🔒 | Mark all (or `?feed=`-scoped) articles read |
963+| `/api/trending` | GET | | Trending articles (`?scope=for-me` requires auth) |
964+| `/api/profile/{did}` | GET | 🔒 | Public profile: feeds, annotations, languages (resolves handles) |
965+| `/api/library` | GET | 🔒 | Liked articles + annotations (paginated) |
966+| `/api/library/create` | POST | 🔒 | Create annotation (writes at.glean.annotation + at.margin.note mirror) |
967+| `/api/library/{id}/delete` | POST | 🔒 | Delete annotation + mirror |
968+| `/api/recs/articles` | GET | 🔒 | Article recommendations |
969+| `/api/recs/feeds` | GET | 🔒 | Feed recommendations |
970+| `/api/recs/people` | GET | 🔒 | People recommendations (followed + discover) |
971+| `/api/recs/dismiss-feed` | POST | 🔒 | Dismiss a feed recommendation |
972+| `/api/recs/dismiss-article` | POST | 🔒 | Dismiss an article recommendation |
973+| `/api/recs/dismiss-person` | POST | 🔒 | Dismiss a person recommendation |
974+| `/api/settings/languages/{code}` | POST | 🔒 | Toggle a preferred recommendation language |
975+| `/api/settings/expanded-view` | POST | 🔒 | Toggle expanded article view |
976+| `/api/settings/digest-enabled` | POST | 🔒 | Toggle daily digest |
977+| `/api/digest` | GET | 🔒 | Daily digest (LLM summary of unread articles); 204 when none/unavailable |
978+| `/api/digest/mark-read` | POST | 🔒 | Mark digest articles read (field: repeated `ids`) |
979+| `/api/auth/login` | GET | | Whether OAuth is enabled (`oauth_enabled`) |
980+| `/api/auth/register` | GET | | Start registration via Eurosky (`{redirect}`) |
981+| `/api/auth/actors` | GET | | Handle typeahead (`?q=`) → `{actors}` |
982+| `/api/auth/start` | POST | 🔒 | Start OAuth flow (field: `handle`) → `{redirect}` |
983+| `/api/auth/callback` | GET | | OAuth callback (browser redirect, not JSON) |
984+| `/api/auth/logout` | POST | 🔒 | End session → `{redirect}` |
985+| `/api/oauth/client-metadata` | GET | | OAuth client metadata document |
986+| `/api/sitemap` | GET | | Sitemap entries (JSON) |
987+| `/api/stats` | GET | | Prometheus metrics parsed into `{metrics}` |
988+| `/metrics` | GET | | Raw Prometheus exposition |
989+
990+XRPC query endpoints (public, no `/api` prefix):
991+
992+| Route | Description |
993+| ----------------------------------- | --------------------------- |
994+| `/xrpc/at.glean.listSubscriptions` | List a user's subscriptions |
995+| `/xrpc/at.glean.listAnnotations` | List annotations |
996+| `/xrpc/at.glean.listLikes` | List likes |
997+| `/xrpc/at.glean.getTrending` | Trending articles |
998+| `/xrpc/at.glean.getRecommendations` | Recommendations |
999+| `/xrpc/at.glean.listFeedLists` | Feed lists |
1000+
1001+### 8.2 Frontend routes
1002+
1003+All HTML page routes live in the SvelteKit app (`web/src/routes/`), not on the Go server. Each route has a `+page.server.ts` load function that calls the JSON API via `endpointsFor(event.fetch)` and a `+page.svelte` rendering component. The layout (`+layout.svelte`) renders the shared chrome and mounts the new-articles banner.
1004+
1005+| Path | Load source |
1006+| ---------------- | -------------------- |
1007+| `/` | `+page` (landing) |
1008+| `/dashboard` | `/api/dashboard` |
1009+| `/articles` | `/api/articles` |
1010+| `/articles/[id]` | `/api/articles/{id}` |
1011+| `/feeds` | `/api/feeds` |
1012+| `/library` | `/api/library` |
1013+| `/trending` | `/api/trending` |
1014+| `/profile/[did]` | `/api/profile/{did}` |
1015+| `/stats` | `/api/stats` |
1016+| `/terms` | (static content) |
1017+| `/auth/login` | `/api/auth/login` |
1018+| `/sitemap.xml` | `/api/sitemap` |
9861019
9871020 ## 9. Project Structure
9881021
9891022 ```
9901023 glean/
9911024 ├── main.go # Entry point, wire everything
992-├── go.mod
993-├── go.sum
994-├── Dockerfile
995-├── Makefile
1025+├── go.mod / go.sum
1026+├── Dockerfile # Multi-stage: builds web/ then Go, runs both
1027+├── Makefile # Targets: build, dev-api, dev-web, web-build, test, ...
9961028 ├── lexicons/
9971029 │ └── at/
9981030 │ ├── glean/ # Glean lexicon JSON schemas (subscription, annotation, like)
@@ -1019,20 +1051,21 @@ glean/
10191051 │ │ ├── follow.go # Follow queries
10201052 │ │ ├── oauth_store.go # OAuth session storage
10211053 │ │ ├── user_settings.go # User settings queries
1022-│ │ ├── store.go # FeedStore adapter for scheduler
1054+│ │ └── store.go # FeedStore adapter for scheduler
10231055 │ ├── feed/
10241056 │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser
10251057 │ │ ├── fetcher.go # Scheduler with dedup + Fetcher
10261058 │ │ ├── discover.go # Feed auto-discovery from URLs
10271059 │ │ └── opml.go # OPML import/export
10281060 │ ├── httpclient/
1029-│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent
1061+│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent
10301062 │ ├── scraper/
10311063 │ │ └── scraper.go # Full article content scraper
10321064 │ ├── metrics/
10331065 │ │ └── metrics.go # Prometheus metrics definitions
10341066 │ ├── ml/
10351067 │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers
1068+│ │ ├── langdetect.go # Known-language table + detection
10361069 │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation
10371070 │ ├── cluster/
10381071 │ │ ├── jaccard.go # Jaccard similarity computation
@@ -1046,49 +1079,57 @@ glean/
10461079 │ ├── feedback/
10471080 │ │ └── feedback.go # Dismiss + impression tracking service
10481081 │ ├── server/
1049-│ │ ├── server.go # HTTP server, router setup
1050-│ │ ├── auth_handler.go # OAuth login/callback/register
1082+│ │ ├── server.go # HTTP server, chi router setup (/api/* routes)
1083+│ │ ├── api.go # JSON helpers (writeJSON), DTOs, null helpers
1084+│ │ ├── api_helpers.go # URL validation helpers
1085+│ │ ├── auth_handler.go # OAuth login/callback/register/logout
10511086 │ │ ├── feeds_handler.go # Feed management handlers
10521087 │ │ ├── articles_handler.go # Article reading handlers
1053-│ │ ├── annotations_handler.go # Annotation handlers
1054-│ │ ├── dashboard_handler.go # Dashboard handler
1088+│ │ ├── annotations_handler.go # Annotation + library handlers
1089+│ │ ├── dashboard_handler.go # Dashboard + recommendation handlers
1090+│ │ ├── recs_handler.go # Recommendation dismiss handlers
10551091 │ │ ├── trending_handler.go # Trending handler
1056-│ │ ├── stats_handler.go # Stats handler (Prometheus metrics display)
1057-│ │ ├── index_handler.go # Landing page handler
10581092 │ │ ├── profile_handler.go # Public profile handler
1059-│ │ ├── settings_handler.go # User settings (language preferences, digest toggle)
1093+│ │ ├── settings_handler.go # User settings (language preferences, digest/expanded toggle)
10601094 │ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read)
1061-│ │ ├── recs_handler.go # Recommendation dismiss handlers
1062-│ │ ├── sitemap_handler.go # XML sitemap handler (public pages)
1063-│ │ ├── terms_handler.go # Terms of service handler
1095+│ │ ├── stats_handler.go # Stats handler (Prometheus metrics → JSON)
1096+│ │ ├── sitemap_handler.go # Sitemap handler
1097+│ │ ├── index_handler.go # /api/me + auth-login meta handler
1098+│ │ ├── sync_handlers.go # Periodic sync + collection-dir backfill
1099+│ │ ├── sanitize.go # HTML sanitization for article content
10641100 │ │ ├── pagination.go # Pagination helpers
1065-│ │ ├── middleware.go # Auth, logging, CSRF middleware
1101+│ │ ├── middleware.go # Auth, logging, CSRF, CORS middleware
10661102 │ │ └── session.go # Session management
1067-│ ├── sanitize/
1068-│ │ └── sanitize.go # HTML sanitization for article content
1069-│ └── tmpl/
1070-│ ├── base.html # Base template with htmx + Tailwind
1071-│ ├── index.html # Landing page
1072-│ ├── login.html # Login page
1073-│ ├── dashboard.html # Dashboard
1074-│ ├── feeds.html # Feed management
1075-│ ├── articles.html # Article listing
1076-│ ├── article_detail.html # Article detail
1077-│ ├── trending.html # Trending articles
1078-│ ├── stats.html # Application metrics
1079-│ ├── library.html # Liked articles + annotations
1080-│ ├── profile.html # User profile
1081-│ ├── error.html # Error page
1082-│ ├── 404.html # Not found page
1083-│ ├── terms.html # Terms of service
1084-│ └── partials/ # Reusable template fragments
1085-├── static/
1086-│ ├── input.css # Tailwind input
1087-│ └── output.css # Tailwind compiled output
1088-├── docs/
1089-│ ├── specs.md # Technical specification (this document)
1090-│ └── design.md # Design system
1091-└── tailwind.config.js
1103+├── web/ # SvelteKit (SSR) frontend, adapter-node
1104+│ ├── src/
1105+│ │ ├── app.css # Tailwind v4 entry + design tokens (@theme, [data-theme], @utility)
1106+│ │ ├── app.html # HTML shell
1107+│ │ ├── app.d.ts # Locals types (user, csrfToken)
1108+│ │ ├── hooks.server.ts # /api/* proxy to Go + per-request user load
1109+│ │ ├── lib/
1110+│ │ │ ├── api.ts # Typed endpoint client (endpoints / endpointsFor(fetch))
1111+│ │ │ ├── types.ts # API response types
1112+│ │ │ ├── format.ts # Date/HTML/youtube formatting helpers
1113+│ │ │ └── components/ # Svelte components (ArticleCard, FeedItem, Icon, ...)
1114+│ │ └── routes/ # File-based routes (+page.server.ts load + +page.svelte)
1115+│ │ ├── +layout.svelte # Chrome (nav, footer, dialogs, new-articles banner)
1116+│ │ ├── articles/[id]/ # Article detail
1117+│ │ ├── dashboard/ # Dashboard
1118+│ │ ├── feeds/ # Feed management
1119+│ │ ├── library/ # Liked + annotations
1120+│ │ ├── trending/ # Trending
1121+│ │ ├── profile/[did]/ # Public profile
1122+│ │ ├── stats/ # Metrics
1123+│ │ ├── auth/login/ # Login
1124+│ │ ├── terms/ # Terms (static)
1125+│ │ └── sitemap.xml/ # Sitemap (+server.ts)
1126+│ ├── static/ # Favicons, manifest, banner
1127+│ ├── svelte.config.js # adapter-node
1128+│ ├── vite.config.ts # dev server on :3000
1129+│ └── package.json # bun; svelte, @sveltejs/kit, @tailwindcss/vite
1130+└── docs/
1131+ ├── specs.md # Technical specification (this document)
1132+ └── design.md # Design system
10921133 ```
10931134
10941135 ## 10. Auth Flow
@@ -14,7 +14,7 @@ The core idea: your RSS subscriptions are a strong signal about your interests.
14 | ---------------- | ----------------------------------------------------------------------------------------------- |14 | ---------------- | ----------------------------------------------------------------------------------------------- |
15 | Backend | Go |15 | Backend | Go |
16 | Database | SQLite (3 files: users, articles, recs via `mattn/go-sqlite3` + `sqlite-vec` for vector search) |16 | Database | SQLite (3 files: users, articles, recs via `mattn/go-sqlite3` + `sqlite-vec` for vector search) |
17-| Frontend | htmx + TailwindCSS |17+| Frontend | SvelteKit (SSR, adapter-node) + TailwindCSS v4 |
18 | Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) |18 | Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) |
19 | AT Protocol role | AppView for `at.glean.*` lexicons |19 | AT Protocol role | AppView for `at.glean.*` lexicons |
20 | Data source | AT Protocol Jetstream → SQLite index |20 | Data source | AT Protocol Jetstream → SQLite index |
@@ -420,35 +420,38 @@ Beyond the clustering system, Glean also discovers new feeds from article conten
420 420
421 ## 5. System Architecture421 ## 5. System Architecture
422 422
423-Glean runs as a single Go binary that fills three roles: **AppView** (indexing `at.glean.*` records from Jetstream, serving XRPC queries), **RSS reader** (fetching and storing feed content), and **web UI** (htmx frontend).423+Glean is a two-process deployment: a **Go binary** serving a JSON API (`/api/*`) plus XRPC endpoints, and a **SvelteKit SSR server** (adapter-node) that renders the UI and proxies `/api/*` to Go. The Go binary fills two roles: **AppView** (indexing `at.glean.*` records from Jetstream, serving XRPC queries) and **RSS reader** (fetching and storing feed content). The SvelteKit server owns all page routes, HTML rendering, and SSR data loading.
424 424
425 ```425 ```
426 Jetstream (GLEAN_JETSTREAM)426 Jetstream (GLEAN_JETSTREAM)
427 │ subscribe427 │ subscribe
428 428
429- ┌─────────────────────┐429+ Browser ──HTTP──► ┌──────────────────────┐
430- │ Go Server (glean.at)│430+ (SvelteKit UI) │ SvelteKit (glean.at) │ :3000
431- │ │431+ │ SSR + /api proxy │
432- Browser ──HTTP──► │ ┌────────────────┐ │ ──XRPC queries──► Other AT apps432+ └──────────┬───────────┘
433- (htmx + TW) │ │ Router │ │433+ │ /api/* (JSON)
434- │ │ ┌───────────┐ │ │434+
435- │ │ │ Handlers │ │ │435+ ┌──────────────────────┐ ┌──────────────────┐
436- │ │ │ (UI + XRPC)│ │ │436+ │ Go Server │ ──XRPC──► Other AT apps
437- │ │ └─────┬─────┘ │ │437+ │ (glean.at) :8080 │
438- │ └────────┼────────┘ │438+ │ ┌────────────────┐ │ ┌──────────────────┐
439- │ │ │439+ │ │ chi Router │ │ │ Feed Scheduler │
440- │ ┌────────▼────────┐ │ ┌──────────────────┐440+ │ │ ┌──────────┐ │ │ │ (goroutine) │
441- │ │ Service Layer │ │ │ Feed Scheduler │441+ │ │ │ Handlers │ │ │ ──sync──►│ Fetcher+Parser │
442- │ │ │──┼──sync──►│ (goroutine) │442+ │ │ │API + XRPC│ │ │ └────────┬─────────┘
443- │ └────────┬────────┘ │ │ Fetcher + Parser│443+ │ │ └────┬─────┘ │ │ │
444- │ │ │ └────────┬─────────┘444+ │ └───────┼────────┘ │ RSS/Atom/JSON feeds
445- │ ┌────────▼────────┐ │ │445+ │ ┌───────▼────────┐ │ ┌──────────────────┐
446- │ │ SQLite │ │ RSS/Atom/JSON feeds446+ │ │ Service Layer │ │ │ Cluster Engine │
447- │ │ (jetstream idx, │ │447+ │ └───────┬────────┘ │ ────────►│ (periodic cron) │
448- │ │ articles, │ │ ┌──────────────────┐448+ │ ┌───────▼────────┐ │ └──────────────────┘
449- │ │ read state, │ │ │ Cluster Engine │449+ │ │ SQLite │ │
450- │ │ clustering) │◄─┼────────►│ (periodic cron) │450+ │ │ (jetstream idx,│ │
451- │ └─────────────────┘ │ └──────────────────┘451+ │ │ articles, │ │ PDS writes (on user
452+ │ │ read state, │◄─┼───────── action via UI)
453+ │ │ clustering) │ │
454+ │ └────────────────┘ │
452 └──────────────────────┘455 └──────────────────────┘
453 456
454 AppView responsibilities:457 AppView responsibilities:
@@ -457,9 +460,8 @@ Glean runs as a single Go binary that fills three roles: **AppView** (indexing `
457 • Convert at.margin.note records to annotations (displayed alongside glean.at annotations), skip if duplicate glean annotation exists460 • Convert at.margin.note records to annotations (displayed alongside glean.at annotations), skip if duplicate glean annotation exists
458 • Mirror glean annotations as at.margin.note records on user PDS for interoperability461 • Mirror glean annotations as at.margin.note records on user PDS for interoperability
459 • Import app.skyreader.feed.subscription records as Glean subscriptions462 • Import app.skyreader.feed.subscription records as Glean subscriptions
460- • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.)463+ • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.)
461- • Host the web UI at glean.at464+ • Write to user PDS on behalf of user (when user acts through UI)
462- • Write to user PDS on behalf of user (when user acts through UI)
463 ```465 ```
464 466
465 ## 6. Database Schema (SQLite)467 ## 6. Database Schema (SQLite)
@@ -929,70 +931,100 @@ CREATE TABLE recs.feed_embedding_meta (
929 931
930 During cron, `ComputeArticleEmbeddings` embeds new articles in batches using `title + summary + content` (the scraped `full_content` is excluded to stay within model token limits — most embedding models cap at ~8k tokens). Text is truncated to 8000 characters as a safety net. Batches are capped at 100 inputs per API call. `ComputeFeedEmbeddings` embeds feed descriptions (`title || description`) and re-embeds when the source text changes (detected via `feed_embedding_meta`). During on-demand article recommendations, the user's liked article embeddings are averaged into an interest vector, then a vec0 KNN query finds the top-200 most semantically similar articles. For cold-start users (<5 subscriptions), their subscribed feed embeddings are averaged and a KNN query finds similar feeds.932 During cron, `ComputeArticleEmbeddings` embeds new articles in batches using `title + summary + content` (the scraped `full_content` is excluded to stay within model token limits — most embedding models cap at ~8k tokens). Text is truncated to 8000 characters as a safety net. Batches are capped at 100 inputs per API call. `ComputeFeedEmbeddings` embeds feed descriptions (`title || description`) and re-embeds when the source text changes (detected via `feed_embedding_meta`). During on-demand article recommendations, the user's liked article embeddings are averaged into an interest vector, then a vec0 KNN query finds the top-200 most semantically similar articles. For cold-start users (<5 subscriptions), their subscribed feed embeddings are averaged and a KNN query finds similar feeds.
931 933
932-## 8. HTTP API / htmx Endpoints934+## 8. HTTP API
933-935+
934-The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend.936+The Go server exposes a JSON API under `/api/*`. The SvelteKit frontend (see `web/`) owns all HTML page routes and consumes these endpoints via `web/src/lib/api.ts`; `web/src/hooks.server.ts` proxies `/api/*` to Go, forwarding cookies and headers. All state-changing requests require a double-submit CSRF token (`X-CSRF-Token` header or `csrf_token` form field) matching the `glean_csrf` cookie.
935-937+
936-### 8.1 Pages938+### 8.1 JSON endpoints
937-939+
938-| Route | Method | Description |940+All endpoints below return JSON. Those marked 🔒 require authentication (session cookie). Form fields are sent `application/x-www-form-urlencoded`; file uploads are `multipart/form-data`.
939-| ------------------------------ | ------ | ---------------------------------------------------------------------- |941+
940-| `/` | GET | Landing page / auth redirect |942+| Route | Method | Auth | Description |
941-| `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds |943+| ---------------------------------- | ------ | ---- | ------------------------------------------------------------------------------ |
942-| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) |944+| `/api/me` | GET | | Current user, CSRF token, feature flags (`has_llm`, `client_id`) |
943-| `/feeds/list` | GET | Feed list fragment (htmx partial) |945+| `/api/dashboard` | GET | 🔒 | Dashboard: article recs, unread articles, trending, digest flag |
944-| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) |946+| `/api/feeds` | GET | 🔒 | Subscriptions (paginated), categories, dead feeds |
945-| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) |947+| `/api/feeds/add` | POST | 🔒 | Add a feed URL (fields: `feed_url`, `category`) |
946-| `/feeds/add` | POST | Add a single feed URL |948+| `/api/feeds/edit` | POST | 🔒 | Edit a subscription category |
947-| `/feeds/remove` | DELETE | Remove a feed |949+| `/api/feeds/opml/upload` | POST | 🔒 | Bulk-import subscriptions from OPML (field: `opml` file) |
948-| `/feeds/refresh` | POST | Refresh all subscribed feeds |950+| `/api/feeds/opml/download` | GET | 🔒 | Export subscriptions as OPML |
949-| `/feeds/retry` | POST | Retry a failed feed |951+| `/api/feeds/refresh` | POST | 🔒 | Refresh all subscribed feeds |
950-| `/feeds/clear` | POST | Clear all subscriptions |952+| `/api/feeds/retry` | POST | 🔒 | Retry a failed feed |
951-| `/feeds/dismiss` | POST | Dismiss a feed recommendation |953+| `/api/feeds/list` | GET | 🔒 | Flat subscription list (optional `?category=`) |
952-| `/articles` | GET | Read articles (paginated, filterable by feed) |954+| `/api/feeds/clear` | POST | 🔒 | Remove all subscriptions |
953-| `/articles/new-count` | GET | Get count of new articles (for badge updates) |955+| `/api/articles` | GET | 🔒 | Read articles (paginated; `?feed=`, `?status=`, `?q=`, `?sort=`, `?category=`) |
954-| `/articles/{id}` | GET | Article detail view |956+| `/api/articles/new-count` | GET | 🔒 | Count of articles newer than `?since=` (unix seconds) — banner polling |
955-| `/articles/{id}/read` | POST | Mark article as read |957+| `/api/articles/{id}` | GET | 🔒 | Article detail + annotations (marks read as a side effect) |
956-| `/articles/{id}/unread` | POST | Mark article as unread |958+| `/api/articles/{id}/read` | POST | 🔒 | Mark article read |
957-| `/articles/{id}/like` | POST | Like an article |959+| `/api/articles/{id}/unread` | POST | 🔒 | Mark article unread |
958-| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL |960+| `/api/articles/{id}/like` | POST | 🔒 | Toggle like (writes/deletes on PDS) |
959-| `/articles/mark-all-read` | POST | Mark all articles as read |961+| `/api/articles/{id}/fetch-content` | POST | 🔒 | Scrape full article content from the source URL |
960-| `/articles/dismiss` | POST | Dismiss an article recommendation |962+| `/api/articles/mark-all-read` | POST | 🔒 | Mark all (or `?feed=`-scoped) articles read |
961-| `/trending` | GET | Community feed: articles ranked by likes (public) |963+| `/api/trending` | GET | | Trending articles (`?scope=for-me` requires auth) |
962-| `/library` | GET | Liked articles and annotations |964+| `/api/profile/{did}` | GET | 🔒 | Public profile: feeds, annotations, languages (resolves handles) |
963-| `/library/create` | POST | Create annotation on an article |965+| `/api/library` | GET | 🔒 | Liked articles + annotations (paginated) |
964-| `/library/{id}/delete` | POST | Delete an annotation |966+| `/api/library/create` | POST | 🔒 | Create annotation (writes at.glean.annotation + at.margin.note mirror) |
965-| `/stats` | GET | Application metrics and performance data (Prometheus, public) |967+| `/api/library/{id}/delete` | POST | 🔒 | Delete annotation + mirror |
966-| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations |968+| `/api/recs/articles` | GET | 🔒 | Article recommendations |
967-| `/settings/languages/{code}` | POST | Toggle a preferred recommendation language (htmx, requires auth) |969+| `/api/recs/feeds` | GET | 🔒 | Feed recommendations |
968-| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) |970+| `/api/recs/people` | GET | 🔒 | People recommendations (followed + discover) |
969-| `/settings/digest-enabled` | POST | Toggle daily digest setting (htmx, requires auth) |971+| `/api/recs/dismiss-feed` | POST | 🔒 | Dismiss a feed recommendation |
970-| `/digest` | GET | Daily digest fragment (LLM summary of unread articles, htmx partial) |972+| `/api/recs/dismiss-article` | POST | 🔒 | Dismiss an article recommendation |
971-| `/digest/mark-read` | POST | Mark digest articles as read |973+| `/api/recs/dismiss-person` | POST | 🔒 | Dismiss a person recommendation |
972-| `/auth/login` | GET | Login page |974+| `/api/settings/languages/{code}` | POST | 🔒 | Toggle a preferred recommendation language |
973-| `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) |975+| `/api/settings/expanded-view` | POST | 🔒 | Toggle expanded article view |
974-| `/auth/resolve` | GET | Resolve handle to DID |976+| `/api/settings/digest-enabled` | POST | 🔒 | Toggle daily digest |
975-| `/auth/start` | POST | Start OAuth authorization flow |977+| `/api/digest` | GET | 🔒 | Daily digest (LLM summary of unread articles); 204 when none/unavailable |
976-| `/auth/callback` | GET | OAuth callback |978+| `/api/digest/mark-read` | POST | 🔒 | Mark digest articles read (field: repeated `ids`) |
977-| `/terms` | GET | Terms of service |979+| `/api/auth/login` | GET | | Whether OAuth is enabled (`oauth_enabled`) |
978-| `/sitemap.xml` | GET | XML sitemap for search engines |980+| `/api/auth/register` | GET | | Start registration via Eurosky (`{redirect}`) |
979-981+| `/api/auth/actors` | GET | | Handle typeahead (`?q=`) → `{actors}` |
980-### 8.2 htmx Patterns982+| `/api/auth/start` | POST | 🔒 | Start OAuth flow (field: `handle`) → `{redirect}` |
981-983+| `/api/auth/callback` | GET | | OAuth callback (browser redirect, not JSON) |
982-- **Feed list**: `<div hx-get="/feeds/list" hx-trigger="load">` renders the subscription list as a fragment984+| `/api/auth/logout` | POST | 🔒 | End session → `{redirect}` |
983-- **Infinite scroll articles**: `<div hx-get="/articles?page=2" hx-trigger="intersect">` for pagination985+| `/api/oauth/client-metadata` | GET | | OAuth client metadata document |
984-- **Like button**: `<button hx-post="/articles/{id}/like" hx-swap="outerHTML">` self-updates the button state986+| `/api/sitemap` | GET | | Sitemap entries (JSON) |
985-- **OPML upload**: `<form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" hx-target="#feed-list">`987+| `/api/stats` | GET | | Prometheus metrics parsed into `{metrics}` |
988+| `/metrics` | GET | | Raw Prometheus exposition |
989+
990+XRPC query endpoints (public, no `/api` prefix):
991+
992+| Route | Description |
993+| ----------------------------------- | --------------------------- |
994+| `/xrpc/at.glean.listSubscriptions` | List a user's subscriptions |
995+| `/xrpc/at.glean.listAnnotations` | List annotations |
996+| `/xrpc/at.glean.listLikes` | List likes |
997+| `/xrpc/at.glean.getTrending` | Trending articles |
998+| `/xrpc/at.glean.getRecommendations` | Recommendations |
999+| `/xrpc/at.glean.listFeedLists` | Feed lists |
1000+
1001+### 8.2 Frontend routes
1002+
1003+All HTML page routes live in the SvelteKit app (`web/src/routes/`), not on the Go server. Each route has a `+page.server.ts` load function that calls the JSON API via `endpointsFor(event.fetch)` and a `+page.svelte` rendering component. The layout (`+layout.svelte`) renders the shared chrome and mounts the new-articles banner.
1004+
1005+| Path | Load source |
1006+| ---------------- | -------------------- |
1007+| `/` | `+page` (landing) |
1008+| `/dashboard` | `/api/dashboard` |
1009+| `/articles` | `/api/articles` |
1010+| `/articles/[id]` | `/api/articles/{id}` |
1011+| `/feeds` | `/api/feeds` |
1012+| `/library` | `/api/library` |
1013+| `/trending` | `/api/trending` |
1014+| `/profile/[did]` | `/api/profile/{did}` |
1015+| `/stats` | `/api/stats` |
1016+| `/terms` | (static content) |
1017+| `/auth/login` | `/api/auth/login` |
1018+| `/sitemap.xml` | `/api/sitemap` |
986 1019
987 ## 9. Project Structure1020 ## 9. Project Structure
988 1021
989 ```1022 ```
990 glean/1023 glean/
991 ├── main.go # Entry point, wire everything1024 ├── main.go # Entry point, wire everything
992-├── go.mod1025+├── go.mod / go.sum
993-├── go.sum1026+├── Dockerfile # Multi-stage: builds web/ then Go, runs both
994-├── Dockerfile1027+├── Makefile # Targets: build, dev-api, dev-web, web-build, test, ...
995-├── Makefile
996 ├── lexicons/1028 ├── lexicons/
997 │ └── at/1029 │ └── at/
998 │ ├── glean/ # Glean lexicon JSON schemas (subscription, annotation, like)1030 │ ├── glean/ # Glean lexicon JSON schemas (subscription, annotation, like)
@@ -1019,20 +1051,21 @@ glean/
1019 │ │ ├── follow.go # Follow queries1051 │ │ ├── follow.go # Follow queries
1020 │ │ ├── oauth_store.go # OAuth session storage1052 │ │ ├── oauth_store.go # OAuth session storage
1021 │ │ ├── user_settings.go # User settings queries1053 │ │ ├── user_settings.go # User settings queries
1022-│ │ ├── store.go # FeedStore adapter for scheduler1054+│ │ └── store.go # FeedStore adapter for scheduler
1023 │ ├── feed/1055 │ ├── feed/
1024 │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser1056 │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser
1025 │ │ ├── fetcher.go # Scheduler with dedup + Fetcher1057 │ │ ├── fetcher.go # Scheduler with dedup + Fetcher
1026 │ │ ├── discover.go # Feed auto-discovery from URLs1058 │ │ ├── discover.go # Feed auto-discovery from URLs
1027 │ │ └── opml.go # OPML import/export1059 │ │ └── opml.go # OPML import/export
1028 │ ├── httpclient/1060 │ ├── httpclient/
1029-│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent1061+│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent
1030 │ ├── scraper/1062 │ ├── scraper/
1031 │ │ └── scraper.go # Full article content scraper1063 │ │ └── scraper.go # Full article content scraper
1032 │ ├── metrics/1064 │ ├── metrics/
1033 │ │ └── metrics.go # Prometheus metrics definitions1065 │ │ └── metrics.go # Prometheus metrics definitions
1034 │ ├── ml/1066 │ ├── ml/
1035 │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers1067 │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers
1068+│ │ ├── langdetect.go # Known-language table + detection
1036 │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation1069 │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation
1037 │ ├── cluster/1070 │ ├── cluster/
1038 │ │ ├── jaccard.go # Jaccard similarity computation1071 │ │ ├── jaccard.go # Jaccard similarity computation
@@ -1046,49 +1079,57 @@ glean/
1046 │ ├── feedback/1079 │ ├── feedback/
1047 │ │ └── feedback.go # Dismiss + impression tracking service1080 │ │ └── feedback.go # Dismiss + impression tracking service
1048 │ ├── server/1081 │ ├── server/
1049-│ │ ├── server.go # HTTP server, router setup1082+│ │ ├── server.go # HTTP server, chi router setup (/api/* routes)
1050-│ │ ├── auth_handler.go # OAuth login/callback/register1083+│ │ ├── api.go # JSON helpers (writeJSON), DTOs, null helpers
1084+│ │ ├── api_helpers.go # URL validation helpers
1085+│ │ ├── auth_handler.go # OAuth login/callback/register/logout
1051 │ │ ├── feeds_handler.go # Feed management handlers1086 │ │ ├── feeds_handler.go # Feed management handlers
1052 │ │ ├── articles_handler.go # Article reading handlers1087 │ │ ├── articles_handler.go # Article reading handlers
1053-│ │ ├── annotations_handler.go # Annotation handlers1088+│ │ ├── annotations_handler.go # Annotation + library handlers
1054-│ │ ├── dashboard_handler.go # Dashboard handler1089+│ │ ├── dashboard_handler.go # Dashboard + recommendation handlers
1090+│ │ ├── recs_handler.go # Recommendation dismiss handlers
1055 │ │ ├── trending_handler.go # Trending handler1091 │ │ ├── trending_handler.go # Trending handler
1056-│ │ ├── stats_handler.go # Stats handler (Prometheus metrics display)
1057-│ │ ├── index_handler.go # Landing page handler
1058 │ │ ├── profile_handler.go # Public profile handler1092 │ │ ├── profile_handler.go # Public profile handler
1059-│ │ ├── settings_handler.go # User settings (language preferences, digest toggle)1093+│ │ ├── settings_handler.go # User settings (language preferences, digest/expanded toggle)
1060 │ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read)1094 │ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read)
1061-│ │ ├── recs_handler.go # Recommendation dismiss handlers1095+│ │ ├── stats_handler.go # Stats handler (Prometheus metrics → JSON)
1062-│ │ ├── sitemap_handler.go # XML sitemap handler (public pages)1096+│ │ ├── sitemap_handler.go # Sitemap handler
1063-│ │ ├── terms_handler.go # Terms of service handler1097+│ │ ├── index_handler.go # /api/me + auth-login meta handler
1098+│ │ ├── sync_handlers.go # Periodic sync + collection-dir backfill
1099+│ │ ├── sanitize.go # HTML sanitization for article content
1064 │ │ ├── pagination.go # Pagination helpers1100 │ │ ├── pagination.go # Pagination helpers
1065-│ │ ├── middleware.go # Auth, logging, CSRF middleware1101+│ │ ├── middleware.go # Auth, logging, CSRF, CORS middleware
1066 │ │ └── session.go # Session management1102 │ │ └── session.go # Session management
1067-│ ├── sanitize/1103+├── web/ # SvelteKit (SSR) frontend, adapter-node
1068-│ │ └── sanitize.go # HTML sanitization for article content1104+│ ├── src/
1069-│ └── tmpl/1105+│ │ ├── app.css # Tailwind v4 entry + design tokens (@theme, [data-theme], @utility)
1070-│ ├── base.html # Base template with htmx + Tailwind1106+│ │ ├── app.html # HTML shell
1071-│ ├── index.html # Landing page1107+│ │ ├── app.d.ts # Locals types (user, csrfToken)
1072-│ ├── login.html # Login page1108+│ │ ├── hooks.server.ts # /api/* proxy to Go + per-request user load
1073-│ ├── dashboard.html # Dashboard1109+│ │ ├── lib/
1074-│ ├── feeds.html # Feed management1110+│ │ │ ├── api.ts # Typed endpoint client (endpoints / endpointsFor(fetch))
1075-│ ├── articles.html # Article listing1111+│ │ │ ├── types.ts # API response types
1076-│ ├── article_detail.html # Article detail1112+│ │ │ ├── format.ts # Date/HTML/youtube formatting helpers
1077-│ ├── trending.html # Trending articles1113+│ │ │ └── components/ # Svelte components (ArticleCard, FeedItem, Icon, ...)
1078-│ ├── stats.html # Application metrics1114+│ │ └── routes/ # File-based routes (+page.server.ts load + +page.svelte)
1079-│ ├── library.html # Liked articles + annotations1115+│ │ ├── +layout.svelte # Chrome (nav, footer, dialogs, new-articles banner)
1080-│ ├── profile.html # User profile1116+│ │ ├── articles/[id]/ # Article detail
1081-│ ├── error.html # Error page1117+│ │ ├── dashboard/ # Dashboard
1082-│ ├── 404.html # Not found page1118+│ │ ├── feeds/ # Feed management
1083-│ ├── terms.html # Terms of service1119+│ │ ├── library/ # Liked + annotations
1084-│ └── partials/ # Reusable template fragments1120+│ │ ├── trending/ # Trending
1085-├── static/1121+│ │ ├── profile/[did]/ # Public profile
1086-│ ├── input.css # Tailwind input1122+│ │ ├── stats/ # Metrics
1087-│ └── output.css # Tailwind compiled output1123+│ │ ├── auth/login/ # Login
1088-├── docs/1124+│ │ ├── terms/ # Terms (static)
1089-│ ├── specs.md # Technical specification (this document)1125+│ │ └── sitemap.xml/ # Sitemap (+server.ts)
1090-│ └── design.md # Design system1126+│ ├── static/ # Favicons, manifest, banner
1091-└── tailwind.config.js1127+│ ├── svelte.config.js # adapter-node
1128+│ ├── vite.config.ts # dev server on :3000
1129+│ └── package.json # bun; svelte, @sveltejs/kit, @tailwindcss/vite
1130+└── docs/
1131+ ├── specs.md # Technical specification (this document)
1132+ └── design.md # Design system
1092 ```1133 ```
1093 1134
1094 ## 10. Auth Flow1135 ## 10. Auth Flow
modified internal/ml/langdetect.go +2 -2
@@ -1,8 +1,8 @@
11 package ml
22
33 type Language struct {
4- Code string
5- Name string
4+ Code string `json:"code"`
5+ Name string `json:"name"`
66 }
77
88 var knownLanguages = []Language{
@@ -1,8 +1,8 @@
1 package ml1 package ml
2 2
3 type Language struct {3 type Language struct {
4- Code string4+ Code string `json:"code"`
5- Name string5+ Name string `json:"name"`
6 }6 }
7 7
8 var knownLanguages = []Language{8 var knownLanguages = []Language{
modified internal/server/annotations_handler.go +21 -25
@@ -57,10 +57,6 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) {
5757 likedPage.HasNext = true
5858 likedPage.NextPage = likedPage.Page + 1
5959 }
60- navSuffix := buildNavSuffix("", true, "")
61- for _, a := range articles {
62- a.NavSuffix = navSuffix
63- }
6460 return nil
6561 })
6662
@@ -84,17 +80,20 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) {
8480 return nil
8581 })
8682
87- if err := g.Wait(); err != nil {
88- s.logger.Warn("library error", "error", err, "did", user.DID)
83+ _ = g.Wait()
84+
85+ items := make([]Article, len(articles))
86+ for i, a := range articles {
87+ a.NavSuffix = buildNavSuffix("", true, "")
88+ items[i] = toArticle(a)
8989 }
9090
91- s.render(w, r, "library.html", map[string]any{
92- "User": user,
93- "CurrentUserDID": user.DID,
94- "Articles": articles,
95- "Annotations": annotations,
96- "LikedPage": likedPage,
97- "AnnotPage": annotPage,
91+ writeJSON(w, http.StatusOK, libraryResponse{
92+ User: toUser(user),
93+ Articles: items,
94+ Annotations: toAnnotations(annotations),
95+ LikedPage: likedPage,
96+ AnnotPage: annotPage,
9897 })
9998 }
10099
@@ -134,7 +133,7 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request)
134133 uri, cid, err := client.CreateRecord(ctx, user.DID, atproto.CollectionAnnotation, record)
135134 if err != nil {
136135 s.logger.Error("failed to write annotation to PDS", "error", err)
137- http.Error(w, "failed to write annotation to PDS: "+err.Error(), http.StatusInternalServerError)
136+ writeAPIError(w, http.StatusInternalServerError, "failed to write annotation to PDS: "+err.Error())
138137 return
139138 }
140139 a.URI = uri
@@ -155,15 +154,12 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request)
155154 }
156155
157156 if err := s.dbs.Articles.CreateAnnotation(ctx, a); err != nil {
158- http.Error(w, err.Error(), http.StatusInternalServerError)
157+ writeAPIError(w, http.StatusInternalServerError, err.Error())
159158 return
160159 }
161160
162161 a.AuthorHandle = atproto.ResolveProfile(r.Context(), user.DID).Handle
163- s.render(w, r, "annotation-card.html", map[string]any{
164- "annotation": a,
165- "userDID": user.DID,
166- })
162+ writeJSON(w, http.StatusOK, annotationResponse{Annotation: toAnnotation(a)})
167163 }
168164
169165 func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) {
@@ -171,18 +167,18 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request)
171167 ctx := r.Context()
172168 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
173169 if err != nil {
174- http.Error(w, "invalid id", http.StatusBadRequest)
170+ writeAPIError(w, http.StatusBadRequest, "invalid id")
175171 return
176172 }
177173
178174 annotation, err := s.dbs.Articles.GetAnnotation(ctx, id)
179175 if err != nil {
180- http.Error(w, "annotation not found", http.StatusNotFound)
176+ writeAPIError(w, http.StatusNotFound, "annotation not found")
181177 return
182178 }
183179
184180 if annotation.AuthorDID != user.DID {
185- http.Error(w, "forbidden", http.StatusForbidden)
181+ writeAPIError(w, http.StatusForbidden, "forbidden")
186182 return
187183 }
188184
@@ -192,7 +188,7 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request)
192188 if ok {
193189 if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil {
194190 s.logger.Error("failed to delete annotation from PDS", "error", delErr)
195- http.Error(w, "failed to delete annotation from PDS: "+delErr.Error(), http.StatusInternalServerError)
191+ writeAPIError(w, http.StatusInternalServerError, "failed to delete annotation from PDS: "+delErr.Error())
196192 return
197193 }
198194 }
@@ -208,11 +204,11 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request)
208204 // Delete by content so any duplicate row created by the mirror (different URI,
209205 // identical content) is removed alongside the canonical annotation.
210206 if err := s.dbs.Articles.DeleteAnnotationsByContent(ctx, user.DID, annotation.ArticleURL, annotation.Quote.String, annotation.Note.String); err != nil {
211- http.Error(w, err.Error(), http.StatusInternalServerError)
207+ writeAPIError(w, http.StatusInternalServerError, err.Error())
212208 return
213209 }
214210
215- w.WriteHeader(http.StatusOK)
211+ w.WriteHeader(http.StatusNoContent)
216212 }
217213
218214 func resolveAnnotationHandles(ctx context.Context, annotations []*db.Annotation) {
@@ -57,10 +57,6 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) {
57 likedPage.HasNext = true57 likedPage.HasNext = true
58 likedPage.NextPage = likedPage.Page + 158 likedPage.NextPage = likedPage.Page + 1
59 }59 }
60- navSuffix := buildNavSuffix("", true, "")
61- for _, a := range articles {
62- a.NavSuffix = navSuffix
63- }
64 return nil60 return nil
65 })61 })
66 62
@@ -84,17 +80,20 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) {
84 return nil80 return nil
85 })81 })
86 82
87- if err := g.Wait(); err != nil {83+ _ = g.Wait()
88- s.logger.Warn("library error", "error", err, "did", user.DID)84+
85+ items := make([]Article, len(articles))
86+ for i, a := range articles {
87+ a.NavSuffix = buildNavSuffix("", true, "")
88+ items[i] = toArticle(a)
89 }89 }
90 90
91- s.render(w, r, "library.html", map[string]any{91+ writeJSON(w, http.StatusOK, libraryResponse{
92- "User": user,92+ User: toUser(user),
93- "CurrentUserDID": user.DID,93+ Articles: items,
94- "Articles": articles,94+ Annotations: toAnnotations(annotations),
95- "Annotations": annotations,95+ LikedPage: likedPage,
96- "LikedPage": likedPage,96+ AnnotPage: annotPage,
97- "AnnotPage": annotPage,
98 })97 })
99 }98 }
100 99
@@ -134,7 +133,7 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request)
134 uri, cid, err := client.CreateRecord(ctx, user.DID, atproto.CollectionAnnotation, record)133 uri, cid, err := client.CreateRecord(ctx, user.DID, atproto.CollectionAnnotation, record)
135 if err != nil {134 if err != nil {
136 s.logger.Error("failed to write annotation to PDS", "error", err)135 s.logger.Error("failed to write annotation to PDS", "error", err)
137- http.Error(w, "failed to write annotation to PDS: "+err.Error(), http.StatusInternalServerError)136+ writeAPIError(w, http.StatusInternalServerError, "failed to write annotation to PDS: "+err.Error())
138 return137 return
139 }138 }
140 a.URI = uri139 a.URI = uri
@@ -155,15 +154,12 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request)
155 }154 }
156 155
157 if err := s.dbs.Articles.CreateAnnotation(ctx, a); err != nil {156 if err := s.dbs.Articles.CreateAnnotation(ctx, a); err != nil {
158- http.Error(w, err.Error(), http.StatusInternalServerError)157+ writeAPIError(w, http.StatusInternalServerError, err.Error())
159 return158 return
160 }159 }
161 160
162 a.AuthorHandle = atproto.ResolveProfile(r.Context(), user.DID).Handle161 a.AuthorHandle = atproto.ResolveProfile(r.Context(), user.DID).Handle
163- s.render(w, r, "annotation-card.html", map[string]any{162+ writeJSON(w, http.StatusOK, annotationResponse{Annotation: toAnnotation(a)})
164- "annotation": a,
165- "userDID": user.DID,
166- })
167 }163 }
168 164
169 func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) {165 func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) {
@@ -171,18 +167,18 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request)
171 ctx := r.Context()167 ctx := r.Context()
172 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)168 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
173 if err != nil {169 if err != nil {
174- http.Error(w, "invalid id", http.StatusBadRequest)170+ writeAPIError(w, http.StatusBadRequest, "invalid id")
175 return171 return
176 }172 }
177 173
178 annotation, err := s.dbs.Articles.GetAnnotation(ctx, id)174 annotation, err := s.dbs.Articles.GetAnnotation(ctx, id)
179 if err != nil {175 if err != nil {
180- http.Error(w, "annotation not found", http.StatusNotFound)176+ writeAPIError(w, http.StatusNotFound, "annotation not found")
181 return177 return
182 }178 }
183 179
184 if annotation.AuthorDID != user.DID {180 if annotation.AuthorDID != user.DID {
185- http.Error(w, "forbidden", http.StatusForbidden)181+ writeAPIError(w, http.StatusForbidden, "forbidden")
186 return182 return
187 }183 }
188 184
@@ -192,7 +188,7 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request)
192 if ok {188 if ok {
193 if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil {189 if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil {
194 s.logger.Error("failed to delete annotation from PDS", "error", delErr)190 s.logger.Error("failed to delete annotation from PDS", "error", delErr)
195- http.Error(w, "failed to delete annotation from PDS: "+delErr.Error(), http.StatusInternalServerError)191+ writeAPIError(w, http.StatusInternalServerError, "failed to delete annotation from PDS: "+delErr.Error())
196 return192 return
197 }193 }
198 }194 }
@@ -208,11 +204,11 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request)
208 // Delete by content so any duplicate row created by the mirror (different URI,204 // Delete by content so any duplicate row created by the mirror (different URI,
209 // identical content) is removed alongside the canonical annotation.205 // identical content) is removed alongside the canonical annotation.
210 if err := s.dbs.Articles.DeleteAnnotationsByContent(ctx, user.DID, annotation.ArticleURL, annotation.Quote.String, annotation.Note.String); err != nil {206 if err := s.dbs.Articles.DeleteAnnotationsByContent(ctx, user.DID, annotation.ArticleURL, annotation.Quote.String, annotation.Note.String); err != nil {
211- http.Error(w, err.Error(), http.StatusInternalServerError)207+ writeAPIError(w, http.StatusInternalServerError, err.Error())
212 return208 return
213 }209 }
214 210
215- w.WriteHeader(http.StatusOK)211+ w.WriteHeader(http.StatusNoContent)
216 }212 }
217 213
218 func resolveAnnotationHandles(ctx context.Context, annotations []*db.Annotation) {214 func resolveAnnotationHandles(ctx context.Context, annotations []*db.Annotation) {
added internal/server/api.go +534 -0
new file mode 100644
@@ -0,0 +1,534 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "encoding/json"
6+ "net/http"
7+ "strings"
8+ "time"
9+
10+ "pkg.rbrt.fr/glean/internal/atproto"
11+ "pkg.rbrt.fr/glean/internal/cluster"
12+ "pkg.rbrt.fr/glean/internal/db"
13+ "pkg.rbrt.fr/glean/internal/ml"
14+)
15+
16+// JSON response helpers.
17+
18+func writeJSON(w http.ResponseWriter, status int, v any) {
19+ w.Header().Set("Content-Type", "application/json")
20+ w.WriteHeader(status)
21+ _ = json.NewEncoder(w).Encode(v)
22+}
23+
24+// nonNil returns s, or an empty slice if s is nil, so it serializes as []
25+// instead of null. Use only at the DB→response boundary for []string fields
26+// whose zero value is nil.
27+func nonNil[T any](s []T) []T {
28+ if s == nil {
29+ return make([]T, 0)
30+ }
31+ return s
32+}
33+
34+type errorResponse struct {
35+ Error string `json:"error"`
36+}
37+
38+func writeAPIError(w http.ResponseWriter, status int, msg string) {
39+ writeJSON(w, status, errorResponse{Error: msg})
40+}
41+
42+// Wire types. Nullable db fields are converted to pointers so they serialize as
43+// null instead of the raw sql.Null* struct.
44+
45+type User struct {
46+ DID string `json:"did"`
47+ Handle string `json:"handle"`
48+ DisplayName string `json:"display_name"`
49+ AvatarURL string `json:"avatar_url"`
50+}
51+
52+func toUser(u *db.User) User {
53+ if u == nil {
54+ return User{}
55+ }
56+ return User{
57+ DID: u.DID,
58+ Handle: u.Handle,
59+ DisplayName: u.DisplayName,
60+ AvatarURL: u.AvatarURL,
61+ }
62+}
63+
64+type Article struct {
65+ ID int64 `json:"id"`
66+ FeedURL string `json:"feed_url"`
67+ FeedTitle string `json:"feed_title"`
68+ FeedFaviconURL string `json:"feed_favicon_url"`
69+ Title string `json:"title"`
70+ URL string `json:"url"`
71+ Author string `json:"author"`
72+ Summary string `json:"summary"`
73+ Content string `json:"content"`
74+ FullContent string `json:"full_content"`
75+ Published *time.Time `json:"published"`
76+ Updated *time.Time `json:"updated"`
77+ IsRead bool `json:"is_read"`
78+ LikeCount int `json:"like_count"`
79+ HasLiked bool `json:"has_liked"`
80+}
81+
82+func toArticle(a *db.Article) Article {
83+ if a == nil {
84+ return Article{}
85+ }
86+ return Article{
87+ ID: a.ID,
88+ FeedURL: a.FeedURL,
89+ FeedTitle: a.FeedTitle,
90+ FeedFaviconURL: nullStr(a.FeedFaviconURL),
91+ Title: a.Title,
92+ URL: nullStr(a.URL),
93+ Author: nullStr(a.Author),
94+ Summary: nullStr(a.Summary),
95+ Content: nullStr(a.Content),
96+ FullContent: nullStr(a.FullContent),
97+ Published: nullTime(a.Published),
98+ Updated: nullTime(a.Updated),
99+ IsRead: a.IsRead.Valid && a.IsRead.Bool,
100+ LikeCount: a.LikeCount,
101+ HasLiked: a.HasLiked,
102+ }
103+}
104+
105+type Feed struct {
106+ FeedURL string `json:"feed_url"`
107+ Title string `json:"title"`
108+ SiteURL string `json:"site_url"`
109+ Description string `json:"description"`
110+ FeedType string `json:"feed_type"`
111+ FaviconURL string `json:"favicon_url"`
112+ SubscriberCount int `json:"subscriber_count"`
113+ ErrorCount int `json:"error_count"`
114+ LastError string `json:"last_error"`
115+ LastFetchedAt *time.Time `json:"last_fetched_at"`
116+}
117+
118+func toFeed(f *db.Feed) Feed {
119+ if f == nil {
120+ return Feed{}
121+ }
122+ return Feed{
123+ FeedURL: f.FeedURL,
124+ Title: nullStr(f.Title),
125+ SiteURL: nullStr(f.SiteURL),
126+ Description: nullStr(f.Description),
127+ FeedType: nullStr(f.FeedType),
128+ FaviconURL: nullStr(f.FaviconURL),
129+ SubscriberCount: f.SubscriberCount,
130+ ErrorCount: f.ErrorCount,
131+ LastError: nullStr(f.LastError),
132+ LastFetchedAt: nullTime(f.LastFetchedAt),
133+ }
134+}
135+
136+type Subscription struct {
137+ ID int64 `json:"id"`
138+ FeedURL string `json:"feed_url"`
139+ FeedTitle string `json:"feed_title"`
140+ Category string `json:"category"`
141+ AddedAt *time.Time `json:"added_at"`
142+ UnreadCount int `json:"unread_count"`
143+ FaviconURL string `json:"favicon_url"`
144+}
145+
146+func toSubscription(s *db.Subscription) Subscription {
147+ if s == nil {
148+ return Subscription{}
149+ }
150+ return Subscription{
151+ ID: s.ID,
152+ FeedURL: s.FeedURL,
153+ FeedTitle: s.FeedTitle,
154+ Category: nullStr(s.Category),
155+ AddedAt: nullTime(s.AddedAt),
156+ UnreadCount: s.UnreadCount,
157+ FaviconURL: nullStr(s.FaviconURL),
158+ }
159+}
160+
161+type Annotation struct {
162+ ID int64 `json:"id"`
163+ AuthorDID string `json:"author_did"`
164+ AuthorHandle string `json:"author_handle"`
165+ FeedURL string `json:"feed_url"`
166+ ArticleURL string `json:"article_url"`
167+ ArticleID *int64 `json:"article_id"`
168+ Quote string `json:"quote"`
169+ Note string `json:"note"`
170+ Tags []string `json:"tags"`
171+ Rating *int64 `json:"rating"`
172+ CreatedAt *time.Time `json:"created_at"`
173+}
174+
175+func toAnnotation(a *db.Annotation) Annotation {
176+ if a == nil {
177+ return Annotation{Tags: make([]string, 0)}
178+ }
179+ tags := make([]string, 0)
180+ if a.Tags.Valid && a.Tags.String != "" {
181+ for t := range strings.SplitSeq(a.Tags.String, ",") {
182+ if t != "" {
183+ tags = append(tags, t)
184+ }
185+ }
186+ }
187+ return Annotation{
188+ ID: a.ID,
189+ AuthorDID: a.AuthorDID,
190+ AuthorHandle: a.AuthorHandle,
191+ FeedURL: a.FeedURL,
192+ ArticleURL: a.ArticleURL,
193+ ArticleID: nullInt64(a.ArticleID),
194+ Quote: nullStr(a.Quote),
195+ Note: nullStr(a.Note),
196+ Tags: tags,
197+ Rating: nullInt64(a.Rating),
198+ CreatedAt: nullTime(a.CreatedAt),
199+ }
200+}
201+
202+type TrendingItem struct {
203+ ArticleID int64 `json:"article_id"`
204+ Title string `json:"title"`
205+ URL string `json:"url"`
206+ Author string `json:"author"`
207+ Summary string `json:"summary"`
208+ FeedURL string `json:"feed_url"`
209+ FeedTitle string `json:"feed_title"`
210+ FaviconURL string `json:"favicon_url"`
211+ LikeCount int `json:"like_count"`
212+ AnnotationCount int `json:"annotation_count"`
213+ HasLiked bool `json:"has_liked"`
214+}
215+
216+func toTrendingItem(t *db.TrendingItem) TrendingItem {
217+ if t == nil {
218+ return TrendingItem{}
219+ }
220+ return TrendingItem{
221+ ArticleID: t.ArticleID,
222+ Title: t.Title,
223+ URL: t.URL,
224+ Author: t.Author,
225+ Summary: t.Summary,
226+ FeedURL: t.FeedURL,
227+ FeedTitle: t.FeedTitle,
228+ FaviconURL: t.FaviconURL,
229+ LikeCount: t.LikeCount,
230+ AnnotationCount: t.AnnotationCount,
231+ HasLiked: t.HasLiked,
232+ }
233+}
234+
235+type FeedRecommendation struct {
236+ FeedURL string `json:"feed_url"`
237+ Title string `json:"title"`
238+ SiteURL string `json:"site_url"`
239+ Description string `json:"description"`
240+ SubscriberCount int `json:"subscriber_count"`
241+ FaviconURL string `json:"favicon_url"`
242+ Score float64 `json:"score"`
243+}
244+
245+func toFeedRecommendation(r *cluster.FeedRecommendation) FeedRecommendation {
246+ if r == nil {
247+ return FeedRecommendation{}
248+ }
249+ return FeedRecommendation{
250+ FeedURL: r.FeedURL,
251+ Title: r.Title,
252+ SiteURL: r.SiteURL,
253+ Description: r.Description,
254+ SubscriberCount: r.SubscriberCount,
255+ FaviconURL: r.FaviconURL,
256+ Score: r.Score,
257+ }
258+}
259+
260+type PersonRecommendation struct {
261+ DID string `json:"did"`
262+ Handle string `json:"handle"`
263+ DisplayName string `json:"display_name"`
264+ AvatarURL string `json:"avatar_url"`
265+ CommonFeeds int `json:"common_feeds"`
266+ CommonLikes int `json:"common_likes"`
267+ CommonTags int `json:"common_tags"`
268+ IsFollowed bool `json:"is_followed"`
269+ Score float64 `json:"score"`
270+}
271+
272+func toPersonRecommendation(r *cluster.PersonRecommendation) PersonRecommendation {
273+ if r == nil {
274+ return PersonRecommendation{}
275+ }
276+ return PersonRecommendation{
277+ DID: r.DID,
278+ Handle: r.Handle,
279+ DisplayName: r.DisplayName,
280+ AvatarURL: r.AvatarURL,
281+ CommonFeeds: r.CommonFeeds,
282+ CommonLikes: r.CommonLikes,
283+ CommonTags: r.CommonTags,
284+ IsFollowed: r.IsFollowed,
285+ Score: r.Jaccard,
286+ }
287+}
288+
289+type ArticleRecommendation struct {
290+ ArticleID int64 `json:"article_id"`
291+ Title string `json:"title"`
292+ URL string `json:"url"`
293+ FeedURL string `json:"feed_url"`
294+ FeedTitle string `json:"feed_title"`
295+ FaviconURL string `json:"favicon_url"`
296+ Author string `json:"author"`
297+ Summary string `json:"summary"`
298+ Published *time.Time `json:"published"`
299+ Score float64 `json:"score"`
300+}
301+
302+func toArticleRecommendation(r *cluster.ArticleRecommendation) ArticleRecommendation {
303+ if r == nil {
304+ return ArticleRecommendation{}
305+ }
306+ return ArticleRecommendation{
307+ ArticleID: r.ArticleID,
308+ Title: r.Title,
309+ URL: r.URL,
310+ FeedURL: r.FeedURL,
311+ FeedTitle: r.FeedTitle,
312+ FaviconURL: r.FaviconURL,
313+ Author: r.Author,
314+ Summary: r.Summary,
315+ Published: nullTime(r.Published),
316+ Score: r.Score,
317+ }
318+}
319+
320+// null helpers.
321+
322+func nullStr(s sql.NullString) string {
323+ if s.Valid {
324+ return s.String
325+ }
326+ return ""
327+}
328+
329+func nullTime(t sql.NullTime) *time.Time {
330+ if t.Valid {
331+ return &t.Time
332+ }
333+ return nil
334+}
335+
336+func nullInt64(n sql.NullInt64) *int64 {
337+ if n.Valid {
338+ v := n.Int64
339+ return &v
340+ }
341+ return nil
342+}
343+
344+// --- /api/me ---
345+
346+type meResponse struct {
347+ User *User `json:"user"`
348+ CSRFToken string `json:"csrf_token"`
349+ HasLLM bool `json:"has_llm"`
350+ ClientID string `json:"client_id"`
351+}
352+
353+// --- dashboard ---
354+
355+type dashboardResponse struct {
356+ User User `json:"user"`
357+ SubscriptionCount int `json:"subscription_count"`
358+ UnreadCount int `json:"unread_count"`
359+ Articles []Article `json:"articles"`
360+ PersonalTrending []TrendingItem `json:"personal_trending"`
361+ GlobalTrending []TrendingItem `json:"global_trending"`
362+ DigestEnabled bool `json:"digest_enabled"`
363+ HasLLM bool `json:"has_llm"`
364+ Now int64 `json:"now"`
365+}
366+
367+// --- articles ---
368+
369+type articlesResponse struct {
370+ User User `json:"user"`
371+ Articles []Article `json:"articles"`
372+ FeedURL string `json:"feed_url"`
373+ Status string `json:"status"`
374+ SearchQuery string `json:"search_query"`
375+ SortOldest bool `json:"sort_oldest"`
376+ Category string `json:"category"`
377+ Categories []string `json:"categories"`
378+ ExpandedView bool `json:"expanded_view"`
379+ Pagination Pagination `json:"pagination"`
380+ Now int64 `json:"now"`
381+ Feed *Feed `json:"feed,omitempty"`
382+ IsSubscribed bool `json:"is_subscribed,omitempty"`
383+}
384+
385+type articleDetailResponse struct {
386+ User User `json:"user"`
387+ CurrentUserDID string `json:"current_user_did"`
388+ Article Article `json:"article"`
389+ Feed Feed `json:"feed"`
390+ Annotations []Annotation `json:"annotations"`
391+ NextID *int64 `json:"next_id"`
392+ NextSuffix string `json:"next_suffix"`
393+}
394+
395+type newArticleCountResponse struct {
396+ Count int `json:"count"`
397+}
398+
399+type articleStateResponse struct {
400+ ID int64 `json:"id"`
401+ IsRead bool `json:"is_read"`
402+}
403+
404+type likeResponse struct {
405+ ID int64 `json:"id"`
406+ Liked bool `json:"liked"`
407+ LikeCount int `json:"like_count"`
408+}
409+
410+type fetchContentResponse struct {
411+ ID int64 `json:"id"`
412+ FullContent string `json:"full_content"`
413+}
414+
415+// --- feeds ---
416+
417+type feedsResponse struct {
418+ User User `json:"user"`
419+ Subscriptions []Subscription `json:"subscriptions"`
420+ SubscriptionCount int `json:"subscription_count"`
421+ Categories []string `json:"categories"`
422+ Category string `json:"category"`
423+ DeadFeeds []Feed `json:"dead_feeds"`
424+ Pagination Pagination `json:"pagination"`
425+}
426+
427+type subscriptionResponse struct {
428+ Subscription Subscription `json:"subscription"`
429+}
430+
431+type feedListResponse struct {
432+ Subscriptions []Subscription `json:"subscriptions"`
433+}
434+
435+type deadFeedsResponse struct {
436+ DeadFeeds []Feed `json:"dead_feeds"`
437+}
438+
439+type opmlUploadResponse struct {
440+ Added int `json:"added"`
441+}
442+
443+// --- trending ---
444+
445+type trendingResponse struct {
446+ User *User `json:"user"`
447+ Trending []TrendingItem `json:"trending"`
448+ Scope string `json:"scope"`
449+ Pagination Pagination `json:"pagination"`
450+}
451+
452+// --- library ---
453+
454+type libraryResponse struct {
455+ User User `json:"user"`
456+ Articles []Article `json:"articles"`
457+ Annotations []Annotation `json:"annotations"`
458+ LikedPage Pagination `json:"liked_page"`
459+ AnnotPage Pagination `json:"annot_page"`
460+}
461+
462+type annotationResponse struct {
463+ Annotation Annotation `json:"annotation"`
464+}
465+
466+// --- profile ---
467+
468+type profileResponse struct {
469+ User User `json:"user"`
470+ ProfileUser User `json:"profile_user"`
471+ Subscriptions []Subscription `json:"subscriptions"`
472+ Annotations []Annotation `json:"annotations"`
473+ SubscriptionCount int `json:"subscription_count"`
474+ AnnotationCount int `json:"annotation_count"`
475+ UserLanguages []string `json:"user_languages"`
476+ AvailableLanguages []ml.Language `json:"available_languages"`
477+ ExpandedView bool `json:"expanded_view"`
478+ DigestEnabled bool `json:"digest_enabled"`
479+}
480+
481+// --- recommendations ---
482+
483+type articleRecsResponse struct {
484+ Articles []Article `json:"articles"`
485+}
486+
487+type feedRecsResponse struct {
488+ Feeds []FeedRecommendation `json:"feeds"`
489+ SubscriptionCount int `json:"subscription_count"`
490+}
491+
492+type peopleRecsResponse struct {
493+ Followed []PersonRecommendation `json:"followed"`
494+ Discover []PersonRecommendation `json:"discover"`
495+}
496+
497+// --- settings ---
498+
499+type languagesResponse struct {
500+ Languages []string `json:"languages"`
501+}
502+
503+type expandedViewResponse struct {
504+ ExpandedView bool `json:"expanded_view"`
505+}
506+
507+type digestEnabledResponse struct {
508+ DigestEnabled bool `json:"digest_enabled"`
509+}
510+
511+// --- digest ---
512+
513+// (digestCtx in digest_handler.go is already a typed response struct.)
514+
515+// --- stats ---
516+
517+type statsResponse struct {
518+ User *User `json:"user"`
519+ Metrics map[string][]metricFamily `json:"metrics"`
520+}
521+
522+// --- auth ---
523+
524+type oauthEnabledResponse struct {
525+ OAuthEnabled bool `json:"oauth_enabled"`
526+}
527+
528+type redirectResponse struct {
529+ Redirect string `json:"redirect"`
530+}
531+
532+type actorsResponse struct {
533+ Actors []atproto.Actor `json:"actors"`
534+}
new file mode 100644
@@ -0,0 +1,534 @@
1+package server
2+
3+import (
4+ "database/sql"
5+ "encoding/json"
6+ "net/http"
7+ "strings"
8+ "time"
9+
10+ "pkg.rbrt.fr/glean/internal/atproto"
11+ "pkg.rbrt.fr/glean/internal/cluster"
12+ "pkg.rbrt.fr/glean/internal/db"
13+ "pkg.rbrt.fr/glean/internal/ml"
14+)
15+
16+// JSON response helpers.
17+
18+func writeJSON(w http.ResponseWriter, status int, v any) {
19+ w.Header().Set("Content-Type", "application/json")
20+ w.WriteHeader(status)
21+ _ = json.NewEncoder(w).Encode(v)
22+}
23+
24+// nonNil returns s, or an empty slice if s is nil, so it serializes as []
25+// instead of null. Use only at the DB→response boundary for []string fields
26+// whose zero value is nil.
27+func nonNil[T any](s []T) []T {
28+ if s == nil {
29+ return make([]T, 0)
30+ }
31+ return s
32+}
33+
34+type errorResponse struct {
35+ Error string `json:"error"`
36+}
37+
38+func writeAPIError(w http.ResponseWriter, status int, msg string) {
39+ writeJSON(w, status, errorResponse{Error: msg})
40+}
41+
42+// Wire types. Nullable db fields are converted to pointers so they serialize as
43+// null instead of the raw sql.Null* struct.
44+
45+type User struct {
46+ DID string `json:"did"`
47+ Handle string `json:"handle"`
48+ DisplayName string `json:"display_name"`
49+ AvatarURL string `json:"avatar_url"`
50+}
51+
52+func toUser(u *db.User) User {
53+ if u == nil {
54+ return User{}
55+ }
56+ return User{
57+ DID: u.DID,
58+ Handle: u.Handle,
59+ DisplayName: u.DisplayName,
60+ AvatarURL: u.AvatarURL,
61+ }
62+}
63+
64+type Article struct {
65+ ID int64 `json:"id"`
66+ FeedURL string `json:"feed_url"`
67+ FeedTitle string `json:"feed_title"`
68+ FeedFaviconURL string `json:"feed_favicon_url"`
69+ Title string `json:"title"`
70+ URL string `json:"url"`
71+ Author string `json:"author"`
72+ Summary string `json:"summary"`
73+ Content string `json:"content"`
74+ FullContent string `json:"full_content"`
75+ Published *time.Time `json:"published"`
76+ Updated *time.Time `json:"updated"`
77+ IsRead bool `json:"is_read"`
78+ LikeCount int `json:"like_count"`
79+ HasLiked bool `json:"has_liked"`
80+}
81+
82+func toArticle(a *db.Article) Article {
83+ if a == nil {
84+ return Article{}
85+ }
86+ return Article{
87+ ID: a.ID,
88+ FeedURL: a.FeedURL,
89+ FeedTitle: a.FeedTitle,
90+ FeedFaviconURL: nullStr(a.FeedFaviconURL),
91+ Title: a.Title,
92+ URL: nullStr(a.URL),
93+ Author: nullStr(a.Author),
94+ Summary: nullStr(a.Summary),
95+ Content: nullStr(a.Content),
96+ FullContent: nullStr(a.FullContent),
97+ Published: nullTime(a.Published),
98+ Updated: nullTime(a.Updated),
99+ IsRead: a.IsRead.Valid && a.IsRead.Bool,
100+ LikeCount: a.LikeCount,
101+ HasLiked: a.HasLiked,
102+ }
103+}
104+
105+type Feed struct {
106+ FeedURL string `json:"feed_url"`
107+ Title string `json:"title"`
108+ SiteURL string `json:"site_url"`
109+ Description string `json:"description"`
110+ FeedType string `json:"feed_type"`
111+ FaviconURL string `json:"favicon_url"`
112+ SubscriberCount int `json:"subscriber_count"`
113+ ErrorCount int `json:"error_count"`
114+ LastError string `json:"last_error"`
115+ LastFetchedAt *time.Time `json:"last_fetched_at"`
116+}
117+
118+func toFeed(f *db.Feed) Feed {
119+ if f == nil {
120+ return Feed{}
121+ }
122+ return Feed{
123+ FeedURL: f.FeedURL,
124+ Title: nullStr(f.Title),
125+ SiteURL: nullStr(f.SiteURL),
126+ Description: nullStr(f.Description),
127+ FeedType: nullStr(f.FeedType),
128+ FaviconURL: nullStr(f.FaviconURL),
129+ SubscriberCount: f.SubscriberCount,
130+ ErrorCount: f.ErrorCount,
131+ LastError: nullStr(f.LastError),
132+ LastFetchedAt: nullTime(f.LastFetchedAt),
133+ }
134+}
135+
136+type Subscription struct {
137+ ID int64 `json:"id"`
138+ FeedURL string `json:"feed_url"`
139+ FeedTitle string `json:"feed_title"`
140+ Category string `json:"category"`
141+ AddedAt *time.Time `json:"added_at"`
142+ UnreadCount int `json:"unread_count"`
143+ FaviconURL string `json:"favicon_url"`
144+}
145+
146+func toSubscription(s *db.Subscription) Subscription {
147+ if s == nil {
148+ return Subscription{}
149+ }
150+ return Subscription{
151+ ID: s.ID,
152+ FeedURL: s.FeedURL,
153+ FeedTitle: s.FeedTitle,
154+ Category: nullStr(s.Category),
155+ AddedAt: nullTime(s.AddedAt),
156+ UnreadCount: s.UnreadCount,
157+ FaviconURL: nullStr(s.FaviconURL),
158+ }
159+}
160+
161+type Annotation struct {
162+ ID int64 `json:"id"`
163+ AuthorDID string `json:"author_did"`
164+ AuthorHandle string `json:"author_handle"`
165+ FeedURL string `json:"feed_url"`
166+ ArticleURL string `json:"article_url"`
167+ ArticleID *int64 `json:"article_id"`
168+ Quote string `json:"quote"`
169+ Note string `json:"note"`
170+ Tags []string `json:"tags"`
171+ Rating *int64 `json:"rating"`
172+ CreatedAt *time.Time `json:"created_at"`
173+}
174+
175+func toAnnotation(a *db.Annotation) Annotation {
176+ if a == nil {
177+ return Annotation{Tags: make([]string, 0)}
178+ }
179+ tags := make([]string, 0)
180+ if a.Tags.Valid && a.Tags.String != "" {
181+ for t := range strings.SplitSeq(a.Tags.String, ",") {
182+ if t != "" {
183+ tags = append(tags, t)
184+ }
185+ }
186+ }
187+ return Annotation{
188+ ID: a.ID,
189+ AuthorDID: a.AuthorDID,
190+ AuthorHandle: a.AuthorHandle,
191+ FeedURL: a.FeedURL,
192+ ArticleURL: a.ArticleURL,
193+ ArticleID: nullInt64(a.ArticleID),
194+ Quote: nullStr(a.Quote),
195+ Note: nullStr(a.Note),
196+ Tags: tags,
197+ Rating: nullInt64(a.Rating),
198+ CreatedAt: nullTime(a.CreatedAt),
199+ }
200+}
201+
202+type TrendingItem struct {
203+ ArticleID int64 `json:"article_id"`
204+ Title string `json:"title"`
205+ URL string `json:"url"`
206+ Author string `json:"author"`
207+ Summary string `json:"summary"`
208+ FeedURL string `json:"feed_url"`
209+ FeedTitle string `json:"feed_title"`
210+ FaviconURL string `json:"favicon_url"`
211+ LikeCount int `json:"like_count"`
212+ AnnotationCount int `json:"annotation_count"`
213+ HasLiked bool `json:"has_liked"`
214+}
215+
216+func toTrendingItem(t *db.TrendingItem) TrendingItem {
217+ if t == nil {
218+ return TrendingItem{}
219+ }
220+ return TrendingItem{
221+ ArticleID: t.ArticleID,
222+ Title: t.Title,
223+ URL: t.URL,
224+ Author: t.Author,
225+ Summary: t.Summary,
226+ FeedURL: t.FeedURL,
227+ FeedTitle: t.FeedTitle,
228+ FaviconURL: t.FaviconURL,
229+ LikeCount: t.LikeCount,
230+ AnnotationCount: t.AnnotationCount,
231+ HasLiked: t.HasLiked,
232+ }
233+}
234+
235+type FeedRecommendation struct {
236+ FeedURL string `json:"feed_url"`
237+ Title string `json:"title"`
238+ SiteURL string `json:"site_url"`
239+ Description string `json:"description"`
240+ SubscriberCount int `json:"subscriber_count"`
241+ FaviconURL string `json:"favicon_url"`
242+ Score float64 `json:"score"`
243+}
244+
245+func toFeedRecommendation(r *cluster.FeedRecommendation) FeedRecommendation {
246+ if r == nil {
247+ return FeedRecommendation{}
248+ }
249+ return FeedRecommendation{
250+ FeedURL: r.FeedURL,
251+ Title: r.Title,
252+ SiteURL: r.SiteURL,
253+ Description: r.Description,
254+ SubscriberCount: r.SubscriberCount,
255+ FaviconURL: r.FaviconURL,
256+ Score: r.Score,
257+ }
258+}
259+
260+type PersonRecommendation struct {
261+ DID string `json:"did"`
262+ Handle string `json:"handle"`
263+ DisplayName string `json:"display_name"`
264+ AvatarURL string `json:"avatar_url"`
265+ CommonFeeds int `json:"common_feeds"`
266+ CommonLikes int `json:"common_likes"`
267+ CommonTags int `json:"common_tags"`
268+ IsFollowed bool `json:"is_followed"`
269+ Score float64 `json:"score"`
270+}
271+
272+func toPersonRecommendation(r *cluster.PersonRecommendation) PersonRecommendation {
273+ if r == nil {
274+ return PersonRecommendation{}
275+ }
276+ return PersonRecommendation{
277+ DID: r.DID,
278+ Handle: r.Handle,
279+ DisplayName: r.DisplayName,
280+ AvatarURL: r.AvatarURL,
281+ CommonFeeds: r.CommonFeeds,
282+ CommonLikes: r.CommonLikes,
283+ CommonTags: r.CommonTags,
284+ IsFollowed: r.IsFollowed,
285+ Score: r.Jaccard,
286+ }
287+}
288+
289+type ArticleRecommendation struct {
290+ ArticleID int64 `json:"article_id"`
291+ Title string `json:"title"`
292+ URL string `json:"url"`
293+ FeedURL string `json:"feed_url"`
294+ FeedTitle string `json:"feed_title"`
295+ FaviconURL string `json:"favicon_url"`
296+ Author string `json:"author"`
297+ Summary string `json:"summary"`
298+ Published *time.Time `json:"published"`
299+ Score float64 `json:"score"`
300+}
301+
302+func toArticleRecommendation(r *cluster.ArticleRecommendation) ArticleRecommendation {
303+ if r == nil {
304+ return ArticleRecommendation{}
305+ }
306+ return ArticleRecommendation{
307+ ArticleID: r.ArticleID,
308+ Title: r.Title,
309+ URL: r.URL,
310+ FeedURL: r.FeedURL,
311+ FeedTitle: r.FeedTitle,
312+ FaviconURL: r.FaviconURL,
313+ Author: r.Author,
314+ Summary: r.Summary,
315+ Published: nullTime(r.Published),
316+ Score: r.Score,
317+ }
318+}
319+
320+// null helpers.
321+
322+func nullStr(s sql.NullString) string {
323+ if s.Valid {
324+ return s.String
325+ }
326+ return ""
327+}
328+
329+func nullTime(t sql.NullTime) *time.Time {
330+ if t.Valid {
331+ return &t.Time
332+ }
333+ return nil
334+}
335+
336+func nullInt64(n sql.NullInt64) *int64 {
337+ if n.Valid {
338+ v := n.Int64
339+ return &v
340+ }
341+ return nil
342+}
343+
344+// --- /api/me ---
345+
346+type meResponse struct {
347+ User *User `json:"user"`
348+ CSRFToken string `json:"csrf_token"`
349+ HasLLM bool `json:"has_llm"`
350+ ClientID string `json:"client_id"`
351+}
352+
353+// --- dashboard ---
354+
355+type dashboardResponse struct {
356+ User User `json:"user"`
357+ SubscriptionCount int `json:"subscription_count"`
358+ UnreadCount int `json:"unread_count"`
359+ Articles []Article `json:"articles"`
360+ PersonalTrending []TrendingItem `json:"personal_trending"`
361+ GlobalTrending []TrendingItem `json:"global_trending"`
362+ DigestEnabled bool `json:"digest_enabled"`
363+ HasLLM bool `json:"has_llm"`
364+ Now int64 `json:"now"`
365+}
366+
367+// --- articles ---
368+
369+type articlesResponse struct {
370+ User User `json:"user"`
371+ Articles []Article `json:"articles"`
372+ FeedURL string `json:"feed_url"`
373+ Status string `json:"status"`
374+ SearchQuery string `json:"search_query"`
375+ SortOldest bool `json:"sort_oldest"`
376+ Category string `json:"category"`
377+ Categories []string `json:"categories"`
378+ ExpandedView bool `json:"expanded_view"`
379+ Pagination Pagination `json:"pagination"`
380+ Now int64 `json:"now"`
381+ Feed *Feed `json:"feed,omitempty"`
382+ IsSubscribed bool `json:"is_subscribed,omitempty"`
383+}
384+
385+type articleDetailResponse struct {
386+ User User `json:"user"`
387+ CurrentUserDID string `json:"current_user_did"`
388+ Article Article `json:"article"`
389+ Feed Feed `json:"feed"`
390+ Annotations []Annotation `json:"annotations"`
391+ NextID *int64 `json:"next_id"`
392+ NextSuffix string `json:"next_suffix"`
393+}
394+
395+type newArticleCountResponse struct {
396+ Count int `json:"count"`
397+}
398+
399+type articleStateResponse struct {
400+ ID int64 `json:"id"`
401+ IsRead bool `json:"is_read"`
402+}
403+
404+type likeResponse struct {
405+ ID int64 `json:"id"`
406+ Liked bool `json:"liked"`
407+ LikeCount int `json:"like_count"`
408+}
409+
410+type fetchContentResponse struct {
411+ ID int64 `json:"id"`
412+ FullContent string `json:"full_content"`
413+}
414+
415+// --- feeds ---
416+
417+type feedsResponse struct {
418+ User User `json:"user"`
419+ Subscriptions []Subscription `json:"subscriptions"`
420+ SubscriptionCount int `json:"subscription_count"`
421+ Categories []string `json:"categories"`
422+ Category string `json:"category"`
423+ DeadFeeds []Feed `json:"dead_feeds"`
424+ Pagination Pagination `json:"pagination"`
425+}
426+
427+type subscriptionResponse struct {
428+ Subscription Subscription `json:"subscription"`
429+}
430+
431+type feedListResponse struct {
432+ Subscriptions []Subscription `json:"subscriptions"`
433+}
434+
435+type deadFeedsResponse struct {
436+ DeadFeeds []Feed `json:"dead_feeds"`
437+}
438+
439+type opmlUploadResponse struct {
440+ Added int `json:"added"`
441+}
442+
443+// --- trending ---
444+
445+type trendingResponse struct {
446+ User *User `json:"user"`
447+ Trending []TrendingItem `json:"trending"`
448+ Scope string `json:"scope"`
449+ Pagination Pagination `json:"pagination"`
450+}
451+
452+// --- library ---
453+
454+type libraryResponse struct {
455+ User User `json:"user"`
456+ Articles []Article `json:"articles"`
457+ Annotations []Annotation `json:"annotations"`
458+ LikedPage Pagination `json:"liked_page"`
459+ AnnotPage Pagination `json:"annot_page"`
460+}
461+
462+type annotationResponse struct {
463+ Annotation Annotation `json:"annotation"`
464+}
465+
466+// --- profile ---
467+
468+type profileResponse struct {
469+ User User `json:"user"`
470+ ProfileUser User `json:"profile_user"`
471+ Subscriptions []Subscription `json:"subscriptions"`
472+ Annotations []Annotation `json:"annotations"`
473+ SubscriptionCount int `json:"subscription_count"`
474+ AnnotationCount int `json:"annotation_count"`
475+ UserLanguages []string `json:"user_languages"`
476+ AvailableLanguages []ml.Language `json:"available_languages"`
477+ ExpandedView bool `json:"expanded_view"`
478+ DigestEnabled bool `json:"digest_enabled"`
479+}
480+
481+// --- recommendations ---
482+
483+type articleRecsResponse struct {
484+ Articles []Article `json:"articles"`
485+}
486+
487+type feedRecsResponse struct {
488+ Feeds []FeedRecommendation `json:"feeds"`
489+ SubscriptionCount int `json:"subscription_count"`
490+}
491+
492+type peopleRecsResponse struct {
493+ Followed []PersonRecommendation `json:"followed"`
494+ Discover []PersonRecommendation `json:"discover"`
495+}
496+
497+// --- settings ---
498+
499+type languagesResponse struct {
500+ Languages []string `json:"languages"`
501+}
502+
503+type expandedViewResponse struct {
504+ ExpandedView bool `json:"expanded_view"`
505+}
506+
507+type digestEnabledResponse struct {
508+ DigestEnabled bool `json:"digest_enabled"`
509+}
510+
511+// --- digest ---
512+
513+// (digestCtx in digest_handler.go is already a typed response struct.)
514+
515+// --- stats ---
516+
517+type statsResponse struct {
518+ User *User `json:"user"`
519+ Metrics map[string][]metricFamily `json:"metrics"`
520+}
521+
522+// --- auth ---
523+
524+type oauthEnabledResponse struct {
525+ OAuthEnabled bool `json:"oauth_enabled"`
526+}
527+
528+type redirectResponse struct {
529+ Redirect string `json:"redirect"`
530+}
531+
532+type actorsResponse struct {
533+ Actors []atproto.Actor `json:"actors"`
534+}
added internal/server/api_test.go +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+package server
2+
3+import (
4+ "encoding/json"
5+ "net/http/httptest"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestNonNil(t *testing.T) {
11+ var nilStrs []string
12+ if got := nonNil(nilStrs); got == nil || len(got) != 0 {
13+ t.Fatalf("nonNil(nil) = %v, want non-nil empty slice", got)
14+ }
15+ in := []string{"a"}
16+ if got := nonNil(in); &got[0] != &in[0] {
17+ t.Fatalf("nonNil returned a copy of a non-nil slice")
18+ }
19+}
20+
21+// TestPtrTo confirms optional response fields serialize as null when unset
22+// and as the value when set via new().
23+func TestPtrTo(t *testing.T) {
24+ type holder struct {
25+ Feed *Feed `json:"feed,omitempty"`
26+ }
27+ rec := httptest.NewRecorder()
28+ writeJSON(rec, 200, holder{})
29+ if !strings.Contains(rec.Body.String(), `{}`) {
30+ t.Fatalf("empty optional should omit: %s", rec.Body.String())
31+ }
32+
33+ rec2 := httptest.NewRecorder()
34+ writeJSON(rec2, 200, holder{Feed: new(Feed{FeedURL: "x"})})
35+ if !strings.Contains(rec2.Body.String(), `"feed_url":"x"`) {
36+ t.Fatalf("set optional should serialize: %s", rec2.Body.String())
37+ }
38+}
39+
40+// TestAnnotation_NilReceiver_Tags ensures tags serialize as [] not null
41+// even for a nil-receiver conversion (the contract the frontend relies on).
42+func TestAnnotation_NilReceiver_Tags(t *testing.T) {
43+ b, err := json.Marshal(toAnnotation(nil))
44+ if err != nil {
45+ t.Fatalf("marshal: %v", err)
46+ }
47+ if !strings.Contains(string(b), `"tags":[]`) {
48+ t.Fatalf("tags = %s, want []", b)
49+ }
50+}
51+
52+func TestWriteAPIError(t *testing.T) {
53+ rec := httptest.NewRecorder()
54+ writeAPIError(rec, 400, "boom")
55+ if rec.Code != 400 {
56+ t.Fatalf("status = %d, want 400", rec.Code)
57+ }
58+ if !strings.Contains(rec.Body.String(), `"error":"boom"`) {
59+ t.Fatalf("body = %s, want error:boom", rec.Body.String())
60+ }
61+}
new file mode 100644
@@ -0,0 +1,61 @@
1+package server
2+
3+import (
4+ "encoding/json"
5+ "net/http/httptest"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestNonNil(t *testing.T) {
11+ var nilStrs []string
12+ if got := nonNil(nilStrs); got == nil || len(got) != 0 {
13+ t.Fatalf("nonNil(nil) = %v, want non-nil empty slice", got)
14+ }
15+ in := []string{"a"}
16+ if got := nonNil(in); &got[0] != &in[0] {
17+ t.Fatalf("nonNil returned a copy of a non-nil slice")
18+ }
19+}
20+
21+// TestPtrTo confirms optional response fields serialize as null when unset
22+// and as the value when set via new().
23+func TestPtrTo(t *testing.T) {
24+ type holder struct {
25+ Feed *Feed `json:"feed,omitempty"`
26+ }
27+ rec := httptest.NewRecorder()
28+ writeJSON(rec, 200, holder{})
29+ if !strings.Contains(rec.Body.String(), `{}`) {
30+ t.Fatalf("empty optional should omit: %s", rec.Body.String())
31+ }
32+
33+ rec2 := httptest.NewRecorder()
34+ writeJSON(rec2, 200, holder{Feed: new(Feed{FeedURL: "x"})})
35+ if !strings.Contains(rec2.Body.String(), `"feed_url":"x"`) {
36+ t.Fatalf("set optional should serialize: %s", rec2.Body.String())
37+ }
38+}
39+
40+// TestAnnotation_NilReceiver_Tags ensures tags serialize as [] not null
41+// even for a nil-receiver conversion (the contract the frontend relies on).
42+func TestAnnotation_NilReceiver_Tags(t *testing.T) {
43+ b, err := json.Marshal(toAnnotation(nil))
44+ if err != nil {
45+ t.Fatalf("marshal: %v", err)
46+ }
47+ if !strings.Contains(string(b), `"tags":[]`) {
48+ t.Fatalf("tags = %s, want []", b)
49+ }
50+}
51+
52+func TestWriteAPIError(t *testing.T) {
53+ rec := httptest.NewRecorder()
54+ writeAPIError(rec, 400, "boom")
55+ if rec.Code != 400 {
56+ t.Fatalf("status = %d, want 400", rec.Code)
57+ }
58+ if !strings.Contains(rec.Body.String(), `"error":"boom"`) {
59+ t.Fatalf("body = %s, want error:boom", rec.Body.String())
60+ }
61+}
modified internal/server/articles_handler.go +103 -171
@@ -16,36 +16,6 @@ import (
1616 "pkg.rbrt.fr/glean/internal/db"
1717 )
1818
19-func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) {
20- fill := "none"
21- likedCls := "text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15"
22- if liked {
23- fill = "currentColor"
24- likedCls = "text-spot-red bg-spot-red/15 hover:bg-spot-red/25"
25- }
26-
27- w.Header().Set("Content-Type", "text/html")
28-
29- if bordered {
30- fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=true" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition %s"><svg class="w-3.5 h-3.5" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count)
31- } else {
32- unlikedBorderCls := "text-spot-text bg-spot-surface border border-spot-divider hover:text-spot-red hover:bg-spot-red/10 hover:border-spot-red/20"
33- likedBorderCls := "text-spot-red bg-spot-red/15 hover:bg-spot-red/25 border border-spot-red/20"
34- borderCls := unlikedBorderCls
35- if liked {
36- borderCls = likedBorderCls
37- }
38- fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center justify-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition w-full %s"><svg class="w-3 h-3" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), borderCls, fill, count)
39- }
40-}
41-
42-func likeTitle(liked bool) string {
43- if liked {
44- return "Unlike"
45- }
46- return "Like"
47-}
48-
4919 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
5020 user := currentUser(r)
5121 ctx := r.Context()
@@ -69,8 +39,10 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
6939 }
7040 }
7141
72- var articles []*db.Article
73- var err error
42+ var (
43+ articles []*db.Article
44+ err error
45+ )
7446
7547 if searchQuery != "" {
7648 articles, err = s.dbs.Articles.SearchArticles(ctx, user.DID, searchQuery, page.Limit()+1, page.Offset())
@@ -87,7 +59,7 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
8759
8860 if err != nil {
8961 s.logger.Error("failed to list articles", "error", err)
90- http.Error(w, err.Error(), http.StatusInternalServerError)
62+ writeAPIError(w, http.StatusInternalServerError, err.Error())
9163 return
9264 }
9365
@@ -97,59 +69,40 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
9769 articles = articles[:page.PageSize]
9870 }
9971
100- navSuffix := buildNavSuffix(feedURL, false, status)
101- for _, a := range articles {
102- a.NavSuffix = navSuffix
103- }
104-
10572 settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)
106- expandedView := false
107- if settings != nil {
108- expandedView = settings.ExpandedView
109- }
110-
111- sortParam := ""
112- if sortOldest {
113- sortParam = "oldest"
114- }
73+ expandedView := settings != nil && settings.ExpandedView
11574
11675 categories, _ := s.dbs.Articles.GetCategories(ctx, user.DID)
76+ categories = nonNil(categories)
77+
78+ out := make([]Article, len(articles))
79+ for i, a := range articles {
80+ out[i] = toArticle(a)
81+ }
11782
118- data := map[string]any{
119- "User": user,
120- "Articles": articles,
121- "FeedURL": feedURL,
122- "Status": status,
123- "SearchQuery": searchQuery,
124- "Page": page,
125- "BaseURL": "/articles",
126- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery, "sort": sortParam, "category": category}),
127- "Now": time.Now(),
128- "ExpandedView": expandedView,
129- "SortOldest": sortOldest,
130- "Category": category,
131- "Categories": categories,
83+ resp := articlesResponse{
84+ User: toUser(user),
85+ Articles: out,
86+ FeedURL: feedURL,
87+ Status: status,
88+ SearchQuery: searchQuery,
89+ SortOldest: sortOldest,
90+ Category: category,
91+ Categories: categories,
92+ ExpandedView: expandedView,
93+ Pagination: page,
94+ Now: time.Now().Unix(),
13295 }
13396
13497 if feedURL != "" {
13598 if feed, err := s.dbs.Articles.GetFeed(ctx, feedURL); err == nil {
136- data["Feed"] = feed
137- } else {
138- s.logger.Warn("failed to get feed", "error", err, "feed", feedURL)
139- }
140- if _, err := s.dbs.Articles.GetSubscription(ctx, user.DID, feedURL); err == nil {
141- data["IsSubscribed"] = true
142- } else {
143- data["IsSubscribed"] = false
99+ resp.Feed = new(toFeed(feed))
144100 }
101+ _, subErr := s.dbs.Articles.GetSubscription(ctx, user.DID, feedURL)
102+ resp.IsSubscribed = subErr == nil
145103 }
146104
147- if isHXRequest(r) {
148- s.render(w, r, "articles-content.html", data)
149- return
150- }
151-
152- s.render(w, r, "articles.html", data)
105+ writeJSON(w, http.StatusOK, resp)
153106 }
154107
155108 func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
@@ -157,7 +110,7 @@ func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
157110 ctx := r.Context()
158111 sinceUnix, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
159112 if err != nil {
160- w.WriteHeader(http.StatusBadRequest)
113+ writeAPIError(w, http.StatusBadRequest, "invalid since")
161114 return
162115 }
163116 since := time.Unix(sinceUnix, 0)
@@ -165,23 +118,11 @@ func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
165118 count, err := s.dbs.Articles.CountNewArticles(ctx, user.DID, since)
166119 if err != nil {
167120 s.logger.Error("failed to count new articles", "error", err, "did", user.DID)
168- w.WriteHeader(http.StatusInternalServerError)
169- return
170- }
171-
172- w.Header().Set("Content-Type", "text/html")
173- if count == 0 {
174- w.Write([]byte(""))
121+ writeAPIError(w, http.StatusInternalServerError, "failed to count")
175122 return
176123 }
177- fmt.Fprintf(w, `<div id="new-articles-banner" class="bg-spot-green rounded-xl px-5 py-3 flex items-center justify-between mb-4"><span class="text-sm text-white font-medium">%d new article%s available.</span><a href="%s" class="text-sm font-bold text-white uppercase tracking-button hover:underline transition">Refresh</a></div>`, count, pluralS(count), r.URL.Query().Get("return"))
178-}
179124
180-func pluralS(n int) string {
181- if n != 1 {
182- return "s"
183- }
184- return ""
125+ writeJSON(w, http.StatusOK, newArticleCountResponse{Count: count})
185126 }
186127
187128 func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
@@ -189,18 +130,21 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
189130 ctx := r.Context()
190131 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
191132 if err != nil {
192- http.Error(w, "invalid id", http.StatusBadRequest)
133+ writeAPIError(w, http.StatusBadRequest, "invalid id")
193134 return
194135 }
195136
196137 article, err := s.dbs.Articles.GetArticle(ctx, id)
197138 if err != nil {
198- http.Error(w, "article not found", http.StatusNotFound)
139+ writeAPIError(w, http.StatusNotFound, "article not found")
199140 return
200141 }
201142
143+ fromFeedURL := r.URL.Query().Get("from_feed")
144+ navLiked := r.URL.Query().Get("liked") == "1"
145+ navStatus := r.URL.Query().Get("status")
146+
202147 var (
203- readState *db.ReadState
204148 likeCount int
205149 liked bool
206150 annotations []*db.Annotation
@@ -208,10 +152,6 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
208152 nextID *int64
209153 )
210154
211- fromFeedURL := r.URL.Query().Get("from_feed")
212- navLiked := r.URL.Query().Get("liked") == "1"
213- navStatus := r.URL.Query().Get("status")
214-
215155 g, gCtx := errgroup.WithContext(ctx)
216156
217157 g.Go(func() error {
@@ -221,15 +161,6 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
221161 return nil
222162 })
223163
224- g.Go(func() error {
225- var err error
226- readState, err = s.dbs.Articles.GetReadState(gCtx, user.DID, id)
227- if err != nil {
228- s.logger.Warn("failed to get read state", "error", err, "id", id)
229- }
230- return nil
231- })
232-
233164 g.Go(func() error {
234165 if article.URL.Valid {
235166 var err error
@@ -281,52 +212,57 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
281212 return nil
282213 })
283214
284- if err := g.Wait(); err != nil {
285- s.logger.Warn("article detail error", "error", err, "id", id)
286- }
287-
288- s.render(w, r, "article_detail.html", map[string]any{
289- "User": user,
290- "CurrentUserDID": user.DID,
291- "Article": article,
292- "Feed": feed,
293- "ReadState": readState,
294- "LikeCount": likeCount,
295- "HasLiked": liked,
296- "Annotations": annotations,
297- "NextID": nextID,
298- "NextSuffix": buildNavSuffix(fromFeedURL, navLiked, navStatus),
299- })
215+ _ = g.Wait()
216+
217+ dto := toArticle(article)
218+ dto.IsRead = true
219+ dto.LikeCount = likeCount
220+ dto.HasLiked = liked
221+
222+ annots := make([]Annotation, len(annotations))
223+ for i, a := range annotations {
224+ annots[i] = toAnnotation(a)
225+ }
226+
227+ resp := articleDetailResponse{
228+ User: toUser(user),
229+ CurrentUserDID: user.DID,
230+ Article: dto,
231+ Feed: toFeed(feed),
232+ Annotations: annots,
233+ NextID: nextID,
234+ NextSuffix: buildNavSuffix(fromFeedURL, navLiked, navStatus),
235+ }
236+
237+ writeJSON(w, http.StatusOK, resp)
300238 }
301239
302240 func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
303241 user := currentUser(r)
304242 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
305243 if err != nil {
306- http.Error(w, "invalid id", http.StatusBadRequest)
244+ writeAPIError(w, http.StatusBadRequest, "invalid id")
307245 return
308246 }
309247 if err := s.dbs.Articles.MarkArticleRead(r.Context(), user.DID, id); err != nil {
310- http.Error(w, err.Error(), http.StatusInternalServerError)
248+ writeAPIError(w, http.StatusInternalServerError, err.Error())
311249 return
312250 }
313- w.Header().Set("Content-Type", "text/html")
314- fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/unread" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as unread" class="group inline-flex items-center gap-1 text-[10px] text-spot-secondary uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Unread</span></button>`, id)
251+ writeJSON(w, http.StatusOK, articleStateResponse{ID: id, IsRead: true})
315252 }
316253
317254 func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
318255 user := currentUser(r)
319256 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
320257 if err != nil {
321- http.Error(w, "invalid id", http.StatusBadRequest)
258+ writeAPIError(w, http.StatusBadRequest, "invalid id")
322259 return
323260 }
324261 if err := s.dbs.Articles.MarkArticleUnread(r.Context(), user.DID, id); err != nil {
325- http.Error(w, err.Error(), http.StatusInternalServerError)
262+ writeAPIError(w, http.StatusInternalServerError, err.Error())
326263 return
327264 }
328- w.Header().Set("Content-Type", "text/html")
329- fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/read" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as read" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Read</span></button>`, id)
265+ writeJSON(w, http.StatusOK, articleStateResponse{ID: id, IsRead: false})
330266 }
331267
332268 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
@@ -334,26 +270,26 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
334270 ctx := r.Context()
335271 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
336272 if err != nil {
337- http.Error(w, "invalid id", http.StatusBadRequest)
273+ writeAPIError(w, http.StatusBadRequest, "invalid id")
338274 return
339275 }
340276
341277 article, err := s.dbs.Articles.GetArticle(ctx, id)
342278 if err != nil {
343- http.Error(w, err.Error(), http.StatusNotFound)
279+ writeAPIError(w, http.StatusNotFound, err.Error())
344280 return
345281 }
346282
347283 liked, err := s.dbs.Articles.HasLiked(ctx, user.DID, article.FeedURL, article.URL.String)
348284 if err != nil {
349- http.Error(w, err.Error(), http.StatusInternalServerError)
285+ writeAPIError(w, http.StatusInternalServerError, err.Error())
350286 return
351287 }
352288
353289 if liked {
354290 existingLike, getErr := s.dbs.Articles.GetLike(ctx, user.DID, article.FeedURL, article.URL.String)
355291 if getErr != nil {
356- http.Error(w, getErr.Error(), http.StatusInternalServerError)
292+ writeAPIError(w, http.StatusInternalServerError, getErr.Error())
357293 return
358294 }
359295 if existingLike.URI != "" {
@@ -362,14 +298,14 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
362298 if ok {
363299 if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil {
364300 s.logger.Error("failed to delete like from PDS", "error", delErr)
365- http.Error(w, "failed to delete like from PDS: "+delErr.Error(), http.StatusInternalServerError)
301+ writeAPIError(w, http.StatusInternalServerError, "failed to delete like from PDS: "+delErr.Error())
366302 return
367303 }
368304 }
369305 }
370306 }
371307 if err := s.dbs.Articles.DeleteLikeByUserArticle(ctx, user.DID, article.FeedURL, article.URL.String); err != nil {
372- http.Error(w, err.Error(), http.StatusInternalServerError)
308+ writeAPIError(w, http.StatusInternalServerError, err.Error())
373309 return
374310 }
375311 } else {
@@ -383,7 +319,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
383319 uri, _, err := client.CreateRecord(ctx, user.DID, atproto.CollectionLike, likeRecord)
384320 if err != nil {
385321 s.logger.Error("failed to write like to PDS", "error", err)
386- http.Error(w, "failed to write like to PDS: "+err.Error(), http.StatusInternalServerError)
322+ writeAPIError(w, http.StatusInternalServerError, "failed to write like to PDS: "+err.Error())
387323 return
388324 }
389325
@@ -395,14 +331,9 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
395331 CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
396332 }
397333 if err := s.dbs.Articles.CreateLike(ctx, like); err != nil && !errors.Is(err, db.ErrDuplicateLike) {
398- http.Error(w, err.Error(), http.StatusInternalServerError)
334+ writeAPIError(w, http.StatusInternalServerError, err.Error())
399335 return
400336 }
401- if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
402- s.logger.Warn("failed to mark impression acted", "error", err)
403- }
404- sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
405- s.engine.RewardSignal(ctx, user.DID, sig)
406337 } else {
407338 like := &db.Like{
408339 URI: fmt.Sprintf("glean:like:%d", time.Now().UnixNano()),
@@ -412,15 +343,15 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
412343 CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
413344 }
414345 if err := s.dbs.Articles.CreateLike(ctx, like); err != nil && !errors.Is(err, db.ErrDuplicateLike) {
415- http.Error(w, err.Error(), http.StatusInternalServerError)
346+ writeAPIError(w, http.StatusInternalServerError, err.Error())
416347 return
417348 }
418- if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
419- s.logger.Warn("failed to mark impression acted", "error", err)
420- }
421- sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
422- s.engine.RewardSignal(ctx, user.DID, sig)
423349 }
350+ if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
351+ s.logger.Warn("failed to mark impression acted", "error", err)
352+ }
353+ sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
354+ s.engine.RewardSignal(ctx, user.DID, sig)
424355 }
425356
426357 likeCount := 0
@@ -430,13 +361,21 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
430361 s.logger.Warn("failed to get like count", "error", err)
431362 }
432363 }
433- bordered := r.URL.Query().Get("bordered") == "true"
434- writeLikeButton(w, id, !liked, likeCount, bordered)
364+
365+ writeJSON(w, http.StatusOK, likeResponse{
366+ ID: id,
367+ Liked: !liked,
368+ LikeCount: likeCount,
369+ })
435370 }
436371
437372 func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
438373 user := currentUser(r)
439374 ctx := r.Context()
375+ if err := r.ParseForm(); err != nil {
376+ writeAPIError(w, http.StatusBadRequest, err.Error())
377+ return
378+ }
440379 feedURL := r.FormValue("feed")
441380 var err error
442381 if feedURL != "" {
@@ -445,10 +384,9 @@ func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
445384 err = s.dbs.Articles.MarkAllSubscribedRead(ctx, user.DID)
446385 }
447386 if err != nil {
448- http.Error(w, err.Error(), http.StatusInternalServerError)
387+ writeAPIError(w, http.StatusInternalServerError, err.Error())
449388 return
450389 }
451- w.Header().Set("HX-Refresh", "true")
452390 w.WriteHeader(http.StatusNoContent)
453391 }
454392
@@ -456,45 +394,39 @@ func (s *Server) handleFetchContent(w http.ResponseWriter, r *http.Request) {
456394 ctx := r.Context()
457395 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
458396 if err != nil {
459- http.Error(w, "invalid id", http.StatusBadRequest)
397+ writeAPIError(w, http.StatusBadRequest, "invalid id")
460398 return
461399 }
462400
463401 article, err := s.dbs.Articles.GetArticle(ctx, id)
464402 if err != nil {
465- http.Error(w, "article not found", http.StatusNotFound)
403+ writeAPIError(w, http.StatusNotFound, "article not found")
466404 return
467405 }
468406
469407 if !article.URL.Valid {
470- s.logger.Warn("cannot fetch content: article has no URL", "id", id)
471- http.Error(w, "article has no URL", http.StatusBadRequest)
408+ writeAPIError(w, http.StatusBadRequest, "article has no URL")
472409 return
473410 }
474411
475412 content, err := s.scraper.Scrape(ctx, article.URL.String)
476413 if err != nil {
477414 s.logger.Error("failed to scrape article", "error", err, "url", article.URL.String)
478- w.Header().Set("Content-Type", "text/html")
479- _, _ = fmt.Fprintf(w, `<div id="article-content" class="text-spot-secondary text-sm">Failed to fetch content. <button hx-post="/articles/%d/fetch-content" hx-target="#article-content" hx-swap="outerHTML" class="text-spot-green underline">Retry</button></div>`, id)
480- return
481- }
482-
483- if content == "" {
484- w.Header().Set("Content-Type", "text/html")
485- _, _ = fmt.Fprintf(w, `<div id="article-content" class="text-spot-secondary text-sm">No readable content found. <a href="%s" target="_blank" rel="noopener noreferrer" class="text-spot-green underline">Read on original site</a></div>`, article.URL.String)
415+ writeAPIError(w, http.StatusBadGateway, "failed to fetch content")
486416 return
487417 }
488418
489419 cleaned := sanitizeHTML(content)
490-
491- if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil {
492- s.logger.Error("failed to save full content", "error", err, "id", id)
420+ if cleaned != "" {
421+ if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil {
422+ s.logger.Error("failed to save full content", "error", err, "id", id)
423+ }
493424 }
494425
495- w.Header().Set("Content-Type", "text/html")
496- _, _ = fmt.Fprintf(w, `<div id="article-content" class="article-body">%s</div>`, cleaned)
497- s.logger.Info("scraped article content", "id", id, "url", article.URL.String, "content_len", len(cleaned))
426+ writeJSON(w, http.StatusOK, fetchContentResponse{
427+ ID: id,
428+ FullContent: cleaned,
429+ })
498430 }
499431
500432 func buildNavSuffix(feedURL string, liked bool, status string) string {
@@ -16,36 +16,6 @@ import (
16 "pkg.rbrt.fr/glean/internal/db"16 "pkg.rbrt.fr/glean/internal/db"
17 )17 )
18 18
19-func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int, bordered bool) {
20- fill := "none"
21- likedCls := "text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15"
22- if liked {
23- fill = "currentColor"
24- likedCls = "text-spot-red bg-spot-red/15 hover:bg-spot-red/25"
25- }
26-
27- w.Header().Set("Content-Type", "text/html")
28-
29- if bordered {
30- fmt.Fprintf(w, `<button hx-post="/articles/%d/like?bordered=true" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition %s"><svg class="w-3.5 h-3.5" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), likedCls, fill, count)
31- } else {
32- unlikedBorderCls := "text-spot-text bg-spot-surface border border-spot-divider hover:text-spot-red hover:bg-spot-red/10 hover:border-spot-red/20"
33- likedBorderCls := "text-spot-red bg-spot-red/15 hover:bg-spot-red/25 border border-spot-red/20"
34- borderCls := unlikedBorderCls
35- if liked {
36- borderCls = likedBorderCls
37- }
38- fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="this" hx-swap="outerHTML" title="%s" class="group inline-flex items-center justify-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition w-full %s"><svg class="w-3 h-3" fill="%s" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/></svg><span>%d</span></button>`, articleID, likeTitle(liked), borderCls, fill, count)
39- }
40-}
41-
42-func likeTitle(liked bool) string {
43- if liked {
44- return "Unlike"
45- }
46- return "Like"
47-}
48-
49 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {19 func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
50 user := currentUser(r)20 user := currentUser(r)
51 ctx := r.Context()21 ctx := r.Context()
@@ -69,8 +39,10 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
69 }39 }
70 }40 }
71 41
72- var articles []*db.Article42+ var (
73- var err error43+ articles []*db.Article
44+ err error
45+ )
74 46
75 if searchQuery != "" {47 if searchQuery != "" {
76 articles, err = s.dbs.Articles.SearchArticles(ctx, user.DID, searchQuery, page.Limit()+1, page.Offset())48 articles, err = s.dbs.Articles.SearchArticles(ctx, user.DID, searchQuery, page.Limit()+1, page.Offset())
@@ -87,7 +59,7 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
87 59
88 if err != nil {60 if err != nil {
89 s.logger.Error("failed to list articles", "error", err)61 s.logger.Error("failed to list articles", "error", err)
90- http.Error(w, err.Error(), http.StatusInternalServerError)62+ writeAPIError(w, http.StatusInternalServerError, err.Error())
91 return63 return
92 }64 }
93 65
@@ -97,59 +69,40 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
97 articles = articles[:page.PageSize]69 articles = articles[:page.PageSize]
98 }70 }
99 71
100- navSuffix := buildNavSuffix(feedURL, false, status)
101- for _, a := range articles {
102- a.NavSuffix = navSuffix
103- }
104-
105 settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)72 settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)
106- expandedView := false73+ expandedView := settings != nil && settings.ExpandedView
107- if settings != nil {
108- expandedView = settings.ExpandedView
109- }
110-
111- sortParam := ""
112- if sortOldest {
113- sortParam = "oldest"
114- }
115 74
116 categories, _ := s.dbs.Articles.GetCategories(ctx, user.DID)75 categories, _ := s.dbs.Articles.GetCategories(ctx, user.DID)
76+ categories = nonNil(categories)
77+
78+ out := make([]Article, len(articles))
79+ for i, a := range articles {
80+ out[i] = toArticle(a)
81+ }
117 82
118- data := map[string]any{83+ resp := articlesResponse{
119- "User": user,84+ User: toUser(user),
120- "Articles": articles,85+ Articles: out,
121- "FeedURL": feedURL,86+ FeedURL: feedURL,
122- "Status": status,87+ Status: status,
123- "SearchQuery": searchQuery,88+ SearchQuery: searchQuery,
124- "Page": page,89+ SortOldest: sortOldest,
125- "BaseURL": "/articles",90+ Category: category,
126- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery, "sort": sortParam, "category": category}),91+ Categories: categories,
127- "Now": time.Now(),92+ ExpandedView: expandedView,
128- "ExpandedView": expandedView,93+ Pagination: page,
129- "SortOldest": sortOldest,94+ Now: time.Now().Unix(),
130- "Category": category,
131- "Categories": categories,
132 }95 }
133 96
134 if feedURL != "" {97 if feedURL != "" {
135 if feed, err := s.dbs.Articles.GetFeed(ctx, feedURL); err == nil {98 if feed, err := s.dbs.Articles.GetFeed(ctx, feedURL); err == nil {
136- data["Feed"] = feed99+ resp.Feed = new(toFeed(feed))
137- } else {
138- s.logger.Warn("failed to get feed", "error", err, "feed", feedURL)
139- }
140- if _, err := s.dbs.Articles.GetSubscription(ctx, user.DID, feedURL); err == nil {
141- data["IsSubscribed"] = true
142- } else {
143- data["IsSubscribed"] = false
144 }100 }
101+ _, subErr := s.dbs.Articles.GetSubscription(ctx, user.DID, feedURL)
102+ resp.IsSubscribed = subErr == nil
145 }103 }
146 104
147- if isHXRequest(r) {105+ writeJSON(w, http.StatusOK, resp)
148- s.render(w, r, "articles-content.html", data)
149- return
150- }
151-
152- s.render(w, r, "articles.html", data)
153 }106 }
154 107
155 func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {108 func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
@@ -157,7 +110,7 @@ func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
157 ctx := r.Context()110 ctx := r.Context()
158 sinceUnix, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)111 sinceUnix, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
159 if err != nil {112 if err != nil {
160- w.WriteHeader(http.StatusBadRequest)113+ writeAPIError(w, http.StatusBadRequest, "invalid since")
161 return114 return
162 }115 }
163 since := time.Unix(sinceUnix, 0)116 since := time.Unix(sinceUnix, 0)
@@ -165,23 +118,11 @@ func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
165 count, err := s.dbs.Articles.CountNewArticles(ctx, user.DID, since)118 count, err := s.dbs.Articles.CountNewArticles(ctx, user.DID, since)
166 if err != nil {119 if err != nil {
167 s.logger.Error("failed to count new articles", "error", err, "did", user.DID)120 s.logger.Error("failed to count new articles", "error", err, "did", user.DID)
168- w.WriteHeader(http.StatusInternalServerError)121+ writeAPIError(w, http.StatusInternalServerError, "failed to count")
169- return
170- }
171-
172- w.Header().Set("Content-Type", "text/html")
173- if count == 0 {
174- w.Write([]byte(""))
175 return122 return
176 }123 }
177- fmt.Fprintf(w, `<div id="new-articles-banner" class="bg-spot-green rounded-xl px-5 py-3 flex items-center justify-between mb-4"><span class="text-sm text-white font-medium">%d new article%s available.</span><a href="%s" class="text-sm font-bold text-white uppercase tracking-button hover:underline transition">Refresh</a></div>`, count, pluralS(count), r.URL.Query().Get("return"))
178-}
179 124
180-func pluralS(n int) string {125+ writeJSON(w, http.StatusOK, newArticleCountResponse{Count: count})
181- if n != 1 {
182- return "s"
183- }
184- return ""
185 }126 }
186 127
187 func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {128 func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
@@ -189,18 +130,21 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
189 ctx := r.Context()130 ctx := r.Context()
190 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)131 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
191 if err != nil {132 if err != nil {
192- http.Error(w, "invalid id", http.StatusBadRequest)133+ writeAPIError(w, http.StatusBadRequest, "invalid id")
193 return134 return
194 }135 }
195 136
196 article, err := s.dbs.Articles.GetArticle(ctx, id)137 article, err := s.dbs.Articles.GetArticle(ctx, id)
197 if err != nil {138 if err != nil {
198- http.Error(w, "article not found", http.StatusNotFound)139+ writeAPIError(w, http.StatusNotFound, "article not found")
199 return140 return
200 }141 }
201 142
143+ fromFeedURL := r.URL.Query().Get("from_feed")
144+ navLiked := r.URL.Query().Get("liked") == "1"
145+ navStatus := r.URL.Query().Get("status")
146+
202 var (147 var (
203- readState *db.ReadState
204 likeCount int148 likeCount int
205 liked bool149 liked bool
206 annotations []*db.Annotation150 annotations []*db.Annotation
@@ -208,10 +152,6 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
208 nextID *int64152 nextID *int64
209 )153 )
210 154
211- fromFeedURL := r.URL.Query().Get("from_feed")
212- navLiked := r.URL.Query().Get("liked") == "1"
213- navStatus := r.URL.Query().Get("status")
214-
215 g, gCtx := errgroup.WithContext(ctx)155 g, gCtx := errgroup.WithContext(ctx)
216 156
217 g.Go(func() error {157 g.Go(func() error {
@@ -221,15 +161,6 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
221 return nil161 return nil
222 })162 })
223 163
224- g.Go(func() error {
225- var err error
226- readState, err = s.dbs.Articles.GetReadState(gCtx, user.DID, id)
227- if err != nil {
228- s.logger.Warn("failed to get read state", "error", err, "id", id)
229- }
230- return nil
231- })
232-
233 g.Go(func() error {164 g.Go(func() error {
234 if article.URL.Valid {165 if article.URL.Valid {
235 var err error166 var err error
@@ -281,52 +212,57 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) {
281 return nil212 return nil
282 })213 })
283 214
284- if err := g.Wait(); err != nil {215+ _ = g.Wait()
285- s.logger.Warn("article detail error", "error", err, "id", id)216+
286- }217+ dto := toArticle(article)
287-218+ dto.IsRead = true
288- s.render(w, r, "article_detail.html", map[string]any{219+ dto.LikeCount = likeCount
289- "User": user,220+ dto.HasLiked = liked
290- "CurrentUserDID": user.DID,221+
291- "Article": article,222+ annots := make([]Annotation, len(annotations))
292- "Feed": feed,223+ for i, a := range annotations {
293- "ReadState": readState,224+ annots[i] = toAnnotation(a)
294- "LikeCount": likeCount,225+ }
295- "HasLiked": liked,226+
296- "Annotations": annotations,227+ resp := articleDetailResponse{
297- "NextID": nextID,228+ User: toUser(user),
298- "NextSuffix": buildNavSuffix(fromFeedURL, navLiked, navStatus),229+ CurrentUserDID: user.DID,
299- })230+ Article: dto,
231+ Feed: toFeed(feed),
232+ Annotations: annots,
233+ NextID: nextID,
234+ NextSuffix: buildNavSuffix(fromFeedURL, navLiked, navStatus),
235+ }
236+
237+ writeJSON(w, http.StatusOK, resp)
300 }238 }
301 239
302 func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {240 func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
303 user := currentUser(r)241 user := currentUser(r)
304 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)242 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
305 if err != nil {243 if err != nil {
306- http.Error(w, "invalid id", http.StatusBadRequest)244+ writeAPIError(w, http.StatusBadRequest, "invalid id")
307 return245 return
308 }246 }
309 if err := s.dbs.Articles.MarkArticleRead(r.Context(), user.DID, id); err != nil {247 if err := s.dbs.Articles.MarkArticleRead(r.Context(), user.DID, id); err != nil {
310- http.Error(w, err.Error(), http.StatusInternalServerError)248+ writeAPIError(w, http.StatusInternalServerError, err.Error())
311 return249 return
312 }250 }
313- w.Header().Set("Content-Type", "text/html")251+ writeJSON(w, http.StatusOK, articleStateResponse{ID: id, IsRead: true})
314- fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/unread" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as unread" class="group inline-flex items-center gap-1 text-[10px] text-spot-secondary uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Unread</span></button>`, id)
315 }252 }
316 253
317 func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {254 func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
318 user := currentUser(r)255 user := currentUser(r)
319 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)256 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
320 if err != nil {257 if err != nil {
321- http.Error(w, "invalid id", http.StatusBadRequest)258+ writeAPIError(w, http.StatusBadRequest, "invalid id")
322 return259 return
323 }260 }
324 if err := s.dbs.Articles.MarkArticleUnread(r.Context(), user.DID, id); err != nil {261 if err := s.dbs.Articles.MarkArticleUnread(r.Context(), user.DID, id); err != nil {
325- http.Error(w, err.Error(), http.StatusInternalServerError)262+ writeAPIError(w, http.StatusInternalServerError, err.Error())
326 return263 return
327 }264 }
328- w.Header().Set("Content-Type", "text/html")265+ writeJSON(w, http.StatusOK, articleStateResponse{ID: id, IsRead: false})
329- fmt.Fprintf(w, `<button id="read-btn-%[1]d" hx-post="/articles/%[1]d/read" hx-target="#read-btn-%[1]d" hx-swap="outerHTML" title="Mark as read" class="group inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover hover:text-spot-green hover:bg-spot-green/15 transition"><svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg><span>Read</span></button>`, id)
330 }266 }
331 267
332 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {268 func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
@@ -334,26 +270,26 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
334 ctx := r.Context()270 ctx := r.Context()
335 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)271 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
336 if err != nil {272 if err != nil {
337- http.Error(w, "invalid id", http.StatusBadRequest)273+ writeAPIError(w, http.StatusBadRequest, "invalid id")
338 return274 return
339 }275 }
340 276
341 article, err := s.dbs.Articles.GetArticle(ctx, id)277 article, err := s.dbs.Articles.GetArticle(ctx, id)
342 if err != nil {278 if err != nil {
343- http.Error(w, err.Error(), http.StatusNotFound)279+ writeAPIError(w, http.StatusNotFound, err.Error())
344 return280 return
345 }281 }
346 282
347 liked, err := s.dbs.Articles.HasLiked(ctx, user.DID, article.FeedURL, article.URL.String)283 liked, err := s.dbs.Articles.HasLiked(ctx, user.DID, article.FeedURL, article.URL.String)
348 if err != nil {284 if err != nil {
349- http.Error(w, err.Error(), http.StatusInternalServerError)285+ writeAPIError(w, http.StatusInternalServerError, err.Error())
350 return286 return
351 }287 }
352 288
353 if liked {289 if liked {
354 existingLike, getErr := s.dbs.Articles.GetLike(ctx, user.DID, article.FeedURL, article.URL.String)290 existingLike, getErr := s.dbs.Articles.GetLike(ctx, user.DID, article.FeedURL, article.URL.String)
355 if getErr != nil {291 if getErr != nil {
356- http.Error(w, getErr.Error(), http.StatusInternalServerError)292+ writeAPIError(w, http.StatusInternalServerError, getErr.Error())
357 return293 return
358 }294 }
359 if existingLike.URI != "" {295 if existingLike.URI != "" {
@@ -362,14 +298,14 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
362 if ok {298 if ok {
363 if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil {299 if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil {
364 s.logger.Error("failed to delete like from PDS", "error", delErr)300 s.logger.Error("failed to delete like from PDS", "error", delErr)
365- http.Error(w, "failed to delete like from PDS: "+delErr.Error(), http.StatusInternalServerError)301+ writeAPIError(w, http.StatusInternalServerError, "failed to delete like from PDS: "+delErr.Error())
366 return302 return
367 }303 }
368 }304 }
369 }305 }
370 }306 }
371 if err := s.dbs.Articles.DeleteLikeByUserArticle(ctx, user.DID, article.FeedURL, article.URL.String); err != nil {307 if err := s.dbs.Articles.DeleteLikeByUserArticle(ctx, user.DID, article.FeedURL, article.URL.String); err != nil {
372- http.Error(w, err.Error(), http.StatusInternalServerError)308+ writeAPIError(w, http.StatusInternalServerError, err.Error())
373 return309 return
374 }310 }
375 } else {311 } else {
@@ -383,7 +319,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
383 uri, _, err := client.CreateRecord(ctx, user.DID, atproto.CollectionLike, likeRecord)319 uri, _, err := client.CreateRecord(ctx, user.DID, atproto.CollectionLike, likeRecord)
384 if err != nil {320 if err != nil {
385 s.logger.Error("failed to write like to PDS", "error", err)321 s.logger.Error("failed to write like to PDS", "error", err)
386- http.Error(w, "failed to write like to PDS: "+err.Error(), http.StatusInternalServerError)322+ writeAPIError(w, http.StatusInternalServerError, "failed to write like to PDS: "+err.Error())
387 return323 return
388 }324 }
389 325
@@ -395,14 +331,9 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
395 CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},331 CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
396 }332 }
397 if err := s.dbs.Articles.CreateLike(ctx, like); err != nil && !errors.Is(err, db.ErrDuplicateLike) {333 if err := s.dbs.Articles.CreateLike(ctx, like); err != nil && !errors.Is(err, db.ErrDuplicateLike) {
398- http.Error(w, err.Error(), http.StatusInternalServerError)334+ writeAPIError(w, http.StatusInternalServerError, err.Error())
399 return335 return
400 }336 }
401- if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
402- s.logger.Warn("failed to mark impression acted", "error", err)
403- }
404- sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
405- s.engine.RewardSignal(ctx, user.DID, sig)
406 } else {337 } else {
407 like := &db.Like{338 like := &db.Like{
408 URI: fmt.Sprintf("glean:like:%d", time.Now().UnixNano()),339 URI: fmt.Sprintf("glean:like:%d", time.Now().UnixNano()),
@@ -412,15 +343,15 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
412 CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},343 CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
413 }344 }
414 if err := s.dbs.Articles.CreateLike(ctx, like); err != nil && !errors.Is(err, db.ErrDuplicateLike) {345 if err := s.dbs.Articles.CreateLike(ctx, like); err != nil && !errors.Is(err, db.ErrDuplicateLike) {
415- http.Error(w, err.Error(), http.StatusInternalServerError)346+ writeAPIError(w, http.StatusInternalServerError, err.Error())
416 return347 return
417 }348 }
418- if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
419- s.logger.Warn("failed to mark impression acted", "error", err)
420- }
421- sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
422- s.engine.RewardSignal(ctx, user.DID, sig)
423 }349 }
350+ if err := s.feedback.MarkImpressionActed(ctx, user.DID, "article", article.URL.String); err != nil {
351+ s.logger.Warn("failed to mark impression acted", "error", err)
352+ }
353+ sig := s.engine.GetDominantSignal(s.engine.GetWeights(ctx, user.DID))
354+ s.engine.RewardSignal(ctx, user.DID, sig)
424 }355 }
425 356
426 likeCount := 0357 likeCount := 0
@@ -430,13 +361,21 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) {
430 s.logger.Warn("failed to get like count", "error", err)361 s.logger.Warn("failed to get like count", "error", err)
431 }362 }
432 }363 }
433- bordered := r.URL.Query().Get("bordered") == "true"364+
434- writeLikeButton(w, id, !liked, likeCount, bordered)365+ writeJSON(w, http.StatusOK, likeResponse{
366+ ID: id,
367+ Liked: !liked,
368+ LikeCount: likeCount,
369+ })
435 }370 }
436 371
437 func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {372 func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
438 user := currentUser(r)373 user := currentUser(r)
439 ctx := r.Context()374 ctx := r.Context()
375+ if err := r.ParseForm(); err != nil {
376+ writeAPIError(w, http.StatusBadRequest, err.Error())
377+ return
378+ }
440 feedURL := r.FormValue("feed")379 feedURL := r.FormValue("feed")
441 var err error380 var err error
442 if feedURL != "" {381 if feedURL != "" {
@@ -445,10 +384,9 @@ func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) {
445 err = s.dbs.Articles.MarkAllSubscribedRead(ctx, user.DID)384 err = s.dbs.Articles.MarkAllSubscribedRead(ctx, user.DID)
446 }385 }
447 if err != nil {386 if err != nil {
448- http.Error(w, err.Error(), http.StatusInternalServerError)387+ writeAPIError(w, http.StatusInternalServerError, err.Error())
449 return388 return
450 }389 }
451- w.Header().Set("HX-Refresh", "true")
452 w.WriteHeader(http.StatusNoContent)390 w.WriteHeader(http.StatusNoContent)
453 }391 }
454 392
@@ -456,45 +394,39 @@ func (s *Server) handleFetchContent(w http.ResponseWriter, r *http.Request) {
456 ctx := r.Context()394 ctx := r.Context()
457 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)395 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
458 if err != nil {396 if err != nil {
459- http.Error(w, "invalid id", http.StatusBadRequest)397+ writeAPIError(w, http.StatusBadRequest, "invalid id")
460 return398 return
461 }399 }
462 400
463 article, err := s.dbs.Articles.GetArticle(ctx, id)401 article, err := s.dbs.Articles.GetArticle(ctx, id)
464 if err != nil {402 if err != nil {
465- http.Error(w, "article not found", http.StatusNotFound)403+ writeAPIError(w, http.StatusNotFound, "article not found")
466 return404 return
467 }405 }
468 406
469 if !article.URL.Valid {407 if !article.URL.Valid {
470- s.logger.Warn("cannot fetch content: article has no URL", "id", id)408+ writeAPIError(w, http.StatusBadRequest, "article has no URL")
471- http.Error(w, "article has no URL", http.StatusBadRequest)
472 return409 return
473 }410 }
474 411
475 content, err := s.scraper.Scrape(ctx, article.URL.String)412 content, err := s.scraper.Scrape(ctx, article.URL.String)
476 if err != nil {413 if err != nil {
477 s.logger.Error("failed to scrape article", "error", err, "url", article.URL.String)414 s.logger.Error("failed to scrape article", "error", err, "url", article.URL.String)
478- w.Header().Set("Content-Type", "text/html")415+ writeAPIError(w, http.StatusBadGateway, "failed to fetch content")
479- _, _ = fmt.Fprintf(w, `<div id="article-content" class="text-spot-secondary text-sm">Failed to fetch content. <button hx-post="/articles/%d/fetch-content" hx-target="#article-content" hx-swap="outerHTML" class="text-spot-green underline">Retry</button></div>`, id)
480- return
481- }
482-
483- if content == "" {
484- w.Header().Set("Content-Type", "text/html")
485- _, _ = fmt.Fprintf(w, `<div id="article-content" class="text-spot-secondary text-sm">No readable content found. <a href="%s" target="_blank" rel="noopener noreferrer" class="text-spot-green underline">Read on original site</a></div>`, article.URL.String)
486 return416 return
487 }417 }
488 418
489 cleaned := sanitizeHTML(content)419 cleaned := sanitizeHTML(content)
490-420+ if cleaned != "" {
491- if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil {421+ if err := s.dbs.Articles.UpdateArticleFullContent(ctx, id, cleaned); err != nil {
492- s.logger.Error("failed to save full content", "error", err, "id", id)422+ s.logger.Error("failed to save full content", "error", err, "id", id)
423+ }
493 }424 }
494 425
495- w.Header().Set("Content-Type", "text/html")426+ writeJSON(w, http.StatusOK, fetchContentResponse{
496- _, _ = fmt.Fprintf(w, `<div id="article-content" class="article-body">%s</div>`, cleaned)427+ ID: id,
497- s.logger.Info("scraped article content", "id", id, "url", article.URL.String, "content_len", len(cleaned))428+ FullContent: cleaned,
429+ })
498 }430 }
499 431
500 func buildNavSuffix(feedURL string, liked bool, status string) string {432 func buildNavSuffix(feedURL string, liked bool, status string) string {
modified internal/server/auth_handler.go +24 -24
@@ -13,45 +13,42 @@ import (
1313 "pkg.rbrt.fr/glean/internal/atproto"
1414 )
1515
16-func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
17- s.render(w, r, "login.html", map[string]any{})
16+func (s *Server) handleAuthLoginMeta(w http.ResponseWriter, r *http.Request) {
17+ writeJSON(w, http.StatusOK, oauthEnabledResponse{OAuthEnabled: s.clientID != ""})
1818 }
1919
20+// handleAuthRegister kicks off registration via the Eurosky PDS.
2021 func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
21- // hardcoded to Eurosky PDS for sign-up because it is a good public one.
2222 authURL, err := s.oauth.StartAuthFlow(r.Context(), "https://eurosky.social")
2323 if err != nil {
2424 s.logger.Error("failed to start register OAuth flow", "error", err)
25- s.renderError(w, r, http.StatusInternalServerError, "Registration failed", "Could not connect to Eurosky. Please try again.")
25+ writeAPIError(w, http.StatusInternalServerError, "Could not connect to Eurosky.")
2626 return
2727 }
28- http.Redirect(w, r, authURL, http.StatusSeeOther)
28+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: authURL})
2929 }
3030
3131 func (s *Server) handleAuthResolve(w http.ResponseWriter, r *http.Request) {
3232 q := strings.TrimPrefix(r.URL.Query().Get("q"), "@")
3333 if q == "" {
34- w.Header().Set("Content-Type", "application/json")
35- w.Write([]byte(`{"actors":[]}`))
34+ writeJSON(w, http.StatusOK, actorsResponse{})
3635 return
3736 }
3837
3938 actors, err := atproto.SearchActorsTypeahead(r.Context(), q, 5)
4039 if err != nil {
4140 s.logger.Warn("actor typeahead failed", "error", err)
42- w.Header().Set("Content-Type", "application/json")
43- w.Write([]byte(`{"actors":[]}`))
41+ writeJSON(w, http.StatusOK, actorsResponse{})
4442 return
4543 }
4644
47- w.Header().Set("Content-Type", "application/json")
48- json.NewEncoder(w).Encode(map[string]any{"actors": actors})
45+ writeJSON(w, http.StatusOK, actorsResponse{Actors: actors})
4946 }
5047
5148 func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
5249 handle := strings.TrimPrefix(r.FormValue("handle"), "@")
5350 if handle == "" {
54- s.renderError(w, r, http.StatusBadRequest, "Missing handle", "Please enter your handle.")
51+ writeAPIError(w, http.StatusBadRequest, "Please enter your handle.")
5552 return
5653 }
5754
@@ -61,22 +58,24 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
6158
6259 did, resolveErr := atproto.ResolveHandle(r.Context(), handle)
6360 if resolveErr != nil {
64- s.renderError(w, r, http.StatusBadRequest, "Handle not found", "Could not resolve that handle. Please check and try again.")
61+ writeAPIError(w, http.StatusBadRequest, "Could not resolve that handle. Please check and try again.")
6562 return
6663 }
6764 user, createErr := s.dbs.Users.CreateUser(r.Context(), did)
6865 if createErr != nil {
69- s.renderError(w, r, http.StatusInternalServerError, "Sign in failed", "Could not create your account. Please try again.")
66+ writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.")
7067 return
7168 }
7269 s.setUserSession(w, user)
73- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
70+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"})
7471 return
7572 }
7673
77- http.Redirect(w, r, authURL, http.StatusSeeOther)
74+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: authURL})
7875 }
7976
77+// handleAuthCallback is hit by the OAuth provider after authorization. It is a
78+// browser navigation (not an XHR), so it issues HTTP redirects rather than JSON.
8079 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
8180 params := r.URL.Query()
8281
@@ -87,21 +86,21 @@ func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
8786
8887 handle := params.Get("handle")
8988 if handle == "" {
90- s.renderError(w, r, http.StatusBadRequest, "Missing handle", "Please enter your handle.")
89+ http.Redirect(w, r, "/auth/login?error=missing_handle", http.StatusSeeOther)
9190 return
9291 }
9392
9493 did, err := atproto.ResolveHandle(r.Context(), handle)
9594 if err != nil {
9695 s.logger.Error("failed to resolve handle", "error", err)
97- s.renderError(w, r, http.StatusBadRequest, "Handle not found", "Could not resolve that handle. Please check and try again.")
96+ http.Redirect(w, r, "/auth/login?error=handle_not_found", http.StatusSeeOther)
9897 return
9998 }
10099
101100 user, err := s.dbs.Users.CreateUser(r.Context(), did)
102101 if err != nil {
103102 s.logger.Error("failed to create user", "error", err)
104- s.renderError(w, r, http.StatusInternalServerError, "Sign in failed", "Could not create your account. Please try again.")
103+ http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther)
105104 return
106105 }
107106
@@ -113,7 +112,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
113112 sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query())
114113 if err != nil {
115114 s.logger.Error("OAuth callback failed", "error", err)
116- s.renderError(w, r, http.StatusInternalServerError, "Authentication failed", "Something went wrong during sign in. Please try again.")
115+ http.Redirect(w, r, "/auth/login?error=auth_failed", http.StatusSeeOther)
117116 return
118117 }
119118
@@ -124,7 +123,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
124123 user, err := s.dbs.Users.CreateUser(r.Context(), did)
125124 if err != nil {
126125 s.logger.Error("failed to create user", "error", err)
127- s.renderError(w, r, http.StatusInternalServerError, "Sign in failed", "Could not create your account. Please try again.")
126+ http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther)
128127 return
129128 }
130129
@@ -136,7 +135,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
136135 encoded, err := encodeSession(s.sessionKey, sessionData)
137136 if err != nil {
138137 s.logger.Error("failed to encode session", "error", err)
139- s.renderError(w, r, http.StatusInternalServerError, "Session error", "Could not create your session. Please try again.")
138+ http.Redirect(w, r, "/auth/login?error=session_error", http.StatusSeeOther)
140139 return
141140 }
142141
@@ -146,6 +145,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
146145 Path: "/",
147146 MaxAge: 86400 * 30,
148147 HttpOnly: true,
148+ Secure: s.secureCookies,
149149 SameSite: http.SameSiteLaxMode,
150150 })
151151
@@ -165,7 +165,7 @@ func (s *Server) pdsClientFromSession(sessData *oauth.ClientSessionData) *atprot
165165
166166 func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
167167 if s.clientID == "" {
168- http.Error(w, "localhost client", http.StatusNotFound)
168+ writeAPIError(w, http.StatusNotFound, "localhost client")
169169 return
170170 }
171171
@@ -188,5 +188,5 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
188188 }
189189 }
190190 s.clearUserSession(w)
191- http.Redirect(w, r, "/", http.StatusSeeOther)
191+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"})
192192 }
@@ -13,45 +13,42 @@ import (
13 "pkg.rbrt.fr/glean/internal/atproto"13 "pkg.rbrt.fr/glean/internal/atproto"
14 )14 )
15 15
16-func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {16+func (s *Server) handleAuthLoginMeta(w http.ResponseWriter, r *http.Request) {
17- s.render(w, r, "login.html", map[string]any{})17+ writeJSON(w, http.StatusOK, oauthEnabledResponse{OAuthEnabled: s.clientID != ""})
18 }18 }
19 19
20+// handleAuthRegister kicks off registration via the Eurosky PDS.
20 func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {21 func (s *Server) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
21- // hardcoded to Eurosky PDS for sign-up because it is a good public one.
22 authURL, err := s.oauth.StartAuthFlow(r.Context(), "https://eurosky.social")22 authURL, err := s.oauth.StartAuthFlow(r.Context(), "https://eurosky.social")
23 if err != nil {23 if err != nil {
24 s.logger.Error("failed to start register OAuth flow", "error", err)24 s.logger.Error("failed to start register OAuth flow", "error", err)
25- s.renderError(w, r, http.StatusInternalServerError, "Registration failed", "Could not connect to Eurosky. Please try again.")25+ writeAPIError(w, http.StatusInternalServerError, "Could not connect to Eurosky.")
26 return26 return
27 }27 }
28- http.Redirect(w, r, authURL, http.StatusSeeOther)28+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: authURL})
29 }29 }
30 30
31 func (s *Server) handleAuthResolve(w http.ResponseWriter, r *http.Request) {31 func (s *Server) handleAuthResolve(w http.ResponseWriter, r *http.Request) {
32 q := strings.TrimPrefix(r.URL.Query().Get("q"), "@")32 q := strings.TrimPrefix(r.URL.Query().Get("q"), "@")
33 if q == "" {33 if q == "" {
34- w.Header().Set("Content-Type", "application/json")34+ writeJSON(w, http.StatusOK, actorsResponse{})
35- w.Write([]byte(`{"actors":[]}`))
36 return35 return
37 }36 }
38 37
39 actors, err := atproto.SearchActorsTypeahead(r.Context(), q, 5)38 actors, err := atproto.SearchActorsTypeahead(r.Context(), q, 5)
40 if err != nil {39 if err != nil {
41 s.logger.Warn("actor typeahead failed", "error", err)40 s.logger.Warn("actor typeahead failed", "error", err)
42- w.Header().Set("Content-Type", "application/json")41+ writeJSON(w, http.StatusOK, actorsResponse{})
43- w.Write([]byte(`{"actors":[]}`))
44 return42 return
45 }43 }
46 44
47- w.Header().Set("Content-Type", "application/json")45+ writeJSON(w, http.StatusOK, actorsResponse{Actors: actors})
48- json.NewEncoder(w).Encode(map[string]any{"actors": actors})
49 }46 }
50 47
51 func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {48 func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
52 handle := strings.TrimPrefix(r.FormValue("handle"), "@")49 handle := strings.TrimPrefix(r.FormValue("handle"), "@")
53 if handle == "" {50 if handle == "" {
54- s.renderError(w, r, http.StatusBadRequest, "Missing handle", "Please enter your handle.")51+ writeAPIError(w, http.StatusBadRequest, "Please enter your handle.")
55 return52 return
56 }53 }
57 54
@@ -61,22 +58,24 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
61 58
62 did, resolveErr := atproto.ResolveHandle(r.Context(), handle)59 did, resolveErr := atproto.ResolveHandle(r.Context(), handle)
63 if resolveErr != nil {60 if resolveErr != nil {
64- s.renderError(w, r, http.StatusBadRequest, "Handle not found", "Could not resolve that handle. Please check and try again.")61+ writeAPIError(w, http.StatusBadRequest, "Could not resolve that handle. Please check and try again.")
65 return62 return
66 }63 }
67 user, createErr := s.dbs.Users.CreateUser(r.Context(), did)64 user, createErr := s.dbs.Users.CreateUser(r.Context(), did)
68 if createErr != nil {65 if createErr != nil {
69- s.renderError(w, r, http.StatusInternalServerError, "Sign in failed", "Could not create your account. Please try again.")66+ writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.")
70 return67 return
71 }68 }
72 s.setUserSession(w, user)69 s.setUserSession(w, user)
73- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)70+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"})
74 return71 return
75 }72 }
76 73
77- http.Redirect(w, r, authURL, http.StatusSeeOther)74+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: authURL})
78 }75 }
79 76
77+// handleAuthCallback is hit by the OAuth provider after authorization. It is a
78+// browser navigation (not an XHR), so it issues HTTP redirects rather than JSON.
80 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {79 func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
81 params := r.URL.Query()80 params := r.URL.Query()
82 81
@@ -87,21 +86,21 @@ func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
87 86
88 handle := params.Get("handle")87 handle := params.Get("handle")
89 if handle == "" {88 if handle == "" {
90- s.renderError(w, r, http.StatusBadRequest, "Missing handle", "Please enter your handle.")89+ http.Redirect(w, r, "/auth/login?error=missing_handle", http.StatusSeeOther)
91 return90 return
92 }91 }
93 92
94 did, err := atproto.ResolveHandle(r.Context(), handle)93 did, err := atproto.ResolveHandle(r.Context(), handle)
95 if err != nil {94 if err != nil {
96 s.logger.Error("failed to resolve handle", "error", err)95 s.logger.Error("failed to resolve handle", "error", err)
97- s.renderError(w, r, http.StatusBadRequest, "Handle not found", "Could not resolve that handle. Please check and try again.")96+ http.Redirect(w, r, "/auth/login?error=handle_not_found", http.StatusSeeOther)
98 return97 return
99 }98 }
100 99
101 user, err := s.dbs.Users.CreateUser(r.Context(), did)100 user, err := s.dbs.Users.CreateUser(r.Context(), did)
102 if err != nil {101 if err != nil {
103 s.logger.Error("failed to create user", "error", err)102 s.logger.Error("failed to create user", "error", err)
104- s.renderError(w, r, http.StatusInternalServerError, "Sign in failed", "Could not create your account. Please try again.")103+ http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther)
105 return104 return
106 }105 }
107 106
@@ -113,7 +112,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
113 sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query())112 sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query())
114 if err != nil {113 if err != nil {
115 s.logger.Error("OAuth callback failed", "error", err)114 s.logger.Error("OAuth callback failed", "error", err)
116- s.renderError(w, r, http.StatusInternalServerError, "Authentication failed", "Something went wrong during sign in. Please try again.")115+ http.Redirect(w, r, "/auth/login?error=auth_failed", http.StatusSeeOther)
117 return116 return
118 }117 }
119 118
@@ -124,7 +123,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
124 user, err := s.dbs.Users.CreateUser(r.Context(), did)123 user, err := s.dbs.Users.CreateUser(r.Context(), did)
125 if err != nil {124 if err != nil {
126 s.logger.Error("failed to create user", "error", err)125 s.logger.Error("failed to create user", "error", err)
127- s.renderError(w, r, http.StatusInternalServerError, "Sign in failed", "Could not create your account. Please try again.")126+ http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther)
128 return127 return
129 }128 }
130 129
@@ -136,7 +135,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
136 encoded, err := encodeSession(s.sessionKey, sessionData)135 encoded, err := encodeSession(s.sessionKey, sessionData)
137 if err != nil {136 if err != nil {
138 s.logger.Error("failed to encode session", "error", err)137 s.logger.Error("failed to encode session", "error", err)
139- s.renderError(w, r, http.StatusInternalServerError, "Session error", "Could not create your session. Please try again.")138+ http.Redirect(w, r, "/auth/login?error=session_error", http.StatusSeeOther)
140 return139 return
141 }140 }
142 141
@@ -146,6 +145,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
146 Path: "/",145 Path: "/",
147 MaxAge: 86400 * 30,146 MaxAge: 86400 * 30,
148 HttpOnly: true,147 HttpOnly: true,
148+ Secure: s.secureCookies,
149 SameSite: http.SameSiteLaxMode,149 SameSite: http.SameSiteLaxMode,
150 })150 })
151 151
@@ -165,7 +165,7 @@ func (s *Server) pdsClientFromSession(sessData *oauth.ClientSessionData) *atprot
165 165
166 func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {166 func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
167 if s.clientID == "" {167 if s.clientID == "" {
168- http.Error(w, "localhost client", http.StatusNotFound)168+ writeAPIError(w, http.StatusNotFound, "localhost client")
169 return169 return
170 }170 }
171 171
@@ -188,5 +188,5 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
188 }188 }
189 }189 }
190 s.clearUserSession(w)190 s.clearUserSession(w)
191- http.Redirect(w, r, "/", http.StatusSeeOther)191+ writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"})
192 }192 }
modified internal/server/dashboard_handler.go +66 -111
@@ -2,16 +2,13 @@ package server
22
33 import (
44 "context"
5- "database/sql"
65 "net/http"
7- "strings"
86 "time"
97
108 "golang.org/x/sync/errgroup"
119
1210 "pkg.rbrt.fr/glean/internal/atproto"
1311 "pkg.rbrt.fr/glean/internal/cluster"
14- "pkg.rbrt.fr/glean/internal/db"
1512 "pkg.rbrt.fr/glean/internal/feedback"
1613 )
1714
@@ -22,10 +19,10 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
2219 var (
2320 unreadCount int
2421 subCount int
25- userLangs []string
26- articles []*db.Article
27- personalTrending []*db.TrendingItem
28- globalTrending []*db.TrendingItem
22+ articles = make([]Article, 0, 5)
23+ personalTrending = make([]TrendingItem, 0, 5)
24+ globalTrending = make([]TrendingItem, 0, 5)
25+ digestEnabled bool
2926 )
3027
3128 g, gCtx := errgroup.WithContext(ctx)
@@ -49,16 +46,15 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
4946 })
5047
5148 g.Go(func() error {
52- var err error
53- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 5, 0, false)
49+ rows, err := s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 5, 0, false)
5450 if err != nil {
5551 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)
52+ return nil
53+ }
54+ articles = make([]Article, len(rows))
55+ for i, a := range rows {
56+ articles[i] = toArticle(a)
5657 }
57- return nil
58- })
59-
60- g.Go(func() error {
61- userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID)
6258 return nil
6359 })
6460
@@ -66,31 +62,42 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
6662 s.logger.Warn("dashboard error", "error", err, "did", user.DID)
6763 }
6864
69- var err error
7065 if subCount == 0 {
71- globalTrending, err = s.engine.GetGlobalTrending(gCtx, user.DID, 5, 0)
66+ rows, err := s.engine.GetGlobalTrending(ctx, user.DID, 5, 0)
7267 if err != nil {
7368 s.logger.Warn("failed to get global trending", "error", err, "did", user.DID)
7469 }
70+ globalTrending = make([]TrendingItem, len(rows))
71+ for i, t := range rows {
72+ globalTrending[i] = toTrendingItem(t)
73+ }
7574 } else {
76- personalTrending, err = s.engine.GetPersonalTrending(gCtx, user.DID, userLangs, 5, 0)
75+ userLangs, _ := s.dbs.Users.GetLanguages(ctx, user.DID)
76+ rows, err := s.engine.GetPersonalTrending(ctx, user.DID, userLangs, 5, 0)
7777 if err != nil {
7878 s.logger.Warn("failed to get personal trending", "error", err, "did", user.DID)
7979 }
80+ personalTrending = make([]TrendingItem, len(rows))
81+ for i, t := range rows {
82+ personalTrending[i] = toTrendingItem(t)
83+ }
8084 }
8185
8286 settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)
83- digestEnabled := settings != nil && settings.DigestEnabled
84-
85- s.render(w, r, "dashboard.html", map[string]any{
86- "User": user,
87- "SubscriptionCount": subCount,
88- "UnreadCount": unreadCount,
89- "Articles": articles,
90- "PersonalTrending": personalTrending,
91- "GlobalTrending": globalTrending,
92- "Now": time.Now(),
93- "DigestEnabled": digestEnabled,
87+ if settings != nil {
88+ digestEnabled = settings.DigestEnabled
89+ }
90+
91+ writeJSON(w, http.StatusOK, dashboardResponse{
92+ User: toUser(user),
93+ SubscriptionCount: subCount,
94+ UnreadCount: unreadCount,
95+ Articles: articles,
96+ PersonalTrending: personalTrending,
97+ GlobalTrending: globalTrending,
98+ DigestEnabled: digestEnabled,
99+ HasLLM: s.llm != nil,
100+ Now: time.Now().Unix(),
94101 })
95102 }
96103
@@ -102,32 +109,23 @@ func (s *Server) handleArticleRecommendations(w http.ResponseWriter, r *http.Req
102109 articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5)
103110 if err != nil {
104111 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)
105- w.WriteHeader(http.StatusOK)
112+ writeJSON(w, http.StatusOK, articleRecsResponse{})
106113 return
107114 }
108115
109- if len(articleRecs) == 0 {
110- w.WriteHeader(http.StatusOK)
111- return
112- }
113-
114- articleRecArticles := make([]*db.Article, len(articleRecs))
115- for i, rec := range articleRecs {
116- articleRecArticles[i] = &db.Article{
116+ recs := make([]Article, 0, len(articleRecs))
117+ for _, rec := range articleRecs {
118+ recs = append(recs, Article{
117119 ID: rec.ArticleID,
120+ Title: rec.Title,
121+ URL: rec.URL,
118122 FeedURL: rec.FeedURL,
119123 FeedTitle: rec.FeedTitle,
120- FeedFaviconURL: sql.NullString{String: rec.FaviconURL, Valid: rec.FaviconURL != ""},
121- Title: rec.Title,
122- URL: sql.NullString{String: rec.URL, Valid: rec.URL != ""},
123- Author: sql.NullString{String: rec.Author, Valid: rec.Author != ""},
124- Summary: sql.NullString{String: rec.Summary, Valid: rec.Summary != ""},
125- Published: rec.Published,
126- IsRead: sql.NullBool{Bool: false, Valid: true},
127- DismissURL: "/recs/dismiss-article",
128- DismissField: "article_url",
129- DismissValue: rec.URL,
130- }
124+ FeedFaviconURL: rec.FaviconURL,
125+ Author: rec.Author,
126+ Summary: rec.Summary,
127+ Published: nullTime(rec.Published),
128+ })
131129 }
132130
133131 var impressions []feedback.Impression
@@ -140,23 +138,7 @@ func (s *Server) handleArticleRecommendations(w http.ResponseWriter, r *http.Req
140138 }
141139 }
142140
143- if cookie, err := r.Cookie("glean_csrf"); err == nil {
144- data := map[string]any{
145- "ArticleRecommendations": articleRecArticles,
146- "CSRFToken": cookie.Value,
147- }
148- var buf strings.Builder
149- if err := s.templates.ExecuteTemplate(&buf, "partials/article-recommendations.html", data); err != nil {
150- s.logger.Error("article recommendations template error", "error", err)
151- w.WriteHeader(http.StatusOK)
152- return
153- }
154- w.Header().Set("Content-Type", "text/html")
155- w.Write([]byte(buf.String()))
156- return
157- }
158-
159- w.WriteHeader(http.StatusOK)
141+ writeJSON(w, http.StatusOK, articleRecsResponse{Articles: recs})
160142 }
161143
162144 func (s *Server) handleFeedRecommendations(w http.ResponseWriter, r *http.Request) {
@@ -167,10 +149,15 @@ func (s *Server) handleFeedRecommendations(w http.ResponseWriter, r *http.Reques
167149 feedRecs, err := s.engine.GetFeedRecommendations(ctx, user.DID, 5)
168150 if err != nil {
169151 s.logger.Warn("failed to get feed recommendations", "error", err, "did", user.DID)
170- w.WriteHeader(http.StatusOK)
152+ writeJSON(w, http.StatusOK, feedRecsResponse{SubscriptionCount: subCount})
171153 return
172154 }
173155
156+ recs := make([]FeedRecommendation, len(feedRecs))
157+ for i, rec := range feedRecs {
158+ recs[i] = toFeedRecommendation(rec)
159+ }
160+
174161 var impressions []feedback.Impression
175162 for _, rec := range feedRecs {
176163 impressions = append(impressions, feedback.Impression{TargetType: "feed", TargetID: rec.FeedURL})
@@ -181,24 +168,10 @@ func (s *Server) handleFeedRecommendations(w http.ResponseWriter, r *http.Reques
181168 }
182169 }
183170
184- if cookie, err := r.Cookie("glean_csrf"); err == nil {
185- data := map[string]any{
186- "FeedRecommendations": feedRecs,
187- "SubscriptionCount": subCount,
188- "CSRFToken": cookie.Value,
189- }
190- var buf strings.Builder
191- if err := s.templates.ExecuteTemplate(&buf, "partials/feed-recommendations.html", data); err != nil {
192- s.logger.Error("feed recommendations template error", "error", err)
193- w.WriteHeader(http.StatusOK)
194- return
195- }
196- w.Header().Set("Content-Type", "text/html")
197- w.Write([]byte(buf.String()))
198- return
199- }
200-
201- w.WriteHeader(http.StatusOK)
171+ writeJSON(w, http.StatusOK, feedRecsResponse{
172+ Feeds: recs,
173+ SubscriptionCount: subCount,
174+ })
202175 }
203176
204177 func (s *Server) handlePeopleRecommendations(w http.ResponseWriter, r *http.Request) {
@@ -208,44 +181,26 @@ func (s *Server) handlePeopleRecommendations(w http.ResponseWriter, r *http.Requ
208181 peopleRecs, err := s.engine.GetPeopleRecommendations(ctx, user.DID, 6)
209182 if err != nil {
210183 s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID)
211- w.WriteHeader(http.StatusOK)
212- return
213- }
214-
215- if len(peopleRecs) == 0 {
216- w.WriteHeader(http.StatusOK)
184+ writeJSON(w, http.StatusOK, peopleRecsResponse{})
217185 return
218186 }
219187
220188 resolvePeopleHandles(ctx, peopleRecs)
221189
222- var followedPeople, discoverPeople []*cluster.PersonRecommendation
190+ followed := make([]PersonRecommendation, 0, len(peopleRecs))
191+ discover := make([]PersonRecommendation, 0, len(peopleRecs))
223192 for _, p := range peopleRecs {
224193 if p.IsFollowed {
225- followedPeople = append(followedPeople, p)
194+ followed = append(followed, toPersonRecommendation(p))
226195 } else {
227- discoverPeople = append(discoverPeople, p)
228- }
229- }
230-
231- if cookie, err := r.Cookie("glean_csrf"); err == nil {
232- data := map[string]any{
233- "FollowedPeople": followedPeople,
234- "DiscoverPeople": discoverPeople,
235- "CSRFToken": cookie.Value,
236- }
237- var buf strings.Builder
238- if err := s.templates.ExecuteTemplate(&buf, "partials/people-recommendations.html", data); err != nil {
239- s.logger.Error("people recommendations template error", "error", err)
240- w.WriteHeader(http.StatusOK)
241- return
196+ discover = append(discover, toPersonRecommendation(p))
242197 }
243- w.Header().Set("Content-Type", "text/html")
244- w.Write([]byte(buf.String()))
245- return
246198 }
247199
248- w.WriteHeader(http.StatusOK)
200+ writeJSON(w, http.StatusOK, peopleRecsResponse{
201+ Followed: followed,
202+ Discover: discover,
203+ })
249204 }
250205
251206 func resolvePeopleHandles(ctx context.Context, people []*cluster.PersonRecommendation) {
@@ -2,16 +2,13 @@ package server
2 2
3 import (3 import (
4 "context"4 "context"
5- "database/sql"
6 "net/http"5 "net/http"
7- "strings"
8 "time"6 "time"
9 7
10 "golang.org/x/sync/errgroup"8 "golang.org/x/sync/errgroup"
11 9
12 "pkg.rbrt.fr/glean/internal/atproto"10 "pkg.rbrt.fr/glean/internal/atproto"
13 "pkg.rbrt.fr/glean/internal/cluster"11 "pkg.rbrt.fr/glean/internal/cluster"
14- "pkg.rbrt.fr/glean/internal/db"
15 "pkg.rbrt.fr/glean/internal/feedback"12 "pkg.rbrt.fr/glean/internal/feedback"
16 )13 )
17 14
@@ -22,10 +19,10 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
22 var (19 var (
23 unreadCount int20 unreadCount int
24 subCount int21 subCount int
25- userLangs []string22+ articles = make([]Article, 0, 5)
26- articles []*db.Article23+ personalTrending = make([]TrendingItem, 0, 5)
27- personalTrending []*db.TrendingItem24+ globalTrending = make([]TrendingItem, 0, 5)
28- globalTrending []*db.TrendingItem25+ digestEnabled bool
29 )26 )
30 27
31 g, gCtx := errgroup.WithContext(ctx)28 g, gCtx := errgroup.WithContext(ctx)
@@ -49,16 +46,15 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
49 })46 })
50 47
51 g.Go(func() error {48 g.Go(func() error {
52- var err error49+ rows, err := s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 5, 0, false)
53- articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", "", 5, 0, false)
54 if err != nil {50 if err != nil {
55 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)51 s.logger.Warn("failed to list unread articles", "error", err, "did", user.DID)
52+ return nil
53+ }
54+ articles = make([]Article, len(rows))
55+ for i, a := range rows {
56+ articles[i] = toArticle(a)
56 }57 }
57- return nil
58- })
59-
60- g.Go(func() error {
61- userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID)
62 return nil58 return nil
63 })59 })
64 60
@@ -66,31 +62,42 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
66 s.logger.Warn("dashboard error", "error", err, "did", user.DID)62 s.logger.Warn("dashboard error", "error", err, "did", user.DID)
67 }63 }
68 64
69- var err error
70 if subCount == 0 {65 if subCount == 0 {
71- globalTrending, err = s.engine.GetGlobalTrending(gCtx, user.DID, 5, 0)66+ rows, err := s.engine.GetGlobalTrending(ctx, user.DID, 5, 0)
72 if err != nil {67 if err != nil {
73 s.logger.Warn("failed to get global trending", "error", err, "did", user.DID)68 s.logger.Warn("failed to get global trending", "error", err, "did", user.DID)
74 }69 }
70+ globalTrending = make([]TrendingItem, len(rows))
71+ for i, t := range rows {
72+ globalTrending[i] = toTrendingItem(t)
73+ }
75 } else {74 } else {
76- personalTrending, err = s.engine.GetPersonalTrending(gCtx, user.DID, userLangs, 5, 0)75+ userLangs, _ := s.dbs.Users.GetLanguages(ctx, user.DID)
76+ rows, err := s.engine.GetPersonalTrending(ctx, user.DID, userLangs, 5, 0)
77 if err != nil {77 if err != nil {
78 s.logger.Warn("failed to get personal trending", "error", err, "did", user.DID)78 s.logger.Warn("failed to get personal trending", "error", err, "did", user.DID)
79 }79 }
80+ personalTrending = make([]TrendingItem, len(rows))
81+ for i, t := range rows {
82+ personalTrending[i] = toTrendingItem(t)
83+ }
80 }84 }
81 85
82 settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)86 settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)
83- digestEnabled := settings != nil && settings.DigestEnabled87+ if settings != nil {
84-88+ digestEnabled = settings.DigestEnabled
85- s.render(w, r, "dashboard.html", map[string]any{89+ }
86- "User": user,90+
87- "SubscriptionCount": subCount,91+ writeJSON(w, http.StatusOK, dashboardResponse{
88- "UnreadCount": unreadCount,92+ User: toUser(user),
89- "Articles": articles,93+ SubscriptionCount: subCount,
90- "PersonalTrending": personalTrending,94+ UnreadCount: unreadCount,
91- "GlobalTrending": globalTrending,95+ Articles: articles,
92- "Now": time.Now(),96+ PersonalTrending: personalTrending,
93- "DigestEnabled": digestEnabled,97+ GlobalTrending: globalTrending,
98+ DigestEnabled: digestEnabled,
99+ HasLLM: s.llm != nil,
100+ Now: time.Now().Unix(),
94 })101 })
95 }102 }
96 103
@@ -102,32 +109,23 @@ func (s *Server) handleArticleRecommendations(w http.ResponseWriter, r *http.Req
102 articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5)109 articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5)
103 if err != nil {110 if err != nil {
104 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)111 s.logger.Warn("failed to get article recommendations", "error", err, "did", user.DID)
105- w.WriteHeader(http.StatusOK)112+ writeJSON(w, http.StatusOK, articleRecsResponse{})
106 return113 return
107 }114 }
108 115
109- if len(articleRecs) == 0 {116+ recs := make([]Article, 0, len(articleRecs))
110- w.WriteHeader(http.StatusOK)117+ for _, rec := range articleRecs {
111- return118+ recs = append(recs, Article{
112- }
113-
114- articleRecArticles := make([]*db.Article, len(articleRecs))
115- for i, rec := range articleRecs {
116- articleRecArticles[i] = &db.Article{
117 ID: rec.ArticleID,119 ID: rec.ArticleID,
120+ Title: rec.Title,
121+ URL: rec.URL,
118 FeedURL: rec.FeedURL,122 FeedURL: rec.FeedURL,
119 FeedTitle: rec.FeedTitle,123 FeedTitle: rec.FeedTitle,
120- FeedFaviconURL: sql.NullString{String: rec.FaviconURL, Valid: rec.FaviconURL != ""},124+ FeedFaviconURL: rec.FaviconURL,
121- Title: rec.Title,125+ Author: rec.Author,
122- URL: sql.NullString{String: rec.URL, Valid: rec.URL != ""},126+ Summary: rec.Summary,
123- Author: sql.NullString{String: rec.Author, Valid: rec.Author != ""},127+ Published: nullTime(rec.Published),
124- Summary: sql.NullString{String: rec.Summary, Valid: rec.Summary != ""},128+ })
125- Published: rec.Published,
126- IsRead: sql.NullBool{Bool: false, Valid: true},
127- DismissURL: "/recs/dismiss-article",
128- DismissField: "article_url",
129- DismissValue: rec.URL,
130- }
131 }129 }
132 130
133 var impressions []feedback.Impression131 var impressions []feedback.Impression
@@ -140,23 +138,7 @@ func (s *Server) handleArticleRecommendations(w http.ResponseWriter, r *http.Req
140 }138 }
141 }139 }
142 140
143- if cookie, err := r.Cookie("glean_csrf"); err == nil {141+ writeJSON(w, http.StatusOK, articleRecsResponse{Articles: recs})
144- data := map[string]any{
145- "ArticleRecommendations": articleRecArticles,
146- "CSRFToken": cookie.Value,
147- }
148- var buf strings.Builder
149- if err := s.templates.ExecuteTemplate(&buf, "partials/article-recommendations.html", data); err != nil {
150- s.logger.Error("article recommendations template error", "error", err)
151- w.WriteHeader(http.StatusOK)
152- return
153- }
154- w.Header().Set("Content-Type", "text/html")
155- w.Write([]byte(buf.String()))
156- return
157- }
158-
159- w.WriteHeader(http.StatusOK)
160 }142 }
161 143
162 func (s *Server) handleFeedRecommendations(w http.ResponseWriter, r *http.Request) {144 func (s *Server) handleFeedRecommendations(w http.ResponseWriter, r *http.Request) {
@@ -167,10 +149,15 @@ func (s *Server) handleFeedRecommendations(w http.ResponseWriter, r *http.Reques
167 feedRecs, err := s.engine.GetFeedRecommendations(ctx, user.DID, 5)149 feedRecs, err := s.engine.GetFeedRecommendations(ctx, user.DID, 5)
168 if err != nil {150 if err != nil {
169 s.logger.Warn("failed to get feed recommendations", "error", err, "did", user.DID)151 s.logger.Warn("failed to get feed recommendations", "error", err, "did", user.DID)
170- w.WriteHeader(http.StatusOK)152+ writeJSON(w, http.StatusOK, feedRecsResponse{SubscriptionCount: subCount})
171 return153 return
172 }154 }
173 155
156+ recs := make([]FeedRecommendation, len(feedRecs))
157+ for i, rec := range feedRecs {
158+ recs[i] = toFeedRecommendation(rec)
159+ }
160+
174 var impressions []feedback.Impression161 var impressions []feedback.Impression
175 for _, rec := range feedRecs {162 for _, rec := range feedRecs {
176 impressions = append(impressions, feedback.Impression{TargetType: "feed", TargetID: rec.FeedURL})163 impressions = append(impressions, feedback.Impression{TargetType: "feed", TargetID: rec.FeedURL})
@@ -181,24 +168,10 @@ func (s *Server) handleFeedRecommendations(w http.ResponseWriter, r *http.Reques
181 }168 }
182 }169 }
183 170
184- if cookie, err := r.Cookie("glean_csrf"); err == nil {171+ writeJSON(w, http.StatusOK, feedRecsResponse{
185- data := map[string]any{172+ Feeds: recs,
186- "FeedRecommendations": feedRecs,173+ SubscriptionCount: subCount,
187- "SubscriptionCount": subCount,174+ })
188- "CSRFToken": cookie.Value,
189- }
190- var buf strings.Builder
191- if err := s.templates.ExecuteTemplate(&buf, "partials/feed-recommendations.html", data); err != nil {
192- s.logger.Error("feed recommendations template error", "error", err)
193- w.WriteHeader(http.StatusOK)
194- return
195- }
196- w.Header().Set("Content-Type", "text/html")
197- w.Write([]byte(buf.String()))
198- return
199- }
200-
201- w.WriteHeader(http.StatusOK)
202 }175 }
203 176
204 func (s *Server) handlePeopleRecommendations(w http.ResponseWriter, r *http.Request) {177 func (s *Server) handlePeopleRecommendations(w http.ResponseWriter, r *http.Request) {
@@ -208,44 +181,26 @@ func (s *Server) handlePeopleRecommendations(w http.ResponseWriter, r *http.Requ
208 peopleRecs, err := s.engine.GetPeopleRecommendations(ctx, user.DID, 6)181 peopleRecs, err := s.engine.GetPeopleRecommendations(ctx, user.DID, 6)
209 if err != nil {182 if err != nil {
210 s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID)183 s.logger.Warn("failed to get people recommendations", "error", err, "did", user.DID)
211- w.WriteHeader(http.StatusOK)184+ writeJSON(w, http.StatusOK, peopleRecsResponse{})
212- return
213- }
214-
215- if len(peopleRecs) == 0 {
216- w.WriteHeader(http.StatusOK)
217 return185 return
218 }186 }
219 187
220 resolvePeopleHandles(ctx, peopleRecs)188 resolvePeopleHandles(ctx, peopleRecs)
221 189
222- var followedPeople, discoverPeople []*cluster.PersonRecommendation190+ followed := make([]PersonRecommendation, 0, len(peopleRecs))
191+ discover := make([]PersonRecommendation, 0, len(peopleRecs))
223 for _, p := range peopleRecs {192 for _, p := range peopleRecs {
224 if p.IsFollowed {193 if p.IsFollowed {
225- followedPeople = append(followedPeople, p)194+ followed = append(followed, toPersonRecommendation(p))
226 } else {195 } else {
227- discoverPeople = append(discoverPeople, p)196+ discover = append(discover, toPersonRecommendation(p))
228- }
229- }
230-
231- if cookie, err := r.Cookie("glean_csrf"); err == nil {
232- data := map[string]any{
233- "FollowedPeople": followedPeople,
234- "DiscoverPeople": discoverPeople,
235- "CSRFToken": cookie.Value,
236- }
237- var buf strings.Builder
238- if err := s.templates.ExecuteTemplate(&buf, "partials/people-recommendations.html", data); err != nil {
239- s.logger.Error("people recommendations template error", "error", err)
240- w.WriteHeader(http.StatusOK)
241- return
242 }197 }
243- w.Header().Set("Content-Type", "text/html")
244- w.Write([]byte(buf.String()))
245- return
246 }198 }
247 199
248- w.WriteHeader(http.StatusOK)200+ writeJSON(w, http.StatusOK, peopleRecsResponse{
201+ Followed: followed,
202+ Discover: discover,
203+ })
249 }204 }
250 205
251 func resolvePeopleHandles(ctx context.Context, people []*cluster.PersonRecommendation) {206 func resolvePeopleHandles(ctx context.Context, people []*cluster.PersonRecommendation) {
modified internal/server/digest_handler.go +36 -65
@@ -17,33 +17,33 @@ import (
1717 )
1818
1919 type digestCtx struct {
20- Title string
21- Summary string
22- Excerpt string
23- ArticleIDs []int64
24- GeneratedAt time.Time
25- CSRFToken string
26- Consumed bool
20+ Title string `json:"title"`
21+ Summary string `json:"summary"`
22+ Excerpt string `json:"excerpt"`
23+ ArticleIDs []int64 `json:"article_ids"`
24+ GeneratedAt int64 `json:"generated_at"`
25+ Consumed bool `json:"consumed"`
2726 }
2827
2928 type digestCacheEntry struct {
30- html string
31- expiry time.Time
32- articleIDs []int64
33- consumed bool
29+ data *digestCtx
30+ expiry time.Time
31+ consumed bool
3432 }
3533
3634 var (
37- digestCache sync.Map
38- digestFlight singleflight.Group
35+ digestCache = sync.Map{}
36+ digestFlight = singleflight.Group{}
3937 )
4038
4139 const digestTTL = 24 * time.Hour
4240
4341 var refRe = regexp.MustCompile(`\[(\d+)\]`)
4442
45-func linkifyRefs(html string, articles []*db.Article) string {
46- return refRe.ReplaceAllStringFunc(html, func(match string) string {
43+// linkifyRefs turns [n] references in the LLM summary into links to the
44+// corresponding article. The frontend receives the already-linkified HTML.
45+func linkifyRefs(htmlText string, articles []*db.Article) string {
46+ return refRe.ReplaceAllStringFunc(htmlText, func(match string) string {
4747 n, err := strconv.Atoi(match[1 : len(match)-1])
4848 if err != nil || n < 1 || n > len(articles) {
4949 return match
@@ -116,18 +116,17 @@ func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx
116116
117117 summary = linkifyRefs(summary, articles)
118118
119+ ids := make([]int64, len(articles))
120+ for i, a := range articles {
121+ ids[i] = a.ID
122+ }
123+
119124 return &digestCtx{
120- Title: title,
121- Summary: summary,
122- Excerpt: excerptFromHTML(summary),
123- ArticleIDs: func() []int64 {
124- ids := make([]int64, len(articles))
125- for i, a := range articles {
126- ids[i] = a.ID
127- }
128- return ids
129- }(),
130- GeneratedAt: time.Now(),
125+ Title: title,
126+ Summary: summary,
127+ Excerpt: excerptFromHTML(summary),
128+ ArticleIDs: ids,
129+ GeneratedAt: time.Now().Unix(),
131130 }
132131 }
133132
@@ -152,8 +151,8 @@ func digestArticleContent(a *db.Article) string {
152151 return string(runes)
153152 }
154153
155-func excerptFromHTML(html string) string {
156- text := plainText(html)
154+func excerptFromHTML(htmlText string) string {
155+ text := plainText(htmlText)
157156 if len(text) > 200 {
158157 text = text[:200]
159158 if idx := strings.LastIndex(text, ". "); idx > 0 {
@@ -193,10 +192,9 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
193192 entry := cached.(*digestCacheEntry)
194193 if time.Now().Before(entry.expiry) {
195194 if entry.consumed {
196- s.renderDigest(w, &digestCtx{Consumed: true})
195+ writeJSON(w, http.StatusOK, &digestCtx{ArticleIDs: []int64{}, Consumed: true})
197196 } else {
198- w.Header().Set("Content-Type", "text/html")
199- w.Write([]byte(entry.html))
197+ writeJSON(w, http.StatusOK, entry.data)
200198 }
201199 return
202200 }
@@ -208,25 +206,11 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
208206 if d == nil {
209207 return nil, nil
210208 }
211-
212- if cookie, err := r.Cookie("glean_csrf"); err == nil {
213- d.CSRFToken = cookie.Value
214- }
215-
216- var buf strings.Builder
217- if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
218- s.logger.Error("digest template error", "error", err)
219- return nil, nil
220- }
221-
222- html := buf.String()
223209 digestCache.Store(key, &digestCacheEntry{
224- html: html,
225- expiry: time.Now().Add(digestTTL),
226- articleIDs: d.ArticleIDs,
210+ data: d,
211+ expiry: time.Now().Add(digestTTL),
227212 })
228-
229- return html, nil
213+ return d, nil
230214 })
231215
232216 if result == nil {
@@ -234,26 +218,14 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
234218 return
235219 }
236220
237- w.Header().Set("Content-Type", "text/html")
238- w.Write([]byte(result.(string)))
239-}
240-
241-func (s *Server) renderDigest(w http.ResponseWriter, d *digestCtx) {
242- var buf strings.Builder
243- if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
244- s.logger.Error("digest template error", "error", err)
245- w.WriteHeader(http.StatusNoContent)
246- return
247- }
248- w.Header().Set("Content-Type", "text/html")
249- w.Write([]byte(buf.String()))
221+ writeJSON(w, http.StatusOK, result.(*digestCtx))
250222 }
251223
252224 func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
253225 user := currentUser(r)
254226
255227 if err := r.ParseForm(); err != nil {
256- http.Error(w, err.Error(), http.StatusBadRequest)
228+ writeAPIError(w, http.StatusBadRequest, err.Error())
257229 return
258230 }
259231
@@ -273,7 +245,7 @@ func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
273245
274246 if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil {
275247 s.logger.Error("failed to mark digest articles read", "error", err)
276- http.Error(w, err.Error(), http.StatusInternalServerError)
248+ writeAPIError(w, http.StatusInternalServerError, err.Error())
277249 return
278250 }
279251
@@ -282,6 +254,5 @@ func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
282254 consumed: true,
283255 })
284256
285- w.Header().Set(HXRefresh, "true")
286- s.renderDigest(w, &digestCtx{Consumed: true})
257+ writeJSON(w, http.StatusOK, &digestCtx{ArticleIDs: []int64{}, Consumed: true})
287258 }
@@ -17,33 +17,33 @@ import (
17 )17 )
18 18
19 type digestCtx struct {19 type digestCtx struct {
20- Title string20+ Title string `json:"title"`
21- Summary string21+ Summary string `json:"summary"`
22- Excerpt string22+ Excerpt string `json:"excerpt"`
23- ArticleIDs []int6423+ ArticleIDs []int64 `json:"article_ids"`
24- GeneratedAt time.Time24+ GeneratedAt int64 `json:"generated_at"`
25- CSRFToken string25+ Consumed bool `json:"consumed"`
26- Consumed bool
27 }26 }
28 27
29 type digestCacheEntry struct {28 type digestCacheEntry struct {
30- html string29+ data *digestCtx
31- expiry time.Time30+ expiry time.Time
32- articleIDs []int6431+ consumed bool
33- consumed bool
34 }32 }
35 33
36 var (34 var (
37- digestCache sync.Map35+ digestCache = sync.Map{}
38- digestFlight singleflight.Group36+ digestFlight = singleflight.Group{}
39 )37 )
40 38
41 const digestTTL = 24 * time.Hour39 const digestTTL = 24 * time.Hour
42 40
43 var refRe = regexp.MustCompile(`\[(\d+)\]`)41 var refRe = regexp.MustCompile(`\[(\d+)\]`)
44 42
45-func linkifyRefs(html string, articles []*db.Article) string {43+// linkifyRefs turns [n] references in the LLM summary into links to the
46- return refRe.ReplaceAllStringFunc(html, func(match string) string {44+// corresponding article. The frontend receives the already-linkified HTML.
45+func linkifyRefs(htmlText string, articles []*db.Article) string {
46+ return refRe.ReplaceAllStringFunc(htmlText, func(match string) string {
47 n, err := strconv.Atoi(match[1 : len(match)-1])47 n, err := strconv.Atoi(match[1 : len(match)-1])
48 if err != nil || n < 1 || n > len(articles) {48 if err != nil || n < 1 || n > len(articles) {
49 return match49 return match
@@ -116,18 +116,17 @@ func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx
116 116
117 summary = linkifyRefs(summary, articles)117 summary = linkifyRefs(summary, articles)
118 118
119+ ids := make([]int64, len(articles))
120+ for i, a := range articles {
121+ ids[i] = a.ID
122+ }
123+
119 return &digestCtx{124 return &digestCtx{
120- Title: title,125+ Title: title,
121- Summary: summary,126+ Summary: summary,
122- Excerpt: excerptFromHTML(summary),127+ Excerpt: excerptFromHTML(summary),
123- ArticleIDs: func() []int64 {128+ ArticleIDs: ids,
124- ids := make([]int64, len(articles))129+ GeneratedAt: time.Now().Unix(),
125- for i, a := range articles {
126- ids[i] = a.ID
127- }
128- return ids
129- }(),
130- GeneratedAt: time.Now(),
131 }130 }
132 }131 }
133 132
@@ -152,8 +151,8 @@ func digestArticleContent(a *db.Article) string {
152 return string(runes)151 return string(runes)
153 }152 }
154 153
155-func excerptFromHTML(html string) string {154+func excerptFromHTML(htmlText string) string {
156- text := plainText(html)155+ text := plainText(htmlText)
157 if len(text) > 200 {156 if len(text) > 200 {
158 text = text[:200]157 text = text[:200]
159 if idx := strings.LastIndex(text, ". "); idx > 0 {158 if idx := strings.LastIndex(text, ". "); idx > 0 {
@@ -193,10 +192,9 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
193 entry := cached.(*digestCacheEntry)192 entry := cached.(*digestCacheEntry)
194 if time.Now().Before(entry.expiry) {193 if time.Now().Before(entry.expiry) {
195 if entry.consumed {194 if entry.consumed {
196- s.renderDigest(w, &digestCtx{Consumed: true})195+ writeJSON(w, http.StatusOK, &digestCtx{ArticleIDs: []int64{}, Consumed: true})
197 } else {196 } else {
198- w.Header().Set("Content-Type", "text/html")197+ writeJSON(w, http.StatusOK, entry.data)
199- w.Write([]byte(entry.html))
200 }198 }
201 return199 return
202 }200 }
@@ -208,25 +206,11 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
208 if d == nil {206 if d == nil {
209 return nil, nil207 return nil, nil
210 }208 }
211-
212- if cookie, err := r.Cookie("glean_csrf"); err == nil {
213- d.CSRFToken = cookie.Value
214- }
215-
216- var buf strings.Builder
217- if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
218- s.logger.Error("digest template error", "error", err)
219- return nil, nil
220- }
221-
222- html := buf.String()
223 digestCache.Store(key, &digestCacheEntry{209 digestCache.Store(key, &digestCacheEntry{
224- html: html,210+ data: d,
225- expiry: time.Now().Add(digestTTL),211+ expiry: time.Now().Add(digestTTL),
226- articleIDs: d.ArticleIDs,
227 })212 })
228-213+ return d, nil
229- return html, nil
230 })214 })
231 215
232 if result == nil {216 if result == nil {
@@ -234,26 +218,14 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
234 return218 return
235 }219 }
236 220
237- w.Header().Set("Content-Type", "text/html")221+ writeJSON(w, http.StatusOK, result.(*digestCtx))
238- w.Write([]byte(result.(string)))
239-}
240-
241-func (s *Server) renderDigest(w http.ResponseWriter, d *digestCtx) {
242- var buf strings.Builder
243- if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
244- s.logger.Error("digest template error", "error", err)
245- w.WriteHeader(http.StatusNoContent)
246- return
247- }
248- w.Header().Set("Content-Type", "text/html")
249- w.Write([]byte(buf.String()))
250 }222 }
251 223
252 func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {224 func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
253 user := currentUser(r)225 user := currentUser(r)
254 226
255 if err := r.ParseForm(); err != nil {227 if err := r.ParseForm(); err != nil {
256- http.Error(w, err.Error(), http.StatusBadRequest)228+ writeAPIError(w, http.StatusBadRequest, err.Error())
257 return229 return
258 }230 }
259 231
@@ -273,7 +245,7 @@ func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
273 245
274 if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil {246 if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil {
275 s.logger.Error("failed to mark digest articles read", "error", err)247 s.logger.Error("failed to mark digest articles read", "error", err)
276- http.Error(w, err.Error(), http.StatusInternalServerError)248+ writeAPIError(w, http.StatusInternalServerError, err.Error())
277 return249 return
278 }250 }
279 251
@@ -282,6 +254,5 @@ func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
282 consumed: true,254 consumed: true,
283 })255 })
284 256
285- w.Header().Set(HXRefresh, "true")257+ writeJSON(w, http.StatusOK, &digestCtx{ArticleIDs: []int64{}, Consumed: true})
286- s.renderDigest(w, &digestCtx{Consumed: true})
287 }258 }
modified internal/server/feeds_handler.go +78 -87
@@ -3,6 +3,7 @@ package server
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "net/http"
78 "net/url"
89 "time"
@@ -72,23 +73,35 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
7273 return nil
7374 })
7475
75- if err := g.Wait(); err != nil {
76- s.logger.Warn("feeds error", "error", err, "did", user.DID)
77- }
76+ _ = g.Wait()
7877
79- s.render(w, r, "feeds.html", map[string]any{
80- "User": user,
81- "Subscriptions": subs,
82- "SubscriptionCount": subCount,
83- "Categories": categories,
84- "Category": category,
85- "DeadFeeds": deadFeeds,
86- "Page": page,
87- "BaseURL": "/feeds",
88- "QueryParams": buildQueryParams(map[string]string{"category": category}),
78+ writeJSON(w, http.StatusOK, feedsResponse{
79+ User: toUser(user),
80+ Subscriptions: toSubscriptions(subs),
81+ SubscriptionCount: subCount,
82+ Categories: nonNil(categories),
83+ Category: category,
84+ DeadFeeds: toFeeds(deadFeeds),
85+ Pagination: page,
8986 })
9087 }
9188
89+func toSubscriptions(subs []*db.Subscription) []Subscription {
90+ out := make([]Subscription, len(subs))
91+ for i, s := range subs {
92+ out[i] = toSubscription(s)
93+ }
94+ return out
95+}
96+
97+func toFeeds(feeds []*db.Feed) []Feed {
98+ out := make([]Feed, len(feeds))
99+ for i, f := range feeds {
100+ out[i] = toFeed(f)
101+ }
102+ return out
103+}
104+
92105 func (s *Server) storeFetchResult(ctx context.Context, feedURL, siteURL string, result *feed.ParseResult) {
93106 if result == nil {
94107 _ = s.dbs.Articles.MarkFeedFetched(ctx, feedURL)
@@ -115,22 +128,21 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
115128 category := r.FormValue("category")
116129
117130 if feedURL == "" {
118- http.Error(w, "url required", http.StatusBadRequest)
131+ writeAPIError(w, http.StatusBadRequest, "url required")
119132 return
120133 }
121134
122135 if !atproto.IsATProtoFeedURL(feedURL) {
123- u, err := url.Parse(feedURL)
124- if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
125- http.Error(w, "invalid feed URL", http.StatusBadRequest)
136+ if err := validateHTTPURL(feedURL); err != nil {
137+ writeAPIError(w, http.StatusBadRequest, err.Error())
126138 return
127139 }
128140 }
129141
130- result, feedURL, err := s.fetcher.FetchOrDiscover(r.Context(), feedURL)
142+ result, resolvedURL, err := s.fetcher.FetchOrDiscover(r.Context(), feedURL)
131143 if err != nil {
132144 s.logger.Error("failed to fetch feed", "error", err, "url", feedURL)
133- http.Error(w, err.Error(), http.StatusInternalServerError)
145+ writeAPIError(w, http.StatusInternalServerError, err.Error())
134146 return
135147 }
136148
@@ -141,7 +153,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
141153 }
142154
143155 f := &db.Feed{
144- FeedURL: feedURL,
156+ FeedURL: resolvedURL,
145157 Title: db.NullStr(feedTitle),
146158 SiteURL: db.NullStr(result.Feed.SiteURL),
147159 Description: db.NullStr(result.Feed.Description),
@@ -150,7 +162,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
150162 }
151163 if err := s.dbs.Articles.UpsertFeed(r.Context(), f); err != nil {
152164 s.logger.Error("failed to upsert feed", "error", err)
153- http.Error(w, err.Error(), http.StatusInternalServerError)
165+ writeAPIError(w, http.StatusInternalServerError, err.Error())
154166 return
155167 }
156168
@@ -158,54 +170,60 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
158170 if client := s.pdsClientForUser(r); client != nil {
159171 record := atproto.SubscriptionRecord{
160172 CreatedAt: time.Now().Format(time.RFC3339),
161- FeedURL: feedURL,
173+ FeedURL: resolvedURL,
162174 Title: feedTitle,
163175 Category: category,
164176 }
165177 uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record)
166178 if err != nil {
167179 s.logger.Error("failed to write subscription to PDS", "error", err)
168- http.Error(w, "failed to write subscription to PDS: "+err.Error(), http.StatusInternalServerError)
180+ writeAPIError(w, http.StatusInternalServerError, "failed to write subscription to PDS: "+err.Error())
169181 return
170182 }
171183 subURI = uri
172184 subCID = cid
173185 }
174186
175- if err := s.dbs.Articles.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil {
187+ if err := s.dbs.Articles.CreateSubscription(r.Context(), user.DID, resolvedURL, feedTitle, category, subURI, subCID); err != nil {
176188 if errors.Is(err, db.ErrDuplicateSubscription) {
177- http.Error(w, "Already subscribed to this feed.", http.StatusConflict)
189+ writeAPIError(w, http.StatusConflict, "Already subscribed to this feed.")
178190 return
179191 }
180192 s.logger.Error("failed to create subscription", "error", err)
181- http.Error(w, err.Error(), http.StatusInternalServerError)
193+ writeAPIError(w, http.StatusInternalServerError, err.Error())
182194 return
183195 }
184196
185- go s.storeFetchResult(context.WithoutCancel(r.Context()), feedURL, result.Feed.SiteURL, result)
197+ go s.storeFetchResult(context.WithoutCancel(r.Context()), resolvedURL, result.Feed.SiteURL, result)
186198
187199 s.engine.InvalidateFeedCache(user.DID)
188- if err := s.feedback.MarkImpressionActed(r.Context(), user.DID, "feed", feedURL); err != nil {
200+ if err := s.feedback.MarkImpressionActed(r.Context(), user.DID, "feed", resolvedURL); err != nil {
189201 s.logger.Warn("failed to mark impression acted", "error", err)
190202 }
191203 sig := s.engine.GetDominantSignal(s.engine.GetWeights(r.Context(), user.DID))
192204 s.engine.RewardSignal(r.Context(), user.DID, sig)
193205
194- sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)
206+ sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, resolvedURL)
195207 if err != nil {
196- s.logger.Warn("failed to get subscription", "error", err)
197208 w.WriteHeader(http.StatusNoContent)
198209 return
199210 }
200- s.render(w, r, "feed-item.html", map[string]any{
201- "User": user,
202- "ID": sub.ID,
203- "FeedURL": sub.FeedURL,
204- "FeedTitle": sub.FeedTitle,
205- "Category": sub.Category,
206- "FaviconURL": sub.FaviconURL,
207- "UnreadCount": sub.UnreadCount,
208- })
211+ writeJSON(w, http.StatusOK, subscriptionResponse{Subscription: toSubscription(sub)})
212+}
213+
214+// validateHTTPURL checks that a feed URL has an http(s) scheme and a host.
215+func validateHTTPURL(rawURL string) error {
216+ u, err := url.Parse(rawURL)
217+ if err != nil {
218+ return fmt.Errorf("invalid feed URL")
219+ }
220+ if u.Scheme != "http" && u.Scheme != "https" {
221+ return fmt.Errorf("invalid feed URL")
222+ }
223+ if u.Host == "" {
224+ return fmt.Errorf("invalid feed URL")
225+ }
226+ return nil
209227 }
210228
211229 func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
@@ -214,14 +232,13 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
214232 category := r.FormValue("category")
215233
216234 if feedURL == "" {
217- http.Error(w, "url required", http.StatusBadRequest)
235+ writeAPIError(w, http.StatusBadRequest, "url required")
218236 return
219237 }
220238
221239 sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)
222240 if err != nil {
223- s.logger.Error("failed to get subscription", "error", err)
224- http.Error(w, "subscription not found", http.StatusNotFound)
241+ writeAPIError(w, http.StatusNotFound, "subscription not found")
225242 return
226243 }
227244
@@ -240,7 +257,7 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
240257 _, newCID, putErr := client.PutRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey, record)
241258 if putErr != nil {
242259 s.logger.Error("failed to put subscription record on PDS", "error", putErr)
243- http.Error(w, "failed to update subscription on PDS: "+putErr.Error(), http.StatusInternalServerError)
260+ writeAPIError(w, http.StatusInternalServerError, "failed to update subscription on PDS: "+putErr.Error())
244261 return
245262 }
246263 cid = newCID
@@ -250,26 +267,16 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
250267
251268 if err := s.dbs.Articles.UpdateSubscription(r.Context(), user.DID, feedURL, sub.FeedTitle, category, sub.URI.String, cid); err != nil {
252269 s.logger.Error("failed to update subscription", "error", err)
253- http.Error(w, err.Error(), http.StatusInternalServerError)
270+ writeAPIError(w, http.StatusInternalServerError, err.Error())
254271 return
255272 }
256273
257274 updated, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)
258275 if err != nil {
259- s.logger.Warn("failed to get updated subscription", "error", err)
260276 w.WriteHeader(http.StatusNoContent)
261277 return
262278 }
263-
264- s.render(w, r, "feed-item.html", map[string]any{
265- "User": user,
266- "ID": updated.ID,
267- "FeedURL": updated.FeedURL,
268- "FeedTitle": updated.FeedTitle,
269- "Category": updated.Category,
270- "FaviconURL": updated.FaviconURL,
271- "UnreadCount": updated.UnreadCount,
272- })
279+ writeJSON(w, http.StatusOK, subscriptionResponse{Subscription: toSubscription(updated)})
273280 }
274281
275282 func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
@@ -277,7 +284,7 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
277284 feedURL := r.FormValue("url")
278285
279286 if feedURL == "" {
280- http.Error(w, "url required", http.StatusBadRequest)
287+ writeAPIError(w, http.StatusBadRequest, "url required")
281288 return
282289 }
283290
@@ -288,7 +295,7 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
288295 if ok {
289296 if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {
290297 s.logger.Error("failed to delete subscription from PDS", "error", delErr)
291- http.Error(w, "failed to delete subscription from PDS: "+delErr.Error(), http.StatusInternalServerError)
298+ writeAPIError(w, http.StatusInternalServerError, "failed to delete subscription from PDS: "+delErr.Error())
292299 return
293300 }
294301 }
@@ -297,11 +304,11 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
297304
298305 if err := s.dbs.Articles.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil {
299306 s.logger.Error("failed to delete subscription", "error", err)
300- http.Error(w, err.Error(), http.StatusInternalServerError)
307+ writeAPIError(w, http.StatusInternalServerError, err.Error())
301308 return
302309 }
303310
304- w.WriteHeader(http.StatusOK)
311+ w.WriteHeader(http.StatusNoContent)
305312 }
306313
307314 func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Request) {
@@ -318,7 +325,7 @@ func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Requ
318325 if ok {
319326 if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {
320327 s.logger.Error("failed to delete subscription from PDS", "error", delErr, "uri", sub.URI.String)
321- http.Error(w, "failed to delete subscription from PDS: "+delErr.Error(), http.StatusInternalServerError)
328+ writeAPIError(w, http.StatusInternalServerError, "failed to delete subscription from PDS: "+delErr.Error())
322329 return
323330 }
324331 }
@@ -328,26 +335,25 @@ func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Requ
328335
329336 if err := s.dbs.Articles.DeleteAllSubscriptions(r.Context(), user.DID); err != nil {
330337 s.logger.Error("failed to clear subscriptions", "error", err)
331- http.Error(w, err.Error(), http.StatusInternalServerError)
338+ writeAPIError(w, http.StatusInternalServerError, err.Error())
332339 return
333340 }
334341
335- w.Header().Set(HXRedirect, "/feeds")
336- w.WriteHeader(http.StatusOK)
342+ w.WriteHeader(http.StatusNoContent)
337343 }
338344
339345 func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
340346 user := currentUser(r)
341347 file, _, err := r.FormFile("opml")
342348 if err != nil {
343- http.Error(w, err.Error(), http.StatusBadRequest)
349+ writeAPIError(w, http.StatusBadRequest, err.Error())
344350 return
345351 }
346352 defer file.Close()
347353
348354 opml, err := feed.ParseOPML(file)
349355 if err != nil {
350- http.Error(w, err.Error(), http.StatusBadRequest)
356+ writeAPIError(w, http.StatusBadRequest, err.Error())
351357 return
352358 }
353359
@@ -416,8 +422,7 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
416422 }
417423 }()
418424
419- w.Header().Set(HXRedirect, "/feeds")
420- w.WriteHeader(http.StatusOK)
425+ writeJSON(w, http.StatusOK, opmlUploadResponse{Added: added})
421426 }
422427
423428 func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
@@ -438,7 +443,7 @@ func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
438443
439444 data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions")
440445 if err != nil {
441- http.Error(w, err.Error(), http.StatusInternalServerError)
446+ writeAPIError(w, http.StatusInternalServerError, err.Error())
442447 return
443448 }
444449
@@ -454,10 +459,7 @@ func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) {
454459 if err != nil {
455460 s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID)
456461 }
457- s.render(w, r, "feed-list.html", map[string]any{
458- "User": user,
459- "Subscriptions": subs,
460- })
462+ writeJSON(w, http.StatusOK, feedListResponse{Subscriptions: toSubscriptions(subs)})
461463 }
462464
463465 func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
@@ -471,10 +473,7 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
471473 if err != nil {
472474 s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID)
473475 }
474- s.render(w, r, "feed-list.html", map[string]any{
475- "User": user,
476- "Subscriptions": subs,
477- })
476+ writeJSON(w, http.StatusOK, feedListResponse{Subscriptions: toSubscriptions(subs)})
478477 }
479478
480479 func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) {
@@ -504,13 +503,13 @@ func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) {
504503 func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) {
505504 feedURL := r.FormValue("url")
506505 if feedURL == "" {
507- http.Error(w, "url required", http.StatusBadRequest)
506+ writeAPIError(w, http.StatusBadRequest, "url required")
508507 return
509508 }
510509
511510 f, err := s.dbs.Articles.GetFeed(r.Context(), feedURL)
512511 if err != nil {
513- http.Error(w, "feed not found", http.StatusNotFound)
512+ writeAPIError(w, http.StatusNotFound, "feed not found")
514513 return
515514 }
516515
@@ -522,13 +521,5 @@ func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) {
522521 if err != nil {
523522 s.logger.Warn("failed to list dead feeds", "error", err, "did", user.DID)
524523 }
525- if len(deadFeeds) == 0 {
526- w.Header().Set("Content-Type", "text/html")
527- w.Write([]byte(""))
528- return
529- }
530-
531- s.render(w, r, "dead-feeds.html", map[string]any{
532- "DeadFeeds": deadFeeds,
533- })
524+ writeJSON(w, http.StatusOK, deadFeedsResponse{DeadFeeds: toFeeds(deadFeeds)})
534525 }
@@ -3,6 +3,7 @@ package server
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "net/http"7 "net/http"
7 "net/url"8 "net/url"
8 "time"9 "time"
@@ -72,23 +73,35 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
72 return nil73 return nil
73 })74 })
74 75
75- if err := g.Wait(); err != nil {76+ _ = g.Wait()
76- s.logger.Warn("feeds error", "error", err, "did", user.DID)
77- }
78 77
79- s.render(w, r, "feeds.html", map[string]any{78+ writeJSON(w, http.StatusOK, feedsResponse{
80- "User": user,79+ User: toUser(user),
81- "Subscriptions": subs,80+ Subscriptions: toSubscriptions(subs),
82- "SubscriptionCount": subCount,81+ SubscriptionCount: subCount,
83- "Categories": categories,82+ Categories: nonNil(categories),
84- "Category": category,83+ Category: category,
85- "DeadFeeds": deadFeeds,84+ DeadFeeds: toFeeds(deadFeeds),
86- "Page": page,85+ Pagination: page,
87- "BaseURL": "/feeds",
88- "QueryParams": buildQueryParams(map[string]string{"category": category}),
89 })86 })
90 }87 }
91 88
89+func toSubscriptions(subs []*db.Subscription) []Subscription {
90+ out := make([]Subscription, len(subs))
91+ for i, s := range subs {
92+ out[i] = toSubscription(s)
93+ }
94+ return out
95+}
96+
97+func toFeeds(feeds []*db.Feed) []Feed {
98+ out := make([]Feed, len(feeds))
99+ for i, f := range feeds {
100+ out[i] = toFeed(f)
101+ }
102+ return out
103+}
104+
92 func (s *Server) storeFetchResult(ctx context.Context, feedURL, siteURL string, result *feed.ParseResult) {105 func (s *Server) storeFetchResult(ctx context.Context, feedURL, siteURL string, result *feed.ParseResult) {
93 if result == nil {106 if result == nil {
94 _ = s.dbs.Articles.MarkFeedFetched(ctx, feedURL)107 _ = s.dbs.Articles.MarkFeedFetched(ctx, feedURL)
@@ -115,22 +128,21 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
115 category := r.FormValue("category")128 category := r.FormValue("category")
116 129
117 if feedURL == "" {130 if feedURL == "" {
118- http.Error(w, "url required", http.StatusBadRequest)131+ writeAPIError(w, http.StatusBadRequest, "url required")
119 return132 return
120 }133 }
121 134
122 if !atproto.IsATProtoFeedURL(feedURL) {135 if !atproto.IsATProtoFeedURL(feedURL) {
123- u, err := url.Parse(feedURL)136+ if err := validateHTTPURL(feedURL); err != nil {
124- if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {137+ writeAPIError(w, http.StatusBadRequest, err.Error())
125- http.Error(w, "invalid feed URL", http.StatusBadRequest)
126 return138 return
127 }139 }
128 }140 }
129 141
130- result, feedURL, err := s.fetcher.FetchOrDiscover(r.Context(), feedURL)142+ result, resolvedURL, err := s.fetcher.FetchOrDiscover(r.Context(), feedURL)
131 if err != nil {143 if err != nil {
132 s.logger.Error("failed to fetch feed", "error", err, "url", feedURL)144 s.logger.Error("failed to fetch feed", "error", err, "url", feedURL)
133- http.Error(w, err.Error(), http.StatusInternalServerError)145+ writeAPIError(w, http.StatusInternalServerError, err.Error())
134 return146 return
135 }147 }
136 148
@@ -141,7 +153,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
141 }153 }
142 154
143 f := &db.Feed{155 f := &db.Feed{
144- FeedURL: feedURL,156+ FeedURL: resolvedURL,
145 Title: db.NullStr(feedTitle),157 Title: db.NullStr(feedTitle),
146 SiteURL: db.NullStr(result.Feed.SiteURL),158 SiteURL: db.NullStr(result.Feed.SiteURL),
147 Description: db.NullStr(result.Feed.Description),159 Description: db.NullStr(result.Feed.Description),
@@ -150,7 +162,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
150 }162 }
151 if err := s.dbs.Articles.UpsertFeed(r.Context(), f); err != nil {163 if err := s.dbs.Articles.UpsertFeed(r.Context(), f); err != nil {
152 s.logger.Error("failed to upsert feed", "error", err)164 s.logger.Error("failed to upsert feed", "error", err)
153- http.Error(w, err.Error(), http.StatusInternalServerError)165+ writeAPIError(w, http.StatusInternalServerError, err.Error())
154 return166 return
155 }167 }
156 168
@@ -158,54 +170,60 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) {
158 if client := s.pdsClientForUser(r); client != nil {170 if client := s.pdsClientForUser(r); client != nil {
159 record := atproto.SubscriptionRecord{171 record := atproto.SubscriptionRecord{
160 CreatedAt: time.Now().Format(time.RFC3339),172 CreatedAt: time.Now().Format(time.RFC3339),
161- FeedURL: feedURL,173+ FeedURL: resolvedURL,
162 Title: feedTitle,174 Title: feedTitle,
163 Category: category,175 Category: category,
164 }176 }
165 uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record)177 uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record)
166 if err != nil {178 if err != nil {
167 s.logger.Error("failed to write subscription to PDS", "error", err)179 s.logger.Error("failed to write subscription to PDS", "error", err)
168- http.Error(w, "failed to write subscription to PDS: "+err.Error(), http.StatusInternalServerError)180+ writeAPIError(w, http.StatusInternalServerError, "failed to write subscription to PDS: "+err.Error())
169 return181 return
170 }182 }
171 subURI = uri183 subURI = uri
172 subCID = cid184 subCID = cid
173 }185 }
174 186
175- if err := s.dbs.Articles.CreateSubscription(r.Context(), user.DID, feedURL, feedTitle, category, subURI, subCID); err != nil {187+ if err := s.dbs.Articles.CreateSubscription(r.Context(), user.DID, resolvedURL, feedTitle, category, subURI, subCID); err != nil {
176 if errors.Is(err, db.ErrDuplicateSubscription) {188 if errors.Is(err, db.ErrDuplicateSubscription) {
177- http.Error(w, "Already subscribed to this feed.", http.StatusConflict)189+ writeAPIError(w, http.StatusConflict, "Already subscribed to this feed.")
178 return190 return
179 }191 }
180 s.logger.Error("failed to create subscription", "error", err)192 s.logger.Error("failed to create subscription", "error", err)
181- http.Error(w, err.Error(), http.StatusInternalServerError)193+ writeAPIError(w, http.StatusInternalServerError, err.Error())
182 return194 return
183 }195 }
184 196
185- go s.storeFetchResult(context.WithoutCancel(r.Context()), feedURL, result.Feed.SiteURL, result)197+ go s.storeFetchResult(context.WithoutCancel(r.Context()), resolvedURL, result.Feed.SiteURL, result)
186 198
187 s.engine.InvalidateFeedCache(user.DID)199 s.engine.InvalidateFeedCache(user.DID)
188- if err := s.feedback.MarkImpressionActed(r.Context(), user.DID, "feed", feedURL); err != nil {200+ if err := s.feedback.MarkImpressionActed(r.Context(), user.DID, "feed", resolvedURL); err != nil {
189 s.logger.Warn("failed to mark impression acted", "error", err)201 s.logger.Warn("failed to mark impression acted", "error", err)
190 }202 }
191 sig := s.engine.GetDominantSignal(s.engine.GetWeights(r.Context(), user.DID))203 sig := s.engine.GetDominantSignal(s.engine.GetWeights(r.Context(), user.DID))
192 s.engine.RewardSignal(r.Context(), user.DID, sig)204 s.engine.RewardSignal(r.Context(), user.DID, sig)
193 205
194- sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)206+ sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, resolvedURL)
195 if err != nil {207 if err != nil {
196- s.logger.Warn("failed to get subscription", "error", err)
197 w.WriteHeader(http.StatusNoContent)208 w.WriteHeader(http.StatusNoContent)
198 return209 return
199 }210 }
200- s.render(w, r, "feed-item.html", map[string]any{211+ writeJSON(w, http.StatusOK, subscriptionResponse{Subscription: toSubscription(sub)})
201- "User": user,212+}
202- "ID": sub.ID,213+
203- "FeedURL": sub.FeedURL,214+// validateHTTPURL checks that a feed URL has an http(s) scheme and a host.
204- "FeedTitle": sub.FeedTitle,215+func validateHTTPURL(rawURL string) error {
205- "Category": sub.Category,216+ u, err := url.Parse(rawURL)
206- "FaviconURL": sub.FaviconURL,217+ if err != nil {
207- "UnreadCount": sub.UnreadCount,218+ return fmt.Errorf("invalid feed URL")
208- })219+ }
220+ if u.Scheme != "http" && u.Scheme != "https" {
221+ return fmt.Errorf("invalid feed URL")
222+ }
223+ if u.Host == "" {
224+ return fmt.Errorf("invalid feed URL")
225+ }
226+ return nil
209 }227 }
210 228
211 func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {229 func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
@@ -214,14 +232,13 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
214 category := r.FormValue("category")232 category := r.FormValue("category")
215 233
216 if feedURL == "" {234 if feedURL == "" {
217- http.Error(w, "url required", http.StatusBadRequest)235+ writeAPIError(w, http.StatusBadRequest, "url required")
218 return236 return
219 }237 }
220 238
221 sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)239 sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)
222 if err != nil {240 if err != nil {
223- s.logger.Error("failed to get subscription", "error", err)241+ writeAPIError(w, http.StatusNotFound, "subscription not found")
224- http.Error(w, "subscription not found", http.StatusNotFound)
225 return242 return
226 }243 }
227 244
@@ -240,7 +257,7 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
240 _, newCID, putErr := client.PutRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey, record)257 _, newCID, putErr := client.PutRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey, record)
241 if putErr != nil {258 if putErr != nil {
242 s.logger.Error("failed to put subscription record on PDS", "error", putErr)259 s.logger.Error("failed to put subscription record on PDS", "error", putErr)
243- http.Error(w, "failed to update subscription on PDS: "+putErr.Error(), http.StatusInternalServerError)260+ writeAPIError(w, http.StatusInternalServerError, "failed to update subscription on PDS: "+putErr.Error())
244 return261 return
245 }262 }
246 cid = newCID263 cid = newCID
@@ -250,26 +267,16 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) {
250 267
251 if err := s.dbs.Articles.UpdateSubscription(r.Context(), user.DID, feedURL, sub.FeedTitle, category, sub.URI.String, cid); err != nil {268 if err := s.dbs.Articles.UpdateSubscription(r.Context(), user.DID, feedURL, sub.FeedTitle, category, sub.URI.String, cid); err != nil {
252 s.logger.Error("failed to update subscription", "error", err)269 s.logger.Error("failed to update subscription", "error", err)
253- http.Error(w, err.Error(), http.StatusInternalServerError)270+ writeAPIError(w, http.StatusInternalServerError, err.Error())
254 return271 return
255 }272 }
256 273
257 updated, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)274 updated, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL)
258 if err != nil {275 if err != nil {
259- s.logger.Warn("failed to get updated subscription", "error", err)
260 w.WriteHeader(http.StatusNoContent)276 w.WriteHeader(http.StatusNoContent)
261 return277 return
262 }278 }
263-279+ writeJSON(w, http.StatusOK, subscriptionResponse{Subscription: toSubscription(updated)})
264- s.render(w, r, "feed-item.html", map[string]any{
265- "User": user,
266- "ID": updated.ID,
267- "FeedURL": updated.FeedURL,
268- "FeedTitle": updated.FeedTitle,
269- "Category": updated.Category,
270- "FaviconURL": updated.FaviconURL,
271- "UnreadCount": updated.UnreadCount,
272- })
273 }280 }
274 281
275 func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {282 func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
@@ -277,7 +284,7 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
277 feedURL := r.FormValue("url")284 feedURL := r.FormValue("url")
278 285
279 if feedURL == "" {286 if feedURL == "" {
280- http.Error(w, "url required", http.StatusBadRequest)287+ writeAPIError(w, http.StatusBadRequest, "url required")
281 return288 return
282 }289 }
283 290
@@ -288,7 +295,7 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
288 if ok {295 if ok {
289 if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {296 if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {
290 s.logger.Error("failed to delete subscription from PDS", "error", delErr)297 s.logger.Error("failed to delete subscription from PDS", "error", delErr)
291- http.Error(w, "failed to delete subscription from PDS: "+delErr.Error(), http.StatusInternalServerError)298+ writeAPIError(w, http.StatusInternalServerError, "failed to delete subscription from PDS: "+delErr.Error())
292 return299 return
293 }300 }
294 }301 }
@@ -297,11 +304,11 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) {
297 304
298 if err := s.dbs.Articles.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil {305 if err := s.dbs.Articles.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil {
299 s.logger.Error("failed to delete subscription", "error", err)306 s.logger.Error("failed to delete subscription", "error", err)
300- http.Error(w, err.Error(), http.StatusInternalServerError)307+ writeAPIError(w, http.StatusInternalServerError, err.Error())
301 return308 return
302 }309 }
303 310
304- w.WriteHeader(http.StatusOK)311+ w.WriteHeader(http.StatusNoContent)
305 }312 }
306 313
307 func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Request) {314 func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Request) {
@@ -318,7 +325,7 @@ func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Requ
318 if ok {325 if ok {
319 if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {326 if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil {
320 s.logger.Error("failed to delete subscription from PDS", "error", delErr, "uri", sub.URI.String)327 s.logger.Error("failed to delete subscription from PDS", "error", delErr, "uri", sub.URI.String)
321- http.Error(w, "failed to delete subscription from PDS: "+delErr.Error(), http.StatusInternalServerError)328+ writeAPIError(w, http.StatusInternalServerError, "failed to delete subscription from PDS: "+delErr.Error())
322 return329 return
323 }330 }
324 }331 }
@@ -328,26 +335,25 @@ func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Requ
328 335
329 if err := s.dbs.Articles.DeleteAllSubscriptions(r.Context(), user.DID); err != nil {336 if err := s.dbs.Articles.DeleteAllSubscriptions(r.Context(), user.DID); err != nil {
330 s.logger.Error("failed to clear subscriptions", "error", err)337 s.logger.Error("failed to clear subscriptions", "error", err)
331- http.Error(w, err.Error(), http.StatusInternalServerError)338+ writeAPIError(w, http.StatusInternalServerError, err.Error())
332 return339 return
333 }340 }
334 341
335- w.Header().Set(HXRedirect, "/feeds")342+ w.WriteHeader(http.StatusNoContent)
336- w.WriteHeader(http.StatusOK)
337 }343 }
338 344
339 func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {345 func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
340 user := currentUser(r)346 user := currentUser(r)
341 file, _, err := r.FormFile("opml")347 file, _, err := r.FormFile("opml")
342 if err != nil {348 if err != nil {
343- http.Error(w, err.Error(), http.StatusBadRequest)349+ writeAPIError(w, http.StatusBadRequest, err.Error())
344 return350 return
345 }351 }
346 defer file.Close()352 defer file.Close()
347 353
348 opml, err := feed.ParseOPML(file)354 opml, err := feed.ParseOPML(file)
349 if err != nil {355 if err != nil {
350- http.Error(w, err.Error(), http.StatusBadRequest)356+ writeAPIError(w, http.StatusBadRequest, err.Error())
351 return357 return
352 }358 }
353 359
@@ -416,8 +422,7 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) {
416 }422 }
417 }()423 }()
418 424
419- w.Header().Set(HXRedirect, "/feeds")425+ writeJSON(w, http.StatusOK, opmlUploadResponse{Added: added})
420- w.WriteHeader(http.StatusOK)
421 }426 }
422 427
423 func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {428 func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
@@ -438,7 +443,7 @@ func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) {
438 443
439 data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions")444 data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions")
440 if err != nil {445 if err != nil {
441- http.Error(w, err.Error(), http.StatusInternalServerError)446+ writeAPIError(w, http.StatusInternalServerError, err.Error())
442 return447 return
443 }448 }
444 449
@@ -454,10 +459,7 @@ func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) {
454 if err != nil {459 if err != nil {
455 s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID)460 s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID)
456 }461 }
457- s.render(w, r, "feed-list.html", map[string]any{462+ writeJSON(w, http.StatusOK, feedListResponse{Subscriptions: toSubscriptions(subs)})
458- "User": user,
459- "Subscriptions": subs,
460- })
461 }463 }
462 464
463 func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {465 func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
@@ -471,10 +473,7 @@ func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) {
471 if err != nil {473 if err != nil {
472 s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID)474 s.logger.Warn("failed to list subscriptions", "error", err, "did", user.DID)
473 }475 }
474- s.render(w, r, "feed-list.html", map[string]any{476+ writeJSON(w, http.StatusOK, feedListResponse{Subscriptions: toSubscriptions(subs)})
475- "User": user,
476- "Subscriptions": subs,
477- })
478 }477 }
479 478
480 func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) {479 func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) {
@@ -504,13 +503,13 @@ func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) {
504 func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) {503 func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) {
505 feedURL := r.FormValue("url")504 feedURL := r.FormValue("url")
506 if feedURL == "" {505 if feedURL == "" {
507- http.Error(w, "url required", http.StatusBadRequest)506+ writeAPIError(w, http.StatusBadRequest, "url required")
508 return507 return
509 }508 }
510 509
511 f, err := s.dbs.Articles.GetFeed(r.Context(), feedURL)510 f, err := s.dbs.Articles.GetFeed(r.Context(), feedURL)
512 if err != nil {511 if err != nil {
513- http.Error(w, "feed not found", http.StatusNotFound)512+ writeAPIError(w, http.StatusNotFound, "feed not found")
514 return513 return
515 }514 }
516 515
@@ -522,13 +521,5 @@ func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) {
522 if err != nil {521 if err != nil {
523 s.logger.Warn("failed to list dead feeds", "error", err, "did", user.DID)522 s.logger.Warn("failed to list dead feeds", "error", err, "did", user.DID)
524 }523 }
525- if len(deadFeeds) == 0 {524+ writeJSON(w, http.StatusOK, deadFeedsResponse{DeadFeeds: toFeeds(deadFeeds)})
526- w.Header().Set("Content-Type", "text/html")
527- w.Write([]byte(""))
528- return
529- }
530-
531- s.render(w, r, "dead-feeds.html", map[string]any{
532- "DeadFeeds": deadFeeds,
533- })
534 }525 }
deleted internal/server/htmx.go +0 -13
deleted file mode 100644
@@ -1,13 +0,0 @@
1-package server
2-
3-import "net/http"
4-
5-const (
6- HXRequest = "HX-Request"
7- HXRedirect = "HX-Redirect"
8- HXRefresh = "HX-Refresh"
9-)
10-
11-func isHXRequest(r *http.Request) bool {
12- return r.Header.Get(HXRequest) == "true"
13-}
deleted file mode 100644
@@ -1,13 +0,0 @@
1-package server
2-
3-import "net/http"
4-
5-const (
6- HXRequest = "HX-Request"
7- HXRedirect = "HX-Redirect"
8- HXRefresh = "HX-Refresh"
9-)
10-
11-func isHXRequest(r *http.Request) bool {
12- return r.Header.Get(HXRequest) == "true"
13-}
deleted internal/server/index_handler.go +0 -12
deleted file mode 100644
@@ -1,12 +0,0 @@
1-package server
2-
3-import "net/http"
4-
5-func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
6- user := s.getUserFromSession(r)
7- if user != nil {
8- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
9- return
10- }
11- s.render(w, r, "index.html", map[string]any{})
12-}
deleted file mode 100644
@@ -1,12 +0,0 @@
1-package server
2-
3-import "net/http"
4-
5-func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
6- user := s.getUserFromSession(r)
7- if user != nil {
8- http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
9- return
10- }
11- s.render(w, r, "index.html", map[string]any{})
12-}
modified internal/server/middleware.go +25 -12
@@ -38,10 +38,12 @@ func (s *Server) isOAuthSessionValid(ctx context.Context, data *sessionData) boo
3838 return err == nil
3939 }
4040
41+// requireAuth gates API routes. Returns 401 JSON so the SvelteKit load layer
42+// can redirect unauthenticated users to the login page.
4143 func (s *Server) requireAuth(next http.Handler) http.Handler {
4244 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4345 if currentUser(r) == nil {
44- http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
46+ writeAPIError(w, http.StatusUnauthorized, "authentication required")
4547 return
4648 }
4749 next.ServeHTTP(w, r)
@@ -56,6 +58,9 @@ func csrfToken() string {
5658 return hex.EncodeToString(b)
5759 }
5860
61+// csrfMiddleware enforces double-submit CSRF. The token is issued in a readable
62+// cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or
63+// csrf_token form field on every state-changing request.
5964 func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
6065 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6166 if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
@@ -66,6 +71,7 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
6671 Path: "/",
6772 MaxAge: 86400,
6873 HttpOnly: false,
74+ Secure: s.secureCookies,
6975 SameSite: http.SameSiteLaxMode,
7076 })
7177 }
@@ -73,33 +79,40 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
7379 return
7480 }
7581
76- if isHXRequest(r) {
77- origin := r.Header.Get("Origin")
78- if origin != "" && !sameOrigin(origin, r.Host) {
79- http.Error(w, "forbidden", http.StatusForbidden)
80- return
81- }
82- next.ServeHTTP(w, r)
82+ // Origin must match the configured allowlist, not the proxied Host header.
83+ if !s.originAllowed(r) {
84+ writeAPIError(w, http.StatusForbidden, "forbidden")
8385 return
8486 }
8587
8688 cookie, err := r.Cookie("glean_csrf")
8789 if err != nil {
88- http.Error(w, "missing csrf token", http.StatusForbidden)
90+ writeAPIError(w, http.StatusForbidden, "missing csrf token")
8991 return
9092 }
91- formToken := r.FormValue("csrf_token")
93+ formToken := r.Header.Get("X-CSRF-Token")
9294 if formToken == "" {
93- formToken = r.Header.Get("X-CSRF-Token")
95+ formToken = r.FormValue("csrf_token")
9496 }
9597 if formToken == "" || formToken != cookie.Value {
96- http.Error(w, "csrf mismatch", http.StatusForbidden)
98+ writeAPIError(w, http.StatusForbidden, "csrf mismatch")
9799 return
98100 }
99101 next.ServeHTTP(w, r)
100102 })
101103 }
102104
105+func (s *Server) originAllowed(r *http.Request) bool {
106+ origin := r.Header.Get("Origin")
107+ if origin == "" {
108+ return true // non-browser client
109+ }
110+ if s.allowedOrigin != "" {
111+ return origin == s.allowedOrigin
112+ }
113+ return sameOrigin(origin, r.Host)
114+}
115+
103116 func sameOrigin(origin, host string) bool {
104117 u, err := url.Parse(origin)
105118 if err != nil {
@@ -38,10 +38,12 @@ func (s *Server) isOAuthSessionValid(ctx context.Context, data *sessionData) boo
38 return err == nil38 return err == nil
39 }39 }
40 40
41+// requireAuth gates API routes. Returns 401 JSON so the SvelteKit load layer
42+// can redirect unauthenticated users to the login page.
41 func (s *Server) requireAuth(next http.Handler) http.Handler {43 func (s *Server) requireAuth(next http.Handler) http.Handler {
42 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {44 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43 if currentUser(r) == nil {45 if currentUser(r) == nil {
44- http.Redirect(w, r, "/auth/login", http.StatusSeeOther)46+ writeAPIError(w, http.StatusUnauthorized, "authentication required")
45 return47 return
46 }48 }
47 next.ServeHTTP(w, r)49 next.ServeHTTP(w, r)
@@ -56,6 +58,9 @@ func csrfToken() string {
56 return hex.EncodeToString(b)58 return hex.EncodeToString(b)
57 }59 }
58 60
61+// csrfMiddleware enforces double-submit CSRF. The token is issued in a readable
62+// cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or
63+// csrf_token form field on every state-changing request.
59 func (s *Server) csrfMiddleware(next http.Handler) http.Handler {64 func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
60 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {65 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61 if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {66 if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
@@ -66,6 +71,7 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
66 Path: "/",71 Path: "/",
67 MaxAge: 86400,72 MaxAge: 86400,
68 HttpOnly: false,73 HttpOnly: false,
74+ Secure: s.secureCookies,
69 SameSite: http.SameSiteLaxMode,75 SameSite: http.SameSiteLaxMode,
70 })76 })
71 }77 }
@@ -73,33 +79,40 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
73 return79 return
74 }80 }
75 81
76- if isHXRequest(r) {82+ // Origin must match the configured allowlist, not the proxied Host header.
77- origin := r.Header.Get("Origin")83+ if !s.originAllowed(r) {
78- if origin != "" && !sameOrigin(origin, r.Host) {84+ writeAPIError(w, http.StatusForbidden, "forbidden")
79- http.Error(w, "forbidden", http.StatusForbidden)
80- return
81- }
82- next.ServeHTTP(w, r)
83 return85 return
84 }86 }
85 87
86 cookie, err := r.Cookie("glean_csrf")88 cookie, err := r.Cookie("glean_csrf")
87 if err != nil {89 if err != nil {
88- http.Error(w, "missing csrf token", http.StatusForbidden)90+ writeAPIError(w, http.StatusForbidden, "missing csrf token")
89 return91 return
90 }92 }
91- formToken := r.FormValue("csrf_token")93+ formToken := r.Header.Get("X-CSRF-Token")
92 if formToken == "" {94 if formToken == "" {
93- formToken = r.Header.Get("X-CSRF-Token")95+ formToken = r.FormValue("csrf_token")
94 }96 }
95 if formToken == "" || formToken != cookie.Value {97 if formToken == "" || formToken != cookie.Value {
96- http.Error(w, "csrf mismatch", http.StatusForbidden)98+ writeAPIError(w, http.StatusForbidden, "csrf mismatch")
97 return99 return
98 }100 }
99 next.ServeHTTP(w, r)101 next.ServeHTTP(w, r)
100 })102 })
101 }103 }
102 104
105+func (s *Server) originAllowed(r *http.Request) bool {
106+ origin := r.Header.Get("Origin")
107+ if origin == "" {
108+ return true // non-browser client
109+ }
110+ if s.allowedOrigin != "" {
111+ return origin == s.allowedOrigin
112+ }
113+ return sameOrigin(origin, r.Host)
114+}
115+
103 func sameOrigin(origin, host string) bool {116 func sameOrigin(origin, host string) bool {
104 u, err := url.Parse(origin)117 u, err := url.Parse(origin)
105 if err != nil {118 if err != nil {
modified internal/server/pagination.go +6 -6
@@ -8,12 +8,12 @@ import (
88 const defaultPageSize = 25
99
1010 type Pagination struct {
11- Page int
12- PageSize int
13- HasPrev bool
14- HasNext bool
15- PrevPage int
16- NextPage int
11+ Page int `json:"page"`
12+ PageSize int `json:"page_size"`
13+ HasPrev bool `json:"has_prev"`
14+ HasNext bool `json:"has_next"`
15+ PrevPage int `json:"prev_page"`
16+ NextPage int `json:"next_page"`
1717 }
1818
1919 func pageFromRequest(r *http.Request, pageSize int) Pagination {
@@ -8,12 +8,12 @@ import (
8 const defaultPageSize = 258 const defaultPageSize = 25
9 9
10 type Pagination struct {10 type Pagination struct {
11- Page int11+ Page int `json:"page"`
12- PageSize int12+ PageSize int `json:"page_size"`
13- HasPrev bool13+ HasPrev bool `json:"has_prev"`
14- HasNext bool14+ HasNext bool `json:"has_next"`
15- PrevPage int15+ PrevPage int `json:"prev_page"`
16- NextPage int16+ NextPage int `json:"next_page"`
17 }17 }
18 18
19 func pageFromRequest(r *http.Request, pageSize int) Pagination {19 func pageFromRequest(r *http.Request, pageSize int) Pagination {
modified internal/server/profile_handler.go +29 -68
@@ -5,7 +5,6 @@ import (
55 "strings"
66
77 "github.com/go-chi/chi/v5"
8- "golang.org/x/sync/errgroup"
98
109 "pkg.rbrt.fr/glean/internal/atproto"
1110 "pkg.rbrt.fr/glean/internal/db"
@@ -23,7 +22,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
2322 resolved, err := atproto.ResolveHandle(ctx, param)
2423 if err != nil {
2524 s.logger.Warn("failed to resolve handle", "error", err, "handle", param)
26- s.renderError(w, r, http.StatusNotFound, "Handle not found", "Could not find a user with that handle.")
25+ writeAPIError(w, http.StatusNotFound, "handle not found")
2726 return
2827 }
2928 did = resolved
@@ -32,7 +31,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
3231 profileUser, err := s.dbs.Users.GetUser(ctx, did)
3332 if err != nil {
3433 s.logger.Warn("failed to get user", "error", err, "did", did)
35- s.renderError(w, r, http.StatusNotFound, "User not found", "This user doesn't exist in Glean yet.")
34+ writeAPIError(w, http.StatusNotFound, "user not found")
3635 return
3736 }
3837
@@ -41,76 +40,38 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
4140 profileUser.DisplayName = p.DisplayName
4241 profileUser.AvatarURL = p.AvatarURL
4342
44- var (
45- subs []*db.Subscription
46- annotations []*db.Annotation
47- subCount int
48- userLangs []string
49- )
50-
5143 user := currentUser(r)
5244
53- g, gCtx := errgroup.WithContext(ctx)
54-
55- g.Go(func() error {
56- var err error
57- subs, err = s.dbs.Articles.ListSubscriptions(gCtx, did, "", 50, 0)
58- if err != nil {
59- s.logger.Warn("failed to list subscriptions", "error", err, "did", did)
60- }
61- return nil
62- })
63-
64- g.Go(func() error {
65- var err error
66- annotations, err = s.dbs.Articles.ListAnnotations(gCtx, "", "", did, 50, 0)
67- if err != nil {
68- s.logger.Warn("failed to list annotations", "error", err, "did", did)
69- return nil
70- }
71- resolveAnnotationHandles(gCtx, annotations)
72- return nil
73- })
74-
75- g.Go(func() error {
76- var err error
77- subCount, err = s.dbs.Articles.GetSubscriptionCount(gCtx, did)
78- if err != nil {
79- s.logger.Warn("failed to get subscription count", "error", err, "did", did)
80- }
81- return nil
82- })
45+ subs, _ := s.dbs.Articles.ListSubscriptions(ctx, did, "", 50, 0)
46+ annotations, _ := s.dbs.Articles.ListAnnotations(ctx, "", "", did, 50, 0)
47+ resolveAnnotationHandles(ctx, annotations)
48+ subCount, _ := s.dbs.Articles.GetSubscriptionCount(ctx, did)
49+ userLangs, _ := s.dbs.Users.GetLanguages(ctx, user.DID)
8350
84- g.Go(func() error {
85- userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID)
86- return nil
87- })
51+ var expandedView, digestEnabled bool
52+ if settings, err := s.dbs.Users.GetSettings(ctx, user.DID); err == nil && settings != nil {
53+ expandedView = settings.ExpandedView
54+ digestEnabled = settings.DigestEnabled
55+ }
8856
89- userSettings := &db.UserSettings{}
90- g.Go(func() error {
91- var err error
92- userSettings, err = s.dbs.Users.GetSettings(gCtx, user.DID)
93- if err != nil {
94- s.logger.Warn("failed to get user settings", "error", err, "did", user.DID)
95- }
96- return nil
57+ writeJSON(w, http.StatusOK, profileResponse{
58+ User: toUser(user),
59+ ProfileUser: toUser(profileUser),
60+ Subscriptions: toSubscriptions(subs),
61+ Annotations: toAnnotations(annotations),
62+ SubscriptionCount: subCount,
63+ AnnotationCount: len(annotations),
64+ UserLanguages: nonNil(userLangs),
65+ AvailableLanguages: ml.KnownLanguages(),
66+ ExpandedView: expandedView,
67+ DigestEnabled: digestEnabled,
9768 })
69+}
9870
99- if err := g.Wait(); err != nil {
100- s.logger.Warn("profile error", "error", err, "did", did)
71+func toAnnotations(annotations []*db.Annotation) []Annotation {
72+ out := make([]Annotation, len(annotations))
73+ for i, a := range annotations {
74+ out[i] = toAnnotation(a)
10175 }
102-
103- s.render(w, r, "profile.html", map[string]any{
104- "User": user,
105- "CurrentUserDID": user.DID,
106- "ProfileUser": profileUser,
107- "Subscriptions": subs,
108- "Annotations": annotations,
109- "SubscriptionCount": subCount,
110- "AnnotationCount": len(annotations),
111- "UserLanguages": userLangs,
112- "AvailableLanguages": ml.KnownLanguages(),
113- "ExpandedView": userSettings.ExpandedView,
114- "DigestEnabled": userSettings.DigestEnabled,
115- })
76+ return out
11677 }
@@ -5,7 +5,6 @@ import (
5 "strings"5 "strings"
6 6
7 "github.com/go-chi/chi/v5"7 "github.com/go-chi/chi/v5"
8- "golang.org/x/sync/errgroup"
9 8
10 "pkg.rbrt.fr/glean/internal/atproto"9 "pkg.rbrt.fr/glean/internal/atproto"
11 "pkg.rbrt.fr/glean/internal/db"10 "pkg.rbrt.fr/glean/internal/db"
@@ -23,7 +22,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
23 resolved, err := atproto.ResolveHandle(ctx, param)22 resolved, err := atproto.ResolveHandle(ctx, param)
24 if err != nil {23 if err != nil {
25 s.logger.Warn("failed to resolve handle", "error", err, "handle", param)24 s.logger.Warn("failed to resolve handle", "error", err, "handle", param)
26- s.renderError(w, r, http.StatusNotFound, "Handle not found", "Could not find a user with that handle.")25+ writeAPIError(w, http.StatusNotFound, "handle not found")
27 return26 return
28 }27 }
29 did = resolved28 did = resolved
@@ -32,7 +31,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
32 profileUser, err := s.dbs.Users.GetUser(ctx, did)31 profileUser, err := s.dbs.Users.GetUser(ctx, did)
33 if err != nil {32 if err != nil {
34 s.logger.Warn("failed to get user", "error", err, "did", did)33 s.logger.Warn("failed to get user", "error", err, "did", did)
35- s.renderError(w, r, http.StatusNotFound, "User not found", "This user doesn't exist in Glean yet.")34+ writeAPIError(w, http.StatusNotFound, "user not found")
36 return35 return
37 }36 }
38 37
@@ -41,76 +40,38 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
41 profileUser.DisplayName = p.DisplayName40 profileUser.DisplayName = p.DisplayName
42 profileUser.AvatarURL = p.AvatarURL41 profileUser.AvatarURL = p.AvatarURL
43 42
44- var (
45- subs []*db.Subscription
46- annotations []*db.Annotation
47- subCount int
48- userLangs []string
49- )
50-
51 user := currentUser(r)43 user := currentUser(r)
52 44
53- g, gCtx := errgroup.WithContext(ctx)45+ subs, _ := s.dbs.Articles.ListSubscriptions(ctx, did, "", 50, 0)
54-46+ annotations, _ := s.dbs.Articles.ListAnnotations(ctx, "", "", did, 50, 0)
55- g.Go(func() error {47+ resolveAnnotationHandles(ctx, annotations)
56- var err error48+ subCount, _ := s.dbs.Articles.GetSubscriptionCount(ctx, did)
57- subs, err = s.dbs.Articles.ListSubscriptions(gCtx, did, "", 50, 0)49+ userLangs, _ := s.dbs.Users.GetLanguages(ctx, user.DID)
58- if err != nil {
59- s.logger.Warn("failed to list subscriptions", "error", err, "did", did)
60- }
61- return nil
62- })
63-
64- g.Go(func() error {
65- var err error
66- annotations, err = s.dbs.Articles.ListAnnotations(gCtx, "", "", did, 50, 0)
67- if err != nil {
68- s.logger.Warn("failed to list annotations", "error", err, "did", did)
69- return nil
70- }
71- resolveAnnotationHandles(gCtx, annotations)
72- return nil
73- })
74-
75- g.Go(func() error {
76- var err error
77- subCount, err = s.dbs.Articles.GetSubscriptionCount(gCtx, did)
78- if err != nil {
79- s.logger.Warn("failed to get subscription count", "error", err, "did", did)
80- }
81- return nil
82- })
83 50
84- g.Go(func() error {51+ var expandedView, digestEnabled bool
85- userLangs, _ = s.dbs.Users.GetLanguages(gCtx, user.DID)52+ if settings, err := s.dbs.Users.GetSettings(ctx, user.DID); err == nil && settings != nil {
86- return nil53+ expandedView = settings.ExpandedView
87- })54+ digestEnabled = settings.DigestEnabled
55+ }
88 56
89- userSettings := &db.UserSettings{}57+ writeJSON(w, http.StatusOK, profileResponse{
90- g.Go(func() error {58+ User: toUser(user),
91- var err error59+ ProfileUser: toUser(profileUser),
92- userSettings, err = s.dbs.Users.GetSettings(gCtx, user.DID)60+ Subscriptions: toSubscriptions(subs),
93- if err != nil {61+ Annotations: toAnnotations(annotations),
94- s.logger.Warn("failed to get user settings", "error", err, "did", user.DID)62+ SubscriptionCount: subCount,
95- }63+ AnnotationCount: len(annotations),
96- return nil64+ UserLanguages: nonNil(userLangs),
65+ AvailableLanguages: ml.KnownLanguages(),
66+ ExpandedView: expandedView,
67+ DigestEnabled: digestEnabled,
97 })68 })
69+}
98 70
99- if err := g.Wait(); err != nil {71+func toAnnotations(annotations []*db.Annotation) []Annotation {
100- s.logger.Warn("profile error", "error", err, "did", did)72+ out := make([]Annotation, len(annotations))
73+ for i, a := range annotations {
74+ out[i] = toAnnotation(a)
101 }75 }
102-76+ return out
103- s.render(w, r, "profile.html", map[string]any{
104- "User": user,
105- "CurrentUserDID": user.DID,
106- "ProfileUser": profileUser,
107- "Subscriptions": subs,
108- "Annotations": annotations,
109- "SubscriptionCount": subCount,
110- "AnnotationCount": len(annotations),
111- "UserLanguages": userLangs,
112- "AvailableLanguages": ml.KnownLanguages(),
113- "ExpandedView": userSettings.ExpandedView,
114- "DigestEnabled": userSettings.DigestEnabled,
115- })
116 }77 }
modified internal/server/server.go +125 -406
@@ -1,17 +1,13 @@
11 package server
22
33 import (
4- "bytes"
54 "context"
65 "fmt"
7- "html/template"
86 "log/slog"
97 "net/http"
108 "net/url"
11- "slices"
129 "strconv"
1310 "strings"
14- "sync"
1511 "time"
1612
1713 "github.com/go-chi/chi/v5"
@@ -30,11 +26,8 @@ import (
3026 "pkg.rbrt.fr/glean/internal/metrics"
3127 "pkg.rbrt.fr/glean/internal/ml"
3228 "pkg.rbrt.fr/glean/internal/scraper"
33- "pkg.rbrt.fr/glean/internal/tmpl"
34- "pkg.rbrt.fr/glean/static"
3529 )
3630
37-
3831 var oauthScopes = []string{
3932 "atproto",
4033 "blob:*/*",
@@ -54,37 +47,29 @@ var oauthScopes = []string{
5447 "rpc:app.bsky.actor.getProfile?aud=*",
5548 }
5649
57-var bufPool = sync.Pool{
58- New: func() any {
59- return new(bytes.Buffer)
60- },
61-}
62-
63-func splitString(s, sep string) []string {
64- return strings.Split(s, sep)
65-}
66-
6750 type Server struct {
68- dbs *db.Store
69- router *chi.Mux
70- templates *template.Template
71- logger *slog.Logger
72- oauth *oauth.ClientApp
73- oauthStore *db.OAuthStore
74- fetcher *feed.Fetcher
75- scheduler *feed.Scheduler
76- engine *cluster.Engine
77- feedback *feedback.Service
78- scraper *scraper.Scraper
79- llm ml.TextModel
80- clientID string
81- callbackURL string
82- sessionKey []byte
51+ dbs *db.Store
52+ router *chi.Mux
53+ logger *slog.Logger
54+ oauth *oauth.ClientApp
55+ oauthStore *db.OAuthStore
56+ fetcher *feed.Fetcher
57+ scheduler *feed.Scheduler
58+ engine *cluster.Engine
59+ feedback *feedback.Service
60+ scraper *scraper.Scraper
61+ llm ml.TextModel
62+ clientID string
63+ callbackURL string
64+ frontendURL string
65+ sessionKey []byte
66+ secureCookies bool // true in production (clientID set); gates cookie Secure flag
67+ allowedOrigin string // configured browser origin for CSRF; empty in localhost dev
8368 }
8469
8570 func New(
8671 dbs *db.Store,
87- clientID, callbackURL, addr string,
72+ clientID, callbackURL, frontendURL string,
8873 scheduler *feed.Scheduler,
8974 fetcher *feed.Fetcher,
9075 engine *cluster.Engine,
@@ -96,37 +81,47 @@ func New(
9681
9782 var config oauth.ClientConfig
9883 if clientID == "" {
99- host := addr
100- if strings.HasPrefix(host, ":") {
101- host = "127.0.0.1" + host
84+ // Localhost dev: the OAuth callback must go through the SvelteKit
85+ // frontend (which proxies /api to Go) so the post-callback redirect to
86+ // /dashboard lands on the frontend, not on Go's API-only server.
87+ origin := frontendURL
88+ if origin == "" {
89+ origin = "http://localhost:3000"
10290 }
103- cbURL := fmt.Sprintf("http://%s/auth/callback", host)
91+ cbURL := strings.TrimRight(origin, "/") + "/api/auth/callback"
10492 config = oauth.NewLocalhostConfig(cbURL, oauthScopes)
10593 } else {
106- config = oauth.NewPublicConfig(clientID, callbackURL, oauthScopes)
94+ // callbackURL points at the public SvelteKit origin, proxied to /api/auth/callback.
95+ cb := callbackURL
96+ if !strings.Contains(cb, "/api/auth/callback") {
97+ cb = strings.TrimRight(cb, "/") + "/api/auth/callback"
98+ }
99+ config = oauth.NewPublicConfig(clientID, cb, oauthScopes)
107100 }
108101 oauthClient := oauth.NewClientApp(&config, oauthStore)
109102
110103 s := &Server{
111- dbs: dbs,
112- router: chi.NewMux(),
113- logger: logger,
114- oauth: oauthClient,
115- oauthStore: oauthStore,
116- fetcher: fetcher,
117- scheduler: scheduler,
118- engine: engine,
119- feedback: feedback.NewService(dbs.SQLDB()),
120- scraper: scraper.New(logger),
121- llm: textModel,
122- clientID: clientID,
123- callbackURL: callbackURL,
124- sessionKey: sessionKey,
104+ dbs: dbs,
105+ router: chi.NewMux(),
106+ logger: logger,
107+ oauth: oauthClient,
108+ oauthStore: oauthStore,
109+ fetcher: fetcher,
110+ scheduler: scheduler,
111+ engine: engine,
112+ feedback: feedback.NewService(dbs.SQLDB()),
113+ scraper: scraper.New(logger),
114+ llm: textModel,
115+ clientID: clientID,
116+ callbackURL: callbackURL,
117+ frontendURL: frontendURL,
118+ sessionKey: sessionKey,
119+ secureCookies: clientID != "",
120+ allowedOrigin: frontendOrigin(frontendURL, clientID),
125121 }
126122
127123 s.setupMiddleware()
128124 s.setupRoutes()
129- s.loadTemplates()
130125
131126 return s
132127 }
@@ -137,10 +132,11 @@ func (s *Server) setupMiddleware() {
137132 s.router.Use(middleware.Compress(5))
138133 s.router.Use(s.metricsMiddleware)
139134 s.router.Use(cors.Handler(cors.Options{
140- AllowedOrigins: []string{"*"},
141- AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
142- AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
143- MaxAge: 300,
135+ AllowedOrigins: s.allowedOrigins(),
136+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
137+ AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
138+ AllowCredentials: true,
139+ MaxAge: 300,
144140 }))
145141 s.router.Use(s.sessionMiddleware)
146142 s.router.Use(s.csrfMiddleware)
@@ -161,21 +157,26 @@ func (s *Server) metricsMiddleware(next http.Handler) http.Handler {
161157 }
162158
163159 func normalizeMetricsPath(p string) string {
164- if strings.HasPrefix(p, "/static/") {
165- return "/static/*"
160+ if strings.HasPrefix(p, "/api/articles/") {
161+ return "/api/articles/*"
162+ }
163+ if strings.HasPrefix(p, "/api/profile/") {
164+ return "/api/profile/*"
166165 }
167166 return p
168167 }
169168
170169 func (s *Server) setupRoutes() {
171- s.router.Get("/", s.handleIndex)
170+ r := s.router
171+
172+ r.Get("/api/me", s.handleMe)
172173
173- s.router.Route("/dashboard", func(r chi.Router) {
174+ r.Route("/api/dashboard", func(r chi.Router) {
174175 r.Use(s.requireAuth)
175176 r.Get("/", s.handleDashboard)
176177 })
177178
178- s.router.Route("/feeds", func(r chi.Router) {
179+ r.Route("/api/feeds", func(r chi.Router) {
179180 r.Use(s.requireAuth)
180181 r.Get("/", s.handleFeeds)
181182 r.Post("/add", s.handleAddFeed)
@@ -189,7 +190,7 @@ func (s *Server) setupRoutes() {
189190 r.Post("/clear", s.handleClearAllSubscriptions)
190191 })
191192
192- s.router.Route("/articles", func(r chi.Router) {
193+ r.Route("/api/articles", func(r chi.Router) {
193194 r.Use(s.requireAuth)
194195 r.Get("/", s.handleArticles)
195196 r.Get("/new-count", s.handleNewArticleCount)
@@ -201,23 +202,23 @@ func (s *Server) setupRoutes() {
201202 r.Post("/mark-all-read", s.handleMarkAllRead)
202203 })
203204
204- s.router.Route("/trending", func(r chi.Router) {
205+ r.Route("/api/trending", func(r chi.Router) {
205206 r.Get("/", s.handleTrending)
206207 })
207208
208- s.router.Route("/profile", func(r chi.Router) {
209+ r.Route("/api/profile", func(r chi.Router) {
209210 r.Use(s.requireAuth)
210211 r.Get("/{did}", s.handleProfile)
211212 })
212213
213- s.router.Route("/library", func(r chi.Router) {
214+ r.Route("/api/library", func(r chi.Router) {
214215 r.Use(s.requireAuth)
215216 r.Get("/", s.handleLibrary)
216217 r.Post("/create", s.handleCreateAnnotation)
217218 r.Post("/{id}/delete", s.handleDeleteAnnotation)
218219 })
219220
220- s.router.Route("/recs", func(r chi.Router) {
221+ r.Route("/api/recs", func(r chi.Router) {
221222 r.Use(s.requireAuth)
222223 r.Get("/articles", s.handleArticleRecommendations)
223224 r.Get("/feeds", s.handleFeedRecommendations)
@@ -227,191 +228,59 @@ func (s *Server) setupRoutes() {
227228 r.Post("/dismiss-person", s.handleDismissPersonRecommendation)
228229 })
229230
230- s.router.Route("/settings", func(r chi.Router) {
231+ r.Route("/api/settings", func(r chi.Router) {
231232 r.Use(s.requireAuth)
232233 r.Post("/languages/{code}", s.handleToggleLanguage)
233234 r.Post("/expanded-view", s.handleToggleExpandedView)
234235 r.Post("/digest-enabled", s.handleToggleDigestEnabled)
235236 })
236237
237- s.router.With(s.requireAuth).Get("/digest", s.handleDigest)
238- s.router.With(s.requireAuth).Post("/digest/mark-read", s.handleDigestMarkRead)
238+ r.With(s.requireAuth).Get("/api/digest", s.handleDigest)
239+ r.With(s.requireAuth).Post("/api/digest/mark-read", s.handleDigestMarkRead)
239240
240- s.router.Get("/auth/login", s.handleAuthLogin)
241- s.router.Get("/auth/register", s.handleAuthRegister)
242- s.router.Get("/auth/resolve", s.handleAuthResolve)
243- s.router.Post("/auth/start", s.handleAuthStart)
244- s.router.Get("/auth/callback", s.handleAuthCallback)
245- s.router.Post("/auth/logout", s.handleAuthLogout)
246- s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)
241+ r.Get("/api/auth/login", s.handleAuthLoginMeta)
242+ r.Get("/api/auth/register", s.handleAuthRegister)
243+ r.Get("/api/auth/actors", s.handleAuthResolve)
244+ r.Post("/api/auth/start", s.handleAuthStart)
245+ r.Get("/api/auth/callback", s.handleAuthCallback)
246+ r.Post("/api/auth/logout", s.handleAuthLogout)
247+ r.Get("/api/oauth/client-metadata", s.handleOAuthClientMetadata)
247248
248249 xrpc := atproto.NewXRPCHandler(s.dbs, s.engine)
249- s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
250- s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
251- s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
252- s.router.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending)
253- s.router.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations)
254- s.router.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists)
255-
256- s.router.Get("/terms", s.handleTerms)
257- s.router.Get("/sitemap.xml", s.handleSitemap)
258- s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files))))
259- s.router.Handle("/metrics", promhttp.Handler())
260- s.router.Get("/stats", s.handleStats)
261- s.router.NotFound(s.handleNotFound)
250+ r.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
251+ r.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
252+ r.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
253+ r.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending)
254+ r.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations)
255+ r.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists)
256+
257+ r.Get("/api/sitemap", s.handleSitemap)
258+ r.Handle("/metrics", promhttp.Handler())
259+ r.Get("/api/stats", s.handleStats)
260+ r.NotFound(s.handleNotFound)
262261 }
263262
264-func (s *Server) loadTemplates() {
265- fm := template.FuncMap{
266- "dict": func(values ...any) (map[string]any, error) {
267- if len(values)%2 != 0 {
268- return nil, fmt.Errorf("dict requires even number of arguments")
269- }
270- m := make(map[string]any, len(values)/2)
271- for i := 0; i < len(values); i += 2 {
272- key, ok := values[i].(string)
273- if !ok {
274- return nil, fmt.Errorf("dict key must be string")
275- }
276- m[key] = values[i+1]
277- }
278- return m, nil
279- },
280- "formatDate": func(t time.Time) string {
281- return t.Format("Jan 02, 2006")
282- },
283- "formatDateTime": func(t time.Time) string {
284- return t.Format("Jan 02, 2006 15:04")
285- },
286- "split": func(sv, sep string) []string {
287- if sv == "" {
288- return nil
289- }
290- var result []string
291- for _, p := range splitString(sv, sep) {
292- if p != "" {
293- result = append(result, p)
294- }
295- }
296- return result
297- },
298- "repeat": func(str string, n int) string {
299- b := strings.Builder{}
300- for range n {
301- b.WriteString(str)
302- }
303- return b.String()
304- },
305- "int": func(n int64) int {
306- return int(n)
307- },
308- "add": func(a, b int) int {
309- return a + b
310- },
311- "youtubeID": func(rawURL string) string {
312- u, err := url.Parse(rawURL)
313- if err != nil {
314- return ""
315- }
316- host := strings.ToLower(u.Hostname())
317- if host == "youtu.be" {
318- id := strings.TrimPrefix(u.Path, "/")
319- if id != "" {
320- return id
321- }
322- return ""
323- }
324- if host == "www.youtube.com" || host == "youtube.com" || host == "m.youtube.com" {
325- if u.Path == "/watch" || u.Path == "/watch/" {
326- id := u.Query().Get("v")
327- if id != "" {
328- return id
329- }
330- }
331- if after, ok := strings.CutPrefix(u.Path, "/embed/"); ok {
332- id := after
333- if id != "" {
334- return id
335- }
336- }
337- if after, ok := strings.CutPrefix(u.Path, "/shorts/"); ok {
338- id := after
339- if id != "" {
340- return id
341- }
342- }
343- }
344- return ""
345- },
346- "isEmbedURL": func(rawURL string) bool {
347- u, err := url.Parse(rawURL)
348- if err != nil {
349- return false
350- }
351- host := strings.ToLower(u.Hostname())
352- return slices.Contains([]string{
353- "www.youtube.com", "youtube.com", "m.youtube.com", "youtu.be",
354- "vimeo.com", "player.vimeo.com",
355- "open.spotify.com", "embed.spotify.com",
356- "w.soundcloud.com",
357- "bandcamp.com",
358- }, host)
359- },
360- "sanitizeHTML": func(input string) template.HTML {
361- return template.HTML(sanitizeHTML(input))
362- },
363- "plainText": plainText,
364- "now": time.Now,
365- "activeClass": func(activePath, linkPath string) string {
366- if activePath == linkPath || (len(activePath) > len(linkPath) && activePath[:len(linkPath)+1] == linkPath+"/") {
367- return "bg-spot-hover text-spot-text font-bold"
368- }
369- return "text-spot-secondary"
370- },
371- "csrfInput": func(token any) template.HTML {
372- s, ok := token.(string)
373- if !ok || s == "" {
374- return ""
375- }
376- return template.HTML(`<input type="hidden" name="csrf_token" value="` + s + `">`)
377- },
378- "paginationURL": func(baseURL string, page int, queryParams map[string]string) string {
379- u, _ := url.Parse(baseURL)
380- q := u.Query()
381- for k, v := range queryParams {
382- q.Set(k, v)
383- }
384- if page > 1 {
385- q.Set("page", fmt.Sprintf("%d", page))
386- } else {
387- q.Del("page")
388- }
389- u.RawQuery = q.Encode()
390- return u.String()
391- },
392- "containsString": func(slice any, s string) bool {
393- sl, ok := slice.([]string)
394- if !ok {
395- return false
396- }
397- return slices.Contains(sl, s)
398- },
263+func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
264+ user := currentUser(r)
265+ csrf := ""
266+ if c, err := r.Cookie("glean_csrf"); err == nil {
267+ csrf = c.Value
399268 }
400-
401- var err error
402- s.templates, err = template.New("").Funcs(fm).ParseFS(tmpl.Files, "*.html", "partials/*.html")
403- if err != nil {
404- s.logger.Error("failed to load templates", "error", err)
269+ var userObj *User
270+ if user != nil {
271+ userObj = new(toUser(user))
405272 }
273+ writeJSON(w, http.StatusOK, meResponse{
274+ User: userObj,
275+ CSRFToken: csrf,
276+ HasLLM: s.llm != nil,
277+ ClientID: s.clientID,
278+ })
406279 }
407280
408281 func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
409282 session := s.getSessionData(r)
410- if session == nil {
411- return nil
412- }
413-
414- if session.SessionID == "" {
283+ if session == nil || session.SessionID == "" {
415284 return nil
416285 }
417286
@@ -459,189 +328,39 @@ func (s *Server) PeriodicSync(ctx context.Context, interval time.Duration) {
459328 }
460329 }
461330
462-func (s *Server) BackfillFromCollectionDir(ctx context.Context, collectionDirURL string, concurrency int) {
463- if collectionDirURL == "" {
464- return
465- }
466-
467- s.logger.Info("backfilling from collection directory", "url", collectionDirURL)
468-
469- dids, err := atproto.FetchSubscriberDIDs(ctx, collectionDirURL)
470- if err != nil {
471- s.logger.Error("failed to fetch subscriber DIDs", "error", err)
472- return
473- }
474-
475- existing, err := s.dbs.Users.UserDIDs(ctx)
476- if err != nil {
477- s.logger.Error("failed to list existing users", "error", err)
478- return
479- }
480-
481- var missing []string
482- for _, did := range dids {
483- if !existing[did] {
484- missing = append(missing, did)
485- }
486- }
487-
488- s.logger.Info("collection directory backfill", "total", len(dids), "missing", len(missing))
489-
490- sem := make(chan struct{}, concurrency)
491- var wg sync.WaitGroup
492-
493- for _, did := range missing {
494- if ctx.Err() != nil {
495- break
496- }
497-
498- sem <- struct{}{}
499- wg.Add(1)
500-
501- go func(did string) {
502- defer func() { <-sem }()
503- defer wg.Done()
504-
505- if _, err := s.dbs.Users.CreateUser(ctx, did); err != nil {
506- s.logger.Error("failed to create user during backfill", "error", err, "did", did)
507- return
508- }
509-
510- pdsURL, err := atproto.ResolvePDSEndpoint(ctx, did)
511- if err != nil {
512- s.logger.Error("failed to resolve PDS for backfill", "error", err, "did", did)
513- return
514- }
515-
516- client := atproto.NewUnauthenticatedClient(pdsURL)
517- sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
518- if err := sync.Run(ctx, did); err != nil {
519- s.logger.Error("backfill sync failed", "error", err, "did", did)
520- }
521- }(did)
522- }
523-
524- wg.Wait()
525- s.logger.Info("collection directory backfill complete")
526-}
527-
528-func (s *Server) runSyncAll(ctx context.Context) {
529- if n, err := s.oauthStore.CountActiveUsers(ctx); err == nil {
530- metrics.ActiveUsers.Set(float64(n))
531- }
532-
533- users, err := s.dbs.Users.ListUsers(ctx)
534- if err != nil {
535- s.logger.Error("failed to list users for sync", "error", err)
536- return
537- }
538-
539- for _, u := range users {
540- sessionIDs, err := s.oauthStore.ListSessionsForDID(ctx, u.DID)
541- if err != nil || len(sessionIDs) == 0 {
542- continue
543- }
544-
545- did, err := syntax.ParseDID(u.DID)
546- if err != nil {
547- continue
548- }
549-
550- sess, err := s.oauth.ResumeSession(ctx, did, sessionIDs[0])
551- if err != nil {
552- s.logger.Warn("failed to resume session for periodic sync", "error", err, "did", u.DID)
553- continue
554- }
555-
556- client := atproto.NewClient(sess.APIClient())
557- sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
558- if err := sync.Run(ctx, u.DID); err != nil {
559- metrics.SyncErrors.Inc()
560- s.logger.Error("periodic sync failed", "error", err, "did", u.DID)
561- }
562-
563- metrics.SyncRuns.Inc()
564- }
565-
566- // Recompute subscriber_count once after all users are synced.
567- if err := s.dbs.Articles.RecountSubscriberCounts(ctx); err != nil {
568- s.logger.Error("recount subscriber counts failed", "error", err)
569- }
570-}
571-
572331 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
573332 s.router.ServeHTTP(w, r)
574333 }
575334
576335 func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
577- w.WriteHeader(http.StatusNotFound)
578- s.render(w, r, "404.html", nil)
336+ writeAPIError(w, http.StatusNotFound, "not found")
579337 }
580338
581-func (s *Server) renderError(w http.ResponseWriter, r *http.Request, code int, title, message string) {
582- if isHXRequest(r) {
583- w.WriteHeader(code)
584- w.Write([]byte(message))
585- return
339+// allowedOrigins returns the browser origins permitted by CORS. In production
340+// this is the configured frontend URL; in localhost dev the SvelteKit proxy
341+// is same-origin so any value works, but we still avoid "*".
342+func (s *Server) allowedOrigins() []string {
343+ if s.allowedOrigin != "" {
344+ return []string{s.allowedOrigin}
586345 }
587- w.WriteHeader(code)
588- s.render(w, r, "error.html", map[string]any{
589- "Title": title,
590- "Message": message,
591- })
346+ return []string{"http://localhost:3000", "http://localhost:5173"}
592347 }
593348
594-func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
595- if data == nil {
596- data = map[string]any{}
597- }
598-
599- data["HasLLM"] = s.llm != nil
600-
601- if cookie, err := r.Cookie("glean_csrf"); err == nil {
602- data["CSRFToken"] = cookie.Value
603- }
604-
605- if isHXRequest(r) {
606- if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
607- s.logger.Error("template error", "error", err, "template", name)
608- http.Error(w, err.Error(), http.StatusInternalServerError)
609- }
610- return
611- }
612-
613- buf := bufPool.Get().(*bytes.Buffer)
614- buf.Reset()
615- defer bufPool.Put(buf)
616-
617- if err := s.templates.ExecuteTemplate(buf, name, data); err != nil {
618- s.logger.Error("template error", "error", err, "template", name)
619- http.Error(w, err.Error(), http.StatusInternalServerError)
620- return
621- }
622-
623- path := r.URL.Path
624- if len(path) > 1 {
625- path = strings.TrimRight(path, "/")
626- }
627- baseData := map[string]any{
628- "Content": template.HTML(buf.String()),
629- "ActivePath": path,
630- }
631- if data != nil {
632- if u, ok := data["User"]; ok {
633- baseData["User"] = u
349+// frontendOrigin derives the browser origin (scheme://host) from frontendURL,
350+// falling back to the client ID host.
351+func frontendOrigin(frontendURL, clientID string) string {
352+ for _, raw := range []string{frontendURL, clientID} {
353+ if raw == "" {
354+ continue
634355 }
635- if csrf, ok := data["CSRFToken"]; ok {
636- baseData["CSRFToken"] = csrf
356+ u, err := url.Parse(raw)
357+ if err != nil || u.Host == "" {
358+ continue
637359 }
638- if hasLLM, ok := data["HasLLM"]; ok {
639- baseData["HasLLM"] = hasLLM
360+ if u.Scheme == "" {
361+ u.Scheme = "https"
640362 }
363+ return u.Scheme + "://" + u.Host
641364 }
642-
643- if err := s.templates.ExecuteTemplate(w, "base.html", baseData); err != nil {
644- s.logger.Error("template error", "error", err, "template", "base.html")
645- http.Error(w, err.Error(), http.StatusInternalServerError)
646- }
365+ return ""
647366 }
@@ -1,17 +1,13 @@
1 package server1 package server
2 2
3 import (3 import (
4- "bytes"
5 "context"4 "context"
6 "fmt"5 "fmt"
7- "html/template"
8 "log/slog"6 "log/slog"
9 "net/http"7 "net/http"
10 "net/url"8 "net/url"
11- "slices"
12 "strconv"9 "strconv"
13 "strings"10 "strings"
14- "sync"
15 "time"11 "time"
16 12
17 "github.com/go-chi/chi/v5"13 "github.com/go-chi/chi/v5"
@@ -30,11 +26,8 @@ import (
30 "pkg.rbrt.fr/glean/internal/metrics"26 "pkg.rbrt.fr/glean/internal/metrics"
31 "pkg.rbrt.fr/glean/internal/ml"27 "pkg.rbrt.fr/glean/internal/ml"
32 "pkg.rbrt.fr/glean/internal/scraper"28 "pkg.rbrt.fr/glean/internal/scraper"
33- "pkg.rbrt.fr/glean/internal/tmpl"
34- "pkg.rbrt.fr/glean/static"
35 )29 )
36 30
37-
38 var oauthScopes = []string{31 var oauthScopes = []string{
39 "atproto",32 "atproto",
40 "blob:*/*",33 "blob:*/*",
@@ -54,37 +47,29 @@ var oauthScopes = []string{
54 "rpc:app.bsky.actor.getProfile?aud=*",47 "rpc:app.bsky.actor.getProfile?aud=*",
55 }48 }
56 49
57-var bufPool = sync.Pool{
58- New: func() any {
59- return new(bytes.Buffer)
60- },
61-}
62-
63-func splitString(s, sep string) []string {
64- return strings.Split(s, sep)
65-}
66-
67 type Server struct {50 type Server struct {
68- dbs *db.Store51+ dbs *db.Store
69- router *chi.Mux52+ router *chi.Mux
70- templates *template.Template53+ logger *slog.Logger
71- logger *slog.Logger54+ oauth *oauth.ClientApp
72- oauth *oauth.ClientApp55+ oauthStore *db.OAuthStore
73- oauthStore *db.OAuthStore56+ fetcher *feed.Fetcher
74- fetcher *feed.Fetcher57+ scheduler *feed.Scheduler
75- scheduler *feed.Scheduler58+ engine *cluster.Engine
76- engine *cluster.Engine59+ feedback *feedback.Service
77- feedback *feedback.Service60+ scraper *scraper.Scraper
78- scraper *scraper.Scraper61+ llm ml.TextModel
79- llm ml.TextModel62+ clientID string
80- clientID string63+ callbackURL string
81- callbackURL string64+ frontendURL string
82- sessionKey []byte65+ sessionKey []byte
66+ secureCookies bool // true in production (clientID set); gates cookie Secure flag
67+ allowedOrigin string // configured browser origin for CSRF; empty in localhost dev
83 }68 }
84 69
85 func New(70 func New(
86 dbs *db.Store,71 dbs *db.Store,
87- clientID, callbackURL, addr string,72+ clientID, callbackURL, frontendURL string,
88 scheduler *feed.Scheduler,73 scheduler *feed.Scheduler,
89 fetcher *feed.Fetcher,74 fetcher *feed.Fetcher,
90 engine *cluster.Engine,75 engine *cluster.Engine,
@@ -96,37 +81,47 @@ func New(
96 81
97 var config oauth.ClientConfig82 var config oauth.ClientConfig
98 if clientID == "" {83 if clientID == "" {
99- host := addr84+ // Localhost dev: the OAuth callback must go through the SvelteKit
100- if strings.HasPrefix(host, ":") {85+ // frontend (which proxies /api to Go) so the post-callback redirect to
101- host = "127.0.0.1" + host86+ // /dashboard lands on the frontend, not on Go's API-only server.
87+ origin := frontendURL
88+ if origin == "" {
89+ origin = "http://localhost:3000"
102 }90 }
103- cbURL := fmt.Sprintf("http://%s/auth/callback", host)91+ cbURL := strings.TrimRight(origin, "/") + "/api/auth/callback"
104 config = oauth.NewLocalhostConfig(cbURL, oauthScopes)92 config = oauth.NewLocalhostConfig(cbURL, oauthScopes)
105 } else {93 } else {
106- config = oauth.NewPublicConfig(clientID, callbackURL, oauthScopes)94+ // callbackURL points at the public SvelteKit origin, proxied to /api/auth/callback.
95+ cb := callbackURL
96+ if !strings.Contains(cb, "/api/auth/callback") {
97+ cb = strings.TrimRight(cb, "/") + "/api/auth/callback"
98+ }
99+ config = oauth.NewPublicConfig(clientID, cb, oauthScopes)
107 }100 }
108 oauthClient := oauth.NewClientApp(&config, oauthStore)101 oauthClient := oauth.NewClientApp(&config, oauthStore)
109 102
110 s := &Server{103 s := &Server{
111- dbs: dbs,104+ dbs: dbs,
112- router: chi.NewMux(),105+ router: chi.NewMux(),
113- logger: logger,106+ logger: logger,
114- oauth: oauthClient,107+ oauth: oauthClient,
115- oauthStore: oauthStore,108+ oauthStore: oauthStore,
116- fetcher: fetcher,109+ fetcher: fetcher,
117- scheduler: scheduler,110+ scheduler: scheduler,
118- engine: engine,111+ engine: engine,
119- feedback: feedback.NewService(dbs.SQLDB()),112+ feedback: feedback.NewService(dbs.SQLDB()),
120- scraper: scraper.New(logger),113+ scraper: scraper.New(logger),
121- llm: textModel,114+ llm: textModel,
122- clientID: clientID,115+ clientID: clientID,
123- callbackURL: callbackURL,116+ callbackURL: callbackURL,
124- sessionKey: sessionKey,117+ frontendURL: frontendURL,
118+ sessionKey: sessionKey,
119+ secureCookies: clientID != "",
120+ allowedOrigin: frontendOrigin(frontendURL, clientID),
125 }121 }
126 122
127 s.setupMiddleware()123 s.setupMiddleware()
128 s.setupRoutes()124 s.setupRoutes()
129- s.loadTemplates()
130 125
131 return s126 return s
132 }127 }
@@ -137,10 +132,11 @@ func (s *Server) setupMiddleware() {
137 s.router.Use(middleware.Compress(5))132 s.router.Use(middleware.Compress(5))
138 s.router.Use(s.metricsMiddleware)133 s.router.Use(s.metricsMiddleware)
139 s.router.Use(cors.Handler(cors.Options{134 s.router.Use(cors.Handler(cors.Options{
140- AllowedOrigins: []string{"*"},135+ AllowedOrigins: s.allowedOrigins(),
141- AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},136+ AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
142- AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},137+ AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
143- MaxAge: 300,138+ AllowCredentials: true,
139+ MaxAge: 300,
144 }))140 }))
145 s.router.Use(s.sessionMiddleware)141 s.router.Use(s.sessionMiddleware)
146 s.router.Use(s.csrfMiddleware)142 s.router.Use(s.csrfMiddleware)
@@ -161,21 +157,26 @@ func (s *Server) metricsMiddleware(next http.Handler) http.Handler {
161 }157 }
162 158
163 func normalizeMetricsPath(p string) string {159 func normalizeMetricsPath(p string) string {
164- if strings.HasPrefix(p, "/static/") {160+ if strings.HasPrefix(p, "/api/articles/") {
165- return "/static/*"161+ return "/api/articles/*"
162+ }
163+ if strings.HasPrefix(p, "/api/profile/") {
164+ return "/api/profile/*"
166 }165 }
167 return p166 return p
168 }167 }
169 168
170 func (s *Server) setupRoutes() {169 func (s *Server) setupRoutes() {
171- s.router.Get("/", s.handleIndex)170+ r := s.router
171+
172+ r.Get("/api/me", s.handleMe)
172 173
173- s.router.Route("/dashboard", func(r chi.Router) {174+ r.Route("/api/dashboard", func(r chi.Router) {
174 r.Use(s.requireAuth)175 r.Use(s.requireAuth)
175 r.Get("/", s.handleDashboard)176 r.Get("/", s.handleDashboard)
176 })177 })
177 178
178- s.router.Route("/feeds", func(r chi.Router) {179+ r.Route("/api/feeds", func(r chi.Router) {
179 r.Use(s.requireAuth)180 r.Use(s.requireAuth)
180 r.Get("/", s.handleFeeds)181 r.Get("/", s.handleFeeds)
181 r.Post("/add", s.handleAddFeed)182 r.Post("/add", s.handleAddFeed)
@@ -189,7 +190,7 @@ func (s *Server) setupRoutes() {
189 r.Post("/clear", s.handleClearAllSubscriptions)190 r.Post("/clear", s.handleClearAllSubscriptions)
190 })191 })
191 192
192- s.router.Route("/articles", func(r chi.Router) {193+ r.Route("/api/articles", func(r chi.Router) {
193 r.Use(s.requireAuth)194 r.Use(s.requireAuth)
194 r.Get("/", s.handleArticles)195 r.Get("/", s.handleArticles)
195 r.Get("/new-count", s.handleNewArticleCount)196 r.Get("/new-count", s.handleNewArticleCount)
@@ -201,23 +202,23 @@ func (s *Server) setupRoutes() {
201 r.Post("/mark-all-read", s.handleMarkAllRead)202 r.Post("/mark-all-read", s.handleMarkAllRead)
202 })203 })
203 204
204- s.router.Route("/trending", func(r chi.Router) {205+ r.Route("/api/trending", func(r chi.Router) {
205 r.Get("/", s.handleTrending)206 r.Get("/", s.handleTrending)
206 })207 })
207 208
208- s.router.Route("/profile", func(r chi.Router) {209+ r.Route("/api/profile", func(r chi.Router) {
209 r.Use(s.requireAuth)210 r.Use(s.requireAuth)
210 r.Get("/{did}", s.handleProfile)211 r.Get("/{did}", s.handleProfile)
211 })212 })
212 213
213- s.router.Route("/library", func(r chi.Router) {214+ r.Route("/api/library", func(r chi.Router) {
214 r.Use(s.requireAuth)215 r.Use(s.requireAuth)
215 r.Get("/", s.handleLibrary)216 r.Get("/", s.handleLibrary)
216 r.Post("/create", s.handleCreateAnnotation)217 r.Post("/create", s.handleCreateAnnotation)
217 r.Post("/{id}/delete", s.handleDeleteAnnotation)218 r.Post("/{id}/delete", s.handleDeleteAnnotation)
218 })219 })
219 220
220- s.router.Route("/recs", func(r chi.Router) {221+ r.Route("/api/recs", func(r chi.Router) {
221 r.Use(s.requireAuth)222 r.Use(s.requireAuth)
222 r.Get("/articles", s.handleArticleRecommendations)223 r.Get("/articles", s.handleArticleRecommendations)
223 r.Get("/feeds", s.handleFeedRecommendations)224 r.Get("/feeds", s.handleFeedRecommendations)
@@ -227,191 +228,59 @@ func (s *Server) setupRoutes() {
227 r.Post("/dismiss-person", s.handleDismissPersonRecommendation)228 r.Post("/dismiss-person", s.handleDismissPersonRecommendation)
228 })229 })
229 230
230- s.router.Route("/settings", func(r chi.Router) {231+ r.Route("/api/settings", func(r chi.Router) {
231 r.Use(s.requireAuth)232 r.Use(s.requireAuth)
232 r.Post("/languages/{code}", s.handleToggleLanguage)233 r.Post("/languages/{code}", s.handleToggleLanguage)
233 r.Post("/expanded-view", s.handleToggleExpandedView)234 r.Post("/expanded-view", s.handleToggleExpandedView)
234 r.Post("/digest-enabled", s.handleToggleDigestEnabled)235 r.Post("/digest-enabled", s.handleToggleDigestEnabled)
235 })236 })
236 237
237- s.router.With(s.requireAuth).Get("/digest", s.handleDigest)238+ r.With(s.requireAuth).Get("/api/digest", s.handleDigest)
238- s.router.With(s.requireAuth).Post("/digest/mark-read", s.handleDigestMarkRead)239+ r.With(s.requireAuth).Post("/api/digest/mark-read", s.handleDigestMarkRead)
239 240
240- s.router.Get("/auth/login", s.handleAuthLogin)241+ r.Get("/api/auth/login", s.handleAuthLoginMeta)
241- s.router.Get("/auth/register", s.handleAuthRegister)242+ r.Get("/api/auth/register", s.handleAuthRegister)
242- s.router.Get("/auth/resolve", s.handleAuthResolve)243+ r.Get("/api/auth/actors", s.handleAuthResolve)
243- s.router.Post("/auth/start", s.handleAuthStart)244+ r.Post("/api/auth/start", s.handleAuthStart)
244- s.router.Get("/auth/callback", s.handleAuthCallback)245+ r.Get("/api/auth/callback", s.handleAuthCallback)
245- s.router.Post("/auth/logout", s.handleAuthLogout)246+ r.Post("/api/auth/logout", s.handleAuthLogout)
246- s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)247+ r.Get("/api/oauth/client-metadata", s.handleOAuthClientMetadata)
247 248
248 xrpc := atproto.NewXRPCHandler(s.dbs, s.engine)249 xrpc := atproto.NewXRPCHandler(s.dbs, s.engine)
249- s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)250+ r.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
250- s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)251+ r.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
251- s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)252+ r.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
252- s.router.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending)253+ r.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending)
253- s.router.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations)254+ r.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations)
254- s.router.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists)255+ r.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists)
255-256+
256- s.router.Get("/terms", s.handleTerms)257+ r.Get("/api/sitemap", s.handleSitemap)
257- s.router.Get("/sitemap.xml", s.handleSitemap)258+ r.Handle("/metrics", promhttp.Handler())
258- s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(static.Files))))259+ r.Get("/api/stats", s.handleStats)
259- s.router.Handle("/metrics", promhttp.Handler())260+ r.NotFound(s.handleNotFound)
260- s.router.Get("/stats", s.handleStats)
261- s.router.NotFound(s.handleNotFound)
262 }261 }
263 262
264-func (s *Server) loadTemplates() {263+func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
265- fm := template.FuncMap{264+ user := currentUser(r)
266- "dict": func(values ...any) (map[string]any, error) {265+ csrf := ""
267- if len(values)%2 != 0 {266+ if c, err := r.Cookie("glean_csrf"); err == nil {
268- return nil, fmt.Errorf("dict requires even number of arguments")267+ csrf = c.Value
269- }
270- m := make(map[string]any, len(values)/2)
271- for i := 0; i < len(values); i += 2 {
272- key, ok := values[i].(string)
273- if !ok {
274- return nil, fmt.Errorf("dict key must be string")
275- }
276- m[key] = values[i+1]
277- }
278- return m, nil
279- },
280- "formatDate": func(t time.Time) string {
281- return t.Format("Jan 02, 2006")
282- },
283- "formatDateTime": func(t time.Time) string {
284- return t.Format("Jan 02, 2006 15:04")
285- },
286- "split": func(sv, sep string) []string {
287- if sv == "" {
288- return nil
289- }
290- var result []string
291- for _, p := range splitString(sv, sep) {
292- if p != "" {
293- result = append(result, p)
294- }
295- }
296- return result
297- },
298- "repeat": func(str string, n int) string {
299- b := strings.Builder{}
300- for range n {
301- b.WriteString(str)
302- }
303- return b.String()
304- },
305- "int": func(n int64) int {
306- return int(n)
307- },
308- "add": func(a, b int) int {
309- return a + b
310- },
311- "youtubeID": func(rawURL string) string {
312- u, err := url.Parse(rawURL)
313- if err != nil {
314- return ""
315- }
316- host := strings.ToLower(u.Hostname())
317- if host == "youtu.be" {
318- id := strings.TrimPrefix(u.Path, "/")
319- if id != "" {
320- return id
321- }
322- return ""
323- }
324- if host == "www.youtube.com" || host == "youtube.com" || host == "m.youtube.com" {
325- if u.Path == "/watch" || u.Path == "/watch/" {
326- id := u.Query().Get("v")
327- if id != "" {
328- return id
329- }
330- }
331- if after, ok := strings.CutPrefix(u.Path, "/embed/"); ok {
332- id := after
333- if id != "" {
334- return id
335- }
336- }
337- if after, ok := strings.CutPrefix(u.Path, "/shorts/"); ok {
338- id := after
339- if id != "" {
340- return id
341- }
342- }
343- }
344- return ""
345- },
346- "isEmbedURL": func(rawURL string) bool {
347- u, err := url.Parse(rawURL)
348- if err != nil {
349- return false
350- }
351- host := strings.ToLower(u.Hostname())
352- return slices.Contains([]string{
353- "www.youtube.com", "youtube.com", "m.youtube.com", "youtu.be",
354- "vimeo.com", "player.vimeo.com",
355- "open.spotify.com", "embed.spotify.com",
356- "w.soundcloud.com",
357- "bandcamp.com",
358- }, host)
359- },
360- "sanitizeHTML": func(input string) template.HTML {
361- return template.HTML(sanitizeHTML(input))
362- },
363- "plainText": plainText,
364- "now": time.Now,
365- "activeClass": func(activePath, linkPath string) string {
366- if activePath == linkPath || (len(activePath) > len(linkPath) && activePath[:len(linkPath)+1] == linkPath+"/") {
367- return "bg-spot-hover text-spot-text font-bold"
368- }
369- return "text-spot-secondary"
370- },
371- "csrfInput": func(token any) template.HTML {
372- s, ok := token.(string)
373- if !ok || s == "" {
374- return ""
375- }
376- return template.HTML(`<input type="hidden" name="csrf_token" value="` + s + `">`)
377- },
378- "paginationURL": func(baseURL string, page int, queryParams map[string]string) string {
379- u, _ := url.Parse(baseURL)
380- q := u.Query()
381- for k, v := range queryParams {
382- q.Set(k, v)
383- }
384- if page > 1 {
385- q.Set("page", fmt.Sprintf("%d", page))
386- } else {
387- q.Del("page")
388- }
389- u.RawQuery = q.Encode()
390- return u.String()
391- },
392- "containsString": func(slice any, s string) bool {
393- sl, ok := slice.([]string)
394- if !ok {
395- return false
396- }
397- return slices.Contains(sl, s)
398- },
399 }268 }
400-269+ var userObj *User
401- var err error270+ if user != nil {
402- s.templates, err = template.New("").Funcs(fm).ParseFS(tmpl.Files, "*.html", "partials/*.html")271+ userObj = new(toUser(user))
403- if err != nil {
404- s.logger.Error("failed to load templates", "error", err)
405 }272 }
273+ writeJSON(w, http.StatusOK, meResponse{
274+ User: userObj,
275+ CSRFToken: csrf,
276+ HasLLM: s.llm != nil,
277+ ClientID: s.clientID,
278+ })
406 }279 }
407 280
408 func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {281 func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
409 session := s.getSessionData(r)282 session := s.getSessionData(r)
410- if session == nil {283+ if session == nil || session.SessionID == "" {
411- return nil
412- }
413-
414- if session.SessionID == "" {
415 return nil284 return nil
416 }285 }
417 286
@@ -459,189 +328,39 @@ func (s *Server) PeriodicSync(ctx context.Context, interval time.Duration) {
459 }328 }
460 }329 }
461 330
462-func (s *Server) BackfillFromCollectionDir(ctx context.Context, collectionDirURL string, concurrency int) {
463- if collectionDirURL == "" {
464- return
465- }
466-
467- s.logger.Info("backfilling from collection directory", "url", collectionDirURL)
468-
469- dids, err := atproto.FetchSubscriberDIDs(ctx, collectionDirURL)
470- if err != nil {
471- s.logger.Error("failed to fetch subscriber DIDs", "error", err)
472- return
473- }
474-
475- existing, err := s.dbs.Users.UserDIDs(ctx)
476- if err != nil {
477- s.logger.Error("failed to list existing users", "error", err)
478- return
479- }
480-
481- var missing []string
482- for _, did := range dids {
483- if !existing[did] {
484- missing = append(missing, did)
485- }
486- }
487-
488- s.logger.Info("collection directory backfill", "total", len(dids), "missing", len(missing))
489-
490- sem := make(chan struct{}, concurrency)
491- var wg sync.WaitGroup
492-
493- for _, did := range missing {
494- if ctx.Err() != nil {
495- break
496- }
497-
498- sem <- struct{}{}
499- wg.Add(1)
500-
501- go func(did string) {
502- defer func() { <-sem }()
503- defer wg.Done()
504-
505- if _, err := s.dbs.Users.CreateUser(ctx, did); err != nil {
506- s.logger.Error("failed to create user during backfill", "error", err, "did", did)
507- return
508- }
509-
510- pdsURL, err := atproto.ResolvePDSEndpoint(ctx, did)
511- if err != nil {
512- s.logger.Error("failed to resolve PDS for backfill", "error", err, "did", did)
513- return
514- }
515-
516- client := atproto.NewUnauthenticatedClient(pdsURL)
517- sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
518- if err := sync.Run(ctx, did); err != nil {
519- s.logger.Error("backfill sync failed", "error", err, "did", did)
520- }
521- }(did)
522- }
523-
524- wg.Wait()
525- s.logger.Info("collection directory backfill complete")
526-}
527-
528-func (s *Server) runSyncAll(ctx context.Context) {
529- if n, err := s.oauthStore.CountActiveUsers(ctx); err == nil {
530- metrics.ActiveUsers.Set(float64(n))
531- }
532-
533- users, err := s.dbs.Users.ListUsers(ctx)
534- if err != nil {
535- s.logger.Error("failed to list users for sync", "error", err)
536- return
537- }
538-
539- for _, u := range users {
540- sessionIDs, err := s.oauthStore.ListSessionsForDID(ctx, u.DID)
541- if err != nil || len(sessionIDs) == 0 {
542- continue
543- }
544-
545- did, err := syntax.ParseDID(u.DID)
546- if err != nil {
547- continue
548- }
549-
550- sess, err := s.oauth.ResumeSession(ctx, did, sessionIDs[0])
551- if err != nil {
552- s.logger.Warn("failed to resume session for periodic sync", "error", err, "did", u.DID)
553- continue
554- }
555-
556- client := atproto.NewClient(sess.APIClient())
557- sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
558- if err := sync.Run(ctx, u.DID); err != nil {
559- metrics.SyncErrors.Inc()
560- s.logger.Error("periodic sync failed", "error", err, "did", u.DID)
561- }
562-
563- metrics.SyncRuns.Inc()
564- }
565-
566- // Recompute subscriber_count once after all users are synced.
567- if err := s.dbs.Articles.RecountSubscriberCounts(ctx); err != nil {
568- s.logger.Error("recount subscriber counts failed", "error", err)
569- }
570-}
571-
572 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {331 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
573 s.router.ServeHTTP(w, r)332 s.router.ServeHTTP(w, r)
574 }333 }
575 334
576 func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {335 func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
577- w.WriteHeader(http.StatusNotFound)336+ writeAPIError(w, http.StatusNotFound, "not found")
578- s.render(w, r, "404.html", nil)
579 }337 }
580 338
581-func (s *Server) renderError(w http.ResponseWriter, r *http.Request, code int, title, message string) {339+// allowedOrigins returns the browser origins permitted by CORS. In production
582- if isHXRequest(r) {340+// this is the configured frontend URL; in localhost dev the SvelteKit proxy
583- w.WriteHeader(code)341+// is same-origin so any value works, but we still avoid "*".
584- w.Write([]byte(message))342+func (s *Server) allowedOrigins() []string {
585- return343+ if s.allowedOrigin != "" {
344+ return []string{s.allowedOrigin}
586 }345 }
587- w.WriteHeader(code)346+ return []string{"http://localhost:3000", "http://localhost:5173"}
588- s.render(w, r, "error.html", map[string]any{
589- "Title": title,
590- "Message": message,
591- })
592 }347 }
593 348
594-func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {349+// frontendOrigin derives the browser origin (scheme://host) from frontendURL,
595- if data == nil {350+// falling back to the client ID host.
596- data = map[string]any{}351+func frontendOrigin(frontendURL, clientID string) string {
597- }352+ for _, raw := range []string{frontendURL, clientID} {
598-353+ if raw == "" {
599- data["HasLLM"] = s.llm != nil354+ continue
600-
601- if cookie, err := r.Cookie("glean_csrf"); err == nil {
602- data["CSRFToken"] = cookie.Value
603- }
604-
605- if isHXRequest(r) {
606- if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
607- s.logger.Error("template error", "error", err, "template", name)
608- http.Error(w, err.Error(), http.StatusInternalServerError)
609- }
610- return
611- }
612-
613- buf := bufPool.Get().(*bytes.Buffer)
614- buf.Reset()
615- defer bufPool.Put(buf)
616-
617- if err := s.templates.ExecuteTemplate(buf, name, data); err != nil {
618- s.logger.Error("template error", "error", err, "template", name)
619- http.Error(w, err.Error(), http.StatusInternalServerError)
620- return
621- }
622-
623- path := r.URL.Path
624- if len(path) > 1 {
625- path = strings.TrimRight(path, "/")
626- }
627- baseData := map[string]any{
628- "Content": template.HTML(buf.String()),
629- "ActivePath": path,
630- }
631- if data != nil {
632- if u, ok := data["User"]; ok {
633- baseData["User"] = u
634 }355 }
635- if csrf, ok := data["CSRFToken"]; ok {356+ u, err := url.Parse(raw)
636- baseData["CSRFToken"] = csrf357+ if err != nil || u.Host == "" {
358+ continue
637 }359 }
638- if hasLLM, ok := data["HasLLM"]; ok {360+ if u.Scheme == "" {
639- baseData["HasLLM"] = hasLLM361+ u.Scheme = "https"
640 }362 }
363+ return u.Scheme + "://" + u.Host
641 }364 }
642-365+ return ""
643- if err := s.templates.ExecuteTemplate(w, "base.html", baseData); err != nil {
644- s.logger.Error("template error", "error", err, "template", "base.html")
645- http.Error(w, err.Error(), http.StatusInternalServerError)
646- }
647 }366 }
modified internal/server/session.go +2 -0
@@ -60,6 +60,7 @@ func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
6060 Path: "/",
6161 MaxAge: 86400 * 30,
6262 HttpOnly: true,
63+ Secure: s.secureCookies,
6364 SameSite: http.SameSiteLaxMode,
6465 })
6566 }
@@ -71,6 +72,7 @@ func (s *Server) clearUserSession(w http.ResponseWriter) {
7172 Path: "/",
7273 MaxAge: -1,
7374 HttpOnly: true,
75+ Secure: s.secureCookies,
7476 SameSite: http.SameSiteLaxMode,
7577 })
7678 }
@@ -60,6 +60,7 @@ func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) {
60 Path: "/",60 Path: "/",
61 MaxAge: 86400 * 30,61 MaxAge: 86400 * 30,
62 HttpOnly: true,62 HttpOnly: true,
63+ Secure: s.secureCookies,
63 SameSite: http.SameSiteLaxMode,64 SameSite: http.SameSiteLaxMode,
64 })65 })
65 }66 }
@@ -71,6 +72,7 @@ func (s *Server) clearUserSession(w http.ResponseWriter) {
71 Path: "/",72 Path: "/",
72 MaxAge: -1,73 MaxAge: -1,
73 HttpOnly: true,74 HttpOnly: true,
75+ Secure: s.secureCookies,
74 SameSite: http.SameSiteLaxMode,76 SameSite: http.SameSiteLaxMode,
75 })77 })
76 }78 }
modified internal/server/settings_handler.go +14 -15
@@ -3,15 +3,17 @@ package server
33 import (
44 "net/http"
55
6+ "github.com/go-chi/chi/v5"
7+
68 "pkg.rbrt.fr/glean/internal/ml"
79 )
810
911 func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
1012 user := currentUser(r)
1113
12- lang := r.PathValue("code")
14+ lang := chi.URLParam(r, "code")
1315 if lang == "" {
14- http.Error(w, "missing language code", http.StatusBadRequest)
16+ writeAPIError(w, http.StatusBadRequest, "missing language code")
1517 return
1618 }
1719
@@ -20,14 +22,14 @@ func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
2022 valid[known.Code] = true
2123 }
2224 if !valid[lang] {
23- http.Error(w, "unknown language", http.StatusBadRequest)
25+ writeAPIError(w, http.StatusBadRequest, "unknown language")
2426 return
2527 }
2628
2729 current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID)
2830 if err != nil {
2931 s.logger.Error("failed to get languages", "error", err)
30- http.Error(w, err.Error(), http.StatusInternalServerError)
32+ writeAPIError(w, http.StatusInternalServerError, err.Error())
3133 return
3234 }
3335
@@ -46,19 +48,18 @@ func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
4648
4749 if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil {
4850 s.logger.Error("failed to update languages", "error", err)
49- http.Error(w, err.Error(), http.StatusInternalServerError)
51+ writeAPIError(w, http.StatusInternalServerError, err.Error())
5052 return
5153 }
5254
53- w.Header().Set(HXRedirect, "/profile/"+user.DID)
54- w.WriteHeader(http.StatusOK)
55+ writeJSON(w, http.StatusOK, languagesResponse{Languages: nonNil(updated)})
5556 }
5657
5758 func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request) {
5859 user := currentUser(r)
5960
6061 if err := r.ParseForm(); err != nil {
61- http.Error(w, err.Error(), http.StatusBadRequest)
62+ writeAPIError(w, http.StatusBadRequest, err.Error())
6263 return
6364 }
6465
@@ -66,19 +67,18 @@ func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request
6667
6768 if err := s.dbs.Users.SetExpandedView(r.Context(), user.DID, enabled); err != nil {
6869 s.logger.Error("failed to update expanded view", "error", err)
69- http.Error(w, err.Error(), http.StatusInternalServerError)
70+ writeAPIError(w, http.StatusInternalServerError, err.Error())
7071 return
7172 }
7273
73- w.Header().Set("HX-Redirect", "/profile/"+user.DID)
74- w.WriteHeader(http.StatusOK)
74+ writeJSON(w, http.StatusOK, expandedViewResponse{ExpandedView: enabled})
7575 }
7676
7777 func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) {
7878 user := currentUser(r)
7979
8080 if err := r.ParseForm(); err != nil {
81- http.Error(w, err.Error(), http.StatusBadRequest)
81+ writeAPIError(w, http.StatusBadRequest, err.Error())
8282 return
8383 }
8484
@@ -86,10 +86,9 @@ func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Reques
8686
8787 if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil {
8888 s.logger.Error("failed to update digest enabled", "error", err)
89- http.Error(w, err.Error(), http.StatusInternalServerError)
89+ writeAPIError(w, http.StatusInternalServerError, err.Error())
9090 return
9191 }
9292
93- w.Header().Set("HX-Redirect", "/profile/"+user.DID)
94- w.WriteHeader(http.StatusOK)
93+ writeJSON(w, http.StatusOK, digestEnabledResponse{DigestEnabled: enabled})
9594 }
@@ -3,15 +3,17 @@ package server
3 import (3 import (
4 "net/http"4 "net/http"
5 5
6+ "github.com/go-chi/chi/v5"
7+
6 "pkg.rbrt.fr/glean/internal/ml"8 "pkg.rbrt.fr/glean/internal/ml"
7 )9 )
8 10
9 func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {11 func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
10 user := currentUser(r)12 user := currentUser(r)
11 13
12- lang := r.PathValue("code")14+ lang := chi.URLParam(r, "code")
13 if lang == "" {15 if lang == "" {
14- http.Error(w, "missing language code", http.StatusBadRequest)16+ writeAPIError(w, http.StatusBadRequest, "missing language code")
15 return17 return
16 }18 }
17 19
@@ -20,14 +22,14 @@ func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
20 valid[known.Code] = true22 valid[known.Code] = true
21 }23 }
22 if !valid[lang] {24 if !valid[lang] {
23- http.Error(w, "unknown language", http.StatusBadRequest)25+ writeAPIError(w, http.StatusBadRequest, "unknown language")
24 return26 return
25 }27 }
26 28
27 current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID)29 current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID)
28 if err != nil {30 if err != nil {
29 s.logger.Error("failed to get languages", "error", err)31 s.logger.Error("failed to get languages", "error", err)
30- http.Error(w, err.Error(), http.StatusInternalServerError)32+ writeAPIError(w, http.StatusInternalServerError, err.Error())
31 return33 return
32 }34 }
33 35
@@ -46,19 +48,18 @@ func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) {
46 48
47 if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil {49 if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil {
48 s.logger.Error("failed to update languages", "error", err)50 s.logger.Error("failed to update languages", "error", err)
49- http.Error(w, err.Error(), http.StatusInternalServerError)51+ writeAPIError(w, http.StatusInternalServerError, err.Error())
50 return52 return
51 }53 }
52 54
53- w.Header().Set(HXRedirect, "/profile/"+user.DID)55+ writeJSON(w, http.StatusOK, languagesResponse{Languages: nonNil(updated)})
54- w.WriteHeader(http.StatusOK)
55 }56 }
56 57
57 func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request) {58 func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request) {
58 user := currentUser(r)59 user := currentUser(r)
59 60
60 if err := r.ParseForm(); err != nil {61 if err := r.ParseForm(); err != nil {
61- http.Error(w, err.Error(), http.StatusBadRequest)62+ writeAPIError(w, http.StatusBadRequest, err.Error())
62 return63 return
63 }64 }
64 65
@@ -66,19 +67,18 @@ func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request
66 67
67 if err := s.dbs.Users.SetExpandedView(r.Context(), user.DID, enabled); err != nil {68 if err := s.dbs.Users.SetExpandedView(r.Context(), user.DID, enabled); err != nil {
68 s.logger.Error("failed to update expanded view", "error", err)69 s.logger.Error("failed to update expanded view", "error", err)
69- http.Error(w, err.Error(), http.StatusInternalServerError)70+ writeAPIError(w, http.StatusInternalServerError, err.Error())
70 return71 return
71 }72 }
72 73
73- w.Header().Set("HX-Redirect", "/profile/"+user.DID)74+ writeJSON(w, http.StatusOK, expandedViewResponse{ExpandedView: enabled})
74- w.WriteHeader(http.StatusOK)
75 }75 }
76 76
77 func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) {77 func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) {
78 user := currentUser(r)78 user := currentUser(r)
79 79
80 if err := r.ParseForm(); err != nil {80 if err := r.ParseForm(); err != nil {
81- http.Error(w, err.Error(), http.StatusBadRequest)81+ writeAPIError(w, http.StatusBadRequest, err.Error())
82 return82 return
83 }83 }
84 84
@@ -86,10 +86,9 @@ func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Reques
86 86
87 if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil {87 if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil {
88 s.logger.Error("failed to update digest enabled", "error", err)88 s.logger.Error("failed to update digest enabled", "error", err)
89- http.Error(w, err.Error(), http.StatusInternalServerError)89+ writeAPIError(w, http.StatusInternalServerError, err.Error())
90 return90 return
91 }91 }
92 92
93- w.Header().Set("HX-Redirect", "/profile/"+user.DID)93+ writeJSON(w, http.StatusOK, digestEnabledResponse{DigestEnabled: enabled})
94- w.WriteHeader(http.StatusOK)
95 }94 }
modified internal/server/sitemap_handler.go +1 -1
@@ -43,7 +43,7 @@ func (s *Server) handleSitemap(w http.ResponseWriter, _ *http.Request) {
4343
4444 func (s *Server) baseURL() string {
4545 if s.clientID == "" {
46- return "http://localhost:8080"
46+ return "http://localhost:3000"
4747 }
4848 host := s.clientID
4949 host, _ = strings.CutPrefix(host, "https://")
@@ -43,7 +43,7 @@ func (s *Server) handleSitemap(w http.ResponseWriter, _ *http.Request) {
43 43
44 func (s *Server) baseURL() string {44 func (s *Server) baseURL() string {
45 if s.clientID == "" {45 if s.clientID == "" {
46- return "http://localhost:8080"46+ return "http://localhost:3000"
47 }47 }
48 host := s.clientID48 host := s.clientID
49 host, _ = strings.CutPrefix(host, "https://")49 host, _ = strings.CutPrefix(host, "https://")
modified internal/server/stats_handler.go +10 -10
@@ -12,25 +12,25 @@ import (
1212 )
1313
1414 type metricFamily struct {
15- Name string
16- Type string
17- Description string
18- Labels map[string]string
19- Value float64
15+ Name string `json:"name"`
16+ Type string `json:"type"`
17+ Description string `json:"description"`
18+ Labels map[string]string `json:"labels"`
19+ Value float64 `json:"value"`
2020 }
2121
2222 func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
23+ userObj := new(toUser(currentUser(r)))
2324 metrics, err := s.fetchMetrics()
2425 if err != nil {
2526 s.logger.Warn("failed to fetch metrics", "error", err)
26- http.Error(w, "Failed to load metrics", http.StatusInternalServerError)
27+ writeAPIError(w, http.StatusInternalServerError, "Failed to load metrics")
2728 return
2829 }
2930
30- user := currentUser(r)
31- s.render(w, r, "stats.html", map[string]any{
32- "User": user,
33- "Metrics": metrics,
31+ writeJSON(w, http.StatusOK, statsResponse{
32+ User: userObj,
33+ Metrics: metrics,
3434 })
3535 }
3636
@@ -12,25 +12,25 @@ import (
12 )12 )
13 13
14 type metricFamily struct {14 type metricFamily struct {
15- Name string15+ Name string `json:"name"`
16- Type string16+ Type string `json:"type"`
17- Description string17+ Description string `json:"description"`
18- Labels map[string]string18+ Labels map[string]string `json:"labels"`
19- Value float6419+ Value float64 `json:"value"`
20 }20 }
21 21
22 func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {22 func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
23+ userObj := new(toUser(currentUser(r)))
23 metrics, err := s.fetchMetrics()24 metrics, err := s.fetchMetrics()
24 if err != nil {25 if err != nil {
25 s.logger.Warn("failed to fetch metrics", "error", err)26 s.logger.Warn("failed to fetch metrics", "error", err)
26- http.Error(w, "Failed to load metrics", http.StatusInternalServerError)27+ writeAPIError(w, http.StatusInternalServerError, "Failed to load metrics")
27 return28 return
28 }29 }
29 30
30- user := currentUser(r)31+ writeJSON(w, http.StatusOK, statsResponse{
31- s.render(w, r, "stats.html", map[string]any{32+ User: userObj,
32- "User": user,33+ Metrics: metrics,
33- "Metrics": metrics,
34 })34 })
35 }35 }
36 36
added internal/server/sync_handlers.go +120 -0
new file mode 100644
@@ -0,0 +1,120 @@
1+package server
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bluesky-social/indigo/atproto/syntax"
8+
9+ "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/metrics"
11+)
12+
13+func (s *Server) BackfillFromCollectionDir(ctx context.Context, collectionDirURL string, concurrency int) {
14+ if collectionDirURL == "" {
15+ return
16+ }
17+
18+ s.logger.Info("backfilling from collection directory", "url", collectionDirURL)
19+
20+ dids, err := atproto.FetchSubscriberDIDs(ctx, collectionDirURL)
21+ if err != nil {
22+ s.logger.Error("failed to fetch subscriber DIDs", "error", err)
23+ return
24+ }
25+
26+ existing, err := s.dbs.Users.UserDIDs(ctx)
27+ if err != nil {
28+ s.logger.Error("failed to list existing users", "error", err)
29+ return
30+ }
31+
32+ var missing []string
33+ for _, did := range dids {
34+ if !existing[did] {
35+ missing = append(missing, did)
36+ }
37+ }
38+
39+ s.logger.Info("collection directory backfill", "total", len(dids), "missing", len(missing))
40+
41+ sem := make(chan struct{}, concurrency)
42+ var wg sync.WaitGroup
43+
44+ for _, did := range missing {
45+ if ctx.Err() != nil {
46+ break
47+ }
48+
49+ sem <- struct{}{}
50+ wg.Add(1)
51+
52+ go func(did string) {
53+ defer func() { <-sem }()
54+ defer wg.Done()
55+
56+ if _, err := s.dbs.Users.CreateUser(ctx, did); err != nil {
57+ s.logger.Error("failed to create user during backfill", "error", err, "did", did)
58+ return
59+ }
60+
61+ pdsURL, err := atproto.ResolvePDSEndpoint(ctx, did)
62+ if err != nil {
63+ s.logger.Error("failed to resolve PDS for backfill", "error", err, "did", did)
64+ return
65+ }
66+
67+ client := atproto.NewUnauthenticatedClient(pdsURL)
68+ sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
69+ if err := sync.Run(ctx, did); err != nil {
70+ s.logger.Error("backfill sync failed", "error", err, "did", did)
71+ }
72+ }(did)
73+ }
74+
75+ wg.Wait()
76+ s.logger.Info("collection directory backfill complete")
77+}
78+
79+func (s *Server) runSyncAll(ctx context.Context) {
80+ if n, err := s.oauthStore.CountActiveUsers(ctx); err == nil {
81+ metrics.ActiveUsers.Set(float64(n))
82+ }
83+
84+ users, err := s.dbs.Users.ListUsers(ctx)
85+ if err != nil {
86+ s.logger.Error("failed to list users for sync", "error", err)
87+ return
88+ }
89+
90+ for _, u := range users {
91+ sessionIDs, err := s.oauthStore.ListSessionsForDID(ctx, u.DID)
92+ if err != nil || len(sessionIDs) == 0 {
93+ continue
94+ }
95+
96+ did, err := syntax.ParseDID(u.DID)
97+ if err != nil {
98+ continue
99+ }
100+
101+ sess, err := s.oauth.ResumeSession(ctx, did, sessionIDs[0])
102+ if err != nil {
103+ s.logger.Warn("failed to resume session for periodic sync", "error", err, "did", u.DID)
104+ continue
105+ }
106+
107+ client := atproto.NewClient(sess.APIClient())
108+ sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
109+ if err := sync.Run(ctx, u.DID); err != nil {
110+ metrics.SyncErrors.Inc()
111+ s.logger.Error("periodic sync failed", "error", err, "did", u.DID)
112+ }
113+
114+ metrics.SyncRuns.Inc()
115+ }
116+
117+ if err := s.dbs.Articles.RecountSubscriberCounts(ctx); err != nil {
118+ s.logger.Error("recount subscriber counts failed", "error", err)
119+ }
120+}
new file mode 100644
@@ -0,0 +1,120 @@
1+package server
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bluesky-social/indigo/atproto/syntax"
8+
9+ "pkg.rbrt.fr/glean/internal/atproto"
10+ "pkg.rbrt.fr/glean/internal/metrics"
11+)
12+
13+func (s *Server) BackfillFromCollectionDir(ctx context.Context, collectionDirURL string, concurrency int) {
14+ if collectionDirURL == "" {
15+ return
16+ }
17+
18+ s.logger.Info("backfilling from collection directory", "url", collectionDirURL)
19+
20+ dids, err := atproto.FetchSubscriberDIDs(ctx, collectionDirURL)
21+ if err != nil {
22+ s.logger.Error("failed to fetch subscriber DIDs", "error", err)
23+ return
24+ }
25+
26+ existing, err := s.dbs.Users.UserDIDs(ctx)
27+ if err != nil {
28+ s.logger.Error("failed to list existing users", "error", err)
29+ return
30+ }
31+
32+ var missing []string
33+ for _, did := range dids {
34+ if !existing[did] {
35+ missing = append(missing, did)
36+ }
37+ }
38+
39+ s.logger.Info("collection directory backfill", "total", len(dids), "missing", len(missing))
40+
41+ sem := make(chan struct{}, concurrency)
42+ var wg sync.WaitGroup
43+
44+ for _, did := range missing {
45+ if ctx.Err() != nil {
46+ break
47+ }
48+
49+ sem <- struct{}{}
50+ wg.Add(1)
51+
52+ go func(did string) {
53+ defer func() { <-sem }()
54+ defer wg.Done()
55+
56+ if _, err := s.dbs.Users.CreateUser(ctx, did); err != nil {
57+ s.logger.Error("failed to create user during backfill", "error", err, "did", did)
58+ return
59+ }
60+
61+ pdsURL, err := atproto.ResolvePDSEndpoint(ctx, did)
62+ if err != nil {
63+ s.logger.Error("failed to resolve PDS for backfill", "error", err, "did", did)
64+ return
65+ }
66+
67+ client := atproto.NewUnauthenticatedClient(pdsURL)
68+ sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
69+ if err := sync.Run(ctx, did); err != nil {
70+ s.logger.Error("backfill sync failed", "error", err, "did", did)
71+ }
72+ }(did)
73+ }
74+
75+ wg.Wait()
76+ s.logger.Info("collection directory backfill complete")
77+}
78+
79+func (s *Server) runSyncAll(ctx context.Context) {
80+ if n, err := s.oauthStore.CountActiveUsers(ctx); err == nil {
81+ metrics.ActiveUsers.Set(float64(n))
82+ }
83+
84+ users, err := s.dbs.Users.ListUsers(ctx)
85+ if err != nil {
86+ s.logger.Error("failed to list users for sync", "error", err)
87+ return
88+ }
89+
90+ for _, u := range users {
91+ sessionIDs, err := s.oauthStore.ListSessionsForDID(ctx, u.DID)
92+ if err != nil || len(sessionIDs) == 0 {
93+ continue
94+ }
95+
96+ did, err := syntax.ParseDID(u.DID)
97+ if err != nil {
98+ continue
99+ }
100+
101+ sess, err := s.oauth.ResumeSession(ctx, did, sessionIDs[0])
102+ if err != nil {
103+ s.logger.Warn("failed to resume session for periodic sync", "error", err, "did", u.DID)
104+ continue
105+ }
106+
107+ client := atproto.NewClient(sess.APIClient())
108+ sync := atproto.NewSync(s.dbs.Articles, s.dbs.Users, client, s.logger)
109+ if err := sync.Run(ctx, u.DID); err != nil {
110+ metrics.SyncErrors.Inc()
111+ s.logger.Error("periodic sync failed", "error", err, "did", u.DID)
112+ }
113+
114+ metrics.SyncRuns.Inc()
115+ }
116+
117+ if err := s.dbs.Articles.RecountSubscriberCounts(ctx); err != nil {
118+ s.logger.Error("recount subscriber counts failed", "error", err)
119+ }
120+}
deleted internal/server/terms_handler.go +0 -7
deleted file mode 100644
@@ -1,7 +0,0 @@
1-package server
2-
3-import "net/http"
4-
5-func (s *Server) handleTerms(w http.ResponseWriter, r *http.Request) {
6- s.render(w, r, "terms.html", map[string]any{})
7-}
deleted file mode 100644
@@ -1,7 +0,0 @@
1-package server
2-
3-import "net/http"
4-
5-func (s *Server) handleTerms(w http.ResponseWriter, r *http.Request) {
6- s.render(w, r, "terms.html", map[string]any{})
7-}
deleted internal/tmpl/404.html +0 -15
deleted file mode 100644
@@ -1,15 +0,0 @@
1-{{define "404.html"}}
2-<div class="flex items-center justify-center min-h-screen px-4 py-12">
3- <div class="max-w-sm w-full text-center">
4- <div class="flex justify-center mb-8">
5- <a href="/" class="w-16 h-16 block">{{template "logo-icon"}}</a>
6- </div>
7- <div class="text-6xl font-bold text-spot-green/30 mb-4">404</div>
8- <h1 class="text-2xl font-bold text-spot-text mb-2">Page not found</h1>
9- <p class="text-spot-secondary text-sm mb-8">This page doesn't exist, but your feeds do.</p>
10- <a href="/dashboard" class="inline-flex items-center justify-center gap-2 bg-spot-green text-white rounded-pill px-6 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
11- Back to Dashboard
12- </a>
13- </div>
14-</div>
15-{{end}}
deleted file mode 100644
@@ -1,15 +0,0 @@
1-{{define "404.html"}}
2-<div class="flex items-center justify-center min-h-screen px-4 py-12">
3- <div class="max-w-sm w-full text-center">
4- <div class="flex justify-center mb-8">
5- <a href="/" class="w-16 h-16 block">{{template "logo-icon"}}</a>
6- </div>
7- <div class="text-6xl font-bold text-spot-green/30 mb-4">404</div>
8- <h1 class="text-2xl font-bold text-spot-text mb-2">Page not found</h1>
9- <p class="text-spot-secondary text-sm mb-8">This page doesn't exist, but your feeds do.</p>
10- <a href="/dashboard" class="inline-flex items-center justify-center gap-2 bg-spot-green text-white rounded-pill px-6 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
11- Back to Dashboard
12- </a>
13- </div>
14-</div>
15-{{end}}
deleted internal/tmpl/article_detail.html +0 -369
deleted file mode 100644
@@ -1,369 +0,0 @@
1-{{define "article_detail.html"}}
2-<div class="max-w-3xl mx-auto">
3- <div class="flex items-center justify-between mb-6">
4- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
5- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
6- Back
7- </a>
8- {{if .NextID}}
9- <a href="/articles/{{.NextID}}{{.NextSuffix}}" id="next-link"
10- class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
11- Next
12- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
13- </a>
14- {{end}}
15- </div>
16-
17- <article>
18- <h1 class="text-2xl font-bold leading-tight" style="letter-spacing: -0.02em;">
19- {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-green transition">{{.Article.Title}}</a>
20- {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}}
21- </h1>
22-
23- <div class="flex items-center gap-2.5 mt-4 text-sm text-spot-secondary flex-wrap">
24- {{if .Article.Author.Valid}}<span class="font-medium">{{.Article.Author.String}}</span>{{end}}
25- {{if .Article.Published.Valid}}<span class="text-spot-muted">&middot;</span><span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
26- {{if .Feed}}
27- <span class="text-spot-muted">&middot;</span>
28- <a href="/articles?feed={{.Feed.FeedURL}}" class="inline-flex items-center gap-1.5 hover:text-spot-green transition">
29- {{if .Feed}}{{template "favicon" dict "src" .Feed.FaviconURL.String "size" "w-4 h-4"}}{{end}}
30- {{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}
31- </a>
32- {{end}}
33- </div>
34-
35- <div class="flex items-center gap-2 mt-6 flex-wrap">
36- <button hx-post="/articles/{{.Article.ID}}/like?bordered=true" hx-target="this" hx-swap="outerHTML"
37- title="{{if .HasLiked}}Unlike{{else}}Like{{end}}"
38- class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition {{if .HasLiked}}text-spot-red bg-spot-red/15 hover:bg-spot-red/25{{else}}text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15{{end}}">
39- <svg class="w-3.5 h-3.5" fill="{{if .HasLiked}}currentColor{{else}}none{{end}}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">{{template "icon-heart"}}</svg>
40- <span>{{.LikeCount}}</span>
41- </button>
42-
43- <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}"
44- hx-target="#read-btn-{{.Article.ID}}" hx-swap="outerHTML"
45- id="read-btn-{{.Article.ID}}"
46- title="{{if .ReadState.IsRead}}Mark as unread{{else}}Mark as read{{end}}"
47- class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition {{if .ReadState.IsRead}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{else}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{end}}">
48- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
49- <span>{{if .ReadState.IsRead}}Unread{{else}}Read{{end}}</span>
50- </button>
51-
52- {{if .Article.URL.Valid}}
53- <a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" title="Open original"
54- class="group inline-flex items-center gap-1.5 text-[10px] text-spot-text uppercase tracking-button px-2.5 py-1 rounded-pill bg-spot-hover hover:brightness-110 transition">
55- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-4.5-6H18m0 0v4.5m0-4.5L10.5 13.5"/></svg>
56- <span>Original</span>
57- </a>
58-
59- <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{.Article.URL.String}}"
60- target="_blank" rel="noopener noreferrer" title="Share on Bluesky"
61- class="group inline-flex items-center gap-1.5 text-[10px] text-spot-text uppercase tracking-button px-2.5 py-1 rounded-pill bg-spot-hover hover:text-spot-blue hover:bg-spot-blue/15 transition">
62- <span class="w-3.5 h-3.5 inline-flex shrink-0">{{template "icon-bluesky"}}</span>
63- <span>Share</span>
64- </a>
65- {{end}}
66- </div>
67-
68- {{if .Article.URL.Valid}}
69- {{with youtubeID .Article.URL.String}}
70- <div class="my-8 aspect-video w-full rounded-xl overflow-hidden shadow-spot-heavy">
71- <iframe class="w-full h-full" src="https://www.youtube.com/embed/{{.}}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
72- </div>
73- {{end}}
74- {{end}}
75-
76- {{if or .Article.Content.Valid .Article.FullContent.Valid .Article.Summary.Valid}}
77- <hr class="my-8 border-spot-divider">
78- {{end}}
79-
80- {{if .Article.Content.Valid}}
81- <div class="article-body">
82- {{sanitizeHTML .Article.Content.String}}
83- </div>
84- {{else if .Article.FullContent.Valid}}
85- <div class="article-body">
86- {{sanitizeHTML .Article.FullContent.String}}
87- </div>
88- {{else if .Article.Summary.Valid}}
89- <div class="article-body">
90- {{sanitizeHTML .Article.Summary.String}}
91- </div>
92- {{end}}
93-
94- {{if .Annotations}}
95- <script id="annotation-quotes" type="application/json">[{{range $i, $a := .Annotations}}{{if $i}},{{end}}{{if $a.Quote.Valid}}{"id":{{$a.ID}},"quote":{{js $a.Quote.String}}}{{end}}{{end}}]</script>
96- {{end}}
97-
98- {{if and (not .Article.Content.Valid) (not .Article.FullContent.Valid) .Article.URL.Valid (not (isEmbedURL .Article.URL.String))}}
99- <div id="article-content" class="mt-6">
100- <button hx-post="/articles/{{.Article.ID}}/fetch-content" hx-target="#article-content" hx-swap="outerHTML"
101- class="border border-spot-outline text-spot-text rounded-pill px-3 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
102- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
103- Fetch full content
104- </button>
105- </div>
106- {{else if not .Article.URL.Valid}}
107- <p class="text-spot-secondary">No content available.</p>
108- {{end}}
109- </article>
110-
111- <hr class="my-8 border-spot-divider">
112-
113- <section>
114- <div class="flex items-center justify-between mb-5">
115- <h2 class="text-lg font-semibold text-spot-text">Annotations</h2>
116- <button id="toggle-highlights" class="text-xs text-spot-secondary hover:text-spot-text transition inline-flex items-center gap-1.5">
117- <span id="toggle-highlights-icon" class="w-4 h-4 inline-flex">
118- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg>
119- </span>
120- <span id="toggle-highlights-label">Hide highlights</span>
121- </button>
122- </div>
123-
124- <form id="comment-form" hx-post="/library/create" hx-target="#annotations-list" hx-swap="beforeend"
125- hx-on::after-request="this.reset()"
126- class="bg-spot-surface rounded-xl shadow-spot p-4 mb-4 space-y-3">
127- {{csrfInput .CSRFToken}}
128- <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}">
129- <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}">
130- <textarea name="note" rows="2" placeholder="Add a comment..."
131- class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none"></textarea>
132- <div class="flex gap-2">
133- <input type="text" name="tags" placeholder="Tags (comma separated)"
134- class="flex-1 min-w-0 bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
135- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition shrink-0">Comment</button>
136- </div>
137- </form>
138-
139- <div id="annotations-list" class="space-y-3">
140- {{range .Annotations}}
141- {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}}
142- {{else}}
143- <p class="text-sm text-spot-secondary text-center py-6">No annotations yet. Select text above or add a note.</p>
144- {{end}}
145- </div>
146- </section>
147-
148- <div id="annotation-popover" class="hidden fixed z-50 w-[22rem] max-w-[calc(100vw-2rem)] bg-spot-surface rounded-xl shadow-spot-heavy border border-spot-divider p-4 space-y-3">
149- <div class="flex items-center justify-between">
150- <span class="text-[10px] font-bold uppercase tracking-button text-spot-secondary">Annotate</span>
151- <button type="button" id="annotation-popover-close" aria-label="Close" class="text-spot-secondary hover:text-spot-text transition">
152- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
153- </button>
154- </div>
155- <form id="annotation-form" hx-post="/library/create" hx-target="#annotations-list" hx-swap="beforeend" class="space-y-3">
156- {{csrfInput .CSRFToken}}
157- <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}">
158- <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}">
159- <div id="quote-highlight" class="hidden mb-1">
160- <blockquote id="quote-preview" class="border-l-2 border-spot-green pl-3 text-sm text-spot-secondary italic"></blockquote>
161- </div>
162- <input type="hidden" name="quote" id="quote-input">
163- <textarea name="note" rows="2" placeholder="Add a note..."
164- class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none"></textarea>
165- <div class="flex gap-2">
166- <input type="text" name="tags" placeholder="Tags (comma separated)"
167- class="flex-1 min-w-0 bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
168- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition shrink-0">Annotate</button>
169- </div>
170- </form>
171- </div>
172-
173- <div class="flex items-center justify-between mt-8 mb-4">
174- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
175- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
176- Back
177- </a>
178- {{if .NextID}}
179- <a href="/articles/{{.NextID}}{{.NextSuffix}}"
180- class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
181- Next
182- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
183- </a>
184- {{end}}
185- </div>
186-</div>
187-
188-<script>
189-(function() {
190- var form = document.getElementById('annotation-form');
191- var quoteInput = document.getElementById('quote-input');
192- var quotePreview = document.getElementById('quote-preview');
193- var quoteHighlight = document.getElementById('quote-highlight');
194- var popover = document.getElementById('annotation-popover');
195- var closeBtn = document.getElementById('annotation-popover-close');
196- var noteField = form.querySelector('textarea[name="note"]');
197-
198- function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
199-
200- function position(rect) {
201- var margin = 8;
202- var popRect = popover.getBoundingClientRect();
203- var width = popRect.width;
204- var height = popRect.height;
205-
206- var left = clamp(rect.left + (rect.width - width) / 2, margin, document.documentElement.clientWidth - width - margin);
207-
208- var top = rect.bottom + margin;
209- if (document.documentElement.clientHeight - rect.bottom < height + margin && rect.top > height + margin) {
210- top = rect.top - height - margin;
211- }
212- top = Math.max(margin, top);
213-
214- popover.style.left = left + 'px';
215- popover.style.top = top + 'px';
216- }
217-
218- function openForSelection(sel, text) {
219- if (text.length > 1000) text = text.substring(0, 1000);
220- quoteInput.value = text;
221- quotePreview.textContent = text;
222- quoteHighlight.classList.remove('hidden');
223- popover.classList.remove('hidden');
224- position(sel.getRangeAt(0).getBoundingClientRect());
225- noteField.focus();
226- }
227-
228- function close() {
229- popover.classList.add('hidden');
230- popover.style.top = '';
231- popover.style.left = '';
232- form.reset();
233- quoteInput.value = '';
234- quoteHighlight.classList.add('hidden');
235- var sel = window.getSelection();
236- if (sel) sel.removeAllRanges();
237- }
238-
239- document.addEventListener('mouseup', function(e) {
240- if (!popover.classList.contains('hidden') && popover.contains(e.target)) return;
241- var sel = window.getSelection();
242- var text = sel.toString().trim();
243- if (!text) return;
244-
245- var body = e.target.closest('.article-body');
246- if (!body) return;
247-
248- openForSelection(sel, text);
249- });
250-
251- closeBtn.addEventListener('click', close);
252-
253- document.addEventListener('mousedown', function(e) {
254- if (popover.classList.contains('hidden')) return;
255- if (popover.contains(e.target)) return;
256- if (e.target.closest('.article-body')) return;
257- close();
258- });
259-
260- document.addEventListener('keydown', function(e) {
261- if (e.key === 'Escape' && !popover.classList.contains('hidden')) close();
262- });
263-
264- var origReset = form.reset.bind(form);
265- form.reset = function() {
266- origReset();
267- quoteInput.value = '';
268- quoteHighlight.classList.add('hidden');
269- };
270-
271- form.addEventListener('htmx:afterRequest', function(e) {
272- if (e.detail && e.detail.successful) close();
273- });
274-
275- function highlightQuotes() {
276- var el = document.getElementById('annotation-quotes');
277- if (!el) return;
278- var quotes = JSON.parse(el.textContent);
279- el.remove();
280-
281- var body = document.querySelector('.article-body');
282- if (!body) return;
283-
284- var treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
285- var textNodes = [];
286- while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode);
287-
288- quotes.forEach(function(a) {
289- if (!a.quote) return;
290- var target = a.quote.trim();
291- if (!target) return;
292-
293- for (var i = 0; i < textNodes.length; i++) {
294- var node = textNodes[i];
295- var idx = node.nodeValue.indexOf(target);
296- if (idx === -1) continue;
297-
298- var range = document.createRange();
299- range.setStart(node, idx);
300- range.setEnd(node, idx + target.length);
301-
302- var mark = document.createElement('mark');
303- mark.setAttribute('data-annotation', a.id);
304- mark.className = 'annotation-highlight cursor-pointer';
305- mark.addEventListener('click', function() {
306- var card = document.getElementById('annotation-' + this.getAttribute('data-annotation'));
307- if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
308- });
309- range.surroundContents(mark);
310-
311- textNodes = [];
312- treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
313- while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode);
314- break;
315- }
316- });
317- }
318-
319- var highlightsEnabled = localStorage.getItem('annotation-highlights') !== 'false';
320-
321- function applyHighlightsState() {
322- var marks = document.querySelectorAll('.annotation-highlight');
323- marks.forEach(function(m) {
324- m.style.backgroundColor = highlightsEnabled ? '' : 'transparent';
325- m.style.borderBottomColor = highlightsEnabled ? '' : 'transparent';
326- });
327- var btn = document.getElementById('toggle-highlights');
328- var label = document.getElementById('toggle-highlights-label');
329- if (btn) label.textContent = highlightsEnabled ? 'Hide highlights' : 'Show highlights';
330- }
331-
332- if (highlightsEnabled) highlightQuotes();
333- applyHighlightsState();
334-
335- document.getElementById('toggle-highlights').addEventListener('click', function() {
336- highlightsEnabled = !highlightsEnabled;
337- localStorage.setItem('annotation-highlights', highlightsEnabled);
338-
339- var body = document.querySelector('.article-body');
340- if (highlightsEnabled && body && !document.querySelector('.annotation-highlight') && document.getElementById('annotation-quotes')) {
341- highlightQuotes();
342- }
343-
344- applyHighlightsState();
345- });
346-
347- document.addEventListener('keydown', function(e) {
348- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
349- if (e.key === 'l') {
350- var btn = document.querySelector('[hx-post*="/like"]');
351- if (btn) btn.click();
352- } else if (e.key === 'm') {
353- var rbtn = document.getElementById('read-btn-{{.Article.ID}}');
354- if (rbtn) rbtn.click();
355- } else if (e.key === 'Escape') {
356- history.back();
357- } else if (e.key === 'o') {
358- var origLink = document.querySelector('a[target="_blank"]');
359- if (origLink && origLink.href) window.open(origLink.href, '_blank');
360- } else if (e.key === 'ArrowRight') {
361- var nextLink = document.getElementById('next-link');
362- if (nextLink) nextLink.click();
363- } else if (e.key === 'ArrowLeft') {
364- history.back();
365- }
366- });
367-})();
368-</script>
369-{{end}}
deleted file mode 100644
@@ -1,369 +0,0 @@
1-{{define "article_detail.html"}}
2-<div class="max-w-3xl mx-auto">
3- <div class="flex items-center justify-between mb-6">
4- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
5- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
6- Back
7- </a>
8- {{if .NextID}}
9- <a href="/articles/{{.NextID}}{{.NextSuffix}}" id="next-link"
10- class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
11- Next
12- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
13- </a>
14- {{end}}
15- </div>
16-
17- <article>
18- <h1 class="text-2xl font-bold leading-tight" style="letter-spacing: -0.02em;">
19- {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-green transition">{{.Article.Title}}</a>
20- {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}}
21- </h1>
22-
23- <div class="flex items-center gap-2.5 mt-4 text-sm text-spot-secondary flex-wrap">
24- {{if .Article.Author.Valid}}<span class="font-medium">{{.Article.Author.String}}</span>{{end}}
25- {{if .Article.Published.Valid}}<span class="text-spot-muted">&middot;</span><span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
26- {{if .Feed}}
27- <span class="text-spot-muted">&middot;</span>
28- <a href="/articles?feed={{.Feed.FeedURL}}" class="inline-flex items-center gap-1.5 hover:text-spot-green transition">
29- {{if .Feed}}{{template "favicon" dict "src" .Feed.FaviconURL.String "size" "w-4 h-4"}}{{end}}
30- {{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}
31- </a>
32- {{end}}
33- </div>
34-
35- <div class="flex items-center gap-2 mt-6 flex-wrap">
36- <button hx-post="/articles/{{.Article.ID}}/like?bordered=true" hx-target="this" hx-swap="outerHTML"
37- title="{{if .HasLiked}}Unlike{{else}}Like{{end}}"
38- class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition {{if .HasLiked}}text-spot-red bg-spot-red/15 hover:bg-spot-red/25{{else}}text-spot-text bg-spot-hover hover:text-spot-red hover:bg-spot-red/15{{end}}">
39- <svg class="w-3.5 h-3.5" fill="{{if .HasLiked}}currentColor{{else}}none{{end}}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">{{template "icon-heart"}}</svg>
40- <span>{{.LikeCount}}</span>
41- </button>
42-
43- <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}"
44- hx-target="#read-btn-{{.Article.ID}}" hx-swap="outerHTML"
45- id="read-btn-{{.Article.ID}}"
46- title="{{if .ReadState.IsRead}}Mark as unread{{else}}Mark as read{{end}}"
47- class="group inline-flex items-center gap-1.5 text-[10px] uppercase tracking-button px-2.5 py-1 rounded-pill transition {{if .ReadState.IsRead}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{else}}text-spot-text bg-spot-hover hover:text-spot-green hover:bg-spot-green/15{{end}}">
48- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
49- <span>{{if .ReadState.IsRead}}Unread{{else}}Read{{end}}</span>
50- </button>
51-
52- {{if .Article.URL.Valid}}
53- <a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" title="Open original"
54- class="group inline-flex items-center gap-1.5 text-[10px] text-spot-text uppercase tracking-button px-2.5 py-1 rounded-pill bg-spot-hover hover:brightness-110 transition">
55- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-4.5-6H18m0 0v4.5m0-4.5L10.5 13.5"/></svg>
56- <span>Original</span>
57- </a>
58-
59- <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{.Article.URL.String}}"
60- target="_blank" rel="noopener noreferrer" title="Share on Bluesky"
61- class="group inline-flex items-center gap-1.5 text-[10px] text-spot-text uppercase tracking-button px-2.5 py-1 rounded-pill bg-spot-hover hover:text-spot-blue hover:bg-spot-blue/15 transition">
62- <span class="w-3.5 h-3.5 inline-flex shrink-0">{{template "icon-bluesky"}}</span>
63- <span>Share</span>
64- </a>
65- {{end}}
66- </div>
67-
68- {{if .Article.URL.Valid}}
69- {{with youtubeID .Article.URL.String}}
70- <div class="my-8 aspect-video w-full rounded-xl overflow-hidden shadow-spot-heavy">
71- <iframe class="w-full h-full" src="https://www.youtube.com/embed/{{.}}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
72- </div>
73- {{end}}
74- {{end}}
75-
76- {{if or .Article.Content.Valid .Article.FullContent.Valid .Article.Summary.Valid}}
77- <hr class="my-8 border-spot-divider">
78- {{end}}
79-
80- {{if .Article.Content.Valid}}
81- <div class="article-body">
82- {{sanitizeHTML .Article.Content.String}}
83- </div>
84- {{else if .Article.FullContent.Valid}}
85- <div class="article-body">
86- {{sanitizeHTML .Article.FullContent.String}}
87- </div>
88- {{else if .Article.Summary.Valid}}
89- <div class="article-body">
90- {{sanitizeHTML .Article.Summary.String}}
91- </div>
92- {{end}}
93-
94- {{if .Annotations}}
95- <script id="annotation-quotes" type="application/json">[{{range $i, $a := .Annotations}}{{if $i}},{{end}}{{if $a.Quote.Valid}}{"id":{{$a.ID}},"quote":{{js $a.Quote.String}}}{{end}}{{end}}]</script>
96- {{end}}
97-
98- {{if and (not .Article.Content.Valid) (not .Article.FullContent.Valid) .Article.URL.Valid (not (isEmbedURL .Article.URL.String))}}
99- <div id="article-content" class="mt-6">
100- <button hx-post="/articles/{{.Article.ID}}/fetch-content" hx-target="#article-content" hx-swap="outerHTML"
101- class="border border-spot-outline text-spot-text rounded-pill px-3 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition inline-flex items-center gap-1.5">
102- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
103- Fetch full content
104- </button>
105- </div>
106- {{else if not .Article.URL.Valid}}
107- <p class="text-spot-secondary">No content available.</p>
108- {{end}}
109- </article>
110-
111- <hr class="my-8 border-spot-divider">
112-
113- <section>
114- <div class="flex items-center justify-between mb-5">
115- <h2 class="text-lg font-semibold text-spot-text">Annotations</h2>
116- <button id="toggle-highlights" class="text-xs text-spot-secondary hover:text-spot-text transition inline-flex items-center gap-1.5">
117- <span id="toggle-highlights-icon" class="w-4 h-4 inline-flex">
118- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42"/></svg>
119- </span>
120- <span id="toggle-highlights-label">Hide highlights</span>
121- </button>
122- </div>
123-
124- <form id="comment-form" hx-post="/library/create" hx-target="#annotations-list" hx-swap="beforeend"
125- hx-on::after-request="this.reset()"
126- class="bg-spot-surface rounded-xl shadow-spot p-4 mb-4 space-y-3">
127- {{csrfInput .CSRFToken}}
128- <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}">
129- <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}">
130- <textarea name="note" rows="2" placeholder="Add a comment..."
131- class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none"></textarea>
132- <div class="flex gap-2">
133- <input type="text" name="tags" placeholder="Tags (comma separated)"
134- class="flex-1 min-w-0 bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
135- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition shrink-0">Comment</button>
136- </div>
137- </form>
138-
139- <div id="annotations-list" class="space-y-3">
140- {{range .Annotations}}
141- {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}}
142- {{else}}
143- <p class="text-sm text-spot-secondary text-center py-6">No annotations yet. Select text above or add a note.</p>
144- {{end}}
145- </div>
146- </section>
147-
148- <div id="annotation-popover" class="hidden fixed z-50 w-[22rem] max-w-[calc(100vw-2rem)] bg-spot-surface rounded-xl shadow-spot-heavy border border-spot-divider p-4 space-y-3">
149- <div class="flex items-center justify-between">
150- <span class="text-[10px] font-bold uppercase tracking-button text-spot-secondary">Annotate</span>
151- <button type="button" id="annotation-popover-close" aria-label="Close" class="text-spot-secondary hover:text-spot-text transition">
152- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
153- </button>
154- </div>
155- <form id="annotation-form" hx-post="/library/create" hx-target="#annotations-list" hx-swap="beforeend" class="space-y-3">
156- {{csrfInput .CSRFToken}}
157- <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}">
158- <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}">
159- <div id="quote-highlight" class="hidden mb-1">
160- <blockquote id="quote-preview" class="border-l-2 border-spot-green pl-3 text-sm text-spot-secondary italic"></blockquote>
161- </div>
162- <input type="hidden" name="quote" id="quote-input">
163- <textarea name="note" rows="2" placeholder="Add a note..."
164- class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none"></textarea>
165- <div class="flex gap-2">
166- <input type="text" name="tags" placeholder="Tags (comma separated)"
167- class="flex-1 min-w-0 bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
168- <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition shrink-0">Annotate</button>
169- </div>
170- </form>
171- </div>
172-
173- <div class="flex items-center justify-between mt-8 mb-4">
174- <a href="javascript:history.back()" class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
175- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
176- Back
177- </a>
178- {{if .NextID}}
179- <a href="/articles/{{.NextID}}{{.NextSuffix}}"
180- class="text-sm text-spot-secondary hover:text-spot-text inline-flex items-center gap-1.5 transition">
181- Next
182- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
183- </a>
184- {{end}}
185- </div>
186-</div>
187-
188-<script>
189-(function() {
190- var form = document.getElementById('annotation-form');
191- var quoteInput = document.getElementById('quote-input');
192- var quotePreview = document.getElementById('quote-preview');
193- var quoteHighlight = document.getElementById('quote-highlight');
194- var popover = document.getElementById('annotation-popover');
195- var closeBtn = document.getElementById('annotation-popover-close');
196- var noteField = form.querySelector('textarea[name="note"]');
197-
198- function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
199-
200- function position(rect) {
201- var margin = 8;
202- var popRect = popover.getBoundingClientRect();
203- var width = popRect.width;
204- var height = popRect.height;
205-
206- var left = clamp(rect.left + (rect.width - width) / 2, margin, document.documentElement.clientWidth - width - margin);
207-
208- var top = rect.bottom + margin;
209- if (document.documentElement.clientHeight - rect.bottom < height + margin && rect.top > height + margin) {
210- top = rect.top - height - margin;
211- }
212- top = Math.max(margin, top);
213-
214- popover.style.left = left + 'px';
215- popover.style.top = top + 'px';
216- }
217-
218- function openForSelection(sel, text) {
219- if (text.length > 1000) text = text.substring(0, 1000);
220- quoteInput.value = text;
221- quotePreview.textContent = text;
222- quoteHighlight.classList.remove('hidden');
223- popover.classList.remove('hidden');
224- position(sel.getRangeAt(0).getBoundingClientRect());
225- noteField.focus();
226- }
227-
228- function close() {
229- popover.classList.add('hidden');
230- popover.style.top = '';
231- popover.style.left = '';
232- form.reset();
233- quoteInput.value = '';
234- quoteHighlight.classList.add('hidden');
235- var sel = window.getSelection();
236- if (sel) sel.removeAllRanges();
237- }
238-
239- document.addEventListener('mouseup', function(e) {
240- if (!popover.classList.contains('hidden') && popover.contains(e.target)) return;
241- var sel = window.getSelection();
242- var text = sel.toString().trim();
243- if (!text) return;
244-
245- var body = e.target.closest('.article-body');
246- if (!body) return;
247-
248- openForSelection(sel, text);
249- });
250-
251- closeBtn.addEventListener('click', close);
252-
253- document.addEventListener('mousedown', function(e) {
254- if (popover.classList.contains('hidden')) return;
255- if (popover.contains(e.target)) return;
256- if (e.target.closest('.article-body')) return;
257- close();
258- });
259-
260- document.addEventListener('keydown', function(e) {
261- if (e.key === 'Escape' && !popover.classList.contains('hidden')) close();
262- });
263-
264- var origReset = form.reset.bind(form);
265- form.reset = function() {
266- origReset();
267- quoteInput.value = '';
268- quoteHighlight.classList.add('hidden');
269- };
270-
271- form.addEventListener('htmx:afterRequest', function(e) {
272- if (e.detail && e.detail.successful) close();
273- });
274-
275- function highlightQuotes() {
276- var el = document.getElementById('annotation-quotes');
277- if (!el) return;
278- var quotes = JSON.parse(el.textContent);
279- el.remove();
280-
281- var body = document.querySelector('.article-body');
282- if (!body) return;
283-
284- var treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
285- var textNodes = [];
286- while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode);
287-
288- quotes.forEach(function(a) {
289- if (!a.quote) return;
290- var target = a.quote.trim();
291- if (!target) return;
292-
293- for (var i = 0; i < textNodes.length; i++) {
294- var node = textNodes[i];
295- var idx = node.nodeValue.indexOf(target);
296- if (idx === -1) continue;
297-
298- var range = document.createRange();
299- range.setStart(node, idx);
300- range.setEnd(node, idx + target.length);
301-
302- var mark = document.createElement('mark');
303- mark.setAttribute('data-annotation', a.id);
304- mark.className = 'annotation-highlight cursor-pointer';
305- mark.addEventListener('click', function() {
306- var card = document.getElementById('annotation-' + this.getAttribute('data-annotation'));
307- if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
308- });
309- range.surroundContents(mark);
310-
311- textNodes = [];
312- treeWalker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
313- while (treeWalker.nextNode()) textNodes.push(treeWalker.currentNode);
314- break;
315- }
316- });
317- }
318-
319- var highlightsEnabled = localStorage.getItem('annotation-highlights') !== 'false';
320-
321- function applyHighlightsState() {
322- var marks = document.querySelectorAll('.annotation-highlight');
323- marks.forEach(function(m) {
324- m.style.backgroundColor = highlightsEnabled ? '' : 'transparent';
325- m.style.borderBottomColor = highlightsEnabled ? '' : 'transparent';
326- });
327- var btn = document.getElementById('toggle-highlights');
328- var label = document.getElementById('toggle-highlights-label');
329- if (btn) label.textContent = highlightsEnabled ? 'Hide highlights' : 'Show highlights';
330- }
331-
332- if (highlightsEnabled) highlightQuotes();
333- applyHighlightsState();
334-
335- document.getElementById('toggle-highlights').addEventListener('click', function() {
336- highlightsEnabled = !highlightsEnabled;
337- localStorage.setItem('annotation-highlights', highlightsEnabled);
338-
339- var body = document.querySelector('.article-body');
340- if (highlightsEnabled && body && !document.querySelector('.annotation-highlight') && document.getElementById('annotation-quotes')) {
341- highlightQuotes();
342- }
343-
344- applyHighlightsState();
345- });
346-
347- document.addEventListener('keydown', function(e) {
348- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
349- if (e.key === 'l') {
350- var btn = document.querySelector('[hx-post*="/like"]');
351- if (btn) btn.click();
352- } else if (e.key === 'm') {
353- var rbtn = document.getElementById('read-btn-{{.Article.ID}}');
354- if (rbtn) rbtn.click();
355- } else if (e.key === 'Escape') {
356- history.back();
357- } else if (e.key === 'o') {
358- var origLink = document.querySelector('a[target="_blank"]');
359- if (origLink && origLink.href) window.open(origLink.href, '_blank');
360- } else if (e.key === 'ArrowRight') {
361- var nextLink = document.getElementById('next-link');
362- if (nextLink) nextLink.click();
363- } else if (e.key === 'ArrowLeft') {
364- history.back();
365- }
366- });
367-})();
368-</script>
369-{{end}}
deleted internal/tmpl/articles.html +0 -212
deleted file mode 100644
@@ -1,212 +0,0 @@
1-{{define "articles.html"}}
2- <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return={{if .FeedURL}}/articles?feed={{.FeedURL}}{{else}}/articles{{end}}" hx-trigger="every 30s" hx-swap="innerHTML"></div>
3-
4- {{if .Feed}}
5- <div class="mb-6">
6- <div class="flex items-center gap-2 mb-4">
7- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">&larr; All articles</a>
8- </div>
9- <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
10- <div class="flex items-center gap-3.5 min-w-0">
11- {{template "favicon" dict "src" .Feed.FaviconURL.String "size" "w-10 h-10"}}
12- <div class="min-w-0">
13- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">{{if .Feed.SiteURL.Valid}}<a href="{{.Feed.SiteURL.String}}" target="_blank" rel="noopener noreferrer" class="hover:text-spot-green transition">{{end}}{{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}{{if .Feed.SiteURL.Valid}}</a>{{end}}</h1>
14- {{if .Feed.SiteURL.Valid}}<a href="{{.Feed.SiteURL.String}}" target="_blank" rel="noopener noreferrer" class="text-xs text-spot-secondary hover:text-spot-green transition">{{.Feed.SiteURL.String}}</a>{{end}}
15- </div>
16- </div>
17- <div class="flex items-center gap-3 shrink-0">
18- {{if not .IsSubscribed}}
19- <form hx-post="/feeds/add" hx-swap="none" hx-on::after-request="if(event.detail.successful) window.location.reload()">
20- {{csrfInput .CSRFToken}}
21- <input type="hidden" name="feed_url" value="{{.FeedURL}}">
22- <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Subscribe</button>
23- </form>
24- {{else}}
25- <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
26- {{csrfInput .CSRFToken}}
27- <input type="hidden" name="feed" value="{{.FeedURL}}">
28- <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
29- </form>
30- {{end}}
31- </div>
32- </div>
33- {{if .Feed.Description.Valid}}<p class="text-sm text-spot-secondary mt-3 leading-relaxed">{{.Feed.Description.String}}</p>{{end}}
34- </div>
35-
36- <div class="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
37- <form method="GET" action="/articles" class="relative w-full sm:flex-1 sm:min-w-[200px]">
38- <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
39- class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
40- hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
41- <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
42- <input type="hidden" name="feed" value="{{.FeedURL}}">
43- {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
44- {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
45- </form>
46- <div class="flex items-center gap-1.5 shrink-0">
47- <a href="/articles?status=all&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
48- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
49- {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
50- All
51- </a>
52- <a href="/articles?status=unread&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
53- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
54- {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
55- Unread
56- </a>
57- <a href="/articles?status=read&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
58- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
59- {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
60- Read
61- </a>
62- <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}&feed={{.FeedURL}}{{if .Status}}&status={{.Status}}{{end}}"
63- class="px-2.5 py-1.5 rounded-pill text-xs transition
64- {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
65- title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
66- {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
67- </a>
68- </div>
69- </div>
70- {{else}}
71- <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
72- <div class="flex items-center gap-3">
73- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Articles</h1>
74- <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
75- {{csrfInput .CSRFToken}}
76- <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-3 py-1 text-[10px] font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
77- </form>
78- </div>
79- <div class="flex items-center gap-1.5">
80- <a href="/articles?status=all{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
81- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
82- {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
83- All
84- </a>
85- <a href="/articles?status=unread{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
86- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
87- {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
88- Unread
89- </a>
90- <a href="/articles?status=read{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
91- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
92- {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
93- Read
94- </a>
95- </div>
96- </div>
97- <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, {{if .SortOldest}}oldest first{{else}}newest first{{end}}.</p>
98-
99- <div class="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
100- <div class="flex items-center gap-1.5 flex-wrap">
101- <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}{{if .Status}}&status={{.Status}}{{end}}"
102- class="px-2.5 py-1.5 rounded-pill text-xs transition
103- {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
104- title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
105- {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
106- </a>
107- {{if .Categories}}
108- <span class="text-spot-divider">|</span>
109- <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}{{if .SortOldest}}sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
110- {{range .Categories}}
111- <a href="/articles?{{if $.Status}}status={{$.Status}}&{{end}}category={{.}}{{if $.SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
112- {{end}}
113- <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}category=__none__{{if .SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a>
114- {{end}}
115- </div>
116- <form method="GET" action="/articles" class="relative w-full sm:flex-1 sm:min-w-[180px]">
117- <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
118- class="w-full bg-spot-hover text-spot-text rounded-pill pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
119- hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
120- <svg class="w-3 h-3 text-spot-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
121- {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
122- {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
123- {{if .Category}}<input type="hidden" name="category" value="{{.Category}}">{{end}}
124- </form>
125- </div>
126- {{end}}
127-
128-<div id="article-list" class="space-y-3">
129- {{template "articles-content.html" .}}
130-</div>
131-
132-{{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}}
133-
134-<script>
135-(function() {
136- var articles = document.querySelectorAll('#article-list article[data-article-id]');
137- var currentIdx = -1;
138-
139- function highlightArticle(idx) {
140- articles.forEach(function(a) { a.classList.remove('ring-2', 'ring-spot-green'); });
141- if (idx >= 0 && idx < articles.length) {
142- currentIdx = idx;
143- articles[idx].classList.add('ring-2', 'ring-spot-green');
144- articles[idx].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
145- }
146- }
147-
148- function getReadBtn(idx) {
149- if (idx < 0 || idx >= articles.length) return null;
150- return articles[idx].querySelector('[hx-post*="/read"], [hx-post*="/unread"]');
151- }
152-
153- document.addEventListener('keydown', function(e) {
154- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
155- if (e.ctrlKey || e.metaKey || e.altKey) return;
156-
157- if (e.key === 'j') {
158- e.preventDefault();
159- highlightArticle(Math.min(currentIdx + 1, articles.length - 1));
160- } else if (e.key === 'k') {
161- e.preventDefault();
162- highlightArticle(Math.max(currentIdx - 1, 0));
163- } else if (e.key === 'o' && currentIdx >= 0) {
164- e.preventDefault();
165- var link = articles[currentIdx].querySelector('a[href^="/articles/"]');
166- if (link) link.click();
167- } else if (e.key === 'm' && currentIdx >= 0) {
168- e.preventDefault();
169- var rbtn = getReadBtn(currentIdx);
170- if (rbtn) rbtn.click();
171- }
172- });
173-
174- {{if .ExpandedView}}
175- // Wait 3s before marking read so fast scrolling doesn't mark everything.
176- // Cancel the timer if the article leaves the viewport before it fires.
177- function markRead(el) {
178- var id = el.getAttribute('data-article-id');
179- if (!id || el.getAttribute('data-marked-read')) return;
180- el.setAttribute('data-marked-read', '1');
181- fetch('/articles/' + id + '/read', {
182- method: 'POST',
183- headers: {'X-CSRF-Token': document.querySelector('input[name="csrf_token"]') ? document.querySelector('input[name="csrf_token"]').value : ''}
184- }).then(function() {
185- el.classList.remove('border-l', 'border-spot-green/80');
186- var title = el.querySelector('a[href^="/articles/"]');
187- if (title) { title.classList.remove('font-bold', 'text-[17px]'); title.classList.add('font-semibold', 'text-[15px]'); }
188- }).catch(function() {});
189- }
190- var readTimers = {};
191- var observer = new IntersectionObserver(function(entries) {
192- entries.forEach(function(entry) {
193- var id = entry.target.getAttribute('data-article-id');
194- if (!id) return;
195- if (entry.isIntersecting) {
196- readTimers[id] = setTimeout(function() { markRead(entry.target); }, 3000);
197- } else {
198- clearTimeout(readTimers[id]);
199- delete readTimers[id];
200- }
201- });
202- }, {rootMargin: '0px 0px -50% 0px', threshold: 0});
203-
204- articles.forEach(function(a) {
205- if (a.getAttribute('data-expanded') === 'true') {
206- observer.observe(a);
207- }
208- });
209- {{end}}
210-})();
211-</script>
212-{{end}}
deleted file mode 100644
@@ -1,212 +0,0 @@
1-{{define "articles.html"}}
2- <div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return={{if .FeedURL}}/articles?feed={{.FeedURL}}{{else}}/articles{{end}}" hx-trigger="every 30s" hx-swap="innerHTML"></div>
3-
4- {{if .Feed}}
5- <div class="mb-6">
6- <div class="flex items-center gap-2 mb-4">
7- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">&larr; All articles</a>
8- </div>
9- <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
10- <div class="flex items-center gap-3.5 min-w-0">
11- {{template "favicon" dict "src" .Feed.FaviconURL.String "size" "w-10 h-10"}}
12- <div class="min-w-0">
13- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">{{if .Feed.SiteURL.Valid}}<a href="{{.Feed.SiteURL.String}}" target="_blank" rel="noopener noreferrer" class="hover:text-spot-green transition">{{end}}{{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}{{if .Feed.SiteURL.Valid}}</a>{{end}}</h1>
14- {{if .Feed.SiteURL.Valid}}<a href="{{.Feed.SiteURL.String}}" target="_blank" rel="noopener noreferrer" class="text-xs text-spot-secondary hover:text-spot-green transition">{{.Feed.SiteURL.String}}</a>{{end}}
15- </div>
16- </div>
17- <div class="flex items-center gap-3 shrink-0">
18- {{if not .IsSubscribed}}
19- <form hx-post="/feeds/add" hx-swap="none" hx-on::after-request="if(event.detail.successful) window.location.reload()">
20- {{csrfInput .CSRFToken}}
21- <input type="hidden" name="feed_url" value="{{.FeedURL}}">
22- <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Subscribe</button>
23- </form>
24- {{else}}
25- <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
26- {{csrfInput .CSRFToken}}
27- <input type="hidden" name="feed" value="{{.FeedURL}}">
28- <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
29- </form>
30- {{end}}
31- </div>
32- </div>
33- {{if .Feed.Description.Valid}}<p class="text-sm text-spot-secondary mt-3 leading-relaxed">{{.Feed.Description.String}}</p>{{end}}
34- </div>
35-
36- <div class="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
37- <form method="GET" action="/articles" class="relative w-full sm:flex-1 sm:min-w-[200px]">
38- <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
39- class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
40- hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
41- <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
42- <input type="hidden" name="feed" value="{{.FeedURL}}">
43- {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
44- {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
45- </form>
46- <div class="flex items-center gap-1.5 shrink-0">
47- <a href="/articles?status=all&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
48- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
49- {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
50- All
51- </a>
52- <a href="/articles?status=unread&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
53- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
54- {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
55- Unread
56- </a>
57- <a href="/articles?status=read&feed={{.FeedURL}}{{if .SortOldest}}&sort=oldest{{end}}"
58- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
59- {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
60- Read
61- </a>
62- <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}&feed={{.FeedURL}}{{if .Status}}&status={{.Status}}{{end}}"
63- class="px-2.5 py-1.5 rounded-pill text-xs transition
64- {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
65- title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
66- {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
67- </a>
68- </div>
69- </div>
70- {{else}}
71- <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
72- <div class="flex items-center gap-3">
73- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Articles</h1>
74- <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?">
75- {{csrfInput .CSRFToken}}
76- <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-3 py-1 text-[10px] font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button>
77- </form>
78- </div>
79- <div class="flex items-center gap-1.5">
80- <a href="/articles?status=all{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
81- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
82- {{if eq .Status "all"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
83- All
84- </a>
85- <a href="/articles?status=unread{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
86- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
87- {{if or (eq .Status "unread") (eq .Status "")}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
88- Unread
89- </a>
90- <a href="/articles?status=read{{if .SortOldest}}&sort=oldest{{end}}{{if .Category}}&category={{.Category}}{{end}}"
91- class="px-3.5 py-1.5 rounded-pill text-xs font-bold uppercase tracking-button transition
92- {{if eq .Status "read"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">
93- Read
94- </a>
95- </div>
96- </div>
97- <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, {{if .SortOldest}}oldest first{{else}}newest first{{end}}.</p>
98-
99- <div class="flex flex-col sm:flex-row sm:items-center gap-3 mb-6">
100- <div class="flex items-center gap-1.5 flex-wrap">
101- <a href="/articles?{{if not .SortOldest}}sort=oldest{{end}}{{if .Status}}&status={{.Status}}{{end}}"
102- class="px-2.5 py-1.5 rounded-pill text-xs transition
103- {{if .SortOldest}}bg-spot-active-pill-bg text-spot-active-pill-text font-bold{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"
104- title="{{if .SortOldest}}Newest first{{else}}Oldest first{{end}}">
105- {{if .SortOldest}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/></svg>{{else}}<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>{{end}}
106- </a>
107- {{if .Categories}}
108- <span class="text-spot-divider">|</span>
109- <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}{{if .SortOldest}}sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
110- {{range .Categories}}
111- <a href="/articles?{{if $.Status}}status={{$.Status}}&{{end}}category={{.}}{{if $.SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
112- {{end}}
113- <a href="/articles?{{if .Status}}status={{.Status}}&{{end}}category=__none__{{if .SortOldest}}&sort=oldest{{end}}" class="text-sm px-3 py-1 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a>
114- {{end}}
115- </div>
116- <form method="GET" action="/articles" class="relative w-full sm:flex-1 sm:min-w-[180px]">
117- <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
118- class="w-full bg-spot-hover text-spot-text rounded-pill pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-secondary"
119- hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
120- <svg class="w-3 h-3 text-spot-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
121- {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
122- {{if .SortOldest}}<input type="hidden" name="sort" value="oldest">{{end}}
123- {{if .Category}}<input type="hidden" name="category" value="{{.Category}}">{{end}}
124- </form>
125- </div>
126- {{end}}
127-
128-<div id="article-list" class="space-y-3">
129- {{template "articles-content.html" .}}
130-</div>
131-
132-{{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}}
133-
134-<script>
135-(function() {
136- var articles = document.querySelectorAll('#article-list article[data-article-id]');
137- var currentIdx = -1;
138-
139- function highlightArticle(idx) {
140- articles.forEach(function(a) { a.classList.remove('ring-2', 'ring-spot-green'); });
141- if (idx >= 0 && idx < articles.length) {
142- currentIdx = idx;
143- articles[idx].classList.add('ring-2', 'ring-spot-green');
144- articles[idx].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
145- }
146- }
147-
148- function getReadBtn(idx) {
149- if (idx < 0 || idx >= articles.length) return null;
150- return articles[idx].querySelector('[hx-post*="/read"], [hx-post*="/unread"]');
151- }
152-
153- document.addEventListener('keydown', function(e) {
154- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
155- if (e.ctrlKey || e.metaKey || e.altKey) return;
156-
157- if (e.key === 'j') {
158- e.preventDefault();
159- highlightArticle(Math.min(currentIdx + 1, articles.length - 1));
160- } else if (e.key === 'k') {
161- e.preventDefault();
162- highlightArticle(Math.max(currentIdx - 1, 0));
163- } else if (e.key === 'o' && currentIdx >= 0) {
164- e.preventDefault();
165- var link = articles[currentIdx].querySelector('a[href^="/articles/"]');
166- if (link) link.click();
167- } else if (e.key === 'm' && currentIdx >= 0) {
168- e.preventDefault();
169- var rbtn = getReadBtn(currentIdx);
170- if (rbtn) rbtn.click();
171- }
172- });
173-
174- {{if .ExpandedView}}
175- // Wait 3s before marking read so fast scrolling doesn't mark everything.
176- // Cancel the timer if the article leaves the viewport before it fires.
177- function markRead(el) {
178- var id = el.getAttribute('data-article-id');
179- if (!id || el.getAttribute('data-marked-read')) return;
180- el.setAttribute('data-marked-read', '1');
181- fetch('/articles/' + id + '/read', {
182- method: 'POST',
183- headers: {'X-CSRF-Token': document.querySelector('input[name="csrf_token"]') ? document.querySelector('input[name="csrf_token"]').value : ''}
184- }).then(function() {
185- el.classList.remove('border-l', 'border-spot-green/80');
186- var title = el.querySelector('a[href^="/articles/"]');
187- if (title) { title.classList.remove('font-bold', 'text-[17px]'); title.classList.add('font-semibold', 'text-[15px]'); }
188- }).catch(function() {});
189- }
190- var readTimers = {};
191- var observer = new IntersectionObserver(function(entries) {
192- entries.forEach(function(entry) {
193- var id = entry.target.getAttribute('data-article-id');
194- if (!id) return;
195- if (entry.isIntersecting) {
196- readTimers[id] = setTimeout(function() { markRead(entry.target); }, 3000);
197- } else {
198- clearTimeout(readTimers[id]);
199- delete readTimers[id];
200- }
201- });
202- }, {rootMargin: '0px 0px -50% 0px', threshold: 0});
203-
204- articles.forEach(function(a) {
205- if (a.getAttribute('data-expanded') === 'true') {
206- observer.observe(a);
207- }
208- });
209- {{end}}
210-})();
211-</script>
212-{{end}}
deleted internal/tmpl/base.html +0 -448
deleted file mode 100644
@@ -1,448 +0,0 @@
1-{{define "base.html"}}
2-<!DOCTYPE html>
3-<html lang="en">
4-<head>
5- <meta charset="utf-8">
6- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
7- <title>Glean</title>
8- <script>
9- (function(){var p=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',p)})();
10- </script>
11- <link rel="stylesheet" href="/static/output.css">
12- <script src="/static/htmx.min.js"></script>
13- <script>
14- document.addEventListener('htmx:confirm', function(e) {
15- if (!e.detail.question) return;
16- e.preventDefault();
17- var dlg = document.getElementById('confirm-dialog');
18- document.getElementById('confirm-dialog-msg').textContent = e.detail.question;
19- var okBtn = document.getElementById('confirm-dialog-ok');
20- var handler = function() {
21- dlg.close();
22- okBtn.removeEventListener('click', handler);
23- e.detail.issueRequest(true);
24- };
25- okBtn.addEventListener('click', handler);
26- dlg.showModal();
27- });
28- </script>
29- <meta property="og:title" content="Glean">
30- <meta property="og:description" content="The social RSS reader built on AT Protocol.">
31- <meta property="og:image" content="/static/banner.png">
32- <meta property="og:type" content="website">
33- <meta property="og:site_name" content="Glean">
34- <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
35- <link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
36- <link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
37- <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
38- <meta name="theme-color" content="#00754A">
39- <meta name="mobile-web-app-capable" content="yes">
40- <meta name="apple-mobile-web-app-capable" content="yes">
41- <meta name="apple-mobile-web-app-title" content="Glean">
42- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
43- <meta name="application-name" content="Glean">
44- <meta name="msapplication-TileColor" content="#00754A">
45- <meta name="msapplication-TileImage" content="/static/icon-192.png">
46- <link rel="manifest" href="/static/manifest.json">
47- <link rel="preconnect" href="https://fonts.googleapis.com">
48- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
49- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
50-</head>
51-<body class="bg-spot-bg text-spot-text min-h-screen flex">
52- <dialog id="confirm-dialog" class="bg-spot-surface rounded-xl p-6 max-w-sm shadow-spot-elevated border border-spot-divider backdrop:bg-black/50" onclick="if(event.target===this)this.close()">
53- <p id="confirm-dialog-msg" class="text-spot-text text-sm mb-6"></p>
54- <div class="flex justify-end gap-3">
55- <button onclick="document.getElementById('confirm-dialog').close(false)" class="text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Cancel</button>
56- <button id="confirm-dialog-ok" class="text-sm text-white bg-spot-red hover:brightness-110 px-4 py-2 rounded-pill font-bold uppercase tracking-button transition">Confirm</button>
57- </div>
58- </dialog>
59- <dialog id="install-dialog" class="bg-spot-surface rounded-xl p-6 max-w-sm shadow-spot-elevated border border-spot-divider backdrop:bg-black/50" onclick="if(event.target===this)this.close()">
60- <div class="flex items-center gap-3 mb-5">
61- <img src="/static/favicon.svg" class="w-12 h-12 rounded-xl shrink-0" alt="Glean">
62- <div>
63- <h3 class="text-sm font-bold text-spot-text">Install Glean</h3>
64- <p class="text-xs text-spot-secondary">Add to your home screen for a native app experience</p>
65- </div>
66- </div>
67- <div id="install-native" class="hidden">
68- <p class="text-sm text-spot-secondary mb-5">Install Glean on your device for quick access.</p>
69- <div class="flex gap-2 justify-end">
70- <button onclick="document.getElementById('install-dialog').close()" class="text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Not now</button>
71- <button id="install-btn" class="text-sm text-white bg-spot-green hover:brightness-110 px-4 py-2 rounded-pill font-bold uppercase tracking-button transition">Install</button>
72- </div>
73- </div>
74- <div id="install-manual" class="hidden">
75- <div id="install-instructions-chrome" class="hidden space-y-3">
76- <p class="text-sm text-spot-secondary">To install Glean as an app:</p>
77- <ol class="text-sm text-spot-secondary space-y-2.5 list-decimal list-inside">
78- <li>Tap the <strong class="text-spot-text">three-dot menu</strong> in the top-right or bottom-right corner</li>
79- <li>Select <strong class="text-spot-text">"Add to Home screen"</strong> or <strong class="text-spot-text">"Install app"</strong></li>
80- <li>Tap <strong class="text-spot-text">"Add"</strong> to confirm</li>
81- </ol>
82- </div>
83- <div id="install-instructions-safari" class="hidden space-y-3">
84- <p class="text-sm text-spot-secondary">To install Glean as an app:</p>
85- <ol class="text-sm text-spot-secondary space-y-2.5 list-decimal list-inside">
86- <li>Tap the <strong class="text-spot-text">Share button</strong> at the bottom of the screen</li>
87- <li>Scroll down and tap <strong class="text-spot-text">"Add to Home Screen"</strong></li>
88- <li>Tap <strong class="text-spot-text">"Add"</strong> to confirm</li>
89- </ol>
90- </div>
91- <div id="install-instructions-firefox" class="hidden space-y-3">
92- <p class="text-sm text-spot-secondary">To install Glean as an app:</p>
93- <ol class="text-sm text-spot-secondary space-y-2.5 list-decimal list-inside">
94- <li>Tap the <strong class="text-spot-text">three-dot menu</strong> in the top-right corner</li>
95- <li>Select <strong class="text-spot-text">"Install"</strong> or <strong class="text-spot-text">"Add to Home screen"</strong></li>
96- <li>Tap <strong class="text-spot-text">"Add"</strong> to confirm</li>
97- </ol>
98- </div>
99- <div class="mt-5 flex justify-end">
100- <button onclick="document.getElementById('install-dialog').close()" class="text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Got it</button>
101- </div>
102- </div>
103- </dialog>
104- <dialog id="shortcuts-dialog" class="bg-spot-surface rounded-xl p-6 max-w-xs shadow-spot-elevated border border-spot-divider backdrop:bg-black/50" onclick="if(event.target===this)this.close()">
105- <h3 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-4">Keyboard shortcuts</h3>
106- <div class="space-y-3 text-sm">
107- <div>
108- <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide mb-2">Navigation</div>
109- <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
110- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">g</kbd><span class="text-spot-secondary">Dashboard</span>
111- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">a</kbd><span class="text-spot-secondary">Articles</span>
112- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">f</kbd><span class="text-spot-secondary">Feeds</span>
113- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">t</kbd><span class="text-spot-secondary">Trending</span>
114- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">l</kbd><span class="text-spot-secondary">Library</span>
115- </div>
116- </div>
117- <div>
118- <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide mb-2">Articles</div>
119- <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
120- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">j</kbd><span class="text-spot-secondary">Next article</span>
121- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">k</kbd><span class="text-spot-secondary">Previous article</span>
122- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">o</kbd><span class="text-spot-secondary">Open article</span>
123- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">m</kbd><span class="text-spot-secondary">Toggle read</span>
124- </div>
125- </div>
126- </div>
127- <button onclick="document.getElementById('shortcuts-dialog').close()" class="mt-5 w-full text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Close</button>
128- </dialog>
129- {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
130- <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
131- <div class="mb-8 px-3">
132- {{template "logo-link"}}
133- </div>
134- <nav class="flex flex-col gap-0.5 text-sm">
135- <a href="/dashboard" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/dashboard"}}">
136- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
137- Dashboard
138- </a>
139- <a href="/articles" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/articles"}}">
140- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
141- Articles
142- </a>
143- <a href="/trending" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/trending"}}">
144- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
145- Trending
146- </a>
147- <a href="/feeds" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/feeds"}}">
148- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
149- Feeds
150- </a>
151- <a href="/library" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/library"}}">
152- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
153- Library
154- </a>
155- </nav>
156- <div class="mt-auto pt-4 border-t border-spot-divider-30 px-1">
157- {{if .User}}
158- <div class="flex items-center gap-2">
159- <a href="/profile/{{.User.DID}}" class="flex items-center gap-2.5 flex-1 min-w-0 px-2 py-1.5 rounded-lg hover:bg-spot-hover-50 transition">
160- {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full shrink-0 ring-1 ring-spot-divider">{{end}}
161- <span class="text-sm font-medium truncate text-spot-text">@{{.User.Handle}}</span>
162- </a>
163- <form method="POST" action="/auth/logout" class="shrink-0">
164- {{csrfInput .CSRFToken}}
165- <button type="submit" class="text-spot-muted hover:text-spot-red p-1.5 rounded-md hover:bg-spot-hover-50 transition" title="Logout">
166- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
167- </button>
168- </form>
169- </div>
170- {{else}}
171- <a href="/auth/login" class="flex items-center justify-center gap-2 w-full bg-spot-green text-white rounded-pill px-4 py-2 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">
172- Sign in
173- </a>
174- {{end}}
175- </div>
176- </aside>
177-
178- {{end}}
179-
180- <main id="main-content" class="{{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}lg:ml-60{{end}} flex-1 min-h-screen flex flex-col">
181- {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
182- <div class="lg:hidden bg-spot-surface/80 backdrop-blur-lg border-b border-spot-divider px-3 py-2 flex items-center justify-between sticky top-0 z-20">
183- {{template "logo-link"}}
184- <div class="flex items-center gap-0.5">
185- <a href="/dashboard" class="p-2 rounded-lg {{if eq .ActivePath "/dashboard"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Dashboard">
186- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
187- </a>
188- <a href="/articles" class="p-2 rounded-lg {{if eq .ActivePath "/articles"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Articles">
189- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
190- </a>
191- <a href="/trending" class="p-2 rounded-lg {{if eq .ActivePath "/trending"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Trending">
192- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
193- </a>
194- <a href="/feeds" class="p-2 rounded-lg {{if eq .ActivePath "/feeds"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Feeds">
195- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
196- </a>
197- <a href="/library" class="p-2 rounded-lg {{if eq .ActivePath "/library"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Library">
198- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
199- </a>
200- {{if .User}}
201- <a href="/profile/{{.User.DID}}" class="shrink-0 ml-1" title="@{{.User.Handle}}">
202- {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full ring-2 ring-spot-divider">{{end}}
203- </a>
204- <form method="POST" action="/auth/logout" class="shrink-0">
205- {{csrfInput .CSRFToken}}
206- <button type="submit" class="text-spot-muted hover:text-spot-red p-1.5 rounded-md hover:bg-spot-hover-50 transition" title="Sign out">
207- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
208- </button>
209- </form>
210- {{else}}
211- <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-3 py-1 text-xs font-bold uppercase tracking-button hover:brightness-110 transition shrink-0 ml-1">Sign in</a>
212- {{end}}
213- </div>
214- </div>
215- {{end}}
216- <div class="{{if not (or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats")))}}w-full{{else}}max-w-6xl mx-auto px-4 lg:px-8 py-6{{end}} flex-1">
217- {{.Content}}
218- </div>
219- <footer class="mt-auto">
220- <div class="w-full bg-spot-surface border-t border-spot-divider">
221- <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 lg:py-8">
222- <div class="hidden md:grid md:grid-cols-[2fr_1fr] md:gap-8 md:items-start">
223- <div class="grid grid-cols-3 gap-8">
224- <div class="flex flex-col gap-2">
225- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
226- <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
227- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
228- Dashboard
229- </a>
230- <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
231- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
232- Trending
233- </a>
234- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
235- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
236- Articles
237- </a>
238- </div>
239- <div class="flex flex-col gap-2">
240- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
241- <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
242- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
243- Feeds
244- </a>
245- <a href="/library" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
246- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
247- Library
248- </a>
249- </div>
250- <div class="flex flex-col gap-2">
251- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Settings</div>
252- <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
253- <svg class="w-3.5 h-3.5 shrink-0 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
254- <svg class="w-3.5 h-3.5 shrink-0 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
255- <span class="theme-text-dark">Dark</span>
256- <span class="theme-text-light hidden">Light</span>
257- </button>
258- <button onclick="document.getElementById('shortcuts-dialog').showModal()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
259- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z"/></svg>
260- Shortcuts
261- </button>
262- <button onclick="showInstallDialog()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
263- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
264- Install App
265- </button>
266- </div>
267- </div>
268- <div class="text-right">
269- <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr" class="hover:text-spot-text transition">julien.rbrt.fr</a></div>
270- <div class="text-[10px] text-spot-secondary mt-0.5">Made in Europe &#127466;&#127482; &middot; <a href="/terms" class="hover:text-spot-text transition">Terms</a></div>
271- </div>
272- </div>
273-
274- <div class="md:hidden flex flex-col gap-5">
275- <div class="grid grid-cols-2 gap-6">
276- <div class="flex flex-col gap-2">
277- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
278- <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text transition">Dashboard</a>
279- <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text transition">Trending</a>
280- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">Articles</a>
281- </div>
282- <div class="flex flex-col gap-2">
283- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
284- <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text transition">Feeds</a>
285- <a href="/library" class="text-xs text-spot-secondary hover:text-spot-text transition">Library</a>
286- </div>
287- </div>
288- <div class="border-t border-spot-divider pt-4">
289- <div class="flex flex-wrap items-center gap-3">
290- <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
291- <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
292- <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
293- <span class="theme-text-dark">Dark</span>
294- <span class="theme-text-light hidden">Light</span>
295- </button>
296- <button onclick="showInstallDialog()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
297- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
298- Install App
299- </button>
300- </div>
301- <div class="border-t border-spot-divider mt-4 pt-3">
302- <span class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr" class="hover:text-spot-text transition">julien.rbrt.fr</a> &middot; Made in Europe &#127466;&#127482; &middot; <a href="/terms" class="hover:text-spot-text transition">Terms</a></span>
303- </div>
304- </div>
305- </div>
306- </div>
307- </div>
308- </footer>
309- </main>
310-
311- <script>
312- var themePref = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
313-
314- function applyTheme() {
315- document.documentElement.setAttribute('data-theme', themePref);
316- var next = themePref === 'dark' ? 'light' : 'dark';
317- ['dark', 'light'].forEach(function(m) {
318- var show = m === next;
319- document.querySelectorAll('.theme-icon-' + m + ', .theme-text-' + m).forEach(function(el) {
320- el.classList.toggle('hidden', !show);
321- });
322- });
323- }
324-
325- function toggleTheme() {
326- themePref = themePref === 'dark' ? 'light' : 'dark';
327- localStorage.setItem('theme', themePref);
328- applyTheme();
329- }
330-
331- applyTheme();
332-
333- window.addEventListener('pageshow', function(e) {
334- if (e.persisted) location.reload();
335- });
336-
337- function closeAnnotate(el) {
338- var form = el.closest('.annotate-form');
339- form.classList.add('hidden');
340- form.querySelector('form').reset();
341- }
342-
343- function openAnnotate(article, quote) {
344- var form = article.querySelector('.annotate-form');
345- var quoteInput = form.querySelector('input[name="quote"]');
346- if (quote) quoteInput.value = quote;
347- form.classList.remove('hidden');
348- (quote ? form.querySelector('input[name="note"]') : quoteInput).focus();
349- }
350-
351- function editAnnotation(btn, id, feedURL, articleURL, quote, note, tags) {
352- var card = btn.closest('[id^="annotation-"]');
353- card.innerHTML = '<form hx-post="/library/create" hx-swap="none" hx-on::after-request="editAnnotationDone(' + id + ')" class="space-y-3">' +
354- '<input type="hidden" name="feed_url" value="' + feedURL + '">' +
355- '<input type="hidden" name="article_url" value="' + articleURL + '">' +
356- '<input type="text" name="quote" value="' + (quote || '').replace(/"/g, '&quot;') + '" placeholder="Quote a passage..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' +
357- '<textarea name="note" rows="3" placeholder="Add a note..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none">' + (note || '') + '</textarea>' +
358- '<input type="text" name="tags" value="' + (tags || '').replace(/"/g, '&quot;') + '" placeholder="Tags (comma separated)" class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' +
359- '<div class="flex gap-2 justify-end">' +
360- '<button type="button" onclick="cancelEdit(' + id + ')" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Cancel</button>' +
361- '<button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>' +
362- '</div></form>';
363- htmx.process(card);
364- var deleteReq = new XMLHttpRequest();
365- deleteReq.open('POST', '/library/' + id + '/delete');
366- deleteReq.setRequestHeader('HX-Request', 'true');
367- deleteReq.send();
368- }
369-
370- function editAnnotationDone(id) {
371- var params = new URLSearchParams(window.location.search);
372- var url = window.location.pathname;
373- if (params.toString()) url += '?' + params.toString();
374- htmx.ajax('GET', url, '#main-content');
375- }
376-
377- function cancelEdit(id) {
378- htmx.ajax('GET', window.location.pathname + window.location.search, '#main-content');
379- }
380-
381- document.addEventListener('mouseup', function(e) {
382- var sel = window.getSelection();
383- var text = sel.toString().trim();
384- if (!text) return;
385-
386- var article = e.target.closest('article[data-article-id]');
387- if (!article) return;
388-
389- var form = article.querySelector('.annotate-form');
390- if (form && !form.classList.contains('hidden')) return;
391-
392- if (text.length > 500) text = text.substring(0, 500);
393- openAnnotate(article, text);
394- });
395-
396- document.addEventListener('keydown', function(e) {
397- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
398- if (e.ctrlKey || e.metaKey || e.altKey) return;
399- if (e.key === 'g') window.location.href = '/dashboard';
400- if (e.key === 'a') window.location.href = '/articles';
401- if (e.key === 'f') window.location.href = '/feeds';
402- if (e.key === 't') window.location.href = '/trending';
403- if (e.key === 'l') window.location.href = '/library';
404- });
405-
406- var deferredPrompt = null;
407- window.addEventListener('beforeinstallprompt', function(e) {
408- e.preventDefault();
409- deferredPrompt = e;
410- });
411-
412- function showInstallDialog() {
413- var dlg = document.getElementById('install-dialog');
414- var nativeEl = document.getElementById('install-native');
415- var manualEl = document.getElementById('install-manual');
416- var ua = navigator.userAgent;
417-
418- nativeEl.classList.add('hidden');
419- manualEl.classList.add('hidden');
420- document.getElementById('install-instructions-chrome').classList.add('hidden');
421- document.getElementById('install-instructions-safari').classList.add('hidden');
422- document.getElementById('install-instructions-firefox').classList.add('hidden');
423-
424- if (deferredPrompt) {
425- nativeEl.classList.remove('hidden');
426- document.getElementById('install-btn').onclick = function() {
427- deferredPrompt.prompt();
428- deferredPrompt.userChoice.then(function() {
429- deferredPrompt = null;
430- dlg.close();
431- });
432- };
433- } else {
434- manualEl.classList.remove('hidden');
435- if (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) {
436- document.getElementById('install-instructions-safari').classList.remove('hidden');
437- } else if (/Firefox/.test(ua) && /Android/.test(ua)) {
438- document.getElementById('install-instructions-firefox').classList.remove('hidden');
439- } else {
440- document.getElementById('install-instructions-chrome').classList.remove('hidden');
441- }
442- }
443- dlg.showModal();
444- }
445- </script>
446-</body>
447-</html>
448-{{end}}
deleted file mode 100644
@@ -1,448 +0,0 @@
1-{{define "base.html"}}
2-<!DOCTYPE html>
3-<html lang="en">
4-<head>
5- <meta charset="utf-8">
6- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
7- <title>Glean</title>
8- <script>
9- (function(){var p=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',p)})();
10- </script>
11- <link rel="stylesheet" href="/static/output.css">
12- <script src="/static/htmx.min.js"></script>
13- <script>
14- document.addEventListener('htmx:confirm', function(e) {
15- if (!e.detail.question) return;
16- e.preventDefault();
17- var dlg = document.getElementById('confirm-dialog');
18- document.getElementById('confirm-dialog-msg').textContent = e.detail.question;
19- var okBtn = document.getElementById('confirm-dialog-ok');
20- var handler = function() {
21- dlg.close();
22- okBtn.removeEventListener('click', handler);
23- e.detail.issueRequest(true);
24- };
25- okBtn.addEventListener('click', handler);
26- dlg.showModal();
27- });
28- </script>
29- <meta property="og:title" content="Glean">
30- <meta property="og:description" content="The social RSS reader built on AT Protocol.">
31- <meta property="og:image" content="/static/banner.png">
32- <meta property="og:type" content="website">
33- <meta property="og:site_name" content="Glean">
34- <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
35- <link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
36- <link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
37- <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
38- <meta name="theme-color" content="#00754A">
39- <meta name="mobile-web-app-capable" content="yes">
40- <meta name="apple-mobile-web-app-capable" content="yes">
41- <meta name="apple-mobile-web-app-title" content="Glean">
42- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
43- <meta name="application-name" content="Glean">
44- <meta name="msapplication-TileColor" content="#00754A">
45- <meta name="msapplication-TileImage" content="/static/icon-192.png">
46- <link rel="manifest" href="/static/manifest.json">
47- <link rel="preconnect" href="https://fonts.googleapis.com">
48- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
49- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
50-</head>
51-<body class="bg-spot-bg text-spot-text min-h-screen flex">
52- <dialog id="confirm-dialog" class="bg-spot-surface rounded-xl p-6 max-w-sm shadow-spot-elevated border border-spot-divider backdrop:bg-black/50" onclick="if(event.target===this)this.close()">
53- <p id="confirm-dialog-msg" class="text-spot-text text-sm mb-6"></p>
54- <div class="flex justify-end gap-3">
55- <button onclick="document.getElementById('confirm-dialog').close(false)" class="text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Cancel</button>
56- <button id="confirm-dialog-ok" class="text-sm text-white bg-spot-red hover:brightness-110 px-4 py-2 rounded-pill font-bold uppercase tracking-button transition">Confirm</button>
57- </div>
58- </dialog>
59- <dialog id="install-dialog" class="bg-spot-surface rounded-xl p-6 max-w-sm shadow-spot-elevated border border-spot-divider backdrop:bg-black/50" onclick="if(event.target===this)this.close()">
60- <div class="flex items-center gap-3 mb-5">
61- <img src="/static/favicon.svg" class="w-12 h-12 rounded-xl shrink-0" alt="Glean">
62- <div>
63- <h3 class="text-sm font-bold text-spot-text">Install Glean</h3>
64- <p class="text-xs text-spot-secondary">Add to your home screen for a native app experience</p>
65- </div>
66- </div>
67- <div id="install-native" class="hidden">
68- <p class="text-sm text-spot-secondary mb-5">Install Glean on your device for quick access.</p>
69- <div class="flex gap-2 justify-end">
70- <button onclick="document.getElementById('install-dialog').close()" class="text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Not now</button>
71- <button id="install-btn" class="text-sm text-white bg-spot-green hover:brightness-110 px-4 py-2 rounded-pill font-bold uppercase tracking-button transition">Install</button>
72- </div>
73- </div>
74- <div id="install-manual" class="hidden">
75- <div id="install-instructions-chrome" class="hidden space-y-3">
76- <p class="text-sm text-spot-secondary">To install Glean as an app:</p>
77- <ol class="text-sm text-spot-secondary space-y-2.5 list-decimal list-inside">
78- <li>Tap the <strong class="text-spot-text">three-dot menu</strong> in the top-right or bottom-right corner</li>
79- <li>Select <strong class="text-spot-text">"Add to Home screen"</strong> or <strong class="text-spot-text">"Install app"</strong></li>
80- <li>Tap <strong class="text-spot-text">"Add"</strong> to confirm</li>
81- </ol>
82- </div>
83- <div id="install-instructions-safari" class="hidden space-y-3">
84- <p class="text-sm text-spot-secondary">To install Glean as an app:</p>
85- <ol class="text-sm text-spot-secondary space-y-2.5 list-decimal list-inside">
86- <li>Tap the <strong class="text-spot-text">Share button</strong> at the bottom of the screen</li>
87- <li>Scroll down and tap <strong class="text-spot-text">"Add to Home Screen"</strong></li>
88- <li>Tap <strong class="text-spot-text">"Add"</strong> to confirm</li>
89- </ol>
90- </div>
91- <div id="install-instructions-firefox" class="hidden space-y-3">
92- <p class="text-sm text-spot-secondary">To install Glean as an app:</p>
93- <ol class="text-sm text-spot-secondary space-y-2.5 list-decimal list-inside">
94- <li>Tap the <strong class="text-spot-text">three-dot menu</strong> in the top-right corner</li>
95- <li>Select <strong class="text-spot-text">"Install"</strong> or <strong class="text-spot-text">"Add to Home screen"</strong></li>
96- <li>Tap <strong class="text-spot-text">"Add"</strong> to confirm</li>
97- </ol>
98- </div>
99- <div class="mt-5 flex justify-end">
100- <button onclick="document.getElementById('install-dialog').close()" class="text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Got it</button>
101- </div>
102- </div>
103- </dialog>
104- <dialog id="shortcuts-dialog" class="bg-spot-surface rounded-xl p-6 max-w-xs shadow-spot-elevated border border-spot-divider backdrop:bg-black/50" onclick="if(event.target===this)this.close()">
105- <h3 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-4">Keyboard shortcuts</h3>
106- <div class="space-y-3 text-sm">
107- <div>
108- <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide mb-2">Navigation</div>
109- <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
110- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">g</kbd><span class="text-spot-secondary">Dashboard</span>
111- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">a</kbd><span class="text-spot-secondary">Articles</span>
112- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">f</kbd><span class="text-spot-secondary">Feeds</span>
113- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">t</kbd><span class="text-spot-secondary">Trending</span>
114- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">l</kbd><span class="text-spot-secondary">Library</span>
115- </div>
116- </div>
117- <div>
118- <div class="text-spot-secondary font-bold text-xs uppercase tracking-wide mb-2">Articles</div>
119- <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5">
120- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">j</kbd><span class="text-spot-secondary">Next article</span>
121- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">k</kbd><span class="text-spot-secondary">Previous article</span>
122- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">o</kbd><span class="text-spot-secondary">Open article</span>
123- <kbd class="text-spot-text bg-spot-hover rounded px-1.5 py-0.5 text-xs font-mono text-center min-w-[28px]">m</kbd><span class="text-spot-secondary">Toggle read</span>
124- </div>
125- </div>
126- </div>
127- <button onclick="document.getElementById('shortcuts-dialog').close()" class="mt-5 w-full text-sm text-spot-text px-4 py-2 rounded-pill border border-spot-outline hover:border-spot-text transition">Close</button>
128- </dialog>
129- {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
130- <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20">
131- <div class="mb-8 px-3">
132- {{template "logo-link"}}
133- </div>
134- <nav class="flex flex-col gap-0.5 text-sm">
135- <a href="/dashboard" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/dashboard"}}">
136- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
137- Dashboard
138- </a>
139- <a href="/articles" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/articles"}}">
140- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
141- Articles
142- </a>
143- <a href="/trending" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/trending"}}">
144- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
145- Trending
146- </a>
147- <a href="/feeds" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/feeds"}}">
148- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
149- Feeds
150- </a>
151- <a href="/library" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-lg {{activeClass .ActivePath "/library"}}">
152- <svg class="w-[18px] h-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
153- Library
154- </a>
155- </nav>
156- <div class="mt-auto pt-4 border-t border-spot-divider-30 px-1">
157- {{if .User}}
158- <div class="flex items-center gap-2">
159- <a href="/profile/{{.User.DID}}" class="flex items-center gap-2.5 flex-1 min-w-0 px-2 py-1.5 rounded-lg hover:bg-spot-hover-50 transition">
160- {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full shrink-0 ring-1 ring-spot-divider">{{end}}
161- <span class="text-sm font-medium truncate text-spot-text">@{{.User.Handle}}</span>
162- </a>
163- <form method="POST" action="/auth/logout" class="shrink-0">
164- {{csrfInput .CSRFToken}}
165- <button type="submit" class="text-spot-muted hover:text-spot-red p-1.5 rounded-md hover:bg-spot-hover-50 transition" title="Logout">
166- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
167- </button>
168- </form>
169- </div>
170- {{else}}
171- <a href="/auth/login" class="flex items-center justify-center gap-2 w-full bg-spot-green text-white rounded-pill px-4 py-2 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">
172- Sign in
173- </a>
174- {{end}}
175- </div>
176- </aside>
177-
178- {{end}}
179-
180- <main id="main-content" class="{{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}lg:ml-60{{end}} flex-1 min-h-screen flex flex-col">
181- {{if or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats"))}}
182- <div class="lg:hidden bg-spot-surface/80 backdrop-blur-lg border-b border-spot-divider px-3 py-2 flex items-center justify-between sticky top-0 z-20">
183- {{template "logo-link"}}
184- <div class="flex items-center gap-0.5">
185- <a href="/dashboard" class="p-2 rounded-lg {{if eq .ActivePath "/dashboard"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Dashboard">
186- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
187- </a>
188- <a href="/articles" class="p-2 rounded-lg {{if eq .ActivePath "/articles"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Articles">
189- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
190- </a>
191- <a href="/trending" class="p-2 rounded-lg {{if eq .ActivePath "/trending"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Trending">
192- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
193- </a>
194- <a href="/feeds" class="p-2 rounded-lg {{if eq .ActivePath "/feeds"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Feeds">
195- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
196- </a>
197- <a href="/library" class="p-2 rounded-lg {{if eq .ActivePath "/library"}}text-spot-green bg-spot-green/10{{else}}text-spot-secondary hover:text-spot-text hover:bg-spot-hover-50{{end}} transition" title="Library">
198- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
199- </a>
200- {{if .User}}
201- <a href="/profile/{{.User.DID}}" class="shrink-0 ml-1" title="@{{.User.Handle}}">
202- {{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" class="w-7 h-7 rounded-full ring-2 ring-spot-divider">{{end}}
203- </a>
204- <form method="POST" action="/auth/logout" class="shrink-0">
205- {{csrfInput .CSRFToken}}
206- <button type="submit" class="text-spot-muted hover:text-spot-red p-1.5 rounded-md hover:bg-spot-hover-50 transition" title="Sign out">
207- <svg class="w-[1.15rem] h-[1.15rem]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
208- </button>
209- </form>
210- {{else}}
211- <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-3 py-1 text-xs font-bold uppercase tracking-button hover:brightness-110 transition shrink-0 ml-1">Sign in</a>
212- {{end}}
213- </div>
214- </div>
215- {{end}}
216- <div class="{{if not (or .User (or (eq .ActivePath "/trending") (eq .ActivePath "/stats")))}}w-full{{else}}max-w-6xl mx-auto px-4 lg:px-8 py-6{{end}} flex-1">
217- {{.Content}}
218- </div>
219- <footer class="mt-auto">
220- <div class="w-full bg-spot-surface border-t border-spot-divider">
221- <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 lg:py-8">
222- <div class="hidden md:grid md:grid-cols-[2fr_1fr] md:gap-8 md:items-start">
223- <div class="grid grid-cols-3 gap-8">
224- <div class="flex flex-col gap-2">
225- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
226- <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
227- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg>
228- Dashboard
229- </a>
230- <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
231- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
232- Trending
233- </a>
234- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
235- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
236- Articles
237- </a>
238- </div>
239- <div class="flex flex-col gap-2">
240- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
241- <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
242- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg>
243- Feeds
244- </a>
245- <a href="/library" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition">
246- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg>
247- Library
248- </a>
249- </div>
250- <div class="flex flex-col gap-2">
251- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Settings</div>
252- <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
253- <svg class="w-3.5 h-3.5 shrink-0 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
254- <svg class="w-3.5 h-3.5 shrink-0 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
255- <span class="theme-text-dark">Dark</span>
256- <span class="theme-text-light hidden">Light</span>
257- </button>
258- <button onclick="document.getElementById('shortcuts-dialog').showModal()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
259- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z"/></svg>
260- Shortcuts
261- </button>
262- <button onclick="showInstallDialog()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left">
263- <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
264- Install App
265- </button>
266- </div>
267- </div>
268- <div class="text-right">
269- <div class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr" class="hover:text-spot-text transition">julien.rbrt.fr</a></div>
270- <div class="text-[10px] text-spot-secondary mt-0.5">Made in Europe &#127466;&#127482; &middot; <a href="/terms" class="hover:text-spot-text transition">Terms</a></div>
271- </div>
272- </div>
273-
274- <div class="md:hidden flex flex-col gap-5">
275- <div class="grid grid-cols-2 gap-6">
276- <div class="flex flex-col gap-2">
277- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div>
278- <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text transition">Dashboard</a>
279- <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text transition">Trending</a>
280- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">Articles</a>
281- </div>
282- <div class="flex flex-col gap-2">
283- <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div>
284- <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text transition">Feeds</a>
285- <a href="/library" class="text-xs text-spot-secondary hover:text-spot-text transition">Library</a>
286- </div>
287- </div>
288- <div class="border-t border-spot-divider pt-4">
289- <div class="flex flex-wrap items-center gap-3">
290- <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
291- <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
292- <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
293- <span class="theme-text-dark">Dark</span>
294- <span class="theme-text-light hidden">Light</span>
295- </button>
296- <button onclick="showInstallDialog()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition">
297- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
298- Install App
299- </button>
300- </div>
301- <div class="border-t border-spot-divider mt-4 pt-3">
302- <span class="text-xs text-spot-secondary">&copy; {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr" class="hover:text-spot-text transition">julien.rbrt.fr</a> &middot; Made in Europe &#127466;&#127482; &middot; <a href="/terms" class="hover:text-spot-text transition">Terms</a></span>
303- </div>
304- </div>
305- </div>
306- </div>
307- </div>
308- </footer>
309- </main>
310-
311- <script>
312- var themePref = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
313-
314- function applyTheme() {
315- document.documentElement.setAttribute('data-theme', themePref);
316- var next = themePref === 'dark' ? 'light' : 'dark';
317- ['dark', 'light'].forEach(function(m) {
318- var show = m === next;
319- document.querySelectorAll('.theme-icon-' + m + ', .theme-text-' + m).forEach(function(el) {
320- el.classList.toggle('hidden', !show);
321- });
322- });
323- }
324-
325- function toggleTheme() {
326- themePref = themePref === 'dark' ? 'light' : 'dark';
327- localStorage.setItem('theme', themePref);
328- applyTheme();
329- }
330-
331- applyTheme();
332-
333- window.addEventListener('pageshow', function(e) {
334- if (e.persisted) location.reload();
335- });
336-
337- function closeAnnotate(el) {
338- var form = el.closest('.annotate-form');
339- form.classList.add('hidden');
340- form.querySelector('form').reset();
341- }
342-
343- function openAnnotate(article, quote) {
344- var form = article.querySelector('.annotate-form');
345- var quoteInput = form.querySelector('input[name="quote"]');
346- if (quote) quoteInput.value = quote;
347- form.classList.remove('hidden');
348- (quote ? form.querySelector('input[name="note"]') : quoteInput).focus();
349- }
350-
351- function editAnnotation(btn, id, feedURL, articleURL, quote, note, tags) {
352- var card = btn.closest('[id^="annotation-"]');
353- card.innerHTML = '<form hx-post="/library/create" hx-swap="none" hx-on::after-request="editAnnotationDone(' + id + ')" class="space-y-3">' +
354- '<input type="hidden" name="feed_url" value="' + feedURL + '">' +
355- '<input type="hidden" name="article_url" value="' + articleURL + '">' +
356- '<input type="text" name="quote" value="' + (quote || '').replace(/"/g, '&quot;') + '" placeholder="Quote a passage..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' +
357- '<textarea name="note" rows="3" placeholder="Add a note..." class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder resize-none">' + (note || '') + '</textarea>' +
358- '<input type="text" name="tags" value="' + (tags || '').replace(/"/g, '&quot;') + '" placeholder="Tags (comma separated)" class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">' +
359- '<div class="flex gap-2 justify-end">' +
360- '<button type="button" onclick="cancelEdit(' + id + ')" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Cancel</button>' +
361- '<button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>' +
362- '</div></form>';
363- htmx.process(card);
364- var deleteReq = new XMLHttpRequest();
365- deleteReq.open('POST', '/library/' + id + '/delete');
366- deleteReq.setRequestHeader('HX-Request', 'true');
367- deleteReq.send();
368- }
369-
370- function editAnnotationDone(id) {
371- var params = new URLSearchParams(window.location.search);
372- var url = window.location.pathname;
373- if (params.toString()) url += '?' + params.toString();
374- htmx.ajax('GET', url, '#main-content');
375- }
376-
377- function cancelEdit(id) {
378- htmx.ajax('GET', window.location.pathname + window.location.search, '#main-content');
379- }
380-
381- document.addEventListener('mouseup', function(e) {
382- var sel = window.getSelection();
383- var text = sel.toString().trim();
384- if (!text) return;
385-
386- var article = e.target.closest('article[data-article-id]');
387- if (!article) return;
388-
389- var form = article.querySelector('.annotate-form');
390- if (form && !form.classList.contains('hidden')) return;
391-
392- if (text.length > 500) text = text.substring(0, 500);
393- openAnnotate(article, text);
394- });
395-
396- document.addEventListener('keydown', function(e) {
397- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
398- if (e.ctrlKey || e.metaKey || e.altKey) return;
399- if (e.key === 'g') window.location.href = '/dashboard';
400- if (e.key === 'a') window.location.href = '/articles';
401- if (e.key === 'f') window.location.href = '/feeds';
402- if (e.key === 't') window.location.href = '/trending';
403- if (e.key === 'l') window.location.href = '/library';
404- });
405-
406- var deferredPrompt = null;
407- window.addEventListener('beforeinstallprompt', function(e) {
408- e.preventDefault();
409- deferredPrompt = e;
410- });
411-
412- function showInstallDialog() {
413- var dlg = document.getElementById('install-dialog');
414- var nativeEl = document.getElementById('install-native');
415- var manualEl = document.getElementById('install-manual');
416- var ua = navigator.userAgent;
417-
418- nativeEl.classList.add('hidden');
419- manualEl.classList.add('hidden');
420- document.getElementById('install-instructions-chrome').classList.add('hidden');
421- document.getElementById('install-instructions-safari').classList.add('hidden');
422- document.getElementById('install-instructions-firefox').classList.add('hidden');
423-
424- if (deferredPrompt) {
425- nativeEl.classList.remove('hidden');
426- document.getElementById('install-btn').onclick = function() {
427- deferredPrompt.prompt();
428- deferredPrompt.userChoice.then(function() {
429- deferredPrompt = null;
430- dlg.close();
431- });
432- };
433- } else {
434- manualEl.classList.remove('hidden');
435- if (/iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) {
436- document.getElementById('install-instructions-safari').classList.remove('hidden');
437- } else if (/Firefox/.test(ua) && /Android/.test(ua)) {
438- document.getElementById('install-instructions-firefox').classList.remove('hidden');
439- } else {
440- document.getElementById('install-instructions-chrome').classList.remove('hidden');
441- }
442- }
443- dlg.showModal();
444- }
445- </script>
446-</body>
447-</html>
448-{{end}}
deleted internal/tmpl/dashboard.html +0 -209
deleted file mode 100644
@@ -1,209 +0,0 @@
1-{{define "dashboard.html"}}
2-<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Dashboard</h1>
4- {{if gt .SubscriptionCount 0}}
5- <div class="flex gap-4 text-sm">
6- <span class="text-spot-secondary"><strong class="text-spot-text">{{.UnreadCount}}</strong> unread</span>
7- <span class="text-spot-secondary"><strong class="text-spot-text">{{.SubscriptionCount}}</strong> feeds</span>
8- </div>
9- {{end}}
10-</div>
11-<p class="text-sm text-spot-secondary mb-6">
12- {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}}
13-</p>
14-
15-<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>
16-
17-{{if eq .SubscriptionCount 0}}
18-<div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8">
19- <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
20- <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
21- </div>
22- <h2 class="text-lg font-semibold text-spot-text mb-2">Add your first feed</h2>
23- <p class="text-sm text-spot-secondary mb-6 max-w-xs mx-auto">Subscribe to RSS feeds to start building your personalized reading experience.</p>
24- <a href="/feeds" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-pill bg-spot-green text-white text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
25- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
26- Add feeds
27- </a>
28-</div>
29-{{else if .Articles}}
30-{{if gt .SubscriptionCount 0}}
31-<div id="article-recommendations" hx-get="/recs/articles" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#article-recommendations">
32- <div class="mb-8">
33- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
34- <div class="animate-pulse space-y-3">
35- <div class="bg-spot-surface rounded-xl px-5 py-4">
36- <div class="flex items-start gap-2.5">
37- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
38- <div class="flex-1 space-y-2">
39- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
40- <div class="h-3 bg-spot-hover rounded w-full"></div>
41- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
42- </div>
43- </div>
44- </div>
45- <div class="bg-spot-surface rounded-xl px-5 py-4">
46- <div class="flex items-start gap-2.5">
47- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
48- <div class="flex-1 space-y-2">
49- <div class="h-4 bg-spot-hover rounded w-1/2"></div>
50- <div class="h-3 bg-spot-hover rounded w-full"></div>
51- </div>
52- </div>
53- </div>
54- </div>
55- </div>
56-</div>
57-{{end}}
58-
59-{{if and .DigestEnabled .HasLLM}}
60-<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
61- <div class="mb-6">
62- <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
63- <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4">
64- <div class="flex items-start gap-2.5">
65- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
66- <div class="flex-1 space-y-2">
67- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
68- <div class="h-3 bg-spot-hover rounded w-full"></div>
69- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
70- </div>
71- </div>
72- </div>
73- </div>
74-</div>
75-{{end}}
76-
77-<div class="mb-8">
78- <div class="flex items-center justify-between mb-4">
79- <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2>
80- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">View all</a>
81- </div>
82- <div class="space-y-3">
83- {{range .Articles}}
84- {{template "article-card.html" .}}
85- {{end}}
86- </div>
87- {{if gt .UnreadCount 5}}
88- <a href="/articles" class="block mt-4 text-center text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition py-2">View all {{.UnreadCount}} unread articles</a>
89- {{end}}
90-</div>
91-{{else}}
92-{{if gt .SubscriptionCount 0}}
93-<div id="article-recommendations" hx-get="/recs/articles" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#article-recommendations">
94- <div class="mb-8">
95- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
96- <div class="animate-pulse space-y-3">
97- <div class="bg-spot-surface rounded-xl px-5 py-4">
98- <div class="flex items-start gap-2.5">
99- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
100- <div class="flex-1 space-y-2">
101- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
102- <div class="h-3 bg-spot-hover rounded w-full"></div>
103- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
104- </div>
105- </div>
106- </div>
107- </div>
108- </div>
109-</div>
110-{{end}}
111-
112-<div class="mb-8">
113- <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>
114- <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center">
115- <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
116- <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
117- </div>
118- <h3 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h3>
119- <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p>
120- </div>
121-</div>
122-{{end}}
123-
124-{{if or .GlobalTrending .PersonalTrending}}
125-<div class="mb-8">
126- <div class="flex items-center justify-between mb-4">
127- <h2 class="text-lg font-semibold text-spot-text">{{if eq .SubscriptionCount 0}}Trending{{else}}Trending in your network{{end}}</h2>
128- <a href="{{if eq .SubscriptionCount 0}}/trending{{else}}/trending?scope=for-me{{end}}" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
129- </div>
130- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
131- {{range .GlobalTrending}}{{template "trending-card.html" .}}{{end}}
132- {{range .PersonalTrending}}{{template "trending-card.html" .}}{{end}}
133- </div>
134-</div>
135-{{end}}
136-
137-<div id="people-recommendations" hx-get="/recs/people" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#people-recommendations">
138- <div class="mb-8">
139- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
140- <div>
141- <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
142- <div class="space-y-3">
143- <div class="animate-pulse bg-spot-surface rounded-xl p-4 flex items-center gap-3.5">
144- <div class="w-10 h-10 bg-spot-hover rounded-full"></div>
145- <div class="flex-1 space-y-2">
146- <div class="h-4 bg-spot-hover rounded w-1/3"></div>
147- <div class="h-3 bg-spot-hover rounded w-1/2"></div>
148- </div>
149- </div>
150- <div class="animate-pulse bg-spot-surface rounded-xl p-4 flex items-center gap-3.5">
151- <div class="w-10 h-10 bg-spot-hover rounded-full"></div>
152- <div class="flex-1 space-y-2">
153- <div class="h-4 bg-spot-hover rounded w-1/4"></div>
154- <div class="h-3 bg-spot-hover rounded w-2/5"></div>
155- </div>
156- </div>
157- </div>
158- </div>
159- <div>
160- <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
161- <div class="space-y-3">
162- <div class="animate-pulse bg-spot-surface rounded-xl p-4 flex items-center gap-3.5">
163- <div class="w-10 h-10 bg-spot-hover rounded-full"></div>
164- <div class="flex-1 space-y-2">
165- <div class="h-4 bg-spot-hover rounded w-1/3"></div>
166- <div class="h-3 bg-spot-hover rounded w-1/2"></div>
167- </div>
168- </div>
169- </div>
170- </div>
171- </div>
172- </div>
173-</div>
174-
175-<div id="feed-recommendations" hx-get="/recs/feeds" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#feed-recommendations">
176- <div class="mb-8">
177- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
178- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
179- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
180- <div class="flex items-center gap-2.5">
181- <div class="w-5 h-5 bg-spot-hover rounded"></div>
182- <div class="flex-1 space-y-2">
183- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
184- <div class="h-3 bg-spot-hover rounded w-full"></div>
185- </div>
186- </div>
187- </div>
188- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
189- <div class="flex items-center gap-2.5">
190- <div class="w-5 h-5 bg-spot-hover rounded"></div>
191- <div class="flex-1 space-y-2">
192- <div class="h-4 bg-spot-hover rounded w-1/2"></div>
193- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
194- </div>
195- </div>
196- </div>
197- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
198- <div class="flex items-center gap-2.5">
199- <div class="w-5 h-5 bg-spot-hover rounded"></div>
200- <div class="flex-1 space-y-2">
201- <div class="h-4 bg-spot-hover rounded w-3/5"></div>
202- <div class="h-3 bg-spot-hover rounded w-full"></div>
203- </div>
204- </div>
205- </div>
206- </div>
207- </div>
208-</div>
209-{{end}}
deleted file mode 100644
@@ -1,209 +0,0 @@
1-{{define "dashboard.html"}}
2-<div class="flex items-center justify-between mb-2 flex-wrap gap-2">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Dashboard</h1>
4- {{if gt .SubscriptionCount 0}}
5- <div class="flex gap-4 text-sm">
6- <span class="text-spot-secondary"><strong class="text-spot-text">{{.UnreadCount}}</strong> unread</span>
7- <span class="text-spot-secondary"><strong class="text-spot-text">{{.SubscriptionCount}}</strong> feeds</span>
8- </div>
9- {{end}}
10-</div>
11-<p class="text-sm text-spot-secondary mb-6">
12- {{if eq .SubscriptionCount 0}}Get started by subscribing to RSS feeds.{{else}}Your personalized feed, based on your social graph.{{end}}
13-</p>
14-
15-<div id="new-articles-poll" hx-get="/articles/new-count?since={{.Now.Unix}}&return=/dashboard" hx-trigger="every 30s" hx-swap="innerHTML"></div>
16-
17-{{if eq .SubscriptionCount 0}}
18-<div class="bg-spot-surface rounded-2xl py-12 px-6 text-center mb-8">
19- <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
20- <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-15m0 0l-6.75 6.75M12.75 4.5l6.75 6.75"/></svg>
21- </div>
22- <h2 class="text-lg font-semibold text-spot-text mb-2">Add your first feed</h2>
23- <p class="text-sm text-spot-secondary mb-6 max-w-xs mx-auto">Subscribe to RSS feeds to start building your personalized reading experience.</p>
24- <a href="/feeds" class="inline-flex items-center gap-2 px-5 py-2.5 rounded-pill bg-spot-green text-white text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
25- <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
26- Add feeds
27- </a>
28-</div>
29-{{else if .Articles}}
30-{{if gt .SubscriptionCount 0}}
31-<div id="article-recommendations" hx-get="/recs/articles" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#article-recommendations">
32- <div class="mb-8">
33- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
34- <div class="animate-pulse space-y-3">
35- <div class="bg-spot-surface rounded-xl px-5 py-4">
36- <div class="flex items-start gap-2.5">
37- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
38- <div class="flex-1 space-y-2">
39- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
40- <div class="h-3 bg-spot-hover rounded w-full"></div>
41- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
42- </div>
43- </div>
44- </div>
45- <div class="bg-spot-surface rounded-xl px-5 py-4">
46- <div class="flex items-start gap-2.5">
47- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
48- <div class="flex-1 space-y-2">
49- <div class="h-4 bg-spot-hover rounded w-1/2"></div>
50- <div class="h-3 bg-spot-hover rounded w-full"></div>
51- </div>
52- </div>
53- </div>
54- </div>
55- </div>
56-</div>
57-{{end}}
58-
59-{{if and .DigestEnabled .HasLLM}}
60-<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
61- <div class="mb-6">
62- <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
63- <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4">
64- <div class="flex items-start gap-2.5">
65- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
66- <div class="flex-1 space-y-2">
67- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
68- <div class="h-3 bg-spot-hover rounded w-full"></div>
69- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
70- </div>
71- </div>
72- </div>
73- </div>
74-</div>
75-{{end}}
76-
77-<div class="mb-8">
78- <div class="flex items-center justify-between mb-4">
79- <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2>
80- <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">View all</a>
81- </div>
82- <div class="space-y-3">
83- {{range .Articles}}
84- {{template "article-card.html" .}}
85- {{end}}
86- </div>
87- {{if gt .UnreadCount 5}}
88- <a href="/articles" class="block mt-4 text-center text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition py-2">View all {{.UnreadCount}} unread articles</a>
89- {{end}}
90-</div>
91-{{else}}
92-{{if gt .SubscriptionCount 0}}
93-<div id="article-recommendations" hx-get="/recs/articles" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#article-recommendations">
94- <div class="mb-8">
95- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
96- <div class="animate-pulse space-y-3">
97- <div class="bg-spot-surface rounded-xl px-5 py-4">
98- <div class="flex items-start gap-2.5">
99- <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
100- <div class="flex-1 space-y-2">
101- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
102- <div class="h-3 bg-spot-hover rounded w-full"></div>
103- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
104- </div>
105- </div>
106- </div>
107- </div>
108- </div>
109-</div>
110-{{end}}
111-
112-<div class="mb-8">
113- <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>
114- <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center">
115- <div class="w-16 h-16 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
116- <svg class="w-8 h-8 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
117- </div>
118- <h3 class="text-xl font-semibold text-spot-text mb-2">You're all caught up!</h3>
119- <p class="text-sm text-spot-secondary">No unread articles. Check back later for new content.</p>
120- </div>
121-</div>
122-{{end}}
123-
124-{{if or .GlobalTrending .PersonalTrending}}
125-<div class="mb-8">
126- <div class="flex items-center justify-between mb-4">
127- <h2 class="text-lg font-semibold text-spot-text">{{if eq .SubscriptionCount 0}}Trending{{else}}Trending in your network{{end}}</h2>
128- <a href="{{if eq .SubscriptionCount 0}}/trending{{else}}/trending?scope=for-me{{end}}" class="text-xs text-spot-secondary hover:text-spot-green uppercase tracking-button transition">See all</a>
129- </div>
130- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
131- {{range .GlobalTrending}}{{template "trending-card.html" .}}{{end}}
132- {{range .PersonalTrending}}{{template "trending-card.html" .}}{{end}}
133- </div>
134-</div>
135-{{end}}
136-
137-<div id="people-recommendations" hx-get="/recs/people" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#people-recommendations">
138- <div class="mb-8">
139- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
140- <div>
141- <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
142- <div class="space-y-3">
143- <div class="animate-pulse bg-spot-surface rounded-xl p-4 flex items-center gap-3.5">
144- <div class="w-10 h-10 bg-spot-hover rounded-full"></div>
145- <div class="flex-1 space-y-2">
146- <div class="h-4 bg-spot-hover rounded w-1/3"></div>
147- <div class="h-3 bg-spot-hover rounded w-1/2"></div>
148- </div>
149- </div>
150- <div class="animate-pulse bg-spot-surface rounded-xl p-4 flex items-center gap-3.5">
151- <div class="w-10 h-10 bg-spot-hover rounded-full"></div>
152- <div class="flex-1 space-y-2">
153- <div class="h-4 bg-spot-hover rounded w-1/4"></div>
154- <div class="h-3 bg-spot-hover rounded w-2/5"></div>
155- </div>
156- </div>
157- </div>
158- </div>
159- <div>
160- <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
161- <div class="space-y-3">
162- <div class="animate-pulse bg-spot-surface rounded-xl p-4 flex items-center gap-3.5">
163- <div class="w-10 h-10 bg-spot-hover rounded-full"></div>
164- <div class="flex-1 space-y-2">
165- <div class="h-4 bg-spot-hover rounded w-1/3"></div>
166- <div class="h-3 bg-spot-hover rounded w-1/2"></div>
167- </div>
168- </div>
169- </div>
170- </div>
171- </div>
172- </div>
173-</div>
174-
175-<div id="feed-recommendations" hx-get="/recs/feeds" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#feed-recommendations">
176- <div class="mb-8">
177- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
178- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
179- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
180- <div class="flex items-center gap-2.5">
181- <div class="w-5 h-5 bg-spot-hover rounded"></div>
182- <div class="flex-1 space-y-2">
183- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
184- <div class="h-3 bg-spot-hover rounded w-full"></div>
185- </div>
186- </div>
187- </div>
188- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
189- <div class="flex items-center gap-2.5">
190- <div class="w-5 h-5 bg-spot-hover rounded"></div>
191- <div class="flex-1 space-y-2">
192- <div class="h-4 bg-spot-hover rounded w-1/2"></div>
193- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
194- </div>
195- </div>
196- </div>
197- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
198- <div class="flex items-center gap-2.5">
199- <div class="w-5 h-5 bg-spot-hover rounded"></div>
200- <div class="flex-1 space-y-2">
201- <div class="h-4 bg-spot-hover rounded w-3/5"></div>
202- <div class="h-3 bg-spot-hover rounded w-full"></div>
203- </div>
204- </div>
205- </div>
206- </div>
207- </div>
208-</div>
209-{{end}}
deleted internal/tmpl/embed.go +0 -6
deleted file mode 100644
@@ -1,6 +0,0 @@
1-package tmpl
2-
3-import "embed"
4-
5-//go:embed *.html partials/*.html
6-var Files embed.FS
deleted file mode 100644
@@ -1,6 +0,0 @@
1-package tmpl
2-
3-import "embed"
4-
5-//go:embed *.html partials/*.html
6-var Files embed.FS
deleted internal/tmpl/error.html +0 -14
deleted file mode 100644
@@ -1,14 +0,0 @@
1-{{define "error.html"}}
2-<div class="flex items-center justify-center min-h-screen px-4 py-12">
3- <div class="max-w-sm w-full text-center">
4- <div class="flex justify-center mb-8">
5- <a href="/" class="w-16 h-16 block">{{template "logo-icon"}}</a>
6- </div>
7- <h1 class="text-2xl font-bold text-spot-text mb-2">{{.Title}}</h1>
8- <p class="text-spot-secondary text-sm mb-8">{{.Message}}</p>
9- <a href="/dashboard" class="inline-flex items-center justify-center gap-2 bg-spot-green text-white rounded-pill px-6 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
10- Back to Dashboard
11- </a>
12- </div>
13-</div>
14-{{end}}
deleted file mode 100644
@@ -1,14 +0,0 @@
1-{{define "error.html"}}
2-<div class="flex items-center justify-center min-h-screen px-4 py-12">
3- <div class="max-w-sm w-full text-center">
4- <div class="flex justify-center mb-8">
5- <a href="/" class="w-16 h-16 block">{{template "logo-icon"}}</a>
6- </div>
7- <h1 class="text-2xl font-bold text-spot-text mb-2">{{.Title}}</h1>
8- <p class="text-spot-secondary text-sm mb-8">{{.Message}}</p>
9- <a href="/dashboard" class="inline-flex items-center justify-center gap-2 bg-spot-green text-white rounded-pill px-6 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
10- Back to Dashboard
11- </a>
12- </div>
13-</div>
14-{{end}}
deleted internal/tmpl/feeds.html +0 -108
deleted file mode 100644
@@ -1,108 +0,0 @@
1-{{define "feeds.html"}}
2-<div class="flex items-center justify-between gap-3 mb-2 flex-wrap">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1>
4- <div class="flex items-center gap-3 flex-wrap">
5- <button id="refresh-btn"
6- hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML"
7- hx-on::before-request="gleanRefreshStart()"
8- hx-on::after-request="gleanRefreshPoll()"
9- class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition disabled:opacity-50">
10- Refresh feeds
11- </button>
12- <span id="refresh-indicator" class="text-sm text-spot-secondary" style="display:none">Refreshing feeds...</span>
13- </div>
14-</div>
15-<script>
16-function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true}
17-function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',{target:'#feed-list',swap:'innerHTML'});document.getElementById('refresh-indicator').style.display='none';document.getElementById('refresh-btn').disabled=false},5000)}
18-function gleanToggleEdit(id){var i=document.getElementById('feed-edit-'+id);if(i){i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.querySelector('input[name=category]').focus()}}
19-</script>
20-<p class="text-sm text-spot-secondary mb-6">Manage your RSS, Atom, and AT Protocol subscriptions.</p>
21-
22-{{template "dead-feeds.html" (dict "DeadFeeds" .DeadFeeds "CSRFToken" .CSRFToken)}}
23-
24-<div id="feed-recommendations" hx-get="/recs/feeds" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#feed-recommendations">
25- <div class="mb-6">
26- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recommended feeds</h2>
27- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
28- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
29- <div class="flex items-center gap-2.5">
30- <div class="w-5 h-5 bg-spot-hover rounded"></div>
31- <div class="flex-1 space-y-2">
32- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
33- <div class="h-3 bg-spot-hover rounded w-full"></div>
34- </div>
35- </div>
36- </div>
37- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
38- <div class="flex items-center gap-2.5">
39- <div class="w-5 h-5 bg-spot-hover rounded"></div>
40- <div class="flex-1 space-y-2">
41- <div class="h-4 bg-spot-hover rounded w-1/2"></div>
42- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
43- </div>
44- </div>
45- </div>
46- </div>
47- </div>
48-</div>
49-
50-<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
51- <div class="lg:col-span-2">
52- {{if .SubscriptionCount}}
53- <div class="flex gap-1.5 mb-6 flex-wrap">
54- <a href="/feeds" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
55- {{range .Categories}}
56- <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
57- {{end}}
58- <a href="/feeds?category=__none__" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a>
59- </div>
60- {{end}}
61-
62- <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider overflow-hidden">
63- {{range .Subscriptions}}
64- {{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}}
65- {{else}}
66- {{template "empty-state.html" (dict "icon" "icon-rss" "title" "No feeds yet" "subtitle" "Add one using the form on the right to get started.")}}
67- {{end}}
68- </div>
69-
70- {{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}}
71- </div>
72-
73- <div class="space-y-6">
74- <div class="bg-spot-surface rounded-xl p-4 space-y-4">
75- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Add feed</h2>
76- <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend"
77- hx-on::before-request="document.getElementById('add-feed-error').textContent=''"
78- hx-on::after-request="if(event.detail.successful){this.reset();location.reload()}else{document.getElementById('add-feed-error').textContent=event.detail.xhr.responseText}"
79- class="space-y-3">
80- {{csrfInput .CSRFToken}}
81- <input type="text" name="feed_url" placeholder="https://example.com/feed.xml or at://did:plc:.../site.standard.publication/..."
82- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
83- required>
84- <input type="text" name="category" placeholder="Category (optional)"
85- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
86- <button type="submit" class="w-full bg-spot-green text-white rounded-pill px-5 py-2.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
87- </form>
88- <div id="add-feed-error" class="text-sm text-spot-red"></div>
89- <div class="border-t border-spot-divider pt-4 space-y-3">
90- <h2 class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import / Export</h2>
91- <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2.5">
92- {{csrfInput .CSRFToken}}
93- <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
94- <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button>
95- </form>
96- {{if .Subscriptions}}<a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>{{end}}
97- </div>
98- </div>
99-
100- {{if .Subscriptions}}
101- <form hx-post="/feeds/clear" hx-confirm="Are you sure you want to unsubscribe from ALL feeds? This cannot be undone.">
102- {{csrfInput .CSRFToken}}
103- <button type="submit" class="w-full border border-spot-red/50 text-spot-red rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-red hover:bg-spot-red/10 transition">Clear all subscriptions</button>
104- </form>
105- {{end}}
106- </div>
107-</div>
108-{{end}}
deleted file mode 100644
@@ -1,108 +0,0 @@
1-{{define "feeds.html"}}
2-<div class="flex items-center justify-between gap-3 mb-2 flex-wrap">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Feeds <span class="text-base font-normal text-spot-secondary">({{.SubscriptionCount}})</span></h1>
4- <div class="flex items-center gap-3 flex-wrap">
5- <button id="refresh-btn"
6- hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML"
7- hx-on::before-request="gleanRefreshStart()"
8- hx-on::after-request="gleanRefreshPoll()"
9- class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition disabled:opacity-50">
10- Refresh feeds
11- </button>
12- <span id="refresh-indicator" class="text-sm text-spot-secondary" style="display:none">Refreshing feeds...</span>
13- </div>
14-</div>
15-<script>
16-function gleanRefreshStart(){document.getElementById('refresh-indicator').style.display='inline';document.getElementById('refresh-btn').disabled=true}
17-function gleanRefreshPoll(){setTimeout(function(){htmx.ajax('GET','/feeds/list',{target:'#feed-list',swap:'innerHTML'});document.getElementById('refresh-indicator').style.display='none';document.getElementById('refresh-btn').disabled=false},5000)}
18-function gleanToggleEdit(id){var i=document.getElementById('feed-edit-'+id);if(i){i.classList.toggle('hidden');if(!i.classList.contains('hidden'))i.querySelector('input[name=category]').focus()}}
19-</script>
20-<p class="text-sm text-spot-secondary mb-6">Manage your RSS, Atom, and AT Protocol subscriptions.</p>
21-
22-{{template "dead-feeds.html" (dict "DeadFeeds" .DeadFeeds "CSRFToken" .CSRFToken)}}
23-
24-<div id="feed-recommendations" hx-get="/recs/feeds" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#feed-recommendations">
25- <div class="mb-6">
26- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recommended feeds</h2>
27- <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
28- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
29- <div class="flex items-center gap-2.5">
30- <div class="w-5 h-5 bg-spot-hover rounded"></div>
31- <div class="flex-1 space-y-2">
32- <div class="h-4 bg-spot-hover rounded w-2/3"></div>
33- <div class="h-3 bg-spot-hover rounded w-full"></div>
34- </div>
35- </div>
36- </div>
37- <div class="animate-pulse bg-spot-surface rounded-xl p-3.5">
38- <div class="flex items-center gap-2.5">
39- <div class="w-5 h-5 bg-spot-hover rounded"></div>
40- <div class="flex-1 space-y-2">
41- <div class="h-4 bg-spot-hover rounded w-1/2"></div>
42- <div class="h-3 bg-spot-hover rounded w-4/5"></div>
43- </div>
44- </div>
45- </div>
46- </div>
47- </div>
48-</div>
49-
50-<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
51- <div class="lg:col-span-2">
52- {{if .SubscriptionCount}}
53- <div class="flex gap-1.5 mb-6 flex-wrap">
54- <a href="/feeds" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a>
55- {{range .Categories}}
56- <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a>
57- {{end}}
58- <a href="/feeds?category=__none__" class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq .Category "__none__"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">Uncategorized</a>
59- </div>
60- {{end}}
61-
62- <div id="feed-list" class="bg-spot-surface rounded-xl divide-y divide-spot-divider overflow-hidden">
63- {{range .Subscriptions}}
64- {{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}}
65- {{else}}
66- {{template "empty-state.html" (dict "icon" "icon-rss" "title" "No feeds yet" "subtitle" "Add one using the form on the right to get started.")}}
67- {{end}}
68- </div>
69-
70- {{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}}
71- </div>
72-
73- <div class="space-y-6">
74- <div class="bg-spot-surface rounded-xl p-4 space-y-4">
75- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Add feed</h2>
76- <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend"
77- hx-on::before-request="document.getElementById('add-feed-error').textContent=''"
78- hx-on::after-request="if(event.detail.successful){this.reset();location.reload()}else{document.getElementById('add-feed-error').textContent=event.detail.xhr.responseText}"
79- class="space-y-3">
80- {{csrfInput .CSRFToken}}
81- <input type="text" name="feed_url" placeholder="https://example.com/feed.xml or at://did:plc:.../site.standard.publication/..."
82- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
83- required>
84- <input type="text" name="category" placeholder="Category (optional)"
85- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
86- <button type="submit" class="w-full bg-spot-green text-white rounded-pill px-5 py-2.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button>
87- </form>
88- <div id="add-feed-error" class="text-sm text-spot-red"></div>
89- <div class="border-t border-spot-divider pt-4 space-y-3">
90- <h2 class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import / Export</h2>
91- <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2.5">
92- {{csrfInput .CSRFToken}}
93- <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
94- <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button>
95- </form>
96- {{if .Subscriptions}}<a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>{{end}}
97- </div>
98- </div>
99-
100- {{if .Subscriptions}}
101- <form hx-post="/feeds/clear" hx-confirm="Are you sure you want to unsubscribe from ALL feeds? This cannot be undone.">
102- {{csrfInput .CSRFToken}}
103- <button type="submit" class="w-full border border-spot-red/50 text-spot-red rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-red hover:bg-spot-red/10 transition">Clear all subscriptions</button>
104- </form>
105- {{end}}
106- </div>
107-</div>
108-{{end}}
deleted internal/tmpl/index.html +0 -272
deleted file mode 100644
@@ -1,272 +0,0 @@
1-{{define "index.html"}}
2-<style>
3-.landing-hero { padding-top: 3rem; padding-bottom: 2.5rem; }
4-@media (min-width: 768px) { .landing-hero { padding-top: 5rem; padding-bottom: 4rem; } }
5-@media (min-width: 1024px) { .landing-hero { padding-top: 6rem; padding-bottom: 5rem; } }
6-
7-.landing-annotation-highlight {
8- background: rgba(0,117,74,0.20);
9- border-bottom: 2px solid rgba(0,117,74,0.45);
10- padding: 1px 2px;
11- border-radius: 2px;
12-}
13-</style>
14-
15-<section class="landing-hero">
16- <div class="max-w-6xl mx-auto px-6">
17- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center">
18- <div>
19- <div class="flex items-center gap-3 mb-8">
20- <span class="w-10 h-10">{{template "logo-icon"}}</span>
21- <span class="font-bold text-xl text-spot-text" style="letter-spacing: -0.02em;">Glean</span>
22- </div>
23- <h1 class="text-4xl md:text-5xl lg:text-[3.5rem] font-bold mb-6 leading-[1.08] text-spot-text" style="letter-spacing: -0.035em;">
24- The social<br>RSS reader.
25- </h1>
26- <p class="text-lg md:text-xl text-spot-secondary mb-8 max-w-md leading-relaxed" style="letter-spacing: -0.01em;">
27- Read your feeds. See what your circle reads. Discover new sources through personalized recommendations. All on AT Protocol.
28- </p>
29- <div class="flex flex-col sm:flex-row gap-3">
30- <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-8 py-3.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition active:scale-[0.98] inline-flex items-center justify-center gap-2 shadow-spot">
31- Sign in
32- </a>
33- <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3.5 text-sm font-bold uppercase tracking-button hover:border-spot-text hover:bg-spot-hover-50 transition active:scale-[0.98]">
34- See what's trending
35- </a>
36- </div>
37- </div>
38-
39- <div class="hidden lg:block">
40- <div class="bg-spot-surface rounded-2xl shadow-spot-heavy overflow-hidden">
41- <div class="px-4 py-2.5 border-b border-spot-divider flex items-center gap-2 bg-spot-bg/50">
42- <div class="flex gap-1.5">
43- <div class="w-2.5 h-2.5 rounded-full bg-spot-red/80"></div>
44- <div class="w-2.5 h-2.5 rounded-full bg-spot-orange/80"></div>
45- <div class="w-2.5 h-2.5 rounded-full bg-spot-green/80"></div>
46- </div>
47- <span class="text-[11px] text-spot-muted ml-2 font-medium">Glean &mdash; Dashboard</span>
48- </div>
49- <div class="p-3 space-y-1.5">
50- <div class="rounded-xl px-4 py-3 bg-spot-hover/40 flex items-start gap-3">
51- <div class="w-8 h-8 rounded-lg bg-spot-green/15 shrink-0 flex items-center justify-center">
52- <svg class="w-4 h-4 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z"/></svg>
53- </div>
54- <div class="flex-1 min-w-0">
55- <div class="text-[13px] font-bold text-spot-text leading-tight">Why the open web is making a comeback</div>
56- <div class="text-[11px] text-spot-secondary mt-0.5">theopenweb.press &middot; 2h ago</div>
57- </div>
58- <div class="flex items-center gap-1 text-spot-green shrink-0 text-xs font-medium">
59- <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"/></svg>
60- 12
61- </div>
62- </div>
63- <div class="rounded-xl px-4 py-3 border-l-2 border-spot-green/80 flex items-start gap-3">
64- <div class="w-8 h-8 rounded-lg bg-spot-blue/15 shrink-0 flex items-center justify-center">
65- <svg class="w-4 h-4 text-spot-blue" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.015a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
66- </div>
67- <div class="flex-1 min-w-0">
68- <div class="text-[13px] font-bold text-spot-text leading-tight">How to take back control of your reading list</div>
69- <div class="text-[11px] text-spot-secondary mt-0.5">readwrite.cafe &middot; 5h ago</div>
70- </div>
71- </div>
72- <div class="rounded-xl px-4 py-3 flex items-start gap-3">
73- <div class="w-8 h-8 rounded-lg bg-spot-orange/15 shrink-0 flex items-center justify-center">
74- <svg class="w-4 h-4 text-spot-orange" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z"/></svg>
75- </div>
76- <div class="flex-1 min-w-0">
77- <div class="text-[13px] font-bold text-spot-text leading-tight">RSS is not dead, it was just resting</div>
78- <div class="text-[11px] text-spot-secondary mt-0.5">syndication.xyz &middot; 8h ago</div>
79- </div>
80- <div class="flex items-center gap-1 text-spot-secondary shrink-0 text-xs">
81- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
82- 3
83- </div>
84- </div>
85- <div class="rounded-xl px-4 py-3 flex items-start gap-3">
86- <div class="w-8 h-8 rounded-lg bg-spot-green/15 shrink-0 flex items-center justify-center">
87- <svg class="w-4 h-4 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
88- </div>
89- <div class="flex-1 min-w-0">
90- <div class="text-[13px] font-bold text-spot-text leading-tight">The case for owning your reading history</div>
91- <div class="text-[11px] text-spot-secondary mt-0.5">protocolweekly.io &middot; 1d ago</div>
92- </div>
93- </div>
94- </div>
95- <div class="px-4 py-2.5 border-t border-spot-divider flex items-center justify-between bg-spot-hover/30">
96- <div class="flex items-center gap-2 text-[11px] text-spot-muted">
97- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
98- 4 trending in your network
99- </div>
100- <span class="text-[11px] text-spot-green font-bold">42 unread</span>
101- </div>
102- </div>
103- </div>
104- </div>
105- </div>
106-</section>
107-
108-<section class="py-16 md:py-24">
109- <div class="w-full h-px bg-gradient-to-r from-transparent via-spot-divider to-transparent mb-16 md:mb-20"></div>
110- <div class="max-w-6xl mx-auto px-6">
111- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
112- <div>
113- <h2 class="text-2xl md:text-3xl font-bold text-spot-text mb-4" style="letter-spacing: -0.025em;">Read with intent</h2>
114- <p class="text-spot-secondary leading-relaxed mb-6">
115- Highlight a passage that resonates. Write a note to your future self or friends. Tag it, come back to it later. Your annotations live in your personal data repository.
116- </p>
117- <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
118- <span class="flex items-center gap-2 text-spot-secondary">
119- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
120- Highlight passages
121- </span>
122- <span class="flex items-center gap-2 text-spot-secondary">
123- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
124- Notes &amp; tags
125- </span>
126- <span class="flex items-center gap-2 text-spot-secondary">
127- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
128- Save articles
129- </span>
130- </div>
131- </div>
132- <div class="bg-spot-surface rounded-2xl shadow-spot-heavy overflow-hidden">
133- <div class="px-5 py-3.5 border-b border-spot-divider flex items-center gap-3">
134- <div class="w-6 h-6 rounded-full bg-spot-green/15 flex items-center justify-center">
135- <svg class="w-3 h-3 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"/></svg>
136- </div>
137- <div>
138- <div class="text-[13px] font-semibold text-spot-text leading-tight">The architecture of attention</div>
139- <div class="text-[11px] text-spot-secondary">readingdesign.org</div>
140- </div>
141- </div>
142- <div class="px-5 py-4 text-sm text-spot-body leading-[1.75]">
143- <p>The real problem isn't information overload &mdash; it's that we've <span class="landing-annotation-highlight">lost the habit of reading deeply</span>. Scrolling replaces absorbing. Skimming replaces understanding. The feed never ends, but comprehension does.</p>
144- </div>
145- <div class="px-5 pb-4">
146- <div class="bg-spot-hover rounded-xl p-3.5 border-l-2 border-spot-green">
147- <p class="text-sm text-spot-text italic leading-relaxed">"lost the habit of reading deeply"</p>
148- <p class="text-xs text-spot-secondary mt-2">This is exactly what I've been feeling. The shift happened so gradually I didn't notice.</p>
149- <div class="flex gap-1.5 mt-2">
150- <span class="text-[11px] bg-spot-surface text-spot-secondary px-2 py-0.5 rounded-pill">reading</span>
151- <span class="text-[11px] bg-spot-surface text-spot-secondary px-2 py-0.5 rounded-pill">attention</span>
152- </div>
153- </div>
154- </div>
155- </div>
156- </div>
157- </div>
158-</section>
159-
160-<section class="py-16 md:py-24 border-t border-spot-divider">
161- <div class="max-w-6xl mx-auto px-6">
162- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
163- <div class="order-2 lg:order-1">
164- <div class="space-y-2">
165- <div class="bg-spot-surface rounded-xl px-4 py-3.5 flex items-start gap-3 shadow-spot">
166- <div class="w-8 h-8 rounded-lg bg-spot-green/15 shrink-0 flex items-center justify-center">
167- <svg class="w-4 h-4 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z"/></svg>
168- </div>
169- <div class="flex-1 min-w-0">
170- <div class="text-[13px] font-semibold text-spot-text leading-tight">Why the open web is making a comeback</div>
171- <div class="text-[11px] text-spot-secondary mt-0.5">theopenweb.press</div>
172- </div>
173- <div class="flex items-center gap-1 text-spot-green shrink-0 text-xs font-medium">
174- <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"/></svg>
175- 12
176- </div>
177- </div>
178- <div class="bg-spot-surface rounded-xl px-4 py-3.5 flex items-start gap-3 shadow-spot">
179- <div class="w-8 h-8 rounded-lg bg-spot-orange/15 shrink-0 flex items-center justify-center">
180- <svg class="w-4 h-4 text-spot-orange" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z"/></svg>
181- </div>
182- <div class="flex-1 min-w-0">
183- <div class="text-[13px] font-semibold text-spot-text leading-tight">RSS is not dead, it was just resting</div>
184- <div class="text-[11px] text-spot-secondary mt-0.5">syndication.xyz</div>
185- </div>
186- <div class="flex items-center gap-1 text-spot-secondary shrink-0 text-xs">
187- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
188- 3
189- </div>
190- </div>
191- <div class="bg-spot-surface rounded-xl px-4 py-3.5 flex items-start gap-3 shadow-spot">
192- <div class="w-8 h-8 rounded-lg bg-spot-blue/15 shrink-0 flex items-center justify-center">
193- <svg class="w-4 h-4 text-spot-blue" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.015a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
194- </div>
195- <div class="flex-1 min-w-0">
196- <div class="text-[13px] font-semibold text-spot-text leading-tight">How to take back control of your reading list</div>
197- <div class="text-[11px] text-spot-secondary mt-0.5">readwrite.cafe</div>
198- </div>
199- </div>
200- </div>
201- </div>
202- <div class="order-1 lg:order-2">
203- <h2 class="text-2xl md:text-3xl font-bold text-spot-text mb-4" style="letter-spacing: -0.025em;">See what your circle reads</h2>
204- <p class="text-spot-secondary leading-relaxed mb-6">
205- Trending articles from your network. Feed recommendations based on overlapping subscriptions. Discover people who read what you read. Social discovery, not algorithmic manipulation.
206- </p>
207- <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
208- <span class="flex items-center gap-2 text-spot-secondary">
209- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
210- Trending articles
211- </span>
212- <span class="flex items-center gap-2 text-spot-secondary">
213- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
214- Article suggestions
215- </span>
216- <span class="flex items-center gap-2 text-spot-secondary">
217- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
218- Feed suggestions
219- </span>
220- <span class="flex items-center gap-2 text-spot-secondary">
221- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
222- Reader suggestions
223- </span>
224- </div>
225- </div>
226- </div>
227- </div>
228-</section>
229-
230-<section class="bg-spot-green-house">
231- <div class="max-w-6xl mx-auto px-6 py-16 md:py-20">
232- <div class="max-w-2xl mb-10">
233- <h2 class="text-2xl md:text-3xl font-bold text-white mb-4" style="letter-spacing: -0.025em;">Your feeds. Your data. Yours.</h2>
234- <p class="text-[rgba(255,255,255,0.70)] leading-relaxed mb-3">
235- Glean is part of the Atmosphere. Annotations are compatible with <a href="https://margin.at" target="_blank" class="text-spot-green hover:brightness-110 transition">Margin.at</a> notes, feeds with <a href="https://skyreader.app" target="_blank" class="text-spot-green hover:brightness-110 transition">Skyreader</a> subscriptions, and your social graph travels with you from <a href="https://bsky.social" target="_blank" class="text-spot-green hover:brightness-110 transition">Bluesky</a> and <a href="https://tangled.org" target="_blank" class="text-spot-green hover:brightness-110 transition">Tangled</a>. All on AT Protocol.
236- </p>
237- <p class="text-[rgba(255,255,255,0.50)] text-sm leading-relaxed">
238- Privacy-first. No ads. No tracking.
239- </p>
240- </div>
241- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
242- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
243- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
244- AT Protocol identity
245- </div>
246- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
247- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
248- Portable data
249- </div>
250- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
251- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
252- <a href="https://tangled.org/julien.rbrt.fr/glean" class="hover:text-white transition">Open source</a>
253- </div>
254- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
255- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
256- <a href="https://tangled.org/julien.rbrt.fr/glean" target="_blank" class="hover:text-white transition">Self-hostable</a>
257- </div>
258- </div>
259- </div>
260-</section>
261-
262-<section class="py-16 text-center">
263- <div class="max-w-md mx-auto px-6">
264- <div class="w-14 h-14 mx-auto mb-6">{{template "logo-icon"}}</div>
265- <h2 class="text-2xl md:text-3xl font-bold text-spot-text mb-3" style="letter-spacing: -0.025em;">Start reading</h2>
266- <p class="text-spot-secondary mb-6 text-sm leading-relaxed">Sign in with your Bluesky handle or any Atmosphere account.</p>
267- <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-10 py-3.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition active:scale-[0.98] inline-flex items-center justify-center gap-2 shadow-spot">
268- Get started
269- </a>
270- </div>
271-</section>
272-{{end}}
deleted file mode 100644
@@ -1,272 +0,0 @@
1-{{define "index.html"}}
2-<style>
3-.landing-hero { padding-top: 3rem; padding-bottom: 2.5rem; }
4-@media (min-width: 768px) { .landing-hero { padding-top: 5rem; padding-bottom: 4rem; } }
5-@media (min-width: 1024px) { .landing-hero { padding-top: 6rem; padding-bottom: 5rem; } }
6-
7-.landing-annotation-highlight {
8- background: rgba(0,117,74,0.20);
9- border-bottom: 2px solid rgba(0,117,74,0.45);
10- padding: 1px 2px;
11- border-radius: 2px;
12-}
13-</style>
14-
15-<section class="landing-hero">
16- <div class="max-w-6xl mx-auto px-6">
17- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center">
18- <div>
19- <div class="flex items-center gap-3 mb-8">
20- <span class="w-10 h-10">{{template "logo-icon"}}</span>
21- <span class="font-bold text-xl text-spot-text" style="letter-spacing: -0.02em;">Glean</span>
22- </div>
23- <h1 class="text-4xl md:text-5xl lg:text-[3.5rem] font-bold mb-6 leading-[1.08] text-spot-text" style="letter-spacing: -0.035em;">
24- The social<br>RSS reader.
25- </h1>
26- <p class="text-lg md:text-xl text-spot-secondary mb-8 max-w-md leading-relaxed" style="letter-spacing: -0.01em;">
27- Read your feeds. See what your circle reads. Discover new sources through personalized recommendations. All on AT Protocol.
28- </p>
29- <div class="flex flex-col sm:flex-row gap-3">
30- <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-8 py-3.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition active:scale-[0.98] inline-flex items-center justify-center gap-2 shadow-spot">
31- Sign in
32- </a>
33- <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3.5 text-sm font-bold uppercase tracking-button hover:border-spot-text hover:bg-spot-hover-50 transition active:scale-[0.98]">
34- See what's trending
35- </a>
36- </div>
37- </div>
38-
39- <div class="hidden lg:block">
40- <div class="bg-spot-surface rounded-2xl shadow-spot-heavy overflow-hidden">
41- <div class="px-4 py-2.5 border-b border-spot-divider flex items-center gap-2 bg-spot-bg/50">
42- <div class="flex gap-1.5">
43- <div class="w-2.5 h-2.5 rounded-full bg-spot-red/80"></div>
44- <div class="w-2.5 h-2.5 rounded-full bg-spot-orange/80"></div>
45- <div class="w-2.5 h-2.5 rounded-full bg-spot-green/80"></div>
46- </div>
47- <span class="text-[11px] text-spot-muted ml-2 font-medium">Glean &mdash; Dashboard</span>
48- </div>
49- <div class="p-3 space-y-1.5">
50- <div class="rounded-xl px-4 py-3 bg-spot-hover/40 flex items-start gap-3">
51- <div class="w-8 h-8 rounded-lg bg-spot-green/15 shrink-0 flex items-center justify-center">
52- <svg class="w-4 h-4 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z"/></svg>
53- </div>
54- <div class="flex-1 min-w-0">
55- <div class="text-[13px] font-bold text-spot-text leading-tight">Why the open web is making a comeback</div>
56- <div class="text-[11px] text-spot-secondary mt-0.5">theopenweb.press &middot; 2h ago</div>
57- </div>
58- <div class="flex items-center gap-1 text-spot-green shrink-0 text-xs font-medium">
59- <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"/></svg>
60- 12
61- </div>
62- </div>
63- <div class="rounded-xl px-4 py-3 border-l-2 border-spot-green/80 flex items-start gap-3">
64- <div class="w-8 h-8 rounded-lg bg-spot-blue/15 shrink-0 flex items-center justify-center">
65- <svg class="w-4 h-4 text-spot-blue" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.015a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
66- </div>
67- <div class="flex-1 min-w-0">
68- <div class="text-[13px] font-bold text-spot-text leading-tight">How to take back control of your reading list</div>
69- <div class="text-[11px] text-spot-secondary mt-0.5">readwrite.cafe &middot; 5h ago</div>
70- </div>
71- </div>
72- <div class="rounded-xl px-4 py-3 flex items-start gap-3">
73- <div class="w-8 h-8 rounded-lg bg-spot-orange/15 shrink-0 flex items-center justify-center">
74- <svg class="w-4 h-4 text-spot-orange" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z"/></svg>
75- </div>
76- <div class="flex-1 min-w-0">
77- <div class="text-[13px] font-bold text-spot-text leading-tight">RSS is not dead, it was just resting</div>
78- <div class="text-[11px] text-spot-secondary mt-0.5">syndication.xyz &middot; 8h ago</div>
79- </div>
80- <div class="flex items-center gap-1 text-spot-secondary shrink-0 text-xs">
81- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
82- 3
83- </div>
84- </div>
85- <div class="rounded-xl px-4 py-3 flex items-start gap-3">
86- <div class="w-8 h-8 rounded-lg bg-spot-green/15 shrink-0 flex items-center justify-center">
87- <svg class="w-4 h-4 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/></svg>
88- </div>
89- <div class="flex-1 min-w-0">
90- <div class="text-[13px] font-bold text-spot-text leading-tight">The case for owning your reading history</div>
91- <div class="text-[11px] text-spot-secondary mt-0.5">protocolweekly.io &middot; 1d ago</div>
92- </div>
93- </div>
94- </div>
95- <div class="px-4 py-2.5 border-t border-spot-divider flex items-center justify-between bg-spot-hover/30">
96- <div class="flex items-center gap-2 text-[11px] text-spot-muted">
97- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
98- 4 trending in your network
99- </div>
100- <span class="text-[11px] text-spot-green font-bold">42 unread</span>
101- </div>
102- </div>
103- </div>
104- </div>
105- </div>
106-</section>
107-
108-<section class="py-16 md:py-24">
109- <div class="w-full h-px bg-gradient-to-r from-transparent via-spot-divider to-transparent mb-16 md:mb-20"></div>
110- <div class="max-w-6xl mx-auto px-6">
111- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
112- <div>
113- <h2 class="text-2xl md:text-3xl font-bold text-spot-text mb-4" style="letter-spacing: -0.025em;">Read with intent</h2>
114- <p class="text-spot-secondary leading-relaxed mb-6">
115- Highlight a passage that resonates. Write a note to your future self or friends. Tag it, come back to it later. Your annotations live in your personal data repository.
116- </p>
117- <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
118- <span class="flex items-center gap-2 text-spot-secondary">
119- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
120- Highlight passages
121- </span>
122- <span class="flex items-center gap-2 text-spot-secondary">
123- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
124- Notes &amp; tags
125- </span>
126- <span class="flex items-center gap-2 text-spot-secondary">
127- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
128- Save articles
129- </span>
130- </div>
131- </div>
132- <div class="bg-spot-surface rounded-2xl shadow-spot-heavy overflow-hidden">
133- <div class="px-5 py-3.5 border-b border-spot-divider flex items-center gap-3">
134- <div class="w-6 h-6 rounded-full bg-spot-green/15 flex items-center justify-center">
135- <svg class="w-3 h-3 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25"/></svg>
136- </div>
137- <div>
138- <div class="text-[13px] font-semibold text-spot-text leading-tight">The architecture of attention</div>
139- <div class="text-[11px] text-spot-secondary">readingdesign.org</div>
140- </div>
141- </div>
142- <div class="px-5 py-4 text-sm text-spot-body leading-[1.75]">
143- <p>The real problem isn't information overload &mdash; it's that we've <span class="landing-annotation-highlight">lost the habit of reading deeply</span>. Scrolling replaces absorbing. Skimming replaces understanding. The feed never ends, but comprehension does.</p>
144- </div>
145- <div class="px-5 pb-4">
146- <div class="bg-spot-hover rounded-xl p-3.5 border-l-2 border-spot-green">
147- <p class="text-sm text-spot-text italic leading-relaxed">"lost the habit of reading deeply"</p>
148- <p class="text-xs text-spot-secondary mt-2">This is exactly what I've been feeling. The shift happened so gradually I didn't notice.</p>
149- <div class="flex gap-1.5 mt-2">
150- <span class="text-[11px] bg-spot-surface text-spot-secondary px-2 py-0.5 rounded-pill">reading</span>
151- <span class="text-[11px] bg-spot-surface text-spot-secondary px-2 py-0.5 rounded-pill">attention</span>
152- </div>
153- </div>
154- </div>
155- </div>
156- </div>
157- </div>
158-</section>
159-
160-<section class="py-16 md:py-24 border-t border-spot-divider">
161- <div class="max-w-6xl mx-auto px-6">
162- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
163- <div class="order-2 lg:order-1">
164- <div class="space-y-2">
165- <div class="bg-spot-surface rounded-xl px-4 py-3.5 flex items-start gap-3 shadow-spot">
166- <div class="w-8 h-8 rounded-lg bg-spot-green/15 shrink-0 flex items-center justify-center">
167- <svg class="w-4 h-4 text-spot-green" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z"/></svg>
168- </div>
169- <div class="flex-1 min-w-0">
170- <div class="text-[13px] font-semibold text-spot-text leading-tight">Why the open web is making a comeback</div>
171- <div class="text-[11px] text-spot-secondary mt-0.5">theopenweb.press</div>
172- </div>
173- <div class="flex items-center gap-1 text-spot-green shrink-0 text-xs font-medium">
174- <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"/></svg>
175- 12
176- </div>
177- </div>
178- <div class="bg-spot-surface rounded-xl px-4 py-3.5 flex items-start gap-3 shadow-spot">
179- <div class="w-8 h-8 rounded-lg bg-spot-orange/15 shrink-0 flex items-center justify-center">
180- <svg class="w-4 h-4 text-spot-orange" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M12 18a3.75 3.75 0 0 0 .495-7.468 5.99 5.99 0 0 0-1.925 3.547 5.975 5.975 0 0 1-2.133-1.001A3.75 3.75 0 0 0 12 18Z"/></svg>
181- </div>
182- <div class="flex-1 min-w-0">
183- <div class="text-[13px] font-semibold text-spot-text leading-tight">RSS is not dead, it was just resting</div>
184- <div class="text-[11px] text-spot-secondary mt-0.5">syndication.xyz</div>
185- </div>
186- <div class="flex items-center gap-1 text-spot-secondary shrink-0 text-xs">
187- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
188- 3
189- </div>
190- </div>
191- <div class="bg-spot-surface rounded-xl px-4 py-3.5 flex items-start gap-3 shadow-spot">
192- <div class="w-8 h-8 rounded-lg bg-spot-blue/15 shrink-0 flex items-center justify-center">
193- <svg class="w-4 h-4 text-spot-blue" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016c.896 0 1.7-.393 2.25-1.015a3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
194- </div>
195- <div class="flex-1 min-w-0">
196- <div class="text-[13px] font-semibold text-spot-text leading-tight">How to take back control of your reading list</div>
197- <div class="text-[11px] text-spot-secondary mt-0.5">readwrite.cafe</div>
198- </div>
199- </div>
200- </div>
201- </div>
202- <div class="order-1 lg:order-2">
203- <h2 class="text-2xl md:text-3xl font-bold text-spot-text mb-4" style="letter-spacing: -0.025em;">See what your circle reads</h2>
204- <p class="text-spot-secondary leading-relaxed mb-6">
205- Trending articles from your network. Feed recommendations based on overlapping subscriptions. Discover people who read what you read. Social discovery, not algorithmic manipulation.
206- </p>
207- <div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
208- <span class="flex items-center gap-2 text-spot-secondary">
209- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
210- Trending articles
211- </span>
212- <span class="flex items-center gap-2 text-spot-secondary">
213- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
214- Article suggestions
215- </span>
216- <span class="flex items-center gap-2 text-spot-secondary">
217- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
218- Feed suggestions
219- </span>
220- <span class="flex items-center gap-2 text-spot-secondary">
221- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
222- Reader suggestions
223- </span>
224- </div>
225- </div>
226- </div>
227- </div>
228-</section>
229-
230-<section class="bg-spot-green-house">
231- <div class="max-w-6xl mx-auto px-6 py-16 md:py-20">
232- <div class="max-w-2xl mb-10">
233- <h2 class="text-2xl md:text-3xl font-bold text-white mb-4" style="letter-spacing: -0.025em;">Your feeds. Your data. Yours.</h2>
234- <p class="text-[rgba(255,255,255,0.70)] leading-relaxed mb-3">
235- Glean is part of the Atmosphere. Annotations are compatible with <a href="https://margin.at" target="_blank" class="text-spot-green hover:brightness-110 transition">Margin.at</a> notes, feeds with <a href="https://skyreader.app" target="_blank" class="text-spot-green hover:brightness-110 transition">Skyreader</a> subscriptions, and your social graph travels with you from <a href="https://bsky.social" target="_blank" class="text-spot-green hover:brightness-110 transition">Bluesky</a> and <a href="https://tangled.org" target="_blank" class="text-spot-green hover:brightness-110 transition">Tangled</a>. All on AT Protocol.
236- </p>
237- <p class="text-[rgba(255,255,255,0.50)] text-sm leading-relaxed">
238- Privacy-first. No ads. No tracking.
239- </p>
240- </div>
241- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
242- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
243- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
244- AT Protocol identity
245- </div>
246- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
247- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
248- Portable data
249- </div>
250- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
251- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
252- <a href="https://tangled.org/julien.rbrt.fr/glean" class="hover:text-white transition">Open source</a>
253- </div>
254- <div class="flex items-center gap-2.5 text-[rgba(255,255,255,0.70)] text-sm">
255- <svg class="w-4 h-4 text-spot-green shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
256- <a href="https://tangled.org/julien.rbrt.fr/glean" target="_blank" class="hover:text-white transition">Self-hostable</a>
257- </div>
258- </div>
259- </div>
260-</section>
261-
262-<section class="py-16 text-center">
263- <div class="max-w-md mx-auto px-6">
264- <div class="w-14 h-14 mx-auto mb-6">{{template "logo-icon"}}</div>
265- <h2 class="text-2xl md:text-3xl font-bold text-spot-text mb-3" style="letter-spacing: -0.025em;">Start reading</h2>
266- <p class="text-spot-secondary mb-6 text-sm leading-relaxed">Sign in with your Bluesky handle or any Atmosphere account.</p>
267- <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-10 py-3.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition active:scale-[0.98] inline-flex items-center justify-center gap-2 shadow-spot">
268- Get started
269- </a>
270- </div>
271-</section>
272-{{end}}
deleted internal/tmpl/library.html +0 -67
deleted file mode 100644
@@ -1,67 +0,0 @@
1-{{define "library.html"}}
2-<div class="flex items-center justify-between mb-2">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Library</h1>
4- <div></div>
5-</div>
6-<p class="text-sm text-spot-secondary mb-6">Your liked articles and annotations.</p>
7-
8-<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-12">
9- <div>
10- <h2 class="text-lg font-semibold text-spot-text mb-4">Liked articles</h2>
11- <div id="liked-list" class="space-y-3">
12- {{range .Articles}}
13- {{template "article-card.html" .}}
14- {{else}}
15- {{template "empty-state.html" (dict "icon" "icon-heart" "color" "red" "title" "No liked articles yet" "subtitle" "Like articles to save them here.")}}
16- {{end}}
17- </div>
18-
19- {{if or .LikedPage.HasPrev .LikedPage.HasNext}}
20- <div class="flex items-center justify-center gap-3 py-6">
21- {{if .LikedPage.HasPrev}}
22- <a href="/library?liked_page={{.LikedPage.PrevPage}}{{if gt .AnnotPage.Page 1}}&annot_page={{.AnnotPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">&laquo; Prev</a>
23- {{else}}
24- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">&laquo; Prev</span>
25- {{end}}
26-
27- <span class="text-sm text-spot-secondary">Page {{.LikedPage.Page}}</span>
28-
29- {{if .LikedPage.HasNext}}
30- <a href="/library?liked_page={{.LikedPage.NextPage}}{{if gt .AnnotPage.Page 1}}&annot_page={{.AnnotPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">Next &raquo;</a>
31- {{else}}
32- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">Next &raquo;</span>
33- {{end}}
34- </div>
35- {{end}}
36- </div>
37-
38- <div>
39- <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2>
40- <div id="annotations-list" class="space-y-3">
41- {{range .Annotations}}
42- {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}}
43- {{else}}
44- {{template "empty-state.html" (dict "icon" "icon-annotation" "title" "No annotations yet" "subtitle" "Highlight and annotate articles as you read.")}}
45- {{end}}
46- </div>
47-
48- {{if or .AnnotPage.HasPrev .AnnotPage.HasNext}}
49- <div class="flex items-center justify-center gap-3 py-6">
50- {{if .AnnotPage.HasPrev}}
51- <a href="/library?annot_page={{.AnnotPage.PrevPage}}{{if gt .LikedPage.Page 1}}&liked_page={{.LikedPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">&laquo; Prev</a>
52- {{else}}
53- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">&laquo; Prev</span>
54- {{end}}
55-
56- <span class="text-sm text-spot-secondary">Page {{.AnnotPage.Page}}</span>
57-
58- {{if .AnnotPage.HasNext}}
59- <a href="/library?annot_page={{.AnnotPage.NextPage}}{{if gt .LikedPage.Page 1}}&liked_page={{.LikedPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">Next &raquo;</a>
60- {{else}}
61- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">Next &raquo;</span>
62- {{end}}
63- </div>
64- {{end}}
65- </div>
66-</div>
67-{{end}}
deleted file mode 100644
@@ -1,67 +0,0 @@
1-{{define "library.html"}}
2-<div class="flex items-center justify-between mb-2">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Library</h1>
4- <div></div>
5-</div>
6-<p class="text-sm text-spot-secondary mb-6">Your liked articles and annotations.</p>
7-
8-<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-12">
9- <div>
10- <h2 class="text-lg font-semibold text-spot-text mb-4">Liked articles</h2>
11- <div id="liked-list" class="space-y-3">
12- {{range .Articles}}
13- {{template "article-card.html" .}}
14- {{else}}
15- {{template "empty-state.html" (dict "icon" "icon-heart" "color" "red" "title" "No liked articles yet" "subtitle" "Like articles to save them here.")}}
16- {{end}}
17- </div>
18-
19- {{if or .LikedPage.HasPrev .LikedPage.HasNext}}
20- <div class="flex items-center justify-center gap-3 py-6">
21- {{if .LikedPage.HasPrev}}
22- <a href="/library?liked_page={{.LikedPage.PrevPage}}{{if gt .AnnotPage.Page 1}}&annot_page={{.AnnotPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">&laquo; Prev</a>
23- {{else}}
24- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">&laquo; Prev</span>
25- {{end}}
26-
27- <span class="text-sm text-spot-secondary">Page {{.LikedPage.Page}}</span>
28-
29- {{if .LikedPage.HasNext}}
30- <a href="/library?liked_page={{.LikedPage.NextPage}}{{if gt .AnnotPage.Page 1}}&annot_page={{.AnnotPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">Next &raquo;</a>
31- {{else}}
32- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">Next &raquo;</span>
33- {{end}}
34- </div>
35- {{end}}
36- </div>
37-
38- <div>
39- <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2>
40- <div id="annotations-list" class="space-y-3">
41- {{range .Annotations}}
42- {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}}
43- {{else}}
44- {{template "empty-state.html" (dict "icon" "icon-annotation" "title" "No annotations yet" "subtitle" "Highlight and annotate articles as you read.")}}
45- {{end}}
46- </div>
47-
48- {{if or .AnnotPage.HasPrev .AnnotPage.HasNext}}
49- <div class="flex items-center justify-center gap-3 py-6">
50- {{if .AnnotPage.HasPrev}}
51- <a href="/library?annot_page={{.AnnotPage.PrevPage}}{{if gt .LikedPage.Page 1}}&liked_page={{.LikedPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">&laquo; Prev</a>
52- {{else}}
53- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">&laquo; Prev</span>
54- {{end}}
55-
56- <span class="text-sm text-spot-secondary">Page {{.AnnotPage.Page}}</span>
57-
58- {{if .AnnotPage.HasNext}}
59- <a href="/library?annot_page={{.AnnotPage.NextPage}}{{if gt .LikedPage.Page 1}}&liked_page={{.LikedPage.Page}}{{end}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">Next &raquo;</a>
60- {{else}}
61- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">Next &raquo;</span>
62- {{end}}
63- </div>
64- {{end}}
65- </div>
66-</div>
67-{{end}}
deleted internal/tmpl/login.html +0 -125
deleted file mode 100644
@@ -1,125 +0,0 @@
1-{{define "login.html"}}
2-<style>
3-.login-divider { display: flex; align-items: center; gap: 0.75rem; }
4-.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--spot-divider); }
5-</style>
6-
7-<div class="flex items-center justify-center min-h-screen px-4 py-12">
8- <div class="max-w-sm w-full">
9- <div class="flex justify-center mb-10">
10- <a href="/" class="w-16 h-16 block">{{template "logo-icon"}}</a>
11- </div>
12-
13- <h1 class="text-2xl font-bold text-spot-text mb-2 text-center" style="letter-spacing: -0.02em;">Welcome to Glean</h1>
14- <p class="text-spot-secondary text-sm mb-8 text-center">The social RSS reader built on AT Protocol.</p>
15-
16- <form action="/auth/start" method="POST" id="login-form">
17- {{csrfInput .CSRFToken}}
18- <div class="relative mb-4">
19- <label for="handle-input" class="block text-xs text-spot-secondary mb-1.5 font-medium">Login with your Atmosphere account</label>
20- <input type="text" name="handle" placeholder="you.bsky.social" id="handle-input"
21- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
22- required autocomplete="off">
23- <div id="handle-suggestions" class="absolute left-0 right-0 top-full mt-1 bg-spot-surface border border-spot-divider rounded-xl shadow-spot-heavy z-50 overflow-hidden hidden"></div>
24- </div>
25-
26- <button type="submit"
27- class="w-full flex items-center justify-center bg-spot-green text-white rounded-pill px-4 py-3.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
28- Login
29- </button>
30- </form>
31-
32- <div class="login-divider text-xs text-spot-muted uppercase tracking-wide mt-8 mb-6">
33- <span>New here?</span>
34- </div>
35-
36- <a href="/auth/register"
37- class="w-full flex items-center justify-center gap-3 border border-spot-outline text-spot-text rounded-pill px-4 py-3.5 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition">
38- <svg class="h-5 w-auto" fill="currentColor" viewBox="77.86 113.9 129.15 129.15" xmlns="http://www.w3.org/2000/svg"><path d="M148.846 144.562C148.846 159.75 161.158 172.062 176.346 172.062H207.012V185.865H176.346C161.158 185.865 148.846 198.177 148.846 213.365V243.045H136.029V213.365C136.029 198.177 123.717 185.865 108.529 185.865H77.8633V172.062H108.529C123.717 172.062 136.029 159.75 136.029 144.562V113.896H148.846V144.562Z"/></svg>
39- Register with Eurosky
40- </a>
41-
42- <p class="text-[11px] text-spot-muted text-center leading-relaxed mt-8">
43- By continuing, you agree to the <a href="/terms" class="underline hover:text-spot-secondary transition">Terms of Service</a>.
44- </p>
45- </div>
46-</div>
47-<script>
48-(function() {
49- var input = document.getElementById('handle-input');
50- var box = document.getElementById('handle-suggestions');
51- var timer = null;
52- var selected = -1;
53-
54- function close() {
55- box.classList.add('hidden');
56- box.innerHTML = '';
57- selected = -1;
58- }
59-
60- function highlight(items, idx) {
61- items.forEach(function(el, i) {
62- el.classList.toggle('bg-spot-hover', i === idx);
63- });
64- }
65-
66- input.addEventListener('input', function() {
67- clearTimeout(timer);
68- var q = input.value.trim().replace(/^@/, '');
69- if (q.length < 1) { close(); return; }
70- timer = setTimeout(function() {
71- fetch('/auth/resolve?q=' + encodeURIComponent(q))
72- .then(function(r) { return r.json(); })
73- .then(function(data) {
74- if (!data.actors || !data.actors.length) { close(); return; }
75- selected = -1;
76- box.innerHTML = '';
77- data.actors.forEach(function(a) {
78- var row = document.createElement('button');
79- row.type = 'button';
80- row.className = 'w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-spot-hover transition';
81- var img = a.avatar
82- ? '<img src="' + a.avatar + '" class="w-8 h-8 rounded-full shrink-0">'
83- : '<div class="w-8 h-8 rounded-full bg-spot-hover shrink-0"></div>';
84- row.innerHTML = img +
85- '<div class="min-w-0">' +
86- '<div class="text-sm text-spot-text truncate">@' + a.handle + '</div>' +
87- (a.displayName ? '<div class="text-xs text-spot-secondary truncate">' + a.displayName + '</div>' : '') +
88- '</div>';
89- row.addEventListener('click', function() {
90- input.value = a.handle;
91- close();
92- });
93- box.appendChild(row);
94- });
95- box.classList.remove('hidden');
96- })
97- .catch(function() { close(); });
98- }, 250);
99- });
100-
101- input.addEventListener('keydown', function(e) {
102- var items = box.querySelectorAll('button');
103- if (!items.length) return;
104- if (e.key === 'ArrowDown') {
105- e.preventDefault();
106- selected = Math.min(selected + 1, items.length - 1);
107- highlight(items, selected);
108- } else if (e.key === 'ArrowUp') {
109- e.preventDefault();
110- selected = Math.max(selected - 1, 0);
111- highlight(items, selected);
112- } else if (e.key === 'Enter' && selected >= 0) {
113- e.preventDefault();
114- items[selected].click();
115- } else if (e.key === 'Escape') {
116- close();
117- }
118- });
119-
120- document.addEventListener('click', function(e) {
121- if (!box.contains(e.target) && e.target !== input) close();
122- });
123-})();
124-</script>
125-{{end}}
deleted file mode 100644
@@ -1,125 +0,0 @@
1-{{define "login.html"}}
2-<style>
3-.login-divider { display: flex; align-items: center; gap: 0.75rem; }
4-.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--spot-divider); }
5-</style>
6-
7-<div class="flex items-center justify-center min-h-screen px-4 py-12">
8- <div class="max-w-sm w-full">
9- <div class="flex justify-center mb-10">
10- <a href="/" class="w-16 h-16 block">{{template "logo-icon"}}</a>
11- </div>
12-
13- <h1 class="text-2xl font-bold text-spot-text mb-2 text-center" style="letter-spacing: -0.02em;">Welcome to Glean</h1>
14- <p class="text-spot-secondary text-sm mb-8 text-center">The social RSS reader built on AT Protocol.</p>
15-
16- <form action="/auth/start" method="POST" id="login-form">
17- {{csrfInput .CSRFToken}}
18- <div class="relative mb-4">
19- <label for="handle-input" class="block text-xs text-spot-secondary mb-1.5 font-medium">Login with your Atmosphere account</label>
20- <input type="text" name="handle" placeholder="you.bsky.social" id="handle-input"
21- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
22- required autocomplete="off">
23- <div id="handle-suggestions" class="absolute left-0 right-0 top-full mt-1 bg-spot-surface border border-spot-divider rounded-xl shadow-spot-heavy z-50 overflow-hidden hidden"></div>
24- </div>
25-
26- <button type="submit"
27- class="w-full flex items-center justify-center bg-spot-green text-white rounded-pill px-4 py-3.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
28- Login
29- </button>
30- </form>
31-
32- <div class="login-divider text-xs text-spot-muted uppercase tracking-wide mt-8 mb-6">
33- <span>New here?</span>
34- </div>
35-
36- <a href="/auth/register"
37- class="w-full flex items-center justify-center gap-3 border border-spot-outline text-spot-text rounded-pill px-4 py-3.5 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition">
38- <svg class="h-5 w-auto" fill="currentColor" viewBox="77.86 113.9 129.15 129.15" xmlns="http://www.w3.org/2000/svg"><path d="M148.846 144.562C148.846 159.75 161.158 172.062 176.346 172.062H207.012V185.865H176.346C161.158 185.865 148.846 198.177 148.846 213.365V243.045H136.029V213.365C136.029 198.177 123.717 185.865 108.529 185.865H77.8633V172.062H108.529C123.717 172.062 136.029 159.75 136.029 144.562V113.896H148.846V144.562Z"/></svg>
39- Register with Eurosky
40- </a>
41-
42- <p class="text-[11px] text-spot-muted text-center leading-relaxed mt-8">
43- By continuing, you agree to the <a href="/terms" class="underline hover:text-spot-secondary transition">Terms of Service</a>.
44- </p>
45- </div>
46-</div>
47-<script>
48-(function() {
49- var input = document.getElementById('handle-input');
50- var box = document.getElementById('handle-suggestions');
51- var timer = null;
52- var selected = -1;
53-
54- function close() {
55- box.classList.add('hidden');
56- box.innerHTML = '';
57- selected = -1;
58- }
59-
60- function highlight(items, idx) {
61- items.forEach(function(el, i) {
62- el.classList.toggle('bg-spot-hover', i === idx);
63- });
64- }
65-
66- input.addEventListener('input', function() {
67- clearTimeout(timer);
68- var q = input.value.trim().replace(/^@/, '');
69- if (q.length < 1) { close(); return; }
70- timer = setTimeout(function() {
71- fetch('/auth/resolve?q=' + encodeURIComponent(q))
72- .then(function(r) { return r.json(); })
73- .then(function(data) {
74- if (!data.actors || !data.actors.length) { close(); return; }
75- selected = -1;
76- box.innerHTML = '';
77- data.actors.forEach(function(a) {
78- var row = document.createElement('button');
79- row.type = 'button';
80- row.className = 'w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-spot-hover transition';
81- var img = a.avatar
82- ? '<img src="' + a.avatar + '" class="w-8 h-8 rounded-full shrink-0">'
83- : '<div class="w-8 h-8 rounded-full bg-spot-hover shrink-0"></div>';
84- row.innerHTML = img +
85- '<div class="min-w-0">' +
86- '<div class="text-sm text-spot-text truncate">@' + a.handle + '</div>' +
87- (a.displayName ? '<div class="text-xs text-spot-secondary truncate">' + a.displayName + '</div>' : '') +
88- '</div>';
89- row.addEventListener('click', function() {
90- input.value = a.handle;
91- close();
92- });
93- box.appendChild(row);
94- });
95- box.classList.remove('hidden');
96- })
97- .catch(function() { close(); });
98- }, 250);
99- });
100-
101- input.addEventListener('keydown', function(e) {
102- var items = box.querySelectorAll('button');
103- if (!items.length) return;
104- if (e.key === 'ArrowDown') {
105- e.preventDefault();
106- selected = Math.min(selected + 1, items.length - 1);
107- highlight(items, selected);
108- } else if (e.key === 'ArrowUp') {
109- e.preventDefault();
110- selected = Math.max(selected - 1, 0);
111- highlight(items, selected);
112- } else if (e.key === 'Enter' && selected >= 0) {
113- e.preventDefault();
114- items[selected].click();
115- } else if (e.key === 'Escape') {
116- close();
117- }
118- });
119-
120- document.addEventListener('click', function(e) {
121- if (!box.contains(e.target) && e.target !== input) close();
122- });
123-})();
124-</script>
125-{{end}}
deleted internal/tmpl/partials/annotation-card.html +0 -39
deleted file mode 100644
@@ -1,39 +0,0 @@
1-{{define "annotation-card.html"}}
2-<div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-4 sm:p-5 border border-spot-divider">
3- {{if .annotation.ArticleURL}}
4- <div class="text-xs mb-3 flex items-center gap-2">
5- {{if .annotation.ArticleID.Valid}}<a href="/articles/{{.annotation.ArticleID.Int64}}" class="text-spot-green hover:text-spot-text transition truncate inline-flex items-center gap-1">
6- <svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
7- </a>{{end}}
8- <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate">{{.annotation.ArticleURL}}</a>
9- </div>
10- {{end}}
11- {{if .annotation.Quote.Valid}}
12- <blockquote class="border-l-2 border-spot-green pl-4 text-sm text-spot-text leading-relaxed">{{.annotation.Quote.String}}</blockquote>
13- {{end}}
14- {{if .annotation.Note.Valid}}
15- <p class="text-sm text-spot-text font-medium leading-relaxed mt-3">{{.annotation.Note.String}}</p>
16- {{end}}
17- {{if .annotation.Tags.Valid}}
18- <div class="flex gap-1.5 mt-3 flex-wrap">
19- {{range $tag := split .annotation.Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-pill">{{$tag}}</span>{{end}}
20- </div>
21- {{end}}
22- {{if .annotation.Rating.Valid}}
23- <div class="text-sm text-spot-orange mt-2 tracking-wide">{{repeat "&#9733;" (int .annotation.Rating.Int64)}}</div>
24- {{end}}
25- <div class="flex items-center justify-between mt-3 pt-3 border-t border-spot-divider">
26- <div class="flex items-center gap-2 text-xs text-spot-muted">
27- <a href="/profile/{{.annotation.AuthorDID}}" class="hover:text-spot-green transition">{{if .annotation.AuthorHandle}}{{.annotation.AuthorHandle}}{{else}}{{.annotation.AuthorDID}}{{end}}</a>
28- {{if .annotation.CreatedAt.Valid}}<span>&middot;</span><span>{{.annotation.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
29- </div>
30- {{if eq .annotation.AuthorDID .userDID}}
31- <div class="flex items-center gap-3">
32- <button class="text-xs text-spot-secondary hover:text-spot-text transition font-medium" onclick="editAnnotation(this, {{.annotation.ID}}, '{{js .annotation.FeedURL}}', '{{js .annotation.ArticleURL}}', '{{js .annotation.Quote.String}}', '{{js .annotation.Note.String}}', '{{js .annotation.Tags.String}}')">Edit</button>
33- <button hx-post="/library/{{.annotation.ID}}/delete" hx-target="#annotation-{{.annotation.ID}}" hx-swap="outerHTML swap:0.3s" hx-confirm="Delete this annotation?"
34- class="text-xs text-spot-secondary hover:text-spot-red transition font-medium">Delete</button>
35- </div>
36- {{end}}
37- </div>
38-</div>
39-{{end}}
deleted file mode 100644
@@ -1,39 +0,0 @@
1-{{define "annotation-card.html"}}
2-<div id="annotation-{{.annotation.ID}}" class="bg-spot-surface rounded-xl p-4 sm:p-5 border border-spot-divider">
3- {{if .annotation.ArticleURL}}
4- <div class="text-xs mb-3 flex items-center gap-2">
5- {{if .annotation.ArticleID.Valid}}<a href="/articles/{{.annotation.ArticleID.Int64}}" class="text-spot-green hover:text-spot-text transition truncate inline-flex items-center gap-1">
6- <svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
7- </a>{{end}}
8- <a href="{{.annotation.ArticleURL}}" class="text-spot-secondary hover:text-spot-green transition truncate">{{.annotation.ArticleURL}}</a>
9- </div>
10- {{end}}
11- {{if .annotation.Quote.Valid}}
12- <blockquote class="border-l-2 border-spot-green pl-4 text-sm text-spot-text leading-relaxed">{{.annotation.Quote.String}}</blockquote>
13- {{end}}
14- {{if .annotation.Note.Valid}}
15- <p class="text-sm text-spot-text font-medium leading-relaxed mt-3">{{.annotation.Note.String}}</p>
16- {{end}}
17- {{if .annotation.Tags.Valid}}
18- <div class="flex gap-1.5 mt-3 flex-wrap">
19- {{range $tag := split .annotation.Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-pill">{{$tag}}</span>{{end}}
20- </div>
21- {{end}}
22- {{if .annotation.Rating.Valid}}
23- <div class="text-sm text-spot-orange mt-2 tracking-wide">{{repeat "&#9733;" (int .annotation.Rating.Int64)}}</div>
24- {{end}}
25- <div class="flex items-center justify-between mt-3 pt-3 border-t border-spot-divider">
26- <div class="flex items-center gap-2 text-xs text-spot-muted">
27- <a href="/profile/{{.annotation.AuthorDID}}" class="hover:text-spot-green transition">{{if .annotation.AuthorHandle}}{{.annotation.AuthorHandle}}{{else}}{{.annotation.AuthorDID}}{{end}}</a>
28- {{if .annotation.CreatedAt.Valid}}<span>&middot;</span><span>{{.annotation.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
29- </div>
30- {{if eq .annotation.AuthorDID .userDID}}
31- <div class="flex items-center gap-3">
32- <button class="text-xs text-spot-secondary hover:text-spot-text transition font-medium" onclick="editAnnotation(this, {{.annotation.ID}}, '{{js .annotation.FeedURL}}', '{{js .annotation.ArticleURL}}', '{{js .annotation.Quote.String}}', '{{js .annotation.Note.String}}', '{{js .annotation.Tags.String}}')">Edit</button>
33- <button hx-post="/library/{{.annotation.ID}}/delete" hx-target="#annotation-{{.annotation.ID}}" hx-swap="outerHTML swap:0.3s" hx-confirm="Delete this annotation?"
34- class="text-xs text-spot-secondary hover:text-spot-red transition font-medium">Delete</button>
35- </div>
36- {{end}}
37- </div>
38-</div>
39-{{end}}
deleted internal/tmpl/partials/article-card-expanded.html +0 -49
deleted file mode 100644
@@ -1,49 +0,0 @@
1-{{define "article-card-expanded.html"}}
2-<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" data-expanded="true" class="bg-spot-surface rounded-xl px-4 sm:px-5 py-4 shadow-spot relative {{if not .IsRead.Bool}}border-l border-spot-green/80{{end}}">
3- <div class="flex items-start justify-between gap-4">
4- <div class="min-w-0 flex-1">
5- <div class="flex items-start gap-2.5">
6- <a href="/articles/{{.ID}}{{.NavSuffix}}" class="{{if .IsRead.Bool}}font-semibold text-[15px]{{else}}font-bold text-[17px]{{end}} text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</a>
7- </div>
8- <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap">
9- {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-3.5 h-3.5"}}
10- <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a>
11- {{if .Author.Valid}}{{if .Author.String}}<span class="text-spot-muted">&middot;</span><span>{{.Author.String}}</span>{{end}}{{end}}
12- {{if .Published.Valid}}<span class="text-spot-muted">&middot;</span><span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
13- </div>
14- </div>
15- <div class="flex items-center gap-1.5 shrink-0 pt-0.5">
16- <div>{{template "like-button.html" .}}</div>
17- </div>
18- </div>
19-
20- {{if .URL.Valid}}
21- {{with youtubeID .URL.String}}
22- <div class="mt-4 aspect-video w-full rounded-xl overflow-hidden shadow-spot-heavy">
23- <iframe class="w-full h-full" src="https://www.youtube.com/embed/{{.}}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
24- </div>
25- {{end}}
26- {{end}}
27-
28- {{if or .Content.Valid .FullContent.Valid .Summary.Valid}}
29- <div class="mt-4 pt-4 border-t border-spot-divider">
30- <div class="article-body text-sm">
31- {{if .Content.Valid}}{{sanitizeHTML .Content.String}}
32- {{else if .FullContent.Valid}}{{sanitizeHTML .FullContent.String}}
33- {{else if .Summary.Valid}}{{sanitizeHTML .Summary.String}}{{end}}
34- </div>
35- </div>
36- {{else}}
37- <div class="mt-3 pt-3 border-t border-spot-divider">
38- <a href="/articles/{{.ID}}{{.NavSuffix}}" class="inline-flex items-center gap-1.5 text-[10px] text-spot-text hover:text-spot-green hover:border-spot-green/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider hover:bg-spot-green/10 transition">
39- Read full article
40- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
41- </a>
42- </div>
43- {{end}}
44-
45- <div class="mt-2 pt-2 border-t border-spot-divider flex items-center justify-end gap-1.5">
46- <div>{{template "like-button.html" .}}</div>
47- </div>
48-</article>
49-{{end}}
\ No newline at end of file
deleted file mode 100644
@@ -1,49 +0,0 @@
1-{{define "article-card-expanded.html"}}
2-<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" data-expanded="true" class="bg-spot-surface rounded-xl px-4 sm:px-5 py-4 shadow-spot relative {{if not .IsRead.Bool}}border-l border-spot-green/80{{end}}">
3- <div class="flex items-start justify-between gap-4">
4- <div class="min-w-0 flex-1">
5- <div class="flex items-start gap-2.5">
6- <a href="/articles/{{.ID}}{{.NavSuffix}}" class="{{if .IsRead.Bool}}font-semibold text-[15px]{{else}}font-bold text-[17px]{{end}} text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</a>
7- </div>
8- <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap">
9- {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-3.5 h-3.5"}}
10- <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a>
11- {{if .Author.Valid}}{{if .Author.String}}<span class="text-spot-muted">&middot;</span><span>{{.Author.String}}</span>{{end}}{{end}}
12- {{if .Published.Valid}}<span class="text-spot-muted">&middot;</span><span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
13- </div>
14- </div>
15- <div class="flex items-center gap-1.5 shrink-0 pt-0.5">
16- <div>{{template "like-button.html" .}}</div>
17- </div>
18- </div>
19-
20- {{if .URL.Valid}}
21- {{with youtubeID .URL.String}}
22- <div class="mt-4 aspect-video w-full rounded-xl overflow-hidden shadow-spot-heavy">
23- <iframe class="w-full h-full" src="https://www.youtube.com/embed/{{.}}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
24- </div>
25- {{end}}
26- {{end}}
27-
28- {{if or .Content.Valid .FullContent.Valid .Summary.Valid}}
29- <div class="mt-4 pt-4 border-t border-spot-divider">
30- <div class="article-body text-sm">
31- {{if .Content.Valid}}{{sanitizeHTML .Content.String}}
32- {{else if .FullContent.Valid}}{{sanitizeHTML .FullContent.String}}
33- {{else if .Summary.Valid}}{{sanitizeHTML .Summary.String}}{{end}}
34- </div>
35- </div>
36- {{else}}
37- <div class="mt-3 pt-3 border-t border-spot-divider">
38- <a href="/articles/{{.ID}}{{.NavSuffix}}" class="inline-flex items-center gap-1.5 text-[10px] text-spot-text hover:text-spot-green hover:border-spot-green/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider hover:bg-spot-green/10 transition">
39- Read full article
40- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
41- </a>
42- </div>
43- {{end}}
44-
45- <div class="mt-2 pt-2 border-t border-spot-divider flex items-center justify-end gap-1.5">
46- <div>{{template "like-button.html" .}}</div>
47- </div>
48-</article>
49-{{end}}
\ No newline at end of file\ No newline at end of file
deleted internal/tmpl/partials/article-card.html +0 -48
deleted file mode 100644
@@ -1,48 +0,0 @@
1-{{define "article-card.html"}}
2-<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" class="bg-spot-surface rounded-xl px-4 sm:px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative group {{if not .IsRead.Bool}}border-l border-spot-green/80{{end}}">
3- <div class="flex items-start justify-between gap-4">
4- <div class="min-w-0 flex-1">
5- <div class="flex items-start gap-2.5">
6- <a href="/articles/{{.ID}}{{.NavSuffix}}" class="{{if .IsRead.Bool}}font-semibold text-[15px]{{else}}font-bold text-[17px]{{end}} text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</a>
7- </div>
8- <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap">
9- {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-3.5 h-3.5"}}
10- <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a>
11- {{if .Author.Valid}}{{if .Author.String}}<span class="text-spot-muted">&middot;</span><span>{{.Author.String}}</span>{{end}}{{end}}
12- {{if .Published.Valid}}<span class="text-spot-muted">&middot;</span><span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
13- </div>
14- {{if .Summary.Valid}}{{if .Summary.String}}
15- <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{plainText .Summary.String}}</p>
16- {{end}}{{end}}
17- </div>
18- <div class="grid grid-cols-1 gap-2 shrink-0 pt-0.5">
19- {{template "like-button.html" .}}
20- <button id="read-btn-{{.ID}}" hx-post="/articles/{{.ID}}/{{if .IsRead.Bool}}unread{{else}}read{{end}}" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" title="{{if .IsRead.Bool}}Mark as unread{{else}}Mark as read{{end}}" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/10 hover:border-spot-green/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider transition">
21- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
22- <span>{{if .IsRead.Bool}}Unread{{else}}Read{{end}}</span>
23- </button>
24- {{if .DismissURL}}
25- <button hx-post="{{.DismissURL}}" hx-target="closest article" hx-swap="delete" hx-vals='{"{{.DismissField}}": "{{.DismissValue}}"}' title="Remove from recommendations"
26- class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-red hover:bg-spot-red/10 hover:border-spot-red/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider transition">
27- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
28- <span>Hide</span>
29- </button>
30- {{end}}
31- </div>
32- </div>
33- <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider">
34- <form hx-post="/library/create" hx-swap="none" hx-on::after-request="closeAnnotate(this)" class="space-y-2">
35- <input type="hidden" name="feed_url" value="{{.FeedURL}}">
36- <input type="hidden" name="article_url" value="{{if .URL.Valid}}{{.URL.String}}{{end}}">
37- <input type="text" name="quote" placeholder="Quote a passage..."
38- class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
39- <div class="flex gap-2">
40- <input type="text" name="note" placeholder="Add a note..."
41- class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
42- <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>
43- <button type="button" onclick="closeAnnotate(this)" class="text-spot-secondary hover:text-spot-text px-2 text-sm transition">&times;</button>
44- </div>
45- </form>
46- </div>
47-</article>
48-{{end}}
deleted file mode 100644
@@ -1,48 +0,0 @@
1-{{define "article-card.html"}}
2-<article data-article-id="{{.ID}}" data-feed-url="{{.FeedURL}}" data-article-url="{{if .URL.Valid}}{{.URL.String}}{{end}}" class="bg-spot-surface rounded-xl px-4 sm:px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot relative group {{if not .IsRead.Bool}}border-l border-spot-green/80{{end}}">
3- <div class="flex items-start justify-between gap-4">
4- <div class="min-w-0 flex-1">
5- <div class="flex items-start gap-2.5">
6- <a href="/articles/{{.ID}}{{.NavSuffix}}" class="{{if .IsRead.Bool}}font-semibold text-[15px]{{else}}font-bold text-[17px]{{end}} text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</a>
7- </div>
8- <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap">
9- {{template "favicon" dict "src" .FeedFaviconURL.String "size" "w-3.5 h-3.5"}}
10- <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a>
11- {{if .Author.Valid}}{{if .Author.String}}<span class="text-spot-muted">&middot;</span><span>{{.Author.String}}</span>{{end}}{{end}}
12- {{if .Published.Valid}}<span class="text-spot-muted">&middot;</span><span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
13- </div>
14- {{if .Summary.Valid}}{{if .Summary.String}}
15- <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{plainText .Summary.String}}</p>
16- {{end}}{{end}}
17- </div>
18- <div class="grid grid-cols-1 gap-2 shrink-0 pt-0.5">
19- {{template "like-button.html" .}}
20- <button id="read-btn-{{.ID}}" hx-post="/articles/{{.ID}}/{{if .IsRead.Bool}}unread{{else}}read{{end}}" hx-target="#read-btn-{{.ID}}" hx-swap="outerHTML" title="{{if .IsRead.Bool}}Mark as unread{{else}}Mark as read{{end}}" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/10 hover:border-spot-green/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider transition">
21- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
22- <span>{{if .IsRead.Bool}}Unread{{else}}Read{{end}}</span>
23- </button>
24- {{if .DismissURL}}
25- <button hx-post="{{.DismissURL}}" hx-target="closest article" hx-swap="delete" hx-vals='{"{{.DismissField}}": "{{.DismissValue}}"}' title="Remove from recommendations"
26- class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-red hover:bg-spot-red/10 hover:border-spot-red/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider transition">
27- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
28- <span>Hide</span>
29- </button>
30- {{end}}
31- </div>
32- </div>
33- <div class="annotate-form hidden mt-4 pt-4 border-t border-spot-divider">
34- <form hx-post="/library/create" hx-swap="none" hx-on::after-request="closeAnnotate(this)" class="space-y-2">
35- <input type="hidden" name="feed_url" value="{{.FeedURL}}">
36- <input type="hidden" name="article_url" value="{{if .URL.Valid}}{{.URL.String}}{{end}}">
37- <input type="text" name="quote" placeholder="Quote a passage..."
38- class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
39- <div class="flex gap-2">
40- <input type="text" name="note" placeholder="Add a note..."
41- class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
42- <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>
43- <button type="button" onclick="closeAnnotate(this)" class="text-spot-secondary hover:text-spot-text px-2 text-sm transition">&times;</button>
44- </div>
45- </form>
46- </div>
47-</article>
48-{{end}}
deleted internal/tmpl/partials/article-recommendations.html +0 -10
deleted file mode 100644
@@ -1,10 +0,0 @@
1-{{define "partials/article-recommendations.html"}}
2-<div class="mb-8">
3- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
4- <div class="space-y-3">
5- {{range .ArticleRecommendations}}
6- {{template "article-card.html" .}}
7- {{end}}
8- </div>
9-</div>
10-{{end}}
deleted file mode 100644
@@ -1,10 +0,0 @@
1-{{define "partials/article-recommendations.html"}}
2-<div class="mb-8">
3- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended for you</h2>
4- <div class="space-y-3">
5- {{range .ArticleRecommendations}}
6- {{template "article-card.html" .}}
7- {{end}}
8- </div>
9-</div>
10-{{end}}
deleted internal/tmpl/partials/articles-content.html +0 -15
deleted file mode 100644
@@ -1,15 +0,0 @@
1-{{define "articles-content.html"}}
2-{{range .Articles}}
3-{{if $.ExpandedView}}{{template "article-card-expanded.html" .}}{{else}}{{template "article-card.html" .}}{{end}}
4-{{else}}
5-<div class="bg-spot-surface rounded-2xl py-12 px-6 text-center">
6- <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
7- <svg class="w-7 h-7 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
8- </div>
9- <p class="text-sm text-spot-text font-semibold mb-1">No articles found</p>
10- {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
11- {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
12- {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
13-</div>
14-{{end}}
15-{{end}}
\ No newline at end of file
deleted file mode 100644
@@ -1,15 +0,0 @@
1-{{define "articles-content.html"}}
2-{{range .Articles}}
3-{{if $.ExpandedView}}{{template "article-card-expanded.html" .}}{{else}}{{template "article-card.html" .}}{{end}}
4-{{else}}
5-<div class="bg-spot-surface rounded-2xl py-12 px-6 text-center">
6- <div class="w-14 h-14 rounded-2xl bg-spot-green/15 flex items-center justify-center mb-5 mx-auto">
7- <svg class="w-7 h-7 text-spot-green" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
8- </div>
9- <p class="text-sm text-spot-text font-semibold mb-1">No articles found</p>
10- {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
11- {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
12- {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
13-</div>
14-{{end}}
15-{{end}}
\ No newline at end of file\ No newline at end of file
deleted internal/tmpl/partials/dead-feeds.html +0 -34
deleted file mode 100644
@@ -1,34 +0,0 @@
1-{{define "dead-feeds.html"}}
2-{{if .DeadFeeds}}
3-<div id="dead-feeds" class="bg-spot-red/10 border border-spot-red/30 rounded-lg p-4 mb-6">
4- <h3 class="text-sm font-bold text-spot-red mb-2">Feeds with errors ({{len .DeadFeeds}})</h3>
5- <div class="space-y-2">
6- {{range .DeadFeeds}}
7- <div class="dead-feed-item flex items-start justify-between gap-3 text-sm flex-wrap">
8- <div class="min-w-0 flex-1">
9- <span class="text-spot-text truncate block font-medium">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.FeedURL}}{{end}}</span>
10- <div class="flex items-center gap-2 mt-1 flex-wrap">
11- <span class="text-spot-red text-xs font-medium">{{.ErrorCount}} errors</span>
12- {{if .LastError.Valid}}<span class="text-spot-secondary text-xs truncate max-w-48" title="{{.LastError.String}}">{{.LastError.String}}</span>{{end}}
13- </div>
14- </div>
15- <div class="flex items-center gap-3 shrink-0">
16- <form hx-post="/feeds/retry" hx-target="#dead-feeds" hx-swap="outerHTML" class="inline">
17- <input type="hidden" name="url" value="{{.FeedURL}}">
18- <button type="submit" class="text-xs text-spot-secondary hover:text-spot-green transition font-bold uppercase">Retry</button>
19- </form>
20- <form hx-delete="/feeds/remove" hx-target="closest .dead-feed-item" hx-swap="outerHTML swap:0.3s"
21- hx-confirm="Unsubscribe from this broken feed?" class="inline">
22- {{csrfInput $.CSRFToken}}
23- <input type="hidden" name="url" value="{{.FeedURL}}">
24- <button type="submit" class="text-xs text-spot-secondary hover:text-spot-red transition font-bold uppercase">Unsubscribe</button>
25- </form>
26- </div>
27- </div>
28- {{end}}
29- </div>
30-</div>
31-{{else}}
32-<span></span>
33-{{end}}
34-{{end}}
deleted file mode 100644
@@ -1,34 +0,0 @@
1-{{define "dead-feeds.html"}}
2-{{if .DeadFeeds}}
3-<div id="dead-feeds" class="bg-spot-red/10 border border-spot-red/30 rounded-lg p-4 mb-6">
4- <h3 class="text-sm font-bold text-spot-red mb-2">Feeds with errors ({{len .DeadFeeds}})</h3>
5- <div class="space-y-2">
6- {{range .DeadFeeds}}
7- <div class="dead-feed-item flex items-start justify-between gap-3 text-sm flex-wrap">
8- <div class="min-w-0 flex-1">
9- <span class="text-spot-text truncate block font-medium">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.FeedURL}}{{end}}</span>
10- <div class="flex items-center gap-2 mt-1 flex-wrap">
11- <span class="text-spot-red text-xs font-medium">{{.ErrorCount}} errors</span>
12- {{if .LastError.Valid}}<span class="text-spot-secondary text-xs truncate max-w-48" title="{{.LastError.String}}">{{.LastError.String}}</span>{{end}}
13- </div>
14- </div>
15- <div class="flex items-center gap-3 shrink-0">
16- <form hx-post="/feeds/retry" hx-target="#dead-feeds" hx-swap="outerHTML" class="inline">
17- <input type="hidden" name="url" value="{{.FeedURL}}">
18- <button type="submit" class="text-xs text-spot-secondary hover:text-spot-green transition font-bold uppercase">Retry</button>
19- </form>
20- <form hx-delete="/feeds/remove" hx-target="closest .dead-feed-item" hx-swap="outerHTML swap:0.3s"
21- hx-confirm="Unsubscribe from this broken feed?" class="inline">
22- {{csrfInput $.CSRFToken}}
23- <input type="hidden" name="url" value="{{.FeedURL}}">
24- <button type="submit" class="text-xs text-spot-secondary hover:text-spot-red transition font-bold uppercase">Unsubscribe</button>
25- </form>
26- </div>
27- </div>
28- {{end}}
29- </div>
30-</div>
31-{{else}}
32-<span></span>
33-{{end}}
34-{{end}}
deleted internal/tmpl/partials/digest.html +0 -46
deleted file mode 100644
@@ -1,46 +0,0 @@
1-{{define "partials/digest.html"}}
2-<div id="digest" class="mb-8">
3- <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
4- <article class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot{{if not .Consumed}} border-l border-spot-green/80{{end}}">
5- {{if .Consumed}}
6- <p class="text-sm text-spot-secondary">That's all for today. Come back tomorrow for a fresh digest.</p>
7- {{else}}
8- <div class="flex items-start justify-between gap-4">
9- <div class="min-w-0 flex-1">
10- <button onclick="var b=document.getElementById('digest-body');var h=document.getElementById('digest-header');var i=document.getElementById('digest-chevron');if(b.classList.contains('hidden')){b.classList.remove('hidden');h.classList.add('hidden');i.style.transform=''}else{b.classList.add('hidden');h.classList.remove('hidden');i.style.transform='rotate(-90deg)'}"
11- class="text-left w-full">
12- <span id="digest-chevron" class="inline-block mr-1.5 transition-transform rotate(-90deg)">
13- <svg class="w-3.5 h-3.5 text-spot-secondary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
14- </span>
15- <span class="font-bold text-[17px] text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</span>
16- </button>
17- <div id="digest-header">
18- <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5">
19- <svg class="w-3.5 h-3.5 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
20- <span class="font-medium">AI summary</span>
21- <span class="text-spot-muted">&middot;</span>
22- <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span>
23- <span class="text-spot-muted">&middot;</span>
24- <span>{{len .ArticleIDs}} articles</span>
25- </div>
26- <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{.Excerpt}}</p>
27- </div>
28- </div>
29- <div class="shrink-0 pt-0.5">
30- <form hx-post="/digest/mark-read" hx-confirm="Mark these articles as read?" hx-target="#digest" hx-swap="outerHTML">
31- {{csrfInput .CSRFToken}}
32- {{range .ArticleIDs}}<input type="hidden" name="ids" value="{{.}}">{{end}}
33- <button type="submit" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/10 hover:border-spot-green/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider transition">
34- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
35- <span>Read</span>
36- </button>
37- </form>
38- </div>
39- </div>
40- <div id="digest-body" class="hidden">
41- <div class="text-sm text-spot-text leading-relaxed space-y-3 border-t border-spot-divider pt-4 mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:text-spot-text [&_strong]:font-semibold [&_strong]:text-[13px] [&_strong]:uppercase [&_strong]:tracking-wide">{{sanitizeHTML .Summary}}</div>
42- </div>
43- {{end}}
44- </article>
45-</div>
46-{{end}}
deleted file mode 100644
@@ -1,46 +0,0 @@
1-{{define "partials/digest.html"}}
2-<div id="digest" class="mb-8">
3- <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
4- <article class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot{{if not .Consumed}} border-l border-spot-green/80{{end}}">
5- {{if .Consumed}}
6- <p class="text-sm text-spot-secondary">That's all for today. Come back tomorrow for a fresh digest.</p>
7- {{else}}
8- <div class="flex items-start justify-between gap-4">
9- <div class="min-w-0 flex-1">
10- <button onclick="var b=document.getElementById('digest-body');var h=document.getElementById('digest-header');var i=document.getElementById('digest-chevron');if(b.classList.contains('hidden')){b.classList.remove('hidden');h.classList.add('hidden');i.style.transform=''}else{b.classList.add('hidden');h.classList.remove('hidden');i.style.transform='rotate(-90deg)'}"
11- class="text-left w-full">
12- <span id="digest-chevron" class="inline-block mr-1.5 transition-transform rotate(-90deg)">
13- <svg class="w-3.5 h-3.5 text-spot-secondary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
14- </span>
15- <span class="font-bold text-[17px] text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</span>
16- </button>
17- <div id="digest-header">
18- <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5">
19- <svg class="w-3.5 h-3.5 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
20- <span class="font-medium">AI summary</span>
21- <span class="text-spot-muted">&middot;</span>
22- <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span>
23- <span class="text-spot-muted">&middot;</span>
24- <span>{{len .ArticleIDs}} articles</span>
25- </div>
26- <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{.Excerpt}}</p>
27- </div>
28- </div>
29- <div class="shrink-0 pt-0.5">
30- <form hx-post="/digest/mark-read" hx-confirm="Mark these articles as read?" hx-target="#digest" hx-swap="outerHTML">
31- {{csrfInput .CSRFToken}}
32- {{range .ArticleIDs}}<input type="hidden" name="ids" value="{{.}}">{{end}}
33- <button type="submit" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/10 hover:border-spot-green/20 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider transition">
34- <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
35- <span>Read</span>
36- </button>
37- </form>
38- </div>
39- </div>
40- <div id="digest-body" class="hidden">
41- <div class="text-sm text-spot-text leading-relaxed space-y-3 border-t border-spot-divider pt-4 mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:text-spot-text [&_strong]:font-semibold [&_strong]:text-[13px] [&_strong]:uppercase [&_strong]:tracking-wide">{{sanitizeHTML .Summary}}</div>
42- </div>
43- {{end}}
44- </article>
45-</div>
46-{{end}}
deleted internal/tmpl/partials/empty-state.html +0 -19
deleted file mode 100644
@@ -1,19 +0,0 @@
1-{{define "empty-state.html"}}
2-<div class="bg-spot-surface rounded-2xl {{if eq .size "compact"}}py-8{{else}}py-12{{end}} px-6 text-center">
3- <div class="{{if eq .size "compact"}}w-12 h-12 rounded-xl mb-3{{else}}w-14 h-14 rounded-2xl mb-5{{end}} flex items-center justify-center mx-auto {{if eq .color "red"}}bg-spot-red/15{{else}}bg-spot-green/15{{end}}">
4- <svg class="{{if eq .size "compact"}}w-6 h-6{{else}}w-7 h-7{{end}} {{if eq .color "red"}}text-spot-red{{else}}text-spot-green{{end}}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
5- {{if eq .icon "icon-sparkles"}}{{template "icon-sparkles"}}
6- {{else if eq .icon "icon-trending"}}{{template "icon-trending"}}
7- {{else if eq .icon "icon-rss"}}{{template "icon-rss"}}
8- {{else if eq .icon "icon-heart"}}{{template "icon-heart"}}
9- {{else if eq .icon "icon-annotation"}}{{template "icon-annotation"}}
10- {{else if eq .icon "icon-newspaper"}}{{template "icon-newspaper"}}
11- {{else if eq .icon "icon-chart"}}{{template "icon-chart"}}
12- {{else if eq .icon "icon-people"}}{{template "icon-people"}}
13- {{end}}
14- </svg>
15- </div>
16- <p class="text-sm text-spot-text font-semibold mb-1">{{.title}}</p>
17- {{if .subtitle}}<p class="text-xs text-spot-muted">{{.subtitle}}</p>{{end}}
18-</div>
19-{{end}}
deleted file mode 100644
@@ -1,19 +0,0 @@
1-{{define "empty-state.html"}}
2-<div class="bg-spot-surface rounded-2xl {{if eq .size "compact"}}py-8{{else}}py-12{{end}} px-6 text-center">
3- <div class="{{if eq .size "compact"}}w-12 h-12 rounded-xl mb-3{{else}}w-14 h-14 rounded-2xl mb-5{{end}} flex items-center justify-center mx-auto {{if eq .color "red"}}bg-spot-red/15{{else}}bg-spot-green/15{{end}}">
4- <svg class="{{if eq .size "compact"}}w-6 h-6{{else}}w-7 h-7{{end}} {{if eq .color "red"}}text-spot-red{{else}}text-spot-green{{end}}" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
5- {{if eq .icon "icon-sparkles"}}{{template "icon-sparkles"}}
6- {{else if eq .icon "icon-trending"}}{{template "icon-trending"}}
7- {{else if eq .icon "icon-rss"}}{{template "icon-rss"}}
8- {{else if eq .icon "icon-heart"}}{{template "icon-heart"}}
9- {{else if eq .icon "icon-annotation"}}{{template "icon-annotation"}}
10- {{else if eq .icon "icon-newspaper"}}{{template "icon-newspaper"}}
11- {{else if eq .icon "icon-chart"}}{{template "icon-chart"}}
12- {{else if eq .icon "icon-people"}}{{template "icon-people"}}
13- {{end}}
14- </svg>
15- </div>
16- <p class="text-sm text-spot-text font-semibold mb-1">{{.title}}</p>
17- {{if .subtitle}}<p class="text-xs text-spot-muted">{{.subtitle}}</p>{{end}}
18-</div>
19-{{end}}
deleted internal/tmpl/partials/favicon.html +0 -8
deleted file mode 100644
@@ -1,8 +0,0 @@
1-{{define "favicon"}}
2-{{if .src}}
3-<img src="{{.src}}" class="{{.size}} rounded shrink-0" loading="lazy" onerror="this.classList.add('hidden');this.nextElementSibling.classList.remove('hidden')">
4-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="{{.size}} shrink-0 hidden"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
5-{{else}}
6-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="{{.size}} shrink-0"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
7-{{end}}
8-{{end}}
\ No newline at end of file
deleted file mode 100644
@@ -1,8 +0,0 @@
1-{{define "favicon"}}
2-{{if .src}}
3-<img src="{{.src}}" class="{{.size}} rounded shrink-0" loading="lazy" onerror="this.classList.add('hidden');this.nextElementSibling.classList.remove('hidden')">
4-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="{{.size}} shrink-0 hidden"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
5-{{else}}
6-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="{{.size}} shrink-0"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
7-{{end}}
8-{{end}}
\ No newline at end of file\ No newline at end of file
deleted internal/tmpl/partials/feed-item.html +0 -45
deleted file mode 100644
@@ -1,45 +0,0 @@
1-{{define "feed-item.html"}}
2-<div id="feed-{{.ID}}" class="contents">
3-<div class="feed-item px-4 sm:px-5 py-4 flex items-center justify-between gap-3 hover:bg-spot-hover-50 transition">
4- <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3">
5- {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}}
6- <div class="min-w-0 flex-1">
7- <div class="flex items-center gap-2 flex-wrap">
8- <span class="font-semibold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
9- {{if and .Category.Valid .Category.String}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-pill shrink-0">{{.Category.String}}</span>{{end}}
10- </div>
11- <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div>
12- </div>
13- </a>
14- <div class="flex items-center gap-1.5 shrink-0">
15- {{if .UnreadCount}}<span class="text-xs bg-spot-green/15 text-spot-green px-2.5 py-0.5 rounded-pill font-bold">{{.UnreadCount}}</span>{{end}}
16- <button type="button"
17- onclick="gleanToggleEdit({{.ID}})"
18- title="Edit Category"
19- class="text-spot-muted hover:text-spot-text transition p-1 rounded hover:bg-spot-hover inline-flex items-center">
20- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
21- </button>
22- <form hx-delete="/feeds/remove" hx-target="#feed-{{.ID}}" hx-swap="outerHTML swap:0.3s"
23- hx-confirm="Unsubscribe from this feed?" class="inline-flex items-center">
24- {{csrfInput .CSRFToken}}
25- <input type="hidden" name="url" value="{{.FeedURL}}">
26- <button type="submit" title="Unsubscribe" class="text-spot-muted hover:text-spot-red transition p-1 rounded hover:bg-spot-red/10 inline-flex items-center">
27- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
28- </button>
29- </form>
30- </div>
31-</div>
32-<div id="feed-edit-{{.ID}}" class="px-4 sm:px-5 py-4 bg-spot-hover/50 hidden">
33- <form hx-post="/feeds/edit" hx-target="#feed-{{.ID}}" hx-swap="outerHTML"
34- class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
35- {{csrfInput .CSRFToken}}
36- <input type="hidden" name="feed_url" value="{{.FeedURL}}">
37- <input type="text" name="category" value="{{if .Category.Valid}}{{.Category.String}}{{end}}"
38- placeholder="Category (optional)"
39- class="flex-1 bg-spot-surface text-spot-text rounded-pill px-4 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
40- <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>
41- <button type="button" onclick="gleanToggleEdit({{.ID}})" class="text-xs text-spot-secondary hover:text-spot-text transition font-medium px-2 py-1.5">Cancel</button>
42- </form>
43-</div>
44-</div>
45-{{end}}
deleted file mode 100644
@@ -1,45 +0,0 @@
1-{{define "feed-item.html"}}
2-<div id="feed-{{.ID}}" class="contents">
3-<div class="feed-item px-4 sm:px-5 py-4 flex items-center justify-between gap-3 hover:bg-spot-hover-50 transition">
4- <a href="/articles?feed={{.FeedURL}}" class="min-w-0 flex-1 flex items-center gap-3">
5- {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}}
6- <div class="min-w-0 flex-1">
7- <div class="flex items-center gap-2 flex-wrap">
8- <span class="font-semibold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
9- {{if and .Category.Valid .Category.String}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-pill shrink-0">{{.Category.String}}</span>{{end}}
10- </div>
11- <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div>
12- </div>
13- </a>
14- <div class="flex items-center gap-1.5 shrink-0">
15- {{if .UnreadCount}}<span class="text-xs bg-spot-green/15 text-spot-green px-2.5 py-0.5 rounded-pill font-bold">{{.UnreadCount}}</span>{{end}}
16- <button type="button"
17- onclick="gleanToggleEdit({{.ID}})"
18- title="Edit Category"
19- class="text-spot-muted hover:text-spot-text transition p-1 rounded hover:bg-spot-hover inline-flex items-center">
20- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
21- </button>
22- <form hx-delete="/feeds/remove" hx-target="#feed-{{.ID}}" hx-swap="outerHTML swap:0.3s"
23- hx-confirm="Unsubscribe from this feed?" class="inline-flex items-center">
24- {{csrfInput .CSRFToken}}
25- <input type="hidden" name="url" value="{{.FeedURL}}">
26- <button type="submit" title="Unsubscribe" class="text-spot-muted hover:text-spot-red transition p-1 rounded hover:bg-spot-red/10 inline-flex items-center">
27- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
28- </button>
29- </form>
30- </div>
31-</div>
32-<div id="feed-edit-{{.ID}}" class="px-4 sm:px-5 py-4 bg-spot-hover/50 hidden">
33- <form hx-post="/feeds/edit" hx-target="#feed-{{.ID}}" hx-swap="outerHTML"
34- class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
35- {{csrfInput .CSRFToken}}
36- <input type="hidden" name="feed_url" value="{{.FeedURL}}">
37- <input type="text" name="category" value="{{if .Category.Valid}}{{.Category.String}}{{end}}"
38- placeholder="Category (optional)"
39- class="flex-1 bg-spot-surface text-spot-text rounded-pill px-4 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder">
40- <button type="submit" class="bg-spot-green text-white rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button>
41- <button type="button" onclick="gleanToggleEdit({{.ID}})" class="text-xs text-spot-secondary hover:text-spot-text transition font-medium px-2 py-1.5">Cancel</button>
42- </form>
43-</div>
44-</div>
45-{{end}}
deleted internal/tmpl/partials/feed-list.html +0 -7
deleted file mode 100644
@@ -1,7 +0,0 @@
1-{{define "feed-list.html"}}
2-{{range .Subscriptions}}
3-{{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}}
4-{{else}}
5-{{template "empty-state.html" (dict "icon" "icon-rss" "title" "No feeds yet" "subtitle" "Add one from the sidebar to get started.")}}
6-{{end}}
7-{{end}}
deleted file mode 100644
@@ -1,7 +0,0 @@
1-{{define "feed-list.html"}}
2-{{range .Subscriptions}}
3-{{template "feed-item.html" dict "ID" .ID "FeedURL" .FeedURL "FeedTitle" .FeedTitle "Category" .Category "FaviconURL" .FaviconURL "UnreadCount" .UnreadCount "CSRFToken" $.CSRFToken}}
4-{{else}}
5-{{template "empty-state.html" (dict "icon" "icon-rss" "title" "No feeds yet" "subtitle" "Add one from the sidebar to get started.")}}
6-{{end}}
7-{{end}}
deleted internal/tmpl/partials/feed-recommendations.html +0 -17
deleted file mode 100644
@@ -1,17 +0,0 @@
1-{{define "partials/feed-recommendations.html"}}
2-{{if .FeedRecommendations}}
3-<div class="mb-8">
4- <h2 class="text-lg font-semibold text-spot-text mb-4">{{if eq .SubscriptionCount 0}}Popular feeds to get started{{else}}Recommended feeds{{end}}</h2>
5- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
6- {{range .FeedRecommendations}}
7- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
8- {{end}}
9- </div>
10-</div>
11-{{else}}
12-<div class="mb-8">
13- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
14- {{template "empty-state.html" (dict "icon" "icon-rss" "title" "No feed suggestions yet" "subtitle" "Subscribe to a few feeds and we'll suggest more based on your reading.")}}
15-</div>
16-{{end}}
17-{{end}}
deleted file mode 100644
@@ -1,17 +0,0 @@
1-{{define "partials/feed-recommendations.html"}}
2-{{if .FeedRecommendations}}
3-<div class="mb-8">
4- <h2 class="text-lg font-semibold text-spot-text mb-4">{{if eq .SubscriptionCount 0}}Popular feeds to get started{{else}}Recommended feeds{{end}}</h2>
5- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
6- {{range .FeedRecommendations}}
7- {{template "recommendation-feed-card.html" (dict "title" .Title "feed_url" .FeedURL "description" .Description "favicon_url" .FaviconURL "subscriber_count" .SubscriberCount "CSRFToken" $.CSRFToken)}}
8- {{end}}
9- </div>
10-</div>
11-{{else}}
12-<div class="mb-8">
13- <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2>
14- {{template "empty-state.html" (dict "icon" "icon-rss" "title" "No feed suggestions yet" "subtitle" "Subscribe to a few feeds and we'll suggest more based on your reading.")}}
15-</div>
16-{{end}}
17-{{end}}
deleted internal/tmpl/partials/icon-annotation.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-annotation"}}<path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-annotation"}}<path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>{{end}}
deleted internal/tmpl/partials/icon-bluesky.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-bluesky"}}<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-bluesky"}}<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>{{end}}
deleted internal/tmpl/partials/icon-chart.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-chart"}}<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-chart"}}<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"/>{{end}}
deleted internal/tmpl/partials/icon-globe.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-globe"}}<circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/><line x1="2" y1="12" x2="22" y2="12"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-globe"}}<circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/><line x1="2" y1="12" x2="22" y2="12"/>{{end}}
deleted internal/tmpl/partials/icon-heart.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-heart"}}<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-heart"}}<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"/>{{end}}
deleted internal/tmpl/partials/icon-newspaper.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-newspaper"}}<path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-newspaper"}}<path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/>{{end}}
deleted internal/tmpl/partials/icon-people.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-people"}}<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-people"}}<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"/>{{end}}
deleted internal/tmpl/partials/icon-rss.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-rss"}}<path stroke-linecap="round" stroke-linejoin="round" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-rss"}}<path stroke-linecap="round" stroke-linejoin="round" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/>{{end}}
deleted internal/tmpl/partials/icon-sparkles.html +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-sparkles"}}<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"/>{{end}}
deleted file mode 100644
@@ -1 +0,0 @@
1-{{define "icon-sparkles"}}<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"/>{{end}}
deleted internal/tmpl/partials/like-button.html +0 -8
deleted file mode 100644
@@ -1,8 +0,0 @@
1-{{define "like-button.html"}}
2-<button hx-post="/articles/{{.ID}}/like" hx-target="this" hx-swap="outerHTML"
3- title="{{if .HasLiked}}Unlike{{else}}Like{{end}}"
4- class="group inline-flex items-center justify-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition w-full {{if .HasLiked}}text-spot-red bg-spot-red/15 hover:bg-spot-red/25 border border-spot-red/20{{else}}text-spot-text bg-spot-surface border border-spot-divider hover:text-spot-red hover:bg-spot-red/10 hover:border-spot-red/20{{end}}">
5- <svg class="w-3 h-3" fill="{{if .HasLiked}}currentColor{{else}}none{{end}}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">{{template "icon-heart"}}</svg>
6- <span>{{.LikeCount}}</span>
7-</button>
8-{{end}}
deleted file mode 100644
@@ -1,8 +0,0 @@
1-{{define "like-button.html"}}
2-<button hx-post="/articles/{{.ID}}/like" hx-target="this" hx-swap="outerHTML"
3- title="{{if .HasLiked}}Unlike{{else}}Like{{end}}"
4- class="group inline-flex items-center justify-center gap-1 text-[10px] uppercase tracking-button px-2 py-0.5 rounded-pill transition w-full {{if .HasLiked}}text-spot-red bg-spot-red/15 hover:bg-spot-red/25 border border-spot-red/20{{else}}text-spot-text bg-spot-surface border border-spot-divider hover:text-spot-red hover:bg-spot-red/10 hover:border-spot-red/20{{end}}">
5- <svg class="w-3 h-3" fill="{{if .HasLiked}}currentColor{{else}}none{{end}}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">{{template "icon-heart"}}</svg>
6- <span>{{.LikeCount}}</span>
7-</button>
8-{{end}}
deleted internal/tmpl/partials/logo.html +0 -5
deleted file mode 100644
@@ -1,5 +0,0 @@
1-{{define "logo-icon"}}<img src="/static/favicon.svg" class="w-full h-full" alt="Glean">{{end}}
2-
3-{{define "logo-link"}}<a href="/" class="font-bold text-xl tracking-tight flex items-center gap-2">
4- <span class="w-7 h-7">{{template "logo-icon"}}</span><span class="text-spot-text">Glean</span>
5-</a>{{end}}
\ No newline at end of file
deleted file mode 100644
@@ -1,5 +0,0 @@
1-{{define "logo-icon"}}<img src="/static/favicon.svg" class="w-full h-full" alt="Glean">{{end}}
2-
3-{{define "logo-link"}}<a href="/" class="font-bold text-xl tracking-tight flex items-center gap-2">
4- <span class="w-7 h-7">{{template "logo-icon"}}</span><span class="text-spot-text">Glean</span>
5-</a>{{end}}
\ No newline at end of file\ No newline at end of file
deleted internal/tmpl/partials/pagination.html +0 -19
deleted file mode 100644
@@ -1,19 +0,0 @@
1-{{define "pagination.html"}}
2-{{if or .HasPrev .HasNext}}
3-<div class="flex items-center justify-center gap-3 py-6">
4- {{if .HasPrev}}
5- <a href="{{paginationURL .BaseURL .PrevPage .QueryParams}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">&laquo; Prev</a>
6- {{else}}
7- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">&laquo; Prev</span>
8- {{end}}
9-
10- <span class="text-sm text-spot-secondary">Page {{.Page}}</span>
11-
12- {{if .HasNext}}
13- <a href="{{paginationURL .BaseURL .NextPage .QueryParams}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">Next &raquo;</a>
14- {{else}}
15- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">Next &raquo;</span>
16- {{end}}
17-</div>
18-{{end}}
19-{{end}}
deleted file mode 100644
@@ -1,19 +0,0 @@
1-{{define "pagination.html"}}
2-{{if or .HasPrev .HasNext}}
3-<div class="flex items-center justify-center gap-3 py-6">
4- {{if .HasPrev}}
5- <a href="{{paginationURL .BaseURL .PrevPage .QueryParams}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">&laquo; Prev</a>
6- {{else}}
7- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">&laquo; Prev</span>
8- {{end}}
9-
10- <span class="text-sm text-spot-secondary">Page {{.Page}}</span>
11-
12- {{if .HasNext}}
13- <a href="{{paginationURL .BaseURL .NextPage .QueryParams}}" class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-text hover:bg-spot-hover-50 transition">Next &raquo;</a>
14- {{else}}
15- <span class="px-3 py-1.5 rounded-lg text-sm bg-spot-hover text-spot-muted cursor-not-allowed">Next &raquo;</span>
16- {{end}}
17-</div>
18-{{end}}
19-{{end}}
deleted internal/tmpl/partials/people-recommendations.html +0 -28
deleted file mode 100644
@@ -1,28 +0,0 @@
1-{{define "partials/people-recommendations.html"}}
2-{{if or .FollowedPeople .DiscoverPeople}}
3-<div class="mb-8">
4- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
5- {{if .FollowedPeople}}
6- <div>
7- <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
8- <div class="space-y-3">
9- {{range .FollowedPeople}}
10- {{template "profile-card.html" .}}
11- {{end}}
12- </div>
13- </div>
14- {{end}}
15- {{if .DiscoverPeople}}
16- <div>
17- <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
18- <div class="space-y-3">
19- {{range .DiscoverPeople}}
20- {{template "profile-card.html" .}}
21- {{end}}
22- </div>
23- </div>
24- {{end}}
25- </div>
26-</div>
27-{{end}}
28-{{end}}
deleted file mode 100644
@@ -1,28 +0,0 @@
1-{{define "partials/people-recommendations.html"}}
2-{{if or .FollowedPeople .DiscoverPeople}}
3-<div class="mb-8">
4- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
5- {{if .FollowedPeople}}
6- <div>
7- <h2 class="text-lg font-semibold text-spot-text mb-4">Your network</h2>
8- <div class="space-y-3">
9- {{range .FollowedPeople}}
10- {{template "profile-card.html" .}}
11- {{end}}
12- </div>
13- </div>
14- {{end}}
15- {{if .DiscoverPeople}}
16- <div>
17- <h2 class="text-lg font-semibold text-spot-text mb-4">Discover new readers</h2>
18- <div class="space-y-3">
19- {{range .DiscoverPeople}}
20- {{template "profile-card.html" .}}
21- {{end}}
22- </div>
23- </div>
24- {{end}}
25- </div>
26-</div>
27-{{end}}
28-{{end}}
deleted internal/tmpl/partials/profile-card.html +0 -21
deleted file mode 100644
@@ -1,21 +0,0 @@
1-{{define "profile-card.html"}}
2-<a href="/profile/{{.DID}}" class="bg-spot-surface rounded-xl p-4 flex items-center gap-3.5 hover:bg-spot-hover-50 transition relative group">
3- {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full ring-1 ring-spot-divider">{{end}}
4- <div class="min-w-0 flex-1">
5- <span class="font-semibold text-spot-text hover:text-spot-green transition">@{{.Handle}}</span>
6- {{if .IsFollowed}}
7- <span class="inline-flex items-center gap-0.5 text-[10px] font-medium text-spot-green bg-spot-green/10 px-1.5 py-0.5 rounded-pill ml-1.5">Following</span>
8- {{end}}
9- {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}}
10- </div>
11- <span class="text-xs text-spot-secondary shrink-0">{{.CommonFeeds}} shared</span>
12- {{if not .IsFollowed}}
13- <button hx-post="/recs/dismiss-person" hx-target="closest .group" hx-swap="outerHTML swap:0.3s"
14- hx-vals='{"target_did": "{{.DID}}"}'
15- onclick="event.preventDefault(); event.stopPropagation();"
16- class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition text-spot-muted hover:text-spot-red p-1 rounded-md hover:bg-spot-red/10" title="Hide">
17- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
18- </button>
19- {{end}}
20-</a>
21-{{end}}
deleted file mode 100644
@@ -1,21 +0,0 @@
1-{{define "profile-card.html"}}
2-<a href="/profile/{{.DID}}" class="bg-spot-surface rounded-xl p-4 flex items-center gap-3.5 hover:bg-spot-hover-50 transition relative group">
3- {{if .AvatarURL}}<img src="{{.AvatarURL}}" class="w-10 h-10 rounded-full ring-1 ring-spot-divider">{{end}}
4- <div class="min-w-0 flex-1">
5- <span class="font-semibold text-spot-text hover:text-spot-green transition">@{{.Handle}}</span>
6- {{if .IsFollowed}}
7- <span class="inline-flex items-center gap-0.5 text-[10px] font-medium text-spot-green bg-spot-green/10 px-1.5 py-0.5 rounded-pill ml-1.5">Following</span>
8- {{end}}
9- {{if .DisplayName}}<div class="text-sm text-spot-secondary">{{.DisplayName}}</div>{{end}}
10- </div>
11- <span class="text-xs text-spot-secondary shrink-0">{{.CommonFeeds}} shared</span>
12- {{if not .IsFollowed}}
13- <button hx-post="/recs/dismiss-person" hx-target="closest .group" hx-swap="outerHTML swap:0.3s"
14- hx-vals='{"target_did": "{{.DID}}"}'
15- onclick="event.preventDefault(); event.stopPropagation();"
16- class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition text-spot-muted hover:text-spot-red p-1 rounded-md hover:bg-spot-red/10" title="Hide">
17- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
18- </button>
19- {{end}}
20-</a>
21-{{end}}
deleted internal/tmpl/partials/recommendation-feed-card.html +0 -28
deleted file mode 100644
@@ -1,28 +0,0 @@
1-{{define "recommendation-feed-card.html"}}
2-<div class="recommendation-card bg-spot-surface rounded-xl p-3.5 hover:bg-spot-hover-50 transition shadow-spot">
3- <div class="flex items-center justify-between gap-2">
4- <a href="/articles?feed={{.feed_url}}" class="min-w-0 flex items-center gap-2.5 flex-1">
5- {{template "favicon" dict "src" .favicon_url "size" "w-5 h-5"}}
6- <div class="min-w-0">
7- <div class="font-semibold text-sm text-spot-text truncate">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div>
8- {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}}
9- </div>
10- </a>
11- <div class="flex items-center gap-2 shrink-0">
12- <span class="text-xs text-spot-secondary">{{.subscriber_count}} subs</span>
13- <form hx-post="/recs/dismiss-feed" hx-target="closest .recommendation-card" hx-swap="outerHTML" class="inline-flex items-center">
14- {{csrfInput .CSRFToken}}
15- <input type="hidden" name="feed_url" value="{{.feed_url}}">
16- <button type="submit" title="Not interested" class="text-spot-muted hover:text-spot-red transition p-0.5 rounded hover:bg-spot-hover inline-flex items-center">
17- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
18- </button>
19- </form>
20- <form hx-post="/feeds/add" hx-target="closest .recommendation-card" hx-swap="outerHTML" class="inline-flex items-center">
21- {{csrfInput .CSRFToken}}
22- <input type="hidden" name="feed_url" value="{{.feed_url}}">
23- <button type="submit" title="Subscribe" class="text-xs font-bold uppercase tracking-button text-spot-green hover:brightness-110 transition">Subscribe</button>
24- </form>
25- </div>
26- </div>
27-</div>
28-{{end}}
deleted file mode 100644
@@ -1,28 +0,0 @@
1-{{define "recommendation-feed-card.html"}}
2-<div class="recommendation-card bg-spot-surface rounded-xl p-3.5 hover:bg-spot-hover-50 transition shadow-spot">
3- <div class="flex items-center justify-between gap-2">
4- <a href="/articles?feed={{.feed_url}}" class="min-w-0 flex items-center gap-2.5 flex-1">
5- {{template "favicon" dict "src" .favicon_url "size" "w-5 h-5"}}
6- <div class="min-w-0">
7- <div class="font-semibold text-sm text-spot-text truncate">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div>
8- {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}}
9- </div>
10- </a>
11- <div class="flex items-center gap-2 shrink-0">
12- <span class="text-xs text-spot-secondary">{{.subscriber_count}} subs</span>
13- <form hx-post="/recs/dismiss-feed" hx-target="closest .recommendation-card" hx-swap="outerHTML" class="inline-flex items-center">
14- {{csrfInput .CSRFToken}}
15- <input type="hidden" name="feed_url" value="{{.feed_url}}">
16- <button type="submit" title="Not interested" class="text-spot-muted hover:text-spot-red transition p-0.5 rounded hover:bg-spot-hover inline-flex items-center">
17- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3.5 h-3.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
18- </button>
19- </form>
20- <form hx-post="/feeds/add" hx-target="closest .recommendation-card" hx-swap="outerHTML" class="inline-flex items-center">
21- {{csrfInput .CSRFToken}}
22- <input type="hidden" name="feed_url" value="{{.feed_url}}">
23- <button type="submit" title="Subscribe" class="text-xs font-bold uppercase tracking-button text-spot-green hover:brightness-110 transition">Subscribe</button>
24- </form>
25- </div>
26- </div>
27-</div>
28-{{end}}
deleted internal/tmpl/profile.html +0 -98
deleted file mode 100644
@@ -1,98 +0,0 @@
1-{{define "profile.html"}}
2-<div class="max-w-2xl mx-auto">
3- <div class="bg-spot-surface rounded-2xl p-4 sm:p-6 mb-6">
4- <div class="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-5 text-center sm:text-left">
5- {{if .ProfileUser.AvatarURL}}<img src="{{.ProfileUser.AvatarURL}}" class="w-20 h-20 rounded-full ring-2 ring-spot-divider">{{else}}<div class="w-20 h-20 rounded-full bg-spot-hover flex items-center justify-center text-spot-muted"><svg class="w-10 h-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg></div>{{end}}
6- <div class="min-w-0 flex-1">
7- <h1 class="text-2xl font-bold text-spot-text truncate" style="letter-spacing: -0.02em;">
8- {{if .ProfileUser.DisplayName}}{{.ProfileUser.DisplayName}}{{end}}
9- </h1>
10- <p class="flex items-center gap-1.5 text-spot-secondary mt-1">@{{.ProfileUser.Handle}}<a href="https://bsky.app/profile/{{.ProfileUser.Handle}}" target="_blank" rel="noopener" class="text-spot-secondary hover:text-spot-green transition"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">{{template "icon-bluesky"}}</svg></a></p>
11- <div class="flex gap-4 mt-3 justify-center sm:justify-start">
12- <a href="/feeds" class="group">
13- <span class="text-xl font-bold text-spot-text">{{.SubscriptionCount}}</span>
14- <span class="text-sm text-spot-secondary ml-1 group-hover:text-spot-text transition">feeds</span>
15- </a>
16- <a href="/library" class="border-l border-spot-divider pl-5 group">
17- <span class="text-xl font-bold text-spot-text">{{.AnnotationCount}}</span>
18- <span class="text-sm text-spot-secondary ml-1 group-hover:text-spot-text transition">annotations</span>
19- </a>
20- </div>
21- </div>
22- </div>
23- </div>
24-
25- {{if eq .ProfileUser.DID .CurrentUserDID}}
26- {{if .HasLLM}}
27- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2>
28- <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5">
29- <form hx-post="/settings/digest-enabled" hx-swap="none" hx-on::after-request="location.reload()">
30- <input type="hidden" name="digest_enabled" value="{{if .DigestEnabled}}0{{else}}1{{end}}">
31- <div class="flex items-center justify-between">
32- <div>
33- <p class="text-sm font-semibold text-spot-text mb-0.5">Daily digest</p>
34- <p class="text-xs text-spot-secondary">Show an AI-generated summary of your 50 most recent unread articles on the dashboard.</p>
35- </div>
36- <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .DigestEnabled}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle daily digest">
37- <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .DigestEnabled}}translate-x-6{{else}}translate-x-1{{end}}"></span>
38- </button>
39- </div>
40- </form>
41- <hr class="border-spot-divider">
42- <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()">
43- <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}">
44- <div class="flex items-center justify-between">
45- <div>
46- <p class="text-sm font-semibold text-spot-text mb-0.5">Expanded article view</p>
47- <p class="text-xs text-spot-secondary">Show full article content inline. Articles are marked as read as you scroll.</p>
48- </div>
49- <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .ExpandedView}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle expanded view">
50- <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .ExpandedView}}translate-x-6{{else}}translate-x-1{{end}}"></span>
51- </button>
52- </div>
53- </form>
54- <hr class="border-spot-divider">
55- <div>
56- <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>
57- <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p>
58- <div class="flex flex-wrap gap-1.5">
59- {{range .AvailableLanguages}}
60- <form hx-post="/settings/languages/{{.Code}}" hx-swap="none" hx-on::after-request="location.reload()" class="inline">
61- <button type="submit" class="cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition border-0 {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">{{.Name}}</button>
62- </form>
63- {{end}}
64- </div>
65- </div>
66- </div>
67- {{end}}
68- {{end}}
69-
70- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>
71- <div class="bg-spot-surface rounded-xl divide-y divide-spot-divider mb-6 overflow-hidden">
72- {{range .Subscriptions}}
73- <a href="/articles?feed={{.FeedURL}}" class="flex items-center gap-3 px-5 py-3.5 hover:bg-spot-hover-50 transition">
74- {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}}
75- <div class="min-w-0 flex-1">
76- <div class="flex items-center gap-2 flex-wrap">
77- <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
78- {{if and .Category.Valid .Category.String}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-pill shrink-0">{{.Category.String}}</span>{{end}}
79- </div>
80- <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div>
81- </div>
82- <svg class="w-4 h-4 text-spot-muted shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
83- </a>
84- {{else}}
85- {{template "empty-state.html" (dict "icon" "icon-rss" "size" "compact" "title" "No feeds yet" "subtitle" "Subscribed feeds will appear here.")}}
86- {{end}}
87- </div>
88-
89- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recent annotations</h2>
90- <div class="space-y-3">
91- {{range .Annotations}}
92- {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}}
93- {{else}}
94- {{template "empty-state.html" (dict "icon" "icon-annotation" "size" "compact" "title" "No annotations yet" "subtitle" "Highlight and annotate articles as you read.")}}
95- {{end}}
96- </div>
97-</div>
98-{{end}}
deleted file mode 100644
@@ -1,98 +0,0 @@
1-{{define "profile.html"}}
2-<div class="max-w-2xl mx-auto">
3- <div class="bg-spot-surface rounded-2xl p-4 sm:p-6 mb-6">
4- <div class="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-5 text-center sm:text-left">
5- {{if .ProfileUser.AvatarURL}}<img src="{{.ProfileUser.AvatarURL}}" class="w-20 h-20 rounded-full ring-2 ring-spot-divider">{{else}}<div class="w-20 h-20 rounded-full bg-spot-hover flex items-center justify-center text-spot-muted"><svg class="w-10 h-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 4-7 8-7s8 3 8 7"/></svg></div>{{end}}
6- <div class="min-w-0 flex-1">
7- <h1 class="text-2xl font-bold text-spot-text truncate" style="letter-spacing: -0.02em;">
8- {{if .ProfileUser.DisplayName}}{{.ProfileUser.DisplayName}}{{end}}
9- </h1>
10- <p class="flex items-center gap-1.5 text-spot-secondary mt-1">@{{.ProfileUser.Handle}}<a href="https://bsky.app/profile/{{.ProfileUser.Handle}}" target="_blank" rel="noopener" class="text-spot-secondary hover:text-spot-green transition"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">{{template "icon-bluesky"}}</svg></a></p>
11- <div class="flex gap-4 mt-3 justify-center sm:justify-start">
12- <a href="/feeds" class="group">
13- <span class="text-xl font-bold text-spot-text">{{.SubscriptionCount}}</span>
14- <span class="text-sm text-spot-secondary ml-1 group-hover:text-spot-text transition">feeds</span>
15- </a>
16- <a href="/library" class="border-l border-spot-divider pl-5 group">
17- <span class="text-xl font-bold text-spot-text">{{.AnnotationCount}}</span>
18- <span class="text-sm text-spot-secondary ml-1 group-hover:text-spot-text transition">annotations</span>
19- </a>
20- </div>
21- </div>
22- </div>
23- </div>
24-
25- {{if eq .ProfileUser.DID .CurrentUserDID}}
26- {{if .HasLLM}}
27- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2>
28- <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5">
29- <form hx-post="/settings/digest-enabled" hx-swap="none" hx-on::after-request="location.reload()">
30- <input type="hidden" name="digest_enabled" value="{{if .DigestEnabled}}0{{else}}1{{end}}">
31- <div class="flex items-center justify-between">
32- <div>
33- <p class="text-sm font-semibold text-spot-text mb-0.5">Daily digest</p>
34- <p class="text-xs text-spot-secondary">Show an AI-generated summary of your 50 most recent unread articles on the dashboard.</p>
35- </div>
36- <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .DigestEnabled}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle daily digest">
37- <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .DigestEnabled}}translate-x-6{{else}}translate-x-1{{end}}"></span>
38- </button>
39- </div>
40- </form>
41- <hr class="border-spot-divider">
42- <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()">
43- <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}">
44- <div class="flex items-center justify-between">
45- <div>
46- <p class="text-sm font-semibold text-spot-text mb-0.5">Expanded article view</p>
47- <p class="text-xs text-spot-secondary">Show full article content inline. Articles are marked as read as you scroll.</p>
48- </div>
49- <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .ExpandedView}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle expanded view">
50- <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .ExpandedView}}translate-x-6{{else}}translate-x-1{{end}}"></span>
51- </button>
52- </div>
53- </form>
54- <hr class="border-spot-divider">
55- <div>
56- <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p>
57- <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p>
58- <div class="flex flex-wrap gap-1.5">
59- {{range .AvailableLanguages}}
60- <form hx-post="/settings/languages/{{.Code}}" hx-swap="none" hx-on::after-request="location.reload()" class="inline">
61- <button type="submit" class="cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition border-0 {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">{{.Name}}</button>
62- </form>
63- {{end}}
64- </div>
65- </div>
66- </div>
67- {{end}}
68- {{end}}
69-
70- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2>
71- <div class="bg-spot-surface rounded-xl divide-y divide-spot-divider mb-6 overflow-hidden">
72- {{range .Subscriptions}}
73- <a href="/articles?feed={{.FeedURL}}" class="flex items-center gap-3 px-5 py-3.5 hover:bg-spot-hover-50 transition">
74- {{template "favicon" dict "src" .FaviconURL.String "size" "w-5 h-5"}}
75- <div class="min-w-0 flex-1">
76- <div class="flex items-center gap-2 flex-wrap">
77- <span class="font-bold text-spot-text truncate">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span>
78- {{if and .Category.Valid .Category.String}}<span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-pill shrink-0">{{.Category.String}}</span>{{end}}
79- </div>
80- <div class="text-xs text-spot-muted truncate mt-0.5">{{.FeedURL}}</div>
81- </div>
82- <svg class="w-4 h-4 text-spot-muted shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
83- </a>
84- {{else}}
85- {{template "empty-state.html" (dict "icon" "icon-rss" "size" "compact" "title" "No feeds yet" "subtitle" "Subscribed feeds will appear here.")}}
86- {{end}}
87- </div>
88-
89- <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Recent annotations</h2>
90- <div class="space-y-3">
91- {{range .Annotations}}
92- {{template "annotation-card.html" dict "annotation" . "userDID" $.CurrentUserDID}}
93- {{else}}
94- {{template "empty-state.html" (dict "icon" "icon-annotation" "size" "compact" "title" "No annotations yet" "subtitle" "Highlight and annotate articles as you read.")}}
95- {{end}}
96- </div>
97-</div>
98-{{end}}
deleted internal/tmpl/stats.html +0 -52
deleted file mode 100644
@@ -1,52 +0,0 @@
1-{{define "stats.html"}}
2-<div class="flex items-center justify-between mb-2">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Stats</h1>
4-</div>
5-<p class="text-sm text-spot-secondary mb-6">Application metrics and performance data.</p>
6-
7-{{if .User}}
8-<div hx-get="/stats" hx-trigger="every 30s" hx-target="#metrics-content" hx-select="#metrics-content" hx-swap="innerHTML"></div>
9-{{end}}
10-
11-<div id="metrics-content" class="space-y-3">
12- {{range $category, $metrics := .Metrics}}
13- <div class="bg-spot-surface rounded-xl overflow-hidden">
14- <div class="px-5 py-3.5 border-b border-spot-divider">
15- <h2 class="font-semibold text-spot-text">{{$category}}</h2>
16- </div>
17- <div class="divide-y divide-spot-divider">
18- {{range $metrics}}
19- <div class="px-4 sm:px-5 py-3.5 flex flex-col sm:flex-row sm:items-start justify-between hover:bg-spot-hover transition gap-1 sm:gap-0">
20- <div class="flex-1 min-w-0">
21- <div class="flex items-center gap-2">
22- <span class="text-sm font-medium text-spot-text">{{.Name}}</span>
23- {{if .Description}}
24- <span class="text-xs text-spot-secondary">{{.Description}}</span>
25- {{end}}
26- </div>
27- {{if .Labels}}
28- <div class="flex gap-1.5 mt-1.5">
29- {{range $key, $value := .Labels}}
30- <span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-pill">{{$key}}={{$value}}</span>
31- {{end}}
32- </div>
33- {{end}}
34- </div>
35- <div class="text-right shrink-0 ml-4">
36- {{if eq .Type "gauge"}}
37- <span class="text-sm font-mono text-spot-green">{{printf "%.2f" .Value}}</span>
38- {{else if eq .Type "counter"}}
39- <span class="text-sm font-mono text-spot-blue">{{printf "%.0f" .Value}}</span>
40- {{else}}
41- <span class="text-sm font-mono text-spot-text">{{printf "%.2f" .Value}}</span>
42- {{end}}
43- </div>
44- </div>
45- {{end}}
46- </div>
47- </div>
48- {{else}}
49- {{template "empty-state.html" (dict "icon" "icon-chart" "title" "No metrics available" "subtitle" "Metrics will appear here once the application has been running for a while.")}}
50- {{end}}
51-</div>
52-{{end}}
deleted file mode 100644
@@ -1,52 +0,0 @@
1-{{define "stats.html"}}
2-<div class="flex items-center justify-between mb-2">
3- <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Stats</h1>
4-</div>
5-<p class="text-sm text-spot-secondary mb-6">Application metrics and performance data.</p>
6-
7-{{if .User}}
8-<div hx-get="/stats" hx-trigger="every 30s" hx-target="#metrics-content" hx-select="#metrics-content" hx-swap="innerHTML"></div>
9-{{end}}
10-
11-<div id="metrics-content" class="space-y-3">
12- {{range $category, $metrics := .Metrics}}
13- <div class="bg-spot-surface rounded-xl overflow-hidden">
14- <div class="px-5 py-3.5 border-b border-spot-divider">
15- <h2 class="font-semibold text-spot-text">{{$category}}</h2>
16- </div>
17- <div class="divide-y divide-spot-divider">
18- {{range $metrics}}
19- <div class="px-4 sm:px-5 py-3.5 flex flex-col sm:flex-row sm:items-start justify-between hover:bg-spot-hover transition gap-1 sm:gap-0">
20- <div class="flex-1 min-w-0">
21- <div class="flex items-center gap-2">
22- <span class="text-sm font-medium text-spot-text">{{.Name}}</span>
23- {{if .Description}}
24- <span class="text-xs text-spot-secondary">{{.Description}}</span>
25- {{end}}
26- </div>
27- {{if .Labels}}
28- <div class="flex gap-1.5 mt-1.5">
29- {{range $key, $value := .Labels}}
30- <span class="text-xs bg-spot-hover text-spot-secondary px-2 py-0.5 rounded-pill">{{$key}}={{$value}}</span>
31- {{end}}
32- </div>
33- {{end}}
34- </div>
35- <div class="text-right shrink-0 ml-4">
36- {{if eq .Type "gauge"}}
37- <span class="text-sm font-mono text-spot-green">{{printf "%.2f" .Value}}</span>
38- {{else if eq .Type "counter"}}
39- <span class="text-sm font-mono text-spot-blue">{{printf "%.0f" .Value}}</span>
40- {{else}}
41- <span class="text-sm font-mono text-spot-text">{{printf "%.2f" .Value}}</span>
42- {{end}}
43- </div>
44- </div>
45- {{end}}
46- </div>
47- </div>
48- {{else}}
49- {{template "empty-state.html" (dict "icon" "icon-chart" "title" "No metrics available" "subtitle" "Metrics will appear here once the application has been running for a while.")}}
50- {{end}}
51-</div>
52-{{end}}
deleted internal/tmpl/terms.html +0 -72
deleted file mode 100644
@@ -1,72 +0,0 @@
1-{{define "terms.html"}}
2-<div class="max-w-2xl mx-auto py-12 px-4">
3- <h1 class="text-2xl font-bold text-spot-text mb-2" style="letter-spacing: -0.02em;">Terms of Service</h1>
4- <p class="text-sm text-spot-secondary mb-8">Last updated: May 2, 2026</p>
5-
6- <div class="space-y-7 text-sm text-spot-body leading-relaxed">
7- <div>
8- <h2 class="text-lg font-bold text-spot-text mb-3">1. Acceptance of Terms</h2>
9- <p>By accessing or using Glean ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
10- </div>
11-
12- <div>
13- <h2 class="text-lg font-bold text-spot-text mb-3">2. Description of Service</h2>
14- <p>Glean is a social RSS reader built on the AT Protocol. It allows you to subscribe to RSS and Atom feeds, read articles, highlight passages, leave annotations, and discover new content through personalized recommendations based on your network.</p>
15- </div>
16-
17- <div>
18- <h2 class="text-lg font-bold text-spot-text mb-3">3. Account and Authentication</h2>
19- <p>Glean uses AT Protocol identity. You sign in with your handle (e.g. from Bluesky or another AT Protocol account provider). Your subscriptions, annotations, and likes are stored in your personal data repository (PDS) on the AT Protocol. You retain full ownership of your data at all times.</p>
20- </div>
21-
22- <div>
23- <h2 class="text-lg font-bold text-spot-text mb-3">4. Your Data</h2>
24- <p>Your data belongs to you. Glean stores records in your PDS using AT Protocol collections. You can export or move your data at any time using standard AT Protocol tools. Glean does not sell, share, or monetize your personal data.</p>
25- </div>
26-
27- <div>
28- <h2 class="text-lg font-bold text-spot-text mb-3">5. Acceptable Use</h2>
29- <p>You agree not to use the Service to: violate any applicable law; infringe on the rights of others; distribute spam, malware, or harmful content; attempt to gain unauthorized access to the Service or its infrastructure; or interfere with the proper functioning of the Service.</p>
30- </div>
31-
32- <div>
33- <h2 class="text-lg font-bold text-spot-text mb-3">6. Content</h2>
34- <p>Glean indexes publicly available RSS and Atom feeds. We do not host article content. Feed publishers retain all rights to their content. If you are a feed publisher and wish to have your feed removed from our index, please contact us.</p>
35- </div>
36-
37- <div>
38- <h2 class="text-lg font-bold text-spot-text mb-3">7. Availability</h2>
39- <p>The Service is provided "as is" and "as available." We strive for reliability but do not guarantee uninterrupted access. We may modify, suspend, or discontinue the Service at any time.</p>
40- </div>
41-
42- <div>
43- <h2 class="text-lg font-bold text-spot-text mb-3">8. Open Source</h2>
44- <p>Glean is open source software. You can inspect, fork, and self-host the code. Contributions are welcome subject to the project's license and contribution guidelines.</p>
45- </div>
46-
47- <div>
48- <h2 class="text-lg font-bold text-spot-text mb-3">9. Limitation of Liability</h2>
49- <p>To the maximum extent permitted by law, the Service is provided without warranties of any kind. We are not liable for any indirect, incidental, or consequential damages arising from your use of the Service.</p>
50- </div>
51-
52- <div>
53- <h2 class="text-lg font-bold text-spot-text mb-3">10. Changes to Terms</h2>
54- <p>We may update these Terms from time to time. Material changes will be communicated through the Service. Continued use of the Service after changes constitutes acceptance of the updated Terms.</p>
55- </div>
56-
57- <div>
58- <h2 class="text-lg font-bold text-spot-text mb-3">11. Contact</h2>
59- <p>For questions about these Terms, reach out via <a href="https://bsky.app/profile/glean.at" class="text-spot-green hover:brightness-110 underline">Bluesky</a> or email at <span id="contact-email"></span>.</p>
60- </div>
61- </div>
62-
63- <div class="mt-12 pt-6 border-t border-spot-divider">
64- <a href="/" class="inline-flex items-center justify-center bg-spot-green text-white rounded-pill px-6 py-2.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
65- Back to Glean
66- </a>
67- </div>
68-</div>
69-<script>
70-(function(){var p='contact',d='glean.at',e=p+'@'+d;var el=document.getElementById('contact-email');var a=document.createElement('a');a.href='mailto:'+e;a.textContent=e;a.className='text-spot-green hover:brightness-110 underline';el.replaceWith(a)})();
71-</script>
72-{{end}}
deleted file mode 100644
@@ -1,72 +0,0 @@
1-{{define "terms.html"}}
2-<div class="max-w-2xl mx-auto py-12 px-4">
3- <h1 class="text-2xl font-bold text-spot-text mb-2" style="letter-spacing: -0.02em;">Terms of Service</h1>
4- <p class="text-sm text-spot-secondary mb-8">Last updated: May 2, 2026</p>
5-
6- <div class="space-y-7 text-sm text-spot-body leading-relaxed">
7- <div>
8- <h2 class="text-lg font-bold text-spot-text mb-3">1. Acceptance of Terms</h2>
9- <p>By accessing or using Glean ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
10- </div>
11-
12- <div>
13- <h2 class="text-lg font-bold text-spot-text mb-3">2. Description of Service</h2>
14- <p>Glean is a social RSS reader built on the AT Protocol. It allows you to subscribe to RSS and Atom feeds, read articles, highlight passages, leave annotations, and discover new content through personalized recommendations based on your network.</p>
15- </div>
16-
17- <div>
18- <h2 class="text-lg font-bold text-spot-text mb-3">3. Account and Authentication</h2>
19- <p>Glean uses AT Protocol identity. You sign in with your handle (e.g. from Bluesky or another AT Protocol account provider). Your subscriptions, annotations, and likes are stored in your personal data repository (PDS) on the AT Protocol. You retain full ownership of your data at all times.</p>
20- </div>
21-
22- <div>
23- <h2 class="text-lg font-bold text-spot-text mb-3">4. Your Data</h2>
24- <p>Your data belongs to you. Glean stores records in your PDS using AT Protocol collections. You can export or move your data at any time using standard AT Protocol tools. Glean does not sell, share, or monetize your personal data.</p>
25- </div>
26-
27- <div>
28- <h2 class="text-lg font-bold text-spot-text mb-3">5. Acceptable Use</h2>
29- <p>You agree not to use the Service to: violate any applicable law; infringe on the rights of others; distribute spam, malware, or harmful content; attempt to gain unauthorized access to the Service or its infrastructure; or interfere with the proper functioning of the Service.</p>
30- </div>
31-
32- <div>
33- <h2 class="text-lg font-bold text-spot-text mb-3">6. Content</h2>
34- <p>Glean indexes publicly available RSS and Atom feeds. We do not host article content. Feed publishers retain all rights to their content. If you are a feed publisher and wish to have your feed removed from our index, please contact us.</p>
35- </div>
36-
37- <div>
38- <h2 class="text-lg font-bold text-spot-text mb-3">7. Availability</h2>
39- <p>The Service is provided "as is" and "as available." We strive for reliability but do not guarantee uninterrupted access. We may modify, suspend, or discontinue the Service at any time.</p>
40- </div>
41-
42- <div>
43- <h2 class="text-lg font-bold text-spot-text mb-3">8. Open Source</h2>
44- <p>Glean is open source software. You can inspect, fork, and self-host the code. Contributions are welcome subject to the project's license and contribution guidelines.</p>
45- </div>
46-
47- <div>
48- <h2 class="text-lg font-bold text-spot-text mb-3">9. Limitation of Liability</h2>
49- <p>To the maximum extent permitted by law, the Service is provided without warranties of any kind. We are not liable for any indirect, incidental, or consequential damages arising from your use of the Service.</p>
50- </div>
51-
52- <div>
53- <h2 class="text-lg font-bold text-spot-text mb-3">10. Changes to Terms</h2>
54- <p>We may update these Terms from time to time. Material changes will be communicated through the Service. Continued use of the Service after changes constitutes acceptance of the updated Terms.</p>
55- </div>
56-
57- <div>
58- <h2 class="text-lg font-bold text-spot-text mb-3">11. Contact</h2>
59- <p>For questions about these Terms, reach out via <a href="https://bsky.app/profile/glean.at" class="text-spot-green hover:brightness-110 underline">Bluesky</a> or email at <span id="contact-email"></span>.</p>
60- </div>
61- </div>
62-
63- <div class="mt-12 pt-6 border-t border-spot-divider">
64- <a href="/" class="inline-flex items-center justify-center bg-spot-green text-white rounded-pill px-6 py-2.5 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
65- Back to Glean
66- </a>
67- </div>
68-</div>
69-<script>
70-(function(){var p='contact',d='glean.at',e=p+'@'+d;var el=document.getElementById('contact-email');var a=document.createElement('a');a.href='mailto:'+e;a.textContent=e;a.className='text-spot-green hover:brightness-110 underline';el.replaceWith(a)})();
71-</script>
72-{{end}}
modified main.go +2 -1
@@ -70,6 +70,7 @@ func main() {
7070
7171 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")
7272 callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")
73+ frontendURL := envOr("GLEAN_FRONTEND_URL", "http://localhost:3000")
7374
7475 storeAdapter := db.NewFeedAdapter(dbs.Articles)
7576 siteFetcher := atproto.NewStandardSiteFetcher(logger)
@@ -104,7 +105,7 @@ func main() {
104105 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())
105106
106107 fetcher := feed.NewFetcher(siteFetcher)
107- srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
108+ srv := server.New(dbs, clientID, callbackURL, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
108109
109110 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
110111
@@ -70,6 +70,7 @@ func main() {
70 70
71 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")71 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")
72 callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")72 callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")
73+ frontendURL := envOr("GLEAN_FRONTEND_URL", "http://localhost:3000")
73 74
74 storeAdapter := db.NewFeedAdapter(dbs.Articles)75 storeAdapter := db.NewFeedAdapter(dbs.Articles)
75 siteFetcher := atproto.NewStandardSiteFetcher(logger)76 siteFetcher := atproto.NewStandardSiteFetcher(logger)
@@ -104,7 +105,7 @@ func main() {
104 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())105 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig())
105 106
106 fetcher := feed.NewFetcher(siteFetcher)107 fetcher := feed.NewFetcher(siteFetcher)
107- srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)108+ srv := server.New(dbs, clientID, callbackURL, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
108 109
109 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)110 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
110 111
deleted package.json +0 -5
deleted file mode 100644
@@ -1,5 +0,0 @@
1-{
2- "dependencies": {
3- "tailwindcss": "^3.4.19"
4- }
5-}
deleted file mode 100644
@@ -1,5 +0,0 @@
1-{
2- "dependencies": {
3- "tailwindcss": "^3.4.19"
4- }
5-}
modified readme.md +43 -28
@@ -36,52 +36,67 @@ The system improves over time: as you subscribe to feeds and like articles, Glea
3636 ### Docker
3737
3838 ```bash
39-docker run -p 8080:8080 -v glean-data:/data atcr.io/julien.rbrt.fr/glean:latest
39+docker run -p 3000:3000 -e GLEAN_SESSION_KEY=changeme -v glean-data:/data atcr.io/julien.rbrt.fr/glean:latest
4040 ```
4141
4242 ### From source
4343
44+The frontend is a SvelteKit app in `web/`; the Go binary serves a JSON API.
45+SvelteKit runs the SSR server (port 3000) and proxies `/api` to the Go API
46+(port 8080).
47+
4448 ```bash
4549 git clone https://github.com/anomalyco/glean.git
4650 cd glean
47-make build
48-./glean
51+make web-install # install frontend deps (bun)
52+make build # builds the frontend and the Go binary
53+```
54+
55+Run both in dev:
56+
57+```bash
58+make dev-api # Go API on :8080
59+make dev-web # SvelteKit dev server on :3000 (proxies /api to :8080)
4960 ```
5061
51-Then open `http://localhost:8080`.
62+Then open `http://localhost:3000`.
5263
5364 ## Configuration
5465
55-| Variable | Default | What it does |
56-| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------ |
57-| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
58-| `GLEAN_ADDR` | `:8080` | Listen address |
59-| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
60-| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |
61-| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
62-| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
63-| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
64-| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
65-| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
66-| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |
67-| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
68-| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |
69-| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
70-| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
71-| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
72-| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
73-| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
74-| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
75-| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
76-| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
66+| Variable | Default | What it does |
67+| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- |
68+| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
69+| `GLEAN_ADDR` | `:8080` | Listen address |
70+| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
71+| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |
72+| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
73+| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
74+| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
75+| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
76+| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
77+| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |
78+| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
79+| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL, must resolve to `/api/auth/callback` on the public origin (leave empty for localhost dev) |
80+| `GLEAN_FRONTEND_URL` | `http://localhost:3000` | Public origin of the SvelteKit frontend; used as the OAuth callback base in localhost dev |
81+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
82+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
83+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
84+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
85+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
86+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
87+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
88+| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
7789
7890 For production:
7991
8092 ```bash
8193 export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata
82-export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/auth/callback
94+export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/api/auth/callback
8395 ```
8496
97+The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to
98+know where the Go API is, and `ORIGIN`/`PORT` for its own listen address.
99+
85100 ## Documentation
86101
87102 - [Technical specification](docs/specs.md) — architecture, database schema, AT Protocol lexicons, API endpoints, recommendations
@@ -89,7 +104,7 @@ export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/auth/callback
89104
90105 ## Stack
91106
92-Go, SQLite, htmx, TailwindCSS, AT Protocol OAuth.
107+Go, SQLite, SvelteKit (SSR), TailwindCSS, AT Protocol OAuth.
93108
94109 ## License
95110
@@ -36,52 +36,67 @@ The system improves over time: as you subscribe to feeds and like articles, Glea
36 ### Docker36 ### Docker
37 37
38 ```bash38 ```bash
39-docker run -p 8080:8080 -v glean-data:/data atcr.io/julien.rbrt.fr/glean:latest39+docker run -p 3000:3000 -e GLEAN_SESSION_KEY=changeme -v glean-data:/data atcr.io/julien.rbrt.fr/glean:latest
40 ```40 ```
41 41
42 ### From source42 ### From source
43 43
44+The frontend is a SvelteKit app in `web/`; the Go binary serves a JSON API.
45+SvelteKit runs the SSR server (port 3000) and proxies `/api` to the Go API
46+(port 8080).
47+
44 ```bash48 ```bash
45 git clone https://github.com/anomalyco/glean.git49 git clone https://github.com/anomalyco/glean.git
46 cd glean50 cd glean
47-make build51+make web-install # install frontend deps (bun)
48-./glean52+make build # builds the frontend and the Go binary
53+```
54+
55+Run both in dev:
56+
57+```bash
58+make dev-api # Go API on :8080
59+make dev-web # SvelteKit dev server on :3000 (proxies /api to :8080)
49 ```60 ```
50 61
51-Then open `http://localhost:8080`.62+Then open `http://localhost:3000`.
52 63
53 ## Configuration64 ## Configuration
54 65
55-| Variable | Default | What it does |66+| Variable | Default | What it does |
56-| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------ |67+| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- |
57-| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |68+| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) |
58-| `GLEAN_ADDR` | `:8080` | Listen address |69+| `GLEAN_ADDR` | `:8080` | Listen address |
59-| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |70+| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) |
60-| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |71+| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL |
61-| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |72+| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) |
62-| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |73+| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) |
63-| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |74+| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) |
64-| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |75+| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill |
65-| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |76+| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers |
66-| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |77+| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution |
67-| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |78+| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) |
68-| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) |79+| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL, must resolve to `/api/auth/callback` on the public origin (leave empty for localhost dev) |
69-| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |80+| `GLEAN_FRONTEND_URL` | `http://localhost:3000` | Public origin of the SvelteKit frontend; used as the OAuth callback base in localhost dev |
70-| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |81+| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) |
71-| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |82+| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint |
72-| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |83+| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name |
73-| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |84+| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension |
74-| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |85+| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) |
75-| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |86+| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint |
76-| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |87+| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name |
88+| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) |
77 89
78 For production:90 For production:
79 91
80 ```bash92 ```bash
81 export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata93 export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata
82-export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/auth/callback94+export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/api/auth/callback
83 ```95 ```
84 96
97+The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to
98+know where the Go API is, and `ORIGIN`/`PORT` for its own listen address.
99+
85 ## Documentation100 ## Documentation
86 101
87 - [Technical specification](docs/specs.md) — architecture, database schema, AT Protocol lexicons, API endpoints, recommendations102 - [Technical specification](docs/specs.md) — architecture, database schema, AT Protocol lexicons, API endpoints, recommendations
@@ -89,7 +104,7 @@ export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/auth/callback
89 104
90 ## Stack105 ## Stack
91 106
92-Go, SQLite, htmx, TailwindCSS, AT Protocol OAuth.107+Go, SQLite, SvelteKit (SSR), TailwindCSS, AT Protocol OAuth.
93 108
94 ## License109 ## License
95 110
deleted static/embed.go +0 -6
deleted file mode 100644
@@ -1,6 +0,0 @@
1-package static
2-
3-import "embed"
4-
5-//go:embed *
6-var Files embed.FS
deleted file mode 100644
@@ -1,6 +0,0 @@
1-package static
2-
3-import "embed"
4-
5-//go:embed *
6-var Files embed.FS
deleted static/htmx.min.js +0 -1
deleted file mode 100644
@@ -1 +0,0 @@
1-var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function ie(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function B(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=S(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=ue(S(e));if(!e){return}if(n){x().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ue(S(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=S(e);e.classList.toggle(t)}function W(e,t){e=S(e);ie(e.parentElement.children,function(e){b(e,t)});w(ue(e),t)}function g(e,t){e=ue(S(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function Y(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=S(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=Y(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),Y(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),Y(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,Y(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,Y(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const u=p(q(t,!!n));i.push(...F(u.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var ge=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function S(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:S(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Gn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function xe(t,n,r){Gn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const be=te().createElement("output");function ve(t,n){const e=ne(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ue(A(t,function(e){return e!==t&&s(ue(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return ue(A(e,function(e){return a(ue(e),t)!=null}))}function Se(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ee(e){return Q.config.attributesToSettle.includes(e)}function Ce(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Jn(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){H(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||te();let n="#"+CSS.escape(ee(o,"id"));let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);je(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){b(e,Q.config.addedClass);Ft(ue(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Le(t){let n=0;for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}return n}function De(t){const n=oe(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];xe(t,r.event,r.listener)}delete n.onHandlers}}function Pe(e){const t=oe(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){ie(t.listenerInfos,function(e){if(e.on){xe(e.on,e.trigger,e.listener)}})}De(e);ie(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function E(e){ae(e,"htmx:beforeCleanupElement");Pe(e);ie(e.children,function(e){E(e)})}function ke(t,e,n){if(t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}u(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}E(t);t.remove()}function Me(e,t,n){return u(e,e.firstChild,t,n)}function Fe(e,t,n){return u(c(e),e,t,n)}function Be(e,t,n){return u(e,null,t,n)}function Xe(e,t,n){return u(c(e),e.nextSibling,t,n)}function Ue(e){E(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;u(e,r,t,n);if(r){while(r.nextSibling){E(r.nextSibling);e.removeChild(r.nextSibling)}E(r);e.removeChild(r)}}function je(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":ke(n,r,o);return;case"afterbegin":Me(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Xe(n,r,o);return;case"delete":Ue(n);return;default:var i=Jn(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const u=l[e];if(u.nodeType!==Node.TEXT_NODE&&u.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(u))}}}return}}catch(e){H(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{je(Q.config.defaultSwapStyle,e,n,r,o)}}}function $e(e,n,r){var t=y(e,"[hx-swap-oob], [data-hx-swap-oob]");ie(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=a(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function _e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=S(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t<i.length;t++){const s=i[t].split(":",2);let e=s[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const l=s[1]||"true";const u=n.querySelector("#"+e);if(u){He(l,u,o,r)}}}$e(n,o,r);ie(y(n,"template"),function(e){if(e.content&&$e(e.content,o,r)){e.remove()}});if(g.select){const c=te().createDocumentFragment();ie(n.querySelectorAll(g.select),function(e){c.appendChild(e)});n=c}Re(n);je(p.swapStyle,g.contextElement,h,n,o);Te()}if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){const f=document.getElementById(ee(t.elt,"id"));const a={preventScroll:p.focusScroll!==undefined?!p.focusScroll:!Q.config.defaultFocusScroll};if(f){if(t.start&&f.setSelectionRange){try{f.setSelectionRange(t.start,t.end)}catch(e){}}f.focus(a)}}b(h,Q.config.swappingClass);ie(o.elts,function(e){if(e.classList){w(e,Q.config.settlingClass)}ae(e,"htmx:afterSwap",g.eventInfo)});re(g.afterSwapCallback);if(!p.ignoreTitle){Xn(o.title)}const n=function(){ie(o.tasks,function(e){e.call()});ie(o.elts,function(e){if(e.classList){b(e,Q.config.settlingClass)}ae(e,"htmx:afterSettle",g.eventInfo)});if(g.anchor){const e=ue(S("#"+g.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}En(o.elts,p);re(g.afterSettleCallback);re(m)};if(p.settleDelay>0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){ae(n,s[e].trim(),[])}}}const Je=/\s/;const C=/[\s,]/;const Ke=/[_$a-zA-Z]/;const Ge=/[_$a-zA-Z0-9]/;const We=['"',"'","/"];const Ze=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ke.exec(e.charAt(n))){var r=n;while(Ge.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(We.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ke.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,C)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,Ze);const l=o.length;const u=O(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};O(o,Ze);c.pollInterval=d(O(o,/[,\[\s]/));O(o,Ze);var i=nt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,Ze);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,C))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,C);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,C))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,C)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,C)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ut(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function ct(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ct(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,u,e,c,f){const a=oe(l);let t;if(c.from){t=m(l,c.from)}else{t=[l]}if(c.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(c)){a.lastValue.set(c,new WeakMap)}a.lastValue.get(c).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(c.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(c,l,e)){return}const t=oe(e);t.triggerSpec=c;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(c.consume){e.stopPropagation()}if(c.target&&e.target){if(!h(ue(e.target),c.target)){return}}if(c.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(c.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(c);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(c.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");u(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},c.throttle)}}else if(c.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");u(l,e)},c.delay)}else{ae(l,"htmx:trigger");u(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:c.trigger,listener:s,on:i});i.addEventListener(c.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){ae(r,"intersect");break}}},o);i.observe(ue(r));gt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!pt(e,r,Xt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ut(ue(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Ht(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Tt(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in jn){const s=jn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(R+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=It(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=It(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ue(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function It(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function Lt(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Dt(t,e,r)}}}}function kt(t){ae(t,"htmx:beforeProcessNode");const n=oe(t);const e=st(t);const r=wt(t,n,e);if(!r){if(ne(t,"hx-boost")==="true"){at(t,n,e)}else if(s(t,"hx-trigger")){e.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){Lt(t)}n.firstInitCompleted=true;ae(t,"htmx:afterProcessNode")}function Mt(e){if(!(e instanceof Element)){return false}const t=oe(e);const n=Le(e);if(t.initHash!==n){Pe(e);t.initHash=n;return true}return false}function Ft(e){e=S(e);if(ft(e)){E(e);return}const t=[];if(Mt(e)){t.push(e)}ie(Tt(e),function(e){if(ft(e)){E(e);return}if(Mt(e)){t.push(e)}});ie(Ht(e),Pt);ie(t,kt)}function Bt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Xt(e,t){return new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}function fe(e,t,n){ae(e,t,le({error:t},n))}function Ut(e){return e==="htmx:afterProcessNode"}function Vt(e,t,n){ie(Jn(e,[],n),function(e){try{t(e)}catch(e){H(e)}})}function H(e){console.error(e)}function ae(e,t,n){e=S(e);if(n==null){n={}}n.elt=e;const r=Xt(t,n);if(Q.logger&&!Ut(t)){Q.logger(e,t,n)}if(n.error){H(n.error+(n.target?", "+n.target:""));ae(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Bt(t);if(o&&i!==t){const s=Xt(i,r.detail);o=o&&e.dispatchEvent(s)}Vt(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let jt;function $t(e){jt=e;if(U()){sessionStorage.setItem("htmx-current-path-for-history",e)}}$t(location.pathname+location.search);function _t(){const e=te().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||te().body}function zt(t,e){if(!U()){return}const n=Kt(e);const r=te().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}t=V(t);const i=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};ae(te().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function Kt(e){const t=Q.config.requestClass;const n=e.cloneNode(true);ie(y(n,"."+t),function(e){b(e,t)});ie(y(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function Gt(){const e=_t();let t=jt;if(U()){t=sessionStorage.getItem("htmx-current-path-for-history")}t=t||location.pathname+location.search;const n=te().querySelector('[hx-history="false" i],[data-hx-history="false" i]');if(!n){ae(te().body,"htmx:beforeHistorySave",{path:t,historyElt:e});zt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},te().title,location.href)}function Wt(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Z(e,"&")||Z(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}$t(e)}function Zt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);$t(e)}function Yt(e){ie(e,function(e){e.call(undefined)})}function Qt(e){const t=new XMLHttpRequest;const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0};const r={path:e,xhr:t,historyElt:_t(),swapSpec:n};t.open("GET",e,true);if(Q.config.historyRestoreAsHxRequest){t.setRequestHeader("HX-Request","true")}t.setRequestHeader("HX-History-Restore-Request","true");t.setRequestHeader("HX-Current-URL",location.href);t.onload=function(){if(this.status>=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);_e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){_e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;w(e,Q.config.requestClass)});return t}function nn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function sn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function ln(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function un(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function cn(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,cn(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,cn(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");ln(f,c.value,o)}const u=ve(e,"hx-include");ie(u,function(e){fn(n,r,i,ue(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const u=l.slice(7);var o=u.split(":");const c=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const u in n){if(n.hasOwnProperty(u)){if(i[u]==null){i[u]=n[u]}}}}return Cn(ue(c(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:S(r)||be,returnPromise:true})}else{let e=S(r.target);if(r.target&&!e||r.source&&!e&&!S(r.source)){e=be}return he(t,n,S(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function In(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Ln(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const u=i.targetOverride||ue(Se(r));if(u==null||u==be){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let c=oe(r);const f=c.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=ue(ce(r,L))}d=(I[1]||"drop").trim();c=oe(h);if(d==="drop"&&c.xhr&&c.abortable!==true){re(s);return e}else if(d==="abort"){if(c.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(c.xhr){if(c.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(p==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;c.xhr=g;c.abortable=B;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:u})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,u,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!Ln(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:u,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=In(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let u=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(u==="false")u=null;const c=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(Fn(n,e.status)){return n}}return{swap:false}}function Xn(e){if(e){const t=f("title");if(t){t.textContent=e}else{window.document.title=e}}}function Un(e,t){if(t==="this"){return e}const n=ue(ce(e,t));if(n==null){fe(e,"htmx:targetError",{target:t});throw new Error(`Invalid re-target ${t}`)}return n}function Vn(t,e){const n=e.xhr;let r=e.target;const o=e.etc;const i=e.select;if(!ae(t,"htmx:beforeOnLoad",e))return;if(T(n,/HX-Trigger:/i)){ze(n,"HX-Trigger",t)}if(T(n,/HX-Location:/i)){let e=n.getResponseHeader("HX-Location");var s={};if(e.indexOf("{")===0){s=v(e);e=s.path;delete s.path}s.push=s.push??"true";Nn("get",e,s);return}const l=T(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(T(n,/HX-Redirect:/i)){e.keepIndicators=true;Q.location.href=n.getResponseHeader("HX-Redirect");l&&Q.location.reload();return}if(l){e.keepIndicators=true;Q.location.reload();return}const u=Mn(t,e);const c=Bn(n);const f=c.swap;let a=!!c.error;let h=Q.config.ignoreTitle||c.ignoreTitle;let d=c.select;if(c.target){e.target=Un(t,c.target)}var p=o.swapOverride;if(p==null&&c.swapOverride){p=c.swapOverride}if(T(n,/HX-Retarget:/i)){e.target=Un(t,n.getResponseHeader("HX-Retarget"))}if(T(n,/HX-Reswap:/i)){p=n.getResponseHeader("HX-Reswap")}var g=n.response;var m=le({shouldSwap:f,serverResponse:g,isError:a,ignoreTitle:h,selectOverride:d,swapOverride:p},e);if(c.event&&!ae(r,c.event,m))return;if(!ae(r,"htmx:beforeSwap",m))return;r=m.target;g=m.serverResponse;a=m.isError;h=m.ignoreTitle;d=m.selectOverride;p=m.swapOverride;e.target=r;e.failed=a;e.successful=!a;if(m.shouldSwap){if(n.status===286){lt(t)}Vt(t,function(e){g=e.transformResponse(g,n,t)});if(u.type){Gt()}var y=bn(t,p);if(!y.hasOwnProperty("ignoreTitle")){y.ignoreTitle=h}w(r,Q.config.swappingClass);if(i){d=i}if(T(n,/HX-Reselect:/i)){d=n.getResponseHeader("HX-Reselect")}const x=o.selectOOB||ne(t,"hx-select-oob");const b=ne(t,"hx-select");_e(r,g,y,{select:d==="unset"?null:d||b,selectOOB:x,eventInfo:e,anchor:e.pathInfo.anchor,contextElement:t,afterSwapCallback:function(){if(T(n,/HX-Trigger-After-Swap:/i)){let e=t;if(!se(t)){e=te().body}ze(n,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(T(n,/HX-Trigger-After-Settle:/i)){let e=t;if(!se(t)){e=te().body}ze(n,"HX-Trigger-After-Settle",e)}},beforeSwapCallback:function(){if(u.type){ae(te().body,"htmx:beforeHistoryUpdate",le({history:u},e));if(u.type==="push"){Wt(u.path);ae(te().body,"htmx:pushedIntoHistory",{path:u.path})}else{Zt(u.path);ae(te().body,"htmx:replacedInHistory",{path:u.path})}}}})}if(a){fe(t,"htmx:responseError",le({error:"Response Status Error Code "+n.status+" from "+e.pathInfo.requestPath},e))}}const jn={};function $n(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function _n(e,t){if(t.init){t.init(n)}jn[e]=le($n(),t)}function zn(e){delete jn[e]}function Jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=a(e,"hx-ext");if(t){ie(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=jn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Jn(ue(c(e)),n,r)}var Kn=false;te().addEventListener("DOMContentLoaded",function(){Kn=true});function Gn(e){if(Kn||te().readyState==="complete"){e()}else{te().addEventListener("DOMContentLoaded",e)}}function Wn(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";const t=Q.config.indicatorClass;const n=Q.config.requestClass;te().head.insertAdjacentHTML("beforeend",`<style${e}>`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"</style>")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
deleted file mode 100644
@@ -1 +0,0 @@
1-var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function ie(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function B(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=S(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=ue(S(e));if(!e){return}if(n){x().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ue(S(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=S(e);e.classList.toggle(t)}function W(e,t){e=S(e);ie(e.parentElement.children,function(e){b(e,t)});w(ue(e),t)}function g(e,t){e=ue(S(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function Y(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=S(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=Y(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),Y(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),Y(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,Y(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,Y(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const u=p(q(t,!!n));i.push(...F(u.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var ge=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function S(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:S(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Gn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function xe(t,n,r){Gn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const be=te().createElement("output");function ve(t,n){const e=ne(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ue(A(t,function(e){return e!==t&&s(ue(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return ue(A(e,function(e){return a(ue(e),t)!=null}))}function Se(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ee(e){return Q.config.attributesToSettle.includes(e)}function Ce(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Jn(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){H(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||te();let n="#"+CSS.escape(ee(o,"id"));let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);je(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){b(e,Q.config.addedClass);Ft(ue(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Le(t){let n=0;for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}return n}function De(t){const n=oe(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];xe(t,r.event,r.listener)}delete n.onHandlers}}function Pe(e){const t=oe(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){ie(t.listenerInfos,function(e){if(e.on){xe(e.on,e.trigger,e.listener)}})}De(e);ie(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function E(e){ae(e,"htmx:beforeCleanupElement");Pe(e);ie(e.children,function(e){E(e)})}function ke(t,e,n){if(t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}u(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}E(t);t.remove()}function Me(e,t,n){return u(e,e.firstChild,t,n)}function Fe(e,t,n){return u(c(e),e,t,n)}function Be(e,t,n){return u(e,null,t,n)}function Xe(e,t,n){return u(c(e),e.nextSibling,t,n)}function Ue(e){E(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;u(e,r,t,n);if(r){while(r.nextSibling){E(r.nextSibling);e.removeChild(r.nextSibling)}E(r);e.removeChild(r)}}function je(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":ke(n,r,o);return;case"afterbegin":Me(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Xe(n,r,o);return;case"delete":Ue(n);return;default:var i=Jn(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const u=l[e];if(u.nodeType!==Node.TEXT_NODE&&u.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(u))}}}return}}catch(e){H(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{je(Q.config.defaultSwapStyle,e,n,r,o)}}}function $e(e,n,r){var t=y(e,"[hx-swap-oob], [data-hx-swap-oob]");ie(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=a(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function _e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=S(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t<i.length;t++){const s=i[t].split(":",2);let e=s[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const l=s[1]||"true";const u=n.querySelector("#"+e);if(u){He(l,u,o,r)}}}$e(n,o,r);ie(y(n,"template"),function(e){if(e.content&&$e(e.content,o,r)){e.remove()}});if(g.select){const c=te().createDocumentFragment();ie(n.querySelectorAll(g.select),function(e){c.appendChild(e)});n=c}Re(n);je(p.swapStyle,g.contextElement,h,n,o);Te()}if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){const f=document.getElementById(ee(t.elt,"id"));const a={preventScroll:p.focusScroll!==undefined?!p.focusScroll:!Q.config.defaultFocusScroll};if(f){if(t.start&&f.setSelectionRange){try{f.setSelectionRange(t.start,t.end)}catch(e){}}f.focus(a)}}b(h,Q.config.swappingClass);ie(o.elts,function(e){if(e.classList){w(e,Q.config.settlingClass)}ae(e,"htmx:afterSwap",g.eventInfo)});re(g.afterSwapCallback);if(!p.ignoreTitle){Xn(o.title)}const n=function(){ie(o.tasks,function(e){e.call()});ie(o.elts,function(e){if(e.classList){b(e,Q.config.settlingClass)}ae(e,"htmx:afterSettle",g.eventInfo)});if(g.anchor){const e=ue(S("#"+g.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}En(o.elts,p);re(g.afterSettleCallback);re(m)};if(p.settleDelay>0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){ae(n,s[e].trim(),[])}}}const Je=/\s/;const C=/[\s,]/;const Ke=/[_$a-zA-Z]/;const Ge=/[_$a-zA-Z0-9]/;const We=['"',"'","/"];const Ze=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ke.exec(e.charAt(n))){var r=n;while(Ge.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(We.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ke.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,C)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,Ze);const l=o.length;const u=O(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};O(o,Ze);c.pollInterval=d(O(o,/[,\[\s]/));O(o,Ze);var i=nt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,Ze);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,C))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,C);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,C))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,C)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,C)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ut(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function ct(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ct(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,u,e,c,f){const a=oe(l);let t;if(c.from){t=m(l,c.from)}else{t=[l]}if(c.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(c)){a.lastValue.set(c,new WeakMap)}a.lastValue.get(c).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(c.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(c,l,e)){return}const t=oe(e);t.triggerSpec=c;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(c.consume){e.stopPropagation()}if(c.target&&e.target){if(!h(ue(e.target),c.target)){return}}if(c.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(c.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(c);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(c.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");u(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},c.throttle)}}else if(c.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");u(l,e)},c.delay)}else{ae(l,"htmx:trigger");u(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:c.trigger,listener:s,on:i});i.addEventListener(c.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){ae(r,"intersect");break}}},o);i.observe(ue(r));gt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!pt(e,r,Xt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ut(ue(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Ht(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Tt(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in jn){const s=jn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(R+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=It(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=It(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ue(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function It(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function Lt(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Dt(t,e,r)}}}}function kt(t){ae(t,"htmx:beforeProcessNode");const n=oe(t);const e=st(t);const r=wt(t,n,e);if(!r){if(ne(t,"hx-boost")==="true"){at(t,n,e)}else if(s(t,"hx-trigger")){e.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){Lt(t)}n.firstInitCompleted=true;ae(t,"htmx:afterProcessNode")}function Mt(e){if(!(e instanceof Element)){return false}const t=oe(e);const n=Le(e);if(t.initHash!==n){Pe(e);t.initHash=n;return true}return false}function Ft(e){e=S(e);if(ft(e)){E(e);return}const t=[];if(Mt(e)){t.push(e)}ie(Tt(e),function(e){if(ft(e)){E(e);return}if(Mt(e)){t.push(e)}});ie(Ht(e),Pt);ie(t,kt)}function Bt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Xt(e,t){return new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}function fe(e,t,n){ae(e,t,le({error:t},n))}function Ut(e){return e==="htmx:afterProcessNode"}function Vt(e,t,n){ie(Jn(e,[],n),function(e){try{t(e)}catch(e){H(e)}})}function H(e){console.error(e)}function ae(e,t,n){e=S(e);if(n==null){n={}}n.elt=e;const r=Xt(t,n);if(Q.logger&&!Ut(t)){Q.logger(e,t,n)}if(n.error){H(n.error+(n.target?", "+n.target:""));ae(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Bt(t);if(o&&i!==t){const s=Xt(i,r.detail);o=o&&e.dispatchEvent(s)}Vt(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let jt;function $t(e){jt=e;if(U()){sessionStorage.setItem("htmx-current-path-for-history",e)}}$t(location.pathname+location.search);function _t(){const e=te().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||te().body}function zt(t,e){if(!U()){return}const n=Kt(e);const r=te().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}t=V(t);const i=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};ae(te().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function Kt(e){const t=Q.config.requestClass;const n=e.cloneNode(true);ie(y(n,"."+t),function(e){b(e,t)});ie(y(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function Gt(){const e=_t();let t=jt;if(U()){t=sessionStorage.getItem("htmx-current-path-for-history")}t=t||location.pathname+location.search;const n=te().querySelector('[hx-history="false" i],[data-hx-history="false" i]');if(!n){ae(te().body,"htmx:beforeHistorySave",{path:t,historyElt:e});zt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},te().title,location.href)}function Wt(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Z(e,"&")||Z(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}$t(e)}function Zt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);$t(e)}function Yt(e){ie(e,function(e){e.call(undefined)})}function Qt(e){const t=new XMLHttpRequest;const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0};const r={path:e,xhr:t,historyElt:_t(),swapSpec:n};t.open("GET",e,true);if(Q.config.historyRestoreAsHxRequest){t.setRequestHeader("HX-Request","true")}t.setRequestHeader("HX-History-Restore-Request","true");t.setRequestHeader("HX-Current-URL",location.href);t.onload=function(){if(this.status>=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);_e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){_e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;w(e,Q.config.requestClass)});return t}function nn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function sn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function ln(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function un(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function cn(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,cn(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,cn(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");ln(f,c.value,o)}const u=ve(e,"hx-include");ie(u,function(e){fn(n,r,i,ue(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const u=l.slice(7);var o=u.split(":");const c=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const u in n){if(n.hasOwnProperty(u)){if(i[u]==null){i[u]=n[u]}}}}return Cn(ue(c(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:S(r)||be,returnPromise:true})}else{let e=S(r.target);if(r.target&&!e||r.source&&!e&&!S(r.source)){e=be}return he(t,n,S(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function In(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Ln(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const u=i.targetOverride||ue(Se(r));if(u==null||u==be){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let c=oe(r);const f=c.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=ue(ce(r,L))}d=(I[1]||"drop").trim();c=oe(h);if(d==="drop"&&c.xhr&&c.abortable!==true){re(s);return e}else if(d==="abort"){if(c.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(c.xhr){if(c.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(p==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;c.xhr=g;c.abortable=B;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:u})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,u,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!Ln(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:u,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=In(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let u=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(u==="false")u=null;const c=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(Fn(n,e.status)){return n}}return{swap:false}}function Xn(e){if(e){const t=f("title");if(t){t.textContent=e}else{window.document.title=e}}}function Un(e,t){if(t==="this"){return e}const n=ue(ce(e,t));if(n==null){fe(e,"htmx:targetError",{target:t});throw new Error(`Invalid re-target ${t}`)}return n}function Vn(t,e){const n=e.xhr;let r=e.target;const o=e.etc;const i=e.select;if(!ae(t,"htmx:beforeOnLoad",e))return;if(T(n,/HX-Trigger:/i)){ze(n,"HX-Trigger",t)}if(T(n,/HX-Location:/i)){let e=n.getResponseHeader("HX-Location");var s={};if(e.indexOf("{")===0){s=v(e);e=s.path;delete s.path}s.push=s.push??"true";Nn("get",e,s);return}const l=T(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(T(n,/HX-Redirect:/i)){e.keepIndicators=true;Q.location.href=n.getResponseHeader("HX-Redirect");l&&Q.location.reload();return}if(l){e.keepIndicators=true;Q.location.reload();return}const u=Mn(t,e);const c=Bn(n);const f=c.swap;let a=!!c.error;let h=Q.config.ignoreTitle||c.ignoreTitle;let d=c.select;if(c.target){e.target=Un(t,c.target)}var p=o.swapOverride;if(p==null&&c.swapOverride){p=c.swapOverride}if(T(n,/HX-Retarget:/i)){e.target=Un(t,n.getResponseHeader("HX-Retarget"))}if(T(n,/HX-Reswap:/i)){p=n.getResponseHeader("HX-Reswap")}var g=n.response;var m=le({shouldSwap:f,serverResponse:g,isError:a,ignoreTitle:h,selectOverride:d,swapOverride:p},e);if(c.event&&!ae(r,c.event,m))return;if(!ae(r,"htmx:beforeSwap",m))return;r=m.target;g=m.serverResponse;a=m.isError;h=m.ignoreTitle;d=m.selectOverride;p=m.swapOverride;e.target=r;e.failed=a;e.successful=!a;if(m.shouldSwap){if(n.status===286){lt(t)}Vt(t,function(e){g=e.transformResponse(g,n,t)});if(u.type){Gt()}var y=bn(t,p);if(!y.hasOwnProperty("ignoreTitle")){y.ignoreTitle=h}w(r,Q.config.swappingClass);if(i){d=i}if(T(n,/HX-Reselect:/i)){d=n.getResponseHeader("HX-Reselect")}const x=o.selectOOB||ne(t,"hx-select-oob");const b=ne(t,"hx-select");_e(r,g,y,{select:d==="unset"?null:d||b,selectOOB:x,eventInfo:e,anchor:e.pathInfo.anchor,contextElement:t,afterSwapCallback:function(){if(T(n,/HX-Trigger-After-Swap:/i)){let e=t;if(!se(t)){e=te().body}ze(n,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(T(n,/HX-Trigger-After-Settle:/i)){let e=t;if(!se(t)){e=te().body}ze(n,"HX-Trigger-After-Settle",e)}},beforeSwapCallback:function(){if(u.type){ae(te().body,"htmx:beforeHistoryUpdate",le({history:u},e));if(u.type==="push"){Wt(u.path);ae(te().body,"htmx:pushedIntoHistory",{path:u.path})}else{Zt(u.path);ae(te().body,"htmx:replacedInHistory",{path:u.path})}}}})}if(a){fe(t,"htmx:responseError",le({error:"Response Status Error Code "+n.status+" from "+e.pathInfo.requestPath},e))}}const jn={};function $n(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function _n(e,t){if(t.init){t.init(n)}jn[e]=le($n(),t)}function zn(e){delete jn[e]}function Jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=a(e,"hx-ext");if(t){ie(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=jn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Jn(ue(c(e)),n,r)}var Kn=false;te().addEventListener("DOMContentLoaded",function(){Kn=true});function Gn(e){if(Kn||te().readyState==="complete"){e()}else{te().addEventListener("DOMContentLoaded",e)}}function Wn(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";const t=Q.config.indicatorClass;const n=Q.config.requestClass;te().head.insertAdjacentHTML("beforeend",`<style${e}>`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"</style>")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file\ No newline at end of file
deleted static/input.css +0 -164
deleted file mode 100644
@@ -1,164 +0,0 @@
1-@tailwind base;
2-@tailwind components;
3-@tailwind utilities;
4-
5-@layer base {
6- :root {
7- color-scheme: light dark;
8- --spot-bg: #f2f0eb;
9- --spot-surface: #fbfaf8;
10- --spot-hover: #edebe9;
11- --spot-hover-50: rgba(237,235,233,0.5);
12- --spot-text: rgba(0,0,0,0.87);
13- --spot-secondary: rgba(0,0,0,0.58);
14- --spot-body: rgba(0,0,0,0.70);
15- --spot-muted: rgba(0,0,0,0.25);
16- --spot-divider: rgba(0,0,0,0.08);
17- --spot-divider-30: rgba(0,0,0,0.12);
18- --spot-outline: rgba(0,0,0,0.15);
19- --spot-placeholder: rgba(0,0,0,0.30);
20- --spot-active-bg: #1E3932;
21- --spot-active-text: #ffffff;
22- --spot-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
23- --spot-shadow-heavy: 0 4px 12px rgba(0,0,0,0.08), 0 12px 28px rgba(0,0,0,0.06);
24- --spot-shadow-elevated: 0 8px 24px rgba(0,0,0,0.10), 0 24px 48px rgba(0,0,0,0.08);
25- }
26-
27- [data-theme="dark"] {
28- color-scheme: dark;
29- --spot-bg: #0a1814;
30- --spot-surface: #152b24;
31- --spot-hover: #1e3c33;
32- --spot-hover-50: rgba(30,60,51,0.5);
33- --spot-text: #f2f2f2;
34- --spot-secondary: rgba(255,255,255,0.78);
35- --spot-body: rgba(255,255,255,0.92);
36- --spot-muted: rgba(255,255,255,0.42);
37- --spot-divider: rgba(255,255,255,0.12);
38- --spot-divider-30: rgba(255,255,255,0.16);
39- --spot-outline: rgba(255,255,255,0.30);
40- --spot-placeholder: rgba(255,255,255,0.45);
41- --spot-active-bg: #ffffff;
42- --spot-active-text: #1E3932;
43- --spot-shadow: 0 1px 2px rgba(0,0,0,0.40), 0 2px 4px rgba(0,0,0,0.20);
44- --spot-shadow-heavy: 0 4px 8px rgba(0,0,0,0.35), 0 12px 24px rgba(0,0,0,0.25);
45- --spot-shadow-elevated: 0 8px 16px rgba(0,0,0,0.40), 0 24px 48px rgba(0,0,0,0.30);
46- }
47-
48- [data-theme="light"] {
49- color-scheme: light;
50- --spot-bg: #f2f0eb;
51- --spot-surface: #fbfaf8;
52- --spot-hover: #edebe9;
53- --spot-hover-50: rgba(237,235,233,0.5);
54- --spot-text: rgba(0,0,0,0.87);
55- --spot-secondary: rgba(0,0,0,0.58);
56- --spot-body: rgba(0,0,0,0.70);
57- --spot-muted: rgba(0,0,0,0.25);
58- --spot-divider: rgba(0,0,0,0.08);
59- --spot-divider-30: rgba(0,0,0,0.12);
60- --spot-outline: rgba(0,0,0,0.15);
61- --spot-placeholder: rgba(0,0,0,0.30);
62- --spot-active-bg: #1E3932;
63- --spot-active-text: #ffffff;
64- --spot-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
65- --spot-shadow-heavy: 0 4px 12px rgba(0,0,0,0.08), 0 12px 28px rgba(0,0,0,0.06);
66- --spot-shadow-elevated: 0 8px 24px rgba(0,0,0,0.10), 0 24px 48px rgba(0,0,0,0.08);
67- }
68-}
69-
70-@layer components {
71- .article-body { max-width: 100%; overflow-wrap: break-word; word-wrap: break-word; }
72- .article-body h1 { @apply text-2xl font-bold mt-8 mb-3 text-spot-text }
73- .article-body h2 { @apply text-xl font-semibold mt-6 mb-2 text-spot-text }
74- .article-body h3 { @apply text-lg font-semibold mt-5 mb-2 text-spot-text }
75- .article-body h4 { @apply text-base font-semibold mt-4 mb-1 text-spot-text }
76- .article-body p { @apply my-3 leading-7 text-spot-body }
77- .article-body ul { @apply list-disc pl-6 my-3 text-spot-body }
78- .article-body ol { @apply list-decimal pl-6 my-3 text-spot-body }
79- .article-body li { @apply my-1 leading-7 }
80- .article-body blockquote { @apply border-l-4 border-spot-divider pl-4 italic text-spot-secondary my-4 }
81- .article-body pre { @apply bg-spot-bg text-spot-body rounded-lg p-4 overflow-x-auto my-4 text-sm leading-6 }
82- .article-body code { @apply bg-spot-surface text-spot-body px-1.5 py-0.5 rounded text-sm font-mono }
83- .article-body pre code { @apply bg-transparent text-spot-body p-0 }
84- .article-body img { @apply rounded-lg max-w-full h-auto my-4 }
85- .article-body figure { @apply my-4 }
86- .article-body figcaption { @apply text-sm text-spot-secondary text-center mt-2 }
87- .article-body a { @apply text-spot-green underline hover:brightness-110 }
88- .article-body table { display: block; overflow-x: auto; @apply w-full border-collapse my-4 text-sm }
89- .article-body th { @apply border border-spot-divider px-3 py-2 bg-spot-surface font-semibold text-left text-spot-text }
90- .article-body td { @apply border border-spot-divider px-3 py-2 text-spot-body }
91- .article-body hr { @apply border-spot-divider my-6 }
92- .article-body iframe, .article-body video { @apply rounded-lg my-4 max-w-full }
93- .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video }
94- .article-body del { @apply line-through text-spot-secondary }
95- .article-body mark { @apply bg-spot-orange/30 px-1 rounded }
96- .article-body .annotation-highlight { @apply bg-spot-green/25 border-b-2 border-spot-green/50 px-0.5 rounded-sm transition-colors }
97- .article-body .annotation-highlight:hover { @apply bg-spot-green/40 }
98- .article-body div, .article-body section, .article-body article, .article-body main, .article-body aside, .article-body nav, .article-body header, .article-body footer, .article-body span { max-width: 100%; overflow-wrap: break-word; }
99-
100- .sidebar-link {
101- transition: color 0.15s, background-color 0.15s;
102- }
103- .sidebar-link:hover {
104- color: var(--spot-text);
105- background: var(--spot-hover-50);
106- }
107- .sidebar-link.active {
108- color: var(--spot-green);
109- background: var(--spot-hover-50);
110- }
111-
112- .htmx-swapping {
113- opacity: 0;
114- transition: opacity 0.3s ease-out;
115- }
116-
117- .animate-fade-in {
118- animation: fadeIn 0.3s ease-out forwards;
119- }
120-
121- .animate-pulse-dot {
122- animation: pulse-dot 2s ease-in-out infinite;
123- }
124-}
125-
126-body {
127- font-family: 'Inter', 'Helvetica Neue', helvetica, arial, sans-serif;
128- letter-spacing: -0.01em;
129- -webkit-font-smoothing: antialiased;
130- -moz-osx-font-smoothing: grayscale;
131-}
132-
133-::-webkit-scrollbar { width: 8px; }
134-::-webkit-scrollbar-track { background: var(--spot-bg); }
135-::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 6px; }
136-::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); }
137-
138-* {
139- scrollbar-width: thin;
140- scrollbar-color: var(--spot-muted) transparent;
141-}
142-
143-::selection {
144- background: rgba(0,117,74,0.30);
145- color: var(--spot-text);
146-}
147-
148-@keyframes fadeIn {
149- from { opacity: 0; transform: translateY(4px); }
150- to { opacity: 1; transform: translateY(0); }
151-}
152-
153-@keyframes pulse-dot {
154- 0%, 100% { opacity: 1; }
155- 50% { opacity: 0.5; }
156-}
157-
158-@media (prefers-reduced-motion: reduce) {
159- *, *::before, *::after {
160- animation-duration: 0.01ms !important;
161- animation-iteration-count: 1 !important;
162- transition-duration: 0.01ms !important;
163- }
164-}
deleted file mode 100644
@@ -1,164 +0,0 @@
1-@tailwind base;
2-@tailwind components;
3-@tailwind utilities;
4-
5-@layer base {
6- :root {
7- color-scheme: light dark;
8- --spot-bg: #f2f0eb;
9- --spot-surface: #fbfaf8;
10- --spot-hover: #edebe9;
11- --spot-hover-50: rgba(237,235,233,0.5);
12- --spot-text: rgba(0,0,0,0.87);
13- --spot-secondary: rgba(0,0,0,0.58);
14- --spot-body: rgba(0,0,0,0.70);
15- --spot-muted: rgba(0,0,0,0.25);
16- --spot-divider: rgba(0,0,0,0.08);
17- --spot-divider-30: rgba(0,0,0,0.12);
18- --spot-outline: rgba(0,0,0,0.15);
19- --spot-placeholder: rgba(0,0,0,0.30);
20- --spot-active-bg: #1E3932;
21- --spot-active-text: #ffffff;
22- --spot-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
23- --spot-shadow-heavy: 0 4px 12px rgba(0,0,0,0.08), 0 12px 28px rgba(0,0,0,0.06);
24- --spot-shadow-elevated: 0 8px 24px rgba(0,0,0,0.10), 0 24px 48px rgba(0,0,0,0.08);
25- }
26-
27- [data-theme="dark"] {
28- color-scheme: dark;
29- --spot-bg: #0a1814;
30- --spot-surface: #152b24;
31- --spot-hover: #1e3c33;
32- --spot-hover-50: rgba(30,60,51,0.5);
33- --spot-text: #f2f2f2;
34- --spot-secondary: rgba(255,255,255,0.78);
35- --spot-body: rgba(255,255,255,0.92);
36- --spot-muted: rgba(255,255,255,0.42);
37- --spot-divider: rgba(255,255,255,0.12);
38- --spot-divider-30: rgba(255,255,255,0.16);
39- --spot-outline: rgba(255,255,255,0.30);
40- --spot-placeholder: rgba(255,255,255,0.45);
41- --spot-active-bg: #ffffff;
42- --spot-active-text: #1E3932;
43- --spot-shadow: 0 1px 2px rgba(0,0,0,0.40), 0 2px 4px rgba(0,0,0,0.20);
44- --spot-shadow-heavy: 0 4px 8px rgba(0,0,0,0.35), 0 12px 24px rgba(0,0,0,0.25);
45- --spot-shadow-elevated: 0 8px 16px rgba(0,0,0,0.40), 0 24px 48px rgba(0,0,0,0.30);
46- }
47-
48- [data-theme="light"] {
49- color-scheme: light;
50- --spot-bg: #f2f0eb;
51- --spot-surface: #fbfaf8;
52- --spot-hover: #edebe9;
53- --spot-hover-50: rgba(237,235,233,0.5);
54- --spot-text: rgba(0,0,0,0.87);
55- --spot-secondary: rgba(0,0,0,0.58);
56- --spot-body: rgba(0,0,0,0.70);
57- --spot-muted: rgba(0,0,0,0.25);
58- --spot-divider: rgba(0,0,0,0.08);
59- --spot-divider-30: rgba(0,0,0,0.12);
60- --spot-outline: rgba(0,0,0,0.15);
61- --spot-placeholder: rgba(0,0,0,0.30);
62- --spot-active-bg: #1E3932;
63- --spot-active-text: #ffffff;
64- --spot-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
65- --spot-shadow-heavy: 0 4px 12px rgba(0,0,0,0.08), 0 12px 28px rgba(0,0,0,0.06);
66- --spot-shadow-elevated: 0 8px 24px rgba(0,0,0,0.10), 0 24px 48px rgba(0,0,0,0.08);
67- }
68-}
69-
70-@layer components {
71- .article-body { max-width: 100%; overflow-wrap: break-word; word-wrap: break-word; }
72- .article-body h1 { @apply text-2xl font-bold mt-8 mb-3 text-spot-text }
73- .article-body h2 { @apply text-xl font-semibold mt-6 mb-2 text-spot-text }
74- .article-body h3 { @apply text-lg font-semibold mt-5 mb-2 text-spot-text }
75- .article-body h4 { @apply text-base font-semibold mt-4 mb-1 text-spot-text }
76- .article-body p { @apply my-3 leading-7 text-spot-body }
77- .article-body ul { @apply list-disc pl-6 my-3 text-spot-body }
78- .article-body ol { @apply list-decimal pl-6 my-3 text-spot-body }
79- .article-body li { @apply my-1 leading-7 }
80- .article-body blockquote { @apply border-l-4 border-spot-divider pl-4 italic text-spot-secondary my-4 }
81- .article-body pre { @apply bg-spot-bg text-spot-body rounded-lg p-4 overflow-x-auto my-4 text-sm leading-6 }
82- .article-body code { @apply bg-spot-surface text-spot-body px-1.5 py-0.5 rounded text-sm font-mono }
83- .article-body pre code { @apply bg-transparent text-spot-body p-0 }
84- .article-body img { @apply rounded-lg max-w-full h-auto my-4 }
85- .article-body figure { @apply my-4 }
86- .article-body figcaption { @apply text-sm text-spot-secondary text-center mt-2 }
87- .article-body a { @apply text-spot-green underline hover:brightness-110 }
88- .article-body table { display: block; overflow-x: auto; @apply w-full border-collapse my-4 text-sm }
89- .article-body th { @apply border border-spot-divider px-3 py-2 bg-spot-surface font-semibold text-left text-spot-text }
90- .article-body td { @apply border border-spot-divider px-3 py-2 text-spot-body }
91- .article-body hr { @apply border-spot-divider my-6 }
92- .article-body iframe, .article-body video { @apply rounded-lg my-4 max-w-full }
93- .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video }
94- .article-body del { @apply line-through text-spot-secondary }
95- .article-body mark { @apply bg-spot-orange/30 px-1 rounded }
96- .article-body .annotation-highlight { @apply bg-spot-green/25 border-b-2 border-spot-green/50 px-0.5 rounded-sm transition-colors }
97- .article-body .annotation-highlight:hover { @apply bg-spot-green/40 }
98- .article-body div, .article-body section, .article-body article, .article-body main, .article-body aside, .article-body nav, .article-body header, .article-body footer, .article-body span { max-width: 100%; overflow-wrap: break-word; }
99-
100- .sidebar-link {
101- transition: color 0.15s, background-color 0.15s;
102- }
103- .sidebar-link:hover {
104- color: var(--spot-text);
105- background: var(--spot-hover-50);
106- }
107- .sidebar-link.active {
108- color: var(--spot-green);
109- background: var(--spot-hover-50);
110- }
111-
112- .htmx-swapping {
113- opacity: 0;
114- transition: opacity 0.3s ease-out;
115- }
116-
117- .animate-fade-in {
118- animation: fadeIn 0.3s ease-out forwards;
119- }
120-
121- .animate-pulse-dot {
122- animation: pulse-dot 2s ease-in-out infinite;
123- }
124-}
125-
126-body {
127- font-family: 'Inter', 'Helvetica Neue', helvetica, arial, sans-serif;
128- letter-spacing: -0.01em;
129- -webkit-font-smoothing: antialiased;
130- -moz-osx-font-smoothing: grayscale;
131-}
132-
133-::-webkit-scrollbar { width: 8px; }
134-::-webkit-scrollbar-track { background: var(--spot-bg); }
135-::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 6px; }
136-::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); }
137-
138-* {
139- scrollbar-width: thin;
140- scrollbar-color: var(--spot-muted) transparent;
141-}
142-
143-::selection {
144- background: rgba(0,117,74,0.30);
145- color: var(--spot-text);
146-}
147-
148-@keyframes fadeIn {
149- from { opacity: 0; transform: translateY(4px); }
150- to { opacity: 1; transform: translateY(0); }
151-}
152-
153-@keyframes pulse-dot {
154- 0%, 100% { opacity: 1; }
155- 50% { opacity: 0.5; }
156-}
157-
158-@media (prefers-reduced-motion: reduce) {
159- *, *::before, *::after {
160- animation-duration: 0.01ms !important;
161- animation-iteration-count: 1 !important;
162- transition-duration: 0.01ms !important;
163- }
164-}
deleted tailwind.config.js +0 -52
deleted file mode 100644
@@ -1,52 +0,0 @@
1-/** @type {import('tailwindcss').Config} */
2-module.exports = {
3- content: ["./internal/tmpl/**/*.html"],
4- theme: {
5- extend: {
6- colors: {
7- spot: {
8- green: '#00754A',
9- 'green-dark': '#006241',
10- 'green-house': '#1E3932',
11- 'green-uplift': '#2b5148',
12- 'green-light': '#d4e9e2',
13- bg: 'var(--spot-bg)',
14- surface: 'var(--spot-surface)',
15- hover: 'var(--spot-hover)',
16- 'hover-50': 'var(--spot-hover-50)',
17- text: 'var(--spot-text)',
18- secondary: 'var(--spot-secondary)',
19- body: 'var(--spot-body)',
20- muted: 'var(--spot-muted)',
21- divider: 'var(--spot-divider)',
22- 'divider-30': 'var(--spot-divider-30)',
23- outline: 'var(--spot-outline)',
24- placeholder: 'var(--spot-placeholder)',
25- 'active-pill-bg': 'var(--spot-active-bg)',
26- 'active-pill-text': 'var(--spot-active-text)',
27- red: '#c82014',
28- orange: '#ffa42b',
29- blue: '#539df5',
30- }
31- },
32- borderRadius: {
33- DEFAULT: '6px',
34- sm: '6px',
35- md: '8px',
36- lg: '10px',
37- xl: '12px',
38- pill: '9999px',
39- full: '50%',
40- },
41- boxShadow: {
42- 'spot': 'var(--spot-shadow)',
43- 'spot-heavy': 'var(--spot-shadow-heavy)',
44- 'spot-elevated': 'var(--spot-shadow-elevated)',
45- },
46- letterSpacing: {
47- button: '1.4px',
48- },
49- },
50- },
51- plugins: [],
52-}
deleted file mode 100644
@@ -1,52 +0,0 @@
1-/** @type {import('tailwindcss').Config} */
2-module.exports = {
3- content: ["./internal/tmpl/**/*.html"],
4- theme: {
5- extend: {
6- colors: {
7- spot: {
8- green: '#00754A',
9- 'green-dark': '#006241',
10- 'green-house': '#1E3932',
11- 'green-uplift': '#2b5148',
12- 'green-light': '#d4e9e2',
13- bg: 'var(--spot-bg)',
14- surface: 'var(--spot-surface)',
15- hover: 'var(--spot-hover)',
16- 'hover-50': 'var(--spot-hover-50)',
17- text: 'var(--spot-text)',
18- secondary: 'var(--spot-secondary)',
19- body: 'var(--spot-body)',
20- muted: 'var(--spot-muted)',
21- divider: 'var(--spot-divider)',
22- 'divider-30': 'var(--spot-divider-30)',
23- outline: 'var(--spot-outline)',
24- placeholder: 'var(--spot-placeholder)',
25- 'active-pill-bg': 'var(--spot-active-bg)',
26- 'active-pill-text': 'var(--spot-active-text)',
27- red: '#c82014',
28- orange: '#ffa42b',
29- blue: '#539df5',
30- }
31- },
32- borderRadius: {
33- DEFAULT: '6px',
34- sm: '6px',
35- md: '8px',
36- lg: '10px',
37- xl: '12px',
38- pill: '9999px',
39- full: '50%',
40- },
41- boxShadow: {
42- 'spot': 'var(--spot-shadow)',
43- 'spot-heavy': 'var(--spot-shadow-heavy)',
44- 'spot-elevated': 'var(--spot-shadow-elevated)',
45- },
46- letterSpacing: {
47- button: '1.4px',
48- },
49- },
50- },
51- plugins: [],
52-}
added web/bun.lock +350 -0
new file mode 100644
@@ -0,0 +1,350 @@
1+{
2+ "lockfileVersion": 1,
3+ "configVersion": 1,
4+ "workspaces": {
5+ "": {
6+ "name": "glean-web",
7+ "devDependencies": {
8+ "@sveltejs/adapter-node": "^5.2.12",
9+ "@sveltejs/kit": "^2.20.0",
10+ "@sveltejs/vite-plugin-svelte": "^5.0.3",
11+ "@tailwindcss/vite": "^4.0.0",
12+ "@types/node": "^26.0.1",
13+ "svelte": "^5.20.0",
14+ "svelte-check": "^4.1.4",
15+ "tailwindcss": "^4.0.0",
16+ "typescript": "^5.7.0",
17+ "vite": "^6.1.0",
18+ },
19+ },
20+ },
21+ "packages": {
22+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
23+
24+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
25+
26+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
27+
28+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
29+
30+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
31+
32+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
33+
34+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
35+
36+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
37+
38+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
39+
40+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
41+
42+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
43+
44+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
45+
46+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
47+
48+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
49+
50+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
51+
52+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
53+
54+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
55+
56+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
57+
58+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
59+
60+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
61+
62+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
63+
64+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
65+
66+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
67+
68+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
69+
70+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
71+
72+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
73+
74+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
75+
76+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
77+
78+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
79+
80+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
81+
82+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
83+
84+ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
85+
86+ "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="],
87+
88+ "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
89+
90+ "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
91+
92+ "@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="],
93+
94+ "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
95+
96+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
97+
98+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
99+
100+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
101+
102+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
103+
104+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
105+
106+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
107+
108+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
109+
110+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
111+
112+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
113+
114+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
115+
116+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
117+
118+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
119+
120+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
121+
122+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
123+
124+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
125+
126+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
127+
128+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
129+
130+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
131+
132+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
133+
134+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
135+
136+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
137+
138+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
139+
140+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
141+
142+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
143+
144+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
145+
146+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
147+
148+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
149+
150+ "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.7", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g=="],
151+
152+ "@sveltejs/kit": ["@sveltejs/kit@2.68.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g=="],
153+
154+ "@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="],
155+
156+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
157+
158+ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
159+
160+ "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
161+
162+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
163+
164+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="],
165+
166+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
167+
168+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
169+
170+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
171+
172+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
173+
174+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
175+
176+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
177+
178+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
179+
180+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
181+
182+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="],
183+
184+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
185+
186+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
187+
188+ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="],
189+
190+ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
191+
192+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
193+
194+ "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
195+
196+ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
197+
198+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
199+
200+ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
201+
202+ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
203+
204+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
205+
206+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
207+
208+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
209+
210+ "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
211+
212+ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
213+
214+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
215+
216+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
217+
218+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
219+
220+ "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
221+
222+ "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
223+
224+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
225+
226+ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
227+
228+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
229+
230+ "esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="],
231+
232+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
233+
234+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
235+
236+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
237+
238+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
239+
240+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
241+
242+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
243+
244+ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
245+
246+ "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
247+
248+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
249+
250+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
251+
252+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
253+
254+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
255+
256+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
257+
258+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
259+
260+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
261+
262+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
263+
264+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
265+
266+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
267+
268+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
269+
270+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
271+
272+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
273+
274+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
275+
276+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
277+
278+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
279+
280+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
281+
282+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
283+
284+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
285+
286+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
287+
288+ "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
289+
290+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
291+
292+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
293+
294+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
295+
296+ "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
297+
298+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
299+
300+ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
301+
302+ "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
303+
304+ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
305+
306+ "set-cookie-parser": ["set-cookie-parser@3.1.1", "", {}, "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA=="],
307+
308+ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
309+
310+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
311+
312+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
313+
314+ "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="],
315+
316+ "svelte-check": ["svelte-check@4.7.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg=="],
317+
318+ "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
319+
320+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
321+
322+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
323+
324+ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
325+
326+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
327+
328+ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
329+
330+ "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
331+
332+ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
333+
334+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
335+
336+ "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
337+
338+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
339+
340+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
341+
342+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
343+
344+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
345+
346+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
347+
348+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
349+ }
350+}
new file mode 100644
@@ -0,0 +1,350 @@
1+{
2+ "lockfileVersion": 1,
3+ "configVersion": 1,
4+ "workspaces": {
5+ "": {
6+ "name": "glean-web",
7+ "devDependencies": {
8+ "@sveltejs/adapter-node": "^5.2.12",
9+ "@sveltejs/kit": "^2.20.0",
10+ "@sveltejs/vite-plugin-svelte": "^5.0.3",
11+ "@tailwindcss/vite": "^4.0.0",
12+ "@types/node": "^26.0.1",
13+ "svelte": "^5.20.0",
14+ "svelte-check": "^4.1.4",
15+ "tailwindcss": "^4.0.0",
16+ "typescript": "^5.7.0",
17+ "vite": "^6.1.0",
18+ },
19+ },
20+ },
21+ "packages": {
22+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
23+
24+ "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
25+
26+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
27+
28+ "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
29+
30+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
31+
32+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
33+
34+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
35+
36+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
37+
38+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
39+
40+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
41+
42+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
43+
44+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
45+
46+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
47+
48+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
49+
50+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
51+
52+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
53+
54+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
55+
56+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
57+
58+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
59+
60+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
61+
62+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
63+
64+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
65+
66+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
67+
68+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
69+
70+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
71+
72+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
73+
74+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
75+
76+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
77+
78+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
79+
80+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
81+
82+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
83+
84+ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
85+
86+ "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg=="],
87+
88+ "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
89+
90+ "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
91+
92+ "@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="],
93+
94+ "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
95+
96+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
97+
98+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
99+
100+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
101+
102+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
103+
104+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
105+
106+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
107+
108+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
109+
110+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
111+
112+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
113+
114+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
115+
116+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
117+
118+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
119+
120+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
121+
122+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
123+
124+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
125+
126+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
127+
128+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
129+
130+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
131+
132+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
133+
134+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
135+
136+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
137+
138+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
139+
140+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
141+
142+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
143+
144+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
145+
146+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
147+
148+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
149+
150+ "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.7", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g=="],
151+
152+ "@sveltejs/kit": ["@sveltejs/kit@2.68.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g=="],
153+
154+ "@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="],
155+
156+ "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
157+
158+ "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
159+
160+ "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
161+
162+ "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="],
163+
164+ "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="],
165+
166+ "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="],
167+
168+ "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="],
169+
170+ "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="],
171+
172+ "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="],
173+
174+ "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="],
175+
176+ "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="],
177+
178+ "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="],
179+
180+ "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="],
181+
182+ "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="],
183+
184+ "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="],
185+
186+ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="],
187+
188+ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="],
189+
190+ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
191+
192+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
193+
194+ "@types/node": ["@types/node@26.0.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw=="],
195+
196+ "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
197+
198+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
199+
200+ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
201+
202+ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
203+
204+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
205+
206+ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
207+
208+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
209+
210+ "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
211+
212+ "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
213+
214+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
215+
216+ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
217+
218+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
219+
220+ "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
221+
222+ "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
223+
224+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
225+
226+ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
227+
228+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
229+
230+ "esrap": ["esrap@2.2.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="],
231+
232+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
233+
234+ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
235+
236+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
237+
238+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
239+
240+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
241+
242+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
243+
244+ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
245+
246+ "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
247+
248+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
249+
250+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
251+
252+ "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
253+
254+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
255+
256+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
257+
258+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
259+
260+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
261+
262+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
263+
264+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
265+
266+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
267+
268+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
269+
270+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
271+
272+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
273+
274+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
275+
276+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
277+
278+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
279+
280+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
281+
282+ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
283+
284+ "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
285+
286+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
287+
288+ "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="],
289+
290+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
291+
292+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
293+
294+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
295+
296+ "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
297+
298+ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
299+
300+ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
301+
302+ "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
303+
304+ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
305+
306+ "set-cookie-parser": ["set-cookie-parser@3.1.1", "", {}, "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA=="],
307+
308+ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
309+
310+ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
311+
312+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
313+
314+ "svelte": ["svelte@5.56.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="],
315+
316+ "svelte-check": ["svelte-check@4.7.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg=="],
317+
318+ "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
319+
320+ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
321+
322+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
323+
324+ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
325+
326+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
327+
328+ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
329+
330+ "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
331+
332+ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
333+
334+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
335+
336+ "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
337+
338+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
339+
340+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
341+
342+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
343+
344+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
345+
346+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
347+
348+ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
349+ }
350+}
added web/package.json +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+{
2+ "name": "glean-web",
3+ "version": "0.0.1",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite dev",
8+ "build": "vite build",
9+ "preview": "vite preview",
10+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
11+ },
12+ "devDependencies": {
13+ "@sveltejs/adapter-node": "^5.2.12",
14+ "@sveltejs/kit": "^2.20.0",
15+ "@sveltejs/vite-plugin-svelte": "^5.0.3",
16+ "@tailwindcss/vite": "^4.0.0",
17+ "@types/node": "^26.0.1",
18+ "svelte": "^5.20.0",
19+ "svelte-check": "^4.1.4",
20+ "tailwindcss": "^4.0.0",
21+ "typescript": "^5.7.0",
22+ "vite": "^6.1.0"
23+ }
24+}
new file mode 100644
@@ -0,0 +1,24 @@
1+{
2+ "name": "glean-web",
3+ "version": "0.0.1",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite dev",
8+ "build": "vite build",
9+ "preview": "vite preview",
10+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
11+ },
12+ "devDependencies": {
13+ "@sveltejs/adapter-node": "^5.2.12",
14+ "@sveltejs/kit": "^2.20.0",
15+ "@sveltejs/vite-plugin-svelte": "^5.0.3",
16+ "@tailwindcss/vite": "^4.0.0",
17+ "@types/node": "^26.0.1",
18+ "svelte": "^5.20.0",
19+ "svelte-check": "^4.1.4",
20+ "tailwindcss": "^4.0.0",
21+ "typescript": "^5.7.0",
22+ "vite": "^6.1.0"
23+ }
24+}
added web/readme.md +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+# Glean web (SvelteKit)
2+
3+SSR frontend for Glean. Talks to the Go JSON API (see `../internal/server`).
4+
5+## Develop
6+
7+```bash
8+bun install
9+# Start the Go API on :8080 first (make dev-api), then:
10+GLEAN_API_URL=http://localhost:8080 bun run dev
11+```
12+
13+Open http://localhost:3000.
14+
15+## How it talks to the API
16+
17+- `src/hooks.server.ts` proxies every `/api/*` request to the Go server
18+ (`GLEAN_API_URL`), forwarding cookies and headers. The same hook also loads
19+ the current user for the layout.
20+- `src/lib/api.ts` wraps the endpoints. Server load functions use
21+ `endpointsFor(event.fetch)` so SvelteKit's per-request fetch resolves relative
22+ `/api` URLs during SSR; browser code uses the default `endpoints`.
23+
24+## Build
25+
26+```bash
27+bun run build # adapter-node output in ./build
28+```
new file mode 100644
@@ -0,0 +1,28 @@
1+# Glean web (SvelteKit)
2+
3+SSR frontend for Glean. Talks to the Go JSON API (see `../internal/server`).
4+
5+## Develop
6+
7+```bash
8+bun install
9+# Start the Go API on :8080 first (make dev-api), then:
10+GLEAN_API_URL=http://localhost:8080 bun run dev
11+```
12+
13+Open http://localhost:3000.
14+
15+## How it talks to the API
16+
17+- `src/hooks.server.ts` proxies every `/api/*` request to the Go server
18+ (`GLEAN_API_URL`), forwarding cookies and headers. The same hook also loads
19+ the current user for the layout.
20+- `src/lib/api.ts` wraps the endpoints. Server load functions use
21+ `endpointsFor(event.fetch)` so SvelteKit's per-request fetch resolves relative
22+ `/api` URLs during SSR; browser code uses the default `endpoints`.
23+
24+## Build
25+
26+```bash
27+bun run build # adapter-node output in ./build
28+```
added web/src/app.css +349 -0
new file mode 100644
@@ -0,0 +1,349 @@
1+@import "tailwindcss";
2+
3+/*
4+ * Glean design system — brutalist / minimal.
5+ * Monochrome base (ink / paper) with a single accent (the brand green).
6+ * Sharp corners, thick borders, hard offset shadows, monospace for data/labels.
7+ */
8+
9+@theme {
10+ --font-sans:
11+ "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular,
12+ monospace;
13+
14+ --color-ink: #0a0a0a;
15+ --color-paper: #fafaf7;
16+ --color-line: #0a0a0a;
17+
18+ --color-accent: #00754a;
19+ --color-accent-ink: #ecfff4;
20+ --color-danger: #c82014;
21+
22+ --color-muted: #6b6b6b;
23+ --color-faint: #c8c8c2;
24+ --color-surface: #f0efe9;
25+
26+ --radius-brutal: 0px;
27+
28+ --shadow-hard: 4px 4px 0 0 var(--color-ink);
29+ --shadow-hard-sm: 2px 2px 0 0 var(--color-ink);
30+}
31+
32+:root {
33+ color-scheme: light dark;
34+ --bg: #fafaf7;
35+ --fg: #0a0a0a;
36+ --surface: #f0efe9;
37+ --border: #0a0a0a;
38+ --muted: #6b6b6b;
39+ --faint: #c8c8c2;
40+ --accent: #00754a;
41+ --accent-ink: #ecfff4;
42+ --danger: #c82014;
43+}
44+
45+[data-theme="dark"] {
46+ color-scheme: dark;
47+ --bg: #0a0a0a;
48+ --fg: #f5f5ef;
49+ --surface: #161616;
50+ --border: #f5f5ef;
51+ --muted: #9a9a9a;
52+ --faint: #3a3a3a;
53+ --accent: #00754a;
54+ --accent-ink: #062018;
55+ --danger: #ff5a4d;
56+}
57+
58+* {
59+ border-color: var(--border);
60+}
61+
62+html,
63+body {
64+ background: var(--bg);
65+ color: var(--fg);
66+ font-family: var(--font-sans);
67+ font-feature-settings: "ss01", "cv01";
68+ -webkit-font-smoothing: antialiased;
69+}
70+
71+::selection {
72+ background: var(--accent);
73+ color: #fff;
74+}
75+
76+::-webkit-scrollbar {
77+ width: 10px;
78+ height: 10px;
79+}
80+::-webkit-scrollbar-track {
81+ background: var(--bg);
82+}
83+::-webkit-scrollbar-thumb {
84+ background: var(--fg);
85+ border: 2px solid var(--bg);
86+}
87+
88+/* ---- Utility component classes ---- */
89+
90+@utility panel {
91+ background: var(--surface);
92+ border: 2px solid var(--border);
93+}
94+
95+@utility panel-press {
96+ transition:
97+ transform 0.08s ease,
98+ box-shadow 0.08s ease;
99+ box-shadow: var(--shadow-hard-sm);
100+ &:hover {
101+ transform: translate(-1px, -1px);
102+ box-shadow: 4px 4px 0 0 var(--border);
103+ }
104+ &:active {
105+ transform: translate(1px, 1px);
106+ box-shadow: 0px 0px 0 0 var(--border);
107+ }
108+}
109+
110+@utility btn {
111+ display: inline-flex;
112+ align-items: center;
113+ justify-content: center;
114+ gap: 0.5rem;
115+ font-weight: 700;
116+ letter-spacing: 0.04em;
117+ text-transform: uppercase;
118+ font-size: 0.72rem;
119+ border: 2px solid var(--border);
120+ padding: 0.5rem 0.9rem;
121+ background: var(--bg);
122+ color: var(--fg);
123+ box-shadow: var(--shadow-hard-sm);
124+ transition:
125+ transform 0.08s ease,
126+ box-shadow 0.08s ease;
127+ cursor: pointer;
128+ &:hover {
129+ transform: translate(-1px, -1px);
130+ box-shadow: 4px 4px 0 0 var(--border);
131+ }
132+ &:active {
133+ transform: translate(1px, 1px);
134+ box-shadow: 0 0 0 0 var(--border);
135+ }
136+ &:disabled {
137+ opacity: 0.5;
138+ cursor: not-allowed;
139+ }
140+}
141+
142+@utility btn-accent {
143+ background: var(--accent);
144+ color: #fff;
145+}
146+
147+@utility btn-ghost {
148+ box-shadow: none;
149+ border-color: transparent;
150+ &:hover {
151+ box-shadow: none;
152+ transform: none;
153+ border-color: var(--border);
154+ }
155+}
156+
157+@utility chip {
158+ display: inline-flex;
159+ align-items: center;
160+ gap: 0.3rem;
161+ font-size: 0.68rem;
162+ font-weight: 600;
163+ letter-spacing: 0.02em;
164+ text-transform: uppercase;
165+ border: 1.5px solid var(--border);
166+ padding: 0.1rem 0.5rem;
167+ background: var(--bg);
168+ &[data-active="true"] {
169+ background: var(--fg);
170+ color: var(--bg);
171+ }
172+}
173+
174+@utility input-brutal {
175+ width: 100%;
176+ background: var(--bg);
177+ border: 2px solid var(--border);
178+ padding: 0.6rem 0.8rem;
179+ font-family: inherit;
180+ font-size: 0.85rem;
181+ color: var(--fg);
182+ outline: none;
183+ &:focus {
184+ box-shadow: var(--shadow-hard-sm);
185+ transform: translate(-1px, -1px);
186+ }
187+ &::placeholder {
188+ color: var(--muted);
189+ }
190+}
191+
192+@utility tag {
193+ display: inline-block;
194+ font-size: 0.65rem;
195+ font-weight: 600;
196+ text-transform: uppercase;
197+ letter-spacing: 0.02em;
198+ border: 1px solid var(--border);
199+ padding: 0.05rem 0.4rem;
200+ background: var(--surface);
201+}
202+
203+@utility kbd-brutal {
204+ display: inline-flex;
205+ align-items: center;
206+ justify-content: center;
207+ min-width: 1.5rem;
208+ height: 1.5rem;
209+ border: 1.5px solid var(--border);
210+ background: var(--bg);
211+ font-size: 0.7rem;
212+ font-weight: 700;
213+}
214+
215+/* ---- Article body (rendered RSS/HTML content) ---- */
216+
217+@utility article-body {
218+ max-width: 100%;
219+ overflow-wrap: break-word;
220+ line-height: 1.7;
221+ font-size: 0.95rem;
222+
223+ & :where(h1) {
224+ font-size: 1.6rem;
225+ font-weight: 800;
226+ margin: 1.8rem 0 0.8rem;
227+ text-transform: uppercase;
228+ letter-spacing: -0.01em;
229+ }
230+ & :where(h2) {
231+ font-size: 1.3rem;
232+ font-weight: 800;
233+ margin: 1.4rem 0 0.6rem;
234+ text-transform: uppercase;
235+ }
236+ & :where(h3) {
237+ font-size: 1.1rem;
238+ font-weight: 700;
239+ margin: 1.1rem 0 0.4rem;
240+ }
241+ & :where(p) {
242+ margin: 0.9rem 0;
243+ }
244+ & :where(ul) {
245+ list-style: square;
246+ padding-left: 1.4rem;
247+ margin: 0.9rem 0;
248+ }
249+ & :where(ol) {
250+ list-style: decimal;
251+ padding-left: 1.4rem;
252+ margin: 0.9rem 0;
253+ }
254+ & :where(li) {
255+ margin: 0.3rem 0;
256+ }
257+ & :where(blockquote) {
258+ border-left: 4px solid var(--accent);
259+ padding: 0.2rem 0 0.2rem 1rem;
260+ margin: 1rem 0;
261+ font-style: italic;
262+ color: var(--muted);
263+ }
264+ & :where(pre) {
265+ background: var(--surface);
266+ border: 2px solid var(--border);
267+ padding: 0.9rem;
268+ overflow-x: auto;
269+ margin: 1rem 0;
270+ font-size: 0.82rem;
271+ }
272+ & :where(code) {
273+ background: var(--surface);
274+ border: 1px solid var(--border);
275+ padding: 0.05rem 0.3rem;
276+ font-size: 0.85em;
277+ }
278+ & :where(pre code) {
279+ border: none;
280+ padding: 0;
281+ background: none;
282+ }
283+ & :where(a) {
284+ color: var(--accent);
285+ text-decoration: underline;
286+ text-underline-offset: 2px;
287+ font-weight: 600;
288+ }
289+ & :where(img) {
290+ max-width: 100%;
291+ height: auto;
292+ border: 2px solid var(--border);
293+ margin: 1rem 0;
294+ }
295+ & :where(table) {
296+ display: block;
297+ overflow-x: auto;
298+ border-collapse: collapse;
299+ margin: 1rem 0;
300+ font-size: 0.85rem;
301+ }
302+ & :where(th),
303+ & :where(td) {
304+ border: 1.5px solid var(--border);
305+ padding: 0.4rem 0.6rem;
306+ text-align: left;
307+ }
308+ & :where(th) {
309+ background: var(--surface);
310+ font-weight: 700;
311+ text-transform: uppercase;
312+ font-size: 0.75rem;
313+ }
314+ & :where(hr) {
315+ border: none;
316+ border-top: 2px solid var(--border);
317+ margin: 1.6rem 0;
318+ }
319+ & :where(iframe, video) {
320+ max-width: 100%;
321+ border: 2px solid var(--border);
322+ margin: 1rem 0;
323+ }
324+ & :where(mark) {
325+ background: var(--accent);
326+ color: #fff;
327+ padding: 0 0.2rem;
328+ }
329+}
330+
331+.annotation-highlight {
332+ background: var(--accent-ink);
333+ border-bottom: 2px solid var(--accent);
334+ padding: 0 0.1rem;
335+ cursor: pointer;
336+ &:hover {
337+ background: var(--accent);
338+ color: #fff;
339+ }
340+}
341+
342+@media (prefers-reduced-motion: reduce) {
343+ *,
344+ *::before,
345+ *::after {
346+ animation-duration: 0.01ms !important;
347+ transition-duration: 0.01ms !important;
348+ }
349+}
new file mode 100644
@@ -0,0 +1,349 @@
1+@import "tailwindcss";
2+
3+/*
4+ * Glean design system — brutalist / minimal.
5+ * Monochrome base (ink / paper) with a single accent (the brand green).
6+ * Sharp corners, thick borders, hard offset shadows, monospace for data/labels.
7+ */
8+
9+@theme {
10+ --font-sans:
11+ "JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular,
12+ monospace;
13+
14+ --color-ink: #0a0a0a;
15+ --color-paper: #fafaf7;
16+ --color-line: #0a0a0a;
17+
18+ --color-accent: #00754a;
19+ --color-accent-ink: #ecfff4;
20+ --color-danger: #c82014;
21+
22+ --color-muted: #6b6b6b;
23+ --color-faint: #c8c8c2;
24+ --color-surface: #f0efe9;
25+
26+ --radius-brutal: 0px;
27+
28+ --shadow-hard: 4px 4px 0 0 var(--color-ink);
29+ --shadow-hard-sm: 2px 2px 0 0 var(--color-ink);
30+}
31+
32+:root {
33+ color-scheme: light dark;
34+ --bg: #fafaf7;
35+ --fg: #0a0a0a;
36+ --surface: #f0efe9;
37+ --border: #0a0a0a;
38+ --muted: #6b6b6b;
39+ --faint: #c8c8c2;
40+ --accent: #00754a;
41+ --accent-ink: #ecfff4;
42+ --danger: #c82014;
43+}
44+
45+[data-theme="dark"] {
46+ color-scheme: dark;
47+ --bg: #0a0a0a;
48+ --fg: #f5f5ef;
49+ --surface: #161616;
50+ --border: #f5f5ef;
51+ --muted: #9a9a9a;
52+ --faint: #3a3a3a;
53+ --accent: #00754a;
54+ --accent-ink: #062018;
55+ --danger: #ff5a4d;
56+}
57+
58+* {
59+ border-color: var(--border);
60+}
61+
62+html,
63+body {
64+ background: var(--bg);
65+ color: var(--fg);
66+ font-family: var(--font-sans);
67+ font-feature-settings: "ss01", "cv01";
68+ -webkit-font-smoothing: antialiased;
69+}
70+
71+::selection {
72+ background: var(--accent);
73+ color: #fff;
74+}
75+
76+::-webkit-scrollbar {
77+ width: 10px;
78+ height: 10px;
79+}
80+::-webkit-scrollbar-track {
81+ background: var(--bg);
82+}
83+::-webkit-scrollbar-thumb {
84+ background: var(--fg);
85+ border: 2px solid var(--bg);
86+}
87+
88+/* ---- Utility component classes ---- */
89+
90+@utility panel {
91+ background: var(--surface);
92+ border: 2px solid var(--border);
93+}
94+
95+@utility panel-press {
96+ transition:
97+ transform 0.08s ease,
98+ box-shadow 0.08s ease;
99+ box-shadow: var(--shadow-hard-sm);
100+ &:hover {
101+ transform: translate(-1px, -1px);
102+ box-shadow: 4px 4px 0 0 var(--border);
103+ }
104+ &:active {
105+ transform: translate(1px, 1px);
106+ box-shadow: 0px 0px 0 0 var(--border);
107+ }
108+}
109+
110+@utility btn {
111+ display: inline-flex;
112+ align-items: center;
113+ justify-content: center;
114+ gap: 0.5rem;
115+ font-weight: 700;
116+ letter-spacing: 0.04em;
117+ text-transform: uppercase;
118+ font-size: 0.72rem;
119+ border: 2px solid var(--border);
120+ padding: 0.5rem 0.9rem;
121+ background: var(--bg);
122+ color: var(--fg);
123+ box-shadow: var(--shadow-hard-sm);
124+ transition:
125+ transform 0.08s ease,
126+ box-shadow 0.08s ease;
127+ cursor: pointer;
128+ &:hover {
129+ transform: translate(-1px, -1px);
130+ box-shadow: 4px 4px 0 0 var(--border);
131+ }
132+ &:active {
133+ transform: translate(1px, 1px);
134+ box-shadow: 0 0 0 0 var(--border);
135+ }
136+ &:disabled {
137+ opacity: 0.5;
138+ cursor: not-allowed;
139+ }
140+}
141+
142+@utility btn-accent {
143+ background: var(--accent);
144+ color: #fff;
145+}
146+
147+@utility btn-ghost {
148+ box-shadow: none;
149+ border-color: transparent;
150+ &:hover {
151+ box-shadow: none;
152+ transform: none;
153+ border-color: var(--border);
154+ }
155+}
156+
157+@utility chip {
158+ display: inline-flex;
159+ align-items: center;
160+ gap: 0.3rem;
161+ font-size: 0.68rem;
162+ font-weight: 600;
163+ letter-spacing: 0.02em;
164+ text-transform: uppercase;
165+ border: 1.5px solid var(--border);
166+ padding: 0.1rem 0.5rem;
167+ background: var(--bg);
168+ &[data-active="true"] {
169+ background: var(--fg);
170+ color: var(--bg);
171+ }
172+}
173+
174+@utility input-brutal {
175+ width: 100%;
176+ background: var(--bg);
177+ border: 2px solid var(--border);
178+ padding: 0.6rem 0.8rem;
179+ font-family: inherit;
180+ font-size: 0.85rem;
181+ color: var(--fg);
182+ outline: none;
183+ &:focus {
184+ box-shadow: var(--shadow-hard-sm);
185+ transform: translate(-1px, -1px);
186+ }
187+ &::placeholder {
188+ color: var(--muted);
189+ }
190+}
191+
192+@utility tag {
193+ display: inline-block;
194+ font-size: 0.65rem;
195+ font-weight: 600;
196+ text-transform: uppercase;
197+ letter-spacing: 0.02em;
198+ border: 1px solid var(--border);
199+ padding: 0.05rem 0.4rem;
200+ background: var(--surface);
201+}
202+
203+@utility kbd-brutal {
204+ display: inline-flex;
205+ align-items: center;
206+ justify-content: center;
207+ min-width: 1.5rem;
208+ height: 1.5rem;
209+ border: 1.5px solid var(--border);
210+ background: var(--bg);
211+ font-size: 0.7rem;
212+ font-weight: 700;
213+}
214+
215+/* ---- Article body (rendered RSS/HTML content) ---- */
216+
217+@utility article-body {
218+ max-width: 100%;
219+ overflow-wrap: break-word;
220+ line-height: 1.7;
221+ font-size: 0.95rem;
222+
223+ & :where(h1) {
224+ font-size: 1.6rem;
225+ font-weight: 800;
226+ margin: 1.8rem 0 0.8rem;
227+ text-transform: uppercase;
228+ letter-spacing: -0.01em;
229+ }
230+ & :where(h2) {
231+ font-size: 1.3rem;
232+ font-weight: 800;
233+ margin: 1.4rem 0 0.6rem;
234+ text-transform: uppercase;
235+ }
236+ & :where(h3) {
237+ font-size: 1.1rem;
238+ font-weight: 700;
239+ margin: 1.1rem 0 0.4rem;
240+ }
241+ & :where(p) {
242+ margin: 0.9rem 0;
243+ }
244+ & :where(ul) {
245+ list-style: square;
246+ padding-left: 1.4rem;
247+ margin: 0.9rem 0;
248+ }
249+ & :where(ol) {
250+ list-style: decimal;
251+ padding-left: 1.4rem;
252+ margin: 0.9rem 0;
253+ }
254+ & :where(li) {
255+ margin: 0.3rem 0;
256+ }
257+ & :where(blockquote) {
258+ border-left: 4px solid var(--accent);
259+ padding: 0.2rem 0 0.2rem 1rem;
260+ margin: 1rem 0;
261+ font-style: italic;
262+ color: var(--muted);
263+ }
264+ & :where(pre) {
265+ background: var(--surface);
266+ border: 2px solid var(--border);
267+ padding: 0.9rem;
268+ overflow-x: auto;
269+ margin: 1rem 0;
270+ font-size: 0.82rem;
271+ }
272+ & :where(code) {
273+ background: var(--surface);
274+ border: 1px solid var(--border);
275+ padding: 0.05rem 0.3rem;
276+ font-size: 0.85em;
277+ }
278+ & :where(pre code) {
279+ border: none;
280+ padding: 0;
281+ background: none;
282+ }
283+ & :where(a) {
284+ color: var(--accent);
285+ text-decoration: underline;
286+ text-underline-offset: 2px;
287+ font-weight: 600;
288+ }
289+ & :where(img) {
290+ max-width: 100%;
291+ height: auto;
292+ border: 2px solid var(--border);
293+ margin: 1rem 0;
294+ }
295+ & :where(table) {
296+ display: block;
297+ overflow-x: auto;
298+ border-collapse: collapse;
299+ margin: 1rem 0;
300+ font-size: 0.85rem;
301+ }
302+ & :where(th),
303+ & :where(td) {
304+ border: 1.5px solid var(--border);
305+ padding: 0.4rem 0.6rem;
306+ text-align: left;
307+ }
308+ & :where(th) {
309+ background: var(--surface);
310+ font-weight: 700;
311+ text-transform: uppercase;
312+ font-size: 0.75rem;
313+ }
314+ & :where(hr) {
315+ border: none;
316+ border-top: 2px solid var(--border);
317+ margin: 1.6rem 0;
318+ }
319+ & :where(iframe, video) {
320+ max-width: 100%;
321+ border: 2px solid var(--border);
322+ margin: 1rem 0;
323+ }
324+ & :where(mark) {
325+ background: var(--accent);
326+ color: #fff;
327+ padding: 0 0.2rem;
328+ }
329+}
330+
331+.annotation-highlight {
332+ background: var(--accent-ink);
333+ border-bottom: 2px solid var(--accent);
334+ padding: 0 0.1rem;
335+ cursor: pointer;
336+ &:hover {
337+ background: var(--accent);
338+ color: #fff;
339+ }
340+}
341+
342+@media (prefers-reduced-motion: reduce) {
343+ *,
344+ *::before,
345+ *::after {
346+ animation-duration: 0.01ms !important;
347+ transition-duration: 0.01ms !important;
348+ }
349+}
added web/src/app.d.ts +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+/// <reference types="@sveltejs/kit" />
2+declare global {
3+ namespace App {
4+ interface Error {
5+ message: string;
6+ }
7+ interface Locals {
8+ user: import('$lib/types').User | null;
9+ csrfToken: string;
10+ }
11+ }
12+}
13+
14+export {};
new file mode 100644
@@ -0,0 +1,14 @@
1+/// <reference types="@sveltejs/kit" />
2+declare global {
3+ namespace App {
4+ interface Error {
5+ message: string;
6+ }
7+ interface Locals {
8+ user: import('$lib/types').User | null;
9+ csrfToken: string;
10+ }
11+ }
12+}
13+
14+export {};
added web/src/app.html +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta
6+ name="viewport"
7+ content="width=device-width, initial-scale=1, maximum-scale=1"
8+ />
9+ <script>
10+ (function () {
11+ var p =
12+ localStorage.getItem("theme") ||
13+ (window.matchMedia("(prefers-color-scheme:dark)").matches
14+ ? "dark"
15+ : "light");
16+ document.documentElement.setAttribute("data-theme", p);
17+ })();
18+ </script>
19+ <link rel="preconnect" href="https://fonts.googleapis.com" />
20+ <link
21+ rel="preconnect"
22+ href="https://fonts.gstatic.com"
23+ crossorigin="anonymous"
24+ />
25+ <link
26+ href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap"
27+ rel="stylesheet"
28+ />
29+ %sveltekit.head%
30+ </head>
31+ <body data-sveltekit-preload-data="hover">
32+ <div style="display: contents">%sveltekit.body%</div>
33+ </body>
34+</html>
new file mode 100644
@@ -0,0 +1,34 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8" />
5+ <meta
6+ name="viewport"
7+ content="width=device-width, initial-scale=1, maximum-scale=1"
8+ />
9+ <script>
10+ (function () {
11+ var p =
12+ localStorage.getItem("theme") ||
13+ (window.matchMedia("(prefers-color-scheme:dark)").matches
14+ ? "dark"
15+ : "light");
16+ document.documentElement.setAttribute("data-theme", p);
17+ })();
18+ </script>
19+ <link rel="preconnect" href="https://fonts.googleapis.com" />
20+ <link
21+ rel="preconnect"
22+ href="https://fonts.gstatic.com"
23+ crossorigin="anonymous"
24+ />
25+ <link
26+ href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap"
27+ rel="stylesheet"
28+ />
29+ %sveltekit.head%
30+ </head>
31+ <body data-sveltekit-preload-data="hover">
32+ <div style="display: contents">%sveltekit.body%</div>
33+ </body>
34+</html>
added web/src/hooks.server.ts +75 -0
new file mode 100644
@@ -0,0 +1,75 @@
1+import type { Handle } from "@sveltejs/kit";
2+
3+const API_URL = process.env.GLEAN_API_URL ?? "http://localhost:8080";
4+
5+async function proxyApi({
6+ request,
7+ url,
8+}: {
9+ request: Request;
10+ url: URL;
11+}): Promise<Response> {
12+ const target = API_URL.replace(/\/$/, "") + url.pathname + url.search;
13+
14+ const headers = new Headers(request.headers);
15+ // Forward the browser's original Host so the Go CSRF same-origin check
16+ // (Origin vs Host) passes. Go routes by path, not Host.
17+ const originHost = request.headers.get("host");
18+ if (originHost) {
19+ headers.set("host", originHost);
20+ headers.set("x-forwarded-host", originHost);
21+ }
22+ const proto = url.protocol.replace(":", "");
23+ headers.set("x-forwarded-proto", proto);
24+
25+ const init: RequestInit = {
26+ method: request.method,
27+ headers,
28+ body:
29+ request.method !== "GET" && request.method !== "HEAD"
30+ ? await request.arrayBuffer()
31+ : undefined,
32+ // @ts-expect-error Node fetch supports duplex for streaming request bodies.
33+ duplex: "half",
34+ };
35+
36+ const upstream = await fetch(target, init);
37+
38+ const respHeaders = new Headers();
39+ for (const [key, value] of upstream.headers.entries()) {
40+ const lower = key.toLowerCase();
41+ if (
42+ lower === "transfer-encoding" ||
43+ lower === "content-encoding" ||
44+ lower === "content-length"
45+ )
46+ continue;
47+ respHeaders.set(key, value);
48+ }
49+
50+ return new Response(upstream.body, {
51+ status: upstream.status,
52+ statusText: upstream.statusText,
53+ headers: respHeaders,
54+ });
55+}
56+
57+export const handle: Handle = async ({ event, resolve }) => {
58+ if (event.url.pathname.startsWith("/api/") || event.url.pathname === "/api") {
59+ return proxyApi(event);
60+ }
61+
62+ // Populate locals for the layout using the per-request fetch.
63+ try {
64+ const res = await event.fetch("/api/me");
65+ if (res.ok) {
66+ const data = await res.json();
67+ event.locals.user = data.user ?? null;
68+ event.locals.csrfToken = data.csrf_token ?? "";
69+ }
70+ } catch {
71+ // API unavailable; continue as anonymous.
72+ }
73+
74+ return resolve(event);
75+};
new file mode 100644
@@ -0,0 +1,75 @@
1+import type { Handle } from "@sveltejs/kit";
2+
3+const API_URL = process.env.GLEAN_API_URL ?? "http://localhost:8080";
4+
5+async function proxyApi({
6+ request,
7+ url,
8+}: {
9+ request: Request;
10+ url: URL;
11+}): Promise<Response> {
12+ const target = API_URL.replace(/\/$/, "") + url.pathname + url.search;
13+
14+ const headers = new Headers(request.headers);
15+ // Forward the browser's original Host so the Go CSRF same-origin check
16+ // (Origin vs Host) passes. Go routes by path, not Host.
17+ const originHost = request.headers.get("host");
18+ if (originHost) {
19+ headers.set("host", originHost);
20+ headers.set("x-forwarded-host", originHost);
21+ }
22+ const proto = url.protocol.replace(":", "");
23+ headers.set("x-forwarded-proto", proto);
24+
25+ const init: RequestInit = {
26+ method: request.method,
27+ headers,
28+ body:
29+ request.method !== "GET" && request.method !== "HEAD"
30+ ? await request.arrayBuffer()
31+ : undefined,
32+ // @ts-expect-error Node fetch supports duplex for streaming request bodies.
33+ duplex: "half",
34+ };
35+
36+ const upstream = await fetch(target, init);
37+
38+ const respHeaders = new Headers();
39+ for (const [key, value] of upstream.headers.entries()) {
40+ const lower = key.toLowerCase();
41+ if (
42+ lower === "transfer-encoding" ||
43+ lower === "content-encoding" ||
44+ lower === "content-length"
45+ )
46+ continue;
47+ respHeaders.set(key, value);
48+ }
49+
50+ return new Response(upstream.body, {
51+ status: upstream.status,
52+ statusText: upstream.statusText,
53+ headers: respHeaders,
54+ });
55+}
56+
57+export const handle: Handle = async ({ event, resolve }) => {
58+ if (event.url.pathname.startsWith("/api/") || event.url.pathname === "/api") {
59+ return proxyApi(event);
60+ }
61+
62+ // Populate locals for the layout using the per-request fetch.
63+ try {
64+ const res = await event.fetch("/api/me");
65+ if (res.ok) {
66+ const data = await res.json();
67+ event.locals.user = data.user ?? null;
68+ event.locals.csrfToken = data.csrf_token ?? "";
69+ }
70+ } catch {
71+ // API unavailable; continue as anonymous.
72+ }
73+
74+ return resolve(event);
75+};
added web/src/lib/api.ts +342 -0
new file mode 100644
@@ -0,0 +1,342 @@
1+import type {
2+ Actor,
3+ Annotation,
4+ Article,
5+ Digest,
6+ Feed,
7+ FeedRecommendation,
8+ KnownLanguage,
9+ MeResponse,
10+ MetricFamily,
11+ Pagination,
12+ PersonRecommendation,
13+ Subscription,
14+ TrendingItem,
15+ User,
16+} from "./types";
17+
18+export class ApiError extends Error {
19+ status: number;
20+ constructor(status: number, message: string) {
21+ super(message);
22+ this.status = status;
23+ }
24+}
25+
26+let cachedCsrf = "";
27+
28+export function setCsrfToken(token: string) {
29+ cachedCsrf = token;
30+}
31+
32+function readCsrfCookie(): string {
33+ if (cachedCsrf) return cachedCsrf;
34+ if (typeof document !== "undefined") {
35+ const match = document.cookie.match(/(?:^|;\s*)glean_csrf=([^;]+)/);
36+ if (match) return decodeURIComponent(match[1]);
37+ }
38+ return "";
39+}
40+
41+type FetchFn = typeof fetch;
42+
43+export const api = {
44+ get: <T>(
45+ path: string,
46+ query?: Record<string, string>,
47+ fetchFn: FetchFn = fetch,
48+ ) => request<T>("GET", path, { query }, fetchFn),
49+ post: <T>(
50+ path: string,
51+ body?: Record<string, unknown>,
52+ fetchFn: FetchFn = fetch,
53+ ) => request<T>("POST", path, { body }, fetchFn),
54+ postForm: <T>(path: string, form: FormData, fetchFn: FetchFn = fetch) =>
55+ request<T>("POST", path, { body: form }, fetchFn),
56+ del: <T>(
57+ path: string,
58+ body?: Record<string, unknown>,
59+ fetchFn: FetchFn = fetch,
60+ ) => request<T>("DELETE", path, { body }, fetchFn),
61+};
62+
63+async function request<T>(
64+ method: string,
65+ path: string,
66+ opts: {
67+ body?: Record<string, unknown> | FormData;
68+ query?: Record<string, string>;
69+ } = {},
70+ fetchFn: FetchFn = fetch,
71+): Promise<T> {
72+ const url = new URL(path, "http://placeholder");
73+ if (opts.query) {
74+ for (const [k, v] of Object.entries(opts.query)) {
75+ if (v !== "" && v != null) url.searchParams.set(k, v);
76+ }
77+ }
78+ const search = url.search;
79+
80+ let body: BodyInit | undefined;
81+ const headers: Record<string, string> = {};
82+ if (opts.body && !(opts.body instanceof FormData)) {
83+ // The Go handlers read inputs via r.FormValue, so send form-encoded bodies.
84+ headers["Content-Type"] = "application/x-www-form-urlencoded";
85+ const params = new URLSearchParams();
86+ for (const [k, v] of Object.entries(opts.body)) {
87+ if (Array.isArray(v)) {
88+ for (const item of v) params.append(k, String(item));
89+ } else if (v !== undefined && v !== null) {
90+ params.set(k, String(v));
91+ }
92+ }
93+ body = params;
94+ } else if (opts.body instanceof FormData) {
95+ body = opts.body;
96+ }
97+
98+ if (method !== "GET" && method !== "HEAD") {
99+ const token = readCsrfCookie();
100+ if (token) headers["X-CSRF-Token"] = token;
101+ }
102+
103+ const res = await fetchFn(`/api${path}${search}`, {
104+ method,
105+ headers,
106+ body,
107+ credentials: "same-origin",
108+ });
109+
110+ if (res.status === 204) return undefined as T;
111+
112+ if (!res.ok) {
113+ let message = res.statusText;
114+ try {
115+ const data = await res.json();
116+ message = data.error ?? message;
117+ } catch {
118+ // keep statusText
119+ }
120+ throw new ApiError(res.status, message);
121+ }
122+
123+ const ct = res.headers.get("content-type") ?? "";
124+ if (ct.includes("application/json")) {
125+ return (await res.json()) as T;
126+ }
127+ return (await res.text()) as unknown as T;
128+}
129+
130+// Typed endpoint helpers.
131+
132+export interface DashboardData {
133+ user: User;
134+ subscription_count: number;
135+ unread_count: number;
136+ articles: Article[];
137+ personal_trending: TrendingItem[];
138+ global_trending: TrendingItem[];
139+ digest_enabled: boolean;
140+ has_llm: boolean;
141+ now: number;
142+}
143+
144+export interface ArticlesData {
145+ user: User;
146+ articles: Article[];
147+ feed_url: string;
148+ status: string;
149+ search_query: string;
150+ sort_oldest: boolean;
151+ category: string;
152+ categories: string[];
153+ expanded_view: boolean;
154+ pagination: Pagination;
155+ now: number;
156+ feed?: Feed;
157+ is_subscribed?: boolean;
158+}
159+
160+export interface ArticleDetailData {
161+ user: User;
162+ current_user_did: string;
163+ article: Article;
164+ feed: Feed;
165+ annotations: Annotation[];
166+ next_id: number | null;
167+ next_suffix: string;
168+}
169+
170+export interface FeedsData {
171+ user: User;
172+ subscriptions: Subscription[];
173+ subscription_count: number;
174+ categories: string[];
175+ category: string;
176+ dead_feeds: Feed[];
177+ pagination: Pagination;
178+}
179+
180+export interface LibraryData {
181+ user: User;
182+ articles: Article[];
183+ annotations: Annotation[];
184+ liked_page: Pagination;
185+ annot_page: Pagination;
186+}
187+
188+export interface TrendingData {
189+ user: User | null;
190+ trending: TrendingItem[];
191+ scope: string;
192+ pagination: Pagination;
193+}
194+
195+export interface ProfileData {
196+ user: User;
197+ profile_user: User;
198+ subscriptions: Subscription[];
199+ annotations: Annotation[];
200+ subscription_count: number;
201+ annotation_count: number;
202+ user_languages: string[];
203+ available_languages: KnownLanguage[];
204+ expanded_view: boolean;
205+ digest_enabled: boolean;
206+}
207+
208+export interface StatsData {
209+ user: User | null;
210+ metrics: Record<string, MetricFamily[]>;
211+}
212+
213+export interface PeopleRecs {
214+ followed: PersonRecommendation[];
215+ discover: PersonRecommendation[];
216+}
217+
218+export interface FeedRecs {
219+ feeds: FeedRecommendation[];
220+ subscription_count: number;
221+}
222+
223+type Endpoints = ReturnType<typeof createEndpoints>;
224+
225+function createEndpoints(fetchFn: FetchFn) {
226+ const a = {
227+ get: <T>(path: string, query?: Record<string, string>) =>
228+ request<T>("GET", path, { query }, fetchFn),
229+ post: <T>(path: string, body?: Record<string, unknown>) =>
230+ request<T>("POST", path, { body }, fetchFn),
231+ postForm: <T>(path: string, form: FormData) =>
232+ request<T>("POST", path, { body: form }, fetchFn),
233+ del: <T>(path: string, body?: Record<string, unknown>) =>
234+ request<T>("DELETE", path, { body }, fetchFn),
235+ };
236+ return {
237+ me: () => a.get<MeResponse>("/me"),
238+ dashboard: () => a.get<DashboardData>("/dashboard"),
239+ articles: (params: Record<string, string>) =>
240+ a.get<ArticlesData>("/articles", params),
241+ article: (id: number, params: Record<string, string>) =>
242+ a.get<ArticleDetailData>(`/articles/${id}`, params),
243+ newArticleCount: (since: number) =>
244+ a.get<{ count: number }>("/articles/new-count", { since: String(since) }),
245+ markRead: (id: number) =>
246+ a.post<{ id: number; is_read: boolean }>(`/articles/${id}/read`),
247+ markUnread: (id: number) =>
248+ a.post<{ id: number; is_read: boolean }>(`/articles/${id}/unread`),
249+ toggleLike: (id: number) =>
250+ a.post<{ id: number; liked: boolean; like_count: number }>(
251+ `/articles/${id}/like`,
252+ ),
253+ fetchContent: (id: number) =>
254+ a.post<{ id: number; full_content: string }>(
255+ `/articles/${id}/fetch-content`,
256+ ),
257+ markAllRead: (feed?: string) =>
258+ a.post<void>("/articles/mark-all-read", feed ? { feed } : {}),
259+
260+ feeds: (category?: string) =>
261+ a.get<FeedsData>("/feeds", category ? { category } : {}),
262+ addFeed: (feed_url: string, category?: string) =>
263+ a.post<{ subscription: Subscription }>("/feeds/add", {
264+ feed_url,
265+ category,
266+ }),
267+ editFeed: (feed_url: string, category: string) =>
268+ a.post<{ subscription: Subscription }>("/feeds/edit", {
269+ feed_url,
270+ category,
271+ }),
272+ removeFeed: (url: string) => a.del<void>("/feeds/remove", { url }),
273+ feedList: (category?: string) =>
274+ a.get<{ subscriptions: Subscription[] }>(
275+ "/feeds/list",
276+ category ? { category } : {},
277+ ),
278+ refreshFeeds: (category?: string) =>
279+ a.post<{ subscriptions: Subscription[] }>(
280+ "/feeds/refresh",
281+ category ? { category } : {},
282+ ),
283+ retryFeed: (url: string) =>
284+ a.post<{ dead_feeds: Feed[] }>("/feeds/retry", { url }),
285+ clearFeeds: () => a.post<void>("/feeds/clear", {}),
286+ uploadOpml: (form: FormData) =>
287+ a.postForm<{ added: number }>("/feeds/opml/upload", form),
288+
289+ trending: (params: Record<string, string>) =>
290+ a.get<TrendingData>("/trending", params),
291+
292+ library: (params: Record<string, string>) =>
293+ a.get<LibraryData>("/library", params),
294+ createAnnotation: (body: Record<string, unknown>) =>
295+ a.post<{ annotation: Annotation }>("/library/create", body),
296+ deleteAnnotation: (id: number) => a.post<void>(`/library/${id}/delete`),
297+
298+ profile: (did: string) => a.get<ProfileData>(`/profile/${did}`),
299+
300+ articleRecs: () => a.get<{ articles: Article[] }>("/recs/articles"),
301+ feedRecs: () => a.get<FeedRecs>("/recs/feeds"),
302+ peopleRecs: () => a.get<PeopleRecs>("/recs/people"),
303+ dismissFeed: (feed_url: string) =>
304+ a.post<void>("/recs/dismiss-feed", { feed_url }),
305+ dismissArticle: (article_url: string) =>
306+ a.post<void>("/recs/dismiss-article", { article_url }),
307+ dismissPerson: (target_did: string) =>
308+ a.post<void>("/recs/dismiss-person", { target_did }),
309+
310+ toggleLanguage: (code: string) =>
311+ a.post<{ languages: string[] }>(`/settings/languages/${code}`),
312+ toggleExpandedView: (expanded_view: boolean) =>
313+ a.post<{ expanded_view: boolean }>("/settings/expanded-view", {
314+ expanded_view: expanded_view ? "1" : "0",
315+ }),
316+ toggleDigest: (digest_enabled: boolean) =>
317+ a.post<{ digest_enabled: boolean }>("/settings/digest-enabled", {
318+ digest_enabled: digest_enabled ? "1" : "0",
319+ }),
320+
321+ digest: () => a.get<Digest | null>("/digest"),
322+ markDigestRead: (ids: number[]) =>
323+ a.post<Digest>("/digest/mark-read", { ids: ids.map(String) }),
324+
325+ authActors: (q: string) =>
326+ a.get<{ actors: Actor[] }>("/auth/actors", { q }),
327+ authStart: (handle: string) =>
328+ a.post<{ redirect: string }>("/auth/start", { handle }),
329+ authRegister: () => a.get<{ redirect: string }>("/auth/register"),
330+ authLogout: () => a.post<{ redirect: string }>("/auth/logout"),
331+
332+ stats: () => a.get<StatsData>("/stats"),
333+ };
334+}
335+
336+// Default endpoints for browser use (uses global fetch).
337+export const endpoints: Endpoints = createEndpoints(fetch);
338+
339+/** Build endpoints bound to a specific fetch (e.g. SvelteKit event.fetch for SSR). */
340+export function endpointsFor(fetchFn: FetchFn): Endpoints {
341+ return createEndpoints(fetchFn);
342+}
new file mode 100644
@@ -0,0 +1,342 @@
1+import type {
2+ Actor,
3+ Annotation,
4+ Article,
5+ Digest,
6+ Feed,
7+ FeedRecommendation,
8+ KnownLanguage,
9+ MeResponse,
10+ MetricFamily,
11+ Pagination,
12+ PersonRecommendation,
13+ Subscription,
14+ TrendingItem,
15+ User,
16+} from "./types";
17+
18+export class ApiError extends Error {
19+ status: number;
20+ constructor(status: number, message: string) {
21+ super(message);
22+ this.status = status;
23+ }
24+}
25+
26+let cachedCsrf = "";
27+
28+export function setCsrfToken(token: string) {
29+ cachedCsrf = token;
30+}
31+
32+function readCsrfCookie(): string {
33+ if (cachedCsrf) return cachedCsrf;
34+ if (typeof document !== "undefined") {
35+ const match = document.cookie.match(/(?:^|;\s*)glean_csrf=([^;]+)/);
36+ if (match) return decodeURIComponent(match[1]);
37+ }
38+ return "";
39+}
40+
41+type FetchFn = typeof fetch;
42+
43+export const api = {
44+ get: <T>(
45+ path: string,
46+ query?: Record<string, string>,
47+ fetchFn: FetchFn = fetch,
48+ ) => request<T>("GET", path, { query }, fetchFn),
49+ post: <T>(
50+ path: string,
51+ body?: Record<string, unknown>,
52+ fetchFn: FetchFn = fetch,
53+ ) => request<T>("POST", path, { body }, fetchFn),
54+ postForm: <T>(path: string, form: FormData, fetchFn: FetchFn = fetch) =>
55+ request<T>("POST", path, { body: form }, fetchFn),
56+ del: <T>(
57+ path: string,
58+ body?: Record<string, unknown>,
59+ fetchFn: FetchFn = fetch,
60+ ) => request<T>("DELETE", path, { body }, fetchFn),
61+};
62+
63+async function request<T>(
64+ method: string,
65+ path: string,
66+ opts: {
67+ body?: Record<string, unknown> | FormData;
68+ query?: Record<string, string>;
69+ } = {},
70+ fetchFn: FetchFn = fetch,
71+): Promise<T> {
72+ const url = new URL(path, "http://placeholder");
73+ if (opts.query) {
74+ for (const [k, v] of Object.entries(opts.query)) {
75+ if (v !== "" && v != null) url.searchParams.set(k, v);
76+ }
77+ }
78+ const search = url.search;
79+
80+ let body: BodyInit | undefined;
81+ const headers: Record<string, string> = {};
82+ if (opts.body && !(opts.body instanceof FormData)) {
83+ // The Go handlers read inputs via r.FormValue, so send form-encoded bodies.
84+ headers["Content-Type"] = "application/x-www-form-urlencoded";
85+ const params = new URLSearchParams();
86+ for (const [k, v] of Object.entries(opts.body)) {
87+ if (Array.isArray(v)) {
88+ for (const item of v) params.append(k, String(item));
89+ } else if (v !== undefined && v !== null) {
90+ params.set(k, String(v));
91+ }
92+ }
93+ body = params;
94+ } else if (opts.body instanceof FormData) {
95+ body = opts.body;
96+ }
97+
98+ if (method !== "GET" && method !== "HEAD") {
99+ const token = readCsrfCookie();
100+ if (token) headers["X-CSRF-Token"] = token;
101+ }
102+
103+ const res = await fetchFn(`/api${path}${search}`, {
104+ method,
105+ headers,
106+ body,
107+ credentials: "same-origin",
108+ });
109+
110+ if (res.status === 204) return undefined as T;
111+
112+ if (!res.ok) {
113+ let message = res.statusText;
114+ try {
115+ const data = await res.json();
116+ message = data.error ?? message;
117+ } catch {
118+ // keep statusText
119+ }
120+ throw new ApiError(res.status, message);
121+ }
122+
123+ const ct = res.headers.get("content-type") ?? "";
124+ if (ct.includes("application/json")) {
125+ return (await res.json()) as T;
126+ }
127+ return (await res.text()) as unknown as T;
128+}
129+
130+// Typed endpoint helpers.
131+
132+export interface DashboardData {
133+ user: User;
134+ subscription_count: number;
135+ unread_count: number;
136+ articles: Article[];
137+ personal_trending: TrendingItem[];
138+ global_trending: TrendingItem[];
139+ digest_enabled: boolean;
140+ has_llm: boolean;
141+ now: number;
142+}
143+
144+export interface ArticlesData {
145+ user: User;
146+ articles: Article[];
147+ feed_url: string;
148+ status: string;
149+ search_query: string;
150+ sort_oldest: boolean;
151+ category: string;
152+ categories: string[];
153+ expanded_view: boolean;
154+ pagination: Pagination;
155+ now: number;
156+ feed?: Feed;
157+ is_subscribed?: boolean;
158+}
159+
160+export interface ArticleDetailData {
161+ user: User;
162+ current_user_did: string;
163+ article: Article;
164+ feed: Feed;
165+ annotations: Annotation[];
166+ next_id: number | null;
167+ next_suffix: string;
168+}
169+
170+export interface FeedsData {
171+ user: User;
172+ subscriptions: Subscription[];
173+ subscription_count: number;
174+ categories: string[];
175+ category: string;
176+ dead_feeds: Feed[];
177+ pagination: Pagination;
178+}
179+
180+export interface LibraryData {
181+ user: User;
182+ articles: Article[];
183+ annotations: Annotation[];
184+ liked_page: Pagination;
185+ annot_page: Pagination;
186+}
187+
188+export interface TrendingData {
189+ user: User | null;
190+ trending: TrendingItem[];
191+ scope: string;
192+ pagination: Pagination;
193+}
194+
195+export interface ProfileData {
196+ user: User;
197+ profile_user: User;
198+ subscriptions: Subscription[];
199+ annotations: Annotation[];
200+ subscription_count: number;
201+ annotation_count: number;
202+ user_languages: string[];
203+ available_languages: KnownLanguage[];
204+ expanded_view: boolean;
205+ digest_enabled: boolean;
206+}
207+
208+export interface StatsData {
209+ user: User | null;
210+ metrics: Record<string, MetricFamily[]>;
211+}
212+
213+export interface PeopleRecs {
214+ followed: PersonRecommendation[];
215+ discover: PersonRecommendation[];
216+}
217+
218+export interface FeedRecs {
219+ feeds: FeedRecommendation[];
220+ subscription_count: number;
221+}
222+
223+type Endpoints = ReturnType<typeof createEndpoints>;
224+
225+function createEndpoints(fetchFn: FetchFn) {
226+ const a = {
227+ get: <T>(path: string, query?: Record<string, string>) =>
228+ request<T>("GET", path, { query }, fetchFn),
229+ post: <T>(path: string, body?: Record<string, unknown>) =>
230+ request<T>("POST", path, { body }, fetchFn),
231+ postForm: <T>(path: string, form: FormData) =>
232+ request<T>("POST", path, { body: form }, fetchFn),
233+ del: <T>(path: string, body?: Record<string, unknown>) =>
234+ request<T>("DELETE", path, { body }, fetchFn),
235+ };
236+ return {
237+ me: () => a.get<MeResponse>("/me"),
238+ dashboard: () => a.get<DashboardData>("/dashboard"),
239+ articles: (params: Record<string, string>) =>
240+ a.get<ArticlesData>("/articles", params),
241+ article: (id: number, params: Record<string, string>) =>
242+ a.get<ArticleDetailData>(`/articles/${id}`, params),
243+ newArticleCount: (since: number) =>
244+ a.get<{ count: number }>("/articles/new-count", { since: String(since) }),
245+ markRead: (id: number) =>
246+ a.post<{ id: number; is_read: boolean }>(`/articles/${id}/read`),
247+ markUnread: (id: number) =>
248+ a.post<{ id: number; is_read: boolean }>(`/articles/${id}/unread`),
249+ toggleLike: (id: number) =>
250+ a.post<{ id: number; liked: boolean; like_count: number }>(
251+ `/articles/${id}/like`,
252+ ),
253+ fetchContent: (id: number) =>
254+ a.post<{ id: number; full_content: string }>(
255+ `/articles/${id}/fetch-content`,
256+ ),
257+ markAllRead: (feed?: string) =>
258+ a.post<void>("/articles/mark-all-read", feed ? { feed } : {}),
259+
260+ feeds: (category?: string) =>
261+ a.get<FeedsData>("/feeds", category ? { category } : {}),
262+ addFeed: (feed_url: string, category?: string) =>
263+ a.post<{ subscription: Subscription }>("/feeds/add", {
264+ feed_url,
265+ category,
266+ }),
267+ editFeed: (feed_url: string, category: string) =>
268+ a.post<{ subscription: Subscription }>("/feeds/edit", {
269+ feed_url,
270+ category,
271+ }),
272+ removeFeed: (url: string) => a.del<void>("/feeds/remove", { url }),
273+ feedList: (category?: string) =>
274+ a.get<{ subscriptions: Subscription[] }>(
275+ "/feeds/list",
276+ category ? { category } : {},
277+ ),
278+ refreshFeeds: (category?: string) =>
279+ a.post<{ subscriptions: Subscription[] }>(
280+ "/feeds/refresh",
281+ category ? { category } : {},
282+ ),
283+ retryFeed: (url: string) =>
284+ a.post<{ dead_feeds: Feed[] }>("/feeds/retry", { url }),
285+ clearFeeds: () => a.post<void>("/feeds/clear", {}),
286+ uploadOpml: (form: FormData) =>
287+ a.postForm<{ added: number }>("/feeds/opml/upload", form),
288+
289+ trending: (params: Record<string, string>) =>
290+ a.get<TrendingData>("/trending", params),
291+
292+ library: (params: Record<string, string>) =>
293+ a.get<LibraryData>("/library", params),
294+ createAnnotation: (body: Record<string, unknown>) =>
295+ a.post<{ annotation: Annotation }>("/library/create", body),
296+ deleteAnnotation: (id: number) => a.post<void>(`/library/${id}/delete`),
297+
298+ profile: (did: string) => a.get<ProfileData>(`/profile/${did}`),
299+
300+ articleRecs: () => a.get<{ articles: Article[] }>("/recs/articles"),
301+ feedRecs: () => a.get<FeedRecs>("/recs/feeds"),
302+ peopleRecs: () => a.get<PeopleRecs>("/recs/people"),
303+ dismissFeed: (feed_url: string) =>
304+ a.post<void>("/recs/dismiss-feed", { feed_url }),
305+ dismissArticle: (article_url: string) =>
306+ a.post<void>("/recs/dismiss-article", { article_url }),
307+ dismissPerson: (target_did: string) =>
308+ a.post<void>("/recs/dismiss-person", { target_did }),
309+
310+ toggleLanguage: (code: string) =>
311+ a.post<{ languages: string[] }>(`/settings/languages/${code}`),
312+ toggleExpandedView: (expanded_view: boolean) =>
313+ a.post<{ expanded_view: boolean }>("/settings/expanded-view", {
314+ expanded_view: expanded_view ? "1" : "0",
315+ }),
316+ toggleDigest: (digest_enabled: boolean) =>
317+ a.post<{ digest_enabled: boolean }>("/settings/digest-enabled", {
318+ digest_enabled: digest_enabled ? "1" : "0",
319+ }),
320+
321+ digest: () => a.get<Digest | null>("/digest"),
322+ markDigestRead: (ids: number[]) =>
323+ a.post<Digest>("/digest/mark-read", { ids: ids.map(String) }),
324+
325+ authActors: (q: string) =>
326+ a.get<{ actors: Actor[] }>("/auth/actors", { q }),
327+ authStart: (handle: string) =>
328+ a.post<{ redirect: string }>("/auth/start", { handle }),
329+ authRegister: () => a.get<{ redirect: string }>("/auth/register"),
330+ authLogout: () => a.post<{ redirect: string }>("/auth/logout"),
331+
332+ stats: () => a.get<StatsData>("/stats"),
333+ };
334+}
335+
336+// Default endpoints for browser use (uses global fetch).
337+export const endpoints: Endpoints = createEndpoints(fetch);
338+
339+/** Build endpoints bound to a specific fetch (e.g. SvelteKit event.fetch for SSR). */
340+export function endpointsFor(fetchFn: FetchFn): Endpoints {
341+ return createEndpoints(fetchFn);
342+}
added web/src/lib/components/AnnotationCard.svelte +166 -0
new file mode 100644
@@ -0,0 +1,166 @@
1+<script lang="ts">
2+ import type { Annotation } from "$lib/types";
3+ import { endpoints } from "$lib/api";
4+ import Icon from "./Icon.svelte";
5+ import { invalidateAll } from "$app/navigation";
6+
7+ interface Props {
8+ annotation: Annotation;
9+ userDID: string;
10+ }
11+ let { annotation, userDID }: Props = $props();
12+
13+ let editing = $state(false);
14+ {
15+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
16+ }
17+ let quote = $state(annotation.quote ?? "");
18+ {
19+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
20+ }
21+ let note = $state(annotation.note ?? "");
22+ {
23+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
24+ }
25+ let tags = $state((annotation.tags ?? []).join(", "));
26+ let saving = $state(false);
27+
28+ const mine = $derived(annotation.author_did === userDID);
29+ const tagsList = $derived(annotation.tags ?? []);
30+
31+ async function saveEdit() {
32+ saving = true;
33+ try {
34+ await endpoints.deleteAnnotation(annotation.id);
35+ await endpoints.createAnnotation({
36+ feed_url: annotation.feed_url,
37+ article_url: annotation.article_url,
38+ quote,
39+ note,
40+ tags,
41+ });
42+ editing = false;
43+ await invalidateAll();
44+ } finally {
45+ saving = false;
46+ }
47+ }
48+
49+ function cancelEdit() {
50+ editing = false;
51+ quote = annotation.quote ?? "";
52+ note = annotation.note ?? "";
53+ tags = (annotation.tags ?? []).join(", ");
54+ }
55+
56+ async function del() {
57+ await endpoints.deleteAnnotation(annotation.id);
58+ await invalidateAll();
59+ }
60+
61+ function when(): string {
62+ if (!annotation.created_at) return "";
63+ const d = new Date(annotation.created_at);
64+ return isNaN(d.getTime())
65+ ? ""
66+ : d.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
67+ }
68+</script>
69+
70+<div id="annotation-{annotation.id}" class="panel p-4">
71+ {#if editing}
72+ <form
73+ onsubmit={(e) => {
74+ e.preventDefault();
75+ saveEdit();
76+ }}
77+ class="space-y-3"
78+ >
79+ <input
80+ bind:value={quote}
81+ class="input-brutal"
82+ placeholder="Quote"
83+ />
84+ <textarea
85+ bind:value={note}
86+ rows="3"
87+ class="input-brutal resize-none"
88+ placeholder="Note"></textarea>
89+ <input
90+ bind:value={tags}
91+ class="input-brutal"
92+ placeholder="Tags (comma separated)"
93+ />
94+ <div class="flex justify-end gap-2">
95+ <button type="button" class="btn" onclick={cancelEdit}
96+ >Cancel</button
97+ >
98+ <button type="submit" class="btn btn-accent" disabled={saving}
99+ >Save</button
100+ >
101+ </div>
102+ </form>
103+ {:else}
104+ {#if annotation.article_url}
105+ <div
106+ class="mb-3 flex items-center gap-2 text-[0.7rem] text-[var(--muted)]"
107+ >
108+ {#if annotation.article_id}
109+ <a
110+ href="/articles/{annotation.article_id}"
111+ class="inline-flex items-center gap-1 hover:text-[var(--accent)]"
112+ ><Icon name="note" class="h-3 w-3" /></a
113+ >
114+ {/if}
115+ <a
116+ href={annotation.article_url}
117+ target="_blank"
118+ rel="noopener"
119+ class="truncate hover:text-[var(--accent)]"
120+ >{annotation.article_url}</a
121+ >
122+ </div>
123+ {/if}
124+ {#if annotation.quote}
125+ <blockquote class="border-l-4 border-[var(--accent)] pl-3 text-sm">
126+ {annotation.quote}
127+ </blockquote>
128+ {/if}
129+ {#if annotation.note}
130+ <p class="mt-3 text-sm font-medium">{annotation.note}</p>
131+ {/if}
132+ {#if tagsList.length}
133+ <div class="mt-3 flex flex-wrap gap-1.5">
134+ {#each tagsList as t}<span class="tag">{t}</span>{/each}
135+ </div>
136+ {/if}
137+ {#if annotation.rating}<div class="mt-2 text-sm text-[var(--accent)]">
138+ {"★".repeat(annotation.rating)}
139+ </div>{/if}
140+ <div
141+ class="mt-3 flex items-center justify-between border-t-2 border-[var(--border)] pt-3 text-[0.7rem] text-[var(--muted)]"
142+ >
143+ <div class="flex items-center gap-2">
144+ <a
145+ href="/profile/{annotation.author_did}"
146+ class="font-bold uppercase hover:text-[var(--accent)]"
147+ >@{annotation.author_handle ||
148+ annotation.author_did.slice(0, 12)}</a
149+ >
150+ {#if when()}<span>·</span><span>{when()}</span>{/if}
151+ </div>
152+ {#if mine}
153+ <div class="flex gap-3">
154+ <button
155+ class="font-bold uppercase hover:text-[var(--fg)]"
156+ onclick={() => (editing = true)}>Edit</button
157+ >
158+ <button
159+ class="font-bold uppercase text-[var(--danger)]"
160+ onclick={del}>Delete</button
161+ >
162+ </div>
163+ {/if}
164+ </div>
165+ {/if}
166+</div>
new file mode 100644
@@ -0,0 +1,166 @@
1+<script lang="ts">
2+ import type { Annotation } from "$lib/types";
3+ import { endpoints } from "$lib/api";
4+ import Icon from "./Icon.svelte";
5+ import { invalidateAll } from "$app/navigation";
6+
7+ interface Props {
8+ annotation: Annotation;
9+ userDID: string;
10+ }
11+ let { annotation, userDID }: Props = $props();
12+
13+ let editing = $state(false);
14+ {
15+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
16+ }
17+ let quote = $state(annotation.quote ?? "");
18+ {
19+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
20+ }
21+ let note = $state(annotation.note ?? "");
22+ {
23+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
24+ }
25+ let tags = $state((annotation.tags ?? []).join(", "));
26+ let saving = $state(false);
27+
28+ const mine = $derived(annotation.author_did === userDID);
29+ const tagsList = $derived(annotation.tags ?? []);
30+
31+ async function saveEdit() {
32+ saving = true;
33+ try {
34+ await endpoints.deleteAnnotation(annotation.id);
35+ await endpoints.createAnnotation({
36+ feed_url: annotation.feed_url,
37+ article_url: annotation.article_url,
38+ quote,
39+ note,
40+ tags,
41+ });
42+ editing = false;
43+ await invalidateAll();
44+ } finally {
45+ saving = false;
46+ }
47+ }
48+
49+ function cancelEdit() {
50+ editing = false;
51+ quote = annotation.quote ?? "";
52+ note = annotation.note ?? "";
53+ tags = (annotation.tags ?? []).join(", ");
54+ }
55+
56+ async function del() {
57+ await endpoints.deleteAnnotation(annotation.id);
58+ await invalidateAll();
59+ }
60+
61+ function when(): string {
62+ if (!annotation.created_at) return "";
63+ const d = new Date(annotation.created_at);
64+ return isNaN(d.getTime())
65+ ? ""
66+ : d.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
67+ }
68+</script>
69+
70+<div id="annotation-{annotation.id}" class="panel p-4">
71+ {#if editing}
72+ <form
73+ onsubmit={(e) => {
74+ e.preventDefault();
75+ saveEdit();
76+ }}
77+ class="space-y-3"
78+ >
79+ <input
80+ bind:value={quote}
81+ class="input-brutal"
82+ placeholder="Quote"
83+ />
84+ <textarea
85+ bind:value={note}
86+ rows="3"
87+ class="input-brutal resize-none"
88+ placeholder="Note"></textarea>
89+ <input
90+ bind:value={tags}
91+ class="input-brutal"
92+ placeholder="Tags (comma separated)"
93+ />
94+ <div class="flex justify-end gap-2">
95+ <button type="button" class="btn" onclick={cancelEdit}
96+ >Cancel</button
97+ >
98+ <button type="submit" class="btn btn-accent" disabled={saving}
99+ >Save</button
100+ >
101+ </div>
102+ </form>
103+ {:else}
104+ {#if annotation.article_url}
105+ <div
106+ class="mb-3 flex items-center gap-2 text-[0.7rem] text-[var(--muted)]"
107+ >
108+ {#if annotation.article_id}
109+ <a
110+ href="/articles/{annotation.article_id}"
111+ class="inline-flex items-center gap-1 hover:text-[var(--accent)]"
112+ ><Icon name="note" class="h-3 w-3" /></a
113+ >
114+ {/if}
115+ <a
116+ href={annotation.article_url}
117+ target="_blank"
118+ rel="noopener"
119+ class="truncate hover:text-[var(--accent)]"
120+ >{annotation.article_url}</a
121+ >
122+ </div>
123+ {/if}
124+ {#if annotation.quote}
125+ <blockquote class="border-l-4 border-[var(--accent)] pl-3 text-sm">
126+ {annotation.quote}
127+ </blockquote>
128+ {/if}
129+ {#if annotation.note}
130+ <p class="mt-3 text-sm font-medium">{annotation.note}</p>
131+ {/if}
132+ {#if tagsList.length}
133+ <div class="mt-3 flex flex-wrap gap-1.5">
134+ {#each tagsList as t}<span class="tag">{t}</span>{/each}
135+ </div>
136+ {/if}
137+ {#if annotation.rating}<div class="mt-2 text-sm text-[var(--accent)]">
138+ {"★".repeat(annotation.rating)}
139+ </div>{/if}
140+ <div
141+ class="mt-3 flex items-center justify-between border-t-2 border-[var(--border)] pt-3 text-[0.7rem] text-[var(--muted)]"
142+ >
143+ <div class="flex items-center gap-2">
144+ <a
145+ href="/profile/{annotation.author_did}"
146+ class="font-bold uppercase hover:text-[var(--accent)]"
147+ >@{annotation.author_handle ||
148+ annotation.author_did.slice(0, 12)}</a
149+ >
150+ {#if when()}<span>·</span><span>{when()}</span>{/if}
151+ </div>
152+ {#if mine}
153+ <div class="flex gap-3">
154+ <button
155+ class="font-bold uppercase hover:text-[var(--fg)]"
156+ onclick={() => (editing = true)}>Edit</button
157+ >
158+ <button
159+ class="font-bold uppercase text-[var(--danger)]"
160+ onclick={del}>Delete</button
161+ >
162+ </div>
163+ {/if}
164+ </div>
165+ {/if}
166+</div>
added web/src/lib/components/ArticleCard.svelte +169 -0
new file mode 100644
@@ -0,0 +1,169 @@
1+<script lang="ts">
2+ import type { Article } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import LikeButton from "./LikeButton.svelte";
5+ import Icon from "./Icon.svelte";
6+ import { endpoints } from "$lib/api";
7+ import { plainText, youtubeID } from "$lib/format";
8+
9+ interface Props {
10+ article: Article;
11+ expanded?: boolean;
12+ navSuffix?: string;
13+ dismissible?: boolean;
14+ onDismiss?: () => void;
15+ }
16+ let {
17+ article,
18+ expanded = false,
19+ navSuffix = "",
20+ dismissible = false,
21+ onDismiss,
22+ }: Props = $props();
23+
24+ {
25+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
26+ }
27+ let read = $state(article.is_read);
28+ {
29+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
30+ }
31+ let liked = $state(article.has_liked);
32+ {
33+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
34+ }
35+ let likeCount = $state(article.like_count);
36+
37+ async function toggleRead() {
38+ const next = !read;
39+ read = next;
40+ try {
41+ if (next) await endpoints.markRead(article.id);
42+ else await endpoints.markUnread(article.id);
43+ } catch {
44+ read = !next;
45+ }
46+ }
47+
48+ async function dismiss() {
49+ if (!article.url) return;
50+ await endpoints.dismissArticle(article.url);
51+ onDismiss?.();
52+ }
53+
54+ const yt = $derived(youtubeID(article.url));
55+ const meta = $derived(
56+ [
57+ article.feed_title || article.feed_url,
58+ article.author,
59+ article.published ? fmt(article.published) : "",
60+ ]
61+ .filter(Boolean)
62+ .join(" / "),
63+ );
64+
65+ function fmt(t: string): string {
66+ const d = new Date(t);
67+ return isNaN(d.getTime())
68+ ? ""
69+ : d.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
70+ }
71+</script>
72+
73+<article
74+ class="panel panel-press relative {expanded ? '' : ''} {!read
75+ ? 'border-l-[6px] border-l-[var(--accent)]'
76+ : ''}"
77+ data-article-id={article.id}
78+>
79+ <div class="p-4">
80+ <div class="flex items-start justify-between gap-4">
81+ <div class="min-w-0 flex-1">
82+ <a
83+ href="/articles/{article.id}{navSuffix}"
84+ class="block {read
85+ ? 'text-[var(--muted)]'
86+ : 'text-[var(--fg)]'} hover:text-[var(--accent)]"
87+ >
88+ <span
89+ class="{expanded
90+ ? 'text-lg'
91+ : 'text-base'} font-bold leading-snug"
92+ >{article.title}</span
93+ >
94+ </a>
95+ <div
96+ class="mt-2 flex items-center gap-2 text-[0.7rem] text-[var(--muted)]"
97+ >
98+ <Favicon
99+ src={article.feed_favicon_url}
100+ size="h-3.5 w-3.5"
101+ />
102+ <span class="truncate font-medium uppercase tracking-wide"
103+ >{meta}</span
104+ >
105+ </div>
106+ {#if !expanded && article.summary}
107+ <p class="mt-2 line-clamp-2 text-sm text-[var(--muted)]">
108+ {plainText(article.summary)}
109+ </p>
110+ {/if}
111+ </div>
112+
113+ <div class="flex shrink-0 flex-col items-end gap-1.5">
114+ <LikeButton
115+ articleId={article.id}
116+ bind:liked
117+ bind:count={likeCount}
118+ />
119+ <button
120+ onclick={toggleRead}
121+ title={read ? "Mark unread" : "Mark read"}
122+ class="chip"
123+ >
124+ <Icon name="check" class="h-3 w-3" />
125+ <span>{read ? "Read" : "New"}</span>
126+ </button>
127+ {#if dismissible}
128+ <button
129+ onclick={dismiss}
130+ title="Hide"
131+ class="chip hover:text-[var(--danger)]"
132+ >
133+ <Icon name="x" class="h-3 w-3" />
134+ </button>
135+ {/if}
136+ </div>
137+ </div>
138+ </div>
139+
140+ {#if expanded}
141+ {#if yt}
142+ <div class="border-y-2 border-[var(--border)]">
143+ <div class="aspect-video w-full">
144+ <iframe
145+ class="h-full w-full"
146+ src="https://www.youtube.com/embed/{yt}"
147+ title="YouTube"
148+ frameborder="0"
149+ allowfullscreen
150+ ></iframe>
151+ </div>
152+ </div>
153+ {/if}
154+ {#if article.content || article.full_content || article.summary}
155+ <div class="article-body p-4 pt-2">
156+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
157+ {@html article.content ||
158+ article.full_content ||
159+ article.summary}
160+ </div>
161+ {:else}
162+ <div class="p-4 pt-0">
163+ <a href="/articles/{article.id}{navSuffix}" class="btn"
164+ >Read full &rarr;</a
165+ >
166+ </div>
167+ {/if}
168+ {/if}
169+</article>
new file mode 100644
@@ -0,0 +1,169 @@
1+<script lang="ts">
2+ import type { Article } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import LikeButton from "./LikeButton.svelte";
5+ import Icon from "./Icon.svelte";
6+ import { endpoints } from "$lib/api";
7+ import { plainText, youtubeID } from "$lib/format";
8+
9+ interface Props {
10+ article: Article;
11+ expanded?: boolean;
12+ navSuffix?: string;
13+ dismissible?: boolean;
14+ onDismiss?: () => void;
15+ }
16+ let {
17+ article,
18+ expanded = false,
19+ navSuffix = "",
20+ dismissible = false,
21+ onDismiss,
22+ }: Props = $props();
23+
24+ {
25+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
26+ }
27+ let read = $state(article.is_read);
28+ {
29+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
30+ }
31+ let liked = $state(article.has_liked);
32+ {
33+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
34+ }
35+ let likeCount = $state(article.like_count);
36+
37+ async function toggleRead() {
38+ const next = !read;
39+ read = next;
40+ try {
41+ if (next) await endpoints.markRead(article.id);
42+ else await endpoints.markUnread(article.id);
43+ } catch {
44+ read = !next;
45+ }
46+ }
47+
48+ async function dismiss() {
49+ if (!article.url) return;
50+ await endpoints.dismissArticle(article.url);
51+ onDismiss?.();
52+ }
53+
54+ const yt = $derived(youtubeID(article.url));
55+ const meta = $derived(
56+ [
57+ article.feed_title || article.feed_url,
58+ article.author,
59+ article.published ? fmt(article.published) : "",
60+ ]
61+ .filter(Boolean)
62+ .join(" / "),
63+ );
64+
65+ function fmt(t: string): string {
66+ const d = new Date(t);
67+ return isNaN(d.getTime())
68+ ? ""
69+ : d.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
70+ }
71+</script>
72+
73+<article
74+ class="panel panel-press relative {expanded ? '' : ''} {!read
75+ ? 'border-l-[6px] border-l-[var(--accent)]'
76+ : ''}"
77+ data-article-id={article.id}
78+>
79+ <div class="p-4">
80+ <div class="flex items-start justify-between gap-4">
81+ <div class="min-w-0 flex-1">
82+ <a
83+ href="/articles/{article.id}{navSuffix}"
84+ class="block {read
85+ ? 'text-[var(--muted)]'
86+ : 'text-[var(--fg)]'} hover:text-[var(--accent)]"
87+ >
88+ <span
89+ class="{expanded
90+ ? 'text-lg'
91+ : 'text-base'} font-bold leading-snug"
92+ >{article.title}</span
93+ >
94+ </a>
95+ <div
96+ class="mt-2 flex items-center gap-2 text-[0.7rem] text-[var(--muted)]"
97+ >
98+ <Favicon
99+ src={article.feed_favicon_url}
100+ size="h-3.5 w-3.5"
101+ />
102+ <span class="truncate font-medium uppercase tracking-wide"
103+ >{meta}</span
104+ >
105+ </div>
106+ {#if !expanded && article.summary}
107+ <p class="mt-2 line-clamp-2 text-sm text-[var(--muted)]">
108+ {plainText(article.summary)}
109+ </p>
110+ {/if}
111+ </div>
112+
113+ <div class="flex shrink-0 flex-col items-end gap-1.5">
114+ <LikeButton
115+ articleId={article.id}
116+ bind:liked
117+ bind:count={likeCount}
118+ />
119+ <button
120+ onclick={toggleRead}
121+ title={read ? "Mark unread" : "Mark read"}
122+ class="chip"
123+ >
124+ <Icon name="check" class="h-3 w-3" />
125+ <span>{read ? "Read" : "New"}</span>
126+ </button>
127+ {#if dismissible}
128+ <button
129+ onclick={dismiss}
130+ title="Hide"
131+ class="chip hover:text-[var(--danger)]"
132+ >
133+ <Icon name="x" class="h-3 w-3" />
134+ </button>
135+ {/if}
136+ </div>
137+ </div>
138+ </div>
139+
140+ {#if expanded}
141+ {#if yt}
142+ <div class="border-y-2 border-[var(--border)]">
143+ <div class="aspect-video w-full">
144+ <iframe
145+ class="h-full w-full"
146+ src="https://www.youtube.com/embed/{yt}"
147+ title="YouTube"
148+ frameborder="0"
149+ allowfullscreen
150+ ></iframe>
151+ </div>
152+ </div>
153+ {/if}
154+ {#if article.content || article.full_content || article.summary}
155+ <div class="article-body p-4 pt-2">
156+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
157+ {@html article.content ||
158+ article.full_content ||
159+ article.summary}
160+ </div>
161+ {:else}
162+ <div class="p-4 pt-0">
163+ <a href="/articles/{article.id}{navSuffix}" class="btn"
164+ >Read full &rarr;</a
165+ >
166+ </div>
167+ {/if}
168+ {/if}
169+</article>
added web/src/lib/components/BlueskyLogo.svelte +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+<script lang="ts">
2+ interface Props {
3+ class?: string;
4+ }
5+ let { class: cls = 'h-4 w-4' }: Props = $props();
6+</script>
7+
8+<svg class={cls} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
9+ <path
10+ d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.299-2.777.472-5.899-.309-6.755-3.37C.42 10.039 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"
11+ />
12+</svg>
new file mode 100644
@@ -0,0 +1,12 @@
1+<script lang="ts">
2+ interface Props {
3+ class?: string;
4+ }
5+ let { class: cls = 'h-4 w-4' }: Props = $props();
6+</script>
7+
8+<svg class={cls} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
9+ <path
10+ d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.299-2.777.472-5.899-.309-6.755-3.37C.42 10.039 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"
11+ />
12+</svg>
added web/src/lib/components/EmptyState.svelte +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+<script lang="ts">
2+ import Icon from "./Icon.svelte";
3+
4+ interface Props {
5+ icon?: string;
6+ title: string;
7+ subtitle?: string;
8+ }
9+ let { icon = "feed", title, subtitle = "" }: Props = $props();
10+</script>
11+
12+<div class="border-2 border-[var(--border)] p-10 text-center">
13+ <div
14+ class="mx-auto mb-4 inline-flex h-14 w-14 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)]"
15+ >
16+ <Icon name={icon} class="h-7 w-7" />
17+ </div>
18+ <p class="text-sm font-bold uppercase tracking-wide">{title}</p>
19+ {#if subtitle}<p class="mt-1 text-xs text-[var(--muted)]">
20+ {subtitle}
21+ </p>{/if}
22+</div>
new file mode 100644
@@ -0,0 +1,22 @@
1+<script lang="ts">
2+ import Icon from "./Icon.svelte";
3+
4+ interface Props {
5+ icon?: string;
6+ title: string;
7+ subtitle?: string;
8+ }
9+ let { icon = "feed", title, subtitle = "" }: Props = $props();
10+</script>
11+
12+<div class="border-2 border-[var(--border)] p-10 text-center">
13+ <div
14+ class="mx-auto mb-4 inline-flex h-14 w-14 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)]"
15+ >
16+ <Icon name={icon} class="h-7 w-7" />
17+ </div>
18+ <p class="text-sm font-bold uppercase tracking-wide">{title}</p>
19+ {#if subtitle}<p class="mt-1 text-xs text-[var(--muted)]">
20+ {subtitle}
21+ </p>{/if}
22+</div>
added web/src/lib/components/Favicon.svelte +26 -0
new file mode 100644
@@ -0,0 +1,26 @@
1+<script lang="ts">
2+ interface Props {
3+ src?: string;
4+ size?: string;
5+ }
6+ let { src = "", size = "h-5 w-5" }: Props = $props();
7+ let failed = $state(false);
8+ $effect(() => {
9+ failed = false;
10+ });
11+</script>
12+
13+{#if src && !failed}
14+ <img
15+ {src}
16+ class="{size} border border-[var(--border)] object-cover"
17+ loading="lazy"
18+ onerror={() => (failed = true)}
19+ alt=""
20+ />
21+{:else}
22+ <span
23+ class="inline-flex {size} items-center justify-center border border-[var(--border)] bg-[var(--surface)] text-[var(--muted)] text-[0.6rem] font-bold"
24+ >·</span
25+ >
26+{/if}
new file mode 100644
@@ -0,0 +1,26 @@
1+<script lang="ts">
2+ interface Props {
3+ src?: string;
4+ size?: string;
5+ }
6+ let { src = "", size = "h-5 w-5" }: Props = $props();
7+ let failed = $state(false);
8+ $effect(() => {
9+ failed = false;
10+ });
11+</script>
12+
13+{#if src && !failed}
14+ <img
15+ {src}
16+ class="{size} border border-[var(--border)] object-cover"
17+ loading="lazy"
18+ onerror={() => (failed = true)}
19+ alt=""
20+ />
21+{:else}
22+ <span
23+ class="inline-flex {size} items-center justify-center border border-[var(--border)] bg-[var(--surface)] text-[var(--muted)] text-[0.6rem] font-bold"
24+ >·</span
25+ >
26+{/if}
added web/src/lib/components/FeedItem.svelte +100 -0
new file mode 100644
@@ -0,0 +1,100 @@
1+<script lang="ts">
2+ import type { Subscription } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import Icon from "./Icon.svelte";
5+ import { endpoints } from "$lib/api";
6+ import { invalidateAll } from "$app/navigation";
7+
8+ interface Props {
9+ sub: Subscription;
10+ removable?: boolean;
11+ }
12+ let { sub, removable = true }: Props = $props();
13+
14+ let editing = $state(false);
15+ {
16+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
17+ }
18+ let category = $state(sub.category ?? "");
19+ let removing = $state(false);
20+
21+ async function save() {
22+ await endpoints.editFeed(sub.feed_url, category);
23+ editing = false;
24+ await invalidateAll();
25+ }
26+
27+ async function remove() {
28+ removing = true;
29+ try {
30+ await endpoints.removeFeed(sub.feed_url);
31+ await invalidateAll();
32+ } finally {
33+ removing = false;
34+ }
35+ }
36+</script>
37+
38+<div class="flex items-center gap-3 px-4 py-3">
39+ <a
40+ href="/articles?feed={encodeURIComponent(sub.feed_url)}"
41+ class="flex min-w-0 flex-1 items-center gap-3"
42+ >
43+ <Favicon src={sub.favicon_url} size="h-6 w-6" />
44+ <div class="min-w-0">
45+ <div class="flex items-center gap-2">
46+ <span class="truncate font-bold"
47+ >{sub.feed_title || sub.feed_url}</span
48+ >
49+ {#if sub.category}<span class="tag">{sub.category}</span>{/if}
50+ </div>
51+ <div class="truncate text-[0.7rem] text-[var(--muted)]">
52+ {sub.feed_url}
53+ </div>
54+ </div>
55+ </a>
56+ <div class="flex shrink-0 items-center gap-2">
57+ {#if sub.unread_count}<span class="chip" data-active="true"
58+ >{sub.unread_count}</span
59+ >{/if}
60+ {#if removable}
61+ {#if editing}
62+ <form
63+ onsubmit={(e) => {
64+ e.preventDefault();
65+ save();
66+ }}
67+ class="flex items-center gap-1.5"
68+ >
69+ <input
70+ bind:value={category}
71+ class="input-brutal !py-1 !text-xs w-32"
72+ placeholder="Category"
73+ />
74+ <button type="submit" class="btn !py-1 !text-[0.65rem]"
75+ >Save</button
76+ >
77+ <button
78+ type="button"
79+ class="btn btn-ghost !py-1 !text-[0.65rem]"
80+ onclick={() => (editing = false)}>×</button
81+ >
82+ </form>
83+ {:else}
84+ <button
85+ onclick={() => (editing = true)}
86+ title="Edit"
87+ class="btn btn-ghost !px-1.5 !py-1"
88+ ><Icon name="edit" class="h-3.5 w-3.5" /></button
89+ >
90+ <button
91+ onclick={remove}
92+ disabled={removing}
93+ title="Unsubscribe"
94+ class="btn btn-ghost !px-1.5 !py-1 text-[var(--danger)]"
95+ ><Icon name="trash" class="h-3.5 w-3.5" /></button
96+ >
97+ {/if}
98+ {/if}
99+ </div>
100+</div>
new file mode 100644
@@ -0,0 +1,100 @@
1+<script lang="ts">
2+ import type { Subscription } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import Icon from "./Icon.svelte";
5+ import { endpoints } from "$lib/api";
6+ import { invalidateAll } from "$app/navigation";
7+
8+ interface Props {
9+ sub: Subscription;
10+ removable?: boolean;
11+ }
12+ let { sub, removable = true }: Props = $props();
13+
14+ let editing = $state(false);
15+ {
16+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
17+ }
18+ let category = $state(sub.category ?? "");
19+ let removing = $state(false);
20+
21+ async function save() {
22+ await endpoints.editFeed(sub.feed_url, category);
23+ editing = false;
24+ await invalidateAll();
25+ }
26+
27+ async function remove() {
28+ removing = true;
29+ try {
30+ await endpoints.removeFeed(sub.feed_url);
31+ await invalidateAll();
32+ } finally {
33+ removing = false;
34+ }
35+ }
36+</script>
37+
38+<div class="flex items-center gap-3 px-4 py-3">
39+ <a
40+ href="/articles?feed={encodeURIComponent(sub.feed_url)}"
41+ class="flex min-w-0 flex-1 items-center gap-3"
42+ >
43+ <Favicon src={sub.favicon_url} size="h-6 w-6" />
44+ <div class="min-w-0">
45+ <div class="flex items-center gap-2">
46+ <span class="truncate font-bold"
47+ >{sub.feed_title || sub.feed_url}</span
48+ >
49+ {#if sub.category}<span class="tag">{sub.category}</span>{/if}
50+ </div>
51+ <div class="truncate text-[0.7rem] text-[var(--muted)]">
52+ {sub.feed_url}
53+ </div>
54+ </div>
55+ </a>
56+ <div class="flex shrink-0 items-center gap-2">
57+ {#if sub.unread_count}<span class="chip" data-active="true"
58+ >{sub.unread_count}</span
59+ >{/if}
60+ {#if removable}
61+ {#if editing}
62+ <form
63+ onsubmit={(e) => {
64+ e.preventDefault();
65+ save();
66+ }}
67+ class="flex items-center gap-1.5"
68+ >
69+ <input
70+ bind:value={category}
71+ class="input-brutal !py-1 !text-xs w-32"
72+ placeholder="Category"
73+ />
74+ <button type="submit" class="btn !py-1 !text-[0.65rem]"
75+ >Save</button
76+ >
77+ <button
78+ type="button"
79+ class="btn btn-ghost !py-1 !text-[0.65rem]"
80+ onclick={() => (editing = false)}>×</button
81+ >
82+ </form>
83+ {:else}
84+ <button
85+ onclick={() => (editing = true)}
86+ title="Edit"
87+ class="btn btn-ghost !px-1.5 !py-1"
88+ ><Icon name="edit" class="h-3.5 w-3.5" /></button
89+ >
90+ <button
91+ onclick={remove}
92+ disabled={removing}
93+ title="Unsubscribe"
94+ class="btn btn-ghost !px-1.5 !py-1 text-[var(--danger)]"
95+ ><Icon name="trash" class="h-3.5 w-3.5" /></button
96+ >
97+ {/if}
98+ {/if}
99+ </div>
100+</div>
added web/src/lib/components/FeedRecommendationCard.svelte +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+<script lang="ts">
2+ import type { FeedRecommendation } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import Icon from "./Icon.svelte";
5+ import { endpoints } from "$lib/api";
6+ import { invalidateAll } from "$app/navigation";
7+
8+ interface Props {
9+ rec: FeedRecommendation;
10+ onDismiss?: (feed_url: string) => void;
11+ }
12+ let { rec, onDismiss }: Props = $props();
13+
14+ let subscribing = $state(false);
15+ let subscribed = $state(false);
16+
17+ async function subscribe() {
18+ subscribing = true;
19+ try {
20+ await endpoints.addFeed(rec.feed_url);
21+ subscribed = true;
22+ await invalidateAll();
23+ } finally {
24+ subscribing = false;
25+ }
26+ }
27+
28+ async function dismiss() {
29+ await endpoints.dismissFeed(rec.feed_url);
30+ onDismiss?.(rec.feed_url);
31+ }
32+</script>
33+
34+<div class="panel p-3.5">
35+ <div class="flex items-center justify-between gap-2">
36+ <a
37+ href="/articles?feed={encodeURIComponent(rec.feed_url)}"
38+ class="flex min-w-0 flex-1 items-center gap-2.5"
39+ >
40+ <Favicon src={rec.favicon_url} size="h-5 w-5" />
41+ <div class="min-w-0">
42+ <div class="truncate text-sm font-bold">
43+ {rec.title || rec.feed_url}
44+ </div>
45+ {#if rec.description}<p
46+ class="truncate text-[0.7rem] text-[var(--muted)]"
47+ >
48+ {rec.description}
49+ </p>{/if}
50+ </div>
51+ </a>
52+ <div class="flex shrink-0 items-center gap-2">
53+ <span class="text-[0.65rem] font-bold uppercase text-[var(--muted)]"
54+ >{rec.subscriber_count}</span
55+ >
56+ <button
57+ onclick={dismiss}
58+ title="Not interested"
59+ class="btn btn-ghost !px-1.5 !py-1 text-[var(--danger)]"
60+ ><Icon name="x" class="h-3.5 w-3.5" /></button
61+ >
62+ {#if subscribed}
63+ <span class="chip" data-active="true"></span>
64+ {:else}
65+ <button
66+ onclick={subscribe}
67+ disabled={subscribing}
68+ class="btn btn-accent !py-1 !text-[0.65rem]"
69+ >Subscribe</button
70+ >
71+ {/if}
72+ </div>
73+ </div>
74+</div>
new file mode 100644
@@ -0,0 +1,74 @@
1+<script lang="ts">
2+ import type { FeedRecommendation } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import Icon from "./Icon.svelte";
5+ import { endpoints } from "$lib/api";
6+ import { invalidateAll } from "$app/navigation";
7+
8+ interface Props {
9+ rec: FeedRecommendation;
10+ onDismiss?: (feed_url: string) => void;
11+ }
12+ let { rec, onDismiss }: Props = $props();
13+
14+ let subscribing = $state(false);
15+ let subscribed = $state(false);
16+
17+ async function subscribe() {
18+ subscribing = true;
19+ try {
20+ await endpoints.addFeed(rec.feed_url);
21+ subscribed = true;
22+ await invalidateAll();
23+ } finally {
24+ subscribing = false;
25+ }
26+ }
27+
28+ async function dismiss() {
29+ await endpoints.dismissFeed(rec.feed_url);
30+ onDismiss?.(rec.feed_url);
31+ }
32+</script>
33+
34+<div class="panel p-3.5">
35+ <div class="flex items-center justify-between gap-2">
36+ <a
37+ href="/articles?feed={encodeURIComponent(rec.feed_url)}"
38+ class="flex min-w-0 flex-1 items-center gap-2.5"
39+ >
40+ <Favicon src={rec.favicon_url} size="h-5 w-5" />
41+ <div class="min-w-0">
42+ <div class="truncate text-sm font-bold">
43+ {rec.title || rec.feed_url}
44+ </div>
45+ {#if rec.description}<p
46+ class="truncate text-[0.7rem] text-[var(--muted)]"
47+ >
48+ {rec.description}
49+ </p>{/if}
50+ </div>
51+ </a>
52+ <div class="flex shrink-0 items-center gap-2">
53+ <span class="text-[0.65rem] font-bold uppercase text-[var(--muted)]"
54+ >{rec.subscriber_count}</span
55+ >
56+ <button
57+ onclick={dismiss}
58+ title="Not interested"
59+ class="btn btn-ghost !px-1.5 !py-1 text-[var(--danger)]"
60+ ><Icon name="x" class="h-3.5 w-3.5" /></button
61+ >
62+ {#if subscribed}
63+ <span class="chip" data-active="true"></span>
64+ {:else}
65+ <button
66+ onclick={subscribe}
67+ disabled={subscribing}
68+ class="btn btn-accent !py-1 !text-[0.65rem]"
69+ >Subscribe</button
70+ >
71+ {/if}
72+ </div>
73+ </div>
74+</div>
added web/src/lib/components/Icon.svelte +68 -0
new file mode 100644
@@ -0,0 +1,68 @@
1+<script lang="ts">
2+ // Monochrome line icons sized by `class`. Each path is drawn inside a 24x24 viewBox
3+ // with stroke=currentColor, fill=none unless `fill` is set.
4+ interface Props {
5+ name: string;
6+ class?: string;
7+ strokeWidth?: number;
8+ fill?: string;
9+ }
10+ let {
11+ name,
12+ class: cls = "h-5 w-5",
13+ strokeWidth = 1.75,
14+ fill = "none",
15+ }: Props = $props();
16+
17+ const paths: Record<string, string> = {
18+ grid: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z",
19+ feed: "M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M6 19a1 1 0 1 0 0-2 1 1 0 0 0 0 2z",
20+ fire: "M12 2c1 3 4 4 4 8a4 4 0 1 1-8 0c0-2 1-3 2-4-3 0-4 3-4 6a6 6 0 1 0 12 0c0-6-4-8-6-10z",
21+ note: "M5 3h10l4 4v14H5zM15 3v4h4M9 13h6M9 17h4",
22+ check: "M4 12l5 5L20 6",
23+ x: "M6 6l12 12M18 6L6 18",
24+ chevronDown: "M6 9l6 6 6-6",
25+ chevronUp: "M6 15l6-6 6 6",
26+ chevronRight: "M9 6l6 6-6 6",
27+ arrowLeft: "M19 12H5M12 19l-7-7 7-7",
28+ arrowRight: "M5 12h14M12 5l7 7-7 7",
29+ external: "M14 5h5v5M19 5l-9 9M19 14v5H5V5h5",
30+ search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM21 21l-4.3-4.3",
31+ heart: "M12 21s-7-4.5-9.5-9C1 9 2.5 5 6 5c2 0 3 1 6 4 3-3 4-4 6-4 3.5 0 5 4 3.5 7-2.5 4.5-9.5 9-9.5 9z",
32+ heartFill:
33+ "M12 21s-7-4.5-9.5-9C1 9 2.5 5 6 5c2 0 3 1 6 4 3-3 4-4 6-4 3.5 0 5 4 3.5 7-2.5 4.5-9.5 9-9.5 9z",
34+ bookmark: "M6 3h12v18l-6-4-6 4z",
35+ plus: "M12 5v14M5 12h14",
36+ minus: "M5 12h14",
37+ logout: "M16 17l5-5-5-5M21 12H9M9 21H4V3h5",
38+ refresh: "M21 12a9 9 0 1 1-3-6.7M21 4v5h-5",
39+ edit: "M4 20h4l11-11-4-4L4 16zM14 6l4 4",
40+ trash: "M4 7h16M9 7V3h6v4M6 7l1 14h10l1-14",
41+ upload: "M12 16V4M7 9l5-5 5 5M4 20h16",
42+ download: "M12 4v12M7 11l5 5 5-5M4 20h16",
43+ keyboard: "M3 6h18v12H3zM7 10h0M11 10h0M15 10h0M7 14h10",
44+ eye: "M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
45+ trending: "M3 17l6-6 4 4 8-8M21 7h-5M21 7v5",
46+ users: "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM2 21c0-4 3-7 7-7s7 3 7 7M17 11a3 3 0 1 0 0-6M22 21c0-3-2-5-5-6",
47+ chart: "M3 21h18M6 21v-6M11 21V8M16 21v-9M21 21V5",
48+ sun: "M12 4V2M12 22v-2M4 12H2M22 12h-2M6 6L4 4M20 4l-2 2M6 18l-2 2M20 20l-2-2M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z",
49+ moon: "M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10z",
50+ globe: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20",
51+ sparkles:
52+ "M12 3l1.8 4.7L18.5 9.5 13.8 11.3 12 16l-1.8-4.7L5.5 9.5l4.7-1.8zM19 14l.9 2.3 2.3.9-2.3.9L19 21l-.9-2.3-2.3-.9 2.3-.9z",
53+ };
54+</script>
55+
56+<svg
57+ xmlns="http://www.w3.org/2000/svg"
58+ {fill}
59+ stroke="currentColor"
60+ stroke-width={strokeWidth}
61+ stroke-linecap="square"
62+ stroke-linejoin="miter"
63+ viewBox="0 0 24 24"
64+ class={cls}
65+ aria-hidden="true"
66+>
67+ <path d={paths[name] ?? ""} />
68+</svg>
new file mode 100644
@@ -0,0 +1,68 @@
1+<script lang="ts">
2+ // Monochrome line icons sized by `class`. Each path is drawn inside a 24x24 viewBox
3+ // with stroke=currentColor, fill=none unless `fill` is set.
4+ interface Props {
5+ name: string;
6+ class?: string;
7+ strokeWidth?: number;
8+ fill?: string;
9+ }
10+ let {
11+ name,
12+ class: cls = "h-5 w-5",
13+ strokeWidth = 1.75,
14+ fill = "none",
15+ }: Props = $props();
16+
17+ const paths: Record<string, string> = {
18+ grid: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z",
19+ feed: "M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M6 19a1 1 0 1 0 0-2 1 1 0 0 0 0 2z",
20+ fire: "M12 2c1 3 4 4 4 8a4 4 0 1 1-8 0c0-2 1-3 2-4-3 0-4 3-4 6a6 6 0 1 0 12 0c0-6-4-8-6-10z",
21+ note: "M5 3h10l4 4v14H5zM15 3v4h4M9 13h6M9 17h4",
22+ check: "M4 12l5 5L20 6",
23+ x: "M6 6l12 12M18 6L6 18",
24+ chevronDown: "M6 9l6 6 6-6",
25+ chevronUp: "M6 15l6-6 6 6",
26+ chevronRight: "M9 6l6 6-6 6",
27+ arrowLeft: "M19 12H5M12 19l-7-7 7-7",
28+ arrowRight: "M5 12h14M12 5l7 7-7 7",
29+ external: "M14 5h5v5M19 5l-9 9M19 14v5H5V5h5",
30+ search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM21 21l-4.3-4.3",
31+ heart: "M12 21s-7-4.5-9.5-9C1 9 2.5 5 6 5c2 0 3 1 6 4 3-3 4-4 6-4 3.5 0 5 4 3.5 7-2.5 4.5-9.5 9-9.5 9z",
32+ heartFill:
33+ "M12 21s-7-4.5-9.5-9C1 9 2.5 5 6 5c2 0 3 1 6 4 3-3 4-4 6-4 3.5 0 5 4 3.5 7-2.5 4.5-9.5 9-9.5 9z",
34+ bookmark: "M6 3h12v18l-6-4-6 4z",
35+ plus: "M12 5v14M5 12h14",
36+ minus: "M5 12h14",
37+ logout: "M16 17l5-5-5-5M21 12H9M9 21H4V3h5",
38+ refresh: "M21 12a9 9 0 1 1-3-6.7M21 4v5h-5",
39+ edit: "M4 20h4l11-11-4-4L4 16zM14 6l4 4",
40+ trash: "M4 7h16M9 7V3h6v4M6 7l1 14h10l1-14",
41+ upload: "M12 16V4M7 9l5-5 5 5M4 20h16",
42+ download: "M12 4v12M7 11l5 5 5-5M4 20h16",
43+ keyboard: "M3 6h18v12H3zM7 10h0M11 10h0M15 10h0M7 14h10",
44+ eye: "M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
45+ trending: "M3 17l6-6 4 4 8-8M21 7h-5M21 7v5",
46+ users: "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM2 21c0-4 3-7 7-7s7 3 7 7M17 11a3 3 0 1 0 0-6M22 21c0-3-2-5-5-6",
47+ chart: "M3 21h18M6 21v-6M11 21V8M16 21v-9M21 21V5",
48+ sun: "M12 4V2M12 22v-2M4 12H2M22 12h-2M6 6L4 4M20 4l-2 2M6 18l-2 2M20 20l-2-2M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z",
49+ moon: "M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10z",
50+ globe: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20",
51+ sparkles:
52+ "M12 3l1.8 4.7L18.5 9.5 13.8 11.3 12 16l-1.8-4.7L5.5 9.5l4.7-1.8zM19 14l.9 2.3 2.3.9-2.3.9L19 21l-.9-2.3-2.3-.9 2.3-.9z",
53+ };
54+</script>
55+
56+<svg
57+ xmlns="http://www.w3.org/2000/svg"
58+ {fill}
59+ stroke="currentColor"
60+ stroke-width={strokeWidth}
61+ stroke-linecap="square"
62+ stroke-linejoin="miter"
63+ viewBox="0 0 24 24"
64+ class={cls}
65+ aria-hidden="true"
66+>
67+ <path d={paths[name] ?? ""} />
68+</svg>
added web/src/lib/components/InstallDialog.svelte +119 -0
new file mode 100644
@@ -0,0 +1,119 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import Icon from "./Icon.svelte";
4+
5+ interface Props {
6+ open: boolean;
7+ onClose: () => void;
8+ }
9+ let { open, onClose }: Props = $props();
10+
11+ interface BIP extends Event {
12+ prompt: () => Promise<void>;
13+ userChoice: Promise<{ outcome: string }>;
14+ }
15+ let deferred = $state<BIP | null>(null);
16+
17+ onMount(() => {
18+ const h = (e: Event) => {
19+ e.preventDefault();
20+ deferred = e as BIP;
21+ };
22+ window.addEventListener("beforeinstallprompt", h);
23+ return () => window.removeEventListener("beforeinstallprompt", h);
24+ });
25+
26+ const ua = $derived(
27+ typeof navigator !== "undefined" ? navigator.userAgent : "",
28+ );
29+ const safari = $derived(
30+ /iPad|iPhone|iPod/.test(ua) ||
31+ (typeof navigator !== "undefined" &&
32+ navigator.platform === "MacIntel" &&
33+ navigator.maxTouchPoints > 1),
34+ );
35+ const ff = $derived(/Firefox/.test(ua) && /Android/.test(ua));
36+
37+ async function install() {
38+ if (!deferred) return;
39+ await deferred.prompt();
40+ await deferred.userChoice;
41+ deferred = null;
42+ onClose();
43+ }
44+
45+ const steps = $derived(
46+ safari
47+ ? [
48+ "Tap the Share button",
49+ 'Select "Add to Home Screen"',
50+ 'Tap "Add"',
51+ ]
52+ : ff
53+ ? [
54+ "Open the three-dot menu",
55+ 'Select "Install" or "Add to Home screen"',
56+ 'Tap "Add"',
57+ ]
58+ : [
59+ "Open the three-dot menu",
60+ 'Select "Add to Home screen" or "Install app"',
61+ 'Tap "Add"',
62+ ],
63+ );
64+</script>
65+
66+{#if open}
67+ <div
68+ class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
69+ onclick={onClose}
70+ onkeydown={(e) => e.key === "Escape" && onClose()}
71+ role="presentation"
72+ >
73+ <div
74+ class="w-full max-w-sm border-2 border-[var(--border)] bg-[var(--bg)] shadow-[6px_6px_0_0_var(--border)]"
75+ onclick={(e) => e.stopPropagation()}
76+ onkeydown={(e) => e.stopPropagation()}
77+ role="dialog"
78+ aria-modal="true"
79+ tabindex="-1"
80+ >
81+ <div
82+ class="flex items-center justify-between border-b-2 border-[var(--border)] px-5 py-3"
83+ >
84+ <h3 class="text-xs font-extrabold uppercase tracking-widest">
85+ Install
86+ </h3>
87+ <button
88+ onclick={onClose}
89+ aria-label="Close"
90+ class="hover:text-[var(--danger)]"
91+ ><Icon name="x" class="h-4 w-4" /></button
92+ >
93+ </div>
94+ <div class="p-5">
95+ {#if deferred}
96+ <p class="mb-4 text-sm text-[var(--muted)]">
97+ Install Glean on your device for quick access.
98+ </p>
99+ <button onclick={install} class="btn btn-accent w-full"
100+ >Install</button
101+ >
102+ {:else}
103+ <p class="mb-3 text-sm text-[var(--muted)]">
104+ To install Glean as an app:
105+ </p>
106+ <ol class="mb-4 space-y-2 text-sm">
107+ {#each steps as s, i (i)}
108+ <li class="flex gap-3">
109+ <span class="font-extrabold">{i + 1}.</span
110+ ><span>{s}</span>
111+ </li>
112+ {/each}
113+ </ol>
114+ <button onclick={onClose} class="btn w-full">Got it</button>
115+ {/if}
116+ </div>
117+ </div>
118+ </div>
119+{/if}
new file mode 100644
@@ -0,0 +1,119 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import Icon from "./Icon.svelte";
4+
5+ interface Props {
6+ open: boolean;
7+ onClose: () => void;
8+ }
9+ let { open, onClose }: Props = $props();
10+
11+ interface BIP extends Event {
12+ prompt: () => Promise<void>;
13+ userChoice: Promise<{ outcome: string }>;
14+ }
15+ let deferred = $state<BIP | null>(null);
16+
17+ onMount(() => {
18+ const h = (e: Event) => {
19+ e.preventDefault();
20+ deferred = e as BIP;
21+ };
22+ window.addEventListener("beforeinstallprompt", h);
23+ return () => window.removeEventListener("beforeinstallprompt", h);
24+ });
25+
26+ const ua = $derived(
27+ typeof navigator !== "undefined" ? navigator.userAgent : "",
28+ );
29+ const safari = $derived(
30+ /iPad|iPhone|iPod/.test(ua) ||
31+ (typeof navigator !== "undefined" &&
32+ navigator.platform === "MacIntel" &&
33+ navigator.maxTouchPoints > 1),
34+ );
35+ const ff = $derived(/Firefox/.test(ua) && /Android/.test(ua));
36+
37+ async function install() {
38+ if (!deferred) return;
39+ await deferred.prompt();
40+ await deferred.userChoice;
41+ deferred = null;
42+ onClose();
43+ }
44+
45+ const steps = $derived(
46+ safari
47+ ? [
48+ "Tap the Share button",
49+ 'Select "Add to Home Screen"',
50+ 'Tap "Add"',
51+ ]
52+ : ff
53+ ? [
54+ "Open the three-dot menu",
55+ 'Select "Install" or "Add to Home screen"',
56+ 'Tap "Add"',
57+ ]
58+ : [
59+ "Open the three-dot menu",
60+ 'Select "Add to Home screen" or "Install app"',
61+ 'Tap "Add"',
62+ ],
63+ );
64+</script>
65+
66+{#if open}
67+ <div
68+ class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
69+ onclick={onClose}
70+ onkeydown={(e) => e.key === "Escape" && onClose()}
71+ role="presentation"
72+ >
73+ <div
74+ class="w-full max-w-sm border-2 border-[var(--border)] bg-[var(--bg)] shadow-[6px_6px_0_0_var(--border)]"
75+ onclick={(e) => e.stopPropagation()}
76+ onkeydown={(e) => e.stopPropagation()}
77+ role="dialog"
78+ aria-modal="true"
79+ tabindex="-1"
80+ >
81+ <div
82+ class="flex items-center justify-between border-b-2 border-[var(--border)] px-5 py-3"
83+ >
84+ <h3 class="text-xs font-extrabold uppercase tracking-widest">
85+ Install
86+ </h3>
87+ <button
88+ onclick={onClose}
89+ aria-label="Close"
90+ class="hover:text-[var(--danger)]"
91+ ><Icon name="x" class="h-4 w-4" /></button
92+ >
93+ </div>
94+ <div class="p-5">
95+ {#if deferred}
96+ <p class="mb-4 text-sm text-[var(--muted)]">
97+ Install Glean on your device for quick access.
98+ </p>
99+ <button onclick={install} class="btn btn-accent w-full"
100+ >Install</button
101+ >
102+ {:else}
103+ <p class="mb-3 text-sm text-[var(--muted)]">
104+ To install Glean as an app:
105+ </p>
106+ <ol class="mb-4 space-y-2 text-sm">
107+ {#each steps as s, i (i)}
108+ <li class="flex gap-3">
109+ <span class="font-extrabold">{i + 1}.</span
110+ ><span>{s}</span>
111+ </li>
112+ {/each}
113+ </ol>
114+ <button onclick={onClose} class="btn w-full">Got it</button>
115+ {/if}
116+ </div>
117+ </div>
118+ </div>
119+{/if}
added web/src/lib/components/LikeButton.svelte +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+<script lang="ts">
2+ import Icon from "./Icon.svelte";
3+ import { endpoints } from "$lib/api";
4+
5+ interface Props {
6+ articleId: number;
7+ liked: boolean;
8+ count: number;
9+ }
10+ let {
11+ articleId,
12+ liked = $bindable(false),
13+ count = $bindable(0),
14+ }: Props = $props();
15+
16+ let loading = $state(false);
17+
18+ async function toggle() {
19+ if (loading) return;
20+ loading = true;
21+ const prevLiked = liked;
22+ const prevCount = count;
23+ liked = !liked;
24+ count += liked ? 1 : -1;
25+ try {
26+ const res = await endpoints.toggleLike(articleId);
27+ liked = res.liked;
28+ count = res.like_count;
29+ } catch {
30+ liked = prevLiked;
31+ count = prevCount;
32+ } finally {
33+ loading = false;
34+ }
35+ }
36+</script>
37+
38+<button
39+ onclick={toggle}
40+ disabled={loading}
41+ title={liked ? "Unlike" : "Like"}
42+ class="chip"
43+>
44+ <Icon name="heart" class="h-3 w-3" fill={liked ? "currentColor" : "none"} />
45+ <span>{count}</span>
46+</button>
new file mode 100644
@@ -0,0 +1,46 @@
1+<script lang="ts">
2+ import Icon from "./Icon.svelte";
3+ import { endpoints } from "$lib/api";
4+
5+ interface Props {
6+ articleId: number;
7+ liked: boolean;
8+ count: number;
9+ }
10+ let {
11+ articleId,
12+ liked = $bindable(false),
13+ count = $bindable(0),
14+ }: Props = $props();
15+
16+ let loading = $state(false);
17+
18+ async function toggle() {
19+ if (loading) return;
20+ loading = true;
21+ const prevLiked = liked;
22+ const prevCount = count;
23+ liked = !liked;
24+ count += liked ? 1 : -1;
25+ try {
26+ const res = await endpoints.toggleLike(articleId);
27+ liked = res.liked;
28+ count = res.like_count;
29+ } catch {
30+ liked = prevLiked;
31+ count = prevCount;
32+ } finally {
33+ loading = false;
34+ }
35+ }
36+</script>
37+
38+<button
39+ onclick={toggle}
40+ disabled={loading}
41+ title={liked ? "Unlike" : "Like"}
42+ class="chip"
43+>
44+ <Icon name="heart" class="h-3 w-3" fill={liked ? "currentColor" : "none"} />
45+ <span>{count}</span>
46+</button>
added web/src/lib/components/Logo.svelte +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ interface Props {
3+ size?: "sm" | "md" | "lg";
4+ }
5+ let { size = "md" }: Props = $props();
6+ const dims = $derived(
7+ size === "lg" ? "h-10 w-10" : size === "sm" ? "h-6 w-6" : "h-7 w-7",
8+ );
9+ const text = $derived(
10+ size === "lg" ? "text-2xl" : size === "sm" ? "text-base" : "text-lg",
11+ );
12+</script>
13+
14+<a href="/" class="inline-flex items-center gap-2" aria-label="Glean home">
15+ <img src="/favicon.svg" class={dims} alt="Glean" />
16+ <span class="{text} font-extrabold tracking-tight uppercase">Glean</span>
17+</a>
new file mode 100644
@@ -0,0 +1,17 @@
1+<script lang="ts">
2+ interface Props {
3+ size?: "sm" | "md" | "lg";
4+ }
5+ let { size = "md" }: Props = $props();
6+ const dims = $derived(
7+ size === "lg" ? "h-10 w-10" : size === "sm" ? "h-6 w-6" : "h-7 w-7",
8+ );
9+ const text = $derived(
10+ size === "lg" ? "text-2xl" : size === "sm" ? "text-base" : "text-lg",
11+ );
12+</script>
13+
14+<a href="/" class="inline-flex items-center gap-2" aria-label="Glean home">
15+ <img src="/favicon.svg" class={dims} alt="Glean" />
16+ <span class="{text} font-extrabold tracking-tight uppercase">Glean</span>
17+</a>
added web/src/lib/components/NewArticlesBanner.svelte +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import { invalidateAll } from "$app/navigation";
4+ import { endpoints } from "$lib/api";
5+ import Icon from "./Icon.svelte";
6+
7+ interface Props {
8+ /** Unix seconds serving as the "last seen" baseline (e.g. page load time). */
9+ since: number;
10+ /** Polling interval in milliseconds. */
11+ interval?: number;
12+ }
13+ let { since, interval = 60_000 }: Props = $props();
14+
15+ let count = $state(0);
16+ let timer: ReturnType<typeof setInterval>;
17+
18+ onMount(() => {
19+ timer = setInterval(poll, interval);
20+ return () => clearInterval(timer);
21+ });
22+
23+ async function poll() {
24+ // Hide the banner for inactive tabs; the next visibility change polls.
25+ if (document.hidden) return;
26+ try {
27+ const res = await endpoints.newArticleCount(since);
28+ count = res.count;
29+ } catch {
30+ count = 0;
31+ }
32+ }
33+
34+ async function refresh() {
35+ since = Math.floor(Date.now() / 1000);
36+ count = 0;
37+ await invalidateAll();
38+ }
39+</script>
40+
41+{#if count > 0}
42+ <div
43+ class="mb-4 flex items-center justify-between gap-3 border-2 border-[var(--border)] bg-[var(--accent)] px-4 py-2.5 text-[var(--accent-ink)] shadow-[4px_4px_0_0_var(--border)] dark:text-white"
44+ role="status"
45+ >
46+ <span class="flex items-center gap-2 text-sm font-bold">
47+ <Icon name="sparkles" class="h-4 w-4" />
48+ {count} new article{count === 1 ? "" : "s"}
49+ </span>
50+ <button
51+ type="button"
52+ onclick={refresh}
53+ class="border-2 border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[var(--fg)] shadow-[2px_2px_0_0_var(--border)] transition-transform hover:-translate-x-px hover:-translate-y-px"
54+ >
55+ Show
56+ </button>
57+ </div>
58+{/if}
new file mode 100644
@@ -0,0 +1,58 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import { invalidateAll } from "$app/navigation";
4+ import { endpoints } from "$lib/api";
5+ import Icon from "./Icon.svelte";
6+
7+ interface Props {
8+ /** Unix seconds serving as the "last seen" baseline (e.g. page load time). */
9+ since: number;
10+ /** Polling interval in milliseconds. */
11+ interval?: number;
12+ }
13+ let { since, interval = 60_000 }: Props = $props();
14+
15+ let count = $state(0);
16+ let timer: ReturnType<typeof setInterval>;
17+
18+ onMount(() => {
19+ timer = setInterval(poll, interval);
20+ return () => clearInterval(timer);
21+ });
22+
23+ async function poll() {
24+ // Hide the banner for inactive tabs; the next visibility change polls.
25+ if (document.hidden) return;
26+ try {
27+ const res = await endpoints.newArticleCount(since);
28+ count = res.count;
29+ } catch {
30+ count = 0;
31+ }
32+ }
33+
34+ async function refresh() {
35+ since = Math.floor(Date.now() / 1000);
36+ count = 0;
37+ await invalidateAll();
38+ }
39+</script>
40+
41+{#if count > 0}
42+ <div
43+ class="mb-4 flex items-center justify-between gap-3 border-2 border-[var(--border)] bg-[var(--accent)] px-4 py-2.5 text-[var(--accent-ink)] shadow-[4px_4px_0_0_var(--border)] dark:text-white"
44+ role="status"
45+ >
46+ <span class="flex items-center gap-2 text-sm font-bold">
47+ <Icon name="sparkles" class="h-4 w-4" />
48+ {count} new article{count === 1 ? "" : "s"}
49+ </span>
50+ <button
51+ type="button"
52+ onclick={refresh}
53+ class="border-2 border-[var(--border)] bg-[var(--bg)] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[var(--fg)] shadow-[2px_2px_0_0_var(--border)] transition-transform hover:-translate-x-px hover:-translate-y-px"
54+ >
55+ Show
56+ </button>
57+ </div>
58+{/if}
added web/src/lib/components/Pagination.svelte +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+<script lang="ts">
2+ import type { Pagination } from "$lib/types";
3+
4+ interface Props {
5+ page: Pagination;
6+ base: string;
7+ params?: Record<string, string>;
8+ }
9+ let { page, base, params = {} }: Props = $props();
10+
11+ function href(n: number): string {
12+ const u = new URL(base, "http://x");
13+ for (const [k, v] of Object.entries(params)) {
14+ if (v) u.searchParams.set(k, v);
15+ }
16+ if (n > 1) u.searchParams.set("page", String(n));
17+ else u.searchParams.delete("page");
18+ return `${u.pathname}${u.search}`;
19+ }
20+</script>
21+
22+{#if page.has_prev || page.has_next}
23+ <nav
24+ class="flex items-center justify-center gap-2 py-8 text-xs font-bold uppercase"
25+ >
26+ {#if page.has_prev}
27+ <a href={href(page.prev_page)} class="btn">&larr; Prev</a>
28+ {:else}
29+ <span class="btn opacity-30 cursor-not-allowed">&larr; Prev</span>
30+ {/if}
31+ <span class="border-2 border-[var(--border)] px-3 py-2"
32+ >P.{page.page}</span
33+ >
34+ {#if page.has_next}
35+ <a href={href(page.next_page)} class="btn">Next &rarr;</a>
36+ {:else}
37+ <span class="btn opacity-30 cursor-not-allowed">Next &rarr;</span>
38+ {/if}
39+ </nav>
40+{/if}
new file mode 100644
@@ -0,0 +1,40 @@
1+<script lang="ts">
2+ import type { Pagination } from "$lib/types";
3+
4+ interface Props {
5+ page: Pagination;
6+ base: string;
7+ params?: Record<string, string>;
8+ }
9+ let { page, base, params = {} }: Props = $props();
10+
11+ function href(n: number): string {
12+ const u = new URL(base, "http://x");
13+ for (const [k, v] of Object.entries(params)) {
14+ if (v) u.searchParams.set(k, v);
15+ }
16+ if (n > 1) u.searchParams.set("page", String(n));
17+ else u.searchParams.delete("page");
18+ return `${u.pathname}${u.search}`;
19+ }
20+</script>
21+
22+{#if page.has_prev || page.has_next}
23+ <nav
24+ class="flex items-center justify-center gap-2 py-8 text-xs font-bold uppercase"
25+ >
26+ {#if page.has_prev}
27+ <a href={href(page.prev_page)} class="btn">&larr; Prev</a>
28+ {:else}
29+ <span class="btn opacity-30 cursor-not-allowed">&larr; Prev</span>
30+ {/if}
31+ <span class="border-2 border-[var(--border)] px-3 py-2"
32+ >P.{page.page}</span
33+ >
34+ {#if page.has_next}
35+ <a href={href(page.next_page)} class="btn">Next &rarr;</a>
36+ {:else}
37+ <span class="btn opacity-30 cursor-not-allowed">Next &rarr;</span>
38+ {/if}
39+ </nav>
40+{/if}
added web/src/lib/components/ProfileCard.svelte +65 -0
new file mode 100644
@@ -0,0 +1,65 @@
1+<script lang="ts">
2+ import type { PersonRecommendation } from "$lib/types";
3+ import { endpoints } from "$lib/api";
4+
5+ interface Props {
6+ person: PersonRecommendation;
7+ onDismiss?: (did: string) => void;
8+ }
9+ let { person, onDismiss }: Props = $props();
10+
11+ async function dismiss(e: MouseEvent) {
12+ e.preventDefault();
13+ e.stopPropagation();
14+ await endpoints.dismissPerson(person.did);
15+ onDismiss?.(person.did);
16+ }
17+</script>
18+
19+<a
20+ href="/profile/{person.did}"
21+ class="panel panel-press flex items-center gap-3 p-3"
22+>
23+ {#if person.avatar_url}
24+ <img
25+ src={person.avatar_url}
26+ class="h-10 w-10 border-2 border-[var(--border)] object-cover"
27+ alt=""
28+ />
29+ {:else}
30+ <span
31+ class="inline-flex h-10 w-10 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] font-extrabold uppercase"
32+ >{(person.handle || "?").charAt(0)}</span
33+ >
34+ {/if}
35+ <div class="min-w-0 flex-1">
36+ <div class="flex items-center gap-1.5">
37+ <span class="truncate font-bold">@{person.handle}</span>
38+ {#if person.is_followed}<span class="tag">Following</span>{/if}
39+ </div>
40+ {#if person.display_name}<div
41+ class="truncate text-xs text-[var(--muted)]"
42+ >
43+ {person.display_name}
44+ </div>{/if}
45+ </div>
46+ <span class="text-[0.65rem] font-bold uppercase text-[var(--muted)]"
47+ >{person.common_feeds} shared</span
48+ >
49+ {#if !person.is_followed}
50+ <button
51+ onclick={dismiss}
52+ class="text-[var(--muted)] hover:text-[var(--danger)]"
53+ title="Hide"
54+ aria-label="Hide"
55+ >
56+ <svg
57+ class="h-4 w-4"
58+ fill="none"
59+ stroke="currentColor"
60+ stroke-width="2"
61+ viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg
62+ >
63+ </button>
64+ {/if}
65+</a>
new file mode 100644
@@ -0,0 +1,65 @@
1+<script lang="ts">
2+ import type { PersonRecommendation } from "$lib/types";
3+ import { endpoints } from "$lib/api";
4+
5+ interface Props {
6+ person: PersonRecommendation;
7+ onDismiss?: (did: string) => void;
8+ }
9+ let { person, onDismiss }: Props = $props();
10+
11+ async function dismiss(e: MouseEvent) {
12+ e.preventDefault();
13+ e.stopPropagation();
14+ await endpoints.dismissPerson(person.did);
15+ onDismiss?.(person.did);
16+ }
17+</script>
18+
19+<a
20+ href="/profile/{person.did}"
21+ class="panel panel-press flex items-center gap-3 p-3"
22+>
23+ {#if person.avatar_url}
24+ <img
25+ src={person.avatar_url}
26+ class="h-10 w-10 border-2 border-[var(--border)] object-cover"
27+ alt=""
28+ />
29+ {:else}
30+ <span
31+ class="inline-flex h-10 w-10 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] font-extrabold uppercase"
32+ >{(person.handle || "?").charAt(0)}</span
33+ >
34+ {/if}
35+ <div class="min-w-0 flex-1">
36+ <div class="flex items-center gap-1.5">
37+ <span class="truncate font-bold">@{person.handle}</span>
38+ {#if person.is_followed}<span class="tag">Following</span>{/if}
39+ </div>
40+ {#if person.display_name}<div
41+ class="truncate text-xs text-[var(--muted)]"
42+ >
43+ {person.display_name}
44+ </div>{/if}
45+ </div>
46+ <span class="text-[0.65rem] font-bold uppercase text-[var(--muted)]"
47+ >{person.common_feeds} shared</span
48+ >
49+ {#if !person.is_followed}
50+ <button
51+ onclick={dismiss}
52+ class="text-[var(--muted)] hover:text-[var(--danger)]"
53+ title="Hide"
54+ aria-label="Hide"
55+ >
56+ <svg
57+ class="h-4 w-4"
58+ fill="none"
59+ stroke="currentColor"
60+ stroke-width="2"
61+ viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg
62+ >
63+ </button>
64+ {/if}
65+</a>
added web/src/lib/components/ShortcutsDialog.svelte +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+<script lang="ts">
2+ import Icon from "./Icon.svelte";
3+
4+ interface Props {
5+ open: boolean;
6+ onClose: () => void;
7+ }
8+ let { open, onClose }: Props = $props();
9+
10+ const nav = [
11+ ["g", "Dashboard"],
12+ ["a", "Articles"],
13+ ["f", "Feeds"],
14+ ["t", "Trending"],
15+ ["l", "Library"],
16+ ];
17+ const articles = [
18+ ["j", "Next article"],
19+ ["k", "Previous article"],
20+ ["o", "Open article"],
21+ ["m", "Toggle read"],
22+ ];
23+</script>
24+
25+{#if open}
26+ <div
27+ class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
28+ onclick={onClose}
29+ onkeydown={(e) => e.key === "Escape" && onClose()}
30+ role="presentation"
31+ >
32+ <div
33+ class="w-full max-w-sm border-2 border-[var(--border)] bg-[var(--bg)] shadow-[6px_6px_0_0_var(--border)]"
34+ onclick={(e) => e.stopPropagation()}
35+ onkeydown={(e) => e.stopPropagation()}
36+ role="dialog"
37+ aria-modal="true"
38+ tabindex="-1"
39+ >
40+ <div
41+ class="flex items-center justify-between border-b-2 border-[var(--border)] px-5 py-3"
42+ >
43+ <h3 class="text-xs font-extrabold uppercase tracking-widest">
44+ Shortcuts
45+ </h3>
46+ <button
47+ onclick={onClose}
48+ aria-label="Close"
49+ class="hover:text-[var(--danger)]"
50+ ><Icon name="x" class="h-4 w-4" /></button
51+ >
52+ </div>
53+ <div class="space-y-4 p-5 text-sm">
54+ {#each [{ title: "Navigation", rows: nav }, { title: "Articles", rows: articles }] as section}
55+ <div>
56+ <p
57+ class="mb-2 text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
58+ >
59+ {section.title}
60+ </p>
61+ <div
62+ class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5"
63+ >
64+ {#each section.rows as [k, label]}
65+ <span class="kbd-brutal">{k}</span>
66+ <span class="self-center text-[var(--muted)]"
67+ >{label}</span
68+ >
69+ {/each}
70+ </div>
71+ </div>
72+ {/each}
73+ </div>
74+ </div>
75+ </div>
76+{/if}
new file mode 100644
@@ -0,0 +1,76 @@
1+<script lang="ts">
2+ import Icon from "./Icon.svelte";
3+
4+ interface Props {
5+ open: boolean;
6+ onClose: () => void;
7+ }
8+ let { open, onClose }: Props = $props();
9+
10+ const nav = [
11+ ["g", "Dashboard"],
12+ ["a", "Articles"],
13+ ["f", "Feeds"],
14+ ["t", "Trending"],
15+ ["l", "Library"],
16+ ];
17+ const articles = [
18+ ["j", "Next article"],
19+ ["k", "Previous article"],
20+ ["o", "Open article"],
21+ ["m", "Toggle read"],
22+ ];
23+</script>
24+
25+{#if open}
26+ <div
27+ class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
28+ onclick={onClose}
29+ onkeydown={(e) => e.key === "Escape" && onClose()}
30+ role="presentation"
31+ >
32+ <div
33+ class="w-full max-w-sm border-2 border-[var(--border)] bg-[var(--bg)] shadow-[6px_6px_0_0_var(--border)]"
34+ onclick={(e) => e.stopPropagation()}
35+ onkeydown={(e) => e.stopPropagation()}
36+ role="dialog"
37+ aria-modal="true"
38+ tabindex="-1"
39+ >
40+ <div
41+ class="flex items-center justify-between border-b-2 border-[var(--border)] px-5 py-3"
42+ >
43+ <h3 class="text-xs font-extrabold uppercase tracking-widest">
44+ Shortcuts
45+ </h3>
46+ <button
47+ onclick={onClose}
48+ aria-label="Close"
49+ class="hover:text-[var(--danger)]"
50+ ><Icon name="x" class="h-4 w-4" /></button
51+ >
52+ </div>
53+ <div class="space-y-4 p-5 text-sm">
54+ {#each [{ title: "Navigation", rows: nav }, { title: "Articles", rows: articles }] as section}
55+ <div>
56+ <p
57+ class="mb-2 text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
58+ >
59+ {section.title}
60+ </p>
61+ <div
62+ class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5"
63+ >
64+ {#each section.rows as [k, label]}
65+ <span class="kbd-brutal">{k}</span>
66+ <span class="self-center text-[var(--muted)]"
67+ >{label}</span
68+ >
69+ {/each}
70+ </div>
71+ </div>
72+ {/each}
73+ </div>
74+ </div>
75+ </div>
76+{/if}
added web/src/lib/components/ThemeToggle.svelte +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+<script lang="ts">
2+ interface Props {
3+ block?: boolean;
4+ }
5+ let { block = false }: Props = $props();
6+
7+ let theme = $state("light");
8+ $effect(() => {
9+ theme =
10+ (typeof document !== "undefined" &&
11+ document.documentElement.getAttribute("data-theme")) ||
12+ "light";
13+ });
14+
15+ function toggle() {
16+ theme = theme === "dark" ? "light" : "dark";
17+ localStorage.setItem("theme", theme);
18+ document.documentElement.setAttribute("data-theme", theme);
19+ }
20+</script>
21+
22+{#if block}
23+ <button
24+ onclick={toggle}
25+ class="block font-bold uppercase hover:text-[var(--accent)]"
26+ >{theme === "dark" ? "Light" : "Dark"} mode</button
27+ >
28+{:else}
29+ <button
30+ onclick={toggle}
31+ class="btn btn-ghost px-2"
32+ title="Toggle theme"
33+ aria-label="Toggle theme"
34+ >
35+ <svg
36+ class="h-4 w-4"
37+ fill="none"
38+ stroke="currentColor"
39+ stroke-width="1.75"
40+ viewBox="0 0 24 24"
41+ >
42+ {#if theme === "dark"}
43+ <path
44+ d="M12 4V2M12 22v-2M4 12H2M22 12h-2M6 6L4 4M20 4l-2 2M6 18l-2 2M20 20l-2-2M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"
45+ />
46+ {:else}
47+ <path d="M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10z" />
48+ {/if}
49+ </svg>
50+ </button>
51+{/if}
new file mode 100644
@@ -0,0 +1,51 @@
1+<script lang="ts">
2+ interface Props {
3+ block?: boolean;
4+ }
5+ let { block = false }: Props = $props();
6+
7+ let theme = $state("light");
8+ $effect(() => {
9+ theme =
10+ (typeof document !== "undefined" &&
11+ document.documentElement.getAttribute("data-theme")) ||
12+ "light";
13+ });
14+
15+ function toggle() {
16+ theme = theme === "dark" ? "light" : "dark";
17+ localStorage.setItem("theme", theme);
18+ document.documentElement.setAttribute("data-theme", theme);
19+ }
20+</script>
21+
22+{#if block}
23+ <button
24+ onclick={toggle}
25+ class="block font-bold uppercase hover:text-[var(--accent)]"
26+ >{theme === "dark" ? "Light" : "Dark"} mode</button
27+ >
28+{:else}
29+ <button
30+ onclick={toggle}
31+ class="btn btn-ghost px-2"
32+ title="Toggle theme"
33+ aria-label="Toggle theme"
34+ >
35+ <svg
36+ class="h-4 w-4"
37+ fill="none"
38+ stroke="currentColor"
39+ stroke-width="1.75"
40+ viewBox="0 0 24 24"
41+ >
42+ {#if theme === "dark"}
43+ <path
44+ d="M12 4V2M12 22v-2M4 12H2M22 12h-2M6 6L4 4M20 4l-2 2M6 18l-2 2M20 20l-2-2M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"
45+ />
46+ {:else}
47+ <path d="M21 13a9 9 0 1 1-10-10 7 7 0 0 0 10 10z" />
48+ {/if}
49+ </svg>
50+ </button>
51+{/if}
added web/src/lib/components/TrendingCard.svelte +50 -0
new file mode 100644
@@ -0,0 +1,50 @@
1+<script lang="ts">
2+ import type { TrendingItem } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import LikeButton from "./LikeButton.svelte";
5+ import Icon from "./Icon.svelte";
6+ import { plainText } from "$lib/format";
7+
8+ interface Props {
9+ item: TrendingItem;
10+ }
11+ let { item }: Props = $props();
12+</script>
13+
14+<div class="panel panel-press p-4">
15+ <div class="flex items-start justify-between gap-4">
16+ <div class="min-w-0 flex-1">
17+ <a
18+ href="/articles/{item.article_id}"
19+ class="block text-base font-bold leading-snug hover:text-[var(--accent)]"
20+ >{item.title}</a
21+ >
22+ <div
23+ class="mt-2 flex items-center gap-2 text-[0.7rem] text-[var(--muted)]"
24+ >
25+ <Favicon src={item.favicon_url} size="h-3.5 w-3.5" />
26+ <span class="truncate font-medium uppercase tracking-wide"
27+ >{item.feed_title || item.feed_url}{item.author
28+ ? " / " + item.author
29+ : ""}</span
30+ >
31+ </div>
32+ {#if item.summary}
33+ <p class="mt-2 line-clamp-2 text-sm text-[var(--muted)]">
34+ {plainText(item.summary)}
35+ </p>
36+ {/if}
37+ </div>
38+ <div class="flex shrink-0 flex-col items-end gap-1.5">
39+ <LikeButton
40+ articleId={item.article_id}
41+ liked={item.has_liked}
42+ count={item.like_count}
43+ />
44+ <span class="chip" title="Annotations">
45+ <Icon name="note" class="h-3 w-3" />
46+ {item.annotation_count}
47+ </span>
48+ </div>
49+ </div>
50+</div>
new file mode 100644
@@ -0,0 +1,50 @@
1+<script lang="ts">
2+ import type { TrendingItem } from "$lib/types";
3+ import Favicon from "./Favicon.svelte";
4+ import LikeButton from "./LikeButton.svelte";
5+ import Icon from "./Icon.svelte";
6+ import { plainText } from "$lib/format";
7+
8+ interface Props {
9+ item: TrendingItem;
10+ }
11+ let { item }: Props = $props();
12+</script>
13+
14+<div class="panel panel-press p-4">
15+ <div class="flex items-start justify-between gap-4">
16+ <div class="min-w-0 flex-1">
17+ <a
18+ href="/articles/{item.article_id}"
19+ class="block text-base font-bold leading-snug hover:text-[var(--accent)]"
20+ >{item.title}</a
21+ >
22+ <div
23+ class="mt-2 flex items-center gap-2 text-[0.7rem] text-[var(--muted)]"
24+ >
25+ <Favicon src={item.favicon_url} size="h-3.5 w-3.5" />
26+ <span class="truncate font-medium uppercase tracking-wide"
27+ >{item.feed_title || item.feed_url}{item.author
28+ ? " / " + item.author
29+ : ""}</span
30+ >
31+ </div>
32+ {#if item.summary}
33+ <p class="mt-2 line-clamp-2 text-sm text-[var(--muted)]">
34+ {plainText(item.summary)}
35+ </p>
36+ {/if}
37+ </div>
38+ <div class="flex shrink-0 flex-col items-end gap-1.5">
39+ <LikeButton
40+ articleId={item.article_id}
41+ liked={item.has_liked}
42+ count={item.like_count}
43+ />
44+ <span class="chip" title="Annotations">
45+ <Icon name="note" class="h-3 w-3" />
46+ {item.annotation_count}
47+ </span>
48+ </div>
49+ </div>
50+</div>
added web/src/lib/format.ts +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+export function formatDate(t: string | null | undefined): string {
2+ if (!t) return '';
3+ const d = new Date(t);
4+ if (isNaN(d.getTime())) return '';
5+ return d.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
6+}
7+
8+export function formatDateTime(t: string | number | null | undefined): string {
9+ if (t == null || t === '') return '';
10+ const d = typeof t === 'number' ? new Date(t * 1000) : new Date(t);
11+ if (isNaN(d.getTime())) return '';
12+ return d.toLocaleString('en-US', {
13+ month: 'short',
14+ day: '2-digit',
15+ year: 'numeric',
16+ hour: '2-digit',
17+ minute: '2-digit'
18+ });
19+}
20+
21+export function plainText(html: string): string {
22+ if (!html) return '';
23+ const s = html
24+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
25+ .replace(/<[^>]+>/g, ' ')
26+ .replace(/&amp;/g, '&')
27+ .replace(/&lt;/g, '<')
28+ .replace(/&gt;/g, '>')
29+ .replace(/&quot;/g, '"')
30+ .replace(/&#39;/g, "'")
31+ .replace(/&apos;/g, "'")
32+ .replace(/&nbsp;/g, ' ')
33+ .replace(/&hellip;/g, '...')
34+ .replace(/&mdash;/g, '—')
35+ .replace(/&ndash;/g, '–')
36+ .replace(/&rsquo;/g, '’')
37+ .replace(/&lsquo;/g, '‘')
38+ .replace(/&rdquo;/g, '"')
39+ .replace(/&ldquo;/g, '"')
40+ .replace(/&#\d+;/g, '')
41+ .replace(/\s+/g, ' ')
42+ .trim();
43+ return s;
44+}
45+
46+const YOUTUBE_HOSTS = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be'];
47+const EMBED_HOSTS = [
48+ 'www.youtube.com',
49+ 'youtube.com',
50+ 'm.youtube.com',
51+ 'youtu.be',
52+ 'vimeo.com',
53+ 'player.vimeo.com',
54+ 'open.spotify.com',
55+ 'embed.spotify.com',
56+ 'w.soundcloud.com',
57+ 'bandcamp.com'
58+];
59+
60+export function youtubeID(rawURL: string): string {
61+ if (!rawURL) return '';
62+ let u: URL;
63+ try {
64+ u = new URL(rawURL);
65+ } catch {
66+ return '';
67+ }
68+ const host = u.hostname.toLowerCase();
69+ if (host === 'youtu.be') {
70+ return u.pathname.slice(1);
71+ }
72+ if (host === 'www.youtube.com' || host === 'youtube.com' || host === 'm.youtube.com') {
73+ if (u.pathname === '/watch' || u.pathname === '/watch/') {
74+ return u.searchParams.get('v') ?? '';
75+ }
76+ if (u.pathname.startsWith('/embed/')) return u.pathname.slice('/embed/'.length);
77+ if (u.pathname.startsWith('/shorts/')) return u.pathname.slice('/shorts/'.length);
78+ }
79+ return '';
80+}
81+
82+export function isEmbedURL(rawURL: string): boolean {
83+ if (!rawURL) return false;
84+ try {
85+ const u = new URL(rawURL);
86+ return EMBED_HOSTS.includes(u.hostname.toLowerCase());
87+ } catch {
88+ return false;
89+ }
90+}
91+
92+export { YOUTUBE_HOSTS };
new file mode 100644
@@ -0,0 +1,92 @@
1+export function formatDate(t: string | null | undefined): string {
2+ if (!t) return '';
3+ const d = new Date(t);
4+ if (isNaN(d.getTime())) return '';
5+ return d.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
6+}
7+
8+export function formatDateTime(t: string | number | null | undefined): string {
9+ if (t == null || t === '') return '';
10+ const d = typeof t === 'number' ? new Date(t * 1000) : new Date(t);
11+ if (isNaN(d.getTime())) return '';
12+ return d.toLocaleString('en-US', {
13+ month: 'short',
14+ day: '2-digit',
15+ year: 'numeric',
16+ hour: '2-digit',
17+ minute: '2-digit'
18+ });
19+}
20+
21+export function plainText(html: string): string {
22+ if (!html) return '';
23+ const s = html
24+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
25+ .replace(/<[^>]+>/g, ' ')
26+ .replace(/&amp;/g, '&')
27+ .replace(/&lt;/g, '<')
28+ .replace(/&gt;/g, '>')
29+ .replace(/&quot;/g, '"')
30+ .replace(/&#39;/g, "'")
31+ .replace(/&apos;/g, "'")
32+ .replace(/&nbsp;/g, ' ')
33+ .replace(/&hellip;/g, '...')
34+ .replace(/&mdash;/g, '—')
35+ .replace(/&ndash;/g, '–')
36+ .replace(/&rsquo;/g, '’')
37+ .replace(/&lsquo;/g, '‘')
38+ .replace(/&rdquo;/g, '"')
39+ .replace(/&ldquo;/g, '"')
40+ .replace(/&#\d+;/g, '')
41+ .replace(/\s+/g, ' ')
42+ .trim();
43+ return s;
44+}
45+
46+const YOUTUBE_HOSTS = ['www.youtube.com', 'youtube.com', 'm.youtube.com', 'youtu.be'];
47+const EMBED_HOSTS = [
48+ 'www.youtube.com',
49+ 'youtube.com',
50+ 'm.youtube.com',
51+ 'youtu.be',
52+ 'vimeo.com',
53+ 'player.vimeo.com',
54+ 'open.spotify.com',
55+ 'embed.spotify.com',
56+ 'w.soundcloud.com',
57+ 'bandcamp.com'
58+];
59+
60+export function youtubeID(rawURL: string): string {
61+ if (!rawURL) return '';
62+ let u: URL;
63+ try {
64+ u = new URL(rawURL);
65+ } catch {
66+ return '';
67+ }
68+ const host = u.hostname.toLowerCase();
69+ if (host === 'youtu.be') {
70+ return u.pathname.slice(1);
71+ }
72+ if (host === 'www.youtube.com' || host === 'youtube.com' || host === 'm.youtube.com') {
73+ if (u.pathname === '/watch' || u.pathname === '/watch/') {
74+ return u.searchParams.get('v') ?? '';
75+ }
76+ if (u.pathname.startsWith('/embed/')) return u.pathname.slice('/embed/'.length);
77+ if (u.pathname.startsWith('/shorts/')) return u.pathname.slice('/shorts/'.length);
78+ }
79+ return '';
80+}
81+
82+export function isEmbedURL(rawURL: string): boolean {
83+ if (!rawURL) return false;
84+ try {
85+ const u = new URL(rawURL);
86+ return EMBED_HOSTS.includes(u.hostname.toLowerCase());
87+ } catch {
88+ return false;
89+ }
90+}
91+
92+export { YOUTUBE_HOSTS };
added web/src/lib/types.ts +142 -0
new file mode 100644
@@ -0,0 +1,142 @@
1+export interface User {
2+ did: string;
3+ handle: string;
4+ display_name: string;
5+ avatar_url: string;
6+}
7+
8+export interface Pagination {
9+ page: number;
10+ page_size: number;
11+ has_prev: boolean;
12+ has_next: boolean;
13+ prev_page: number;
14+ next_page: number;
15+}
16+
17+export interface Article {
18+ id: number;
19+ feed_url: string;
20+ feed_title: string;
21+ feed_favicon_url: string;
22+ title: string;
23+ url: string;
24+ author: string;
25+ summary: string;
26+ content: string;
27+ full_content: string;
28+ published: string | null;
29+ updated: string | null;
30+ is_read: boolean;
31+ like_count: number;
32+ has_liked: boolean;
33+}
34+
35+export interface Feed {
36+ feed_url: string;
37+ title: string;
38+ site_url: string;
39+ description: string;
40+ feed_type: string;
41+ favicon_url: string;
42+ subscriber_count: number;
43+ error_count: number;
44+ last_error: string;
45+ last_fetched_at: string | null;
46+}
47+
48+export interface Subscription {
49+ id: number;
50+ feed_url: string;
51+ feed_title: string;
52+ category: string;
53+ added_at: string | null;
54+ unread_count: number;
55+ favicon_url: string;
56+}
57+
58+export interface Annotation {
59+ id: number;
60+ author_did: string;
61+ author_handle: string;
62+ feed_url: string;
63+ article_url: string;
64+ article_id: number | null;
65+ quote: string;
66+ note: string;
67+ tags: string[];
68+ rating: number | null;
69+ created_at: string | null;
70+}
71+
72+export interface TrendingItem {
73+ article_id: number;
74+ title: string;
75+ url: string;
76+ author: string;
77+ summary: string;
78+ feed_url: string;
79+ feed_title: string;
80+ favicon_url: string;
81+ like_count: number;
82+ annotation_count: number;
83+ has_liked: boolean;
84+}
85+
86+export interface FeedRecommendation {
87+ feed_url: string;
88+ title: string;
89+ site_url: string;
90+ description: string;
91+ subscriber_count: number;
92+ favicon_url: string;
93+ score: number;
94+}
95+
96+export interface PersonRecommendation {
97+ did: string;
98+ handle: string;
99+ display_name: string;
100+ avatar_url: string;
101+ common_feeds: number;
102+ common_likes: number;
103+ common_tags: number;
104+ is_followed: boolean;
105+ score: number;
106+}
107+
108+export interface Digest {
109+ title: string;
110+ summary: string;
111+ excerpt: string;
112+ article_ids: number[];
113+ generated_at: number;
114+ consumed: boolean;
115+}
116+
117+export interface KnownLanguage {
118+ code: string;
119+ name: string;
120+}
121+
122+export interface MeResponse {
123+ user: User | null;
124+ csrf_token: string;
125+ has_llm: boolean;
126+ client_id: string;
127+}
128+
129+export interface Actor {
130+ did: string;
131+ handle: string;
132+ displayName?: string;
133+ avatar?: string;
134+}
135+
136+export interface MetricFamily {
137+ name: string;
138+ type: string;
139+ description: string;
140+ labels: Record<string, string> | null;
141+ value: number;
142+}
new file mode 100644
@@ -0,0 +1,142 @@
1+export interface User {
2+ did: string;
3+ handle: string;
4+ display_name: string;
5+ avatar_url: string;
6+}
7+
8+export interface Pagination {
9+ page: number;
10+ page_size: number;
11+ has_prev: boolean;
12+ has_next: boolean;
13+ prev_page: number;
14+ next_page: number;
15+}
16+
17+export interface Article {
18+ id: number;
19+ feed_url: string;
20+ feed_title: string;
21+ feed_favicon_url: string;
22+ title: string;
23+ url: string;
24+ author: string;
25+ summary: string;
26+ content: string;
27+ full_content: string;
28+ published: string | null;
29+ updated: string | null;
30+ is_read: boolean;
31+ like_count: number;
32+ has_liked: boolean;
33+}
34+
35+export interface Feed {
36+ feed_url: string;
37+ title: string;
38+ site_url: string;
39+ description: string;
40+ feed_type: string;
41+ favicon_url: string;
42+ subscriber_count: number;
43+ error_count: number;
44+ last_error: string;
45+ last_fetched_at: string | null;
46+}
47+
48+export interface Subscription {
49+ id: number;
50+ feed_url: string;
51+ feed_title: string;
52+ category: string;
53+ added_at: string | null;
54+ unread_count: number;
55+ favicon_url: string;
56+}
57+
58+export interface Annotation {
59+ id: number;
60+ author_did: string;
61+ author_handle: string;
62+ feed_url: string;
63+ article_url: string;
64+ article_id: number | null;
65+ quote: string;
66+ note: string;
67+ tags: string[];
68+ rating: number | null;
69+ created_at: string | null;
70+}
71+
72+export interface TrendingItem {
73+ article_id: number;
74+ title: string;
75+ url: string;
76+ author: string;
77+ summary: string;
78+ feed_url: string;
79+ feed_title: string;
80+ favicon_url: string;
81+ like_count: number;
82+ annotation_count: number;
83+ has_liked: boolean;
84+}
85+
86+export interface FeedRecommendation {
87+ feed_url: string;
88+ title: string;
89+ site_url: string;
90+ description: string;
91+ subscriber_count: number;
92+ favicon_url: string;
93+ score: number;
94+}
95+
96+export interface PersonRecommendation {
97+ did: string;
98+ handle: string;
99+ display_name: string;
100+ avatar_url: string;
101+ common_feeds: number;
102+ common_likes: number;
103+ common_tags: number;
104+ is_followed: boolean;
105+ score: number;
106+}
107+
108+export interface Digest {
109+ title: string;
110+ summary: string;
111+ excerpt: string;
112+ article_ids: number[];
113+ generated_at: number;
114+ consumed: boolean;
115+}
116+
117+export interface KnownLanguage {
118+ code: string;
119+ name: string;
120+}
121+
122+export interface MeResponse {
123+ user: User | null;
124+ csrf_token: string;
125+ has_llm: boolean;
126+ client_id: string;
127+}
128+
129+export interface Actor {
130+ did: string;
131+ handle: string;
132+ displayName?: string;
133+ avatar?: string;
134+}
135+
136+export interface MetricFamily {
137+ name: string;
138+ type: string;
139+ description: string;
140+ labels: Record<string, string> | null;
141+ value: number;
142+}
added web/src/routes/+error.svelte +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+<script lang="ts">
2+ import { page } from "$app/state";
3+ import Logo from "$lib/components/Logo.svelte";
4+</script>
5+
6+<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12">
7+ <div class="w-full max-w-sm text-center">
8+ <div class="mb-8 flex justify-center"><Logo size="lg" /></div>
9+ <div class="mb-2 text-6xl font-extrabold text-[var(--accent)]">
10+ {page.status}
11+ </div>
12+ {#if page.status === 404}
13+ <h1 class="text-xl font-extrabold uppercase tracking-wide">
14+ Not found
15+ </h1>
16+ <p class="mt-2 text-sm text-[var(--muted)]">
17+ This page doesn't exist.
18+ </p>
19+ {:else}
20+ <h1 class="text-xl font-extrabold uppercase tracking-wide">
21+ Error
22+ </h1>
23+ <p class="mt-2 text-sm text-[var(--muted)]">
24+ {page.error?.message ?? "Something went wrong."}
25+ </p>
26+ {/if}
27+ <a href="/" class="btn btn-accent mt-8">Home</a>
28+ </div>
29+</div>
new file mode 100644
@@ -0,0 +1,29 @@
1+<script lang="ts">
2+ import { page } from "$app/state";
3+ import Logo from "$lib/components/Logo.svelte";
4+</script>
5+
6+<div class="flex min-h-screen flex-col items-center justify-center px-4 py-12">
7+ <div class="w-full max-w-sm text-center">
8+ <div class="mb-8 flex justify-center"><Logo size="lg" /></div>
9+ <div class="mb-2 text-6xl font-extrabold text-[var(--accent)]">
10+ {page.status}
11+ </div>
12+ {#if page.status === 404}
13+ <h1 class="text-xl font-extrabold uppercase tracking-wide">
14+ Not found
15+ </h1>
16+ <p class="mt-2 text-sm text-[var(--muted)]">
17+ This page doesn't exist.
18+ </p>
19+ {:else}
20+ <h1 class="text-xl font-extrabold uppercase tracking-wide">
21+ Error
22+ </h1>
23+ <p class="mt-2 text-sm text-[var(--muted)]">
24+ {page.error?.message ?? "Something went wrong."}
25+ </p>
26+ {/if}
27+ <a href="/" class="btn btn-accent mt-8">Home</a>
28+ </div>
29+</div>
added web/src/routes/+layout.server.ts +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+import type { LayoutServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+
4+export const load: LayoutServerLoad = async (event) => {
5+ const api = endpointsFor(event.fetch);
6+ try {
7+ const me = await api.me();
8+ return {
9+ user: me.user,
10+ csrfToken: me.csrf_token,
11+ hasLLM: me.has_llm,
12+ clientID: me.client_id,
13+ };
14+ } catch {
15+ return { user: null, csrfToken: "", hasLLM: false, clientID: "" };
16+ }
17+};
new file mode 100644
@@ -0,0 +1,17 @@
1+import type { LayoutServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+
4+export const load: LayoutServerLoad = async (event) => {
5+ const api = endpointsFor(event.fetch);
6+ try {
7+ const me = await api.me();
8+ return {
9+ user: me.user,
10+ csrfToken: me.csrf_token,
11+ hasLLM: me.has_llm,
12+ clientID: me.client_id,
13+ };
14+ } catch {
15+ return { user: null, csrfToken: "", hasLLM: false, clientID: "" };
16+ }
17+};
added web/src/routes/+layout.svelte +354 -0
new file mode 100644
@@ -0,0 +1,354 @@
1+<script lang="ts">
2+ import "../app.css";
3+ import { page } from "$app/state";
4+ import { goto } from "$app/navigation";
5+ import type { LayoutData } from "./$types";
6+ import Logo from "$lib/components/Logo.svelte";
7+ import Icon from "$lib/components/Icon.svelte";
8+ import ThemeToggle from "$lib/components/ThemeToggle.svelte";
9+ import ShortcutsDialog from "$lib/components/ShortcutsDialog.svelte";
10+ import InstallDialog from "$lib/components/InstallDialog.svelte";
11+ import { setCsrfToken, endpoints } from "$lib/api";
12+
13+ let {
14+ data,
15+ children,
16+ }: { data: LayoutData; children: import("svelte").Snippet } = $props();
17+
18+ $effect(() => {
19+ setCsrfToken(data.csrfToken);
20+ });
21+
22+ const chromeless = $derived(
23+ page.url.pathname === "/" ||
24+ page.url.pathname.startsWith("/auth/") ||
25+ page.url.pathname === "/terms",
26+ );
27+
28+ let menuOpen = $state(false);
29+ let showShortcuts = $state(false);
30+ let showInstall = $state(false);
31+
32+ const navItems = $derived(
33+ data.user
34+ ? [
35+ {
36+ href: "/dashboard",
37+ label: "Dashboard",
38+ icon: "grid",
39+ match: "/dashboard",
40+ },
41+ {
42+ href: "/articles",
43+ label: "Articles",
44+ icon: "feed",
45+ match: "/articles",
46+ },
47+ {
48+ href: "/trending",
49+ label: "Trending",
50+ icon: "trending",
51+ match: "/trending",
52+ },
53+ {
54+ href: "/feeds",
55+ label: "Feeds",
56+ icon: "globe",
57+ match: "/feeds",
58+ },
59+ {
60+ href: "/library",
61+ label: "Library",
62+ icon: "note",
63+ match: "/library",
64+ },
65+ ]
66+ : [
67+ {
68+ href: "/trending",
69+ label: "Trending",
70+ icon: "trending",
71+ match: "/trending",
72+ },
73+ {
74+ href: "/articles",
75+ label: "Articles",
76+ icon: "feed",
77+ match: "/articles",
78+ },
79+ ],
80+ );
81+
82+ function isActive(match: string): boolean {
83+ const p = page.url.pathname;
84+ return match === "/articles"
85+ ? p.startsWith("/articles")
86+ : p === match || p.startsWith(match + "/");
87+ }
88+
89+ async function logout() {
90+ try {
91+ await endpoints.authLogout();
92+ } catch {
93+ // ignore
94+ }
95+ menuOpen = false;
96+ goto("/", { invalidateAll: true });
97+ }
98+
99+ function handleKeydown(e: KeyboardEvent) {
100+ const t = e.target as HTMLElement | null;
101+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) return;
102+ if (e.ctrlKey || e.metaKey || e.altKey) return;
103+ if (!data.user) return;
104+ const map: Record<string, string> = {
105+ g: "/dashboard",
106+ a: "/articles",
107+ f: "/feeds",
108+ t: "/trending",
109+ l: "/library",
110+ };
111+ if (map[e.key]) goto(map[e.key]);
112+ }
113+</script>
114+
115+<svelte:head>
116+ <title>Glean</title>
117+ <meta property="og:title" content="Glean" />
118+ <meta
119+ property="og:description"
120+ content="The social RSS reader built on AT Protocol."
121+ />
122+ <meta property="og:image" content="/banner.png" />
123+ <meta property="og:type" content="website" />
124+ <meta name="theme-color" content="#00754A" />
125+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
126+ <link rel="manifest" href="/manifest.json" />
127+</svelte:head>
128+
129+<svelte:window onkeydown={handleKeydown} />
130+
131+<div class="min-h-screen flex flex-col">
132+ <!-- Top header bar -->
133+ <header
134+ class="sticky top-0 z-30 border-b-2 border-[var(--border)] bg-[var(--bg)]"
135+ >
136+ <div class="mx-auto flex h-14 max-w-5xl items-center gap-4 px-4">
137+ <Logo />
138+
139+ {#if !chromeless}
140+ <nav class="hidden items-center gap-1 md:flex">
141+ {#each navItems as item}
142+ <a
143+ href={item.href}
144+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition {isActive(
145+ item.match,
146+ )
147+ ? 'bg-[var(--fg)] text-[var(--bg)]'
148+ : 'text-[var(--muted)] hover:text-[var(--fg)] hover:bg-[var(--surface)]'}"
149+ >
150+ <Icon name={item.icon} class="h-3.5 w-3.5" />
151+ {item.label}
152+ </a>
153+ {/each}
154+ </nav>
155+
156+ <div class="ml-auto flex items-center gap-2">
157+ <button
158+ class="btn btn-ghost px-2"
159+ title="Search"
160+ onclick={() => goto("/articles")}
161+ aria-label="Search"
162+ >
163+ <Icon name="search" class="h-4 w-4" />
164+ </button>
165+ <button
166+ class="btn btn-ghost hidden px-2 sm:inline-flex"
167+ onclick={() => (showShortcuts = true)}
168+ title="Shortcuts"
169+ aria-label="Shortcuts"
170+ >
171+ <Icon name="keyboard" class="h-4 w-4" />
172+ </button>
173+ {#if data.user}
174+ <div class="relative">
175+ <button
176+ class="flex items-center gap-2 border-2 border-[var(--border)] px-2 py-1"
177+ onclick={() => (menuOpen = !menuOpen)}
178+ >
179+ {#if data.user.avatar_url}
180+ <img
181+ src={data.user.avatar_url}
182+ class="h-6 w-6 border border-[var(--border)] object-cover"
183+ alt=""
184+ />
185+ {/if}
186+ <span class="text-xs font-bold"
187+ >@{data.user.handle}</span
188+ >
189+ <Icon name="chevronDown" class="h-3 w-3" />
190+ </button>
191+ {#if menuOpen}
192+ <button
193+ class="fixed inset-0 z-40 cursor-default"
194+ onclick={() => (menuOpen = false)}
195+ aria-label="Close menu"
196+ tabindex="-1"
197+ ></button>
198+ <div
199+ class="absolute right-0 top-full z-50 mt-1 w-48 border-2 border-[var(--border)] bg-[var(--bg)] shadow-[4px_4px_0_0_var(--border)]"
200+ >
201+ <a
202+ href="/profile/{data.user.did}"
203+ class="block border-b-2 border-[var(--border)] px-4 py-2.5 text-xs font-bold uppercase hover:bg-[var(--surface)]"
204+ onclick={() => (menuOpen = false)}
205+ >Profile</a
206+ >
207+ <button
208+ class="block w-full px-4 py-2.5 text-left text-xs font-bold uppercase hover:bg-[var(--surface)]"
209+ onclick={() => (showInstall = true)}
210+ >Install App</button
211+ >
212+ <button
213+ class="block w-full border-t-2 border-[var(--border)] px-4 py-2.5 text-left text-xs font-bold uppercase text-[var(--danger)] hover:bg-[var(--surface)]"
214+ onclick={logout}>Sign out</button
215+ >
216+ </div>
217+ {/if}
218+ </div>
219+ {:else}
220+ <a href="/auth/login" class="btn btn-accent">Sign in</a>
221+ {/if}
222+ </div>
223+ {:else}
224+ <div class="ml-auto flex items-center gap-2">
225+ <ThemeToggle />
226+ {#if !data.user}
227+ <a href="/auth/login" class="btn btn-accent">Sign in</a>
228+ {/if}
229+ </div>
230+ {/if}
231+ </div>
232+
233+ <!-- Mobile nav row -->
234+ {#if !chromeless}
235+ <nav
236+ class="flex items-center gap-1 overflow-x-auto border-t-2 border-[var(--border)] px-2 py-1 md:hidden"
237+ >
238+ {#each navItems as item}
239+ <a
240+ href={item.href}
241+ class="inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 text-[0.7rem] font-bold uppercase {isActive(
242+ item.match,
243+ )
244+ ? 'bg-[var(--fg)] text-[var(--bg)]'
245+ : 'text-[var(--muted)]'}"
246+ >
247+ <Icon name={item.icon} class="h-3.5 w-3.5" />
248+ {item.label}
249+ </a>
250+ {/each}
251+ </nav>
252+ {/if}
253+ </header>
254+
255+ <!-- Content -->
256+ <main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
257+ {@render children()}
258+ </main>
259+
260+ <!-- Footer -->
261+ <footer class="border-t-2 border-[var(--border)]">
262+ <div
263+ class="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8 text-xs md:flex-row md:items-start md:justify-between"
264+ >
265+ <div class="space-y-2">
266+ <Logo size="sm" />
267+ <p class="max-w-xs text-[var(--muted)]">
268+ The social RSS reader on AT Protocol. Your feeds, your data,
269+ yours.
270+ </p>
271+ </div>
272+ <div class="flex flex-wrap gap-x-10 gap-y-6">
273+ <div class="space-y-1.5">
274+ <p
275+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
276+ >
277+ Read
278+ </p>
279+ {#if data.user}<a
280+ href="/dashboard"
281+ class="block font-bold uppercase hover:text-[var(--accent)]"
282+ >Dashboard</a
283+ >{/if}
284+ <a
285+ href="/trending"
286+ class="block font-bold uppercase hover:text-[var(--accent)]"
287+ >Trending</a
288+ >
289+ <a
290+ href="/articles"
291+ class="block font-bold uppercase hover:text-[var(--accent)]"
292+ >Articles</a
293+ >
294+ </div>
295+ <div class="space-y-1.5">
296+ <p
297+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
298+ >
299+ Library
300+ </p>
301+ {#if data.user}
302+ <a
303+ href="/feeds"
304+ class="block font-bold uppercase hover:text-[var(--accent)]"
305+ >Feeds</a
306+ >
307+ <a
308+ href="/library"
309+ class="block font-bold uppercase hover:text-[var(--accent)]"
310+ >Annotations</a
311+ >
312+ {/if}
313+ </div>
314+ <div class="space-y-1.5">
315+ <p
316+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
317+ >
318+ Settings
319+ </p>
320+ <ThemeToggle />
321+ <button
322+ class="block font-bold uppercase hover:text-[var(--accent)]"
323+ onclick={() => (showShortcuts = true)}>Shortcuts</button
324+ >
325+ <button
326+ class="block font-bold uppercase hover:text-[var(--accent)]"
327+ onclick={() => (showInstall = true)}>Install App</button
328+ >
329+ <a
330+ href="/terms"
331+ class="block font-bold uppercase hover:text-[var(--accent)]"
332+ >Terms</a
333+ >
334+ </div>
335+ </div>
336+ </div>
337+ <div class="border-t-2 border-[var(--border)]">
338+ <div
339+ class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
340+ >
341+ <span>&copy; {new Date().getFullYear()} Glean</span>
342+ <span
343+ >Made in Europe · <a
344+ href="https://bsky.app/profile/julien.rbrt.fr"
345+ class="hover:text-[var(--fg)]">julien.rbrt.fr</a
346+ ></span
347+ >
348+ </div>
349+ </div>
350+ </footer>
351+</div>
352+
353+<ShortcutsDialog open={showShortcuts} onClose={() => (showShortcuts = false)} />
354+<InstallDialog open={showInstall} onClose={() => (showInstall = false)} />
new file mode 100644
@@ -0,0 +1,354 @@
1+<script lang="ts">
2+ import "../app.css";
3+ import { page } from "$app/state";
4+ import { goto } from "$app/navigation";
5+ import type { LayoutData } from "./$types";
6+ import Logo from "$lib/components/Logo.svelte";
7+ import Icon from "$lib/components/Icon.svelte";
8+ import ThemeToggle from "$lib/components/ThemeToggle.svelte";
9+ import ShortcutsDialog from "$lib/components/ShortcutsDialog.svelte";
10+ import InstallDialog from "$lib/components/InstallDialog.svelte";
11+ import { setCsrfToken, endpoints } from "$lib/api";
12+
13+ let {
14+ data,
15+ children,
16+ }: { data: LayoutData; children: import("svelte").Snippet } = $props();
17+
18+ $effect(() => {
19+ setCsrfToken(data.csrfToken);
20+ });
21+
22+ const chromeless = $derived(
23+ page.url.pathname === "/" ||
24+ page.url.pathname.startsWith("/auth/") ||
25+ page.url.pathname === "/terms",
26+ );
27+
28+ let menuOpen = $state(false);
29+ let showShortcuts = $state(false);
30+ let showInstall = $state(false);
31+
32+ const navItems = $derived(
33+ data.user
34+ ? [
35+ {
36+ href: "/dashboard",
37+ label: "Dashboard",
38+ icon: "grid",
39+ match: "/dashboard",
40+ },
41+ {
42+ href: "/articles",
43+ label: "Articles",
44+ icon: "feed",
45+ match: "/articles",
46+ },
47+ {
48+ href: "/trending",
49+ label: "Trending",
50+ icon: "trending",
51+ match: "/trending",
52+ },
53+ {
54+ href: "/feeds",
55+ label: "Feeds",
56+ icon: "globe",
57+ match: "/feeds",
58+ },
59+ {
60+ href: "/library",
61+ label: "Library",
62+ icon: "note",
63+ match: "/library",
64+ },
65+ ]
66+ : [
67+ {
68+ href: "/trending",
69+ label: "Trending",
70+ icon: "trending",
71+ match: "/trending",
72+ },
73+ {
74+ href: "/articles",
75+ label: "Articles",
76+ icon: "feed",
77+ match: "/articles",
78+ },
79+ ],
80+ );
81+
82+ function isActive(match: string): boolean {
83+ const p = page.url.pathname;
84+ return match === "/articles"
85+ ? p.startsWith("/articles")
86+ : p === match || p.startsWith(match + "/");
87+ }
88+
89+ async function logout() {
90+ try {
91+ await endpoints.authLogout();
92+ } catch {
93+ // ignore
94+ }
95+ menuOpen = false;
96+ goto("/", { invalidateAll: true });
97+ }
98+
99+ function handleKeydown(e: KeyboardEvent) {
100+ const t = e.target as HTMLElement | null;
101+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) return;
102+ if (e.ctrlKey || e.metaKey || e.altKey) return;
103+ if (!data.user) return;
104+ const map: Record<string, string> = {
105+ g: "/dashboard",
106+ a: "/articles",
107+ f: "/feeds",
108+ t: "/trending",
109+ l: "/library",
110+ };
111+ if (map[e.key]) goto(map[e.key]);
112+ }
113+</script>
114+
115+<svelte:head>
116+ <title>Glean</title>
117+ <meta property="og:title" content="Glean" />
118+ <meta
119+ property="og:description"
120+ content="The social RSS reader built on AT Protocol."
121+ />
122+ <meta property="og:image" content="/banner.png" />
123+ <meta property="og:type" content="website" />
124+ <meta name="theme-color" content="#00754A" />
125+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
126+ <link rel="manifest" href="/manifest.json" />
127+</svelte:head>
128+
129+<svelte:window onkeydown={handleKeydown} />
130+
131+<div class="min-h-screen flex flex-col">
132+ <!-- Top header bar -->
133+ <header
134+ class="sticky top-0 z-30 border-b-2 border-[var(--border)] bg-[var(--bg)]"
135+ >
136+ <div class="mx-auto flex h-14 max-w-5xl items-center gap-4 px-4">
137+ <Logo />
138+
139+ {#if !chromeless}
140+ <nav class="hidden items-center gap-1 md:flex">
141+ {#each navItems as item}
142+ <a
143+ href={item.href}
144+ class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition {isActive(
145+ item.match,
146+ )
147+ ? 'bg-[var(--fg)] text-[var(--bg)]'
148+ : 'text-[var(--muted)] hover:text-[var(--fg)] hover:bg-[var(--surface)]'}"
149+ >
150+ <Icon name={item.icon} class="h-3.5 w-3.5" />
151+ {item.label}
152+ </a>
153+ {/each}
154+ </nav>
155+
156+ <div class="ml-auto flex items-center gap-2">
157+ <button
158+ class="btn btn-ghost px-2"
159+ title="Search"
160+ onclick={() => goto("/articles")}
161+ aria-label="Search"
162+ >
163+ <Icon name="search" class="h-4 w-4" />
164+ </button>
165+ <button
166+ class="btn btn-ghost hidden px-2 sm:inline-flex"
167+ onclick={() => (showShortcuts = true)}
168+ title="Shortcuts"
169+ aria-label="Shortcuts"
170+ >
171+ <Icon name="keyboard" class="h-4 w-4" />
172+ </button>
173+ {#if data.user}
174+ <div class="relative">
175+ <button
176+ class="flex items-center gap-2 border-2 border-[var(--border)] px-2 py-1"
177+ onclick={() => (menuOpen = !menuOpen)}
178+ >
179+ {#if data.user.avatar_url}
180+ <img
181+ src={data.user.avatar_url}
182+ class="h-6 w-6 border border-[var(--border)] object-cover"
183+ alt=""
184+ />
185+ {/if}
186+ <span class="text-xs font-bold"
187+ >@{data.user.handle}</span
188+ >
189+ <Icon name="chevronDown" class="h-3 w-3" />
190+ </button>
191+ {#if menuOpen}
192+ <button
193+ class="fixed inset-0 z-40 cursor-default"
194+ onclick={() => (menuOpen = false)}
195+ aria-label="Close menu"
196+ tabindex="-1"
197+ ></button>
198+ <div
199+ class="absolute right-0 top-full z-50 mt-1 w-48 border-2 border-[var(--border)] bg-[var(--bg)] shadow-[4px_4px_0_0_var(--border)]"
200+ >
201+ <a
202+ href="/profile/{data.user.did}"
203+ class="block border-b-2 border-[var(--border)] px-4 py-2.5 text-xs font-bold uppercase hover:bg-[var(--surface)]"
204+ onclick={() => (menuOpen = false)}
205+ >Profile</a
206+ >
207+ <button
208+ class="block w-full px-4 py-2.5 text-left text-xs font-bold uppercase hover:bg-[var(--surface)]"
209+ onclick={() => (showInstall = true)}
210+ >Install App</button
211+ >
212+ <button
213+ class="block w-full border-t-2 border-[var(--border)] px-4 py-2.5 text-left text-xs font-bold uppercase text-[var(--danger)] hover:bg-[var(--surface)]"
214+ onclick={logout}>Sign out</button
215+ >
216+ </div>
217+ {/if}
218+ </div>
219+ {:else}
220+ <a href="/auth/login" class="btn btn-accent">Sign in</a>
221+ {/if}
222+ </div>
223+ {:else}
224+ <div class="ml-auto flex items-center gap-2">
225+ <ThemeToggle />
226+ {#if !data.user}
227+ <a href="/auth/login" class="btn btn-accent">Sign in</a>
228+ {/if}
229+ </div>
230+ {/if}
231+ </div>
232+
233+ <!-- Mobile nav row -->
234+ {#if !chromeless}
235+ <nav
236+ class="flex items-center gap-1 overflow-x-auto border-t-2 border-[var(--border)] px-2 py-1 md:hidden"
237+ >
238+ {#each navItems as item}
239+ <a
240+ href={item.href}
241+ class="inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 text-[0.7rem] font-bold uppercase {isActive(
242+ item.match,
243+ )
244+ ? 'bg-[var(--fg)] text-[var(--bg)]'
245+ : 'text-[var(--muted)]'}"
246+ >
247+ <Icon name={item.icon} class="h-3.5 w-3.5" />
248+ {item.label}
249+ </a>
250+ {/each}
251+ </nav>
252+ {/if}
253+ </header>
254+
255+ <!-- Content -->
256+ <main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
257+ {@render children()}
258+ </main>
259+
260+ <!-- Footer -->
261+ <footer class="border-t-2 border-[var(--border)]">
262+ <div
263+ class="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8 text-xs md:flex-row md:items-start md:justify-between"
264+ >
265+ <div class="space-y-2">
266+ <Logo size="sm" />
267+ <p class="max-w-xs text-[var(--muted)]">
268+ The social RSS reader on AT Protocol. Your feeds, your data,
269+ yours.
270+ </p>
271+ </div>
272+ <div class="flex flex-wrap gap-x-10 gap-y-6">
273+ <div class="space-y-1.5">
274+ <p
275+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
276+ >
277+ Read
278+ </p>
279+ {#if data.user}<a
280+ href="/dashboard"
281+ class="block font-bold uppercase hover:text-[var(--accent)]"
282+ >Dashboard</a
283+ >{/if}
284+ <a
285+ href="/trending"
286+ class="block font-bold uppercase hover:text-[var(--accent)]"
287+ >Trending</a
288+ >
289+ <a
290+ href="/articles"
291+ class="block font-bold uppercase hover:text-[var(--accent)]"
292+ >Articles</a
293+ >
294+ </div>
295+ <div class="space-y-1.5">
296+ <p
297+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
298+ >
299+ Library
300+ </p>
301+ {#if data.user}
302+ <a
303+ href="/feeds"
304+ class="block font-bold uppercase hover:text-[var(--accent)]"
305+ >Feeds</a
306+ >
307+ <a
308+ href="/library"
309+ class="block font-bold uppercase hover:text-[var(--accent)]"
310+ >Annotations</a
311+ >
312+ {/if}
313+ </div>
314+ <div class="space-y-1.5">
315+ <p
316+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
317+ >
318+ Settings
319+ </p>
320+ <ThemeToggle />
321+ <button
322+ class="block font-bold uppercase hover:text-[var(--accent)]"
323+ onclick={() => (showShortcuts = true)}>Shortcuts</button
324+ >
325+ <button
326+ class="block font-bold uppercase hover:text-[var(--accent)]"
327+ onclick={() => (showInstall = true)}>Install App</button
328+ >
329+ <a
330+ href="/terms"
331+ class="block font-bold uppercase hover:text-[var(--accent)]"
332+ >Terms</a
333+ >
334+ </div>
335+ </div>
336+ </div>
337+ <div class="border-t-2 border-[var(--border)]">
338+ <div
339+ class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
340+ >
341+ <span>&copy; {new Date().getFullYear()} Glean</span>
342+ <span
343+ >Made in Europe · <a
344+ href="https://bsky.app/profile/julien.rbrt.fr"
345+ class="hover:text-[var(--fg)]">julien.rbrt.fr</a
346+ ></span
347+ >
348+ </div>
349+ </div>
350+ </footer>
351+</div>
352+
353+<ShortcutsDialog open={showShortcuts} onClose={() => (showShortcuts = false)} />
354+<InstallDialog open={showInstall} onClose={() => (showInstall = false)} />
added web/src/routes/+page.server.ts +8 -0
new file mode 100644
@@ -0,0 +1,8 @@
1+import { redirect } from '@sveltejs/kit';
2+import type { PageServerLoad } from './$types';
3+
4+export const load: PageServerLoad = async ({ parent }) => {
5+ const { user } = await parent();
6+ if (user) throw redirect(303, '/dashboard');
7+ return {};
8+};
new file mode 100644
@@ -0,0 +1,8 @@
1+import { redirect } from '@sveltejs/kit';
2+import type { PageServerLoad } from './$types';
3+
4+export const load: PageServerLoad = async ({ parent }) => {
5+ const { user } = await parent();
6+ if (user) throw redirect(303, '/dashboard');
7+ return {};
8+};
added web/src/routes/+page.svelte +254 -0
new file mode 100644
@@ -0,0 +1,254 @@
1+<script lang="ts">
2+ // Landing page is static; auth gating happens in +page.server.ts.
3+ import Logo from "$lib/components/Logo.svelte";
4+ import Icon from "$lib/components/Icon.svelte";
5+</script>
6+
7+<!-- HERO -->
8+<section class="border-b-2 border-[var(--border)]">
9+ <div
10+ class="mx-auto grid max-w-5xl grid-cols-1 gap-10 px-4 py-16 lg:grid-cols-2 lg:items-center lg:py-24"
11+ >
12+ <div>
13+ <div class="mb-8"><Logo size="lg" /></div>
14+ <h1
15+ class="text-4xl font-extrabold uppercase leading-[0.95] tracking-tight md:text-6xl"
16+ >
17+ The social<br />RSS reader.
18+ </h1>
19+ <p
20+ class="mt-6 max-w-md text-sm leading-relaxed text-[var(--muted)]"
21+ >
22+ Read your feeds. See what your circle reads. Discover new
23+ sources through personalized recommendations. All on AT
24+ Protocol.
25+ </p>
26+ <div class="mt-8 flex flex-col gap-3 sm:flex-row">
27+ <a href="/auth/login" class="btn btn-accent">Sign in</a>
28+ <a href="/trending" class="btn">See what's trending</a>
29+ </div>
30+ </div>
31+
32+ <!-- Mock dashboard panel -->
33+ <div
34+ class="panel hidden p-0 shadow-[6px_6px_0_0_var(--border)] lg:block"
35+ >
36+ <div
37+ class="flex items-center gap-2 border-b-2 border-[var(--border)] px-3 py-2"
38+ >
39+ <span
40+ class="h-2.5 w-2.5 border-2 border-[var(--border)] bg-[var(--accent)]"
41+ ></span>
42+ <span class="h-2.5 w-2.5 border-2 border-[var(--border)]"
43+ ></span>
44+ <span class="h-2.5 w-2.5 border-2 border-[var(--border)]"
45+ ></span>
46+ <span
47+ class="ml-2 text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)]"
48+ >Glean — Dashboard</span
49+ >
50+ </div>
51+ <div class="divide-y-2 divide-[var(--border)]">
52+ <div class="flex items-start gap-3 px-4 py-3">
53+ <span
54+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center border-2 border-[var(--border)] bg-[var(--accent)] text-xs font-extrabold text-white"
55+ ></span
56+ >
57+ <div class="min-w-0 flex-1">
58+ <p class="text-[0.8rem] font-bold leading-tight">
59+ Why the open web is making a comeback
60+ </p>
61+ <p
62+ class="mt-0.5 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
63+ >
64+ theopenweb.press · 2h
65+ </p>
66+ </div>
67+ <span class="chip shrink-0" data-active="true">♥ 12</span>
68+ </div>
69+ <div
70+ class="flex items-start gap-3 border-l-[6px] border-l-[var(--accent)] px-4 py-3"
71+ >
72+ <span
73+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] text-xs"
74+ >📰</span
75+ >
76+ <div class="min-w-0 flex-1">
77+ <p class="text-[0.8rem] font-bold leading-tight">
78+ How to take back control of your reading list
79+ </p>
80+ <p
81+ class="mt-0.5 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
82+ >
83+ readwrite.cafe · 5h
84+ </p>
85+ </div>
86+ </div>
87+ <div class="flex items-start gap-3 px-4 py-3">
88+ <span
89+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] text-xs"
90+ >🔥</span
91+ >
92+ <div class="min-w-0 flex-1">
93+ <p class="text-[0.8rem] font-bold leading-tight">
94+ RSS is not dead, it was just resting
95+ </p>
96+ <p
97+ class="mt-0.5 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
98+ >
99+ syndication.xyz · 8h
100+ </p>
101+ </div>
102+ </div>
103+ </div>
104+ <div
105+ class="flex items-center justify-between border-t-2 border-[var(--border)] px-4 py-2 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
106+ >
107+ <span>4 trending in your network</span>
108+ <span class="font-extrabold text-[var(--accent)]"
109+ >42 unread</span
110+ >
111+ </div>
112+ </div>
113+ </div>
114+</section>
115+
116+<!-- FEATURES -->
117+<section class="border-b-2 border-[var(--border)]">
118+ <div class="mx-auto max-w-5xl px-4 py-16">
119+ <p
120+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
121+ >
122+ // Features
123+ </p>
124+ <h2
125+ class="mt-2 text-2xl font-extrabold uppercase tracking-tight md:text-3xl"
126+ >
127+ Read with intent
128+ </h2>
129+ <div
130+ class="mt-10 grid grid-cols-1 gap-0 border-2 border-[var(--border)] md:grid-cols-3"
131+ >
132+ <div
133+ class="border-b-2 border-[var(--border)] p-6 md:border-b-0 md:border-r-2"
134+ >
135+ <Icon name="note" class="h-6 w-6 text-[var(--accent)]" />
136+ <h3 class="mt-4 text-sm font-extrabold uppercase tracking-wide">
137+ Highlight
138+ </h3>
139+ <p class="mt-2 text-xs leading-relaxed text-[var(--muted)]">
140+ Select any passage in an article. Your highlights live in
141+ your personal data repository.
142+ </p>
143+ </div>
144+ <div
145+ class="border-b-2 border-[var(--border)] p-6 md:border-b-0 md:border-r-2"
146+ >
147+ <Icon name="note" class="h-6 w-6 text-[var(--accent)]" />
148+ <h3 class="mt-4 text-sm font-extrabold uppercase tracking-wide">
149+ Annotate
150+ </h3>
151+ <p class="mt-2 text-xs leading-relaxed text-[var(--muted)]">
152+ Write notes for your future self. Tag them. Find them later
153+ in your library.
154+ </p>
155+ </div>
156+ <div class="p-6">
157+ <Icon name="bookmark" class="h-6 w-6 text-[var(--accent)]" />
158+ <h3 class="mt-4 text-sm font-extrabold uppercase tracking-wide">
159+ Save
160+ </h3>
161+ <p class="mt-2 text-xs leading-relaxed text-[var(--muted)]">
162+ Keep articles around. Like them to boost them into your
163+ friends' feeds.
164+ </p>
165+ </div>
166+ </div>
167+ </div>
168+</section>
169+
170+<!-- OPEN / AT PROTOCOL -->
171+<section
172+ class="border-b-2 border-[var(--border)] bg-[var(--accent)] text-white"
173+>
174+ <div class="mx-auto max-w-5xl px-4 py-16">
175+ <p class="text-xs font-extrabold uppercase tracking-widest opacity-70">
176+ // Open
177+ </p>
178+ <h2
179+ class="mt-2 max-w-2xl text-2xl font-extrabold uppercase tracking-tight md:text-3xl"
180+ >
181+ Your feeds. Your data. Yours.
182+ </h2>
183+ <p class="mt-5 max-w-2xl text-sm leading-relaxed opacity-80">
184+ Glean is part of the Atmosphere. Annotations are compatible with
185+ <a
186+ href="https://margin.at"
187+ target="_blank"
188+ rel="noopener"
189+ class="underline underline-offset-2 hover:text-white"
190+ >Margin.at</a
191+ >
192+ notes, feeds with
193+ <a
194+ href="https://skyreader.app"
195+ target="_blank"
196+ rel="noopener"
197+ class="underline underline-offset-2 hover:text-white"
198+ >Skyreader</a
199+ >
200+ subscriptions, and your social graph travels with you from
201+ <a
202+ href="https://bsky.social"
203+ target="_blank"
204+ rel="noopener"
205+ class="underline underline-offset-2 hover:text-white">Bluesky</a
206+ >
207+ and
208+ <a
209+ href="https://tangled.org"
210+ target="_blank"
211+ rel="noopener"
212+ class="underline underline-offset-2 hover:text-white">Tangled</a
213+ >. All on AT Protocol.
214+ </p>
215+ <div class="mt-8 grid grid-cols-2 gap-4 md:grid-cols-4">
216+ <span
217+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide"
218+ >AT Protocol identity</span
219+ >
220+ <span
221+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide"
222+ >Portable data</span
223+ >
224+ <a
225+ href="https://tangled.org/julien.rbrt.fr/glean"
226+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide hover:bg-white hover:text-[var(--accent)]"
227+ >Open source</a
228+ >
229+ <a
230+ href="https://tangled.org/julien.rbrt.fr/glean"
231+ target="_blank"
232+ rel="noopener"
233+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide hover:bg-white hover:text-[var(--accent)]"
234+ >Self-hostable</a
235+ >
236+ </div>
237+ </div>
238+</section>
239+
240+<!-- FINAL CTA -->
241+<section class="py-20 text-center">
242+ <div class="mx-auto max-w-md px-4">
243+ <Logo size="md" />
244+ <h2
245+ class="mt-6 text-2xl font-extrabold uppercase tracking-tight md:text-3xl"
246+ >
247+ Start reading
248+ </h2>
249+ <p class="mt-3 text-xs leading-relaxed text-[var(--muted)]">
250+ Sign in with your Bluesky handle or any Atmosphere account.
251+ </p>
252+ <a href="/auth/login" class="btn btn-accent mt-6">Get started</a>
253+ </div>
254+</section>
new file mode 100644
@@ -0,0 +1,254 @@
1+<script lang="ts">
2+ // Landing page is static; auth gating happens in +page.server.ts.
3+ import Logo from "$lib/components/Logo.svelte";
4+ import Icon from "$lib/components/Icon.svelte";
5+</script>
6+
7+<!-- HERO -->
8+<section class="border-b-2 border-[var(--border)]">
9+ <div
10+ class="mx-auto grid max-w-5xl grid-cols-1 gap-10 px-4 py-16 lg:grid-cols-2 lg:items-center lg:py-24"
11+ >
12+ <div>
13+ <div class="mb-8"><Logo size="lg" /></div>
14+ <h1
15+ class="text-4xl font-extrabold uppercase leading-[0.95] tracking-tight md:text-6xl"
16+ >
17+ The social<br />RSS reader.
18+ </h1>
19+ <p
20+ class="mt-6 max-w-md text-sm leading-relaxed text-[var(--muted)]"
21+ >
22+ Read your feeds. See what your circle reads. Discover new
23+ sources through personalized recommendations. All on AT
24+ Protocol.
25+ </p>
26+ <div class="mt-8 flex flex-col gap-3 sm:flex-row">
27+ <a href="/auth/login" class="btn btn-accent">Sign in</a>
28+ <a href="/trending" class="btn">See what's trending</a>
29+ </div>
30+ </div>
31+
32+ <!-- Mock dashboard panel -->
33+ <div
34+ class="panel hidden p-0 shadow-[6px_6px_0_0_var(--border)] lg:block"
35+ >
36+ <div
37+ class="flex items-center gap-2 border-b-2 border-[var(--border)] px-3 py-2"
38+ >
39+ <span
40+ class="h-2.5 w-2.5 border-2 border-[var(--border)] bg-[var(--accent)]"
41+ ></span>
42+ <span class="h-2.5 w-2.5 border-2 border-[var(--border)]"
43+ ></span>
44+ <span class="h-2.5 w-2.5 border-2 border-[var(--border)]"
45+ ></span>
46+ <span
47+ class="ml-2 text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)]"
48+ >Glean — Dashboard</span
49+ >
50+ </div>
51+ <div class="divide-y-2 divide-[var(--border)]">
52+ <div class="flex items-start gap-3 px-4 py-3">
53+ <span
54+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center border-2 border-[var(--border)] bg-[var(--accent)] text-xs font-extrabold text-white"
55+ ></span
56+ >
57+ <div class="min-w-0 flex-1">
58+ <p class="text-[0.8rem] font-bold leading-tight">
59+ Why the open web is making a comeback
60+ </p>
61+ <p
62+ class="mt-0.5 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
63+ >
64+ theopenweb.press · 2h
65+ </p>
66+ </div>
67+ <span class="chip shrink-0" data-active="true">♥ 12</span>
68+ </div>
69+ <div
70+ class="flex items-start gap-3 border-l-[6px] border-l-[var(--accent)] px-4 py-3"
71+ >
72+ <span
73+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] text-xs"
74+ >📰</span
75+ >
76+ <div class="min-w-0 flex-1">
77+ <p class="text-[0.8rem] font-bold leading-tight">
78+ How to take back control of your reading list
79+ </p>
80+ <p
81+ class="mt-0.5 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
82+ >
83+ readwrite.cafe · 5h
84+ </p>
85+ </div>
86+ </div>
87+ <div class="flex items-start gap-3 px-4 py-3">
88+ <span
89+ class="inline-flex h-7 w-7 shrink-0 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] text-xs"
90+ >🔥</span
91+ >
92+ <div class="min-w-0 flex-1">
93+ <p class="text-[0.8rem] font-bold leading-tight">
94+ RSS is not dead, it was just resting
95+ </p>
96+ <p
97+ class="mt-0.5 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
98+ >
99+ syndication.xyz · 8h
100+ </p>
101+ </div>
102+ </div>
103+ </div>
104+ <div
105+ class="flex items-center justify-between border-t-2 border-[var(--border)] px-4 py-2 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]"
106+ >
107+ <span>4 trending in your network</span>
108+ <span class="font-extrabold text-[var(--accent)]"
109+ >42 unread</span
110+ >
111+ </div>
112+ </div>
113+ </div>
114+</section>
115+
116+<!-- FEATURES -->
117+<section class="border-b-2 border-[var(--border)]">
118+ <div class="mx-auto max-w-5xl px-4 py-16">
119+ <p
120+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
121+ >
122+ // Features
123+ </p>
124+ <h2
125+ class="mt-2 text-2xl font-extrabold uppercase tracking-tight md:text-3xl"
126+ >
127+ Read with intent
128+ </h2>
129+ <div
130+ class="mt-10 grid grid-cols-1 gap-0 border-2 border-[var(--border)] md:grid-cols-3"
131+ >
132+ <div
133+ class="border-b-2 border-[var(--border)] p-6 md:border-b-0 md:border-r-2"
134+ >
135+ <Icon name="note" class="h-6 w-6 text-[var(--accent)]" />
136+ <h3 class="mt-4 text-sm font-extrabold uppercase tracking-wide">
137+ Highlight
138+ </h3>
139+ <p class="mt-2 text-xs leading-relaxed text-[var(--muted)]">
140+ Select any passage in an article. Your highlights live in
141+ your personal data repository.
142+ </p>
143+ </div>
144+ <div
145+ class="border-b-2 border-[var(--border)] p-6 md:border-b-0 md:border-r-2"
146+ >
147+ <Icon name="note" class="h-6 w-6 text-[var(--accent)]" />
148+ <h3 class="mt-4 text-sm font-extrabold uppercase tracking-wide">
149+ Annotate
150+ </h3>
151+ <p class="mt-2 text-xs leading-relaxed text-[var(--muted)]">
152+ Write notes for your future self. Tag them. Find them later
153+ in your library.
154+ </p>
155+ </div>
156+ <div class="p-6">
157+ <Icon name="bookmark" class="h-6 w-6 text-[var(--accent)]" />
158+ <h3 class="mt-4 text-sm font-extrabold uppercase tracking-wide">
159+ Save
160+ </h3>
161+ <p class="mt-2 text-xs leading-relaxed text-[var(--muted)]">
162+ Keep articles around. Like them to boost them into your
163+ friends' feeds.
164+ </p>
165+ </div>
166+ </div>
167+ </div>
168+</section>
169+
170+<!-- OPEN / AT PROTOCOL -->
171+<section
172+ class="border-b-2 border-[var(--border)] bg-[var(--accent)] text-white"
173+>
174+ <div class="mx-auto max-w-5xl px-4 py-16">
175+ <p class="text-xs font-extrabold uppercase tracking-widest opacity-70">
176+ // Open
177+ </p>
178+ <h2
179+ class="mt-2 max-w-2xl text-2xl font-extrabold uppercase tracking-tight md:text-3xl"
180+ >
181+ Your feeds. Your data. Yours.
182+ </h2>
183+ <p class="mt-5 max-w-2xl text-sm leading-relaxed opacity-80">
184+ Glean is part of the Atmosphere. Annotations are compatible with
185+ <a
186+ href="https://margin.at"
187+ target="_blank"
188+ rel="noopener"
189+ class="underline underline-offset-2 hover:text-white"
190+ >Margin.at</a
191+ >
192+ notes, feeds with
193+ <a
194+ href="https://skyreader.app"
195+ target="_blank"
196+ rel="noopener"
197+ class="underline underline-offset-2 hover:text-white"
198+ >Skyreader</a
199+ >
200+ subscriptions, and your social graph travels with you from
201+ <a
202+ href="https://bsky.social"
203+ target="_blank"
204+ rel="noopener"
205+ class="underline underline-offset-2 hover:text-white">Bluesky</a
206+ >
207+ and
208+ <a
209+ href="https://tangled.org"
210+ target="_blank"
211+ rel="noopener"
212+ class="underline underline-offset-2 hover:text-white">Tangled</a
213+ >. All on AT Protocol.
214+ </p>
215+ <div class="mt-8 grid grid-cols-2 gap-4 md:grid-cols-4">
216+ <span
217+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide"
218+ >AT Protocol identity</span
219+ >
220+ <span
221+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide"
222+ >Portable data</span
223+ >
224+ <a
225+ href="https://tangled.org/julien.rbrt.fr/glean"
226+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide hover:bg-white hover:text-[var(--accent)]"
227+ >Open source</a
228+ >
229+ <a
230+ href="https://tangled.org/julien.rbrt.fr/glean"
231+ target="_blank"
232+ rel="noopener"
233+ class="border-2 border-white/40 px-3 py-2 text-[0.7rem] font-bold uppercase tracking-wide hover:bg-white hover:text-[var(--accent)]"
234+ >Self-hostable</a
235+ >
236+ </div>
237+ </div>
238+</section>
239+
240+<!-- FINAL CTA -->
241+<section class="py-20 text-center">
242+ <div class="mx-auto max-w-md px-4">
243+ <Logo size="md" />
244+ <h2
245+ class="mt-6 text-2xl font-extrabold uppercase tracking-tight md:text-3xl"
246+ >
247+ Start reading
248+ </h2>
249+ <p class="mt-3 text-xs leading-relaxed text-[var(--muted)]">
250+ Sign in with your Bluesky handle or any Atmosphere account.
251+ </p>
252+ <a href="/auth/login" class="btn btn-accent mt-6">Get started</a>
253+ </div>
254+</section>
added web/src/routes/articles/+page.server.ts +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+
9+ const params: Record<string, string> = {};
10+ for (const key of ["feed", "status", "q", "sort", "category", "page"]) {
11+ const v = event.url.searchParams.get(key);
12+ if (v) params[key] = v;
13+ }
14+ return await endpointsFor(event.fetch).articles(params);
15+};
new file mode 100644
@@ -0,0 +1,15 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+
9+ const params: Record<string, string> = {};
10+ for (const key of ["feed", "status", "q", "sort", "category", "page"]) {
11+ const v = event.url.searchParams.get(key);
12+ if (v) params[key] = v;
13+ }
14+ return await endpointsFor(event.fetch).articles(params);
15+};
added web/src/routes/articles/+page.svelte +317 -0
new file mode 100644
@@ -0,0 +1,317 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import { goto, invalidateAll } from "$app/navigation";
4+ import ArticleCard from "$lib/components/ArticleCard.svelte";
5+ import Favicon from "$lib/components/Favicon.svelte";
6+ import Pagination from "$lib/components/Pagination.svelte";
7+ import EmptyState from "$lib/components/EmptyState.svelte";
8+ import Icon from "$lib/components/Icon.svelte";
9+ import NewArticlesBanner from "$lib/components/NewArticlesBanner.svelte";
10+ import { endpoints } from "$lib/api";
11+
12+ let { data }: { data: PageData } = $props();
13+
14+ {
15+ /* svelte-ignore state_referenced_locally -- controlled input, synced on navigate */
16+ }
17+ let search = $state(data.search_query);
18+ let searchTimer: ReturnType<typeof setTimeout>;
19+
20+ const status = $derived(data.status);
21+ const sort = $derived(data.sort_oldest ? "oldest" : "");
22+ const feedURL = $derived(data.feed_url);
23+ const category = $derived(data.category);
24+
25+ function buildURL(overrides: Record<string, string | undefined>): string {
26+ const p = new URLSearchParams();
27+ const base: Record<string, string> = {
28+ feed: feedURL,
29+ status: status,
30+ q: data.search_query,
31+ sort: sort,
32+ category: category,
33+ };
34+ const merged = { ...base, ...overrides };
35+ for (const [k, v] of Object.entries(merged)) {
36+ if (v) p.set(k, v);
37+ }
38+ return "/articles" + (p.toString() ? "?" + p.toString() : "");
39+ }
40+
41+ function onSearch(value: string) {
42+ search = value;
43+ clearTimeout(searchTimer);
44+ searchTimer = setTimeout(() => {
45+ goto(buildURL({ q: value || undefined, page: undefined }), {
46+ keepFocus: true,
47+ });
48+ }, 300);
49+ }
50+
51+ function markAllRead() {
52+ endpoints
53+ .markAllRead(feedURL)
54+ .then(() => goto("/articles", { invalidateAll: true }));
55+ }
56+
57+ // Expanded view: mark articles read on scroll.
58+ function readOnScroll(node: HTMLElement, id: number) {
59+ if (!data.expanded_view) return;
60+ let timer: ReturnType<typeof setTimeout> | undefined;
61+ const observer = new IntersectionObserver(
62+ (entries) => {
63+ for (const entry of entries) {
64+ if (entry.isIntersecting) {
65+ timer = setTimeout(() => {
66+ endpoints.markRead(id).catch(() => {});
67+ node.classList.remove("read-marker");
68+ }, 3000);
69+ } else if (timer) {
70+ clearTimeout(timer);
71+ timer = undefined;
72+ }
73+ }
74+ },
75+ { rootMargin: "0px 0px -50% 0px", threshold: 0 },
76+ );
77+ observer.observe(node);
78+ return {
79+ destroy() {
80+ if (timer) clearTimeout(timer);
81+ observer.disconnect();
82+ },
83+ };
84+ }
85+</script>
86+
87+{#if data.feed}
88+ <!-- Feed header -->
89+ <div class="mb-6">
90+ <a
91+ href="/articles"
92+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
93+ >
94+ <Icon name="arrowLeft" class="h-3.5 w-3.5" />All articles
95+ </a>
96+ <div
97+ class="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
98+ >
99+ <div class="flex min-w-0 items-center gap-3.5">
100+ <Favicon src={data.feed.favicon_url} size="h-10 w-10" />
101+ <div class="min-w-0">
102+ <h1
103+ class="truncate text-2xl font-extrabold uppercase tracking-tight"
104+ >
105+ {#if data.feed.site_url}
106+ <a
107+ href={data.feed.site_url}
108+ target="_blank"
109+ rel="noopener noreferrer"
110+ class="hover:text-[var(--accent)]"
111+ >{data.feed.title || data.feed.feed_url}</a
112+ >
113+ {:else}
114+ {data.feed.title || data.feed.feed_url}
115+ {/if}
116+ </h1>
117+ {#if data.feed.site_url}
118+ <a
119+ href={data.feed.site_url}
120+ target="_blank"
121+ rel="noopener noreferrer"
122+ class="text-xs text-[var(--muted)] hover:text-[var(--accent)]"
123+ >{data.feed.site_url}</a
124+ >
125+ {/if}
126+ </div>
127+ </div>
128+ <div class="flex shrink-0 items-center gap-2">
129+ {#if !data.is_subscribed}
130+ <button
131+ onclick={() =>
132+ endpoints
133+ .addFeed(feedURL)
134+ .then(() => invalidateAll())}
135+ class="btn btn-accent">Subscribe</button
136+ >
137+ {:else}
138+ <button onclick={markAllRead} class="btn"
139+ >Mark all read</button
140+ >
141+ {/if}
142+ </div>
143+ </div>
144+ {#if data.feed.description}
145+ <p class="mt-3 text-sm leading-relaxed text-[var(--muted)]">
146+ {data.feed.description}
147+ </p>
148+ {/if}
149+ </div>
150+
151+ <!-- Search + filters (feed view) -->
152+ <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
153+ <div class="relative w-full sm:min-w-[200px] sm:flex-1">
154+ <Icon
155+ name="search"
156+ class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]"
157+ />
158+ <input
159+ type="text"
160+ value={search}
161+ oninput={(e) => onSearch(e.currentTarget.value)}
162+ placeholder="Search articles..."
163+ class="input-brutal pl-10"
164+ />
165+ </div>
166+ <div class="flex shrink-0 items-center gap-1.5">
167+ <a
168+ href={buildURL({ status: "all", page: undefined })}
169+ class="chip"
170+ data-active={status === "all"}>All</a
171+ >
172+ <a
173+ href={buildURL({ status: "unread", page: undefined })}
174+ class="chip"
175+ data-active={status === "unread"}>Unread</a
176+ >
177+ <a
178+ href={buildURL({ status: "read", page: undefined })}
179+ class="chip"
180+ data-active={status === "read"}>Read</a
181+ >
182+ <a
183+ href={buildURL({
184+ sort: sort ? undefined : "oldest",
185+ page: undefined,
186+ })}
187+ class="chip"
188+ data-active={!!sort}
189+ title={sort ? "Newest first" : "Oldest first"}
190+ >
191+ <Icon
192+ name={sort ? "chevronUp" : "chevronDown"}
193+ class="h-3.5 w-3.5"
194+ />
195+ </a>
196+ </div>
197+ </div>
198+{:else}
199+ <!-- Title row -->
200+ <div
201+ class="mb-4 flex flex-wrap items-center justify-between gap-3 border-b-2 border-[var(--border)] pb-4"
202+ >
203+ <div class="flex items-center gap-3">
204+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
205+ Articles
206+ </h1>
207+ <button onclick={markAllRead} class="btn">Mark all read</button>
208+ </div>
209+ <div class="flex items-center gap-1.5">
210+ <a
211+ href={buildURL({ status: "all", page: undefined })}
212+ class="chip"
213+ data-active={status === "all"}>All</a
214+ >
215+ <a
216+ href={buildURL({ status: "unread", page: undefined })}
217+ class="chip"
218+ data-active={status === "unread" || status === ""}>Unread</a
219+ >
220+ <a
221+ href={buildURL({ status: "read", page: undefined })}
222+ class="chip"
223+ data-active={status === "read"}>Read</a
224+ >
225+ </div>
226+ </div>
227+ <p class="mb-6 text-xs text-[var(--muted)]">
228+ All your subscribed articles, {sort ? "oldest first" : "newest first"}.
229+ </p>
230+
231+ <!-- Sort + categories + search -->
232+ <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
233+ <div class="flex flex-wrap items-center gap-1.5">
234+ <a
235+ href={buildURL({
236+ sort: sort ? undefined : "oldest",
237+ page: undefined,
238+ })}
239+ class="chip"
240+ data-active={!!sort}
241+ title={sort ? "Newest first" : "Oldest first"}
242+ >
243+ <Icon
244+ name={sort ? "chevronUp" : "chevronDown"}
245+ class="h-3.5 w-3.5"
246+ />
247+ </a>
248+ {#if data.categories.length}
249+ <span class="text-[var(--faint)]">|</span>
250+ <a
251+ href={buildURL({ category: undefined, page: undefined })}
252+ class="chip"
253+ data-active={!category}>All</a
254+ >
255+ {#each data.categories as cat}
256+ <a
257+ href={buildURL({ category: cat, page: undefined })}
258+ class="chip"
259+ data-active={category === cat}>{cat}</a
260+ >
261+ {/each}
262+ <a
263+ href={buildURL({ category: "__none__", page: undefined })}
264+ class="chip"
265+ data-active={category === "__none__"}>Uncategorized</a
266+ >
267+ {/if}
268+ </div>
269+ <div class="relative w-full sm:min-w-[180px] sm:flex-1">
270+ <Icon
271+ name="search"
272+ class="pointer-events-none absolute left-2.5 top-1/2 h-3 w-3 -translate-y-1/2 text-[var(--muted)]"
273+ />
274+ <input
275+ type="text"
276+ value={search}
277+ oninput={(e) => onSearch(e.currentTarget.value)}
278+ placeholder="Search articles..."
279+ class="input-brutal px-3 py-1.5 pl-8 text-xs"
280+ />
281+ </div>
282+ </div>
283+{/if}
284+
285+<!-- Live banner: surfaces newly-fetched articles since the page loaded -->
286+<NewArticlesBanner since={data.now} />
287+
288+<!-- List -->
289+<div class="space-y-3">
290+ {#if data.articles.length === 0}
291+ <EmptyState
292+ icon="feed"
293+ title="No articles found"
294+ subtitle={status === "unread"
295+ ? "You've read everything. Nice work."
296+ : status === "read"
297+ ? "Nothing marked as read yet."
298+ : "Subscribe to feeds to see articles here."}
299+ />
300+ {:else}
301+ {#each data.articles as a (a.id)}
302+ <div use:readOnScroll={a.id} class="contents">
303+ <ArticleCard
304+ article={a}
305+ expanded={data.expanded_view}
306+ navSuffix="?liked=1"
307+ />
308+ </div>
309+ {/each}
310+ {/if}
311+</div>
312+
313+<Pagination
314+ page={data.pagination}
315+ base="/articles"
316+ params={{ feed: feedURL, status, q: data.search_query, sort, category }}
317+/>
new file mode 100644
@@ -0,0 +1,317 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import { goto, invalidateAll } from "$app/navigation";
4+ import ArticleCard from "$lib/components/ArticleCard.svelte";
5+ import Favicon from "$lib/components/Favicon.svelte";
6+ import Pagination from "$lib/components/Pagination.svelte";
7+ import EmptyState from "$lib/components/EmptyState.svelte";
8+ import Icon from "$lib/components/Icon.svelte";
9+ import NewArticlesBanner from "$lib/components/NewArticlesBanner.svelte";
10+ import { endpoints } from "$lib/api";
11+
12+ let { data }: { data: PageData } = $props();
13+
14+ {
15+ /* svelte-ignore state_referenced_locally -- controlled input, synced on navigate */
16+ }
17+ let search = $state(data.search_query);
18+ let searchTimer: ReturnType<typeof setTimeout>;
19+
20+ const status = $derived(data.status);
21+ const sort = $derived(data.sort_oldest ? "oldest" : "");
22+ const feedURL = $derived(data.feed_url);
23+ const category = $derived(data.category);
24+
25+ function buildURL(overrides: Record<string, string | undefined>): string {
26+ const p = new URLSearchParams();
27+ const base: Record<string, string> = {
28+ feed: feedURL,
29+ status: status,
30+ q: data.search_query,
31+ sort: sort,
32+ category: category,
33+ };
34+ const merged = { ...base, ...overrides };
35+ for (const [k, v] of Object.entries(merged)) {
36+ if (v) p.set(k, v);
37+ }
38+ return "/articles" + (p.toString() ? "?" + p.toString() : "");
39+ }
40+
41+ function onSearch(value: string) {
42+ search = value;
43+ clearTimeout(searchTimer);
44+ searchTimer = setTimeout(() => {
45+ goto(buildURL({ q: value || undefined, page: undefined }), {
46+ keepFocus: true,
47+ });
48+ }, 300);
49+ }
50+
51+ function markAllRead() {
52+ endpoints
53+ .markAllRead(feedURL)
54+ .then(() => goto("/articles", { invalidateAll: true }));
55+ }
56+
57+ // Expanded view: mark articles read on scroll.
58+ function readOnScroll(node: HTMLElement, id: number) {
59+ if (!data.expanded_view) return;
60+ let timer: ReturnType<typeof setTimeout> | undefined;
61+ const observer = new IntersectionObserver(
62+ (entries) => {
63+ for (const entry of entries) {
64+ if (entry.isIntersecting) {
65+ timer = setTimeout(() => {
66+ endpoints.markRead(id).catch(() => {});
67+ node.classList.remove("read-marker");
68+ }, 3000);
69+ } else if (timer) {
70+ clearTimeout(timer);
71+ timer = undefined;
72+ }
73+ }
74+ },
75+ { rootMargin: "0px 0px -50% 0px", threshold: 0 },
76+ );
77+ observer.observe(node);
78+ return {
79+ destroy() {
80+ if (timer) clearTimeout(timer);
81+ observer.disconnect();
82+ },
83+ };
84+ }
85+</script>
86+
87+{#if data.feed}
88+ <!-- Feed header -->
89+ <div class="mb-6">
90+ <a
91+ href="/articles"
92+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
93+ >
94+ <Icon name="arrowLeft" class="h-3.5 w-3.5" />All articles
95+ </a>
96+ <div
97+ class="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
98+ >
99+ <div class="flex min-w-0 items-center gap-3.5">
100+ <Favicon src={data.feed.favicon_url} size="h-10 w-10" />
101+ <div class="min-w-0">
102+ <h1
103+ class="truncate text-2xl font-extrabold uppercase tracking-tight"
104+ >
105+ {#if data.feed.site_url}
106+ <a
107+ href={data.feed.site_url}
108+ target="_blank"
109+ rel="noopener noreferrer"
110+ class="hover:text-[var(--accent)]"
111+ >{data.feed.title || data.feed.feed_url}</a
112+ >
113+ {:else}
114+ {data.feed.title || data.feed.feed_url}
115+ {/if}
116+ </h1>
117+ {#if data.feed.site_url}
118+ <a
119+ href={data.feed.site_url}
120+ target="_blank"
121+ rel="noopener noreferrer"
122+ class="text-xs text-[var(--muted)] hover:text-[var(--accent)]"
123+ >{data.feed.site_url}</a
124+ >
125+ {/if}
126+ </div>
127+ </div>
128+ <div class="flex shrink-0 items-center gap-2">
129+ {#if !data.is_subscribed}
130+ <button
131+ onclick={() =>
132+ endpoints
133+ .addFeed(feedURL)
134+ .then(() => invalidateAll())}
135+ class="btn btn-accent">Subscribe</button
136+ >
137+ {:else}
138+ <button onclick={markAllRead} class="btn"
139+ >Mark all read</button
140+ >
141+ {/if}
142+ </div>
143+ </div>
144+ {#if data.feed.description}
145+ <p class="mt-3 text-sm leading-relaxed text-[var(--muted)]">
146+ {data.feed.description}
147+ </p>
148+ {/if}
149+ </div>
150+
151+ <!-- Search + filters (feed view) -->
152+ <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
153+ <div class="relative w-full sm:min-w-[200px] sm:flex-1">
154+ <Icon
155+ name="search"
156+ class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]"
157+ />
158+ <input
159+ type="text"
160+ value={search}
161+ oninput={(e) => onSearch(e.currentTarget.value)}
162+ placeholder="Search articles..."
163+ class="input-brutal pl-10"
164+ />
165+ </div>
166+ <div class="flex shrink-0 items-center gap-1.5">
167+ <a
168+ href={buildURL({ status: "all", page: undefined })}
169+ class="chip"
170+ data-active={status === "all"}>All</a
171+ >
172+ <a
173+ href={buildURL({ status: "unread", page: undefined })}
174+ class="chip"
175+ data-active={status === "unread"}>Unread</a
176+ >
177+ <a
178+ href={buildURL({ status: "read", page: undefined })}
179+ class="chip"
180+ data-active={status === "read"}>Read</a
181+ >
182+ <a
183+ href={buildURL({
184+ sort: sort ? undefined : "oldest",
185+ page: undefined,
186+ })}
187+ class="chip"
188+ data-active={!!sort}
189+ title={sort ? "Newest first" : "Oldest first"}
190+ >
191+ <Icon
192+ name={sort ? "chevronUp" : "chevronDown"}
193+ class="h-3.5 w-3.5"
194+ />
195+ </a>
196+ </div>
197+ </div>
198+{:else}
199+ <!-- Title row -->
200+ <div
201+ class="mb-4 flex flex-wrap items-center justify-between gap-3 border-b-2 border-[var(--border)] pb-4"
202+ >
203+ <div class="flex items-center gap-3">
204+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
205+ Articles
206+ </h1>
207+ <button onclick={markAllRead} class="btn">Mark all read</button>
208+ </div>
209+ <div class="flex items-center gap-1.5">
210+ <a
211+ href={buildURL({ status: "all", page: undefined })}
212+ class="chip"
213+ data-active={status === "all"}>All</a
214+ >
215+ <a
216+ href={buildURL({ status: "unread", page: undefined })}
217+ class="chip"
218+ data-active={status === "unread" || status === ""}>Unread</a
219+ >
220+ <a
221+ href={buildURL({ status: "read", page: undefined })}
222+ class="chip"
223+ data-active={status === "read"}>Read</a
224+ >
225+ </div>
226+ </div>
227+ <p class="mb-6 text-xs text-[var(--muted)]">
228+ All your subscribed articles, {sort ? "oldest first" : "newest first"}.
229+ </p>
230+
231+ <!-- Sort + categories + search -->
232+ <div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
233+ <div class="flex flex-wrap items-center gap-1.5">
234+ <a
235+ href={buildURL({
236+ sort: sort ? undefined : "oldest",
237+ page: undefined,
238+ })}
239+ class="chip"
240+ data-active={!!sort}
241+ title={sort ? "Newest first" : "Oldest first"}
242+ >
243+ <Icon
244+ name={sort ? "chevronUp" : "chevronDown"}
245+ class="h-3.5 w-3.5"
246+ />
247+ </a>
248+ {#if data.categories.length}
249+ <span class="text-[var(--faint)]">|</span>
250+ <a
251+ href={buildURL({ category: undefined, page: undefined })}
252+ class="chip"
253+ data-active={!category}>All</a
254+ >
255+ {#each data.categories as cat}
256+ <a
257+ href={buildURL({ category: cat, page: undefined })}
258+ class="chip"
259+ data-active={category === cat}>{cat}</a
260+ >
261+ {/each}
262+ <a
263+ href={buildURL({ category: "__none__", page: undefined })}
264+ class="chip"
265+ data-active={category === "__none__"}>Uncategorized</a
266+ >
267+ {/if}
268+ </div>
269+ <div class="relative w-full sm:min-w-[180px] sm:flex-1">
270+ <Icon
271+ name="search"
272+ class="pointer-events-none absolute left-2.5 top-1/2 h-3 w-3 -translate-y-1/2 text-[var(--muted)]"
273+ />
274+ <input
275+ type="text"
276+ value={search}
277+ oninput={(e) => onSearch(e.currentTarget.value)}
278+ placeholder="Search articles..."
279+ class="input-brutal px-3 py-1.5 pl-8 text-xs"
280+ />
281+ </div>
282+ </div>
283+{/if}
284+
285+<!-- Live banner: surfaces newly-fetched articles since the page loaded -->
286+<NewArticlesBanner since={data.now} />
287+
288+<!-- List -->
289+<div class="space-y-3">
290+ {#if data.articles.length === 0}
291+ <EmptyState
292+ icon="feed"
293+ title="No articles found"
294+ subtitle={status === "unread"
295+ ? "You've read everything. Nice work."
296+ : status === "read"
297+ ? "Nothing marked as read yet."
298+ : "Subscribe to feeds to see articles here."}
299+ />
300+ {:else}
301+ {#each data.articles as a (a.id)}
302+ <div use:readOnScroll={a.id} class="contents">
303+ <ArticleCard
304+ article={a}
305+ expanded={data.expanded_view}
306+ navSuffix="?liked=1"
307+ />
308+ </div>
309+ {/each}
310+ {/if}
311+</div>
312+
313+<Pagination
314+ page={data.pagination}
315+ base="/articles"
316+ params={{ feed: feedURL, status, q: data.search_query, sort, category }}
317+/>
added web/src/routes/articles/[id]/+page.server.ts +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect, error } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+
9+ const id = Number(event.params.id);
10+ if (!id) throw error(404, "Article not found");
11+
12+ const query: Record<string, string> = {};
13+ for (const key of ["from_feed", "liked", "status"]) {
14+ const v = event.url.searchParams.get(key);
15+ if (v) query[key] = v;
16+ }
17+
18+ try {
19+ return await endpointsFor(event.fetch).article(id, query);
20+ } catch (e: any) {
21+ if (e?.status === 404) throw error(404, "Article not found");
22+ throw e;
23+ }
24+};
new file mode 100644
@@ -0,0 +1,24 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect, error } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+
9+ const id = Number(event.params.id);
10+ if (!id) throw error(404, "Article not found");
11+
12+ const query: Record<string, string> = {};
13+ for (const key of ["from_feed", "liked", "status"]) {
14+ const v = event.url.searchParams.get(key);
15+ if (v) query[key] = v;
16+ }
17+
18+ try {
19+ return await endpointsFor(event.fetch).article(id, query);
20+ } catch (e: any) {
21+ if (e?.status === 404) throw error(404, "Article not found");
22+ throw e;
23+ }
24+};
added web/src/routes/articles/[id]/+page.svelte +479 -0
new file mode 100644
@@ -0,0 +1,479 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import LikeButton from "$lib/components/LikeButton.svelte";
4+ import Favicon from "$lib/components/Favicon.svelte";
5+ import AnnotationCard from "$lib/components/AnnotationCard.svelte";
6+ import Icon from "$lib/components/Icon.svelte";
7+ import BlueskyLogo from "$lib/components/BlueskyLogo.svelte";
8+ import { endpoints } from "$lib/api";
9+ import { invalidateAll } from "$app/navigation";
10+ import { youtubeID, isEmbedURL } from "$lib/format";
11+ import type { Annotation } from "$lib/types";
12+
13+ let { data }: { data: PageData } = $props();
14+
15+ {
16+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
17+ }
18+ let liked = $state(data.article.has_liked);
19+ {
20+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
21+ }
22+ let likeCount = $state(data.article.like_count);
23+ {
24+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
25+ }
26+ let read = $state(data.article.is_read);
27+ {
28+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
29+ }
30+ let annotations = $state<Annotation[]>(data.annotations);
31+ {
32+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
33+ }
34+ let fullContent = $state(data.article.full_content);
35+ let fetchingContent = $state(false);
36+ let contentError = $state("");
37+
38+ let popoverOpen = $state(false);
39+ let popoverTop = $state("0px");
40+ let popoverLeft = $state("0px");
41+ let quoteValue = $state("");
42+ let noteValue = $state("");
43+ let tagsValue = $state("");
44+ let submitting = $state(false);
45+
46+ let bodyEl = $state<HTMLElement | null>(null);
47+ let popoverEl = $state<HTMLElement | null>(null);
48+
49+ function goBack() {
50+ history.back();
51+ }
52+
53+ const yt = $derived(youtubeID(data.article.url));
54+ const showContent = $derived(
55+ fullContent || data.article.content || data.article.summary,
56+ );
57+
58+ $effect(() => {
59+ annotations = data.annotations;
60+ });
61+
62+ function clamp(v: number, min: number, max: number) {
63+ return Math.max(min, Math.min(max, v));
64+ }
65+
66+ function openForSelection() {
67+ const sel = window.getSelection();
68+ const text = sel?.toString().trim() ?? "";
69+ if (!text || !bodyEl) return;
70+ const range = sel!.getRangeAt(0);
71+ const rect = range.getBoundingClientRect();
72+ let q = text;
73+ if (q.length > 1000) q = q.substring(0, 1000);
74+ quoteValue = q;
75+ popoverOpen = true;
76+ const margin = 8;
77+ const width = 352;
78+ let left = clamp(
79+ rect.left + (rect.width - width) / 2,
80+ margin,
81+ window.innerWidth - width - margin,
82+ );
83+ let top = rect.bottom + margin;
84+ if (
85+ window.innerHeight - rect.bottom < 320 + margin &&
86+ rect.top > 320 + margin
87+ ) {
88+ top = rect.top - 320 - margin;
89+ }
90+ top = Math.max(margin, top);
91+ popoverLeft = left + "px";
92+ popoverTop = top + "px";
93+ }
94+
95+ function onMouseUp(e: MouseEvent) {
96+ if (popoverEl?.contains(e.target as Node)) return;
97+ if (!bodyEl?.contains(e.target as Node)) {
98+ closePopover();
99+ return;
100+ }
101+ const sel = window.getSelection();
102+ const text = sel?.toString().trim();
103+ if (text) openForSelection();
104+ }
105+
106+ function closePopover() {
107+ popoverOpen = false;
108+ quoteValue = "";
109+ noteValue = "";
110+ const sel = window.getSelection();
111+ sel?.removeAllRanges();
112+ }
113+
114+ function onKeydown(e: KeyboardEvent) {
115+ const t = e.target as HTMLElement | null;
116+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) return;
117+ if (e.key === "Escape" && popoverOpen) closePopover();
118+ }
119+
120+ async function submitAnnotation() {
121+ submitting = true;
122+ try {
123+ const res = await endpoints.createAnnotation({
124+ feed_url: data.article.feed_url,
125+ article_url: data.article.url,
126+ quote: quoteValue,
127+ note: noteValue,
128+ tags: tagsValue,
129+ });
130+ annotations = [...annotations, res.annotation];
131+ noteValue = "";
132+ tagsValue = "";
133+ quoteValue = "";
134+ closePopover();
135+ } finally {
136+ submitting = false;
137+ }
138+ }
139+
140+ let commentNote = $state("");
141+ let commentTags = $state("");
142+
143+ async function submitComment() {
144+ submitting = true;
145+ try {
146+ const res = await endpoints.createAnnotation({
147+ feed_url: data.article.feed_url,
148+ article_url: data.article.url,
149+ note: commentNote,
150+ tags: commentTags,
151+ });
152+ annotations = [...annotations, res.annotation];
153+ commentNote = "";
154+ commentTags = "";
155+ } finally {
156+ submitting = false;
157+ }
158+ }
159+
160+ async function toggleRead() {
161+ const next = !read;
162+ read = next;
163+ try {
164+ if (next) await endpoints.markRead(data.article.id);
165+ else await endpoints.markUnread(data.article.id);
166+ await invalidateAll();
167+ } catch {
168+ read = !next;
169+ }
170+ }
171+
172+ async function fetchContent() {
173+ fetchingContent = true;
174+ contentError = "";
175+ try {
176+ const res = await endpoints.fetchContent(data.article.id);
177+ fullContent = res.full_content;
178+ } catch (e) {
179+ contentError =
180+ e instanceof Error ? e.message : "Failed to fetch content";
181+ } finally {
182+ fetchingContent = false;
183+ }
184+ }
185+
186+ function fmt(t: string | null): string {
187+ if (!t) return "";
188+ return new Date(t).toLocaleString("en-US", {
189+ month: "short",
190+ day: "2-digit",
191+ year: "numeric",
192+ hour: "2-digit",
193+ minute: "2-digit",
194+ });
195+ }
196+</script>
197+
198+<svelte:window onmouseup={onMouseUp} onkeydown={onKeydown} />
199+
200+<div class="mx-auto max-w-3xl">
201+ <!-- Top nav -->
202+ <div
203+ class="mb-6 flex items-center justify-between border-b-2 border-[var(--border)] pb-4"
204+ >
205+ <button
206+ type="button"
207+ onclick={goBack}
208+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
209+ >
210+ <Icon name="arrowLeft" class="h-4 w-4" />Back
211+ </button>
212+ {#if data.next_id}
213+ <a
214+ href="/articles/{data.next_id}{data.next_suffix}"
215+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
216+ >
217+ Next<Icon name="arrowRight" class="h-4 w-4" />
218+ </a>
219+ {/if}
220+ </div>
221+
222+ <article>
223+ <h1
224+ class="text-2xl font-extrabold uppercase leading-tight tracking-tight md:text-3xl"
225+ >
226+ {#if data.article.url}
227+ <a
228+ href={data.article.url}
229+ target="_blank"
230+ rel="noopener noreferrer"
231+ class="hover:text-[var(--accent)]">{data.article.title}</a
232+ >
233+ {:else}
234+ {data.article.title}
235+ {/if}
236+ </h1>
237+
238+ <!-- Meta -->
239+ <div
240+ class="mt-4 flex flex-wrap items-center gap-2.5 text-xs text-[var(--muted)]"
241+ >
242+ {#if data.article.author}<span class="font-bold text-[var(--fg)]"
243+ >{data.article.author}</span
244+ >{/if}
245+ {#if data.article.published}<span>·</span><span
246+ >{fmt(data.article.published)}</span
247+ >{/if}
248+ {#if data.feed?.feed_url}
249+ <span>·</span>
250+ <a
251+ href="/articles?feed={encodeURIComponent(
252+ data.feed.feed_url,
253+ )}"
254+ class="inline-flex items-center gap-1.5 hover:text-[var(--accent)]"
255+ >
256+ <Favicon src={data.feed.favicon_url} size="h-4 w-4" />
257+ {data.feed.title || data.feed.feed_url}
258+ </a>
259+ {/if}
260+ </div>
261+
262+ <!-- Action bar -->
263+ <div class="mt-6 flex flex-wrap items-center gap-2">
264+ <LikeButton
265+ articleId={data.article.id}
266+ bind:liked
267+ bind:count={likeCount}
268+ />
269+ <button
270+ onclick={toggleRead}
271+ title={read ? "Mark as unread" : "Mark as read"}
272+ class="chip"
273+ data-active={read}
274+ >
275+ <Icon name="check" class="h-3.5 w-3.5" />{read
276+ ? "Unread"
277+ : "Read"}
278+ </button>
279+ {#if data.article.url}
280+ <a
281+ href={data.article.url}
282+ target="_blank"
283+ rel="noopener noreferrer"
284+ title="Open original"
285+ class="chip"
286+ >
287+ <Icon name="external" class="h-3.5 w-3.5" />Original
288+ </a>
289+ <a
290+ href="https://bsky.app/intent/compose?text={encodeURIComponent(
291+ data.article.title + ' ' + data.article.url,
292+ )}"
293+ target="_blank"
294+ rel="noopener noreferrer"
295+ title="Share on Bluesky"
296+ class="chip"
297+ >
298+ <BlueskyLogo class="h-3.5 w-3.5" />Share
299+ </a>
300+ {/if}
301+ </div>
302+
303+ {#if yt}
304+ <div
305+ class="my-8 aspect-video w-full overflow-hidden border-2 border-[var(--border)] shadow-[4px_4px_0_0_var(--border)]"
306+ >
307+ <iframe
308+ class="h-full w-full"
309+ src="https://www.youtube.com/embed/{yt}"
310+ title="YouTube video player"
311+ frameborder="0"
312+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
313+ referrerpolicy="strict-origin-when-cross-origin"
314+ allowfullscreen
315+ ></iframe>
316+ </div>
317+ {/if}
318+
319+ {#if showContent}<hr
320+ class="my-8 border-t-2 border-[var(--border)]"
321+ />{/if}
322+
323+ {#if showContent}
324+ <div bind:this={bodyEl} class="article-body">
325+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
326+ {@html showContent}
327+ </div>
328+ {/if}
329+
330+ {#if !data.article.content && !fullContent && data.article.url && !isEmbedURL(data.article.url)}
331+ <div class="mt-6">
332+ {#if fetchingContent}
333+ <span class="text-sm text-[var(--muted)]">Fetching…</span>
334+ {:else if contentError}
335+ <div class="text-sm text-[var(--muted)]">
336+ Failed to fetch content. <button
337+ class="text-[var(--accent)] underline"
338+ onclick={fetchContent}>Retry</button
339+ >
340+ </div>
341+ {:else}
342+ <button onclick={fetchContent} class="btn">
343+ <Icon
344+ name="refresh"
345+ class="h-3.5 w-3.5"
346+ strokeWidth={1.5}
347+ />Fetch full content
348+ </button>
349+ {/if}
350+ </div>
351+ {:else if !data.article.url}
352+ <p class="text-[var(--muted)]">No content available.</p>
353+ {/if}
354+ </article>
355+
356+ <hr class="my-8 border-t-2 border-[var(--border)]" />
357+
358+ <!-- Annotations -->
359+ <section>
360+ <h2
361+ class="mb-5 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
362+ >
363+ Annotations
364+ </h2>
365+
366+ <form
367+ onsubmit={(e) => {
368+ e.preventDefault();
369+ submitComment();
370+ }}
371+ class="panel mb-4 space-y-3 p-4"
372+ >
373+ <textarea
374+ bind:value={commentNote}
375+ rows="2"
376+ placeholder="Add a comment..."
377+ class="input-brutal resize-none"></textarea>
378+ <div class="flex gap-2">
379+ <input
380+ bind:value={commentTags}
381+ type="text"
382+ placeholder="Tags (comma separated)"
383+ class="input-brutal min-w-0 flex-1"
384+ />
385+ <button
386+ type="submit"
387+ disabled={submitting}
388+ class="btn btn-accent shrink-0">Comment</button
389+ >
390+ </div>
391+ </form>
392+
393+ <div class="space-y-3">
394+ {#if annotations.length === 0}
395+ <p class="py-6 text-center text-sm text-[var(--muted)]">
396+ No annotations yet. Select text above or add a note.
397+ </p>
398+ {:else}
399+ {#each annotations as a (a.id)}
400+ <AnnotationCard
401+ annotation={a}
402+ userDID={data.current_user_did}
403+ />
404+ {/each}
405+ {/if}
406+ </div>
407+ </section>
408+
409+ <!-- Selection popover -->
410+ {#if popoverOpen}
411+ <div
412+ bind:this={popoverEl}
413+ class="panel fixed z-50 w-[22rem] max-w-[calc(100vw-2rem)] space-y-3 p-4 shadow-[6px_6px_0_0_var(--border)]"
414+ style="left: {popoverLeft}; top: {popoverTop};"
415+ >
416+ <div class="flex items-center justify-between">
417+ <span
418+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
419+ >Annotate</span
420+ >
421+ <button
422+ type="button"
423+ onclick={closePopover}
424+ aria-label="Close"
425+ class="text-[var(--muted)] hover:text-[var(--fg)]"
426+ >
427+ <Icon name="x" class="h-4 w-4" />
428+ </button>
429+ </div>
430+ {#if quoteValue}
431+ <blockquote
432+ class="border-l-[6px] border-l-[var(--accent)] pl-3 text-sm italic text-[var(--muted)]"
433+ >
434+ {quoteValue}
435+ </blockquote>
436+ {/if}
437+ <textarea
438+ bind:value={noteValue}
439+ rows="2"
440+ placeholder="Add a note..."
441+ class="input-brutal resize-none"></textarea>
442+ <div class="flex gap-2">
443+ <input
444+ bind:value={tagsValue}
445+ type="text"
446+ placeholder="Tags (comma separated)"
447+ class="input-brutal min-w-0 flex-1"
448+ />
449+ <button
450+ type="button"
451+ onclick={submitAnnotation}
452+ disabled={submitting}
453+ class="btn btn-accent shrink-0">Annotate</button
454+ >
455+ </div>
456+ </div>
457+ {/if}
458+
459+ <!-- Bottom nav -->
460+ <div
461+ class="mt-8 mb-4 flex items-center justify-between border-t-2 border-[var(--border)] pt-4"
462+ >
463+ <button
464+ type="button"
465+ onclick={goBack}
466+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
467+ >
468+ <Icon name="arrowLeft" class="h-4 w-4" />Back
469+ </button>
470+ {#if data.next_id}
471+ <a
472+ href="/articles/{data.next_id}{data.next_suffix}"
473+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
474+ >
475+ Next<Icon name="arrowRight" class="h-4 w-4" />
476+ </a>
477+ {/if}
478+ </div>
479+</div>
new file mode 100644
@@ -0,0 +1,479 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import LikeButton from "$lib/components/LikeButton.svelte";
4+ import Favicon from "$lib/components/Favicon.svelte";
5+ import AnnotationCard from "$lib/components/AnnotationCard.svelte";
6+ import Icon from "$lib/components/Icon.svelte";
7+ import BlueskyLogo from "$lib/components/BlueskyLogo.svelte";
8+ import { endpoints } from "$lib/api";
9+ import { invalidateAll } from "$app/navigation";
10+ import { youtubeID, isEmbedURL } from "$lib/format";
11+ import type { Annotation } from "$lib/types";
12+
13+ let { data }: { data: PageData } = $props();
14+
15+ {
16+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
17+ }
18+ let liked = $state(data.article.has_liked);
19+ {
20+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
21+ }
22+ let likeCount = $state(data.article.like_count);
23+ {
24+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
25+ }
26+ let read = $state(data.article.is_read);
27+ {
28+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
29+ }
30+ let annotations = $state<Annotation[]>(data.annotations);
31+ {
32+ /* svelte-ignore state_referenced_locally -- optimistic local copy */
33+ }
34+ let fullContent = $state(data.article.full_content);
35+ let fetchingContent = $state(false);
36+ let contentError = $state("");
37+
38+ let popoverOpen = $state(false);
39+ let popoverTop = $state("0px");
40+ let popoverLeft = $state("0px");
41+ let quoteValue = $state("");
42+ let noteValue = $state("");
43+ let tagsValue = $state("");
44+ let submitting = $state(false);
45+
46+ let bodyEl = $state<HTMLElement | null>(null);
47+ let popoverEl = $state<HTMLElement | null>(null);
48+
49+ function goBack() {
50+ history.back();
51+ }
52+
53+ const yt = $derived(youtubeID(data.article.url));
54+ const showContent = $derived(
55+ fullContent || data.article.content || data.article.summary,
56+ );
57+
58+ $effect(() => {
59+ annotations = data.annotations;
60+ });
61+
62+ function clamp(v: number, min: number, max: number) {
63+ return Math.max(min, Math.min(max, v));
64+ }
65+
66+ function openForSelection() {
67+ const sel = window.getSelection();
68+ const text = sel?.toString().trim() ?? "";
69+ if (!text || !bodyEl) return;
70+ const range = sel!.getRangeAt(0);
71+ const rect = range.getBoundingClientRect();
72+ let q = text;
73+ if (q.length > 1000) q = q.substring(0, 1000);
74+ quoteValue = q;
75+ popoverOpen = true;
76+ const margin = 8;
77+ const width = 352;
78+ let left = clamp(
79+ rect.left + (rect.width - width) / 2,
80+ margin,
81+ window.innerWidth - width - margin,
82+ );
83+ let top = rect.bottom + margin;
84+ if (
85+ window.innerHeight - rect.bottom < 320 + margin &&
86+ rect.top > 320 + margin
87+ ) {
88+ top = rect.top - 320 - margin;
89+ }
90+ top = Math.max(margin, top);
91+ popoverLeft = left + "px";
92+ popoverTop = top + "px";
93+ }
94+
95+ function onMouseUp(e: MouseEvent) {
96+ if (popoverEl?.contains(e.target as Node)) return;
97+ if (!bodyEl?.contains(e.target as Node)) {
98+ closePopover();
99+ return;
100+ }
101+ const sel = window.getSelection();
102+ const text = sel?.toString().trim();
103+ if (text) openForSelection();
104+ }
105+
106+ function closePopover() {
107+ popoverOpen = false;
108+ quoteValue = "";
109+ noteValue = "";
110+ const sel = window.getSelection();
111+ sel?.removeAllRanges();
112+ }
113+
114+ function onKeydown(e: KeyboardEvent) {
115+ const t = e.target as HTMLElement | null;
116+ if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) return;
117+ if (e.key === "Escape" && popoverOpen) closePopover();
118+ }
119+
120+ async function submitAnnotation() {
121+ submitting = true;
122+ try {
123+ const res = await endpoints.createAnnotation({
124+ feed_url: data.article.feed_url,
125+ article_url: data.article.url,
126+ quote: quoteValue,
127+ note: noteValue,
128+ tags: tagsValue,
129+ });
130+ annotations = [...annotations, res.annotation];
131+ noteValue = "";
132+ tagsValue = "";
133+ quoteValue = "";
134+ closePopover();
135+ } finally {
136+ submitting = false;
137+ }
138+ }
139+
140+ let commentNote = $state("");
141+ let commentTags = $state("");
142+
143+ async function submitComment() {
144+ submitting = true;
145+ try {
146+ const res = await endpoints.createAnnotation({
147+ feed_url: data.article.feed_url,
148+ article_url: data.article.url,
149+ note: commentNote,
150+ tags: commentTags,
151+ });
152+ annotations = [...annotations, res.annotation];
153+ commentNote = "";
154+ commentTags = "";
155+ } finally {
156+ submitting = false;
157+ }
158+ }
159+
160+ async function toggleRead() {
161+ const next = !read;
162+ read = next;
163+ try {
164+ if (next) await endpoints.markRead(data.article.id);
165+ else await endpoints.markUnread(data.article.id);
166+ await invalidateAll();
167+ } catch {
168+ read = !next;
169+ }
170+ }
171+
172+ async function fetchContent() {
173+ fetchingContent = true;
174+ contentError = "";
175+ try {
176+ const res = await endpoints.fetchContent(data.article.id);
177+ fullContent = res.full_content;
178+ } catch (e) {
179+ contentError =
180+ e instanceof Error ? e.message : "Failed to fetch content";
181+ } finally {
182+ fetchingContent = false;
183+ }
184+ }
185+
186+ function fmt(t: string | null): string {
187+ if (!t) return "";
188+ return new Date(t).toLocaleString("en-US", {
189+ month: "short",
190+ day: "2-digit",
191+ year: "numeric",
192+ hour: "2-digit",
193+ minute: "2-digit",
194+ });
195+ }
196+</script>
197+
198+<svelte:window onmouseup={onMouseUp} onkeydown={onKeydown} />
199+
200+<div class="mx-auto max-w-3xl">
201+ <!-- Top nav -->
202+ <div
203+ class="mb-6 flex items-center justify-between border-b-2 border-[var(--border)] pb-4"
204+ >
205+ <button
206+ type="button"
207+ onclick={goBack}
208+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
209+ >
210+ <Icon name="arrowLeft" class="h-4 w-4" />Back
211+ </button>
212+ {#if data.next_id}
213+ <a
214+ href="/articles/{data.next_id}{data.next_suffix}"
215+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
216+ >
217+ Next<Icon name="arrowRight" class="h-4 w-4" />
218+ </a>
219+ {/if}
220+ </div>
221+
222+ <article>
223+ <h1
224+ class="text-2xl font-extrabold uppercase leading-tight tracking-tight md:text-3xl"
225+ >
226+ {#if data.article.url}
227+ <a
228+ href={data.article.url}
229+ target="_blank"
230+ rel="noopener noreferrer"
231+ class="hover:text-[var(--accent)]">{data.article.title}</a
232+ >
233+ {:else}
234+ {data.article.title}
235+ {/if}
236+ </h1>
237+
238+ <!-- Meta -->
239+ <div
240+ class="mt-4 flex flex-wrap items-center gap-2.5 text-xs text-[var(--muted)]"
241+ >
242+ {#if data.article.author}<span class="font-bold text-[var(--fg)]"
243+ >{data.article.author}</span
244+ >{/if}
245+ {#if data.article.published}<span>·</span><span
246+ >{fmt(data.article.published)}</span
247+ >{/if}
248+ {#if data.feed?.feed_url}
249+ <span>·</span>
250+ <a
251+ href="/articles?feed={encodeURIComponent(
252+ data.feed.feed_url,
253+ )}"
254+ class="inline-flex items-center gap-1.5 hover:text-[var(--accent)]"
255+ >
256+ <Favicon src={data.feed.favicon_url} size="h-4 w-4" />
257+ {data.feed.title || data.feed.feed_url}
258+ </a>
259+ {/if}
260+ </div>
261+
262+ <!-- Action bar -->
263+ <div class="mt-6 flex flex-wrap items-center gap-2">
264+ <LikeButton
265+ articleId={data.article.id}
266+ bind:liked
267+ bind:count={likeCount}
268+ />
269+ <button
270+ onclick={toggleRead}
271+ title={read ? "Mark as unread" : "Mark as read"}
272+ class="chip"
273+ data-active={read}
274+ >
275+ <Icon name="check" class="h-3.5 w-3.5" />{read
276+ ? "Unread"
277+ : "Read"}
278+ </button>
279+ {#if data.article.url}
280+ <a
281+ href={data.article.url}
282+ target="_blank"
283+ rel="noopener noreferrer"
284+ title="Open original"
285+ class="chip"
286+ >
287+ <Icon name="external" class="h-3.5 w-3.5" />Original
288+ </a>
289+ <a
290+ href="https://bsky.app/intent/compose?text={encodeURIComponent(
291+ data.article.title + ' ' + data.article.url,
292+ )}"
293+ target="_blank"
294+ rel="noopener noreferrer"
295+ title="Share on Bluesky"
296+ class="chip"
297+ >
298+ <BlueskyLogo class="h-3.5 w-3.5" />Share
299+ </a>
300+ {/if}
301+ </div>
302+
303+ {#if yt}
304+ <div
305+ class="my-8 aspect-video w-full overflow-hidden border-2 border-[var(--border)] shadow-[4px_4px_0_0_var(--border)]"
306+ >
307+ <iframe
308+ class="h-full w-full"
309+ src="https://www.youtube.com/embed/{yt}"
310+ title="YouTube video player"
311+ frameborder="0"
312+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
313+ referrerpolicy="strict-origin-when-cross-origin"
314+ allowfullscreen
315+ ></iframe>
316+ </div>
317+ {/if}
318+
319+ {#if showContent}<hr
320+ class="my-8 border-t-2 border-[var(--border)]"
321+ />{/if}
322+
323+ {#if showContent}
324+ <div bind:this={bodyEl} class="article-body">
325+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
326+ {@html showContent}
327+ </div>
328+ {/if}
329+
330+ {#if !data.article.content && !fullContent && data.article.url && !isEmbedURL(data.article.url)}
331+ <div class="mt-6">
332+ {#if fetchingContent}
333+ <span class="text-sm text-[var(--muted)]">Fetching…</span>
334+ {:else if contentError}
335+ <div class="text-sm text-[var(--muted)]">
336+ Failed to fetch content. <button
337+ class="text-[var(--accent)] underline"
338+ onclick={fetchContent}>Retry</button
339+ >
340+ </div>
341+ {:else}
342+ <button onclick={fetchContent} class="btn">
343+ <Icon
344+ name="refresh"
345+ class="h-3.5 w-3.5"
346+ strokeWidth={1.5}
347+ />Fetch full content
348+ </button>
349+ {/if}
350+ </div>
351+ {:else if !data.article.url}
352+ <p class="text-[var(--muted)]">No content available.</p>
353+ {/if}
354+ </article>
355+
356+ <hr class="my-8 border-t-2 border-[var(--border)]" />
357+
358+ <!-- Annotations -->
359+ <section>
360+ <h2
361+ class="mb-5 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
362+ >
363+ Annotations
364+ </h2>
365+
366+ <form
367+ onsubmit={(e) => {
368+ e.preventDefault();
369+ submitComment();
370+ }}
371+ class="panel mb-4 space-y-3 p-4"
372+ >
373+ <textarea
374+ bind:value={commentNote}
375+ rows="2"
376+ placeholder="Add a comment..."
377+ class="input-brutal resize-none"></textarea>
378+ <div class="flex gap-2">
379+ <input
380+ bind:value={commentTags}
381+ type="text"
382+ placeholder="Tags (comma separated)"
383+ class="input-brutal min-w-0 flex-1"
384+ />
385+ <button
386+ type="submit"
387+ disabled={submitting}
388+ class="btn btn-accent shrink-0">Comment</button
389+ >
390+ </div>
391+ </form>
392+
393+ <div class="space-y-3">
394+ {#if annotations.length === 0}
395+ <p class="py-6 text-center text-sm text-[var(--muted)]">
396+ No annotations yet. Select text above or add a note.
397+ </p>
398+ {:else}
399+ {#each annotations as a (a.id)}
400+ <AnnotationCard
401+ annotation={a}
402+ userDID={data.current_user_did}
403+ />
404+ {/each}
405+ {/if}
406+ </div>
407+ </section>
408+
409+ <!-- Selection popover -->
410+ {#if popoverOpen}
411+ <div
412+ bind:this={popoverEl}
413+ class="panel fixed z-50 w-[22rem] max-w-[calc(100vw-2rem)] space-y-3 p-4 shadow-[6px_6px_0_0_var(--border)]"
414+ style="left: {popoverLeft}; top: {popoverTop};"
415+ >
416+ <div class="flex items-center justify-between">
417+ <span
418+ class="text-[0.65rem] font-extrabold uppercase tracking-widest text-[var(--muted)]"
419+ >Annotate</span
420+ >
421+ <button
422+ type="button"
423+ onclick={closePopover}
424+ aria-label="Close"
425+ class="text-[var(--muted)] hover:text-[var(--fg)]"
426+ >
427+ <Icon name="x" class="h-4 w-4" />
428+ </button>
429+ </div>
430+ {#if quoteValue}
431+ <blockquote
432+ class="border-l-[6px] border-l-[var(--accent)] pl-3 text-sm italic text-[var(--muted)]"
433+ >
434+ {quoteValue}
435+ </blockquote>
436+ {/if}
437+ <textarea
438+ bind:value={noteValue}
439+ rows="2"
440+ placeholder="Add a note..."
441+ class="input-brutal resize-none"></textarea>
442+ <div class="flex gap-2">
443+ <input
444+ bind:value={tagsValue}
445+ type="text"
446+ placeholder="Tags (comma separated)"
447+ class="input-brutal min-w-0 flex-1"
448+ />
449+ <button
450+ type="button"
451+ onclick={submitAnnotation}
452+ disabled={submitting}
453+ class="btn btn-accent shrink-0">Annotate</button
454+ >
455+ </div>
456+ </div>
457+ {/if}
458+
459+ <!-- Bottom nav -->
460+ <div
461+ class="mt-8 mb-4 flex items-center justify-between border-t-2 border-[var(--border)] pt-4"
462+ >
463+ <button
464+ type="button"
465+ onclick={goBack}
466+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
467+ >
468+ <Icon name="arrowLeft" class="h-4 w-4" />Back
469+ </button>
470+ {#if data.next_id}
471+ <a
472+ href="/articles/{data.next_id}{data.next_suffix}"
473+ class="inline-flex items-center gap-1.5 text-[0.7rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--fg)]"
474+ >
475+ Next<Icon name="arrowRight" class="h-4 w-4" />
476+ </a>
477+ {/if}
478+ </div>
479+</div>
added web/src/routes/auth/login/+page.server.ts +8 -0
new file mode 100644
@@ -0,0 +1,8 @@
1+import { redirect } from '@sveltejs/kit';
2+import type { PageServerLoad } from './$types';
3+
4+export const load: PageServerLoad = async ({ parent }) => {
5+ const { user } = await parent();
6+ if (user) throw redirect(303, '/dashboard');
7+ return {};
8+};
new file mode 100644
@@ -0,0 +1,8 @@
1+import { redirect } from '@sveltejs/kit';
2+import type { PageServerLoad } from './$types';
3+
4+export const load: PageServerLoad = async ({ parent }) => {
5+ const { user } = await parent();
6+ if (user) throw redirect(303, '/dashboard');
7+ return {};
8+};
added web/src/routes/auth/login/+page.svelte +195 -0
new file mode 100644
@@ -0,0 +1,195 @@
1+<script lang="ts">
2+ import { endpoints } from "$lib/api";
3+ import Logo from "$lib/components/Logo.svelte";
4+ import type { Actor } from "$lib/types";
5+
6+ let handle = $state("");
7+ let actors = $state<Actor[]>([]);
8+ let selected = $state(-1);
9+ let timer: ReturnType<typeof setTimeout>;
10+ let err = $state("");
11+
12+ function onInput() {
13+ clearTimeout(timer);
14+ const q = handle.trim().replace(/^@/, "");
15+ if (q.length < 1) {
16+ actors = [];
17+ selected = -1;
18+ return;
19+ }
20+ timer = setTimeout(async () => {
21+ try {
22+ const r = await endpoints.authActors(q);
23+ actors = r.actors ?? [];
24+ selected = -1;
25+ } catch {
26+ actors = [];
27+ }
28+ }, 250);
29+ }
30+
31+ function onKeydown(e: KeyboardEvent) {
32+ if (!actors.length) return;
33+ if (e.key === "ArrowDown") {
34+ e.preventDefault();
35+ selected = Math.min(selected + 1, actors.length - 1);
36+ } else if (e.key === "ArrowUp") {
37+ e.preventDefault();
38+ selected = Math.max(selected - 1, 0);
39+ } else if (e.key === "Enter" && selected >= 0) {
40+ e.preventDefault();
41+ pick(actors[selected]);
42+ } else if (e.key === "Escape") {
43+ actors = [];
44+ }
45+ }
46+
47+ function pick(a: Actor) {
48+ handle = a.handle;
49+ actors = [];
50+ }
51+
52+ async function submit() {
53+ const h = handle.trim().replace(/^@/, "");
54+ if (!h) return;
55+ try {
56+ const r = await endpoints.authStart(h);
57+ window.location.href = r.redirect;
58+ } catch (e) {
59+ err = e instanceof Error ? e.message : "Sign in failed";
60+ }
61+ }
62+
63+ async function register() {
64+ try {
65+ const r = await endpoints.authRegister();
66+ window.location.href = r.redirect;
67+ } catch (e) {
68+ err = e instanceof Error ? e.message : "Registration failed";
69+ }
70+ }
71+</script>
72+
73+<div class="flex min-h-screen items-center justify-center px-4 py-12">
74+ <div class="w-full max-w-sm">
75+ <div class="mb-8 flex justify-center">
76+ <Logo size="lg" />
77+ </div>
78+
79+ <div class="panel p-6">
80+ <h1
81+ class="text-center text-2xl font-extrabold uppercase tracking-tight"
82+ >
83+ Welcome
84+ </h1>
85+ <p
86+ class="mt-1 text-center text-xs uppercase tracking-widest text-[var(--muted)]"
87+ >
88+ The social RSS reader on AT Protocol.
89+ </p>
90+
91+ <form
92+ onsubmit={(e) => {
93+ e.preventDefault();
94+ submit();
95+ }}
96+ id="login-form"
97+ class="mt-6 space-y-3"
98+ >
99+ <label
100+ for="handle-input"
101+ class="block text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
102+ >
103+ Account handle
104+ </label>
105+ <div class="relative">
106+ <input
107+ bind:value={handle}
108+ oninput={onInput}
109+ onkeydown={onKeydown}
110+ type="text"
111+ placeholder="you.bsky.social"
112+ id="handle-input"
113+ class="input-brutal"
114+ autocomplete="off"
115+ />
116+ <div
117+ class="absolute left-0 right-0 top-full z-50 panel divide-y-2 divide-[var(--border)] {actors.length
118+ ? ''
119+ : 'hidden'}"
120+ >
121+ {#each actors as a, i}
122+ <button
123+ type="button"
124+ onclick={() => pick(a)}
125+ class="flex w-full items-center gap-3 p-2.5 text-left {i ===
126+ selected
127+ ? 'bg-[var(--bg)]'
128+ : ''}"
129+ >
130+ {#if a.avatar}
131+ <img
132+ src={a.avatar}
133+ class="h-8 w-8 shrink-0 border-2 border-[var(--border)] object-cover"
134+ alt=""
135+ />
136+ {:else}
137+ <div
138+ class="h-8 w-8 shrink-0 border-2 border-[var(--border)] bg-[var(--surface)]"
139+ ></div>
140+ {/if}
141+ <div class="min-w-0">
142+ <div class="truncate text-sm font-bold">
143+ @{a.handle}
144+ </div>
145+ {#if a.displayName}
146+ <div
147+ class="truncate text-xs text-[var(--muted)]"
148+ >
149+ {a.displayName}
150+ </div>
151+ {/if}
152+ </div>
153+ </button>
154+ {/each}
155+ </div>
156+ </div>
157+
158+ <button type="submit" class="btn btn-accent w-full"
159+ >Login</button
160+ >
161+ </form>
162+
163+ {#if err}
164+ <p
165+ class="mt-4 text-center text-xs font-bold uppercase tracking-wide text-[var(--danger)]"
166+ >
167+ {err}
168+ </p>
169+ {/if}
170+ </div>
171+
172+ <div
173+ class="my-6 flex items-center gap-3 text-xs uppercase tracking-widest text-[var(--muted)]"
174+ >
175+ <span class="h-0 flex-1 border-t-2 border-[var(--border)]"></span>
176+ <span>New here?</span>
177+ <span class="h-0 flex-1 border-t-2 border-[var(--border)]"></span>
178+ </div>
179+
180+ <button type="button" onclick={register} class="btn w-full">
181+ Register with Eurosky
182+ </button>
183+
184+ <p
185+ class="mt-8 text-center text-[11px] leading-relaxed text-[var(--muted)]"
186+ >
187+ By continuing, you agree to the
188+ <a
189+ href="/terms"
190+ class="font-bold text-[var(--accent)] underline underline-offset-2"
191+ >Terms of Service</a
192+ >.
193+ </p>
194+ </div>
195+</div>
new file mode 100644
@@ -0,0 +1,195 @@
1+<script lang="ts">
2+ import { endpoints } from "$lib/api";
3+ import Logo from "$lib/components/Logo.svelte";
4+ import type { Actor } from "$lib/types";
5+
6+ let handle = $state("");
7+ let actors = $state<Actor[]>([]);
8+ let selected = $state(-1);
9+ let timer: ReturnType<typeof setTimeout>;
10+ let err = $state("");
11+
12+ function onInput() {
13+ clearTimeout(timer);
14+ const q = handle.trim().replace(/^@/, "");
15+ if (q.length < 1) {
16+ actors = [];
17+ selected = -1;
18+ return;
19+ }
20+ timer = setTimeout(async () => {
21+ try {
22+ const r = await endpoints.authActors(q);
23+ actors = r.actors ?? [];
24+ selected = -1;
25+ } catch {
26+ actors = [];
27+ }
28+ }, 250);
29+ }
30+
31+ function onKeydown(e: KeyboardEvent) {
32+ if (!actors.length) return;
33+ if (e.key === "ArrowDown") {
34+ e.preventDefault();
35+ selected = Math.min(selected + 1, actors.length - 1);
36+ } else if (e.key === "ArrowUp") {
37+ e.preventDefault();
38+ selected = Math.max(selected - 1, 0);
39+ } else if (e.key === "Enter" && selected >= 0) {
40+ e.preventDefault();
41+ pick(actors[selected]);
42+ } else if (e.key === "Escape") {
43+ actors = [];
44+ }
45+ }
46+
47+ function pick(a: Actor) {
48+ handle = a.handle;
49+ actors = [];
50+ }
51+
52+ async function submit() {
53+ const h = handle.trim().replace(/^@/, "");
54+ if (!h) return;
55+ try {
56+ const r = await endpoints.authStart(h);
57+ window.location.href = r.redirect;
58+ } catch (e) {
59+ err = e instanceof Error ? e.message : "Sign in failed";
60+ }
61+ }
62+
63+ async function register() {
64+ try {
65+ const r = await endpoints.authRegister();
66+ window.location.href = r.redirect;
67+ } catch (e) {
68+ err = e instanceof Error ? e.message : "Registration failed";
69+ }
70+ }
71+</script>
72+
73+<div class="flex min-h-screen items-center justify-center px-4 py-12">
74+ <div class="w-full max-w-sm">
75+ <div class="mb-8 flex justify-center">
76+ <Logo size="lg" />
77+ </div>
78+
79+ <div class="panel p-6">
80+ <h1
81+ class="text-center text-2xl font-extrabold uppercase tracking-tight"
82+ >
83+ Welcome
84+ </h1>
85+ <p
86+ class="mt-1 text-center text-xs uppercase tracking-widest text-[var(--muted)]"
87+ >
88+ The social RSS reader on AT Protocol.
89+ </p>
90+
91+ <form
92+ onsubmit={(e) => {
93+ e.preventDefault();
94+ submit();
95+ }}
96+ id="login-form"
97+ class="mt-6 space-y-3"
98+ >
99+ <label
100+ for="handle-input"
101+ class="block text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
102+ >
103+ Account handle
104+ </label>
105+ <div class="relative">
106+ <input
107+ bind:value={handle}
108+ oninput={onInput}
109+ onkeydown={onKeydown}
110+ type="text"
111+ placeholder="you.bsky.social"
112+ id="handle-input"
113+ class="input-brutal"
114+ autocomplete="off"
115+ />
116+ <div
117+ class="absolute left-0 right-0 top-full z-50 panel divide-y-2 divide-[var(--border)] {actors.length
118+ ? ''
119+ : 'hidden'}"
120+ >
121+ {#each actors as a, i}
122+ <button
123+ type="button"
124+ onclick={() => pick(a)}
125+ class="flex w-full items-center gap-3 p-2.5 text-left {i ===
126+ selected
127+ ? 'bg-[var(--bg)]'
128+ : ''}"
129+ >
130+ {#if a.avatar}
131+ <img
132+ src={a.avatar}
133+ class="h-8 w-8 shrink-0 border-2 border-[var(--border)] object-cover"
134+ alt=""
135+ />
136+ {:else}
137+ <div
138+ class="h-8 w-8 shrink-0 border-2 border-[var(--border)] bg-[var(--surface)]"
139+ ></div>
140+ {/if}
141+ <div class="min-w-0">
142+ <div class="truncate text-sm font-bold">
143+ @{a.handle}
144+ </div>
145+ {#if a.displayName}
146+ <div
147+ class="truncate text-xs text-[var(--muted)]"
148+ >
149+ {a.displayName}
150+ </div>
151+ {/if}
152+ </div>
153+ </button>
154+ {/each}
155+ </div>
156+ </div>
157+
158+ <button type="submit" class="btn btn-accent w-full"
159+ >Login</button
160+ >
161+ </form>
162+
163+ {#if err}
164+ <p
165+ class="mt-4 text-center text-xs font-bold uppercase tracking-wide text-[var(--danger)]"
166+ >
167+ {err}
168+ </p>
169+ {/if}
170+ </div>
171+
172+ <div
173+ class="my-6 flex items-center gap-3 text-xs uppercase tracking-widest text-[var(--muted)]"
174+ >
175+ <span class="h-0 flex-1 border-t-2 border-[var(--border)]"></span>
176+ <span>New here?</span>
177+ <span class="h-0 flex-1 border-t-2 border-[var(--border)]"></span>
178+ </div>
179+
180+ <button type="button" onclick={register} class="btn w-full">
181+ Register with Eurosky
182+ </button>
183+
184+ <p
185+ class="mt-8 text-center text-[11px] leading-relaxed text-[var(--muted)]"
186+ >
187+ By continuing, you agree to the
188+ <a
189+ href="/terms"
190+ class="font-bold text-[var(--accent)] underline underline-offset-2"
191+ >Terms of Service</a
192+ >.
193+ </p>
194+ </div>
195+</div>
added web/src/routes/dashboard/+page.server.ts +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ return await endpointsFor(event.fetch).dashboard();
9+};
new file mode 100644
@@ -0,0 +1,9 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ return await endpointsFor(event.fetch).dashboard();
9+};
added web/src/routes/dashboard/+page.svelte +337 -0
new file mode 100644
@@ -0,0 +1,337 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import ArticleCard from "$lib/components/ArticleCard.svelte";
4+ import TrendingCard from "$lib/components/TrendingCard.svelte";
5+ import ProfileCard from "$lib/components/ProfileCard.svelte";
6+ import FeedRecommendationCard from "$lib/components/FeedRecommendationCard.svelte";
7+ import EmptyState from "$lib/components/EmptyState.svelte";
8+ import Icon from "$lib/components/Icon.svelte";
9+ import NewArticlesBanner from "$lib/components/NewArticlesBanner.svelte";
10+ import { endpoints } from "$lib/api";
11+ import type {
12+ Article,
13+ Digest,
14+ FeedRecommendation,
15+ PersonRecommendation,
16+ TrendingItem,
17+ } from "$lib/types";
18+
19+ let { data }: { data: PageData } = $props();
20+
21+ let articleRecs = $state<Article[] | null>(null);
22+ let feedRecs = $state<FeedRecommendation[] | null>(null);
23+ let followed = $state<PersonRecommendation[]>([]);
24+ let discover = $state<PersonRecommendation[]>([]);
25+ let digest = $state<Digest | null>(null);
26+
27+ $effect(() => {
28+ if (data.subscription_count > 0) {
29+ endpoints
30+ .articleRecs()
31+ .then((r) => (articleRecs = r.articles))
32+ .catch(() => (articleRecs = []));
33+ }
34+ if (data.digest_enabled && data.has_llm) {
35+ endpoints
36+ .digest()
37+ .then((d) => (digest = d))
38+ .catch(() => (digest = null));
39+ }
40+ endpoints
41+ .feedRecs()
42+ .then((r) => (feedRecs = r.feeds))
43+ .catch(() => (feedRecs = []));
44+ endpoints
45+ .peopleRecs()
46+ .then((r) => {
47+ followed = r.followed;
48+ discover = r.discover;
49+ })
50+ .catch(() => {});
51+ });
52+
53+ function dismissArticle(url: string) {
54+ articleRecs = (articleRecs ?? []).filter((a) => a.url !== url);
55+ }
56+ function dismissFeed(url: string) {
57+ feedRecs = (feedRecs ?? []).filter((f) => f.feed_url !== url);
58+ }
59+ function dismissPerson(did: string) {
60+ followed = followed.filter((p) => p.did !== did);
61+ discover = discover.filter((p) => p.did !== did);
62+ }
63+
64+ let digestOpen = $state(false);
65+ async function markDigestRead() {
66+ if (!digest) return;
67+ await endpoints.markDigestRead(digest.article_ids);
68+ digest = { ...digest, consumed: true };
69+ }
70+
71+ const trending: TrendingItem[] = $derived([
72+ ...(data.global_trending ?? []),
73+ ...(data.personal_trending ?? []),
74+ ]);
75+</script>
76+
77+<!-- Header -->
78+<div
79+ class="mb-6 flex flex-wrap items-end justify-between gap-3 border-b-2 border-[var(--border)] pb-4"
80+>
81+ <div>
82+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
83+ Dashboard
84+ </h1>
85+ <p class="mt-1 text-xs text-[var(--muted)]">
86+ {data.subscription_count === 0
87+ ? "Get started by subscribing to RSS feeds."
88+ : "Your personalized feed, based on your social graph."}
89+ </p>
90+ </div>
91+ {#if data.subscription_count > 0}
92+ <div class="flex gap-2">
93+ <span class="chip" data-active="true"
94+ >{data.unread_count} unread</span
95+ >
96+ <span class="chip">{data.subscription_count} feeds</span>
97+ </div>
98+ {/if}
99+</div>
100+
101+{#if data.subscription_count > 0}
102+ <NewArticlesBanner since={data.now} />
103+{/if}
104+
105+{#if data.subscription_count === 0}
106+ <div class="mb-10">
107+ <EmptyState
108+ icon="plus"
109+ title="Add your first feed"
110+ subtitle="Subscribe to RSS feeds to start building your personalized reading experience."
111+ />
112+ <div class="mt-4 text-center">
113+ <a href="/feeds" class="btn btn-accent"
114+ ><Icon name="plus" class="h-4 w-4" />Add feeds</a
115+ >
116+ </div>
117+ </div>
118+{:else if data.articles.length > 0}
119+ <!-- Recommended articles -->
120+ {#if articleRecs && articleRecs.length > 0}
121+ <section class="mb-10">
122+ <h2
123+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
124+ >
125+ Recommended for you
126+ </h2>
127+ <div class="space-y-3">
128+ {#each articleRecs as a (a.id)}
129+ <ArticleCard
130+ article={a}
131+ dismissible
132+ onDismiss={() => dismissArticle(a.url)}
133+ />
134+ {/each}
135+ </div>
136+ </section>
137+ {/if}
138+
139+ <!-- Digest -->
140+ {#if data.digest_enabled && data.has_llm}
141+ {#if digest === null}
142+ <section class="mb-10">
143+ <h2
144+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
145+ >
146+ Daily digest
147+ </h2>
148+ <div class="panel animate-pulse px-5 py-4">
149+ <div class="h-4 w-2/3 bg-[var(--surface)]"></div>
150+ </div>
151+ </section>
152+ {:else if !digest.consumed}
153+ <section class="mb-10">
154+ <h2
155+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
156+ >
157+ Daily digest
158+ </h2>
159+ <article
160+ class="panel border-l-[6px] border-l-[var(--accent)] p-5"
161+ >
162+ <div class="flex items-start justify-between gap-4">
163+ <div class="min-w-0 flex-1">
164+ <button
165+ class="w-full text-left"
166+ onclick={() => (digestOpen = !digestOpen)}
167+ >
168+ <span
169+ class="font-extrabold leading-snug hover:text-[var(--accent)]"
170+ >{digest.title}</span
171+ >
172+ </button>
173+ {#if !digestOpen}
174+ <p
175+ class="mt-2 line-clamp-2 text-xs leading-relaxed text-[var(--muted)]"
176+ >
177+ {digest.excerpt}
178+ </p>
179+ {/if}
180+ </div>
181+ <button onclick={markDigestRead} class="btn shrink-0"
182+ >Read</button
183+ >
184+ </div>
185+ {#if digestOpen}
186+ <div
187+ class="article-body mt-3 border-t-2 border-[var(--border)] pt-4 text-sm"
188+ >
189+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
190+ {@html digest.summary}
191+ </div>
192+ {/if}
193+ </article>
194+ </section>
195+ {/if}
196+ {/if}
197+
198+ <!-- Unread -->
199+ <section class="mb-10">
200+ <div class="mb-3 flex items-center justify-between">
201+ <h2
202+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
203+ >
204+ Unread articles
205+ </h2>
206+ <a
207+ href="/articles"
208+ class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--accent)]"
209+ >View all</a
210+ >
211+ </div>
212+ <div class="space-y-3">
213+ {#each data.articles as a (a.id)}
214+ <ArticleCard article={a} />
215+ {/each}
216+ </div>
217+ {#if data.unread_count > 5}
218+ <a
219+ href="/articles"
220+ class="mt-4 block py-2 text-center text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--accent)]"
221+ >
222+ View all {data.unread_count} unread articles
223+ </a>
224+ {/if}
225+ </section>
226+{:else}
227+ <!-- Caught up: still show recs above the empty state -->
228+ {#if articleRecs && articleRecs.length > 0}
229+ <section class="mb-10">
230+ <h2
231+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
232+ >
233+ Recommended for you
234+ </h2>
235+ <div class="space-y-3">
236+ {#each articleRecs as a (a.id)}
237+ <ArticleCard
238+ article={a}
239+ dismissible
240+ onDismiss={() => dismissArticle(a.url)}
241+ />
242+ {/each}
243+ </div>
244+ </section>
245+ {/if}
246+ <section class="mb-10">
247+ <h2
248+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
249+ >
250+ Unread articles
251+ </h2>
252+ <EmptyState
253+ icon="check"
254+ title="You're all caught up!"
255+ subtitle="No unread articles. Check back later for new content."
256+ />
257+ </section>
258+{/if}
259+
260+<!-- Trending -->
261+{#if trending.length > 0}
262+ <section class="mb-10">
263+ <div class="mb-3 flex items-center justify-between">
264+ <h2
265+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
266+ >
267+ {data.subscription_count === 0
268+ ? "Trending"
269+ : "Trending in your network"}
270+ </h2>
271+ <a
272+ href={data.subscription_count === 0
273+ ? "/trending"
274+ : "/trending?scope=for-me"}
275+ class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--accent)]"
276+ >See all</a
277+ >
278+ </div>
279+ <div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
280+ {#each trending as t (t.article_id)}
281+ <TrendingCard item={t} />
282+ {/each}
283+ </div>
284+ </section>
285+{/if}
286+
287+<!-- People recs -->
288+{#if followed.length > 0 || discover.length > 0}
289+ <section class="mb-10 grid grid-cols-1 gap-8 md:grid-cols-2">
290+ {#if followed.length > 0}
291+ <div>
292+ <h2
293+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
294+ >
295+ Your network
296+ </h2>
297+ <div class="space-y-3">
298+ {#each followed as p (p.did)}
299+ <ProfileCard person={p} onDismiss={dismissPerson} />
300+ {/each}
301+ </div>
302+ </div>
303+ {/if}
304+ {#if discover.length > 0}
305+ <div>
306+ <h2
307+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
308+ >
309+ Discover new readers
310+ </h2>
311+ <div class="space-y-3">
312+ {#each discover as p (p.did)}
313+ <ProfileCard person={p} onDismiss={dismissPerson} />
314+ {/each}
315+ </div>
316+ </div>
317+ {/if}
318+ </section>
319+{/if}
320+
321+<!-- Feed recs -->
322+{#if feedRecs !== null && feedRecs.length > 0}
323+ <section class="mb-10">
324+ <h2
325+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
326+ >
327+ {data.subscription_count === 0
328+ ? "Popular feeds to get started"
329+ : "Recommended feeds"}
330+ </h2>
331+ <div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
332+ {#each feedRecs as f (f.feed_url)}
333+ <FeedRecommendationCard rec={f} onDismiss={dismissFeed} />
334+ {/each}
335+ </div>
336+ </section>
337+{/if}
new file mode 100644
@@ -0,0 +1,337 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import ArticleCard from "$lib/components/ArticleCard.svelte";
4+ import TrendingCard from "$lib/components/TrendingCard.svelte";
5+ import ProfileCard from "$lib/components/ProfileCard.svelte";
6+ import FeedRecommendationCard from "$lib/components/FeedRecommendationCard.svelte";
7+ import EmptyState from "$lib/components/EmptyState.svelte";
8+ import Icon from "$lib/components/Icon.svelte";
9+ import NewArticlesBanner from "$lib/components/NewArticlesBanner.svelte";
10+ import { endpoints } from "$lib/api";
11+ import type {
12+ Article,
13+ Digest,
14+ FeedRecommendation,
15+ PersonRecommendation,
16+ TrendingItem,
17+ } from "$lib/types";
18+
19+ let { data }: { data: PageData } = $props();
20+
21+ let articleRecs = $state<Article[] | null>(null);
22+ let feedRecs = $state<FeedRecommendation[] | null>(null);
23+ let followed = $state<PersonRecommendation[]>([]);
24+ let discover = $state<PersonRecommendation[]>([]);
25+ let digest = $state<Digest | null>(null);
26+
27+ $effect(() => {
28+ if (data.subscription_count > 0) {
29+ endpoints
30+ .articleRecs()
31+ .then((r) => (articleRecs = r.articles))
32+ .catch(() => (articleRecs = []));
33+ }
34+ if (data.digest_enabled && data.has_llm) {
35+ endpoints
36+ .digest()
37+ .then((d) => (digest = d))
38+ .catch(() => (digest = null));
39+ }
40+ endpoints
41+ .feedRecs()
42+ .then((r) => (feedRecs = r.feeds))
43+ .catch(() => (feedRecs = []));
44+ endpoints
45+ .peopleRecs()
46+ .then((r) => {
47+ followed = r.followed;
48+ discover = r.discover;
49+ })
50+ .catch(() => {});
51+ });
52+
53+ function dismissArticle(url: string) {
54+ articleRecs = (articleRecs ?? []).filter((a) => a.url !== url);
55+ }
56+ function dismissFeed(url: string) {
57+ feedRecs = (feedRecs ?? []).filter((f) => f.feed_url !== url);
58+ }
59+ function dismissPerson(did: string) {
60+ followed = followed.filter((p) => p.did !== did);
61+ discover = discover.filter((p) => p.did !== did);
62+ }
63+
64+ let digestOpen = $state(false);
65+ async function markDigestRead() {
66+ if (!digest) return;
67+ await endpoints.markDigestRead(digest.article_ids);
68+ digest = { ...digest, consumed: true };
69+ }
70+
71+ const trending: TrendingItem[] = $derived([
72+ ...(data.global_trending ?? []),
73+ ...(data.personal_trending ?? []),
74+ ]);
75+</script>
76+
77+<!-- Header -->
78+<div
79+ class="mb-6 flex flex-wrap items-end justify-between gap-3 border-b-2 border-[var(--border)] pb-4"
80+>
81+ <div>
82+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
83+ Dashboard
84+ </h1>
85+ <p class="mt-1 text-xs text-[var(--muted)]">
86+ {data.subscription_count === 0
87+ ? "Get started by subscribing to RSS feeds."
88+ : "Your personalized feed, based on your social graph."}
89+ </p>
90+ </div>
91+ {#if data.subscription_count > 0}
92+ <div class="flex gap-2">
93+ <span class="chip" data-active="true"
94+ >{data.unread_count} unread</span
95+ >
96+ <span class="chip">{data.subscription_count} feeds</span>
97+ </div>
98+ {/if}
99+</div>
100+
101+{#if data.subscription_count > 0}
102+ <NewArticlesBanner since={data.now} />
103+{/if}
104+
105+{#if data.subscription_count === 0}
106+ <div class="mb-10">
107+ <EmptyState
108+ icon="plus"
109+ title="Add your first feed"
110+ subtitle="Subscribe to RSS feeds to start building your personalized reading experience."
111+ />
112+ <div class="mt-4 text-center">
113+ <a href="/feeds" class="btn btn-accent"
114+ ><Icon name="plus" class="h-4 w-4" />Add feeds</a
115+ >
116+ </div>
117+ </div>
118+{:else if data.articles.length > 0}
119+ <!-- Recommended articles -->
120+ {#if articleRecs && articleRecs.length > 0}
121+ <section class="mb-10">
122+ <h2
123+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
124+ >
125+ Recommended for you
126+ </h2>
127+ <div class="space-y-3">
128+ {#each articleRecs as a (a.id)}
129+ <ArticleCard
130+ article={a}
131+ dismissible
132+ onDismiss={() => dismissArticle(a.url)}
133+ />
134+ {/each}
135+ </div>
136+ </section>
137+ {/if}
138+
139+ <!-- Digest -->
140+ {#if data.digest_enabled && data.has_llm}
141+ {#if digest === null}
142+ <section class="mb-10">
143+ <h2
144+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
145+ >
146+ Daily digest
147+ </h2>
148+ <div class="panel animate-pulse px-5 py-4">
149+ <div class="h-4 w-2/3 bg-[var(--surface)]"></div>
150+ </div>
151+ </section>
152+ {:else if !digest.consumed}
153+ <section class="mb-10">
154+ <h2
155+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
156+ >
157+ Daily digest
158+ </h2>
159+ <article
160+ class="panel border-l-[6px] border-l-[var(--accent)] p-5"
161+ >
162+ <div class="flex items-start justify-between gap-4">
163+ <div class="min-w-0 flex-1">
164+ <button
165+ class="w-full text-left"
166+ onclick={() => (digestOpen = !digestOpen)}
167+ >
168+ <span
169+ class="font-extrabold leading-snug hover:text-[var(--accent)]"
170+ >{digest.title}</span
171+ >
172+ </button>
173+ {#if !digestOpen}
174+ <p
175+ class="mt-2 line-clamp-2 text-xs leading-relaxed text-[var(--muted)]"
176+ >
177+ {digest.excerpt}
178+ </p>
179+ {/if}
180+ </div>
181+ <button onclick={markDigestRead} class="btn shrink-0"
182+ >Read</button
183+ >
184+ </div>
185+ {#if digestOpen}
186+ <div
187+ class="article-body mt-3 border-t-2 border-[var(--border)] pt-4 text-sm"
188+ >
189+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
190+ {@html digest.summary}
191+ </div>
192+ {/if}
193+ </article>
194+ </section>
195+ {/if}
196+ {/if}
197+
198+ <!-- Unread -->
199+ <section class="mb-10">
200+ <div class="mb-3 flex items-center justify-between">
201+ <h2
202+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
203+ >
204+ Unread articles
205+ </h2>
206+ <a
207+ href="/articles"
208+ class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--accent)]"
209+ >View all</a
210+ >
211+ </div>
212+ <div class="space-y-3">
213+ {#each data.articles as a (a.id)}
214+ <ArticleCard article={a} />
215+ {/each}
216+ </div>
217+ {#if data.unread_count > 5}
218+ <a
219+ href="/articles"
220+ class="mt-4 block py-2 text-center text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--accent)]"
221+ >
222+ View all {data.unread_count} unread articles
223+ </a>
224+ {/if}
225+ </section>
226+{:else}
227+ <!-- Caught up: still show recs above the empty state -->
228+ {#if articleRecs && articleRecs.length > 0}
229+ <section class="mb-10">
230+ <h2
231+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
232+ >
233+ Recommended for you
234+ </h2>
235+ <div class="space-y-3">
236+ {#each articleRecs as a (a.id)}
237+ <ArticleCard
238+ article={a}
239+ dismissible
240+ onDismiss={() => dismissArticle(a.url)}
241+ />
242+ {/each}
243+ </div>
244+ </section>
245+ {/if}
246+ <section class="mb-10">
247+ <h2
248+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
249+ >
250+ Unread articles
251+ </h2>
252+ <EmptyState
253+ icon="check"
254+ title="You're all caught up!"
255+ subtitle="No unread articles. Check back later for new content."
256+ />
257+ </section>
258+{/if}
259+
260+<!-- Trending -->
261+{#if trending.length > 0}
262+ <section class="mb-10">
263+ <div class="mb-3 flex items-center justify-between">
264+ <h2
265+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
266+ >
267+ {data.subscription_count === 0
268+ ? "Trending"
269+ : "Trending in your network"}
270+ </h2>
271+ <a
272+ href={data.subscription_count === 0
273+ ? "/trending"
274+ : "/trending?scope=for-me"}
275+ class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--muted)] hover:text-[var(--accent)]"
276+ >See all</a
277+ >
278+ </div>
279+ <div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
280+ {#each trending as t (t.article_id)}
281+ <TrendingCard item={t} />
282+ {/each}
283+ </div>
284+ </section>
285+{/if}
286+
287+<!-- People recs -->
288+{#if followed.length > 0 || discover.length > 0}
289+ <section class="mb-10 grid grid-cols-1 gap-8 md:grid-cols-2">
290+ {#if followed.length > 0}
291+ <div>
292+ <h2
293+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
294+ >
295+ Your network
296+ </h2>
297+ <div class="space-y-3">
298+ {#each followed as p (p.did)}
299+ <ProfileCard person={p} onDismiss={dismissPerson} />
300+ {/each}
301+ </div>
302+ </div>
303+ {/if}
304+ {#if discover.length > 0}
305+ <div>
306+ <h2
307+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
308+ >
309+ Discover new readers
310+ </h2>
311+ <div class="space-y-3">
312+ {#each discover as p (p.did)}
313+ <ProfileCard person={p} onDismiss={dismissPerson} />
314+ {/each}
315+ </div>
316+ </div>
317+ {/if}
318+ </section>
319+{/if}
320+
321+<!-- Feed recs -->
322+{#if feedRecs !== null && feedRecs.length > 0}
323+ <section class="mb-10">
324+ <h2
325+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
326+ >
327+ {data.subscription_count === 0
328+ ? "Popular feeds to get started"
329+ : "Recommended feeds"}
330+ </h2>
331+ <div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
332+ {#each feedRecs as f (f.feed_url)}
333+ <FeedRecommendationCard rec={f} onDismiss={dismissFeed} />
334+ {/each}
335+ </div>
336+ </section>
337+{/if}
added web/src/routes/feeds/+page.server.ts +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ const category = event.url.searchParams.get("category") ?? "";
9+ return await endpointsFor(event.fetch).feeds(category);
10+};
new file mode 100644
@@ -0,0 +1,10 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ const category = event.url.searchParams.get("category") ?? "";
9+ return await endpointsFor(event.fetch).feeds(category);
10+};
added web/src/routes/feeds/+page.svelte +308 -0
new file mode 100644
@@ -0,0 +1,308 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import type { PageData } from "./$types";
4+ import FeedItem from "$lib/components/FeedItem.svelte";
5+ import Pagination from "$lib/components/Pagination.svelte";
6+ import EmptyState from "$lib/components/EmptyState.svelte";
7+ import FeedRecommendationCard from "$lib/components/FeedRecommendationCard.svelte";
8+ import Icon from "$lib/components/Icon.svelte";
9+ import { endpoints } from "$lib/api";
10+ import { invalidateAll } from "$app/navigation";
11+ import type { FeedRecommendation } from "$lib/types";
12+
13+ let { data }: { data: PageData } = $props();
14+
15+ let addURL = $state("");
16+ let addCategory = $state("");
17+ let addError = $state("");
18+ let adding = $state(false);
19+
20+ let refreshing = $state(false);
21+ let feedRecs = $state<FeedRecommendation[] | null>(null);
22+ let deadFeeds = $derived(data.dead_feeds);
23+
24+ onMount(() => {
25+ endpoints
26+ .feedRecs()
27+ .then((r) => (feedRecs = r.feeds))
28+ .catch(() => (feedRecs = []));
29+ });
30+
31+ async function addFeed() {
32+ adding = true;
33+ addError = "";
34+ try {
35+ await endpoints.addFeed(addURL, addCategory);
36+ addURL = "";
37+ addCategory = "";
38+ await invalidateAll();
39+ } catch (e) {
40+ addError = e instanceof Error ? e.message : "Failed";
41+ } finally {
42+ adding = false;
43+ }
44+ }
45+
46+ async function refresh() {
47+ refreshing = true;
48+ try {
49+ await endpoints.refreshFeeds(data.category);
50+ await new Promise((r) => setTimeout(r, 4000));
51+ await invalidateAll();
52+ } finally {
53+ refreshing = false;
54+ }
55+ }
56+
57+ async function retry(url: string) {
58+ await endpoints.retryFeed(url);
59+ await invalidateAll();
60+ }
61+
62+ async function removeDead(url: string) {
63+ await endpoints.removeFeed(url);
64+ await invalidateAll();
65+ }
66+
67+ async function clearAll() {
68+ if (
69+ !confirm(
70+ "Are you sure you want to unsubscribe from ALL feeds? This cannot be undone.",
71+ )
72+ )
73+ return;
74+ await endpoints.clearFeeds();
75+ await invalidateAll();
76+ }
77+
78+ async function uploadOpml(e: Event) {
79+ const input = e.target as HTMLInputElement;
80+ const file = input.files?.[0];
81+ if (!file) return;
82+ const form = new FormData();
83+ form.append("opml", file);
84+ await endpoints.uploadOpml(form);
85+ await invalidateAll();
86+ }
87+
88+ function dismissRec(url: string) {
89+ feedRecs = (feedRecs ?? []).filter((f) => f.feed_url !== url);
90+ }
91+</script>
92+
93+<div class="flex items-center justify-between gap-3 mb-2 flex-wrap">
94+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
95+ Feeds <span class="text-base font-normal text-[var(--muted)]"
96+ >({data.subscription_count})</span
97+ >
98+ </h1>
99+ <button onclick={refresh} disabled={refreshing} class="btn">
100+ <Icon name="refresh" class="h-4 w-4" />
101+ {refreshing ? "Refreshing..." : "Refresh"}
102+ </button>
103+</div>
104+<p class="text-xs uppercase tracking-widest text-[var(--muted)] mb-6">
105+ Manage your RSS, Atom, and AT Protocol subscriptions.
106+</p>
107+
108+{#if deadFeeds.length > 0}
109+ <section class="panel mb-6">
110+ <div
111+ class="border-b-2 border-[var(--border)] px-4 py-2"
112+ style="background:var(--danger);color:#fff"
113+ >
114+ <span class="text-xs font-extrabold uppercase tracking-widest">
115+ Feeds with errors ({deadFeeds.length})
116+ </span>
117+ </div>
118+ <div class="divide-y-2 divide-[var(--border)]">
119+ {#each deadFeeds as f (f.feed_url)}
120+ <div
121+ class="flex items-start justify-between gap-3 p-4 flex-wrap"
122+ >
123+ <div class="min-w-0 flex-1">
124+ <span class="block truncate font-bold"
125+ >{f.title || f.feed_url}</span
126+ >
127+ <div class="mt-1 flex items-center gap-2 flex-wrap">
128+ <span
129+ class="chip"
130+ style="background:var(--danger);color:#fff;border-color:var(--danger)"
131+ >
132+ {f.error_count} ERR
133+ </span>
134+ {#if f.last_error}
135+ <span
136+ class="text-xs text-[var(--muted)] truncate max-w-48"
137+ title={f.last_error}
138+ >
139+ {f.last_error}
140+ </span>
141+ {/if}
142+ </div>
143+ </div>
144+ <div class="flex items-center gap-2 shrink-0">
145+ <button onclick={() => retry(f.feed_url)} class="btn"
146+ >Retry</button
147+ >
148+ <button
149+ onclick={() => removeDead(f.feed_url)}
150+ class="btn"
151+ style="background:var(--danger);color:#fff"
152+ >Unsub</button
153+ >
154+ </div>
155+ </div>
156+ {/each}
157+ </div>
158+ </section>
159+{/if}
160+
161+{#if feedRecs !== null && feedRecs.length > 0}
162+ <section class="mb-6">
163+ <h2
164+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
165+ >
166+ {data.subscription_count === 0
167+ ? "Popular feeds to get started"
168+ : "Recommended feeds"}
169+ </h2>
170+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
171+ {#each feedRecs as f (f.feed_url)}
172+ <FeedRecommendationCard rec={f} onDismiss={dismissRec} />
173+ {/each}
174+ </div>
175+ </section>
176+{/if}
177+
178+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
179+ <div class="lg:col-span-2">
180+ {#if data.subscription_count > 0}
181+ <div class="mb-4 flex flex-wrap gap-2">
182+ <a
183+ href="/feeds"
184+ class="chip panel-press"
185+ data-active={!data.category ? "true" : "false"}>All</a
186+ >
187+ {#each data.categories as cat (cat)}
188+ <a
189+ href="/feeds?category={encodeURIComponent(cat)}"
190+ class="chip panel-press"
191+ data-active={data.category === cat ? "true" : "false"}
192+ >{cat}</a
193+ >
194+ {/each}
195+ <a
196+ href="/feeds?category=__none__"
197+ class="chip panel-press"
198+ data-active={data.category === "__none__"
199+ ? "true"
200+ : "false"}>Uncategorized</a
201+ >
202+ </div>
203+ {/if}
204+
205+ <div class="panel divide-y-2 divide-[var(--border)]">
206+ {#if data.subscriptions.length === 0}
207+ <EmptyState
208+ icon="feed"
209+ title="No feeds yet"
210+ subtitle="Add one using the form on the right."
211+ />
212+ {:else}
213+ {#each data.subscriptions as sub (sub.id)}
214+ <FeedItem {sub} />
215+ {/each}
216+ {/if}
217+ </div>
218+
219+ <Pagination
220+ page={data.pagination}
221+ base="/feeds"
222+ params={{ category: data.category }}
223+ />
224+ </div>
225+
226+ <aside class="space-y-6">
227+ <section class="panel p-4 space-y-4">
228+ <h2
229+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
230+ >
231+ Add feed
232+ </h2>
233+ <form
234+ onsubmit={(e) => {
235+ e.preventDefault();
236+ addFeed();
237+ }}
238+ class="space-y-3"
239+ >
240+ <input
241+ bind:value={addURL}
242+ type="text"
243+ placeholder="https://example.com/feed.xml"
244+ class="input-brutal"
245+ required
246+ />
247+ <input
248+ bind:value={addCategory}
249+ type="text"
250+ placeholder="Category (optional)"
251+ class="input-brutal"
252+ />
253+ <button
254+ type="submit"
255+ disabled={adding}
256+ class="btn btn-accent w-full"
257+ >
258+ <Icon name="plus" class="h-4 w-4" />
259+ Add
260+ </button>
261+ </form>
262+ {#if addError}
263+ <p
264+ class="text-xs font-bold uppercase tracking-wide text-[var(--danger)]"
265+ >
266+ {addError}
267+ </p>
268+ {/if}
269+
270+ <div class="border-t-2 border-[var(--border)] pt-4 space-y-3">
271+ <h2
272+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
273+ >
274+ Import / Export
275+ </h2>
276+ <label class="block cursor-pointer">
277+ <input
278+ type="file"
279+ accept=".opml,.xml"
280+ onchange={uploadOpml}
281+ class="hidden"
282+ />
283+ <span class="btn w-full">
284+ <Icon name="upload" class="h-4 w-4" />
285+ Import OPML
286+ </span>
287+ </label>
288+ {#if data.subscriptions.length > 0}
289+ <a href="/api/feeds/opml/download" class="btn w-full">
290+ <Icon name="download" class="h-4 w-4" />
291+ Export OPML
292+ </a>
293+ {/if}
294+ </div>
295+ </section>
296+
297+ {#if data.subscriptions.length > 0}
298+ <button
299+ onclick={clearAll}
300+ class="btn w-full"
301+ style="background:var(--danger);color:#fff"
302+ >
303+ <Icon name="trash" class="h-4 w-4" />
304+ Clear all subscriptions
305+ </button>
306+ {/if}
307+ </aside>
308+</div>
new file mode 100644
@@ -0,0 +1,308 @@
1+<script lang="ts">
2+ import { onMount } from "svelte";
3+ import type { PageData } from "./$types";
4+ import FeedItem from "$lib/components/FeedItem.svelte";
5+ import Pagination from "$lib/components/Pagination.svelte";
6+ import EmptyState from "$lib/components/EmptyState.svelte";
7+ import FeedRecommendationCard from "$lib/components/FeedRecommendationCard.svelte";
8+ import Icon from "$lib/components/Icon.svelte";
9+ import { endpoints } from "$lib/api";
10+ import { invalidateAll } from "$app/navigation";
11+ import type { FeedRecommendation } from "$lib/types";
12+
13+ let { data }: { data: PageData } = $props();
14+
15+ let addURL = $state("");
16+ let addCategory = $state("");
17+ let addError = $state("");
18+ let adding = $state(false);
19+
20+ let refreshing = $state(false);
21+ let feedRecs = $state<FeedRecommendation[] | null>(null);
22+ let deadFeeds = $derived(data.dead_feeds);
23+
24+ onMount(() => {
25+ endpoints
26+ .feedRecs()
27+ .then((r) => (feedRecs = r.feeds))
28+ .catch(() => (feedRecs = []));
29+ });
30+
31+ async function addFeed() {
32+ adding = true;
33+ addError = "";
34+ try {
35+ await endpoints.addFeed(addURL, addCategory);
36+ addURL = "";
37+ addCategory = "";
38+ await invalidateAll();
39+ } catch (e) {
40+ addError = e instanceof Error ? e.message : "Failed";
41+ } finally {
42+ adding = false;
43+ }
44+ }
45+
46+ async function refresh() {
47+ refreshing = true;
48+ try {
49+ await endpoints.refreshFeeds(data.category);
50+ await new Promise((r) => setTimeout(r, 4000));
51+ await invalidateAll();
52+ } finally {
53+ refreshing = false;
54+ }
55+ }
56+
57+ async function retry(url: string) {
58+ await endpoints.retryFeed(url);
59+ await invalidateAll();
60+ }
61+
62+ async function removeDead(url: string) {
63+ await endpoints.removeFeed(url);
64+ await invalidateAll();
65+ }
66+
67+ async function clearAll() {
68+ if (
69+ !confirm(
70+ "Are you sure you want to unsubscribe from ALL feeds? This cannot be undone.",
71+ )
72+ )
73+ return;
74+ await endpoints.clearFeeds();
75+ await invalidateAll();
76+ }
77+
78+ async function uploadOpml(e: Event) {
79+ const input = e.target as HTMLInputElement;
80+ const file = input.files?.[0];
81+ if (!file) return;
82+ const form = new FormData();
83+ form.append("opml", file);
84+ await endpoints.uploadOpml(form);
85+ await invalidateAll();
86+ }
87+
88+ function dismissRec(url: string) {
89+ feedRecs = (feedRecs ?? []).filter((f) => f.feed_url !== url);
90+ }
91+</script>
92+
93+<div class="flex items-center justify-between gap-3 mb-2 flex-wrap">
94+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
95+ Feeds <span class="text-base font-normal text-[var(--muted)]"
96+ >({data.subscription_count})</span
97+ >
98+ </h1>
99+ <button onclick={refresh} disabled={refreshing} class="btn">
100+ <Icon name="refresh" class="h-4 w-4" />
101+ {refreshing ? "Refreshing..." : "Refresh"}
102+ </button>
103+</div>
104+<p class="text-xs uppercase tracking-widest text-[var(--muted)] mb-6">
105+ Manage your RSS, Atom, and AT Protocol subscriptions.
106+</p>
107+
108+{#if deadFeeds.length > 0}
109+ <section class="panel mb-6">
110+ <div
111+ class="border-b-2 border-[var(--border)] px-4 py-2"
112+ style="background:var(--danger);color:#fff"
113+ >
114+ <span class="text-xs font-extrabold uppercase tracking-widest">
115+ Feeds with errors ({deadFeeds.length})
116+ </span>
117+ </div>
118+ <div class="divide-y-2 divide-[var(--border)]">
119+ {#each deadFeeds as f (f.feed_url)}
120+ <div
121+ class="flex items-start justify-between gap-3 p-4 flex-wrap"
122+ >
123+ <div class="min-w-0 flex-1">
124+ <span class="block truncate font-bold"
125+ >{f.title || f.feed_url}</span
126+ >
127+ <div class="mt-1 flex items-center gap-2 flex-wrap">
128+ <span
129+ class="chip"
130+ style="background:var(--danger);color:#fff;border-color:var(--danger)"
131+ >
132+ {f.error_count} ERR
133+ </span>
134+ {#if f.last_error}
135+ <span
136+ class="text-xs text-[var(--muted)] truncate max-w-48"
137+ title={f.last_error}
138+ >
139+ {f.last_error}
140+ </span>
141+ {/if}
142+ </div>
143+ </div>
144+ <div class="flex items-center gap-2 shrink-0">
145+ <button onclick={() => retry(f.feed_url)} class="btn"
146+ >Retry</button
147+ >
148+ <button
149+ onclick={() => removeDead(f.feed_url)}
150+ class="btn"
151+ style="background:var(--danger);color:#fff"
152+ >Unsub</button
153+ >
154+ </div>
155+ </div>
156+ {/each}
157+ </div>
158+ </section>
159+{/if}
160+
161+{#if feedRecs !== null && feedRecs.length > 0}
162+ <section class="mb-6">
163+ <h2
164+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
165+ >
166+ {data.subscription_count === 0
167+ ? "Popular feeds to get started"
168+ : "Recommended feeds"}
169+ </h2>
170+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
171+ {#each feedRecs as f (f.feed_url)}
172+ <FeedRecommendationCard rec={f} onDismiss={dismissRec} />
173+ {/each}
174+ </div>
175+ </section>
176+{/if}
177+
178+<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
179+ <div class="lg:col-span-2">
180+ {#if data.subscription_count > 0}
181+ <div class="mb-4 flex flex-wrap gap-2">
182+ <a
183+ href="/feeds"
184+ class="chip panel-press"
185+ data-active={!data.category ? "true" : "false"}>All</a
186+ >
187+ {#each data.categories as cat (cat)}
188+ <a
189+ href="/feeds?category={encodeURIComponent(cat)}"
190+ class="chip panel-press"
191+ data-active={data.category === cat ? "true" : "false"}
192+ >{cat}</a
193+ >
194+ {/each}
195+ <a
196+ href="/feeds?category=__none__"
197+ class="chip panel-press"
198+ data-active={data.category === "__none__"
199+ ? "true"
200+ : "false"}>Uncategorized</a
201+ >
202+ </div>
203+ {/if}
204+
205+ <div class="panel divide-y-2 divide-[var(--border)]">
206+ {#if data.subscriptions.length === 0}
207+ <EmptyState
208+ icon="feed"
209+ title="No feeds yet"
210+ subtitle="Add one using the form on the right."
211+ />
212+ {:else}
213+ {#each data.subscriptions as sub (sub.id)}
214+ <FeedItem {sub} />
215+ {/each}
216+ {/if}
217+ </div>
218+
219+ <Pagination
220+ page={data.pagination}
221+ base="/feeds"
222+ params={{ category: data.category }}
223+ />
224+ </div>
225+
226+ <aside class="space-y-6">
227+ <section class="panel p-4 space-y-4">
228+ <h2
229+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
230+ >
231+ Add feed
232+ </h2>
233+ <form
234+ onsubmit={(e) => {
235+ e.preventDefault();
236+ addFeed();
237+ }}
238+ class="space-y-3"
239+ >
240+ <input
241+ bind:value={addURL}
242+ type="text"
243+ placeholder="https://example.com/feed.xml"
244+ class="input-brutal"
245+ required
246+ />
247+ <input
248+ bind:value={addCategory}
249+ type="text"
250+ placeholder="Category (optional)"
251+ class="input-brutal"
252+ />
253+ <button
254+ type="submit"
255+ disabled={adding}
256+ class="btn btn-accent w-full"
257+ >
258+ <Icon name="plus" class="h-4 w-4" />
259+ Add
260+ </button>
261+ </form>
262+ {#if addError}
263+ <p
264+ class="text-xs font-bold uppercase tracking-wide text-[var(--danger)]"
265+ >
266+ {addError}
267+ </p>
268+ {/if}
269+
270+ <div class="border-t-2 border-[var(--border)] pt-4 space-y-3">
271+ <h2
272+ class="text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
273+ >
274+ Import / Export
275+ </h2>
276+ <label class="block cursor-pointer">
277+ <input
278+ type="file"
279+ accept=".opml,.xml"
280+ onchange={uploadOpml}
281+ class="hidden"
282+ />
283+ <span class="btn w-full">
284+ <Icon name="upload" class="h-4 w-4" />
285+ Import OPML
286+ </span>
287+ </label>
288+ {#if data.subscriptions.length > 0}
289+ <a href="/api/feeds/opml/download" class="btn w-full">
290+ <Icon name="download" class="h-4 w-4" />
291+ Export OPML
292+ </a>
293+ {/if}
294+ </div>
295+ </section>
296+
297+ {#if data.subscriptions.length > 0}
298+ <button
299+ onclick={clearAll}
300+ class="btn w-full"
301+ style="background:var(--danger);color:#fff"
302+ >
303+ <Icon name="trash" class="h-4 w-4" />
304+ Clear all subscriptions
305+ </button>
306+ {/if}
307+ </aside>
308+</div>
added web/src/routes/library/+page.server.ts +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ const params: Record<string, string> = {};
9+ const lp = event.url.searchParams.get("liked_page");
10+ const ap = event.url.searchParams.get("annot_page");
11+ if (lp) params.liked_page = lp;
12+ if (ap) params.annot_page = ap;
13+ return await endpointsFor(event.fetch).library(params);
14+};
new file mode 100644
@@ -0,0 +1,14 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ const params: Record<string, string> = {};
9+ const lp = event.url.searchParams.get("liked_page");
10+ const ap = event.url.searchParams.get("annot_page");
11+ if (lp) params.liked_page = lp;
12+ if (ap) params.annot_page = ap;
13+ return await endpointsFor(event.fetch).library(params);
14+};
added web/src/routes/library/+page.svelte +128 -0
new file mode 100644
@@ -0,0 +1,128 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import ArticleCard from "$lib/components/ArticleCard.svelte";
4+ import AnnotationCard from "$lib/components/AnnotationCard.svelte";
5+ import EmptyState from "$lib/components/EmptyState.svelte";
6+
7+ let { data }: { data: PageData } = $props();
8+
9+ function likedHref(page: number): string {
10+ const p = new URLSearchParams();
11+ if (page > 1) p.set("liked_page", String(page));
12+ if (data.annot_page.page > 1)
13+ p.set("annot_page", String(data.annot_page.page));
14+ return "/library" + (p.toString() ? "?" + p.toString() : "");
15+ }
16+ function annotHref(page: number): string {
17+ const p = new URLSearchParams();
18+ if (page > 1) p.set("annot_page", String(page));
19+ if (data.liked_page.page > 1)
20+ p.set("liked_page", String(data.liked_page.page));
21+ return "/library" + (p.toString() ? "?" + p.toString() : "");
22+ }
23+</script>
24+
25+<h1 class="text-2xl font-extrabold uppercase tracking-tight mb-2">Library</h1>
26+<p class="mb-6 text-xs uppercase tracking-widest text-[var(--muted)]">
27+ Your liked articles and annotations.
28+</p>
29+
30+<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-12">
31+ <section>
32+ <h2
33+ class="mb-4 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
34+ >
35+ Liked articles
36+ </h2>
37+ <div class="space-y-3">
38+ {#if data.articles.length === 0}
39+ <EmptyState
40+ icon="heart"
41+ title="No liked articles yet"
42+ subtitle="Like articles to save them here."
43+ />
44+ {:else}
45+ {#each data.articles as a (a.id)}
46+ <ArticleCard article={a} navSuffix="?liked=1" />
47+ {/each}
48+ {/if}
49+ </div>
50+
51+ {#if data.liked_page.has_prev || data.liked_page.has_next}
52+ <nav
53+ class="flex items-center justify-center gap-2 py-8 text-xs font-bold uppercase"
54+ >
55+ {#if data.liked_page.has_prev}
56+ <a href={likedHref(data.liked_page.prev_page)} class="btn"
57+ >&larr; Prev</a
58+ >
59+ {:else}
60+ <span class="btn opacity-30 cursor-not-allowed"
61+ >&larr; Prev</span
62+ >
63+ {/if}
64+ <span class="border-2 border-[var(--border)] px-3 py-2">
65+ P.{data.liked_page.page}
66+ </span>
67+ {#if data.liked_page.has_next}
68+ <a href={likedHref(data.liked_page.next_page)} class="btn"
69+ >Next &rarr;</a
70+ >
71+ {:else}
72+ <span class="btn opacity-30 cursor-not-allowed"
73+ >Next &rarr;</span
74+ >
75+ {/if}
76+ </nav>
77+ {/if}
78+ </section>
79+
80+ <section>
81+ <h2
82+ class="mb-4 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
83+ >
84+ Annotations
85+ </h2>
86+ <div class="space-y-3">
87+ {#if data.annotations.length === 0}
88+ <EmptyState
89+ icon="note"
90+ title="No annotations yet"
91+ subtitle="Highlight and annotate articles as you read."
92+ />
93+ {:else}
94+ {#each data.annotations as a (a.id)}
95+ <AnnotationCard annotation={a} userDID={data.user.did} />
96+ {/each}
97+ {/if}
98+ </div>
99+
100+ {#if data.annot_page.has_prev || data.annot_page.has_next}
101+ <nav
102+ class="flex items-center justify-center gap-2 py-8 text-xs font-bold uppercase"
103+ >
104+ {#if data.annot_page.has_prev}
105+ <a href={annotHref(data.annot_page.prev_page)} class="btn"
106+ >&larr; Prev</a
107+ >
108+ {:else}
109+ <span class="btn opacity-30 cursor-not-allowed"
110+ >&larr; Prev</span
111+ >
112+ {/if}
113+ <span class="border-2 border-[var(--border)] px-3 py-2">
114+ P.{data.annot_page.page}
115+ </span>
116+ {#if data.annot_page.has_next}
117+ <a href={annotHref(data.annot_page.next_page)} class="btn"
118+ >Next &rarr;</a
119+ >
120+ {:else}
121+ <span class="btn opacity-30 cursor-not-allowed"
122+ >Next &rarr;</span
123+ >
124+ {/if}
125+ </nav>
126+ {/if}
127+ </section>
128+</div>
new file mode 100644
@@ -0,0 +1,128 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import ArticleCard from "$lib/components/ArticleCard.svelte";
4+ import AnnotationCard from "$lib/components/AnnotationCard.svelte";
5+ import EmptyState from "$lib/components/EmptyState.svelte";
6+
7+ let { data }: { data: PageData } = $props();
8+
9+ function likedHref(page: number): string {
10+ const p = new URLSearchParams();
11+ if (page > 1) p.set("liked_page", String(page));
12+ if (data.annot_page.page > 1)
13+ p.set("annot_page", String(data.annot_page.page));
14+ return "/library" + (p.toString() ? "?" + p.toString() : "");
15+ }
16+ function annotHref(page: number): string {
17+ const p = new URLSearchParams();
18+ if (page > 1) p.set("annot_page", String(page));
19+ if (data.liked_page.page > 1)
20+ p.set("liked_page", String(data.liked_page.page));
21+ return "/library" + (p.toString() ? "?" + p.toString() : "");
22+ }
23+</script>
24+
25+<h1 class="text-2xl font-extrabold uppercase tracking-tight mb-2">Library</h1>
26+<p class="mb-6 text-xs uppercase tracking-widest text-[var(--muted)]">
27+ Your liked articles and annotations.
28+</p>
29+
30+<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-12">
31+ <section>
32+ <h2
33+ class="mb-4 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
34+ >
35+ Liked articles
36+ </h2>
37+ <div class="space-y-3">
38+ {#if data.articles.length === 0}
39+ <EmptyState
40+ icon="heart"
41+ title="No liked articles yet"
42+ subtitle="Like articles to save them here."
43+ />
44+ {:else}
45+ {#each data.articles as a (a.id)}
46+ <ArticleCard article={a} navSuffix="?liked=1" />
47+ {/each}
48+ {/if}
49+ </div>
50+
51+ {#if data.liked_page.has_prev || data.liked_page.has_next}
52+ <nav
53+ class="flex items-center justify-center gap-2 py-8 text-xs font-bold uppercase"
54+ >
55+ {#if data.liked_page.has_prev}
56+ <a href={likedHref(data.liked_page.prev_page)} class="btn"
57+ >&larr; Prev</a
58+ >
59+ {:else}
60+ <span class="btn opacity-30 cursor-not-allowed"
61+ >&larr; Prev</span
62+ >
63+ {/if}
64+ <span class="border-2 border-[var(--border)] px-3 py-2">
65+ P.{data.liked_page.page}
66+ </span>
67+ {#if data.liked_page.has_next}
68+ <a href={likedHref(data.liked_page.next_page)} class="btn"
69+ >Next &rarr;</a
70+ >
71+ {:else}
72+ <span class="btn opacity-30 cursor-not-allowed"
73+ >Next &rarr;</span
74+ >
75+ {/if}
76+ </nav>
77+ {/if}
78+ </section>
79+
80+ <section>
81+ <h2
82+ class="mb-4 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
83+ >
84+ Annotations
85+ </h2>
86+ <div class="space-y-3">
87+ {#if data.annotations.length === 0}
88+ <EmptyState
89+ icon="note"
90+ title="No annotations yet"
91+ subtitle="Highlight and annotate articles as you read."
92+ />
93+ {:else}
94+ {#each data.annotations as a (a.id)}
95+ <AnnotationCard annotation={a} userDID={data.user.did} />
96+ {/each}
97+ {/if}
98+ </div>
99+
100+ {#if data.annot_page.has_prev || data.annot_page.has_next}
101+ <nav
102+ class="flex items-center justify-center gap-2 py-8 text-xs font-bold uppercase"
103+ >
104+ {#if data.annot_page.has_prev}
105+ <a href={annotHref(data.annot_page.prev_page)} class="btn"
106+ >&larr; Prev</a
107+ >
108+ {:else}
109+ <span class="btn opacity-30 cursor-not-allowed"
110+ >&larr; Prev</span
111+ >
112+ {/if}
113+ <span class="border-2 border-[var(--border)] px-3 py-2">
114+ P.{data.annot_page.page}
115+ </span>
116+ {#if data.annot_page.has_next}
117+ <a href={annotHref(data.annot_page.next_page)} class="btn"
118+ >Next &rarr;</a
119+ >
120+ {:else}
121+ <span class="btn opacity-30 cursor-not-allowed"
122+ >Next &rarr;</span
123+ >
124+ {/if}
125+ </nav>
126+ {/if}
127+ </section>
128+</div>
added web/src/routes/profile/[did]/+page.server.ts +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect, error } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ try {
9+ return await endpointsFor(event.fetch).profile(event.params.did);
10+ } catch (e: any) {
11+ if (e?.status === 404) throw error(404, e.message);
12+ throw e;
13+ }
14+};
new file mode 100644
@@ -0,0 +1,14 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+import { redirect, error } from "@sveltejs/kit";
4+
5+export const load: PageServerLoad = async (event) => {
6+ const { user } = await event.parent();
7+ if (!user) throw redirect(303, "/auth/login");
8+ try {
9+ return await endpointsFor(event.fetch).profile(event.params.did);
10+ } catch (e: any) {
11+ if (e?.status === 404) throw error(404, e.message);
12+ throw e;
13+ }
14+};
added web/src/routes/profile/[did]/+page.svelte +232 -0
new file mode 100644
@@ -0,0 +1,232 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import Favicon from "$lib/components/Favicon.svelte";
4+ import AnnotationCard from "$lib/components/AnnotationCard.svelte";
5+ import EmptyState from "$lib/components/EmptyState.svelte";
6+ import Icon from "$lib/components/Icon.svelte";
7+ import BlueskyLogo from "$lib/components/BlueskyLogo.svelte";
8+ import { endpoints } from "$lib/api";
9+ import { invalidateAll } from "$app/navigation";
10+
11+ let { data }: { data: PageData } = $props();
12+
13+ const isMe = $derived(data.profile_user.did === data.user.did);
14+
15+ let digestSaving = $state(false);
16+ let expandedSaving = $state(false);
17+
18+ async function toggleDigest() {
19+ digestSaving = true;
20+ try {
21+ await endpoints.toggleDigest(!data.digest_enabled);
22+ await invalidateAll();
23+ } finally {
24+ digestSaving = false;
25+ }
26+ }
27+ async function toggleExpanded() {
28+ expandedSaving = true;
29+ try {
30+ await endpoints.toggleExpandedView(!data.expanded_view);
31+ await invalidateAll();
32+ } finally {
33+ expandedSaving = false;
34+ }
35+ }
36+ async function toggleLang(code: string) {
37+ await endpoints.toggleLanguage(code);
38+ await invalidateAll();
39+ }
40+</script>
41+
42+<div class="mx-auto max-w-2xl">
43+ <section class="panel mb-6 p-4 sm:p-6">
44+ <div
45+ class="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-5 text-center sm:text-left"
46+ >
47+ {#if data.profile_user.avatar_url}
48+ <img
49+ src={data.profile_user.avatar_url}
50+ class="h-20 w-20 border-2 border-[var(--border)] object-cover"
51+ alt=""
52+ />
53+ {:else}
54+ <div
55+ class="flex h-20 w-20 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] text-[var(--muted)]"
56+ >
57+ <Icon name="users" class="h-10 w-10" />
58+ </div>
59+ {/if}
60+ <div class="min-w-0 flex-1">
61+ <h1
62+ class="truncate text-2xl font-extrabold uppercase tracking-tight"
63+ >
64+ {data.profile_user.display_name}
65+ </h1>
66+ <p
67+ class="mt-1 flex items-center justify-center sm:justify-start gap-1.5 text-[var(--muted)]"
68+ >
69+ <span class="font-bold">@{data.profile_user.handle}</span>
70+ <a
71+ href="https://bsky.app/profile/{data.profile_user
72+ .handle}"
73+ target="_blank"
74+ rel="noopener"
75+ class="text-[var(--muted)] hover:text-[var(--accent)]"
76+ >
77+ <BlueskyLogo class="h-4 w-4" />
78+ </a>
79+ </p>
80+ <div
81+ class="mt-3 flex justify-center sm:justify-start divide-x-2 divide-[var(--border)]"
82+ >
83+ <a href="/feeds" class="px-4 first:pl-0">
84+ <span class="text-xl font-extrabold"
85+ >{data.subscription_count}</span
86+ >
87+ <span
88+ class="ml-1 text-xs uppercase tracking-wide text-[var(--muted)]"
89+ >feeds</span
90+ >
91+ </a>
92+ <a href="/library" class="px-4">
93+ <span class="text-xl font-extrabold"
94+ >{data.annotation_count}</span
95+ >
96+ <span
97+ class="ml-1 text-xs uppercase tracking-wide text-[var(--muted)]"
98+ >annotations</span
99+ >
100+ </a>
101+ </div>
102+ </div>
103+ </div>
104+ </section>
105+
106+ {#if isMe && data.hasLLM}
107+ <h2
108+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
109+ >
110+ Settings
111+ </h2>
112+ <section class="panel mb-6 divide-y-2 divide-[var(--border)]">
113+ <div class="flex items-center justify-between gap-4 p-5">
114+ <div>
115+ <p class="text-sm font-bold">Daily digest</p>
116+ <p class="mt-0.5 text-xs text-[var(--muted)]">
117+ Show an AI-generated summary of your 50 most recent
118+ unread articles on the dashboard.
119+ </p>
120+ </div>
121+ <button
122+ type="button"
123+ onclick={toggleDigest}
124+ disabled={digestSaving}
125+ class="btn {data.digest_enabled ? 'btn-accent' : ''}"
126+ aria-label="Toggle daily digest"
127+ >
128+ {data.digest_enabled ? "On" : "Off"}
129+ </button>
130+ </div>
131+ <div class="flex items-center justify-between gap-4 p-5">
132+ <div>
133+ <p class="text-sm font-bold">Expanded article view</p>
134+ <p class="mt-0.5 text-xs text-[var(--muted)]">
135+ Show full article content inline. Articles are marked as
136+ read as you scroll.
137+ </p>
138+ </div>
139+ <button
140+ type="button"
141+ onclick={toggleExpanded}
142+ disabled={expandedSaving}
143+ class="btn {data.expanded_view ? 'btn-accent' : ''}"
144+ aria-label="Toggle expanded view"
145+ >
146+ {data.expanded_view ? "On" : "Off"}
147+ </button>
148+ </div>
149+ <div class="p-5">
150+ <p class="text-sm font-bold">Recommendation languages</p>
151+ <p class="mt-0.5 mb-4 text-xs text-[var(--muted)]">
152+ Filter article recommendations to specific languages. Leave
153+ empty to show all.
154+ </p>
155+ <div class="flex flex-wrap gap-2">
156+ {#each data.available_languages as lang}
157+ <button
158+ type="button"
159+ onclick={() => toggleLang(lang.code)}
160+ class="chip panel-press cursor-pointer"
161+ data-active={data.user_languages.includes(lang.code)
162+ ? "true"
163+ : "false"}>{lang.name}</button
164+ >
165+ {/each}
166+ </div>
167+ </div>
168+ </section>
169+ {/if}
170+
171+ <h2
172+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
173+ >
174+ Feeds
175+ </h2>
176+ <div class="panel mb-6 divide-y-2 divide-[var(--border)]">
177+ {#if data.subscriptions.length === 0}
178+ <EmptyState
179+ icon="feed"
180+ title="No feeds yet"
181+ subtitle="Subscribed feeds will appear here."
182+ />
183+ {:else}
184+ {#each data.subscriptions as sub (sub.id)}
185+ <a
186+ href="/articles?feed={encodeURIComponent(sub.feed_url)}"
187+ class="flex items-center gap-3 p-4 transition-colors hover:bg-[var(--bg)]"
188+ >
189+ <Favicon src={sub.favicon_url} size="h-5 w-5" />
190+ <div class="min-w-0 flex-1">
191+ <div class="flex items-center gap-2 flex-wrap">
192+ <span class="truncate font-bold"
193+ >{sub.feed_title || sub.feed_url}</span
194+ >
195+ {#if sub.category}
196+ <span class="tag shrink-0">{sub.category}</span>
197+ {/if}
198+ </div>
199+ <div
200+ class="mt-0.5 truncate text-xs text-[var(--muted)]"
201+ >
202+ {sub.feed_url}
203+ </div>
204+ </div>
205+ <Icon
206+ name="chevronRight"
207+ class="h-4 w-4 shrink-0 text-[var(--muted)]"
208+ />
209+ </a>
210+ {/each}
211+ {/if}
212+ </div>
213+
214+ <h2
215+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
216+ >
217+ Recent annotations
218+ </h2>
219+ <div class="space-y-3">
220+ {#if data.annotations.length === 0}
221+ <EmptyState
222+ icon="note"
223+ title="No annotations yet"
224+ subtitle="Highlight and annotate articles as you read."
225+ />
226+ {:else}
227+ {#each data.annotations as a (a.id)}
228+ <AnnotationCard annotation={a} userDID={data.user.did} />
229+ {/each}
230+ {/if}
231+ </div>
232+</div>
new file mode 100644
@@ -0,0 +1,232 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import Favicon from "$lib/components/Favicon.svelte";
4+ import AnnotationCard from "$lib/components/AnnotationCard.svelte";
5+ import EmptyState from "$lib/components/EmptyState.svelte";
6+ import Icon from "$lib/components/Icon.svelte";
7+ import BlueskyLogo from "$lib/components/BlueskyLogo.svelte";
8+ import { endpoints } from "$lib/api";
9+ import { invalidateAll } from "$app/navigation";
10+
11+ let { data }: { data: PageData } = $props();
12+
13+ const isMe = $derived(data.profile_user.did === data.user.did);
14+
15+ let digestSaving = $state(false);
16+ let expandedSaving = $state(false);
17+
18+ async function toggleDigest() {
19+ digestSaving = true;
20+ try {
21+ await endpoints.toggleDigest(!data.digest_enabled);
22+ await invalidateAll();
23+ } finally {
24+ digestSaving = false;
25+ }
26+ }
27+ async function toggleExpanded() {
28+ expandedSaving = true;
29+ try {
30+ await endpoints.toggleExpandedView(!data.expanded_view);
31+ await invalidateAll();
32+ } finally {
33+ expandedSaving = false;
34+ }
35+ }
36+ async function toggleLang(code: string) {
37+ await endpoints.toggleLanguage(code);
38+ await invalidateAll();
39+ }
40+</script>
41+
42+<div class="mx-auto max-w-2xl">
43+ <section class="panel mb-6 p-4 sm:p-6">
44+ <div
45+ class="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-5 text-center sm:text-left"
46+ >
47+ {#if data.profile_user.avatar_url}
48+ <img
49+ src={data.profile_user.avatar_url}
50+ class="h-20 w-20 border-2 border-[var(--border)] object-cover"
51+ alt=""
52+ />
53+ {:else}
54+ <div
55+ class="flex h-20 w-20 items-center justify-center border-2 border-[var(--border)] bg-[var(--surface)] text-[var(--muted)]"
56+ >
57+ <Icon name="users" class="h-10 w-10" />
58+ </div>
59+ {/if}
60+ <div class="min-w-0 flex-1">
61+ <h1
62+ class="truncate text-2xl font-extrabold uppercase tracking-tight"
63+ >
64+ {data.profile_user.display_name}
65+ </h1>
66+ <p
67+ class="mt-1 flex items-center justify-center sm:justify-start gap-1.5 text-[var(--muted)]"
68+ >
69+ <span class="font-bold">@{data.profile_user.handle}</span>
70+ <a
71+ href="https://bsky.app/profile/{data.profile_user
72+ .handle}"
73+ target="_blank"
74+ rel="noopener"
75+ class="text-[var(--muted)] hover:text-[var(--accent)]"
76+ >
77+ <BlueskyLogo class="h-4 w-4" />
78+ </a>
79+ </p>
80+ <div
81+ class="mt-3 flex justify-center sm:justify-start divide-x-2 divide-[var(--border)]"
82+ >
83+ <a href="/feeds" class="px-4 first:pl-0">
84+ <span class="text-xl font-extrabold"
85+ >{data.subscription_count}</span
86+ >
87+ <span
88+ class="ml-1 text-xs uppercase tracking-wide text-[var(--muted)]"
89+ >feeds</span
90+ >
91+ </a>
92+ <a href="/library" class="px-4">
93+ <span class="text-xl font-extrabold"
94+ >{data.annotation_count}</span
95+ >
96+ <span
97+ class="ml-1 text-xs uppercase tracking-wide text-[var(--muted)]"
98+ >annotations</span
99+ >
100+ </a>
101+ </div>
102+ </div>
103+ </div>
104+ </section>
105+
106+ {#if isMe && data.hasLLM}
107+ <h2
108+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
109+ >
110+ Settings
111+ </h2>
112+ <section class="panel mb-6 divide-y-2 divide-[var(--border)]">
113+ <div class="flex items-center justify-between gap-4 p-5">
114+ <div>
115+ <p class="text-sm font-bold">Daily digest</p>
116+ <p class="mt-0.5 text-xs text-[var(--muted)]">
117+ Show an AI-generated summary of your 50 most recent
118+ unread articles on the dashboard.
119+ </p>
120+ </div>
121+ <button
122+ type="button"
123+ onclick={toggleDigest}
124+ disabled={digestSaving}
125+ class="btn {data.digest_enabled ? 'btn-accent' : ''}"
126+ aria-label="Toggle daily digest"
127+ >
128+ {data.digest_enabled ? "On" : "Off"}
129+ </button>
130+ </div>
131+ <div class="flex items-center justify-between gap-4 p-5">
132+ <div>
133+ <p class="text-sm font-bold">Expanded article view</p>
134+ <p class="mt-0.5 text-xs text-[var(--muted)]">
135+ Show full article content inline. Articles are marked as
136+ read as you scroll.
137+ </p>
138+ </div>
139+ <button
140+ type="button"
141+ onclick={toggleExpanded}
142+ disabled={expandedSaving}
143+ class="btn {data.expanded_view ? 'btn-accent' : ''}"
144+ aria-label="Toggle expanded view"
145+ >
146+ {data.expanded_view ? "On" : "Off"}
147+ </button>
148+ </div>
149+ <div class="p-5">
150+ <p class="text-sm font-bold">Recommendation languages</p>
151+ <p class="mt-0.5 mb-4 text-xs text-[var(--muted)]">
152+ Filter article recommendations to specific languages. Leave
153+ empty to show all.
154+ </p>
155+ <div class="flex flex-wrap gap-2">
156+ {#each data.available_languages as lang}
157+ <button
158+ type="button"
159+ onclick={() => toggleLang(lang.code)}
160+ class="chip panel-press cursor-pointer"
161+ data-active={data.user_languages.includes(lang.code)
162+ ? "true"
163+ : "false"}>{lang.name}</button
164+ >
165+ {/each}
166+ </div>
167+ </div>
168+ </section>
169+ {/if}
170+
171+ <h2
172+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
173+ >
174+ Feeds
175+ </h2>
176+ <div class="panel mb-6 divide-y-2 divide-[var(--border)]">
177+ {#if data.subscriptions.length === 0}
178+ <EmptyState
179+ icon="feed"
180+ title="No feeds yet"
181+ subtitle="Subscribed feeds will appear here."
182+ />
183+ {:else}
184+ {#each data.subscriptions as sub (sub.id)}
185+ <a
186+ href="/articles?feed={encodeURIComponent(sub.feed_url)}"
187+ class="flex items-center gap-3 p-4 transition-colors hover:bg-[var(--bg)]"
188+ >
189+ <Favicon src={sub.favicon_url} size="h-5 w-5" />
190+ <div class="min-w-0 flex-1">
191+ <div class="flex items-center gap-2 flex-wrap">
192+ <span class="truncate font-bold"
193+ >{sub.feed_title || sub.feed_url}</span
194+ >
195+ {#if sub.category}
196+ <span class="tag shrink-0">{sub.category}</span>
197+ {/if}
198+ </div>
199+ <div
200+ class="mt-0.5 truncate text-xs text-[var(--muted)]"
201+ >
202+ {sub.feed_url}
203+ </div>
204+ </div>
205+ <Icon
206+ name="chevronRight"
207+ class="h-4 w-4 shrink-0 text-[var(--muted)]"
208+ />
209+ </a>
210+ {/each}
211+ {/if}
212+ </div>
213+
214+ <h2
215+ class="mb-3 text-xs font-extrabold uppercase tracking-widest text-[var(--muted)]"
216+ >
217+ Recent annotations
218+ </h2>
219+ <div class="space-y-3">
220+ {#if data.annotations.length === 0}
221+ <EmptyState
222+ icon="note"
223+ title="No annotations yet"
224+ subtitle="Highlight and annotate articles as you read."
225+ />
226+ {:else}
227+ {#each data.annotations as a (a.id)}
228+ <AnnotationCard annotation={a} userDID={data.user.did} />
229+ {/each}
230+ {/if}
231+ </div>
232+</div>
added web/src/routes/sitemap.xml/+server.ts +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+import type { RequestHandler } from './$types';
2+
3+export const GET: RequestHandler = async ({ fetch }) => {
4+ const res = await fetch('/api/sitemap');
5+ const text = await res.text();
6+ return new Response(text, {
7+ headers: { 'content-type': 'application/xml' }
8+ });
9+};
new file mode 100644
@@ -0,0 +1,9 @@
1+import type { RequestHandler } from './$types';
2+
3+export const GET: RequestHandler = async ({ fetch }) => {
4+ const res = await fetch('/api/sitemap');
5+ const text = await res.text();
6+ return new Response(text, {
7+ headers: { 'content-type': 'application/xml' }
8+ });
9+};
added web/src/routes/stats/+page.server.ts +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+
4+export const load: PageServerLoad = async (event) => {
5+ return await endpointsFor(event.fetch).stats();
6+};
new file mode 100644
@@ -0,0 +1,6 @@
1+import type { PageServerLoad } from "./$types";
2+import { endpointsFor } from "$lib/api";
3+
4+export const load: PageServerLoad = async (event) => {
5+ return await endpointsFor(event.fetch).stats();
6+};
added web/src/routes/stats/+page.svelte +80 -0
new file mode 100644
@@ -0,0 +1,80 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import EmptyState from "$lib/components/EmptyState.svelte";
4+
5+ let { data }: { data: PageData } = $props();
6+
7+ const categories = $derived(Object.keys(data.metrics));
8+</script>
9+
10+<h1 class="text-2xl font-extrabold uppercase tracking-tight mb-2">Stats</h1>
11+<p class="mb-6 text-xs uppercase tracking-widest text-[var(--muted)]">
12+ Application metrics and performance data.
13+</p>
14+
15+<div class="space-y-3">
16+ {#if categories.length === 0}
17+ <EmptyState
18+ icon="chart"
19+ title="No metrics available"
20+ subtitle="Metrics will appear here once the application has been running for a while."
21+ />
22+ {:else}
23+ {#each categories as cat}
24+ <section class="panel divide-y-2 divide-[var(--border)]">
25+ <header class="px-5 py-3 border-b-2 border-[var(--border)]">
26+ <h2
27+ class="text-xs font-extrabold uppercase tracking-widest"
28+ >
29+ {cat}
30+ </h2>
31+ </header>
32+ <div>
33+ {#each data.metrics[cat] as m}
34+ <div
35+ class="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-0 px-4 sm:px-5 py-3.5"
36+ >
37+ <div class="min-w-0 flex-1">
38+ <div class="flex items-center gap-2 flex-wrap">
39+ <span class="text-sm font-bold"
40+ >{m.name}</span
41+ >
42+ {#if m.description}
43+ <span
44+ class="text-xs text-[var(--muted)]"
45+ >{m.description}</span
46+ >
47+ {/if}
48+ </div>
49+ {#if m.labels}
50+ <div class="mt-1.5 flex flex-wrap gap-1.5">
51+ {#each Object.entries(m.labels) as [k, v]}
52+ <span class="tag">{k}={v}</span>
53+ {/each}
54+ </div>
55+ {/if}
56+ </div>
57+ <div class="shrink-0 sm:ml-4 sm:text-right">
58+ {#if m.type === "gauge"}
59+ <span
60+ class="font-bold tabular-nums"
61+ style="color:var(--accent)"
62+ >{m.value.toFixed(2)}</span
63+ >
64+ {:else if m.type === "counter"}
65+ <span class="font-bold tabular-nums"
66+ >{Math.round(m.value)}</span
67+ >
68+ {:else}
69+ <span class="font-bold tabular-nums"
70+ >{m.value.toFixed(2)}</span
71+ >
72+ {/if}
73+ </div>
74+ </div>
75+ {/each}
76+ </div>
77+ </section>
78+ {/each}
79+ {/if}
80+</div>
new file mode 100644
@@ -0,0 +1,80 @@
1+<script lang="ts">
2+ import type { PageData } from "./$types";
3+ import EmptyState from "$lib/components/EmptyState.svelte";
4+
5+ let { data }: { data: PageData } = $props();
6+
7+ const categories = $derived(Object.keys(data.metrics));
8+</script>
9+
10+<h1 class="text-2xl font-extrabold uppercase tracking-tight mb-2">Stats</h1>
11+<p class="mb-6 text-xs uppercase tracking-widest text-[var(--muted)]">
12+ Application metrics and performance data.
13+</p>
14+
15+<div class="space-y-3">
16+ {#if categories.length === 0}
17+ <EmptyState
18+ icon="chart"
19+ title="No metrics available"
20+ subtitle="Metrics will appear here once the application has been running for a while."
21+ />
22+ {:else}
23+ {#each categories as cat}
24+ <section class="panel divide-y-2 divide-[var(--border)]">
25+ <header class="px-5 py-3 border-b-2 border-[var(--border)]">
26+ <h2
27+ class="text-xs font-extrabold uppercase tracking-widest"
28+ >
29+ {cat}
30+ </h2>
31+ </header>
32+ <div>
33+ {#each data.metrics[cat] as m}
34+ <div
35+ class="flex flex-col sm:flex-row sm:items-start justify-between gap-1 sm:gap-0 px-4 sm:px-5 py-3.5"
36+ >
37+ <div class="min-w-0 flex-1">
38+ <div class="flex items-center gap-2 flex-wrap">
39+ <span class="text-sm font-bold"
40+ >{m.name}</span
41+ >
42+ {#if m.description}
43+ <span
44+ class="text-xs text-[var(--muted)]"
45+ >{m.description}</span
46+ >
47+ {/if}
48+ </div>
49+ {#if m.labels}
50+ <div class="mt-1.5 flex flex-wrap gap-1.5">
51+ {#each Object.entries(m.labels) as [k, v]}
52+ <span class="tag">{k}={v}</span>
53+ {/each}
54+ </div>
55+ {/if}
56+ </div>
57+ <div class="shrink-0 sm:ml-4 sm:text-right">
58+ {#if m.type === "gauge"}
59+ <span
60+ class="font-bold tabular-nums"
61+ style="color:var(--accent)"
62+ >{m.value.toFixed(2)}</span
63+ >
64+ {:else if m.type === "counter"}
65+ <span class="font-bold tabular-nums"
66+ >{Math.round(m.value)}</span
67+ >
68+ {:else}
69+ <span class="font-bold tabular-nums"
70+ >{m.value.toFixed(2)}</span
71+ >
72+ {/if}
73+ </div>
74+ </div>
75+ {/each}
76+ </div>
77+ </section>
78+ {/each}
79+ {/if}
80+</div>
added web/src/routes/terms/+page.svelte +146 -0
new file mode 100644
@@ -0,0 +1,146 @@
1+<div class="mx-auto max-w-2xl px-4 py-12">
2+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
3+ Terms of Service
4+ </h1>
5+ <p class="mt-1 text-xs uppercase tracking-widest text-[var(--muted)]">
6+ Last updated: May 2, 2026
7+ </p>
8+
9+ <div class="panel mt-6 divide-y-2 divide-[var(--border)]">
10+ <section class="p-6">
11+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
12+ 1. Acceptance of Terms
13+ </h2>
14+ <p class="text-sm leading-relaxed">
15+ By accessing or using Glean ("the Service"), you agree to be
16+ bound by these Terms of Service. If you do not agree, do not use
17+ the Service.
18+ </p>
19+ </section>
20+ <section class="p-6">
21+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
22+ 2. Description of Service
23+ </h2>
24+ <p class="text-sm leading-relaxed">
25+ Glean is a social RSS reader built on the AT Protocol. It allows
26+ you to subscribe to RSS and Atom feeds, read articles, highlight
27+ passages, leave annotations, and discover new content through
28+ personalized recommendations based on your network.
29+ </p>
30+ </section>
31+ <section class="p-6">
32+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
33+ 3. Account and Authentication
34+ </h2>
35+ <p class="text-sm leading-relaxed">
36+ Glean uses AT Protocol identity. You sign in with your handle
37+ (e.g. from Bluesky or another AT Protocol account provider).
38+ Your subscriptions, annotations, and likes are stored in your
39+ personal data repository (PDS) on the AT Protocol. You retain
40+ full ownership of your data at all times.
41+ </p>
42+ </section>
43+ <section class="p-6">
44+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
45+ 4. Your Data
46+ </h2>
47+ <p class="text-sm leading-relaxed">
48+ Your data belongs to you. Glean stores records in your PDS using
49+ AT Protocol collections. You can export or move your data at any
50+ time using standard AT Protocol tools. Glean does not sell,
51+ share, or monetize your personal data.
52+ </p>
53+ </section>
54+ <section class="p-6">
55+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
56+ 5. Acceptable Use
57+ </h2>
58+ <p class="text-sm leading-relaxed">
59+ You agree not to use the Service to: violate any applicable law;
60+ infringe on the rights of others; distribute spam, malware, or
61+ harmful content; attempt to gain unauthorized access to the
62+ Service or its infrastructure; or interfere with the proper
63+ functioning of the Service.
64+ </p>
65+ </section>
66+ <section class="p-6">
67+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
68+ 6. Content
69+ </h2>
70+ <p class="text-sm leading-relaxed">
71+ Glean indexes publicly available RSS and Atom feeds. We do not
72+ host article content. Feed publishers retain all rights to their
73+ content. If you are a feed publisher and wish to have your feed
74+ removed from our index, please contact us.
75+ </p>
76+ </section>
77+ <section class="p-6">
78+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
79+ 7. Availability
80+ </h2>
81+ <p class="text-sm leading-relaxed">
82+ The Service is provided "as is" and "as available." We strive
83+ for reliability but do not guarantee uninterrupted access. We
84+ may modify, suspend, or discontinue the Service at any time.
85+ </p>
86+ </section>
87+ <section class="p-6">
88+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
89+ 8. Open Source
90+ </h2>
91+ <p class="text-sm leading-relaxed">
92+ Glean is open source software. You can inspect, fork, and
93+ self-host the code. Contributions are welcome subject to the
94+ project's license and contribution guidelines.
95+ </p>
96+ </section>
97+ <section class="p-6">
98+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
99+ 9. Limitation of Liability
100+ </h2>
101+ <p class="text-sm leading-relaxed">
102+ To the maximum extent permitted by law, the Service is provided
103+ without warranties of any kind. We are not liable for any
104+ indirect, incidental, or consequential damages arising from your
105+ use of the Service.
106+ </p>
107+ </section>
108+ <section class="p-6">
109+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
110+ 10. Changes to Terms
111+ </h2>
112+ <p class="text-sm leading-relaxed">
113+ We may update these Terms from time to time. Material changes
114+ will be communicated through the Service. Continued use of the
115+ Service after changes constitutes acceptance of the updated
116+ Terms.
117+ </p>
118+ </section>
119+ <section class="p-6">
120+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
121+ 11. Contact
122+ </h2>
123+ <p class="text-sm leading-relaxed">
124+ For questions about these Terms, reach out via
125+ <a
126+ href="https://bsky.app/profile/glean.at"
127+ class="font-bold text-[var(--accent)] underline underline-offset-2"
128+ >Bluesky</a
129+ >
130+ or email at
131+ <a
132+ href="mailto:contact@glean.at"
133+ class="font-bold text-[var(--accent)] underline underline-offset-2"
134+ >contact@glean.at</a
135+ >.
136+ </p>
137+ </section>
138+ </div>
139+
140+ <div class="mt-8">
141+ <a href="/" class="btn btn-accent">
142+ <span>&larr;</span>
143+ Back to Glean
144+ </a>
145+ </div>
146+</div>
new file mode 100644
@@ -0,0 +1,146 @@
1+<div class="mx-auto max-w-2xl px-4 py-12">
2+ <h1 class="text-2xl font-extrabold uppercase tracking-tight">
3+ Terms of Service
4+ </h1>
5+ <p class="mt-1 text-xs uppercase tracking-widest text-[var(--muted)]">
6+ Last updated: May 2, 2026
7+ </p>
8+
9+ <div class="panel mt-6 divide-y-2 divide-[var(--border)]">
10+ <section class="p-6">
11+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
12+ 1. Acceptance of Terms
13+ </h2>
14+ <p class="text-sm leading-relaxed">
15+ By accessing or using Glean ("the Service"), you agree to be
16+ bound by these Terms of Service. If you do not agree, do not use
17+ the Service.
18+ </p>
19+ </section>
20+ <section class="p-6">
21+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
22+ 2. Description of Service
23+ </h2>
24+ <p class="text-sm leading-relaxed">
25+ Glean is a social RSS reader built on the AT Protocol. It allows
26+ you to subscribe to RSS and Atom feeds, read articles, highlight
27+ passages, leave annotations, and discover new content through
28+ personalized recommendations based on your network.
29+ </p>
30+ </section>
31+ <section class="p-6">
32+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
33+ 3. Account and Authentication
34+ </h2>
35+ <p class="text-sm leading-relaxed">
36+ Glean uses AT Protocol identity. You sign in with your handle
37+ (e.g. from Bluesky or another AT Protocol account provider).
38+ Your subscriptions, annotations, and likes are stored in your
39+ personal data repository (PDS) on the AT Protocol. You retain
40+ full ownership of your data at all times.
41+ </p>
42+ </section>
43+ <section class="p-6">
44+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
45+ 4. Your Data
46+ </h2>
47+ <p class="text-sm leading-relaxed">
48+ Your data belongs to you. Glean stores records in your PDS using
49+ AT Protocol collections. You can export or move your data at any
50+ time using standard AT Protocol tools. Glean does not sell,
51+ share, or monetize your personal data.
52+ </p>
53+ </section>
54+ <section class="p-6">
55+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
56+ 5. Acceptable Use
57+ </h2>
58+ <p class="text-sm leading-relaxed">
59+ You agree not to use the Service to: violate any applicable law;
60+ infringe on the rights of others; distribute spam, malware, or
61+ harmful content; attempt to gain unauthorized access to the
62+ Service or its infrastructure; or interfere with the proper
63+ functioning of the Service.
64+ </p>
65+ </section>
66+ <section class="p-6">
67+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
68+ 6. Content
69+ </h2>
70+ <p class="text-sm leading-relaxed">
71+ Glean indexes publicly available RSS and Atom feeds. We do not
72+ host article content. Feed publishers retain all rights to their
73+ content. If you are a feed publisher and wish to have your feed
74+ removed from our index, please contact us.
75+ </p>
76+ </section>
77+ <section class="p-6">
78+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
79+ 7. Availability
80+ </h2>
81+ <p class="text-sm leading-relaxed">
82+ The Service is provided "as is" and "as available." We strive
83+ for reliability but do not guarantee uninterrupted access. We
84+ may modify, suspend, or discontinue the Service at any time.
85+ </p>
86+ </section>
87+ <section class="p-6">
88+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
89+ 8. Open Source
90+ </h2>
91+ <p class="text-sm leading-relaxed">
92+ Glean is open source software. You can inspect, fork, and
93+ self-host the code. Contributions are welcome subject to the
94+ project's license and contribution guidelines.
95+ </p>
96+ </section>
97+ <section class="p-6">
98+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
99+ 9. Limitation of Liability
100+ </h2>
101+ <p class="text-sm leading-relaxed">
102+ To the maximum extent permitted by law, the Service is provided
103+ without warranties of any kind. We are not liable for any
104+ indirect, incidental, or consequential damages arising from your
105+ use of the Service.
106+ </p>
107+ </section>
108+ <section class="p-6">
109+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
110+ 10. Changes to Terms
111+ </h2>
112+ <p class="text-sm leading-relaxed">
113+ We may update these Terms from time to time. Material changes
114+ will be communicated through the Service. Continued use of the
115+ Service after changes constitutes acceptance of the updated
116+ Terms.
117+ </p>
118+ </section>
119+ <section class="p-6">
120+ <h2 class="mb-2 text-sm font-extrabold uppercase tracking-widest">
121+ 11. Contact
122+ </h2>
123+ <p class="text-sm leading-relaxed">
124+ For questions about these Terms, reach out via
125+ <a
126+ href="https://bsky.app/profile/glean.at"
127+ class="font-bold text-[var(--accent)] underline underline-offset-2"
128+ >Bluesky</a
129+ >
130+ or email at
131+ <a
132+ href="mailto:contact@glean.at"
133+ class="font-bold text-[var(--accent)] underline underline-offset-2"
134+ >contact@glean.at</a
135+ >.
136+ </p>
137+ </section>
138+ </div>
139+
140+ <div class="mt-8">
141+ <a href="/" class="btn btn-accent">
142+ <span>&larr;</span>
143+ Back to Glean
144+ </a>
145+ </div>
146+</div>
renamed web/static/apple-touch-icon.png +0 -0
similarity index 100%
rename from static/apple-touch-icon.png
rename to web/static/apple-touch-icon.png
similarity index 100%
rename from static/apple-touch-icon.png
rename to web/static/apple-touch-icon.png
renamed web/static/banner.png +0 -0
similarity index 100%
rename from static/banner.png
rename to web/static/banner.png
similarity index 100%
rename from static/banner.png
rename to web/static/banner.png
renamed web/static/favicon.png +0 -0
similarity index 100%
rename from static/favicon.png
rename to web/static/favicon.png
similarity index 100%
rename from static/favicon.png
rename to web/static/favicon.png
renamed web/static/favicon.svg +0 -0
similarity index 100%
rename from static/favicon.svg
rename to web/static/favicon.svg
similarity index 100%
rename from static/favicon.svg
rename to web/static/favicon.svg
renamed web/static/icon-192.png +0 -0
similarity index 100%
rename from static/icon-192.png
rename to web/static/icon-192.png
similarity index 100%
rename from static/icon-192.png
rename to web/static/icon-192.png
renamed web/static/icon-512.png +0 -0
similarity index 100%
rename from static/icon-512.png
rename to web/static/icon-512.png
similarity index 100%
rename from static/icon-512.png
rename to web/static/icon-512.png
renamed web/static/icon-maskable-192.png +0 -0
similarity index 100%
rename from static/icon-maskable-192.png
rename to web/static/icon-maskable-192.png
similarity index 100%
rename from static/icon-maskable-192.png
rename to web/static/icon-maskable-192.png
renamed web/static/icon-maskable-512.png +0 -0
similarity index 100%
rename from static/icon-maskable-512.png
rename to web/static/icon-maskable-512.png
similarity index 100%
rename from static/icon-maskable-512.png
rename to web/static/icon-maskable-512.png
renamed web/static/manifest.json +6 -6
similarity index 77%
rename from static/manifest.json
rename to web/static/manifest.json
@@ -11,34 +11,34 @@
1111 "categories": ["news", "social", "productivity"],
1212 "icons": [
1313 {
14- "src": "/static/favicon.svg",
14+ "src": "/favicon.svg",
1515 "sizes": "any",
1616 "type": "image/svg+xml"
1717 },
1818 {
19- "src": "/static/icon-192.png",
19+ "src": "/icon-192.png",
2020 "sizes": "192x192",
2121 "type": "image/png"
2222 },
2323 {
24- "src": "/static/icon-512.png",
24+ "src": "/icon-512.png",
2525 "sizes": "512x512",
2626 "type": "image/png"
2727 },
2828 {
29- "src": "/static/icon-maskable-192.png",
29+ "src": "/icon-maskable-192.png",
3030 "sizes": "192x192",
3131 "type": "image/png",
3232 "purpose": "maskable"
3333 },
3434 {
35- "src": "/static/icon-maskable-512.png",
35+ "src": "/icon-maskable-512.png",
3636 "sizes": "512x512",
3737 "type": "image/png",
3838 "purpose": "maskable"
3939 },
4040 {
41- "src": "/static/apple-touch-icon.png",
41+ "src": "/apple-touch-icon.png",
4242 "sizes": "180x180",
4343 "type": "image/png"
4444 }
similarity index 77%
rename from static/manifest.json
rename to web/static/manifest.json
@@ -11,34 +11,34 @@
11 "categories": ["news", "social", "productivity"],11 "categories": ["news", "social", "productivity"],
12 "icons": [12 "icons": [
13 {13 {
14- "src": "/static/favicon.svg",14+ "src": "/favicon.svg",
15 "sizes": "any",15 "sizes": "any",
16 "type": "image/svg+xml"16 "type": "image/svg+xml"
17 },17 },
18 {18 {
19- "src": "/static/icon-192.png",19+ "src": "/icon-192.png",
20 "sizes": "192x192",20 "sizes": "192x192",
21 "type": "image/png"21 "type": "image/png"
22 },22 },
23 {23 {
24- "src": "/static/icon-512.png",24+ "src": "/icon-512.png",
25 "sizes": "512x512",25 "sizes": "512x512",
26 "type": "image/png"26 "type": "image/png"
27 },27 },
28 {28 {
29- "src": "/static/icon-maskable-192.png",29+ "src": "/icon-maskable-192.png",
30 "sizes": "192x192",30 "sizes": "192x192",
31 "type": "image/png",31 "type": "image/png",
32 "purpose": "maskable"32 "purpose": "maskable"
33 },33 },
34 {34 {
35- "src": "/static/icon-maskable-512.png",35+ "src": "/icon-maskable-512.png",
36 "sizes": "512x512",36 "sizes": "512x512",
37 "type": "image/png",37 "type": "image/png",
38 "purpose": "maskable"38 "purpose": "maskable"
39 },39 },
40 {40 {
41- "src": "/static/apple-touch-icon.png",41+ "src": "/apple-touch-icon.png",
42 "sizes": "180x180",42 "sizes": "180x180",
43 "type": "image/png"43 "type": "image/png"
44 }44 }
added web/svelte.config.js +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+import adapter from '@sveltejs/adapter-node';
2+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3+
4+/** @type {import('@sveltejs/kit').Config} */
5+const config = {
6+ preprocess: vitePreprocess(),
7+ kit: {
8+ adapter: adapter()
9+ }
10+};
11+
12+export default config;
new file mode 100644
@@ -0,0 +1,12 @@
1+import adapter from '@sveltejs/adapter-node';
2+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3+
4+/** @type {import('@sveltejs/kit').Config} */
5+const config = {
6+ preprocess: vitePreprocess(),
7+ kit: {
8+ adapter: adapter()
9+ }
10+};
11+
12+export default config;
added web/tsconfig.json +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+{
2+ "extends": "./.svelte-kit/tsconfig.json",
3+ "compilerOptions": {
4+ "allowJs": true,
5+ "checkJs": true,
6+ "esModuleInterop": true,
7+ "forceConsistentCasingInFileNames": true,
8+ "resolveJsonModule": true,
9+ "skipLibCheck": true,
10+ "sourceMap": true,
11+ "strict": true,
12+ "moduleResolution": "bundler"
13+ }
14+}
new file mode 100644
@@ -0,0 +1,14 @@
1+{
2+ "extends": "./.svelte-kit/tsconfig.json",
3+ "compilerOptions": {
4+ "allowJs": true,
5+ "checkJs": true,
6+ "esModuleInterop": true,
7+ "forceConsistentCasingInFileNames": true,
8+ "resolveJsonModule": true,
9+ "skipLibCheck": true,
10+ "sourceMap": true,
11+ "strict": true,
12+ "moduleResolution": "bundler"
13+ }
14+}
added web/vite.config.ts +12 -0
new file mode 100644
@@ -0,0 +1,12 @@
1+import { sveltekit } from "@sveltejs/kit/vite";
2+import tailwindcss from "@tailwindcss/vite";
3+import { defineConfig } from "vite";
4+
5+export default defineConfig({
6+ plugins: [tailwindcss(), sveltekit()],
7+ server: {
8+ // Match the production adapter-node port so dev and prod share an origin.
9+ port: 3000,
10+ strictPort: true,
11+ },
12+});
new file mode 100644
@@ -0,0 +1,12 @@
1+import { sveltekit } from "@sveltejs/kit/vite";
2+import tailwindcss from "@tailwindcss/vite";
3+import { defineConfig } from "vite";
4+
5+export default defineConfig({
6+ plugins: [tailwindcss(), sveltekit()],
7+ server: {
8+ // Match the production adapter-node port so dev and prod share an origin.
9+ port: 3000,
10+ strictPort: true,
11+ },
12+});