Compare commits

...

6 Commits

Author SHA1 Message Date
Admin
8c47aa3a11 fix: cover proxy routing, session filtering, library tab deep-link, profile UX
Some checks failed
Release / Test backend (push) Successful in 1m3s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 5m55s
Release / Deploy to prod (push) Failing after 48s
Release / Deploy to homelab (push) Successful in 21s
Release / Gitea Release (push) Successful in 29s
- Catalogue/cover: rewrite raw scraped cover URLs to /api/cover/{domain}/{slug}
  in handleCatalogue so all covers route through the backend proxy; fix broken
  cdn.novelfire.net fallback in handleGetCover to read stored URL from PocketBase
- Catalogue/profile: add Svelte 5 onerror handlers on cover <img> tags to show
  letter-initial placeholder when image fails to load
- Library page: read ?status URL param to initialise activeShelf tab on load so
  /books?status=reading correctly pre-selects the Reading tab
- Sessions: filter bot/tool user-agents (curl, python, wget, etc.) and debug-IP
  sessions from listUserSessions display; also purge them in pruneStaleUserSessions
- Profile: show email under username, quick stats chips (streak/chapters/completed)
  in header, reading count on Library row, dedicated Sign out row, history covers
  routed through /api/cover proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 13:32:48 +05:00
Admin
1f987be75a feat: optimize prod deployment to avoid unnecessary container restarts
Some checks failed
Release / Test backend (push) Successful in 1m7s
Release / Test UI (push) Successful in 1m29s
Release / Build and push images (push) Successful in 4m30s
Release / Deploy to prod (push) Failing after 19s
Release / Deploy to homelab (push) Successful in 13s
Release / Gitea Release (push) Successful in 27s
Previously: 'docker compose up -d' recreated all services with changed images,
causing dependent services (pocketbase, minio, redis, etc.) to restart and
wait for healthchecks, leading to longer downtime.

Now: Use '--no-deps' flag to restart ONLY the services with updated images
(backend, runner, ui, caddy, pocketbase) without touching their dependencies.

Benefits:
- Faster deployments (~15-20s vs ~60s)
- No unnecessary restarts of infrastructure services
- Reduced downtime for the application

The final 'docker compose up -d --remove-orphans' ensures any orphaned
containers are cleaned up and all services are in the desired state.
2026-04-16 21:51:42 +05:00
Admin
7a4008bd9c chore: improve workflow job names for clarity
All checks were successful
Release / Test backend (push) Successful in 1m2s
Release / Test UI (push) Successful in 58s
Release / Build and push images (push) Successful in 4m34s
Release / Deploy to prod (push) Successful in 2m24s
Release / Deploy to homelab (push) Successful in 15s
Release / Gitea Release (push) Successful in 20s
- 'Check ui' → 'Test UI' (consistent with 'Test backend')
- 'Docker' → 'Build and push images' (more descriptive of what it does)

Job IDs remain unchanged (test-backend, check-ui, docker) for stability.
2026-04-16 21:34:23 +05:00
Admin
f4834f968a fix: disable strict host key checking for homelab SSH
Some checks failed
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 1m0s
Release / Docker (push) Failing after 2m52s
Release / Deploy to prod (push) Has been skipped
Release / Deploy to homelab (push) Has been skipped
Release / Gitea Release (push) Has been skipped
Homelab is on private network (192.168.0.109), so we can safely disable
strict host key checking. This avoids the complexity of managing known_hosts
entries in Gitea secrets.

Changes:
- Remove HOMELAB_SSH_KNOWN_HOSTS requirement
- Add -o StrictHostKeyChecking=no to scp/ssh commands
- Add -o UserKnownHostsFile=/dev/null to avoid host key persistence
2026-04-16 21:23:59 +05:00
Admin
32ee3c302d chore: add .opencode/ to gitignore
Local OpenCode agent state (memory, node_modules) shouldn't be committed.
2026-04-16 20:34:05 +05:00
Admin
f5650a98ec chore: remove unused homelab/runner directory
We use homelab/docker-compose.yml (full stack) for the homelab deployment,
not homelab/runner/docker-compose.yml (runner-only subset). Removing the
unused directory to prevent confusion.
2026-04-16 20:25:37 +05:00
8 changed files with 154 additions and 137 deletions

