Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50a13447a4 | ||
|
|
ce34d2c75f | ||
|
|
d394ac454b | ||
|
|
f24720b087 | ||
|
|
71a628673d | ||
|
|
5f5aac5e3e |
@@ -103,7 +103,7 @@ jobs:
|
||||
# PROD_USER — SSH login user (typically root)
|
||||
# PROD_SSH_KEY — private key whose public half is in authorized_keys
|
||||
# PROD_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <PROD_HOST>
|
||||
deploy:
|
||||
deploy-prod:
|
||||
name: Deploy to prod
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
@@ -132,6 +132,43 @@ jobs:
|
||||
doppler run -- docker compose pull backend runner ui caddy pocketbase
|
||||
doppler run -- docker compose up -d --remove-orphans'
|
||||
|
||||
# ── deploy homelab runner ─────────────────────────────────────────────────────
|
||||
# Syncs the homelab runner compose file and restarts the runner service.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# HOMELAB_HOST — homelab server IP (192.168.0.109)
|
||||
# HOMELAB_USER — SSH login user (typically root)
|
||||
# HOMELAB_SSH_KEY — private key whose public half is in authorized_keys
|
||||
# HOMELAB_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <HOMELAB_HOST>
|
||||
deploy-homelab:
|
||||
name: Deploy to homelab
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
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 \
|
||||
homelab/runner/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 \
|
||||
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}" \
|
||||
'set -euo pipefail
|
||||
cd /opt/libnovel-runner
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose pull runner
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose up -d runner'
|
||||
|
||||
# ── Gitea release ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: Gitea Release
|
||||
|
||||
60
HOMELAB_SECRETS_SETUP.md
Normal file
60
HOMELAB_SECRETS_SETUP.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Homelab Deployment Secrets Setup
|
||||
|
||||
The release workflow now includes automatic deployment to the homelab runner server. You need to add these secrets to Gitea.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Go to: `https://gitea.kalekber.cc/kamil/libnovel/settings/secrets/actions`
|
||||
|
||||
### 1. HOMELAB_HOST
|
||||
```
|
||||
192.168.0.109
|
||||
```
|
||||
|
||||
### 2. HOMELAB_USER
|
||||
```
|
||||
root
|
||||
```
|
||||
|
||||
### 3. HOMELAB_SSH_KEY
|
||||
|
||||
If you want to use the same SSH key as prod:
|
||||
- Copy the value from `PROD_SSH_KEY` secret
|
||||
|
||||
If you want a separate key:
|
||||
```bash
|
||||
# On your local machine or CI runner
|
||||
cat ~/.ssh/id_rsa # or your preferred key
|
||||
```
|
||||
|
||||
### 4. HOMELAB_SSH_KNOWN_HOSTS
|
||||
|
||||
Run this when the homelab server is reachable:
|
||||
```bash
|
||||
ssh-keyscan -H 192.168.0.109 2>/dev/null
|
||||
```
|
||||
|
||||
Expected output format:
|
||||
```
|
||||
|1|base64hash...|192.168.0.109 ssh-rsa AAAAB3NzaC...
|
||||
|1|base64hash...|192.168.0.109 ecdsa-sha2-nistp256 AAAAE2...
|
||||
|1|base64hash...|192.168.0.109 ssh-ed25519 AAAAC3...
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
After adding the secrets, the next release (e.g., v4.1.10) will automatically:
|
||||
1. Build all Docker images
|
||||
2. Deploy to prod (165.22.70.138) ✅
|
||||
3. Deploy to homelab (192.168.0.109) ✅ NEW
|
||||
4. Create a Gitea release
|
||||
|
||||
Both deployments run in parallel for faster releases.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the homelab deployment fails:
|
||||
- Check that the secrets are set correctly
|
||||
- Verify SSH access: `ssh root@192.168.0.109`
|
||||
- Check Doppler config exists: `doppler configs --project libnovel`
|
||||
- Manually test: `cd /opt/libnovel-runner && doppler run --project libnovel --config prd_homelab -- docker compose pull runner`
|
||||
@@ -16,7 +16,7 @@
|
||||
const display = $derived(hovered || rating || 0);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<button
|
||||
@@ -44,10 +44,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if avg && count}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
|
||||
{:else if avg}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
|
||||
{#if count > 0}
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-(--color-text)">{avg.toFixed(1)}</span>
|
||||
<span class="text-xs text-(--color-muted) leading-none">{count} {count === 1 ? 'rating' : 'ratings'}</span>
|
||||
</div>
|
||||
{:else if !readonly}
|
||||
<span class="text-xs text-(--color-muted)">Rate this book</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -247,13 +247,16 @@
|
||||
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
|
||||
{#if streak > 0}
|
||||
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<span class="font-semibold text-(--color-text)">{streak}</span>
|
||||
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-brand) font-semibold">
|
||||
<svg class="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
|
||||
</svg>
|
||||
{streak} day{streak !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{#if data.stats.booksInProgress > 0}
|
||||
<span class="text-(--color-muted)">
|
||||
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted)">
|
||||
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span>
|
||||
{data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -263,32 +266,39 @@
|
||||
{#if shelfBooks.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each shelfBooks as { book, chapter }}
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Chapter badge -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
<!-- Reading progress bar -->
|
||||
{#if book.total_chapters > 0}
|
||||
{@const pct = Math.min(100, Math.round((chapter / book.total_chapters) * 100))}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
<div class="h-full bg-(--color-brand) transition-all" style="width: {pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<!-- Listen button (hover overlay) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(book.slug, chapter)}
|
||||
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute bottom-9 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||
title="Listen"
|
||||
aria-label="Listen to chapter {chapter}"
|
||||
>
|
||||
@@ -296,6 +306,9 @@
|
||||
</button>
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -307,7 +320,7 @@
|
||||
{#if data.continueCompleted.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Completed</h2>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.continueCompleted as { book, chapter }}
|
||||
@@ -318,7 +331,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
|
||||
@@ -339,7 +352,7 @@
|
||||
{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Ready to Listen</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Ready to Listen</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/listen" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<button type="button" onclick={() => hide('ready-to-listen')} title="Hide section"
|
||||
@@ -360,7 +373,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Headphones badge -->
|
||||
@@ -402,7 +415,7 @@
|
||||
{#if !hidden.has('browse-genre')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Browse by genre</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
|
||||
@@ -415,8 +428,11 @@
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
|
||||
{#each GENRES as genre}
|
||||
{@const isTop = data.topGenre && genre.toLowerCase() === data.topGenre.toLowerCase()}
|
||||
<a href="/catalogue?genre={encodeURIComponent(genre)}"
|
||||
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
|
||||
class="shrink-0 px-3.5 py-1.5 rounded-full border text-sm transition-colors whitespace-nowrap {isTop
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand) font-semibold'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3)'}">
|
||||
{genre}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -428,7 +444,7 @@
|
||||
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Trending Now</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('trending')} title="Hide section"
|
||||
@@ -449,7 +465,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
|
||||
@@ -477,15 +493,18 @@
|
||||
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">
|
||||
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">
|
||||
Because you read <span class="text-(--color-brand)">{data.topGenre ? data.topGenre.charAt(0).toUpperCase() + data.topGenre.slice(1) : ''}</span>
|
||||
</h2>
|
||||
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue?genre={encodeURIComponent(data.topGenre ?? '')}" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.recommendedBooks as book}
|
||||
@@ -497,7 +516,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -524,7 +543,7 @@
|
||||
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
|
||||
@@ -545,7 +564,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if count > 1}
|
||||
@@ -577,7 +596,7 @@
|
||||
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
<button type="button" onclick={() => hide('from-following')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -594,7 +613,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { listAIJobs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
const jobs = await listAIJobs().catch((e) => {
|
||||
log.warn('admin/layout', 'failed to load ai jobs for sidebar badge', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
const runningAiJobs = jobs.filter((j) => j.status === 'running' || j.status === 'pending').length;
|
||||
return { runningAiJobs };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
const internalLinks = [
|
||||
{
|
||||
@@ -105,8 +106,9 @@
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: LayoutData;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
@@ -136,6 +138,7 @@
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each internalLinks as link}
|
||||
{@const active = page.url.pathname.startsWith(link.href)}
|
||||
{@const isAiJobs = link.href === '/admin/ai-jobs'}
|
||||
<a
|
||||
href={link.href}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
@@ -147,7 +150,12 @@
|
||||
<svg class="w-3.5 h-3.5 shrink-0 {active ? 'text-(--color-brand)' : 'opacity-50'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{@html link.icon}
|
||||
</svg>
|
||||
{link.label()}
|
||||
<span class="flex-1">{link.label()}</span>
|
||||
{#if isAiJobs && data.runningAiJobs > 0}
|
||||
<span class="text-[10px] font-bold tabular-nums px-1.5 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
|
||||
{data.runningAiJobs}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
// ── Cancel ────────────────────────────────────────────────────────────────────
|
||||
let cancellingIds = $state(new Set<string>());
|
||||
let cancelErrors: Record<string, string> = $state({});
|
||||
let cancellingAll = $state(false);
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingIds.has(id)) return;
|
||||
@@ -77,6 +78,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelAllRunning() {
|
||||
if (cancellingAll) return;
|
||||
cancellingAll = true;
|
||||
const inFlight = jobs.filter((j) => j.status === 'running' || j.status === 'pending');
|
||||
await Promise.all(inFlight.map((j) => cancelJob(j.id)));
|
||||
cancellingAll = false;
|
||||
}
|
||||
|
||||
// ── Review & Apply (chapter-names jobs) ──────────────────────────────────────
|
||||
|
||||
interface ProposedTitle {
|
||||
@@ -411,7 +420,9 @@
|
||||
|
||||
function fmtDate(s: string | undefined) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
const d = new Date(s);
|
||||
if (d.getFullYear() < 2000) return '—';
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
@@ -482,6 +493,27 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bulk actions -->
|
||||
{#if stats.running + stats.pending > 0}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={cancelAllRunning}
|
||||
disabled={cancellingAll}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if cancellingAll}
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
Cancelling…
|
||||
{:else}
|
||||
Cancel all in-flight ({stats.running + stats.pending})
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
|
||||
@@ -169,7 +169,9 @@
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
const d = new Date(dateStr);
|
||||
if (d.getFullYear() < 2000) return '-';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
|
||||
@@ -47,9 +47,31 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||
<!-- Broadcast panel -->
|
||||
<div class="mb-6 rounded-lg border border-(--color-border) bg-(--color-surface-2) p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-(--color-brand) mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-(--color-text)">Broadcast to users</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">To send push notifications or in-app messages to all subscribers, use the push dashboard.</p>
|
||||
<a
|
||||
href="https://push.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-(--color-brand) hover:underline"
|
||||
>
|
||||
Open push.libnovel.cc
|
||||
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">Notifications</h1>
|
||||
<h1 class="text-xl font-semibold">Your Notification Inbox</h1>
|
||||
{#if unreadCount > 0}
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{unreadCount} unread</p>
|
||||
{/if}
|
||||
|
||||
@@ -780,7 +780,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<div class="flex flex-col gap-3 min-w-0 flex-1">
|
||||
<!-- Title + "not in library" badge -->
|
||||
<div class="flex items-start gap-2 flex-wrap">
|
||||
<h1 class="text-xl sm:text-3xl font-bold text-(--color-text) leading-tight">{book.title}</h1>
|
||||
@@ -794,28 +794,57 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
{#if book.author}
|
||||
<p class="text-(--color-muted) text-sm">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Author + Quick Stats -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#if book.author}
|
||||
<p class="text-(--color-text) text-sm font-medium">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Quick Stats Row -->
|
||||
<div class="flex items-center gap-3 flex-wrap text-xs text-(--color-muted)">
|
||||
{#if book.total_chapters}
|
||||
<span class="flex items-center gap-1">
|
||||
<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="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>
|
||||
{book.total_chapters} chapters
|
||||
</span>
|
||||
{/if}
|
||||
{#if ratingAvg.count > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5 fill-amber-400" viewBox="0 0 24 24">
|
||||
<path d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>
|
||||
</svg>
|
||||
{ratingAvg.avg.toFixed(1)} ({ratingAvg.count} {ratingAvg.count === 1 ? 'rating' : 'ratings'})
|
||||
</span>
|
||||
{/if}
|
||||
{#if data.readersThisWeek && data.readersThisWeek > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/>
|
||||
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{data.readersThisWeek} this week
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status + genres -->
|
||||
<div class="flex flex-wrap gap-1.5 mt-0.5">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#if book.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
|
||||
<span class="text-xs px-2.5 py-1 rounded-full font-medium
|
||||
{book.status.toLowerCase() === 'ongoing'
|
||||
? 'bg-green-500/15 text-green-400 border border-green-500/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
|
||||
{book.status}
|
||||
</span>
|
||||
{/if}
|
||||
{#each genres as genre}
|
||||
<a
|
||||
href="/catalogue?genre={encodeURIComponent(genre)}"
|
||||
class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-text) transition-colors"
|
||||
class="text-xs px-2.5 py-1 rounded-full bg-(--color-brand)/10 text-(--color-brand) border border-(--color-brand)/20 hover:bg-(--color-brand)/20 hover:border-(--color-brand)/40 transition-colors"
|
||||
>{genre}</a>
|
||||
{/each}
|
||||
{#if data.readersThisWeek && data.readersThisWeek > 0}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
|
||||
{data.readersThisWeek} reading this week
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1020,7 +1049,8 @@
|
||||
|
||||
<!-- ── Book description ──────────────────────────────────────────────────────── -->
|
||||
{#if book.summary}
|
||||
<div class="mb-6">
|
||||
<div class="mb-6 bg-(--color-surface-2)/40 rounded-xl p-5 border border-(--color-border)/50">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-wide mb-3">Summary</h2>
|
||||
<div class="relative">
|
||||
<p
|
||||
class="text-(--color-muted) text-sm leading-7 break-words whitespace-pre-line {summaryExpanded ? '' : 'line-clamp-5'}"
|
||||
@@ -1029,13 +1059,13 @@
|
||||
</p>
|
||||
{#if !summaryExpanded && book.summary.length > 300}
|
||||
<!-- gradient fade over the last line when collapsed -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-(--color-surface) to-transparent pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-(--color-surface-2)/40 to-transparent pointer-events-none"></div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if book.summary.length > 300}
|
||||
<button
|
||||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||||
class="mt-2 text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors inline-flex items-center gap-1"
|
||||
class="mt-3 px-3 py-1.5 text-xs font-medium text-(--color-brand) hover:text-(--color-brand-dim) bg-(--color-brand)/10 hover:bg-(--color-brand)/20 border border-(--color-brand)/20 rounded-lg transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
|
||||
<svg class="w-3 h-3 transition-transform {summaryExpanded ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
let prefs = $state<Prefs>(loadPrefs());
|
||||
// svelte-ignore state_referenced_locally
|
||||
let showOnboarding = $state(!prefs.onboarded);
|
||||
let isEditingPrefs = $state(false);
|
||||
|
||||
// Onboarding temp state
|
||||
// svelte-ignore state_referenced_locally
|
||||
@@ -46,11 +47,10 @@
|
||||
function finishOnboarding(skip = false) {
|
||||
if (!skip) {
|
||||
prefs = { genres: tempGenres, status: tempStatus, onboarded: true };
|
||||
} else {
|
||||
prefs = { ...prefs, onboarded: true };
|
||||
savePrefs(prefs);
|
||||
}
|
||||
savePrefs(prefs);
|
||||
showOnboarding = false;
|
||||
isEditingPrefs = false;
|
||||
}
|
||||
|
||||
// ── Book deck (client-side filtered) ───────────────────────────────────────
|
||||
@@ -60,6 +60,14 @@
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
function cleanSummary(text: string): string {
|
||||
return text.replace(/^summary\s*/i, '').trim();
|
||||
}
|
||||
|
||||
function capitalizeFirst(s: string): string {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
// Resolved books from streamed promises — populated via $effect once promises settle
|
||||
let resolvedBooks = $state<Book[]>([]);
|
||||
let resolvedVotedBooks = $state<VotedBook[]>([]);
|
||||
@@ -237,6 +245,8 @@
|
||||
read_now: { x: 30, y: -1300 },
|
||||
};
|
||||
|
||||
let votedToastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function doAction(action: VoteAction) {
|
||||
if (animating || !currentBook) return;
|
||||
animating = true;
|
||||
@@ -271,6 +281,10 @@
|
||||
animating = false;
|
||||
showPreview = false;
|
||||
|
||||
// Auto-clear toast after 4 s
|
||||
if (votedToastTimer) clearTimeout(votedToastTimer);
|
||||
votedToastTimer = setTimeout(() => { voted = null; }, 4000);
|
||||
|
||||
if (action === 'read_now') {
|
||||
goto(`/books/${book.slug}`);
|
||||
} else {
|
||||
@@ -278,6 +292,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function undoLast() {
|
||||
if (!voted || animating) return;
|
||||
const { slug } = voted;
|
||||
voted = null;
|
||||
if (votedToastTimer) { clearTimeout(votedToastTimer); votedToastTimer = null; }
|
||||
votedBooks = votedBooks.filter((v) => v.slug !== slug);
|
||||
if (idx > 0) idx--;
|
||||
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async function resetDeck() {
|
||||
await fetch('/api/discover/vote', { method: 'DELETE' });
|
||||
votedBooks = [];
|
||||
@@ -301,6 +325,10 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Discover — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Onboarding modal ───────────────────────────────────────────────────────── -->
|
||||
{#if showOnboarding}
|
||||
<div class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
@@ -349,11 +377,11 @@
|
||||
<div class="flex gap-3">
|
||||
<button type="button" onclick={() => finishOnboarding(true)}
|
||||
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Skip
|
||||
{isEditingPrefs ? 'Cancel' : 'Skip'}
|
||||
</button>
|
||||
<button type="button" onclick={() => finishOnboarding(false)}
|
||||
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors">
|
||||
Start Discovering
|
||||
{isEditingPrefs ? 'Save Preferences' : 'Start Discovering'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,11 +423,11 @@
|
||||
<p class="text-sm text-(--color-muted) mb-3">{previewBook.author}</p>
|
||||
{/if}
|
||||
{#if previewBook.summary}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{previewBook.summary}</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{cleanSummary(previewBook.summary)}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 mb-5">
|
||||
{#each parseBookGenres(previewBook.genres).slice(0, 4) as genre}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{capitalizeFirst(genre)}</span>
|
||||
{/each}
|
||||
{#if previewBook.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{previewBook.status}</span>
|
||||
@@ -486,6 +514,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Voted toast ─────────────────────────────────────────────────────────────── -->
|
||||
{#if voted}
|
||||
{@const toastBg = voted.action === 'like' ? 'bg-green-500/15 border-green-500/30' : voted.action === 'read_now' ? 'bg-blue-500/15 border-blue-500/30' : 'bg-(--color-surface-2) border-(--color-border)'}
|
||||
{@const toastColor = voted.action === 'like' ? 'text-green-400' : voted.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
|
||||
<div class="fixed bottom-24 left-1/2 -translate-x-1/2 z-30 flex items-center gap-3 px-4 py-3
|
||||
rounded-2xl {toastBg} border shadow-xl text-sm whitespace-nowrap pointer-events-auto animate-in slide-in-from-bottom-2 duration-200">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if voted.action === 'like'}
|
||||
<svg class="w-4 h-4 {toastColor}" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
{:else if voted.action === 'read_now'}
|
||||
<svg class="w-4 h-4 {toastColor}" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 {toastColor}" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="font-semibold {toastColor}">
|
||||
{voted.action === 'like' ? 'Added to Library' : voted.action === 'read_now' ? 'Opening Book...' : 'Skipped'}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onclick={undoLast}
|
||||
class="px-2 py-1 rounded-lg text-xs font-bold text-(--color-text) hover:bg-(--color-surface-3)/50 transition-colors">
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Page layout ────────────────────────────────────────────────────────────── -->
|
||||
<div class="select-none -mx-4 -my-8 lg:min-h-[calc(100svh-3.5rem)]
|
||||
lg:grid lg:grid-cols-[1fr_380px] xl:grid-cols-[1fr_420px]">
|
||||
@@ -496,34 +555,43 @@
|
||||
min-h-[calc(100svh-3.5rem)] lg:border-r lg:border-(--color-border)">
|
||||
|
||||
<!-- Header row -->
|
||||
<div class="w-full max-w-sm lg:max-w-none flex items-center justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !loading && !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" onclick={() => (showHistory = true)} title="History"
|
||||
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{#if votedBooks.length}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
|
||||
{votedBooks.length > 9 ? '9+' : votedBooks.length}
|
||||
</span>
|
||||
<div class="w-full max-w-sm lg:max-w-none flex flex-col gap-3 mb-4 shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
|
||||
{#if !loading && !deckEmpty}
|
||||
<p class="text-xs text-(--color-muted)">{totalRemaining} of {deck.length} books remaining</p>
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" onclick={() => (showHistory = true)} title="History"
|
||||
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{#if votedBooks.length}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
|
||||
{votedBooks.length > 9 ? '9+' : votedBooks.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick={() => { showOnboarding = true; isEditingPrefs = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
title="Preferences"
|
||||
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
{#if !loading && !deckEmpty && deck.length > 0}
|
||||
<div class="w-full h-1.5 bg-(--color-surface-2) rounded-full overflow-hidden">
|
||||
<div class="h-full bg-(--color-brand) transition-all duration-500 ease-out rounded-full"
|
||||
style="width: {((idx / deck.length) * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
@@ -539,23 +607,41 @@
|
||||
|
||||
{:else if deckEmpty}
|
||||
<!-- ── Empty state ───────────────────────────────────────────────── -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
|
||||
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">📚</div>
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-sm px-4">
|
||||
<div class="relative">
|
||||
<div class="w-24 h-24 rounded-full bg-(--color-brand)/10 flex items-center justify-center text-5xl border-4 border-(--color-brand)/20">
|
||||
🎉
|
||||
</div>
|
||||
<div class="absolute -top-1 -right-1 w-8 h-8 rounded-full bg-green-500/20 border-2 border-green-500/40 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
You've seen all available books.
|
||||
{#if prefs.genres.length > 0}Try adjusting your preferences to see more.
|
||||
{:else}Check your library for books you liked.{/if}
|
||||
<h2 class="text-xl font-bold text-(--color-text) mb-2">All Caught Up!</h2>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed">
|
||||
You've explored all {deck.length} books in your discover queue.
|
||||
{#if prefs.genres.length > 0}
|
||||
<br />Try adjusting your genre preferences to discover more.
|
||||
{:else}
|
||||
<br />Set your preferences to get personalized recommendations.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
|
||||
My Library
|
||||
</a>
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
{#if votedBooks.filter(v => v.action === 'like' || v.action === 'read_now').length > 0}
|
||||
<a href="/books" class="py-3 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors shadow-lg shadow-(--color-brand)/20">
|
||||
View My Library
|
||||
</a>
|
||||
{/if}
|
||||
<button type="button" onclick={() => { showOnboarding = true; isEditingPrefs = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
|
||||
class="py-3 rounded-xl text-sm font-semibold bg-(--color-surface-2) text-(--color-text) hover:bg-(--color-surface-3) transition-colors border border-(--color-border)">
|
||||
Change Preferences
|
||||
</button>
|
||||
<button type="button" onclick={resetDeck}
|
||||
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Start over
|
||||
class="py-2 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,7 +705,7 @@
|
||||
{#if book.author}<p class="text-white/70 text-sm mb-2">{book.author}</p>{/if}
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
|
||||
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{capitalizeFirst(genre)}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
|
||||
@@ -674,8 +760,27 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Swipe hint (mobile only, shown while cards remain) -->
|
||||
<p class="lg:hidden text-xs text-(--color-muted)/60 mt-2 shrink-0 text-center">Swipe ← skip · swipe → like · tap for details</p>
|
||||
<!-- Keyboard hint (desktop only) -->
|
||||
<p class="hidden lg:block text-xs text-(--color-muted)/40 mt-2 shrink-0">← Skip · ↑ Read now · → Like · Space for details</p>
|
||||
<div class="hidden lg:flex items-center justify-center gap-4 mt-3 shrink-0">
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">←</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Skip</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">↑</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Read</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">→</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Like</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/30">
|
||||
<kbd class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)">Space</kbd>
|
||||
<span class="text-xs text-(--color-muted)">Details</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -711,7 +816,7 @@
|
||||
<!-- Metadata pills -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each parseBookGenres(book.genres).slice(0, 5) as genre}
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{capitalizeFirst(genre)}</span>
|
||||
{/each}
|
||||
{#if book.status}
|
||||
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-text) font-medium">{book.status}</span>
|
||||
@@ -735,38 +840,9 @@
|
||||
{#if book.summary}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Summary</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed">{book.summary}</p>
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed">{cleanSummary(book.summary)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons (duplicated for desktop convenience) -->
|
||||
<div class="mt-auto flex flex-col gap-2.5 pt-4 border-t border-(--color-border)">
|
||||
<button type="button" onclick={() => doAction('read_now')} disabled={animating}
|
||||
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-blue-500 text-white font-bold text-sm hover:bg-blue-400 active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/20">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
Read Now
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={() => doAction('skip')} disabled={animating}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-red-500/15 border border-red-500/30 text-red-400 text-sm font-semibold hover:bg-red-500/25 active:scale-95 transition-all disabled:opacity-40">
|
||||
<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.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
Skip
|
||||
</button>
|
||||
<button type="button" onclick={() => doAction('like')} disabled={animating}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-green-500/15 border border-green-500/30 text-green-400 text-sm font-semibold hover:bg-green-500/25 active:scale-95 transition-all disabled:opacity-40">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
Like
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onclick={() => { showPreview = true; }}
|
||||
class="w-full py-2 rounded-xl text-xs text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors">
|
||||
More details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if deckEmpty}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-4 text-center p-8 text-(--color-muted)">
|
||||
|
||||
Reference in New Issue
Block a user