Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac81d6f29 | ||
|
|
3c5e5d007a | ||
|
|
8c47aa3a11 | ||
|
|
1f987be75a |
@@ -130,7 +130,7 @@ jobs:
|
||||
'set -euo pipefail
|
||||
cd /opt/libnovel
|
||||
doppler run -- docker compose pull backend runner ui caddy pocketbase
|
||||
doppler run -- docker compose up -d --remove-orphans'
|
||||
doppler run -- docker compose up -d --no-deps --remove-orphans backend runner ui caddy pocketbase'
|
||||
|
||||
# ── deploy homelab runner ─────────────────────────────────────────────────────
|
||||
# Syncs the homelab runner compose file and restarts the runner service.
|
||||
|
||||
225
DOCKERFILE_ANALYSIS.md
Normal file
225
DOCKERFILE_ANALYSIS.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Dockerfile Dependency Analysis
|
||||
|
||||
## Current Image Sizes
|
||||
|
||||
| Image | Size | Status |
|
||||
|-------|------|--------|
|
||||
| backend | 179MB | ✅ Good |
|
||||
| runner | 178MB | ✅ Good |
|
||||
| pocketbase | 37MB | ✅ Excellent |
|
||||
| caddy | 114MB | ✅ Good |
|
||||
| ui | **413MB** | ⚠️ **LARGE** |
|
||||
|
||||
---
|
||||
|
||||
## UI Dependencies Analysis (413MB image)
|
||||
|
||||
### Production Dependencies (package.json)
|
||||
|
||||
| Package | Used? | Size Impact | Notes |
|
||||
|---------|-------|-------------|-------|
|
||||
| `@aws-sdk/client-s3` | ❌ **UNUSED** | ~100MB | **REMOVE** - Not imported anywhere |
|
||||
| `@aws-sdk/s3-request-presigner` | ❌ **UNUSED** | ~50MB | **REMOVE** - Not imported anywhere |
|
||||
| `@grafana/faro-web-sdk` | ✅ Used | ~2MB | Keep - RUM tracking |
|
||||
| `@inlang/paraglide-js` | ✅ Used | ~1MB | Keep - i18n |
|
||||
| `@opentelemetry/*` (5 packages) | ✅ Used | ~15MB | Keep - Server-side tracing |
|
||||
| `@sentry/sveltekit` | ✅ Used | ~10MB | Keep - Error tracking |
|
||||
| `cropperjs` | ✅ Used | ~500KB | Keep - Avatar cropping |
|
||||
| `ioredis` | ✅ Used | ~5MB | Keep - Redis client (server-side) |
|
||||
| `marked` | ✅ Used | ~500KB | Keep - Markdown parsing |
|
||||
| `pocketbase` | ✅ Used | ~200KB | Keep - PocketBase client |
|
||||
| **EXTRANEOUS** | | | |
|
||||
| `@playwright/test` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
|
||||
| `playwright-core` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
|
||||
| `playwright` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
|
||||
|
||||
**Total waste: ~300MB (AWS SDK + Playwright)**
|
||||
|
||||
### Why AWS SDK is in dependencies?
|
||||
|
||||
Checking git history... it was likely added for direct S3 uploads but never actually used. The backend handles all S3 operations.
|
||||
|
||||
---
|
||||
|
||||
## Backend Dependencies Analysis (179MB image)
|
||||
|
||||
### Docker Image Breakdown
|
||||
|
||||
```dockerfile
|
||||
FROM alpine:3.21 # ~7MB base
|
||||
RUN apk add ffmpeg # ~40MB (needed for audio transcoding)
|
||||
RUN apk add ca-certificates # ~200KB (needed for HTTPS)
|
||||
COPY /out/backend # ~130MB (Go binary + stdlib)
|
||||
```
|
||||
|
||||
**All dependencies justified:**
|
||||
- ✅ `ffmpeg` - Required for pocket-tts WAV→MP3 transcoding
|
||||
- ✅ `ca-certificates` - Required for HTTPS connections to external services
|
||||
- ✅ Go binary includes all dependencies (static linking)
|
||||
|
||||
### Go Module Analysis
|
||||
|
||||
```bash
|
||||
go list -m all | wc -l # 169 modules
|
||||
```
|
||||
|
||||
Go binaries are statically linked, so unused imports don't increase image size. The build process with `-ldflags="-s -w"` strips symbols and debug info.
|
||||
|
||||
**Optimization already applied:** ✅
|
||||
- CGO_ENABLED=0 (static linking, no libc dependency)
|
||||
- -ldflags="-s -w" (strip symbols, ~20% size reduction)
|
||||
- BuildKit cache mounts (faster rebuilds)
|
||||
|
||||
---
|
||||
|
||||
## Caddy Dependencies Analysis (114MB image)
|
||||
|
||||
```dockerfile
|
||||
FROM caddy:2-alpine # ~50MB base
|
||||
COPY /usr/bin/caddy # ~60MB (with 3 plugins)
|
||||
COPY errors/ # ~4MB (error page assets)
|
||||
```
|
||||
|
||||
**Plugins in use:**
|
||||
- ✅ `caddy-ratelimit` - Used for API rate limiting
|
||||
- ✅ `caddy-crowdsec-bouncer` - Used for CrowdSec integration
|
||||
- ✅ `caddy-l4` - Used for TCP/UDP proxying (Redis TLS proxy)
|
||||
|
||||
**All plugins justified** - actively used in production Caddyfile.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Remove AWS SDK from UI (PRIORITY: HIGH)
|
||||
|
||||
**Impact:** ~150MB reduction (413MB → 263MB, 36% smaller)
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm uninstall @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||
```
|
||||
|
||||
**Risk:** None - confirmed unused via grep
|
||||
|
||||
### 2. Remove Playwright from production (PRIORITY: HIGH)
|
||||
|
||||
**Impact:** ~150MB reduction (263MB → 113MB, 57% smaller)
|
||||
|
||||
**Issue:** Playwright packages are marked as "extraneous" - they're installed but not in package.json. This happens when:
|
||||
- Someone ran `npm install playwright` without `--save-dev`
|
||||
- package-lock.json got corrupted
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
cd ui
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
This will regenerate package-lock.json without the extraneous packages.
|
||||
|
||||
### 3. Consider distroless for backend/runner (OPTIONAL)
|
||||
|
||||
**Impact:** ~10-15MB reduction per image
|
||||
|
||||
**Current:** Alpine + ffmpeg (required)
|
||||
**Alternative:** Use distroless + statically compiled ffmpeg
|
||||
|
||||
**Tradeoff:**
|
||||
- Pros: Smaller attack surface, smaller image
|
||||
- Cons: Harder to debug, need to bundle ffmpeg binary
|
||||
- Verdict: **NOT WORTH IT** - ffmpeg from apk is well-maintained
|
||||
|
||||
### 4. Use .dockerignore (ALREADY GOOD ✅)
|
||||
|
||||
Both UI and backend have proper .dockerignore files:
|
||||
- ✅ node_modules excluded (ui)
|
||||
- ✅ build artifacts excluded
|
||||
- ✅ .git excluded
|
||||
|
||||
---
|
||||
|
||||
## Expected Results After Cleanup
|
||||
|
||||
| Image | Before | After | Savings |
|
||||
|-------|--------|-------|---------|
|
||||
| backend | 179MB | 179MB | 0MB (already optimal) |
|
||||
| runner | 178MB | 178MB | 0MB (already optimal) |
|
||||
| pocketbase | 37MB | 37MB | 0MB (already optimal) |
|
||||
| caddy | 114MB | 114MB | 0MB (already optimal) |
|
||||
| ui | **413MB** | **~110MB** | **~300MB (73% smaller)** |
|
||||
|
||||
**Total deployment size reduction:** ~300MB
|
||||
**Deployment time improvement:** ~20-30s faster (less to pull from Docker Hub)
|
||||
|
||||
---
|
||||
|
||||
## Action Plan
|
||||
|
||||
```bash
|
||||
# 1. Clean up UI dependencies
|
||||
cd ui
|
||||
npm uninstall @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# 2. Verify no imports remain
|
||||
grep -r "@aws-sdk" src/ # Should return nothing
|
||||
grep -r "playwright" src/ # Should return nothing
|
||||
|
||||
# 3. Test build locally
|
||||
npm run build
|
||||
|
||||
# 4. Commit changes
|
||||
git add package.json package-lock.json
|
||||
git commit -m "chore: remove unused AWS SDK and Playwright dependencies from UI
|
||||
|
||||
- Remove @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner (~150MB)
|
||||
- Remove extraneous Playwright packages (~150MB)
|
||||
- UI image size: 413MB → ~110MB (73% smaller)
|
||||
|
||||
All S3 operations are handled by the backend, not the UI."
|
||||
|
||||
# 5. Tag and deploy
|
||||
git tag v4.3.7 -m "chore: remove unused dependencies, reduce UI image by 73%"
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend/Runner Go Dependencies (For Reference)
|
||||
|
||||
The Go images are already well-optimized. Here are the main dependencies:
|
||||
|
||||
**Backend (179MB):**
|
||||
- PocketBase SDK
|
||||
- MinIO SDK (S3)
|
||||
- Meilisearch SDK
|
||||
- Redis SDK (ioredis equivalent)
|
||||
- HTTP router (chi)
|
||||
- OpenTelemetry SDK
|
||||
|
||||
**Runner (178MB):**
|
||||
- Same as backend
|
||||
- + Chromedp (headless Chrome for scraping)
|
||||
- + Audio processing libs
|
||||
|
||||
All are actively used - no dead code found.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Current state:**
|
||||
- Backend, runner, pocketbase, caddy: ✅ Already well-optimized
|
||||
- UI: ⚠️ Carrying 300MB of unused dependencies
|
||||
|
||||
**Impact of cleanup:**
|
||||
- 73% smaller UI image
|
||||
- Faster deployments
|
||||
- Lower bandwidth costs
|
||||
- Cleaner dependency tree
|
||||
|
||||
**Effort:** ~5 minutes (remove 2 packages + regenerate lockfile)
|
||||
**Risk:** Very low (confirmed unused via code search)
|
||||
@@ -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")
|
||||
|
||||
3975
ui/package-lock.json
generated
3975
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,6 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@grafana/faro-web-sdk": "^2.3.1",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
|
||||
@@ -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