View File

@@ -32,7 +32,7 @@ jobs:
# ── ui: type-check & build ────────────────────────────────────────────────────
check-ui:
name: Check ui
name: Test UI
runs-on: ubuntu-latest
defaults:
run:
@@ -57,7 +57,7 @@ jobs:
# ── docker: build + push all images via docker bake ──────────────────────────
docker:
name: Docker
name: Build and push images
runs-on: ubuntu-latest
needs: [test-backend, check-ui]
steps:
@@ -130,6 +130,8 @@ jobs:
'set -euo pipefail
cd /opt/libnovel
doppler run -- docker compose pull backend runner ui caddy pocketbase
# Restart only the services with new images, without waiting for dependencies
doppler run -- docker compose up -d --no-deps backend runner ui caddy pocketbase
doppler run -- docker compose up -d --remove-orphans'
# ── deploy homelab runner ─────────────────────────────────────────────────────
@@ -152,17 +154,20 @@ jobs:
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.HOMELAB_SSH_KEY }}" > ~/.ssh/homelab_key
chmod 600 ~/.ssh/homelab_key
printf '%s\n' "${{ secrets.HOMELAB_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Copy docker-compose.yml to homelab
run: |
scp -i ~/.ssh/homelab_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
homelab/docker-compose.yml \
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}:/opt/libnovel-runner/docker-compose.yml"
- name: Pull new runner image and restart
run: |
ssh -i ~/.ssh/homelab_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}" \
'set -euo pipefail
cd /opt/libnovel-runner

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ Thumbs.db
*.swp
*.swo
*~
.opencode/

View File

@@ -293,7 +293,8 @@ func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
// handleGetCover handles GET /api/cover/{domain}/{slug}.
// Serves the cover image directly from MinIO when available; falls back to a
// redirect to the novelfire CDN when the cover has not yet been downloaded.
// redirect to the stored cover URL from PocketBase when the cover has not yet
// been downloaded to MinIO.
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
@@ -318,10 +319,20 @@ func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
}
}
// Fallback: redirect to the CDN. The caller sees a working image; the
// cover will be populated on the next catalogue refresh run.
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
http.Redirect(w, r, coverURL, http.StatusFound)
// Fallback: read the stored cover URL from PocketBase and redirect to it.
// This avoids the broken cdn.novelfire.net domain and uses the actual URL
// scraped from the source. If the book is not found, return 404.
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
if err != nil {
s.deps.Log.Warn("handleGetCover: ReadMetadata error", "slug", slug, "err", err)
http.Error(w, "cover not found", http.StatusNotFound)
return
}
if !ok || meta.Cover == "" || strings.HasPrefix(meta.Cover, "/api/cover/") {
http.Error(w, "cover not found", http.StatusNotFound)
return
}
http.Redirect(w, r, meta.Cover, http.StatusFound)
}
// ── Preview (live scrape, no store writes) ─────────────────────────────────────
@@ -1891,6 +1902,16 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
return
}
// Rewrite raw scraped cover URLs to go through the backend cover proxy.
// /api/cover/{domain}/{slug} serves from MinIO when available, otherwise
// redirects to the CDN. This avoids ERR_BLOCKED_BY_ORB when the source
// site returns HTML error pages instead of images.
for i := range books {
if !strings.HasPrefix(books[i].Cover, "/api/cover/") {
books[i].Cover = fmt.Sprintf("/api/cover/novelfire.net/%s", books[i].Slug)
}
}
hasNext := int64(page*limit) < total
w.Header().Set("Cache-Control", "public, max-age=60")

View File

