Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c47aa3a11 | ||
|
|
1f987be75a | ||
|
|
7a4008bd9c | ||
|
|
f4834f968a | ||
|
|
32ee3c302d | ||
|
|
f5650a98ec |
@@ -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
1
.gitignore
vendored
@@ -28,3 +28,4 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.opencode/
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────── -->
|
||||
|
||||
Reference in New Issue
Block a user