feat: introduce svelte frontend and redesignUnverified
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. | |
| 1 | 6 | GLEAN_ADDR=:8080 |
| 7 | +# SQLite database path. | |
| 2 | 8 | GLEAN_DB=glean.db |
| 9 | +# HMAC key for signing session cookies. Generate with: openssl rand -hex 32 | |
| 3 | 10 | 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 | 17 | GLEAN_JETSTREAM=wss://jetstream1.eurosky.network |
| 5 | 18 | GLEAN_PLC_URL=https://plc.eurosky.network |
| 6 | 19 | GLEAN_SYNC_INTERVAL=8h |
| @@ -8,22 +21,39 @@ GLEAN_CLUSTER_INTERVAL=60m | ||
| 8 | 21 | GLEAN_FETCH_INTERVAL=15m |
| 9 | 22 | GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription |
| 10 | 23 | 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.). | |
| 16 | 33 | # Without embeddings, recommendations rely only on subscription overlap and social graph. |
| 17 | 34 | GLEAN_EMBED_BASE_URL=https://llms.example.com/v1 |
| 18 | 35 | GLEAN_EMBED_API_KEY=API-KEY-001 |
| 19 | 36 | GLEAN_EMBED_MODEL=qwen-embedding-4b |
| 20 | 37 | 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. | |
| 22 | 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 | 43 | GLEAN_LLM_BASE_URL=https://llms.example.com/v1 |
| 25 | 44 | GLEAN_LLM_API_KEY=API-KEY-001 |
| 26 | 45 | GLEAN_LLM_MODEL=qwen3.5-35b-a3b |
| 46 | + | |
| 27 | 47 | # Enable Go pprof profiling server (off by default). |
| 28 | 48 | # Set to a listen address like :6060 to enable, leave empty to disable. |
| 29 | 49 | # 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=:8080 | 6 | GLEAN_ADDR=:8080 |
| 7 | +# SQLite database path. | ||
| 2 | GLEAN_DB=glean.db | 8 | 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-string | 10 | 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.network | 17 | GLEAN_JETSTREAM=wss://jetstream1.eurosky.network |
| 5 | GLEAN_PLC_URL=https://plc.eurosky.network | 18 | GLEAN_PLC_URL=https://plc.eurosky.network |
| 6 | GLEAN_SYNC_INTERVAL=8h | 19 | GLEAN_SYNC_INTERVAL=8h |
| @@ -8,22 +21,39 @@ GLEAN_CLUSTER_INTERVAL=60m | |||
| 8 | GLEAN_FETCH_INTERVAL=15m | 21 | GLEAN_FETCH_INTERVAL=15m |
| 9 | GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription | 22 | GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription |
| 10 | GLEAN_BACKFILL_CONCURRENCY=5 | 23 | GLEAN_BACKFILL_CONCURRENCY=5 |
| 11 | -# Leave empty for localhost OAuth (development) | 24 | + |
| 12 | -# GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata | 25 | +# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses OAuth localhost flow). |
| 13 | -# GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback | 26 | +# 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/v1 | 34 | GLEAN_EMBED_BASE_URL=https://llms.example.com/v1 |
| 18 | GLEAN_EMBED_API_KEY=API-KEY-001 | 35 | GLEAN_EMBED_API_KEY=API-KEY-001 |
| 19 | GLEAN_EMBED_MODEL=qwen-embedding-4b | 36 | GLEAN_EMBED_MODEL=qwen-embedding-4b |
| 20 | GLEAN_EMBED_DIMENSION=2560 | 37 | 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. | ||
| 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/v1 | 43 | GLEAN_LLM_BASE_URL=https://llms.example.com/v1 |
| 25 | GLEAN_LLM_API_KEY=API-KEY-001 | 44 | GLEAN_LLM_API_KEY=API-KEY-001 |
| 26 | GLEAN_LLM_MODEL=qwen3.5-35b-a3b | 45 | 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=:6060 | 49 | # 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 | ||
| 25 | 25 | # database |
| 26 | 26 | *.db* |
| 27 | 27 | |
| 28 | -# tailwind | |
| 29 | -static/output.css | |
| 28 | +# frontend | |
| 30 | 29 | node_modules/ |
| 30 | +web/node_modules/ | |
| 31 | +web/build/ | |
| 32 | +web/.svelte-kit/ | |
| 31 | 33 | |
| 32 | 34 | # todo |
| 33 | 35 | todo.md |
| @@ -25,9 +25,11 @@ go.work.sum | |||
| 25 | # database | 25 | # database |
| 26 | *.db* | 26 | *.db* |
| 27 | 27 | ||
| 28 | -# tailwind | 28 | +# 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 | # todo | 34 | # todo |
| 33 | todo.md | 35 | todo.md |
modified
Dockerfile +23 -6 | @@ -7,21 +7,38 @@ WORKDIR /src | ||
| 7 | 7 | COPY go.mod go.sum ./ |
| 8 | 8 | RUN go mod download |
| 9 | 9 | |
| 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 | |
| 12 | 12 | |
| 13 | 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 | 19 | RUN --mount=type=cache,target=/root/.cache/go-build \ |
| 17 | 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 | 21 | CGO_ENABLED=1 go build -tags fts5 -ldflags="-s -w" -o /glean . |
| 19 | 22 | |
| 20 | -FROM alpine:3.21 | |
| 23 | +FROM node:22-alpine | |
| 21 | 24 | |
| 22 | 25 | RUN apk add --no-cache ca-certificates |
| 23 | 26 | |
| 27 | +# Go API binary. | |
| 24 | 28 | COPY --from=builder /glean /usr/local/bin/glean |
| 25 | 29 | |
| 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 download | 8 | 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-lockfile | 11 | +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.21 | 23 | +FROM node:22-alpine |
| 21 | 24 | ||
| 22 | RUN apk add --no-cache ca-certificates | 25 | RUN apk add --no-cache ca-certificates |
| 23 | 26 | ||
| 27 | +# Go API binary. | ||
| 24 | COPY --from=builder /glean /usr/local/bin/glean | 28 | COPY --from=builder /glean /usr/local/bin/glean |
| 25 | 29 | ||
| 26 | -EXPOSE 8080 | 30 | +# 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: | ||
| 13 | 13 | test -z "$(shell gofmt -l ./...)" |
| 14 | 14 | golangci-lint run ./... --fix |
| 15 | 15 | |
| 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 | |
| 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 | 22 | .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 | |
| 27 | 31 | |
| 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 . | |
| 31 | 35 | |
| 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 | |
| 35 | 39 | |
| 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 | |
| 39 | 43 | |
| 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 . | |
| 43 | 47 | |
| 44 | 48 | .PHONY: icons |
| 45 | 49 | 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) | |
| 48 | 60 | |
| 49 | 61 | .PHONY: test |
| 50 | 62 | test: |
| 51 | 63 | go test -tags fts5 ./... |
| 52 | 64 | |
| 65 | +.PHONY: check | |
| 66 | +check: | |
| 67 | + cd web && bun run check | |
| 68 | + | |
| 53 | 69 | .PHONY: clean |
| 54 | 70 | 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 | |
| 56 | 73 | |
| 57 | 74 | .PHONY: docker-build |
| 58 | 75 | docker-build: |
| @@ -13,46 +13,63 @@ lint: | |||
| 13 | test -z "$(shell gofmt -l ./...)" | 13 | test -z "$(shell gofmt -l ./...)" |
| 14 | golangci-lint run ./... --fix | 14 | golangci-lint run ./... --fix |
| 15 | 15 | ||
| 16 | -.PHONY: lex-lint | 16 | +.PHONY: web-install |
| 17 | -lex-lint: | 17 | +web-install: |
| 18 | - goat lex lint | 18 | + 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: dev | 22 | .PHONY: dev |
| 25 | -dev: css htmx | 23 | +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: build | 32 | +.PHONY: dev-api |
| 29 | -build: css htmx | 33 | +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: css | 36 | +.PHONY: dev-web |
| 33 | -css: | 37 | +dev-web: |
| 34 | - bunx tailwindcss -i ./static/input.css -o ./static/output.css --minify | 38 | + cd web && GLEAN_API_URL=http://localhost:8080 bun run dev |
| 35 | 39 | ||
| 36 | -.PHONY: htmx | 40 | +.PHONY: web-build |
| 37 | -htmx: | 41 | +web-build: |
| 38 | - curl -sL 'https://unpkg.com/htmx.org@2' -o ./static/htmx.min.js | 42 | + cd web && bun run build |
| 39 | 43 | ||
| 40 | -.PHONY: css-watch | 44 | +.PHONY: build |
| 41 | -css-watch: | 45 | +build: web-build |
| 42 | - bunx tailwindcss -i ./static/input.css -o ./static/output.css --watch | 46 | + go build -tags fts5 -o glean . |
| 43 | 47 | ||
| 44 | .PHONY: icons | 48 | .PHONY: icons |
| 45 | icons: | 49 | icons: |
| 46 | - magick static/favicon.svg -background none -density 1200 -resize 512x512 -depth 8 PNG32:static/favicon.png | 50 | + 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.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) | ||
| 48 | 60 | ||
| 49 | .PHONY: test | 61 | .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: clean | 69 | .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.png | 71 | + rm -f glean glean.db |
| 72 | + rm -rf web/build web/.svelte-kit | ||
| 56 | 73 | ||
| 57 | .PHONY: docker-build | 74 | .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 @@ | ||
| 1 | 1 | # 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 | 5 | ## 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. | |
| 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. | |
| 18 | 16 | |
| 19 | 17 | ## 2. Color Palette |
| 20 | 18 | |
| 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. | |
| 66 | 50 | |
| 67 | 51 | ## 3. Typography |
| 68 | 52 | |
| 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. | |
| 72 | 54 | |
| 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)]` | | |
| 81 | 63 | |
| 82 | 64 | ## 4. Components |
| 83 | 65 | |
| 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`. | |
| 95 | 67 | |
| 96 | -**Primary Outlined:** | |
| 68 | +### Buttons | |
| 97 | 69 | |
| 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 | | |
| 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 | -``` | |
| 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. | |
| 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 | -### 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). | |
| 113 | 87 | |
| 114 | -`rounded-xl` (12px) radius, `shadow-spot` elevation, `bg-spot-surface` background. | |
| 88 | +### Forms | |
| 115 | 89 | |
| 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`. | |
| 119 | 92 | |
| 120 | 93 | ### Shadows |
| 121 | 94 | |
| 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 | | |
| 129 | 99 | |
| 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)]`. | |
| 133 | 101 | |
| 134 | -### Forms | |
| 102 | +### Navigation | |
| 135 | 103 | |
| 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). | |
| 139 | 108 | |
| 140 | -### Badges / Tags | |
| 109 | +### Article body | |
| 141 | 110 | |
| 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>`. | |
| 145 | 112 | |
| 146 | 113 | ## 5. Layout |
| 147 | 114 | |
| 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 | | |
| 153 | 121 | |
| 154 | -### Responsive Breakpoints | |
| 122 | +### Responsive Behavior | |
| 155 | 123 | |
| 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 | | |
| 161 | 128 | |
| 162 | -## 6. Tailwind Config | |
| 129 | +## 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 | 136 | ## 7. Asset Pipeline |
| 167 | 137 | |
| 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`. | |
| 173 | 141 | |
| 174 | 142 | ## 8. Page Structure |
| 175 | 143 | |
| 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 System | 1 | # 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 & Atmosphere | 5 | ## 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 Palette | 17 | ## 2. Color Palette |
| 20 | 18 | ||
| 21 | -### Primary Greens | 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. |
| 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 Theme | 43 | +| `--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. Typography | 51 | ## 3. Typography |
| 68 | 52 | ||
| 69 | -**Font:** Inter (Google Fonts), weights 400/500/600/700 | 53 | +**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. Components | 64 | ## 4. Components |
| 83 | 65 | ||
| 84 | -### Buttons | 66 | +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 transition | 71 | +| ---------------- | ---------------------------------------------------------- | ---------------------------- | |
| 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 transition | 81 | +- **`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 | -### 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). | ||
| 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 transition | 91 | +- Textareas and selects reuse `input-brutal`. |
| 118 | -``` | ||
| 119 | 92 | ||
| 120 | ### Shadows | 93 | ### 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 bottom | 100 | +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 | -### Forms | 102 | +### 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 button | 106 | +- **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 / Tags | 109 | +### 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. Layout | 113 | ## 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 wrapper | 118 | +| 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 Breakpoints | 122 | +### 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 Config | 129 | +## 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 Pipeline | 136 | ## 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 utilities | 139 | +- **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 Structure | 142 | ## 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. | ||
| 14 | 14 | | ---------------- | ----------------------------------------------------------------------------------------------- | |
| 15 | 15 | | Backend | Go | |
| 16 | 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 | 18 | | Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) | |
| 19 | 19 | | AT Protocol role | AppView for `at.glean.*` lexicons | |
| 20 | 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 | 421 | ## 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 | 426 | Jetstream (GLEAN_JETSTREAM) |
| 427 | 427 | │ subscribe |
| 428 | 428 | ▼ |
| 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 | + │ └────────────────┘ │ | |
| 452 | 455 | └──────────────────────┘ |
| 453 | 456 | |
| 454 | 457 | AppView responsibilities: |
| @@ -457,9 +460,8 @@ Glean runs as a single Go binary that fills three roles: **AppView** (indexing ` | ||
| 457 | 460 | • Convert at.margin.note records to annotations (displayed alongside glean.at annotations), skip if duplicate glean annotation exists |
| 458 | 461 | • Mirror glean annotations as at.margin.note records on user PDS for interoperability |
| 459 | 462 | • 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) | |
| 463 | 465 | ``` |
| 464 | 466 | |
| 465 | 467 | ## 6. Database Schema (SQLite) |
| @@ -929,70 +931,100 @@ CREATE TABLE recs.feed_embedding_meta ( | ||
| 929 | 931 | |
| 930 | 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 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` | | |
| 986 | 1019 | |
| 987 | 1020 | ## 9. Project Structure |
| 988 | 1021 | |
| 989 | 1022 | ``` |
| 990 | 1023 | glean/ |
| 991 | 1024 | ├── 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, ... | |
| 996 | 1028 | ├── lexicons/ |
| 997 | 1029 | │ └── at/ |
| 998 | 1030 | │ ├── glean/ # Glean lexicon JSON schemas (subscription, annotation, like) |
| @@ -1019,20 +1051,21 @@ glean/ | ||
| 1019 | 1051 | │ │ ├── follow.go # Follow queries |
| 1020 | 1052 | │ │ ├── oauth_store.go # OAuth session storage |
| 1021 | 1053 | │ │ ├── user_settings.go # User settings queries |
| 1022 | -│ │ ├── store.go # FeedStore adapter for scheduler | |
| 1054 | +│ │ └── store.go # FeedStore adapter for scheduler | |
| 1023 | 1055 | │ ├── feed/ |
| 1024 | 1056 | │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser |
| 1025 | 1057 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher |
| 1026 | 1058 | │ │ ├── discover.go # Feed auto-discovery from URLs |
| 1027 | 1059 | │ │ └── opml.go # OPML import/export |
| 1028 | 1060 | │ ├── httpclient/ |
| 1029 | -│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent | |
| 1061 | +│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent | |
| 1030 | 1062 | │ ├── scraper/ |
| 1031 | 1063 | │ │ └── scraper.go # Full article content scraper |
| 1032 | 1064 | │ ├── metrics/ |
| 1033 | 1065 | │ │ └── metrics.go # Prometheus metrics definitions |
| 1034 | 1066 | │ ├── ml/ |
| 1035 | 1067 | │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers |
| 1068 | +│ │ ├── langdetect.go # Known-language table + detection | |
| 1036 | 1069 | │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation |
| 1037 | 1070 | │ ├── cluster/ |
| 1038 | 1071 | │ │ ├── jaccard.go # Jaccard similarity computation |
| @@ -1046,49 +1079,57 @@ glean/ | ||
| 1046 | 1079 | │ ├── feedback/ |
| 1047 | 1080 | │ │ └── feedback.go # Dismiss + impression tracking service |
| 1048 | 1081 | │ ├── 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 | |
| 1051 | 1086 | │ │ ├── feeds_handler.go # Feed management handlers |
| 1052 | 1087 | │ │ ├── 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 | |
| 1055 | 1091 | │ │ ├── trending_handler.go # Trending handler |
| 1056 | -│ │ ├── stats_handler.go # Stats handler (Prometheus metrics display) | |
| 1057 | -│ │ ├── index_handler.go # Landing page handler | |
| 1058 | 1092 | │ │ ├── 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 | 1094 | │ │ ├── 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 | |
| 1064 | 1100 | │ │ ├── pagination.go # Pagination helpers |
| 1065 | -│ │ ├── middleware.go # Auth, logging, CSRF middleware | |
| 1101 | +│ │ ├── middleware.go # Auth, logging, CSRF, CORS middleware | |
| 1066 | 1102 | │ │ └── 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 | |
| 1092 | 1133 | ``` |
| 1093 | 1134 | |
| 1094 | 1135 | ## 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 Architecture | 421 | ## 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 | │ subscribe | 427 | │ 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 apps | 432 | + └──────────┬───────────┘ |
| 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 feeds | 446 | + │ │ 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 exists | 460 | • 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 interoperability | 461 | • Mirror glean annotations as at.margin.note records on user PDS for interoperability |
| 459 | • Import app.skyreader.feed.subscription records as Glean subscriptions | 462 | • 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.at | 464 | + • 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 Endpoints | 934 | +## 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 Pages | 938 | +### 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 Patterns | 982 | +| `/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 fragment | 984 | +| `/api/auth/logout` | POST | 🔒 | End session → `{redirect}` | |
| 983 | -- **Infinite scroll articles**: `<div hx-get="/articles?page=2" hx-trigger="intersect">` for pagination | 985 | +| `/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 state | 986 | +| `/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 Structure | 1020 | ## 9. Project Structure |
| 988 | 1021 | ||
| 989 | ``` | 1022 | ``` |
| 990 | glean/ | 1023 | glean/ |
| 991 | ├── main.go # Entry point, wire everything | 1024 | ├── main.go # Entry point, wire everything |
| 992 | -├── go.mod | 1025 | +├── go.mod / go.sum |
| 993 | -├── go.sum | 1026 | +├── Dockerfile # Multi-stage: builds web/ then Go, runs both |
| 994 | -├── Dockerfile | 1027 | +├── 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 queries | 1051 | │ │ ├── follow.go # Follow queries |
| 1020 | │ │ ├── oauth_store.go # OAuth session storage | 1052 | │ │ ├── oauth_store.go # OAuth session storage |
| 1021 | │ │ ├── user_settings.go # User settings queries | 1053 | │ │ ├── user_settings.go # User settings queries |
| 1022 | -│ │ ├── store.go # FeedStore adapter for scheduler | 1054 | +│ │ └── store.go # FeedStore adapter for scheduler |
| 1023 | │ ├── feed/ | 1055 | │ ├── feed/ |
| 1024 | │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser | 1056 | │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser |
| 1025 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher | 1057 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher |
| 1026 | │ │ ├── discover.go # Feed auto-discovery from URLs | 1058 | │ │ ├── discover.go # Feed auto-discovery from URLs |
| 1027 | │ │ └── opml.go # OPML import/export | 1059 | │ │ └── opml.go # OPML import/export |
| 1028 | │ ├── httpclient/ | 1060 | │ ├── httpclient/ |
| 1029 | -│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent | 1061 | +│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent |
| 1030 | │ ├── scraper/ | 1062 | │ ├── scraper/ |
| 1031 | │ │ └── scraper.go # Full article content scraper | 1063 | │ │ └── scraper.go # Full article content scraper |
| 1032 | │ ├── metrics/ | 1064 | │ ├── metrics/ |
| 1033 | │ │ └── metrics.go # Prometheus metrics definitions | 1065 | │ │ └── metrics.go # Prometheus metrics definitions |
| 1034 | │ ├── ml/ | 1066 | │ ├── ml/ |
| 1035 | │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers | 1067 | │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers |
| 1068 | +│ │ ├── langdetect.go # Known-language table + detection | ||
| 1036 | │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation | 1069 | │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation |
| 1037 | │ ├── cluster/ | 1070 | │ ├── cluster/ |
| 1038 | │ │ ├── jaccard.go # Jaccard similarity computation | 1071 | │ │ ├── jaccard.go # Jaccard similarity computation |
| @@ -1046,49 +1079,57 @@ glean/ | |||
| 1046 | │ ├── feedback/ | 1079 | │ ├── feedback/ |
| 1047 | │ │ └── feedback.go # Dismiss + impression tracking service | 1080 | │ │ └── feedback.go # Dismiss + impression tracking service |
| 1048 | │ ├── server/ | 1081 | │ ├── server/ |
| 1049 | -│ │ ├── server.go # HTTP server, router setup | 1082 | +│ │ ├── server.go # HTTP server, chi router setup (/api/* routes) |
| 1050 | -│ │ ├── auth_handler.go # OAuth login/callback/register | 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 | ||
| 1051 | │ │ ├── feeds_handler.go # Feed management handlers | 1086 | │ │ ├── feeds_handler.go # Feed management handlers |
| 1052 | │ │ ├── articles_handler.go # Article reading handlers | 1087 | │ │ ├── articles_handler.go # Article reading handlers |
| 1053 | -│ │ ├── annotations_handler.go # Annotation handlers | 1088 | +│ │ ├── annotations_handler.go # Annotation + library handlers |
| 1054 | -│ │ ├── dashboard_handler.go # Dashboard handler | 1089 | +│ │ ├── dashboard_handler.go # Dashboard + recommendation handlers |
| 1090 | +│ │ ├── recs_handler.go # Recommendation dismiss handlers | ||
| 1055 | │ │ ├── trending_handler.go # Trending handler | 1091 | │ │ ├── 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 handler | 1092 | │ │ ├── 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 handlers | 1095 | +│ │ ├── 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 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 | ||
| 1064 | │ │ ├── pagination.go # Pagination helpers | 1100 | │ │ ├── pagination.go # Pagination helpers |
| 1065 | -│ │ ├── middleware.go # Auth, logging, CSRF middleware | 1101 | +│ │ ├── middleware.go # Auth, logging, CSRF, CORS middleware |
| 1066 | │ │ └── session.go # Session management | 1102 | │ │ └── session.go # Session management |
| 1067 | -│ ├── sanitize/ | 1103 | +├── web/ # SvelteKit (SSR) frontend, adapter-node |
| 1068 | -│ │ └── sanitize.go # HTML sanitization for article content | 1104 | +│ ├── src/ |
| 1069 | -│ └── tmpl/ | 1105 | +│ │ ├── app.css # Tailwind v4 entry + design tokens (@theme, [data-theme], @utility) |
| 1070 | -│ ├── base.html # Base template with htmx + Tailwind | 1106 | +│ │ ├── app.html # HTML shell |
| 1071 | -│ ├── index.html # Landing page | 1107 | +│ │ ├── app.d.ts # Locals types (user, csrfToken) |
| 1072 | -│ ├── login.html # Login page | 1108 | +│ │ ├── hooks.server.ts # /api/* proxy to Go + per-request user load |
| 1073 | -│ ├── dashboard.html # Dashboard | 1109 | +│ │ ├── lib/ |
| 1074 | -│ ├── feeds.html # Feed management | 1110 | +│ │ │ ├── api.ts # Typed endpoint client (endpoints / endpointsFor(fetch)) |
| 1075 | -│ ├── articles.html # Article listing | 1111 | +│ │ │ ├── types.ts # API response types |
| 1076 | -│ ├── article_detail.html # Article detail | 1112 | +│ │ │ ├── format.ts # Date/HTML/youtube formatting helpers |
| 1077 | -│ ├── trending.html # Trending articles | 1113 | +│ │ │ └── components/ # Svelte components (ArticleCard, FeedItem, Icon, ...) |
| 1078 | -│ ├── stats.html # Application metrics | 1114 | +│ │ └── routes/ # File-based routes (+page.server.ts load + +page.svelte) |
| 1079 | -│ ├── library.html # Liked articles + annotations | 1115 | +│ │ ├── +layout.svelte # Chrome (nav, footer, dialogs, new-articles banner) |
| 1080 | -│ ├── profile.html # User profile | 1116 | +│ │ ├── articles/[id]/ # Article detail |
| 1081 | -│ ├── error.html # Error page | 1117 | +│ │ ├── dashboard/ # Dashboard |
| 1082 | -│ ├── 404.html # Not found page | 1118 | +│ │ ├── feeds/ # Feed management |
| 1083 | -│ ├── terms.html # Terms of service | 1119 | +│ │ ├── library/ # Liked + annotations |
| 1084 | -│ └── partials/ # Reusable template fragments | 1120 | +│ │ ├── trending/ # Trending |
| 1085 | -├── static/ | 1121 | +│ │ ├── profile/[did]/ # Public profile |
| 1086 | -│ ├── input.css # Tailwind input | 1122 | +│ │ ├── stats/ # Metrics |
| 1087 | -│ └── output.css # Tailwind compiled output | 1123 | +│ │ ├── 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 system | 1126 | +│ ├── static/ # Favicons, manifest, banner |
| 1091 | -└── tailwind.config.js | 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 | ||
| 1092 | ``` | 1133 | ``` |
| 1093 | 1134 | ||
| 1094 | ## 10. Auth Flow | 1135 | ## 10. Auth Flow |
modified
internal/ml/langdetect.go +2 -2 | @@ -1,8 +1,8 @@ | ||
| 1 | 1 | package ml |
| 2 | 2 | |
| 3 | 3 | type Language struct { |
| 4 | - Code string | |
| 5 | - Name string | |
| 4 | + Code string `json:"code"` | |
| 5 | + Name string `json:"name"` | |
| 6 | 6 | } |
| 7 | 7 | |
| 8 | 8 | var knownLanguages = []Language{ |
| @@ -1,8 +1,8 @@ | |||
| 1 | package ml | 1 | package ml |
| 2 | 2 | ||
| 3 | type Language struct { | 3 | type Language struct { |
| 4 | - Code string | 4 | + Code string `json:"code"` |
| 5 | - Name string | 5 | + 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) { | ||
| 57 | 57 | likedPage.HasNext = true |
| 58 | 58 | likedPage.NextPage = likedPage.Page + 1 |
| 59 | 59 | } |
| 60 | - navSuffix := buildNavSuffix("", true, "") | |
| 61 | - for _, a := range articles { | |
| 62 | - a.NavSuffix = navSuffix | |
| 63 | - } | |
| 64 | 60 | return nil |
| 65 | 61 | }) |
| 66 | 62 | |
| @@ -84,17 +80,20 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) { | ||
| 84 | 80 | return nil |
| 85 | 81 | }) |
| 86 | 82 | |
| 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) | |
| 89 | 89 | } |
| 90 | 90 | |
| 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, | |
| 98 | 97 | }) |
| 99 | 98 | } |
| 100 | 99 | |
| @@ -134,7 +133,7 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 134 | 133 | uri, cid, err := client.CreateRecord(ctx, user.DID, atproto.CollectionAnnotation, record) |
| 135 | 134 | if err != nil { |
| 136 | 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 | 137 | return |
| 139 | 138 | } |
| 140 | 139 | a.URI = uri |
| @@ -155,15 +154,12 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 155 | 154 | } |
| 156 | 155 | |
| 157 | 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 | 158 | return |
| 160 | 159 | } |
| 161 | 160 | |
| 162 | 161 | 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)}) | |
| 167 | 163 | } |
| 168 | 164 | |
| 169 | 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 | 167 | ctx := r.Context() |
| 172 | 168 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 173 | 169 | if err != nil { |
| 174 | - http.Error(w, "invalid id", http.StatusBadRequest) | |
| 170 | + writeAPIError(w, http.StatusBadRequest, "invalid id") | |
| 175 | 171 | return |
| 176 | 172 | } |
| 177 | 173 | |
| 178 | 174 | annotation, err := s.dbs.Articles.GetAnnotation(ctx, id) |
| 179 | 175 | if err != nil { |
| 180 | - http.Error(w, "annotation not found", http.StatusNotFound) | |
| 176 | + writeAPIError(w, http.StatusNotFound, "annotation not found") | |
| 181 | 177 | return |
| 182 | 178 | } |
| 183 | 179 | |
| 184 | 180 | if annotation.AuthorDID != user.DID { |
| 185 | - http.Error(w, "forbidden", http.StatusForbidden) | |
| 181 | + writeAPIError(w, http.StatusForbidden, "forbidden") | |
| 186 | 182 | return |
| 187 | 183 | } |
| 188 | 184 | |
| @@ -192,7 +188,7 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 192 | 188 | if ok { |
| 193 | 189 | if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil { |
| 194 | 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 | 192 | return |
| 197 | 193 | } |
| 198 | 194 | } |
| @@ -208,11 +204,11 @@ func (s *Server) handleDeleteAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 208 | 204 | // Delete by content so any duplicate row created by the mirror (different URI, |
| 209 | 205 | // identical content) is removed alongside the canonical annotation. |
| 210 | 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 | 208 | return |
| 213 | 209 | } |
| 214 | 210 | |
| 215 | - w.WriteHeader(http.StatusOK) | |
| 211 | + w.WriteHeader(http.StatusNoContent) | |
| 216 | 212 | } |
| 217 | 213 | |
| 218 | 214 | 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 = true | 57 | likedPage.HasNext = true |
| 58 | likedPage.NextPage = likedPage.Page + 1 | 58 | likedPage.NextPage = likedPage.Page + 1 |
| 59 | } | 59 | } |
| 60 | - navSuffix := buildNavSuffix("", true, "") | ||
| 61 | - for _, a := range articles { | ||
| 62 | - a.NavSuffix = navSuffix | ||
| 63 | - } | ||
| 64 | return nil | 60 | return nil |
| 65 | }) | 61 | }) |
| 66 | 62 | ||
| @@ -84,17 +80,20 @@ func (s *Server) handleLibrary(w http.ResponseWriter, r *http.Request) { | |||
| 84 | return nil | 80 | 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 | return | 137 | return |
| 139 | } | 138 | } |
| 140 | a.URI = uri | 139 | 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 | return | 158 | return |
| 160 | } | 159 | } |
| 161 | 160 | ||
| 162 | a.AuthorHandle = atproto.ResolveProfile(r.Context(), user.DID).Handle | 161 | 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 | return | 171 | 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 | return | 177 | 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 | return | 182 | 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 | return | 192 | 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 | return | 208 | 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 ( | ||
| 16 | 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 | 19 | func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { |
| 50 | 20 | user := currentUser(r) |
| 51 | 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.Article | |
| 73 | - var err error | |
| 42 | + var ( | |
| 43 | + articles []*db.Article | |
| 44 | + err error | |
| 45 | + ) | |
| 74 | 46 | |
| 75 | 47 | if searchQuery != "" { |
| 76 | 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 | 60 | if err != nil { |
| 89 | 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 | 63 | return |
| 92 | 64 | } |
| 93 | 65 | |
| @@ -97,59 +69,40 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 97 | 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 | 72 | 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 | |
| 115 | 74 | |
| 116 | 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{ | |
| 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(), | |
| 132 | 95 | } |
| 133 | 96 | |
| 134 | 97 | if feedURL != "" { |
| 135 | 98 | 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)) | |
| 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) { | |
| 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) | |
| 153 | 106 | } |
| 154 | 107 | |
| 155 | 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 | 110 | ctx := r.Context() |
| 158 | 111 | sinceUnix, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) |
| 159 | 112 | if err != nil { |
| 160 | - w.WriteHeader(http.StatusBadRequest) | |
| 113 | + writeAPIError(w, http.StatusBadRequest, "invalid since") | |
| 161 | 114 | return |
| 162 | 115 | } |
| 163 | 116 | since := time.Unix(sinceUnix, 0) |
| @@ -165,23 +118,11 @@ func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) { | ||
| 165 | 118 | count, err := s.dbs.Articles.CountNewArticles(ctx, user.DID, since) |
| 166 | 119 | if err != nil { |
| 167 | 120 | 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") | |
| 175 | 122 | 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 { | |
| 181 | - if n != 1 { | |
| 182 | - return "s" | |
| 183 | - } | |
| 184 | - return "" | |
| 125 | + writeJSON(w, http.StatusOK, newArticleCountResponse{Count: count}) | |
| 185 | 126 | } |
| 186 | 127 | |
| 187 | 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 | 130 | ctx := r.Context() |
| 190 | 131 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 191 | 132 | if err != nil { |
| 192 | - http.Error(w, "invalid id", http.StatusBadRequest) | |
| 133 | + writeAPIError(w, http.StatusBadRequest, "invalid id") | |
| 193 | 134 | return |
| 194 | 135 | } |
| 195 | 136 | |
| 196 | 137 | article, err := s.dbs.Articles.GetArticle(ctx, id) |
| 197 | 138 | if err != nil { |
| 198 | - http.Error(w, "article not found", http.StatusNotFound) | |
| 139 | + writeAPIError(w, http.StatusNotFound, "article not found") | |
| 199 | 140 | 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 | 147 | var ( |
| 203 | - readState *db.ReadState | |
| 204 | 148 | likeCount int |
| 205 | 149 | liked bool |
| 206 | 150 | annotations []*db.Annotation |
| @@ -208,10 +152,6 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | ||
| 208 | 152 | 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 | 155 | g, gCtx := errgroup.WithContext(ctx) |
| 216 | 156 | |
| 217 | 157 | g.Go(func() error { |
| @@ -221,15 +161,6 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | ||
| 221 | 161 | 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 | 164 | g.Go(func() error { |
| 234 | 165 | if article.URL.Valid { |
| 235 | 166 | var err error |
| @@ -281,52 +212,57 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | ||
| 281 | 212 | return nil |
| 282 | 213 | }) |
| 283 | 214 | |
| 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) | |
| 300 | 238 | } |
| 301 | 239 | |
| 302 | 240 | func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { |
| 303 | 241 | user := currentUser(r) |
| 304 | 242 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 305 | 243 | if err != nil { |
| 306 | - http.Error(w, "invalid id", http.StatusBadRequest) | |
| 244 | + writeAPIError(w, http.StatusBadRequest, "invalid id") | |
| 307 | 245 | return |
| 308 | 246 | } |
| 309 | 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 | 249 | return |
| 312 | 250 | } |
| 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}) | |
| 315 | 252 | } |
| 316 | 253 | |
| 317 | 254 | func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { |
| 318 | 255 | user := currentUser(r) |
| 319 | 256 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 320 | 257 | if err != nil { |
| 321 | - http.Error(w, "invalid id", http.StatusBadRequest) | |
| 258 | + writeAPIError(w, http.StatusBadRequest, "invalid id") | |
| 322 | 259 | return |
| 323 | 260 | } |
| 324 | 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 | 263 | return |
| 327 | 264 | } |
| 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}) | |
| 330 | 266 | } |
| 331 | 267 | |
| 332 | 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 | 270 | ctx := r.Context() |
| 335 | 271 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 336 | 272 | if err != nil { |
| 337 | - http.Error(w, "invalid id", http.StatusBadRequest) | |
| 273 | + writeAPIError(w, http.StatusBadRequest, "invalid id") | |
| 338 | 274 | return |
| 339 | 275 | } |
| 340 | 276 | |
| 341 | 277 | article, err := s.dbs.Articles.GetArticle(ctx, id) |
| 342 | 278 | if err != nil { |
| 343 | - http.Error(w, err.Error(), http.StatusNotFound) | |
| 279 | + writeAPIError(w, http.StatusNotFound, err.Error()) | |
| 344 | 280 | return |
| 345 | 281 | } |
| 346 | 282 | |
| 347 | 283 | liked, err := s.dbs.Articles.HasLiked(ctx, user.DID, article.FeedURL, article.URL.String) |
| 348 | 284 | if err != nil { |
| 349 | - http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 285 | + writeAPIError(w, http.StatusInternalServerError, err.Error()) | |
| 350 | 286 | return |
| 351 | 287 | } |
| 352 | 288 | |
| 353 | 289 | if liked { |
| 354 | 290 | existingLike, getErr := s.dbs.Articles.GetLike(ctx, user.DID, article.FeedURL, article.URL.String) |
| 355 | 291 | if getErr != nil { |
| 356 | - http.Error(w, getErr.Error(), http.StatusInternalServerError) | |
| 292 | + writeAPIError(w, http.StatusInternalServerError, getErr.Error()) | |
| 357 | 293 | return |
| 358 | 294 | } |
| 359 | 295 | if existingLike.URI != "" { |
| @@ -362,14 +298,14 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | ||
| 362 | 298 | if ok { |
| 363 | 299 | if delErr := client.DeleteRecord(ctx, user.DID, parsed.Collection, parsed.RKey); delErr != nil { |
| 364 | 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 | 302 | return |
| 367 | 303 | } |
| 368 | 304 | } |
| 369 | 305 | } |
| 370 | 306 | } |
| 371 | 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 | 309 | return |
| 374 | 310 | } |
| 375 | 311 | } else { |
| @@ -383,7 +319,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | ||
| 383 | 319 | uri, _, err := client.CreateRecord(ctx, user.DID, atproto.CollectionLike, likeRecord) |
| 384 | 320 | if err != nil { |
| 385 | 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 | 323 | return |
| 388 | 324 | } |
| 389 | 325 | |
| @@ -395,14 +331,9 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | ||
| 395 | 331 | CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, |
| 396 | 332 | } |
| 397 | 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 | 335 | 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 | 337 | } else { |
| 407 | 338 | like := &db.Like{ |
| 408 | 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 | 343 | CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, |
| 413 | 344 | } |
| 414 | 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 | 347 | 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 | 357 | likeCount := 0 |
| @@ -430,13 +361,21 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | ||
| 430 | 361 | s.logger.Warn("failed to get like count", "error", err) |
| 431 | 362 | } |
| 432 | 363 | } |
| 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 | + }) | |
| 435 | 370 | } |
| 436 | 371 | |
| 437 | 372 | func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) { |
| 438 | 373 | user := currentUser(r) |
| 439 | 374 | ctx := r.Context() |
| 375 | + if err := r.ParseForm(); err != nil { | |
| 376 | + writeAPIError(w, http.StatusBadRequest, err.Error()) | |
| 377 | + return | |
| 378 | + } | |
| 440 | 379 | feedURL := r.FormValue("feed") |
| 441 | 380 | var err error |
| 442 | 381 | if feedURL != "" { |
| @@ -445,10 +384,9 @@ func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) { | ||
| 445 | 384 | err = s.dbs.Articles.MarkAllSubscribedRead(ctx, user.DID) |
| 446 | 385 | } |
| 447 | 386 | if err != nil { |
| 448 | - http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 387 | + writeAPIError(w, http.StatusInternalServerError, err.Error()) | |
| 449 | 388 | return |
| 450 | 389 | } |
| 451 | - w.Header().Set("HX-Refresh", "true") | |
| 452 | 390 | w.WriteHeader(http.StatusNoContent) |
| 453 | 391 | } |
| 454 | 392 | |
| @@ -456,45 +394,39 @@ func (s *Server) handleFetchContent(w http.ResponseWriter, r *http.Request) { | ||
| 456 | 394 | ctx := r.Context() |
| 457 | 395 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 458 | 396 | if err != nil { |
| 459 | - http.Error(w, "invalid id", http.StatusBadRequest) | |
| 397 | + writeAPIError(w, http.StatusBadRequest, "invalid id") | |
| 460 | 398 | return |
| 461 | 399 | } |
| 462 | 400 | |
| 463 | 401 | article, err := s.dbs.Articles.GetArticle(ctx, id) |
| 464 | 402 | if err != nil { |
| 465 | - http.Error(w, "article not found", http.StatusNotFound) | |
| 403 | + writeAPIError(w, http.StatusNotFound, "article not found") | |
| 466 | 404 | return |
| 467 | 405 | } |
| 468 | 406 | |
| 469 | 407 | 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") | |
| 472 | 409 | return |
| 473 | 410 | } |
| 474 | 411 | |
| 475 | 412 | content, err := s.scraper.Scrape(ctx, article.URL.String) |
| 476 | 413 | if err != nil { |
| 477 | 414 | 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") | |
| 486 | 416 | return |
| 487 | 417 | } |
| 488 | 418 | |
| 489 | 419 | 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 | + } | |
| 493 | 424 | } |
| 494 | 425 | |
| 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 | + }) | |
| 498 | 430 | } |
| 499 | 431 | |
| 500 | 432 | 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.Article | 42 | + var ( |
| 73 | - var err error | 43 | + 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 | return | 63 | 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 := false | 73 | + 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"] = feed | 99 | + 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 | return | 114 | 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 | return | 122 | 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 | return | 134 | 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 | return | 140 | 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 int | 148 | likeCount int |
| 205 | liked bool | 149 | liked bool |
| 206 | annotations []*db.Annotation | 150 | annotations []*db.Annotation |
| @@ -208,10 +152,6 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | |||
| 208 | nextID *int64 | 152 | 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 nil | 161 | 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 error | 166 | var err error |
| @@ -281,52 +212,57 @@ func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | |||
| 281 | return nil | 212 | 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 | return | 245 | 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 | return | 249 | 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 | return | 259 | 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 | return | 263 | 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 | return | 274 | 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 | return | 280 | 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 | return | 286 | 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 | return | 293 | 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 | return | 302 | 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 | return | 309 | 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 | return | 323 | 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 | return | 335 | 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 | return | 347 | 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 := 0 | 357 | 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 error | 380 | 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 | return | 388 | 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 | return | 398 | 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 | return | 404 | 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 | return | 409 | 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 | return | 416 | 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 ( | ||
| 13 | 13 | "pkg.rbrt.fr/glean/internal/atproto" |
| 14 | 14 | ) |
| 15 | 15 | |
| 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 != ""}) | |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | +// handleAuthRegister kicks off registration via the Eurosky PDS. | |
| 20 | 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 | 22 | authURL, err := s.oauth.StartAuthFlow(r.Context(), "https://eurosky.social") |
| 23 | 23 | if err != nil { |
| 24 | 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 | 26 | return |
| 27 | 27 | } |
| 28 | - http.Redirect(w, r, authURL, http.StatusSeeOther) | |
| 28 | + writeJSON(w, http.StatusOK, redirectResponse{Redirect: authURL}) | |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | func (s *Server) handleAuthResolve(w http.ResponseWriter, r *http.Request) { |
| 32 | 32 | q := strings.TrimPrefix(r.URL.Query().Get("q"), "@") |
| 33 | 33 | if q == "" { |
| 34 | - w.Header().Set("Content-Type", "application/json") | |
| 35 | - w.Write([]byte(`{"actors":[]}`)) | |
| 34 | + writeJSON(w, http.StatusOK, actorsResponse{}) | |
| 36 | 35 | return |
| 37 | 36 | } |
| 38 | 37 | |
| 39 | 38 | actors, err := atproto.SearchActorsTypeahead(r.Context(), q, 5) |
| 40 | 39 | if err != nil { |
| 41 | 40 | 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{}) | |
| 44 | 42 | return |
| 45 | 43 | } |
| 46 | 44 | |
| 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}) | |
| 49 | 46 | } |
| 50 | 47 | |
| 51 | 48 | func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { |
| 52 | 49 | handle := strings.TrimPrefix(r.FormValue("handle"), "@") |
| 53 | 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 | 52 | return |
| 56 | 53 | } |
| 57 | 54 | |
| @@ -61,22 +58,24 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { | ||
| 61 | 58 | |
| 62 | 59 | did, resolveErr := atproto.ResolveHandle(r.Context(), handle) |
| 63 | 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 | 62 | return |
| 66 | 63 | } |
| 67 | 64 | user, createErr := s.dbs.Users.CreateUser(r.Context(), did) |
| 68 | 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 | 67 | return |
| 71 | 68 | } |
| 72 | 69 | s.setUserSession(w, user) |
| 73 | - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | |
| 70 | + writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"}) | |
| 74 | 71 | 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 | 79 | func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { |
| 81 | 80 | params := r.URL.Query() |
| 82 | 81 | |
| @@ -87,21 +86,21 @@ func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 87 | 86 | |
| 88 | 87 | handle := params.Get("handle") |
| 89 | 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 | 90 | return |
| 92 | 91 | } |
| 93 | 92 | |
| 94 | 93 | did, err := atproto.ResolveHandle(r.Context(), handle) |
| 95 | 94 | if err != nil { |
| 96 | 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 | 97 | return |
| 99 | 98 | } |
| 100 | 99 | |
| 101 | 100 | user, err := s.dbs.Users.CreateUser(r.Context(), did) |
| 102 | 101 | if err != nil { |
| 103 | 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 | 104 | return |
| 106 | 105 | } |
| 107 | 106 | |
| @@ -113,7 +112,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 113 | 112 | sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query()) |
| 114 | 113 | if err != nil { |
| 115 | 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 | 116 | return |
| 118 | 117 | } |
| 119 | 118 | |
| @@ -124,7 +123,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 124 | 123 | user, err := s.dbs.Users.CreateUser(r.Context(), did) |
| 125 | 124 | if err != nil { |
| 126 | 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 | 127 | return |
| 129 | 128 | } |
| 130 | 129 | |
| @@ -136,7 +135,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 136 | 135 | encoded, err := encodeSession(s.sessionKey, sessionData) |
| 137 | 136 | if err != nil { |
| 138 | 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 | 139 | return |
| 141 | 140 | } |
| 142 | 141 | |
| @@ -146,6 +145,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 146 | 145 | Path: "/", |
| 147 | 146 | MaxAge: 86400 * 30, |
| 148 | 147 | HttpOnly: true, |
| 148 | + Secure: s.secureCookies, | |
| 149 | 149 | SameSite: http.SameSiteLaxMode, |
| 150 | 150 | }) |
| 151 | 151 | |
| @@ -165,7 +165,7 @@ func (s *Server) pdsClientFromSession(sessData *oauth.ClientSessionData) *atprot | ||
| 165 | 165 | |
| 166 | 166 | func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { |
| 167 | 167 | if s.clientID == "" { |
| 168 | - http.Error(w, "localhost client", http.StatusNotFound) | |
| 168 | + writeAPIError(w, http.StatusNotFound, "localhost client") | |
| 169 | 169 | return |
| 170 | 170 | } |
| 171 | 171 | |
| @@ -188,5 +188,5 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { | ||
| 188 | 188 | } |
| 189 | 189 | } |
| 190 | 190 | s.clearUserSession(w) |
| 191 | - http.Redirect(w, r, "/", http.StatusSeeOther) | |
| 191 | + writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"}) | |
| 192 | 192 | } |
| @@ -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 | return | 26 | 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 | return | 35 | 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 | return | 42 | 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 | return | 52 | 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 | return | 62 | 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 | return | 67 | 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 | return | 71 | 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 | return | 90 | 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 | return | 97 | 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 | return | 104 | 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 | return | 116 | 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 | return | 127 | 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 | return | 139 | 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 | return | 169 | 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 | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | - "database/sql" | |
| 6 | 5 | "net/http" |
| 7 | - "strings" | |
| 8 | 6 | "time" |
| 9 | 7 | |
| 10 | 8 | "golang.org/x/sync/errgroup" |
| 11 | 9 | |
| 12 | 10 | "pkg.rbrt.fr/glean/internal/atproto" |
| 13 | 11 | "pkg.rbrt.fr/glean/internal/cluster" |
| 14 | - "pkg.rbrt.fr/glean/internal/db" | |
| 15 | 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 | 19 | var ( |
| 23 | 20 | unreadCount int |
| 24 | 21 | 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 | |
| 29 | 26 | ) |
| 30 | 27 | |
| 31 | 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 | 48 | 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) | |
| 54 | 50 | if err != nil { |
| 55 | 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 | 58 | return nil |
| 63 | 59 | }) |
| 64 | 60 | |
| @@ -66,31 +62,42 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 66 | 62 | s.logger.Warn("dashboard error", "error", err, "did", user.DID) |
| 67 | 63 | } |
| 68 | 64 | |
| 69 | - var err error | |
| 70 | 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 | 67 | if err != nil { |
| 73 | 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 | 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 | 77 | if err != nil { |
| 78 | 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 | 86 | 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(), | |
| 94 | 101 | }) |
| 95 | 102 | } |
| 96 | 103 | |
| @@ -102,32 +109,23 @@ func (s *Server) handleArticleRecommendations(w http.ResponseWriter, r *http.Req | ||
| 102 | 109 | articleRecs, err := s.engine.GetArticleRecommendations(ctx, user.DID, userLangs, 5) |
| 103 | 110 | if err != nil { |
| 104 | 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 | 113 | return |
| 107 | 114 | } |
| 108 | 115 | |
| 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{ | |
| 117 | 119 | ID: rec.ArticleID, |
| 120 | + Title: rec.Title, | |
| 121 | + URL: rec.URL, | |
| 118 | 122 | FeedURL: rec.FeedURL, |
| 119 | 123 | 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 | + }) | |
| 131 | 129 | } |
| 132 | 130 | |
| 133 | 131 | 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 { | |
| 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}) | |
| 160 | 142 | } |
| 161 | 143 | |
| 162 | 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 | 149 | feedRecs, err := s.engine.GetFeedRecommendations(ctx, user.DID, 5) |
| 168 | 150 | if err != nil { |
| 169 | 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 | 153 | 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 | 161 | var impressions []feedback.Impression |
| 175 | 162 | for _, rec := range feedRecs { |
| 176 | 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 { | |
| 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 | + }) | |
| 202 | 175 | } |
| 203 | 176 | |
| 204 | 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 | 181 | peopleRecs, err := s.engine.GetPeopleRecommendations(ctx, user.DID, 6) |
| 209 | 182 | if err != nil { |
| 210 | 183 | 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{}) | |
| 217 | 185 | return |
| 218 | 186 | } |
| 219 | 187 | |
| 220 | 188 | resolvePeopleHandles(ctx, peopleRecs) |
| 221 | 189 | |
| 222 | - var followedPeople, discoverPeople []*cluster.PersonRecommendation | |
| 190 | + followed := make([]PersonRecommendation, 0, len(peopleRecs)) | |
| 191 | + discover := make([]PersonRecommendation, 0, len(peopleRecs)) | |
| 223 | 192 | for _, p := range peopleRecs { |
| 224 | 193 | if p.IsFollowed { |
| 225 | - followedPeople = append(followedPeople, p) | |
| 194 | + followed = append(followed, toPersonRecommendation(p)) | |
| 226 | 195 | } 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)) | |
| 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 | 206 | 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 int | 20 | unreadCount int |
| 24 | subCount int | 21 | subCount int |
| 25 | - userLangs []string | 22 | + articles = make([]Article, 0, 5) |
| 26 | - articles []*db.Article | 23 | + personalTrending = make([]TrendingItem, 0, 5) |
| 27 | - personalTrending []*db.TrendingItem | 24 | + globalTrending = make([]TrendingItem, 0, 5) |
| 28 | - globalTrending []*db.TrendingItem | 25 | + 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 error | 49 | + 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 nil | 58 | 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.DigestEnabled | 87 | + 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 | return | 113 | 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 | - return | 118 | + 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.Impression | 131 | 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 | return | 153 | 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.Impression | 161 | 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 | return | 185 | return |
| 218 | } | 186 | } |
| 219 | 187 | ||
| 220 | resolvePeopleHandles(ctx, peopleRecs) | 188 | resolvePeopleHandles(ctx, peopleRecs) |
| 221 | 189 | ||
| 222 | - var followedPeople, discoverPeople []*cluster.PersonRecommendation | 190 | + 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 ( | ||
| 17 | 17 | ) |
| 18 | 18 | |
| 19 | 19 | 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"` | |
| 27 | 26 | } |
| 28 | 27 | |
| 29 | 28 | 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 | |
| 34 | 32 | } |
| 35 | 33 | |
| 36 | 34 | var ( |
| 37 | - digestCache sync.Map | |
| 38 | - digestFlight singleflight.Group | |
| 35 | + digestCache = sync.Map{} | |
| 36 | + digestFlight = singleflight.Group{} | |
| 39 | 37 | ) |
| 40 | 38 | |
| 41 | 39 | const digestTTL = 24 * time.Hour |
| 42 | 40 | |
| 43 | 41 | var refRe = regexp.MustCompile(`\[(\d+)\]`) |
| 44 | 42 | |
| 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 { | |
| 47 | 47 | n, err := strconv.Atoi(match[1 : len(match)-1]) |
| 48 | 48 | if err != nil || n < 1 || n > len(articles) { |
| 49 | 49 | return match |
| @@ -116,18 +116,17 @@ func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx | ||
| 116 | 116 | |
| 117 | 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 | 124 | 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(), | |
| 131 | 130 | } |
| 132 | 131 | } |
| 133 | 132 | |
| @@ -152,8 +151,8 @@ func digestArticleContent(a *db.Article) string { | ||
| 152 | 151 | return string(runes) |
| 153 | 152 | } |
| 154 | 153 | |
| 155 | -func excerptFromHTML(html string) string { | |
| 156 | - text := plainText(html) | |
| 154 | +func excerptFromHTML(htmlText string) string { | |
| 155 | + text := plainText(htmlText) | |
| 157 | 156 | if len(text) > 200 { |
| 158 | 157 | text = text[:200] |
| 159 | 158 | if idx := strings.LastIndex(text, ". "); idx > 0 { |
| @@ -193,10 +192,9 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) { | ||
| 193 | 192 | entry := cached.(*digestCacheEntry) |
| 194 | 193 | if time.Now().Before(entry.expiry) { |
| 195 | 194 | if entry.consumed { |
| 196 | - s.renderDigest(w, &digestCtx{Consumed: true}) | |
| 195 | + writeJSON(w, http.StatusOK, &digestCtx{ArticleIDs: []int64{}, Consumed: true}) | |
| 197 | 196 | } else { |
| 198 | - w.Header().Set("Content-Type", "text/html") | |
| 199 | - w.Write([]byte(entry.html)) | |
| 197 | + writeJSON(w, http.StatusOK, entry.data) | |
| 200 | 198 | } |
| 201 | 199 | return |
| 202 | 200 | } |
| @@ -208,25 +206,11 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) { | ||
| 208 | 206 | if d == nil { |
| 209 | 207 | 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 | 209 | 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), | |
| 227 | 212 | }) |
| 228 | - | |
| 229 | - return html, nil | |
| 213 | + return d, nil | |
| 230 | 214 | }) |
| 231 | 215 | |
| 232 | 216 | if result == nil { |
| @@ -234,26 +218,14 @@ func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) { | ||
| 234 | 218 | return |
| 235 | 219 | } |
| 236 | 220 | |
| 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)) | |
| 250 | 222 | } |
| 251 | 223 | |
| 252 | 224 | func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) { |
| 253 | 225 | user := currentUser(r) |
| 254 | 226 | |
| 255 | 227 | if err := r.ParseForm(); err != nil { |
| 256 | - http.Error(w, err.Error(), http.StatusBadRequest) | |
| 228 | + writeAPIError(w, http.StatusBadRequest, err.Error()) | |
| 257 | 229 | return |
| 258 | 230 | } |
| 259 | 231 | |
| @@ -273,7 +245,7 @@ func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) { | ||
| 273 | 245 | |
| 274 | 246 | if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil { |
| 275 | 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 | 249 | return |
| 278 | 250 | } |
| 279 | 251 | |
| @@ -282,6 +254,5 @@ func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) { | ||
| 282 | 254 | consumed: true, |
| 283 | 255 | }) |
| 284 | 256 | |
| 285 | - w.Header().Set(HXRefresh, "true") | |
| 286 | - s.renderDigest(w, &digestCtx{Consumed: true}) | |
| 257 | + writeJSON(w, http.StatusOK, &digestCtx{ArticleIDs: []int64{}, Consumed: true}) | |
| 287 | 258 | } |
| @@ -17,33 +17,33 @@ import ( | |||
| 17 | ) | 17 | ) |
| 18 | 18 | ||
| 19 | type digestCtx struct { | 19 | type digestCtx struct { |
| 20 | - Title string | 20 | + Title string `json:"title"` |
| 21 | - Summary string | 21 | + Summary string `json:"summary"` |
| 22 | - Excerpt string | 22 | + Excerpt string `json:"excerpt"` |
| 23 | - ArticleIDs []int64 | 23 | + ArticleIDs []int64 `json:"article_ids"` |
| 24 | - GeneratedAt time.Time | 24 | + GeneratedAt int64 `json:"generated_at"` |
| 25 | - CSRFToken string | 25 | + Consumed bool `json:"consumed"` |
| 26 | - Consumed bool | ||
| 27 | } | 26 | } |
| 28 | 27 | ||
| 29 | type digestCacheEntry struct { | 28 | type digestCacheEntry struct { |
| 30 | - html string | 29 | + data *digestCtx |
| 31 | - expiry time.Time | 30 | + expiry time.Time |
| 32 | - articleIDs []int64 | 31 | + consumed bool |
| 33 | - consumed bool | ||
| 34 | } | 32 | } |
| 35 | 33 | ||
| 36 | var ( | 34 | var ( |
| 37 | - digestCache sync.Map | 35 | + digestCache = sync.Map{} |
| 38 | - digestFlight singleflight.Group | 36 | + digestFlight = singleflight.Group{} |
| 39 | ) | 37 | ) |
| 40 | 38 | ||
| 41 | const digestTTL = 24 * time.Hour | 39 | 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 match | 49 | 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 | return | 199 | 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, nil | 207 | 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 | return | 218 | 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 | return | 229 | 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 | return | 249 | 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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "errors" |
| 6 | + "fmt" | |
| 6 | 7 | "net/http" |
| 7 | 8 | "net/url" |
| 8 | 9 | "time" |
| @@ -72,23 +73,35 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 72 | 73 | return nil |
| 73 | 74 | }) |
| 74 | 75 | |
| 75 | - if err := g.Wait(); err != nil { | |
| 76 | - s.logger.Warn("feeds error", "error", err, "did", user.DID) | |
| 77 | - } | |
| 76 | + _ = g.Wait() | |
| 78 | 77 | |
| 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, | |
| 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 | 105 | func (s *Server) storeFetchResult(ctx context.Context, feedURL, siteURL string, result *feed.ParseResult) { |
| 93 | 106 | if result == nil { |
| 94 | 107 | _ = s.dbs.Articles.MarkFeedFetched(ctx, feedURL) |
| @@ -115,22 +128,21 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 115 | 128 | category := r.FormValue("category") |
| 116 | 129 | |
| 117 | 130 | if feedURL == "" { |
| 118 | - http.Error(w, "url required", http.StatusBadRequest) | |
| 131 | + writeAPIError(w, http.StatusBadRequest, "url required") | |
| 119 | 132 | return |
| 120 | 133 | } |
| 121 | 134 | |
| 122 | 135 | 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()) | |
| 126 | 138 | 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 | 143 | if err != nil { |
| 132 | 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 | 146 | return |
| 135 | 147 | } |
| 136 | 148 | |
| @@ -141,7 +153,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 141 | 153 | } |
| 142 | 154 | |
| 143 | 155 | f := &db.Feed{ |
| 144 | - FeedURL: feedURL, | |
| 156 | + FeedURL: resolvedURL, | |
| 145 | 157 | Title: db.NullStr(feedTitle), |
| 146 | 158 | SiteURL: db.NullStr(result.Feed.SiteURL), |
| 147 | 159 | Description: db.NullStr(result.Feed.Description), |
| @@ -150,7 +162,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 150 | 162 | } |
| 151 | 163 | if err := s.dbs.Articles.UpsertFeed(r.Context(), f); err != nil { |
| 152 | 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 | 166 | return |
| 155 | 167 | } |
| 156 | 168 | |
| @@ -158,54 +170,60 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 158 | 170 | if client := s.pdsClientForUser(r); client != nil { |
| 159 | 171 | record := atproto.SubscriptionRecord{ |
| 160 | 172 | CreatedAt: time.Now().Format(time.RFC3339), |
| 161 | - FeedURL: feedURL, | |
| 173 | + FeedURL: resolvedURL, | |
| 162 | 174 | Title: feedTitle, |
| 163 | 175 | Category: category, |
| 164 | 176 | } |
| 165 | 177 | uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record) |
| 166 | 178 | if err != nil { |
| 167 | 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 | 181 | return |
| 170 | 182 | } |
| 171 | 183 | subURI = uri |
| 172 | 184 | 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 | 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 | 190 | return |
| 179 | 191 | } |
| 180 | 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 | 194 | 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 | 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 | 201 | s.logger.Warn("failed to mark impression acted", "error", err) |
| 190 | 202 | } |
| 191 | 203 | sig := s.engine.GetDominantSignal(s.engine.GetWeights(r.Context(), user.DID)) |
| 192 | 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 | 207 | if err != nil { |
| 196 | - s.logger.Warn("failed to get subscription", "error", err) | |
| 197 | 208 | w.WriteHeader(http.StatusNoContent) |
| 198 | 209 | return |
| 199 | 210 | } |
| 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 | |
| 209 | 227 | } |
| 210 | 228 | |
| 211 | 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 | 232 | category := r.FormValue("category") |
| 215 | 233 | |
| 216 | 234 | if feedURL == "" { |
| 217 | - http.Error(w, "url required", http.StatusBadRequest) | |
| 235 | + writeAPIError(w, http.StatusBadRequest, "url required") | |
| 218 | 236 | return |
| 219 | 237 | } |
| 220 | 238 | |
| 221 | 239 | sub, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL) |
| 222 | 240 | 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") | |
| 225 | 242 | return |
| 226 | 243 | } |
| 227 | 244 | |
| @@ -240,7 +257,7 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) { | ||
| 240 | 257 | _, newCID, putErr := client.PutRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey, record) |
| 241 | 258 | if putErr != nil { |
| 242 | 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 | 261 | return |
| 245 | 262 | } |
| 246 | 263 | cid = newCID |
| @@ -250,26 +267,16 @@ func (s *Server) handleEditFeed(w http.ResponseWriter, r *http.Request) { | ||
| 250 | 267 | |
| 251 | 268 | if err := s.dbs.Articles.UpdateSubscription(r.Context(), user.DID, feedURL, sub.FeedTitle, category, sub.URI.String, cid); err != nil { |
| 252 | 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 | 271 | return |
| 255 | 272 | } |
| 256 | 273 | |
| 257 | 274 | updated, err := s.dbs.Articles.GetSubscription(r.Context(), user.DID, feedURL) |
| 258 | 275 | if err != nil { |
| 259 | - s.logger.Warn("failed to get updated subscription", "error", err) | |
| 260 | 276 | w.WriteHeader(http.StatusNoContent) |
| 261 | 277 | return |
| 262 | 278 | } |
| 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)}) | |
| 273 | 280 | } |
| 274 | 281 | |
| 275 | 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 | 284 | feedURL := r.FormValue("url") |
| 278 | 285 | |
| 279 | 286 | if feedURL == "" { |
| 280 | - http.Error(w, "url required", http.StatusBadRequest) | |
| 287 | + writeAPIError(w, http.StatusBadRequest, "url required") | |
| 281 | 288 | return |
| 282 | 289 | } |
| 283 | 290 | |
| @@ -288,7 +295,7 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { | ||
| 288 | 295 | if ok { |
| 289 | 296 | if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil { |
| 290 | 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 | 299 | return |
| 293 | 300 | } |
| 294 | 301 | } |
| @@ -297,11 +304,11 @@ func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { | ||
| 297 | 304 | |
| 298 | 305 | if err := s.dbs.Articles.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil { |
| 299 | 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 | 308 | return |
| 302 | 309 | } |
| 303 | 310 | |
| 304 | - w.WriteHeader(http.StatusOK) | |
| 311 | + w.WriteHeader(http.StatusNoContent) | |
| 305 | 312 | } |
| 306 | 313 | |
| 307 | 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 | 325 | if ok { |
| 319 | 326 | if delErr := client.DeleteRecord(r.Context(), user.DID, parsed.Collection, parsed.RKey); delErr != nil { |
| 320 | 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 | 329 | return |
| 323 | 330 | } |
| 324 | 331 | } |
| @@ -328,26 +335,25 @@ func (s *Server) handleClearAllSubscriptions(w http.ResponseWriter, r *http.Requ | ||
| 328 | 335 | |
| 329 | 336 | if err := s.dbs.Articles.DeleteAllSubscriptions(r.Context(), user.DID); err != nil { |
| 330 | 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 | 339 | return |
| 333 | 340 | } |
| 334 | 341 | |
| 335 | - w.Header().Set(HXRedirect, "/feeds") | |
| 336 | - w.WriteHeader(http.StatusOK) | |
| 342 | + w.WriteHeader(http.StatusNoContent) | |
| 337 | 343 | } |
| 338 | 344 | |
| 339 | 345 | func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { |
| 340 | 346 | user := currentUser(r) |
| 341 | 347 | file, _, err := r.FormFile("opml") |
| 342 | 348 | if err != nil { |
| 343 | - http.Error(w, err.Error(), http.StatusBadRequest) | |
| 349 | + writeAPIError(w, http.StatusBadRequest, err.Error()) | |
| 344 | 350 | return |
| 345 | 351 | } |
| 346 | 352 | defer file.Close() |
| 347 | 353 | |
| 348 | 354 | opml, err := feed.ParseOPML(file) |
| 349 | 355 | if err != nil { |
| 350 | - http.Error(w, err.Error(), http.StatusBadRequest) | |
| 356 | + writeAPIError(w, http.StatusBadRequest, err.Error()) | |
| 351 | 357 | 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") | |
| 420 | - w.WriteHeader(http.StatusOK) | |
| 425 | + writeJSON(w, http.StatusOK, opmlUploadResponse{Added: added}) | |
| 421 | 426 | } |
| 422 | 427 | |
| 423 | 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 | 444 | data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions") |
| 440 | 445 | if err != nil { |
| 441 | - http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 446 | + writeAPIError(w, http.StatusInternalServerError, err.Error()) | |
| 442 | 447 | return |
| 443 | 448 | } |
| 444 | 449 | |
| @@ -454,10 +459,7 @@ func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { | ||
| 454 | 459 | if err != nil { |
| 455 | 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{ | |
| 458 | - "User": user, | |
| 459 | - "Subscriptions": subs, | |
| 460 | - }) | |
| 462 | + writeJSON(w, http.StatusOK, feedListResponse{Subscriptions: toSubscriptions(subs)}) | |
| 461 | 463 | } |
| 462 | 464 | |
| 463 | 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 | 473 | if err != nil { |
| 472 | 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{ | |
| 475 | - "User": user, | |
| 476 | - "Subscriptions": subs, | |
| 477 | - }) | |
| 476 | + writeJSON(w, http.StatusOK, feedListResponse{Subscriptions: toSubscriptions(subs)}) | |
| 478 | 477 | } |
| 479 | 478 | |
| 480 | 479 | func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) { |
| @@ -504,13 +503,13 @@ func (s *Server) refreshUserFeeds(ctx context.Context, userDID string) { | ||
| 504 | 503 | func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) { |
| 505 | 504 | feedURL := r.FormValue("url") |
| 506 | 505 | if feedURL == "" { |
| 507 | - http.Error(w, "url required", http.StatusBadRequest) | |
| 506 | + writeAPIError(w, http.StatusBadRequest, "url required") | |
| 508 | 507 | return |
| 509 | 508 | } |
| 510 | 509 | |
| 511 | 510 | f, err := s.dbs.Articles.GetFeed(r.Context(), feedURL) |
| 512 | 511 | if err != nil { |
| 513 | - http.Error(w, "feed not found", http.StatusNotFound) | |
| 512 | + writeAPIError(w, http.StatusNotFound, "feed not found") | |
| 514 | 513 | return |
| 515 | 514 | } |
| 516 | 515 | |
| @@ -522,13 +521,5 @@ func (s *Server) handleRetryFeed(w http.ResponseWriter, r *http.Request) { | ||
| 522 | 521 | if err != nil { |
| 523 | 522 | s.logger.Warn("failed to list dead feeds", "error", err, "did", user.DID) |
| 524 | 523 | } |
| 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)}) | |
| 534 | 525 | } |
| @@ -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 nil | 73 | 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 | return | 132 | 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 | return | 138 | 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 | return | 146 | 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 | return | 166 | 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 | return | 181 | return |
| 170 | } | 182 | } |
| 171 | subURI = uri | 183 | subURI = uri |
| 172 | subCID = cid | 184 | 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 | return | 190 | 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 | return | 194 | 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 | return | 209 | 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 | return | 236 | 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 | return | 242 | 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 | return | 261 | return |
| 245 | } | 262 | } |
| 246 | cid = newCID | 263 | 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 | return | 271 | 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 | return | 277 | 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 | return | 288 | 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 | return | 299 | 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 | return | 308 | 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 | return | 329 | 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 | return | 339 | 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 | return | 350 | 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 | return | 357 | 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 | return | 447 | 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 | return | 507 | 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 | return | 513 | 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 | ||
| 38 | 38 | 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 | 43 | func (s *Server) requireAuth(next http.Handler) http.Handler { |
| 42 | 44 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 43 | 45 | if currentUser(r) == nil { |
| 44 | - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) | |
| 46 | + writeAPIError(w, http.StatusUnauthorized, "authentication required") | |
| 45 | 47 | return |
| 46 | 48 | } |
| 47 | 49 | next.ServeHTTP(w, r) |
| @@ -56,6 +58,9 @@ func csrfToken() string { | ||
| 56 | 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 | 64 | func (s *Server) csrfMiddleware(next http.Handler) http.Handler { |
| 60 | 65 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 61 | 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 | 71 | Path: "/", |
| 67 | 72 | MaxAge: 86400, |
| 68 | 73 | HttpOnly: false, |
| 74 | + Secure: s.secureCookies, | |
| 69 | 75 | SameSite: http.SameSiteLaxMode, |
| 70 | 76 | }) |
| 71 | 77 | } |
| @@ -73,33 +79,40 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | ||
| 73 | 79 | return |
| 74 | 80 | } |
| 75 | 81 | |
| 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") | |
| 83 | 85 | return |
| 84 | 86 | } |
| 85 | 87 | |
| 86 | 88 | cookie, err := r.Cookie("glean_csrf") |
| 87 | 89 | if err != nil { |
| 88 | - http.Error(w, "missing csrf token", http.StatusForbidden) | |
| 90 | + writeAPIError(w, http.StatusForbidden, "missing csrf token") | |
| 89 | 91 | return |
| 90 | 92 | } |
| 91 | - formToken := r.FormValue("csrf_token") | |
| 93 | + formToken := r.Header.Get("X-CSRF-Token") | |
| 92 | 94 | if formToken == "" { |
| 93 | - formToken = r.Header.Get("X-CSRF-Token") | |
| 95 | + formToken = r.FormValue("csrf_token") | |
| 94 | 96 | } |
| 95 | 97 | if formToken == "" || formToken != cookie.Value { |
| 96 | - http.Error(w, "csrf mismatch", http.StatusForbidden) | |
| 98 | + writeAPIError(w, http.StatusForbidden, "csrf mismatch") | |
| 97 | 99 | return |
| 98 | 100 | } |
| 99 | 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 | 116 | func sameOrigin(origin, host string) bool { |
| 104 | 117 | u, err := url.Parse(origin) |
| 105 | 118 | if err != nil { |
| @@ -38,10 +38,12 @@ func (s *Server) isOAuthSessionValid(ctx context.Context, data *sessionData) boo | |||
| 38 | return err == nil | 38 | 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 | return | 47 | 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 | return | 79 | 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 | return | 85 | 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 | return | 91 | 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 | return | 99 | 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 ( | ||
| 8 | 8 | const defaultPageSize = 25 |
| 9 | 9 | |
| 10 | 10 | 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"` | |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | func pageFromRequest(r *http.Request, pageSize int) Pagination { |
| @@ -8,12 +8,12 @@ import ( | |||
| 8 | const defaultPageSize = 25 | 8 | const defaultPageSize = 25 |
| 9 | 9 | ||
| 10 | type Pagination struct { | 10 | type Pagination struct { |
| 11 | - Page int | 11 | + Page int `json:"page"` |
| 12 | - PageSize int | 12 | + PageSize int `json:"page_size"` |
| 13 | - HasPrev bool | 13 | + HasPrev bool `json:"has_prev"` |
| 14 | - HasNext bool | 14 | + HasNext bool `json:"has_next"` |
| 15 | - PrevPage int | 15 | + PrevPage int `json:"prev_page"` |
| 16 | - NextPage int | 16 | + 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 ( | ||
| 5 | 5 | "strings" |
| 6 | 6 | |
| 7 | 7 | "github.com/go-chi/chi/v5" |
| 8 | - "golang.org/x/sync/errgroup" | |
| 9 | 8 | |
| 10 | 9 | "pkg.rbrt.fr/glean/internal/atproto" |
| 11 | 10 | "pkg.rbrt.fr/glean/internal/db" |
| @@ -23,7 +22,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 23 | 22 | resolved, err := atproto.ResolveHandle(ctx, param) |
| 24 | 23 | if err != nil { |
| 25 | 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 | 26 | return |
| 28 | 27 | } |
| 29 | 28 | did = resolved |
| @@ -32,7 +31,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 32 | 31 | profileUser, err := s.dbs.Users.GetUser(ctx, did) |
| 33 | 32 | if err != nil { |
| 34 | 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 | 35 | return |
| 37 | 36 | } |
| 38 | 37 | |
| @@ -41,76 +40,38 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 41 | 40 | profileUser.DisplayName = p.DisplayName |
| 42 | 41 | 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 | 43 | user := currentUser(r) |
| 52 | 44 | |
| 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) | |
| 83 | 50 | |
| 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 | + } | |
| 88 | 56 | |
| 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, | |
| 97 | 68 | }) |
| 69 | +} | |
| 98 | 70 | |
| 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) | |
| 101 | 75 | } |
| 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 | |
| 116 | 77 | } |
| @@ -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 | return | 26 | return |
| 28 | } | 27 | } |
| 29 | did = resolved | 28 | 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 | return | 35 | return |
| 37 | } | 36 | } |
| 38 | 37 | ||
| @@ -41,76 +40,38 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | |||
| 41 | profileUser.DisplayName = p.DisplayName | 40 | profileUser.DisplayName = p.DisplayName |
| 42 | profileUser.AvatarURL = p.AvatarURL | 41 | 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 error | 48 | + 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 nil | 53 | + 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 error | 59 | + 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 nil | 64 | + 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 @@ | ||
| 1 | 1 | package server |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | - "bytes" | |
| 5 | 4 | "context" |
| 6 | 5 | "fmt" |
| 7 | - "html/template" | |
| 8 | 6 | "log/slog" |
| 9 | 7 | "net/http" |
| 10 | 8 | "net/url" |
| 11 | - "slices" | |
| 12 | 9 | "strconv" |
| 13 | 10 | "strings" |
| 14 | - "sync" | |
| 15 | 11 | "time" |
| 16 | 12 | |
| 17 | 13 | "github.com/go-chi/chi/v5" |
| @@ -30,11 +26,8 @@ import ( | ||
| 30 | 26 | "pkg.rbrt.fr/glean/internal/metrics" |
| 31 | 27 | "pkg.rbrt.fr/glean/internal/ml" |
| 32 | 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 | 31 | var oauthScopes = []string{ |
| 39 | 32 | "atproto", |
| 40 | 33 | "blob:*/*", |
| @@ -54,37 +47,29 @@ var oauthScopes = []string{ | ||
| 54 | 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 | 50 | 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 | |
| 83 | 68 | } |
| 84 | 69 | |
| 85 | 70 | func New( |
| 86 | 71 | dbs *db.Store, |
| 87 | - clientID, callbackURL, addr string, | |
| 72 | + clientID, callbackURL, frontendURL string, | |
| 88 | 73 | scheduler *feed.Scheduler, |
| 89 | 74 | fetcher *feed.Fetcher, |
| 90 | 75 | engine *cluster.Engine, |
| @@ -96,37 +81,47 @@ func New( | ||
| 96 | 81 | |
| 97 | 82 | var config oauth.ClientConfig |
| 98 | 83 | 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" | |
| 102 | 90 | } |
| 103 | - cbURL := fmt.Sprintf("http://%s/auth/callback", host) | |
| 91 | + cbURL := strings.TrimRight(origin, "/") + "/api/auth/callback" | |
| 104 | 92 | config = oauth.NewLocalhostConfig(cbURL, oauthScopes) |
| 105 | 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 | 101 | oauthClient := oauth.NewClientApp(&config, oauthStore) |
| 109 | 102 | |
| 110 | 103 | 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), | |
| 125 | 121 | } |
| 126 | 122 | |
| 127 | 123 | s.setupMiddleware() |
| 128 | 124 | s.setupRoutes() |
| 129 | - s.loadTemplates() | |
| 130 | 125 | |
| 131 | 126 | return s |
| 132 | 127 | } |
| @@ -137,10 +132,11 @@ func (s *Server) setupMiddleware() { | ||
| 137 | 132 | s.router.Use(middleware.Compress(5)) |
| 138 | 133 | s.router.Use(s.metricsMiddleware) |
| 139 | 134 | 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, | |
| 144 | 140 | })) |
| 145 | 141 | s.router.Use(s.sessionMiddleware) |
| 146 | 142 | s.router.Use(s.csrfMiddleware) |
| @@ -161,21 +157,26 @@ func (s *Server) metricsMiddleware(next http.Handler) http.Handler { | ||
| 161 | 157 | } |
| 162 | 158 | |
| 163 | 159 | 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/*" | |
| 166 | 165 | } |
| 167 | 166 | return p |
| 168 | 167 | } |
| 169 | 168 | |
| 170 | 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 | 175 | r.Use(s.requireAuth) |
| 175 | 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 | 180 | r.Use(s.requireAuth) |
| 180 | 181 | r.Get("/", s.handleFeeds) |
| 181 | 182 | r.Post("/add", s.handleAddFeed) |
| @@ -189,7 +190,7 @@ func (s *Server) setupRoutes() { | ||
| 189 | 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 | 194 | r.Use(s.requireAuth) |
| 194 | 195 | r.Get("/", s.handleArticles) |
| 195 | 196 | r.Get("/new-count", s.handleNewArticleCount) |
| @@ -201,23 +202,23 @@ func (s *Server) setupRoutes() { | ||
| 201 | 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 | 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 | 210 | r.Use(s.requireAuth) |
| 210 | 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 | 215 | r.Use(s.requireAuth) |
| 215 | 216 | r.Get("/", s.handleLibrary) |
| 216 | 217 | r.Post("/create", s.handleCreateAnnotation) |
| 217 | 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 | 222 | r.Use(s.requireAuth) |
| 222 | 223 | r.Get("/articles", s.handleArticleRecommendations) |
| 223 | 224 | r.Get("/feeds", s.handleFeedRecommendations) |
| @@ -227,191 +228,59 @@ func (s *Server) setupRoutes() { | ||
| 227 | 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 | 232 | r.Use(s.requireAuth) |
| 232 | 233 | r.Post("/languages/{code}", s.handleToggleLanguage) |
| 233 | 234 | r.Post("/expanded-view", s.handleToggleExpandedView) |
| 234 | 235 | r.Post("/digest-enabled", s.handleToggleDigestEnabled) |
| 235 | 236 | }) |
| 236 | 237 | |
| 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) | |
| 239 | 240 | |
| 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) | |
| 247 | 248 | |
| 248 | 249 | 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) | |
| 262 | 261 | } |
| 263 | 262 | |
| 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 | |
| 399 | 268 | } |
| 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)) | |
| 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 | 281 | func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client { |
| 409 | 282 | session := s.getSessionData(r) |
| 410 | - if session == nil { | |
| 411 | - return nil | |
| 412 | - } | |
| 413 | - | |
| 414 | - if session.SessionID == "" { | |
| 283 | + if session == nil || session.SessionID == "" { | |
| 415 | 284 | 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 | 331 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 573 | 332 | s.router.ServeHTTP(w, r) |
| 574 | 333 | } |
| 575 | 334 | |
| 576 | 335 | 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") | |
| 579 | 337 | } |
| 580 | 338 | |
| 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} | |
| 586 | 345 | } |
| 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"} | |
| 592 | 347 | } |
| 593 | 348 | |
| 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 | |
| 634 | 355 | } |
| 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 | |
| 637 | 359 | } |
| 638 | - if hasLLM, ok := data["HasLLM"]; ok { | |
| 639 | - baseData["HasLLM"] = hasLLM | |
| 360 | + if u.Scheme == "" { | |
| 361 | + u.Scheme = "https" | |
| 640 | 362 | } |
| 363 | + return u.Scheme + "://" + u.Host | |
| 641 | 364 | } |
| 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 "" | |
| 647 | 366 | } |
| @@ -1,17 +1,13 @@ | |||
| 1 | package server | 1 | 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.Store | 51 | + dbs *db.Store |
| 69 | - router *chi.Mux | 52 | + router *chi.Mux |
| 70 | - templates *template.Template | 53 | + logger *slog.Logger |
| 71 | - logger *slog.Logger | 54 | + oauth *oauth.ClientApp |
| 72 | - oauth *oauth.ClientApp | 55 | + oauthStore *db.OAuthStore |
| 73 | - oauthStore *db.OAuthStore | 56 | + fetcher *feed.Fetcher |
| 74 | - fetcher *feed.Fetcher | 57 | + scheduler *feed.Scheduler |
| 75 | - scheduler *feed.Scheduler | 58 | + engine *cluster.Engine |
| 76 | - engine *cluster.Engine | 59 | + feedback *feedback.Service |
| 77 | - feedback *feedback.Service | 60 | + scraper *scraper.Scraper |
| 78 | - scraper *scraper.Scraper | 61 | + llm ml.TextModel |
| 79 | - llm ml.TextModel | 62 | + clientID string |
| 80 | - clientID string | 63 | + callbackURL string |
| 81 | - callbackURL string | 64 | + frontendURL string |
| 82 | - sessionKey []byte | 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 | ||
| 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.ClientConfig | 82 | var config oauth.ClientConfig |
| 98 | if clientID == "" { | 83 | if clientID == "" { |
| 99 | - host := addr | 84 | + // 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" + host | 86 | + // /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 s | 126 | 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 p | 166 | 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 error | 270 | + 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 nil | 284 | 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 | - return | 343 | + 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 != nil | 354 | + 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"] = csrf | 357 | + if err != nil || u.Host == "" { |
| 358 | + continue | ||
| 637 | } | 359 | } |
| 638 | - if hasLLM, ok := data["HasLLM"]; ok { | 360 | + if u.Scheme == "" { |
| 639 | - baseData["HasLLM"] = hasLLM | 361 | + 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) { | ||
| 60 | 60 | Path: "/", |
| 61 | 61 | MaxAge: 86400 * 30, |
| 62 | 62 | HttpOnly: true, |
| 63 | + Secure: s.secureCookies, | |
| 63 | 64 | SameSite: http.SameSiteLaxMode, |
| 64 | 65 | }) |
| 65 | 66 | } |
| @@ -71,6 +72,7 @@ func (s *Server) clearUserSession(w http.ResponseWriter) { | ||
| 71 | 72 | Path: "/", |
| 72 | 73 | MaxAge: -1, |
| 73 | 74 | HttpOnly: true, |
| 75 | + Secure: s.secureCookies, | |
| 74 | 76 | SameSite: http.SameSiteLaxMode, |
| 75 | 77 | }) |
| 76 | 78 | } |
| @@ -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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | 5 | |
| 6 | + "github.com/go-chi/chi/v5" | |
| 7 | + | |
| 6 | 8 | "pkg.rbrt.fr/glean/internal/ml" |
| 7 | 9 | ) |
| 8 | 10 | |
| 9 | 11 | func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) { |
| 10 | 12 | user := currentUser(r) |
| 11 | 13 | |
| 12 | - lang := r.PathValue("code") | |
| 14 | + lang := chi.URLParam(r, "code") | |
| 13 | 15 | if lang == "" { |
| 14 | - http.Error(w, "missing language code", http.StatusBadRequest) | |
| 16 | + writeAPIError(w, http.StatusBadRequest, "missing language code") | |
| 15 | 17 | return |
| 16 | 18 | } |
| 17 | 19 | |
| @@ -20,14 +22,14 @@ func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) { | ||
| 20 | 22 | valid[known.Code] = true |
| 21 | 23 | } |
| 22 | 24 | if !valid[lang] { |
| 23 | - http.Error(w, "unknown language", http.StatusBadRequest) | |
| 25 | + writeAPIError(w, http.StatusBadRequest, "unknown language") | |
| 24 | 26 | return |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID) |
| 28 | 30 | if err != nil { |
| 29 | 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 | 33 | return |
| 32 | 34 | } |
| 33 | 35 | |
| @@ -46,19 +48,18 @@ func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) { | ||
| 46 | 48 | |
| 47 | 49 | if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil { |
| 48 | 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 | 52 | return |
| 51 | 53 | } |
| 52 | 54 | |
| 53 | - w.Header().Set(HXRedirect, "/profile/"+user.DID) | |
| 54 | - w.WriteHeader(http.StatusOK) | |
| 55 | + writeJSON(w, http.StatusOK, languagesResponse{Languages: nonNil(updated)}) | |
| 55 | 56 | } |
| 56 | 57 | |
| 57 | 58 | func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request) { |
| 58 | 59 | user := currentUser(r) |
| 59 | 60 | |
| 60 | 61 | if err := r.ParseForm(); err != nil { |
| 61 | - http.Error(w, err.Error(), http.StatusBadRequest) | |
| 62 | + writeAPIError(w, http.StatusBadRequest, err.Error()) | |
| 62 | 63 | return |
| 63 | 64 | } |
| 64 | 65 | |
| @@ -66,19 +67,18 @@ func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request | ||
| 66 | 67 | |
| 67 | 68 | if err := s.dbs.Users.SetExpandedView(r.Context(), user.DID, enabled); err != nil { |
| 68 | 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 | 71 | return |
| 71 | 72 | } |
| 72 | 73 | |
| 73 | - w.Header().Set("HX-Redirect", "/profile/"+user.DID) | |
| 74 | - w.WriteHeader(http.StatusOK) | |
| 74 | + writeJSON(w, http.StatusOK, expandedViewResponse{ExpandedView: enabled}) | |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | 77 | func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) { |
| 78 | 78 | user := currentUser(r) |
| 79 | 79 | |
| 80 | 80 | if err := r.ParseForm(); err != nil { |
| 81 | - http.Error(w, err.Error(), http.StatusBadRequest) | |
| 81 | + writeAPIError(w, http.StatusBadRequest, err.Error()) | |
| 82 | 82 | return |
| 83 | 83 | } |
| 84 | 84 | |
| @@ -86,10 +86,9 @@ func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Reques | ||
| 86 | 86 | |
| 87 | 87 | if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil { |
| 88 | 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 | 90 | return |
| 91 | 91 | } |
| 92 | 92 | |
| 93 | - w.Header().Set("HX-Redirect", "/profile/"+user.DID) | |
| 94 | - w.WriteHeader(http.StatusOK) | |
| 93 | + writeJSON(w, http.StatusOK, digestEnabledResponse{DigestEnabled: enabled}) | |
| 95 | 94 | } |
| @@ -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 | return | 17 | return |
| 16 | } | 18 | } |
| 17 | 19 | ||
| @@ -20,14 +22,14 @@ func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) { | |||
| 20 | valid[known.Code] = true | 22 | 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 | return | 26 | 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 | return | 33 | 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 | return | 52 | 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 | return | 63 | 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 | return | 71 | 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 | return | 82 | 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 | return | 90 | 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) { | ||
| 43 | 43 | |
| 44 | 44 | func (s *Server) baseURL() string { |
| 45 | 45 | if s.clientID == "" { |
| 46 | - return "http://localhost:8080" | |
| 46 | + return "http://localhost:3000" | |
| 47 | 47 | } |
| 48 | 48 | host := s.clientID |
| 49 | 49 | 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.clientID | 48 | 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 ( | ||
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | 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"` | |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 22 | func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { |
| 23 | + userObj := new(toUser(currentUser(r))) | |
| 23 | 24 | metrics, err := s.fetchMetrics() |
| 24 | 25 | if err != nil { |
| 25 | 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 | 28 | return |
| 28 | 29 | } |
| 29 | 30 | |
| 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, | |
| 34 | 34 | }) |
| 35 | 35 | } |
| 36 | 36 | |
| @@ -12,25 +12,25 @@ import ( | |||
| 12 | ) | 12 | ) |
| 13 | 13 | ||
| 14 | type metricFamily struct { | 14 | type metricFamily struct { |
| 15 | - Name string | 15 | + Name string `json:"name"` |
| 16 | - Type string | 16 | + Type string `json:"type"` |
| 17 | - Description string | 17 | + Description string `json:"description"` |
| 18 | - Labels map[string]string | 18 | + Labels map[string]string `json:"labels"` |
| 19 | - Value float64 | 19 | + 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 | return | 28 | 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 | -} | ||
modified
internal/server/trending_handler.go +16 -8 | @@ -14,7 +14,7 @@ func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | ||
| 14 | 14 | |
| 15 | 15 | scope := r.URL.Query().Get("scope") |
| 16 | 16 | if scope == scopeForMe && user == nil { |
| 17 | - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) | |
| 17 | + writeAPIError(w, http.StatusUnauthorized, "authentication required") | |
| 18 | 18 | return |
| 19 | 19 | } |
| 20 | 20 | if scope != scopeForMe { |
| @@ -50,12 +50,20 @@ func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | ||
| 50 | 50 | trending = trending[:page.PageSize] |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | - s.render(w, r, "trending.html", map[string]any{ | |
| 54 | - "User": user, | |
| 55 | - "Trending": trending, | |
| 56 | - "Scope": scope, | |
| 57 | - "Page": page, | |
| 58 | - "BaseURL": "/trending", | |
| 59 | - "QueryParams": buildQueryParams(map[string]string{"scope": scope}), | |
| 53 | + out := make([]TrendingItem, len(trending)) | |
| 54 | + for i, t := range trending { | |
| 55 | + out[i] = toTrendingItem(t) | |
| 56 | + } | |
| 57 | + | |
| 58 | + var userObj *User | |
| 59 | + if user != nil { | |
| 60 | + userObj = new(toUser(user)) | |
| 61 | + } | |
| 62 | + | |
| 63 | + writeJSON(w, http.StatusOK, trendingResponse{ | |
| 64 | + User: userObj, | |
| 65 | + Trending: out, | |
| 66 | + Scope: scope, | |
| 67 | + Pagination: page, | |
| 60 | 68 | }) |
| 61 | 69 | } |
| @@ -14,7 +14,7 @@ func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | |||
| 14 | 14 | ||
| 15 | scope := r.URL.Query().Get("scope") | 15 | scope := r.URL.Query().Get("scope") |
| 16 | if scope == scopeForMe && user == nil { | 16 | if scope == scopeForMe && user == nil { |
| 17 | - http.Redirect(w, r, "/auth/login", http.StatusSeeOther) | 17 | + writeAPIError(w, http.StatusUnauthorized, "authentication required") |
| 18 | return | 18 | return |
| 19 | } | 19 | } |
| 20 | if scope != scopeForMe { | 20 | if scope != scopeForMe { |
| @@ -50,12 +50,20 @@ func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | |||
| 50 | trending = trending[:page.PageSize] | 50 | trending = trending[:page.PageSize] |
| 51 | } | 51 | } |
| 52 | 52 | ||
| 53 | - s.render(w, r, "trending.html", map[string]any{ | 53 | + out := make([]TrendingItem, len(trending)) |
| 54 | - "User": user, | 54 | + for i, t := range trending { |
| 55 | - "Trending": trending, | 55 | + out[i] = toTrendingItem(t) |
| 56 | - "Scope": scope, | 56 | + } |
| 57 | - "Page": page, | 57 | + |
| 58 | - "BaseURL": "/trending", | 58 | + var userObj *User |
| 59 | - "QueryParams": buildQueryParams(map[string]string{"scope": scope}), | 59 | + if user != nil { |
| 60 | + userObj = new(toUser(user)) | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + writeJSON(w, http.StatusOK, trendingResponse{ | ||
| 64 | + User: userObj, | ||
| 65 | + Trending: out, | ||
| 66 | + Scope: scope, | ||
| 67 | + Pagination: page, | ||
| 60 | }) | 68 | }) |
| 61 | } | 69 | } |
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">·</span><span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 26 | - {{if .Feed}} | |
| 27 | - <span class="text-spot-muted">·</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">·</span><span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | ||
| 26 | - {{if .Feed}} | ||
| 27 | - <span class="text-spot-muted">·</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">← 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">← 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">© {{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 🇪🇺 · <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">© {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr" class="hover:text-spot-text transition">julien.rbrt.fr</a> · Made in Europe 🇪🇺 · <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, '"') + '" 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, '"') + '" 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">© {{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 🇪🇺 · <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">© {{now.Format "2006"}} <a href="https://bsky.app/profile/julien.rbrt.fr" class="hover:text-spot-text transition">julien.rbrt.fr</a> · Made in Europe 🇪🇺 · <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, '"') + '" 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, '"') + '" 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 — 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 · 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 · 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 · 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 · 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 & 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 — 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 — 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 · 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 · 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 · 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 · 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 & 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 — 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">« 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">« 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 »</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 »</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">« 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">« 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 »</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 »</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">« 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">« 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 »</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 »</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">« 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">« 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 »</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 »</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 "★" (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>·</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 "★" (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>·</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">·</span><span>{{.Author.String}}</span>{{end}}{{end}} | |
| 12 | - {{if .Published.Valid}}<span class="text-spot-muted">·</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">·</span><span>{{.Author.String}}</span>{{end}}{{end}} | ||
| 12 | - {{if .Published.Valid}}<span class="text-spot-muted">·</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">·</span><span>{{.Author.String}}</span>{{end}}{{end}} | |
| 12 | - {{if .Published.Valid}}<span class="text-spot-muted">·</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">×</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">·</span><span>{{.Author.String}}</span>{{end}}{{end}} | ||
| 12 | - {{if .Published.Valid}}<span class="text-spot-muted">·</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">×</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">·</span> | |
| 22 | - <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span> | |
| 23 | - <span class="text-spot-muted">·</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">·</span> | ||
| 22 | - <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span> | ||
| 23 | - <span class="text-spot-muted">·</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/icon-trending.html +0 -1 | deleted file mode 100644 | ||
| @@ -1 +0,0 @@ | ||
| 1 | -{{define "icon-trending"}}<path stroke-linecap="round" stroke-linejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>{{end}} | |
| deleted file mode 100644 | |||
| @@ -1 +0,0 @@ | |||
| 1 | -{{define "icon-trending"}}<path stroke-linecap="round" stroke-linejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>{{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">« 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">« 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 »</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 »</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">« 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">« 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 »</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 »</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/partials/trending-card.html +0 -21 | deleted file mode 100644 | ||
| @@ -1,21 +0,0 @@ | ||
| 1 | -{{define "trending-card.html"}} | |
| 2 | -<div class="bg-spot-surface rounded-xl px-4 sm:px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | |
| 3 | - <div class="flex items-start justify-between gap-4"> | |
| 4 | - <div class="min-w-0 flex-1"> | |
| 5 | - <a href="/articles/{{.ArticleID}}" class="font-semibold text-spot-text hover:text-spot-green transition text-[17px] leading-snug">{{.Title}}</a> | |
| 6 | - <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap"> | |
| 7 | - {{template "favicon" dict "src" .FaviconURL "size" "w-3.5 h-3.5"}} | |
| 8 | - <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a> | |
| 9 | - {{if .Author}}<span class="text-spot-muted">·</span><span>{{.Author}}</span>{{end}} | |
| 10 | - </div> | |
| 11 | - {{if .Summary}} | |
| 12 | - <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{plainText .Summary}}</p> | |
| 13 | - {{end}} | |
| 14 | - </div> | |
| 15 | - <div class="flex flex-col items-end shrink-0 gap-1"> | |
| 16 | - {{template "like-button.html" (dict "ID" .ArticleID "HasLiked" .HasLiked "LikeCount" .LikeCount)}} | |
| 17 | - <span title="Annotations" class="inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider"><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.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 0 1 .778-.332 48.294 48.294 0 0 0 5.83-.498c1.585-.233 2.708-1.626 2.708-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><span>{{.AnnotationCount}}</span></span> | |
| 18 | - </div> | |
| 19 | - </div> | |
| 20 | -</div> | |
| 21 | -{{end}} | |
| deleted file mode 100644 | |||
| @@ -1,21 +0,0 @@ | |||
| 1 | -{{define "trending-card.html"}} | ||
| 2 | -<div class="bg-spot-surface rounded-xl px-4 sm:px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | ||
| 3 | - <div class="flex items-start justify-between gap-4"> | ||
| 4 | - <div class="min-w-0 flex-1"> | ||
| 5 | - <a href="/articles/{{.ArticleID}}" class="font-semibold text-spot-text hover:text-spot-green transition text-[17px] leading-snug">{{.Title}}</a> | ||
| 6 | - <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5 flex-wrap"> | ||
| 7 | - {{template "favicon" dict "src" .FaviconURL "size" "w-3.5 h-3.5"}} | ||
| 8 | - <a href="/articles?feed={{.FeedURL}}" class="hover:text-spot-text transition font-medium">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</a> | ||
| 9 | - {{if .Author}}<span class="text-spot-muted">·</span><span>{{.Author}}</span>{{end}} | ||
| 10 | - </div> | ||
| 11 | - {{if .Summary}} | ||
| 12 | - <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{plainText .Summary}}</p> | ||
| 13 | - {{end}} | ||
| 14 | - </div> | ||
| 15 | - <div class="flex flex-col items-end shrink-0 gap-1"> | ||
| 16 | - {{template "like-button.html" (dict "ID" .ArticleID "HasLiked" .HasLiked "LikeCount" .LikeCount)}} | ||
| 17 | - <span title="Annotations" class="inline-flex items-center gap-1 text-[10px] text-spot-text uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-surface border border-spot-divider"><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.087.16 2.185.283 3.293.369V21l4.184-4.183a1.14 1.14 0 0 1 .778-.332 48.294 48.294 0 0 0 5.83-.498c1.585-.233 2.708-1.626 2.708-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><span>{{.AnnotationCount}}</span></span> | ||
| 18 | - </div> | ||
| 19 | - </div> | ||
| 20 | -</div> | ||
| 21 | -{{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}} | ||
deleted
internal/tmpl/trending.html +0 -31 | deleted file mode 100644 | ||
| @@ -1,31 +0,0 @@ | ||
| 1 | -{{define "trending.html"}} | |
| 2 | -{{if not .User}} | |
| 3 | -<div class="bg-spot-surface rounded-xl shadow-spot p-6 mb-6 flex items-center justify-between gap-4 flex-wrap"> | |
| 4 | - <p class="text-sm text-spot-text font-medium">Sign in to like, annotate, and get personalized recommendations.</p> | |
| 5 | - <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:brightness-110 transition shrink-0">Sign in</a> | |
| 6 | -</div> | |
| 7 | -{{end}} | |
| 8 | - | |
| 9 | -<div class="flex items-center justify-between mb-2 flex-wrap gap-2"> | |
| 10 | - <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Trending</h1> | |
| 11 | - <div class="flex gap-1.5"> | |
| 12 | - <a href="/trending?scope=all" | |
| 13 | - class="text-sm px-4 py-1.5 rounded-pill font-bold {{if ne .Scope "for-me"}}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> | |
| 14 | - <a href="/trending?scope=for-me" | |
| 15 | - class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq .Scope "for-me"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">For me</a> | |
| 16 | - </div> | |
| 17 | -</div> | |
| 18 | -<p class="text-sm text-spot-secondary mb-6"> | |
| 19 | - {{if eq .Scope "for-me"}}Trending articles from the past 7 days, from readers with similar tastes.{{else}}Most liked and discussed articles from the past 7 days across Glean.{{end}} | |
| 20 | -</p> | |
| 21 | - | |
| 22 | -<div id="trending-list" class="space-y-3"> | |
| 23 | - {{range .Trending}} | |
| 24 | - {{template "trending-card.html" .}} | |
| 25 | - {{else}} | |
| 26 | - {{template "empty-state.html" (dict "icon" "icon-trending" "title" "No trending articles yet" "subtitle" "Start liking and annotating to see trends.")}} | |
| 27 | - {{end}} | |
| 28 | -</div> | |
| 29 | - | |
| 30 | -{{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}} | |
| 31 | -{{end}} | |
| deleted file mode 100644 | |||
| @@ -1,31 +0,0 @@ | |||
| 1 | -{{define "trending.html"}} | ||
| 2 | -{{if not .User}} | ||
| 3 | -<div class="bg-spot-surface rounded-xl shadow-spot p-6 mb-6 flex items-center justify-between gap-4 flex-wrap"> | ||
| 4 | - <p class="text-sm text-spot-text font-medium">Sign in to like, annotate, and get personalized recommendations.</p> | ||
| 5 | - <a href="/auth/login" class="bg-spot-green text-white rounded-pill px-5 py-2 text-xs font-bold uppercase tracking-button hover:brightness-110 transition shrink-0">Sign in</a> | ||
| 6 | -</div> | ||
| 7 | -{{end}} | ||
| 8 | - | ||
| 9 | -<div class="flex items-center justify-between mb-2 flex-wrap gap-2"> | ||
| 10 | - <h1 class="text-2xl font-bold text-spot-text" style="letter-spacing: -0.02em;">Trending</h1> | ||
| 11 | - <div class="flex gap-1.5"> | ||
| 12 | - <a href="/trending?scope=all" | ||
| 13 | - class="text-sm px-4 py-1.5 rounded-pill font-bold {{if ne .Scope "for-me"}}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> | ||
| 14 | - <a href="/trending?scope=for-me" | ||
| 15 | - class="text-sm px-4 py-1.5 rounded-pill font-bold {{if eq .Scope "for-me"}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">For me</a> | ||
| 16 | - </div> | ||
| 17 | -</div> | ||
| 18 | -<p class="text-sm text-spot-secondary mb-6"> | ||
| 19 | - {{if eq .Scope "for-me"}}Trending articles from the past 7 days, from readers with similar tastes.{{else}}Most liked and discussed articles from the past 7 days across Glean.{{end}} | ||
| 20 | -</p> | ||
| 21 | - | ||
| 22 | -<div id="trending-list" class="space-y-3"> | ||
| 23 | - {{range .Trending}} | ||
| 24 | - {{template "trending-card.html" .}} | ||
| 25 | - {{else}} | ||
| 26 | - {{template "empty-state.html" (dict "icon" "icon-trending" "title" "No trending articles yet" "subtitle" "Start liking and annotating to see trends.")}} | ||
| 27 | - {{end}} | ||
| 28 | -</div> | ||
| 29 | - | ||
| 30 | -{{template "pagination.html" (dict "HasPrev" .Page.HasPrev "HasNext" .Page.HasNext "PrevPage" .Page.PrevPage "NextPage" .Page.NextPage "Page" .Page.Page "BaseURL" .BaseURL "QueryParams" .QueryParams)}} | ||
| 31 | -{{end}} | ||
modified
main.go +2 -1 | @@ -70,6 +70,7 @@ func main() { | ||
| 70 | 70 | |
| 71 | 71 | clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") |
| 72 | 72 | callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") |
| 73 | + frontendURL := envOr("GLEAN_FRONTEND_URL", "http://localhost:3000") | |
| 73 | 74 | |
| 74 | 75 | storeAdapter := db.NewFeedAdapter(dbs.Articles) |
| 75 | 76 | siteFetcher := atproto.NewStandardSiteFetcher(logger) |
| @@ -104,7 +105,7 @@ func main() { | ||
| 104 | 105 | engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig()) |
| 105 | 106 | |
| 106 | 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 | 110 | cron := cluster.NewCron(engine, *clusterInterval, logger, dbs) |
| 110 | 111 | |
| @@ -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 | ||
| 36 | 36 | ### Docker |
| 37 | 37 | |
| 38 | 38 | ```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 | |
| 40 | 40 | ``` |
| 41 | 41 | |
| 42 | 42 | ### 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 | 48 | ```bash |
| 45 | 49 | git clone https://github.com/anomalyco/glean.git |
| 46 | 50 | 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) | |
| 49 | 60 | ``` |
| 50 | 61 | |
| 51 | -Then open `http://localhost:8080`. | |
| 62 | +Then open `http://localhost:3000`. | |
| 52 | 63 | |
| 53 | 64 | ## Configuration |
| 54 | 65 | |
| 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) | | |
| 77 | 89 | |
| 78 | 90 | For production: |
| 79 | 91 | |
| 80 | 92 | ```bash |
| 81 | 93 | 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 | |
| 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 | 100 | ## Documentation |
| 86 | 101 | |
| 87 | 102 | - [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 | 105 | ## Stack |
| 91 | 106 | |
| 92 | -Go, SQLite, htmx, TailwindCSS, AT Protocol OAuth. | |
| 107 | +Go, SQLite, SvelteKit (SSR), TailwindCSS, AT Protocol OAuth. | |
| 93 | 108 | |
| 94 | 109 | ## License |
| 95 | 110 | |
| @@ -36,52 +36,67 @@ The system improves over time: as you subscribe to feeds and like articles, Glea | |||
| 36 | ### Docker | 36 | ### Docker |
| 37 | 37 | ||
| 38 | ```bash | 38 | ```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 |
| 40 | ``` | 40 | ``` |
| 41 | 41 | ||
| 42 | ### From source | 42 | ### 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 | ```bash | 48 | ```bash |
| 45 | git clone https://github.com/anomalyco/glean.git | 49 | git clone https://github.com/anomalyco/glean.git |
| 46 | cd glean | 50 | cd glean |
| 47 | -make build | 51 | +make web-install # install frontend deps (bun) |
| 48 | -./glean | 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) | ||
| 49 | ``` | 60 | ``` |
| 50 | 61 | ||
| 51 | -Then open `http://localhost:8080`. | 62 | +Then open `http://localhost:3000`. |
| 52 | 63 | ||
| 53 | ## Configuration | 64 | ## 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 | ```bash | 92 | ```bash |
| 81 | export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata | 93 | 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 |
| 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 | ## Documentation | 100 | ## Documentation |
| 86 | 101 | ||
| 87 | - [Technical specification](docs/specs.md) — architecture, database schema, AT Protocol lexicons, API endpoints, recommendations | 102 | - [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 | ## Stack | 105 | ## Stack |
| 91 | 106 | ||
| 92 | -Go, SQLite, htmx, TailwindCSS, AT Protocol OAuth. | 107 | +Go, SQLite, SvelteKit (SSR), TailwindCSS, AT Protocol OAuth. |
| 93 | 108 | ||
| 94 | ## License | 109 | ## 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 →</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 →</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">← Prev</a> | |
| 28 | + {:else} | |
| 29 | + <span class="btn opacity-30 cursor-not-allowed">← 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 →</a> | |
| 36 | + {:else} | |
| 37 | + <span class="btn opacity-30 cursor-not-allowed">Next →</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">← Prev</a> | ||
| 28 | + {:else} | ||
| 29 | + <span class="btn opacity-30 cursor-not-allowed">← 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 →</a> | ||
| 36 | + {:else} | ||
| 37 | + <span class="btn opacity-30 cursor-not-allowed">Next →</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(/&/g, '&') | |
| 27 | + .replace(/</g, '<') | |
| 28 | + .replace(/>/g, '>') | |
| 29 | + .replace(/"/g, '"') | |
| 30 | + .replace(/'/g, "'") | |
| 31 | + .replace(/'/g, "'") | |
| 32 | + .replace(/ /g, ' ') | |
| 33 | + .replace(/…/g, '...') | |
| 34 | + .replace(/—/g, '—') | |
| 35 | + .replace(/–/g, '–') | |
| 36 | + .replace(/’/g, '’') | |
| 37 | + .replace(/‘/g, '‘') | |
| 38 | + .replace(/”/g, '"') | |
| 39 | + .replace(/“/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(/&/g, '&') | ||
| 27 | + .replace(/</g, '<') | ||
| 28 | + .replace(/>/g, '>') | ||
| 29 | + .replace(/"/g, '"') | ||
| 30 | + .replace(/'/g, "'") | ||
| 31 | + .replace(/'/g, "'") | ||
| 32 | + .replace(/ /g, ' ') | ||
| 33 | + .replace(/…/g, '...') | ||
| 34 | + .replace(/—/g, '—') | ||
| 35 | + .replace(/–/g, '–') | ||
| 36 | + .replace(/’/g, '’') | ||
| 37 | + .replace(/‘/g, '‘') | ||
| 38 | + .replace(/”/g, '"') | ||
| 39 | + .replace(/“/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>© {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>© {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 | + >← Prev</a | |
| 58 | + > | |
| 59 | + {:else} | |
| 60 | + <span class="btn opacity-30 cursor-not-allowed" | |
| 61 | + >← 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 →</a | |
| 70 | + > | |
| 71 | + {:else} | |
| 72 | + <span class="btn opacity-30 cursor-not-allowed" | |
| 73 | + >Next →</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 | + >← Prev</a | |
| 107 | + > | |
| 108 | + {:else} | |
| 109 | + <span class="btn opacity-30 cursor-not-allowed" | |
| 110 | + >← 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 →</a | |
| 119 | + > | |
| 120 | + {:else} | |
| 121 | + <span class="btn opacity-30 cursor-not-allowed" | |
| 122 | + >Next →</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 | + >← Prev</a | ||
| 58 | + > | ||
| 59 | + {:else} | ||
| 60 | + <span class="btn opacity-30 cursor-not-allowed" | ||
| 61 | + >← 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 →</a | ||
| 70 | + > | ||
| 71 | + {:else} | ||
| 72 | + <span class="btn opacity-30 cursor-not-allowed" | ||
| 73 | + >Next →</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 | + >← Prev</a | ||
| 107 | + > | ||
| 108 | + {:else} | ||
| 109 | + <span class="btn opacity-30 cursor-not-allowed" | ||
| 110 | + >← 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 →</a | ||
| 119 | + > | ||
| 120 | + {:else} | ||
| 121 | + <span class="btn opacity-30 cursor-not-allowed" | ||
| 122 | + >Next →</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>←</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>←</span> | ||
| 143 | + Back to Glean | ||
| 144 | + </a> | ||
| 145 | + </div> | ||
| 146 | +</div> | ||
added
web/src/routes/trending/+page.server.ts +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 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 | + const scope = event.url.searchParams.get("scope") ?? "all"; | |
| 8 | + if (scope === "for-me" && !user) throw redirect(303, "/auth/login"); | |
| 9 | + const page = event.url.searchParams.get("page"); | |
| 10 | + const params: Record<string, string> = { scope }; | |
| 11 | + if (page) params.page = page; | |
| 12 | + return await endpointsFor(event.fetch).trending(params); | |
| 13 | +}; | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 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 | + const scope = event.url.searchParams.get("scope") ?? "all"; | ||
| 8 | + if (scope === "for-me" && !user) throw redirect(303, "/auth/login"); | ||
| 9 | + const page = event.url.searchParams.get("page"); | ||
| 10 | + const params: Record<string, string> = { scope }; | ||
| 11 | + if (page) params.page = page; | ||
| 12 | + return await endpointsFor(event.fetch).trending(params); | ||
| 13 | +}; | ||
added
web/src/routes/trending/+page.svelte +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +<script lang="ts"> | |
| 2 | + import type { PageData } from "./$types"; | |
| 3 | + import TrendingCard from "$lib/components/TrendingCard.svelte"; | |
| 4 | + import Pagination from "$lib/components/Pagination.svelte"; | |
| 5 | + import EmptyState from "$lib/components/EmptyState.svelte"; | |
| 6 | + | |
| 7 | + let { data }: { data: PageData } = $props(); | |
| 8 | + | |
| 9 | + const scope = $derived(data.scope); | |
| 10 | +</script> | |
| 11 | + | |
| 12 | +{#if !data.user} | |
| 13 | + <div | |
| 14 | + class="panel mb-6 flex items-center justify-between gap-4 p-4 flex-wrap" | |
| 15 | + > | |
| 16 | + <p class="text-sm font-bold uppercase tracking-wide"> | |
| 17 | + Sign in to like, annotate, and get personalized recommendations. | |
| 18 | + </p> | |
| 19 | + <a href="/auth/login" class="btn btn-accent shrink-0">Sign in</a> | |
| 20 | + </div> | |
| 21 | +{/if} | |
| 22 | + | |
| 23 | +<div class="flex items-center justify-between mb-2 flex-wrap gap-2"> | |
| 24 | + <h1 class="text-2xl font-extrabold uppercase tracking-tight">Trending</h1> | |
| 25 | + <div class="flex gap-2"> | |
| 26 | + <a | |
| 27 | + href="/trending?scope=all" | |
| 28 | + class="chip panel-press" | |
| 29 | + data-active={scope !== "for-me" ? "true" : "false"}>All</a | |
| 30 | + > | |
| 31 | + <a | |
| 32 | + href="/trending?scope=for-me" | |
| 33 | + class="chip panel-press" | |
| 34 | + data-active={scope === "for-me" ? "true" : "false"}>For me</a | |
| 35 | + > | |
| 36 | + </div> | |
| 37 | +</div> | |
| 38 | +<p class="mb-6 text-xs uppercase tracking-widest text-[var(--muted)]"> | |
| 39 | + {scope === "for-me" | |
| 40 | + ? "Trending articles from the past 7 days, from readers with similar tastes." | |
| 41 | + : "Most liked and discussed articles from the past 7 days across Glean."} | |
| 42 | +</p> | |
| 43 | + | |
| 44 | +<div class="space-y-3"> | |
| 45 | + {#if data.trending.length === 0} | |
| 46 | + <EmptyState | |
| 47 | + icon="trending" | |
| 48 | + title="No trending articles yet" | |
| 49 | + subtitle="Start liking and annotating to see trends." | |
| 50 | + /> | |
| 51 | + {:else} | |
| 52 | + {#each data.trending as t (t.article_id)} | |
| 53 | + <TrendingCard item={t} /> | |
| 54 | + {/each} | |
| 55 | + {/if} | |
| 56 | +</div> | |
| 57 | + | |
| 58 | +<Pagination page={data.pagination} base="/trending" params={{ scope }} /> | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +<script lang="ts"> | ||
| 2 | + import type { PageData } from "./$types"; | ||
| 3 | + import TrendingCard from "$lib/components/TrendingCard.svelte"; | ||
| 4 | + import Pagination from "$lib/components/Pagination.svelte"; | ||
| 5 | + import EmptyState from "$lib/components/EmptyState.svelte"; | ||
| 6 | + | ||
| 7 | + let { data }: { data: PageData } = $props(); | ||
| 8 | + | ||
| 9 | + const scope = $derived(data.scope); | ||
| 10 | +</script> | ||
| 11 | + | ||
| 12 | +{#if !data.user} | ||
| 13 | + <div | ||
| 14 | + class="panel mb-6 flex items-center justify-between gap-4 p-4 flex-wrap" | ||
| 15 | + > | ||
| 16 | + <p class="text-sm font-bold uppercase tracking-wide"> | ||
| 17 | + Sign in to like, annotate, and get personalized recommendations. | ||
| 18 | + </p> | ||
| 19 | + <a href="/auth/login" class="btn btn-accent shrink-0">Sign in</a> | ||
| 20 | + </div> | ||
| 21 | +{/if} | ||
| 22 | + | ||
| 23 | +<div class="flex items-center justify-between mb-2 flex-wrap gap-2"> | ||
| 24 | + <h1 class="text-2xl font-extrabold uppercase tracking-tight">Trending</h1> | ||
| 25 | + <div class="flex gap-2"> | ||
| 26 | + <a | ||
| 27 | + href="/trending?scope=all" | ||
| 28 | + class="chip panel-press" | ||
| 29 | + data-active={scope !== "for-me" ? "true" : "false"}>All</a | ||
| 30 | + > | ||
| 31 | + <a | ||
| 32 | + href="/trending?scope=for-me" | ||
| 33 | + class="chip panel-press" | ||
| 34 | + data-active={scope === "for-me" ? "true" : "false"}>For me</a | ||
| 35 | + > | ||
| 36 | + </div> | ||
| 37 | +</div> | ||
| 38 | +<p class="mb-6 text-xs uppercase tracking-widest text-[var(--muted)]"> | ||
| 39 | + {scope === "for-me" | ||
| 40 | + ? "Trending articles from the past 7 days, from readers with similar tastes." | ||
| 41 | + : "Most liked and discussed articles from the past 7 days across Glean."} | ||
| 42 | +</p> | ||
| 43 | + | ||
| 44 | +<div class="space-y-3"> | ||
| 45 | + {#if data.trending.length === 0} | ||
| 46 | + <EmptyState | ||
| 47 | + icon="trending" | ||
| 48 | + title="No trending articles yet" | ||
| 49 | + subtitle="Start liking and annotating to see trends." | ||
| 50 | + /> | ||
| 51 | + {:else} | ||
| 52 | + {#each data.trending as t (t.article_id)} | ||
| 53 | + <TrendingCard item={t} /> | ||
| 54 | + {/each} | ||
| 55 | + {/if} | ||
| 56 | +</div> | ||
| 57 | + | ||
| 58 | +<Pagination page={data.pagination} base="/trending" params={{ scope }} /> | ||
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 @@ | ||
| 11 | 11 | "categories": ["news", "social", "productivity"], |
| 12 | 12 | "icons": [ |
| 13 | 13 | { |
| 14 | - "src": "/static/favicon.svg", | |
| 14 | + "src": "/favicon.svg", | |
| 15 | 15 | "sizes": "any", |
| 16 | 16 | "type": "image/svg+xml" |
| 17 | 17 | }, |
| 18 | 18 | { |
| 19 | - "src": "/static/icon-192.png", | |
| 19 | + "src": "/icon-192.png", | |
| 20 | 20 | "sizes": "192x192", |
| 21 | 21 | "type": "image/png" |
| 22 | 22 | }, |
| 23 | 23 | { |
| 24 | - "src": "/static/icon-512.png", | |
| 24 | + "src": "/icon-512.png", | |
| 25 | 25 | "sizes": "512x512", |
| 26 | 26 | "type": "image/png" |
| 27 | 27 | }, |
| 28 | 28 | { |
| 29 | - "src": "/static/icon-maskable-192.png", | |
| 29 | + "src": "/icon-maskable-192.png", | |
| 30 | 30 | "sizes": "192x192", |
| 31 | 31 | "type": "image/png", |
| 32 | 32 | "purpose": "maskable" |
| 33 | 33 | }, |
| 34 | 34 | { |
| 35 | - "src": "/static/icon-maskable-512.png", | |
| 35 | + "src": "/icon-maskable-512.png", | |
| 36 | 36 | "sizes": "512x512", |
| 37 | 37 | "type": "image/png", |
| 38 | 38 | "purpose": "maskable" |
| 39 | 39 | }, |
| 40 | 40 | { |
| 41 | - "src": "/static/apple-touch-icon.png", | |
| 41 | + "src": "/apple-touch-icon.png", | |
| 42 | 42 | "sizes": "180x180", |
| 43 | 43 | "type": "image/png" |
| 44 | 44 | } |
| 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 | +}); | ||