@@ -1,115 +0,0 @@
# LibNovel homelab runner
#
# Connects to production PocketBase and MinIO via public subdomains.
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
# Run with: doppler run -- docker compose up -d
#
# Differs from prod runner:
# - RUNNER_WORKER_ID=homelab-runner-1 (unique, avoids task claiming conflicts)
# - MINIO_ENDPOINT/USE_SSL → storage.libnovel.cc over HTTPS
# - POCKETBASE_URL → https://pb.libnovel.cc
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
# - VALKEY_ADDR → unset (not exposed publicly)
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
# - LibreTranslate service for machine translation (internal network only)
#
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
services:
libretranslate:
image: libretranslate/libretranslate:latest
restart: unless-stopped
environment:
LT_API_KEYS: "true"
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
# Limit to source→target pairs the runner actually uses
LT_LOAD_ONLY: "en,ru,id,pt,fr"
LT_DISABLE_WEB_UI: "true"
LT_UPDATE_MODELS: "false"
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
runner:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
# directly to Caddy on prod; TLS and SNI still work normally.
extra_hosts:
- "storage.libnovel.cc:165.22.70.138"
- "pb.libnovel.cc:165.22.70.138"
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# ── MinIO (S3 API via public subdomain) ─────────────────────────────────
MINIO_ENDPOINT: "storage.libnovel.cc"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
MINIO_USE_SSL: "true"
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
# ── Meilisearch (via search.libnovel.cc Caddy proxy) ────────────────────
MEILI_URL: "${MEILI_URL}"
MEILI_API_KEY: "${MEILI_API_KEY}"
VALKEY_ADDR: ""
# Force IPv4 DNS resolution — homelab has no IPv6 route to search.libnovel.cc
GODEBUG: "preferIPv4=1"
# ── Kokoro TTS ──────────────────────────────────────────────────────────
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
# ── Runner tuning ───────────────────────────────────────────────────────
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
# ── Observability ───────────────────────────────────────────────────────
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
healthcheck:
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
interval: 60s
timeout: 5s
retries: 3
volumes:
libretranslate_models:
libretranslate_db:

View File

@@ -1431,10 +1431,34 @@ export async function isSessionRevoked(authSessionId: string): Promise<boolean>
}
/**
* List all active sessions for a user.
* Returns true for user-agents that are clearly automated tools (curl, scrapers,
* debug logins, etc.) that should not appear in the user-facing sessions list.
* These sessions still exist in the DB so auth checks continue to work.
*/
function isBotUserAgent(ua: string): boolean {
if (!ua) return false;
const lower = ua.toLowerCase();
return (
lower.startsWith('curl/') ||
lower.startsWith('python') ||
lower.startsWith('wget/') ||
lower.startsWith('go-http-client') ||
lower.startsWith('axios/') ||
lower.startsWith('node-fetch') ||
lower.startsWith('undici') ||
lower.startsWith('okhttp') ||
lower.startsWith('java/')
);
}
/**
* List all active sessions for a user, excluding non-browser/tool sessions
* (curl, debug-login artifacts, scrapers, etc.) from the displayed list.
* The records still exist in the DB so auth validity checks are unaffected.
*/
export async function listUserSessions(userId: string): Promise<UserSession[]> {
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
return all.filter((s) => !isBotUserAgent(s.user_agent) && s.ip !== 'debug');
}
/**
@@ -1453,9 +1477,11 @@ async function pruneStaleUserSessions(
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const toDelete = new Set<string>();
// Mark stale sessions
// Mark stale sessions and debug/tool sessions for deletion
for (const s of all) {
if (s.last_seen < cutoff) toDelete.add(s.id);
if (s.last_seen < cutoff || s.ip === 'debug' || isBotUserAgent(s.user_agent)) {
toDelete.add(s.id);
}
}
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { untrack } from 'svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@@ -17,7 +19,22 @@
}
type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
let activeShelf = $state<Shelf | 'all'>('all');
// Map the ?status URL param to the internal shelf key so that links like
// /books?status=reading correctly pre-select the Reading tab.
function urlStatusToShelf(status: string | null): Shelf | 'all' {
switch (status) {
case 'reading': return '';
case 'plan_to_read': return 'plan_to_read';
case 'completed': return 'completed';
case 'dropped': return 'dropped';
default: return 'all';
}
}
let activeShelf = $state<Shelf | 'all'>(
untrack(() => urlStatusToShelf(page.url.searchParams.get('status')))
);
const shelfLabels: Record<string, string> = {
all: 'All',

View File

@@ -542,7 +542,12 @@
alt={novel.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
(img.nextElementSibling as HTMLElement | null)?.style.setProperty('display', 'flex');
}}
/><div class="w-full h-full absolute inset-0 items-center justify-center bg-(--color-surface-3)" style="display:none"><span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span></div>
{:else}
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
<span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
@@ -630,7 +635,18 @@
<!-- Cover thumbnail -->
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-(--color-surface) relative">
{#if novel.cover}
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
<img
src={novel.cover}
alt={novel.title}
class="w-full h-full object-cover"
loading="lazy"
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
const fb = img.parentElement?.querySelector('.cover-fallback') as HTMLElement | null;
if (fb) fb.style.display = 'flex';
}}
/><div class="cover-fallback w-full h-full absolute inset-0 items-center justify-center bg-(--color-surface-3)" style="display:none"><span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span></div>
{:else}
<div class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
<span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>

View File

@@ -427,7 +427,10 @@
<div class="min-w-0 flex-1">
<h1 class="text-xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
{#if data.email}
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{data.email}</p>
{/if}
<div class="flex items-center gap-2 mt-1.5 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
@@ -435,6 +438,29 @@
</span>
{/if}
</div>
<!-- Quick stats chips -->
{#if data.stats.totalChaptersRead > 0 || data.stats.streak > 0}
<div class="flex items-center gap-2 mt-2 flex-wrap">
{#if data.stats.streak > 0}
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
<svg class="w-3 h-3 text-orange-400" fill="currentColor" viewBox="0 0 24 24"><path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67z"/></svg>
{data.stats.streak}d streak
</span>
{/if}
{#if data.stats.totalChaptersRead > 0}
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
{data.stats.totalChaptersRead.toLocaleString()} chapters
</span>
{/if}
{#if data.stats.booksCompleted > 0}
<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) border border-(--color-border) text-(--color-muted)">
<svg class="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{data.stats.booksCompleted} completed
</span>
{/if}
</div>
{/if}
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
{/if}
@@ -455,6 +481,9 @@
</svg>
</span>
<span class="flex-1 text-sm font-medium text-(--color-text)">Library</span>
{#if data.stats.booksReading > 0}
<span class="text-xs text-(--color-muted) mr-2 hidden sm:inline">{data.stats.booksReading} reading</span>
{/if}
<svg class={chevronClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
@@ -550,12 +579,14 @@
href="/books/{item.slug}/chapters/{item.chapter}"
class="flex items-center gap-3 px-5 py-3 hover:bg-(--color-surface-3)/60 transition-colors group"
>
<div class="w-7 h-10 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0">
{#if item.cover}
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
<div class="w-7 h-10 rounded overflow-hidden bg-(--color-surface-3) flex-shrink-0 relative">
<img
src="/api/cover/novelfire.net/{item.slug}"
alt={item.title}
class="w-full h-full object-cover"
loading="lazy"
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-(--color-text) truncate group-hover:text-(--color-brand) transition-colors">{item.title}</p>
@@ -1103,6 +1134,21 @@
</div>
{/if}
<!-- Sign out -->
<form method="POST" action="/logout">
<button
type="submit"
class="w-full flex items-center gap-3.5 px-5 py-4 hover:bg-(--color-surface-3)/60 transition-colors group text-left"
>
<span class="shrink-0 w-8 h-8 rounded-lg bg-(--color-surface-3) border border-(--color-border) flex items-center justify-center">
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-danger) transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" 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>
</span>
<span class="flex-1 text-sm font-medium text-(--color-text) group-hover:text-(--color-danger) transition-colors">Sign out</span>
</button>
</form>
</div>
<!-- ── Danger zone ───────────────────────────────────────────────────────── -->