Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / Scraper / Lint (push) Successful in 12s
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 19s
CI / UI / Build (push) Successful in 32s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 15s
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (push) Failing after 11s
CI / Scraper / Docker Push (push) Successful in 45s
iOS CI / Build (push) Failing after 2m44s
iOS CI / Test (push) Has been skipped
iOS CI / Build (pull_request) Failing after 5m46s
iOS CI / Test (pull_request) Has been skipped
- Add book_comments and comment_votes PocketBase collections (pb-init.sh + pocketbase.go EnsureCollections) - Web: CommentsSection.svelte with post form, vote buttons, lazy-loaded per-book - API routes: GET/POST /api/comments/[slug], POST /api/comments/[id]/vote - iOS: BookComment + CommentsResponse models, fetchComments/postComment/voteComment in APIClient, CommentsView + CommentsViewModel wired into BookDetailView - Fix profile page SSR crash (ERR_MODULE_NOT_FOUND cropperjs): lazy-load AvatarCropModal via dynamic import guarded by browser, move URL.createObjectURL into onMount
474 lines
16 KiB
Svelte
474 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import { invalidateAll } from '$app/navigation';
|
|
import type { PageData, ActionData } from './$types';
|
|
import { audioStore } from '$lib/audio.svelte';
|
|
import { browser } from '$app/environment';
|
|
|
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
|
|
|
// ── Avatar ───────────────────────────────────────────────────────────────────
|
|
let avatarUrl = $state<string | null>(data.avatarUrl ?? null);
|
|
let avatarUploading = $state(false);
|
|
let avatarError = $state('');
|
|
let fileInput: HTMLInputElement | null = null;
|
|
|
|
// Crop modal state
|
|
let cropFile = $state<File | null>(null);
|
|
|
|
function handleAvatarChange(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
// Reset input so the same file can be re-selected after cancel
|
|
if (fileInput) fileInput.value = '';
|
|
cropFile = file;
|
|
}
|
|
|
|
async function handleCropConfirm(blob: Blob, mimeType: string) {
|
|
cropFile = null;
|
|
avatarUploading = true;
|
|
avatarError = '';
|
|
try {
|
|
// Step 1: get presigned PUT URL
|
|
const presignRes = await fetch('/api/profile/avatar', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mime_type: mimeType })
|
|
});
|
|
if (!presignRes.ok) {
|
|
const body = await presignRes.json().catch(() => ({})) as { message?: string };
|
|
avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
|
|
return;
|
|
}
|
|
const { upload_url, key } = await presignRes.json() as { upload_url: string; key: string };
|
|
|
|
// Step 2: PUT blob directly to MinIO
|
|
const putRes = await fetch(upload_url, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': mimeType },
|
|
body: blob
|
|
});
|
|
if (!putRes.ok) {
|
|
avatarError = `Upload failed (${putRes.status})`;
|
|
return;
|
|
}
|
|
|
|
// Step 3: record key in PocketBase and get fresh presigned GET URL
|
|
const patchRes = await fetch('/api/profile/avatar', {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ key })
|
|
});
|
|
if (!patchRes.ok) {
|
|
const body = await patchRes.json().catch(() => ({})) as { message?: string };
|
|
avatarError = body.message ?? `Failed to save avatar (${patchRes.status})`;
|
|
return;
|
|
}
|
|
const result = await patchRes.json() as { avatar_url: string | null };
|
|
avatarUrl = result.avatar_url;
|
|
} catch {
|
|
avatarError = 'Network error during upload';
|
|
} finally {
|
|
avatarUploading = false;
|
|
}
|
|
}
|
|
|
|
function handleCropCancel() {
|
|
cropFile = null;
|
|
}
|
|
|
|
// ── Settings ────────────────────────────────────────────────────────────────
|
|
let voices = $state<string[]>([]);
|
|
let voicesLoaded = $state(false);
|
|
|
|
// Load voices on mount
|
|
$effect(() => {
|
|
fetch('/api/voices')
|
|
.then((r) => r.json())
|
|
.then((d: { voices: string[] }) => {
|
|
voices = d.voices ?? [];
|
|
voicesLoaded = true;
|
|
})
|
|
.catch(() => {
|
|
voicesLoaded = true;
|
|
});
|
|
});
|
|
|
|
// Mirror from audioStore so sliders feel live
|
|
let voice = $state(audioStore.voice);
|
|
let speed = $state(audioStore.speed);
|
|
let autoNext = $state(audioStore.autoNext);
|
|
|
|
// Keep in sync when layout changes them externally
|
|
$effect(() => {
|
|
voice = audioStore.voice;
|
|
speed = audioStore.speed;
|
|
autoNext = audioStore.autoNext;
|
|
});
|
|
|
|
let settingsSaving = $state(false);
|
|
let settingsSaved = $state(false);
|
|
|
|
async function saveSettings() {
|
|
settingsSaving = true;
|
|
settingsSaved = false;
|
|
try {
|
|
await fetch('/api/settings', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ autoNext, voice, speed })
|
|
});
|
|
// Sync to audioStore so the player picks up changes immediately
|
|
audioStore.autoNext = autoNext;
|
|
audioStore.voice = voice;
|
|
audioStore.speed = speed;
|
|
await invalidateAll();
|
|
settingsSaved = true;
|
|
setTimeout(() => (settingsSaved = false), 2500);
|
|
} finally {
|
|
settingsSaving = false;
|
|
}
|
|
}
|
|
|
|
// ── Password change ─────────────────────────────────────────────────────────
|
|
let pwSubmitting = $state(false);
|
|
let pwSuccess = $state(false);
|
|
|
|
$effect(() => {
|
|
if (form?.success) {
|
|
pwSuccess = true;
|
|
setTimeout(() => (pwSuccess = false), 3000);
|
|
}
|
|
});
|
|
|
|
// ── Sessions ────────────────────────────────────────────────────────────────
|
|
type Session = {
|
|
id: string;
|
|
user_agent: string;
|
|
ip: string;
|
|
created_at: string;
|
|
last_seen: string;
|
|
is_current: boolean;
|
|
};
|
|
|
|
let sessions = $state<Session[]>(data.sessions ?? []);
|
|
let revokingId = $state<string | null>(null);
|
|
let revokeError = $state('');
|
|
|
|
async function revokeSession(session: Session) {
|
|
revokingId = session.id;
|
|
revokeError = '';
|
|
try {
|
|
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
|
|
if (!res.ok) {
|
|
revokeError = 'Failed to end session. Please try again.';
|
|
return;
|
|
}
|
|
if (session.is_current) {
|
|
// Ended our own session — submit the logout form to clear the cookie
|
|
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
|
|
if (logoutForm) {
|
|
logoutForm.submit();
|
|
}
|
|
return;
|
|
}
|
|
// Remove from local list
|
|
sessions = sessions.filter((s) => s.id !== session.id);
|
|
} catch {
|
|
revokeError = 'Network error. Please try again.';
|
|
} finally {
|
|
revokingId = null;
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
if (!iso) return '—';
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
}).format(new Date(iso));
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function parseUA(ua: string): string {
|
|
if (!ua) return 'Unknown browser';
|
|
// Very lightweight UA display — just show the most meaningful part
|
|
if (/Mobile/i.test(ua)) {
|
|
const match = ua.match(/\(([^)]+)\)/);
|
|
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
|
|
}
|
|
if (/Chrome\/(\d+)/i.test(ua)) return `Chrome ${ua.match(/Chrome\/(\d+)/i)![1]}`;
|
|
if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${ua.match(/Firefox\/(\d+)/i)![1]}`;
|
|
if (/Safari\/(\d+)/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
|
|
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
|
|
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Profile — libnovel</title>
|
|
</svelte:head>
|
|
|
|
{#if cropFile && browser}
|
|
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
|
|
<AvatarCropModal
|
|
file={cropFile}
|
|
onconfirm={handleCropConfirm}
|
|
oncancel={handleCropCancel}
|
|
/>
|
|
{/await}
|
|
{/if}
|
|
|
|
<!-- Hidden logout form used when user ends their own session -->
|
|
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
|
|
|
<div class="max-w-xl mx-auto space-y-10">
|
|
<div class="flex items-center gap-5">
|
|
<!-- Avatar -->
|
|
<div class="relative shrink-0">
|
|
<button
|
|
onclick={() => fileInput?.click()}
|
|
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-zinc-600 hover:ring-amber-400 transition-all focus:outline-none focus:ring-amber-400"
|
|
title="Change profile picture"
|
|
disabled={avatarUploading}
|
|
>
|
|
{#if avatarUrl}
|
|
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
|
|
{:else}
|
|
<div class="w-full h-full bg-zinc-700 flex items-center justify-center">
|
|
<svg class="w-10 h-10 text-zinc-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
<!-- Hover overlay -->
|
|
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{#if avatarUploading}
|
|
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
|
</svg>
|
|
{:else}
|
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
class="hidden"
|
|
onchange={handleAvatarChange}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
|
|
<p class="text-zinc-400 text-sm mt-0.5 capitalize">{data.user.role}</p>
|
|
{#if avatarError}
|
|
<p class="text-red-400 text-xs mt-1">{avatarError}</p>
|
|
{:else}
|
|
<p class="text-zinc-500 text-xs mt-1">Click avatar to change photo</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
|
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-5">
|
|
<h2 class="text-lg font-semibold text-zinc-100">Reading settings</h2>
|
|
|
|
<!-- Voice -->
|
|
<div class="space-y-1.5">
|
|
<label class="block text-sm font-medium text-zinc-300" for="voice-select">TTS voice</label>
|
|
{#if !voicesLoaded}
|
|
<div class="h-9 bg-zinc-700 rounded animate-pulse"></div>
|
|
{:else if voices.length === 0}
|
|
<select id="voice-select" disabled class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-400 text-sm cursor-not-allowed">
|
|
<option>No voices available</option>
|
|
</select>
|
|
{:else}
|
|
<select
|
|
id="voice-select"
|
|
bind:value={voice}
|
|
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
>
|
|
{#each voices as v}
|
|
<option value={v}>{v}</option>
|
|
{/each}
|
|
</select>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Speed -->
|
|
<div class="space-y-1.5">
|
|
<label class="block text-sm font-medium text-zinc-300" for="speed-range">
|
|
Playback speed — <span class="text-amber-400 font-mono">{speed.toFixed(1)}x</span>
|
|
</label>
|
|
<input
|
|
id="speed-range"
|
|
type="range"
|
|
min="0.5"
|
|
max="3.0"
|
|
step="0.1"
|
|
bind:value={speed}
|
|
class="w-full accent-amber-400"
|
|
/>
|
|
<div class="flex justify-between text-xs text-zinc-500">
|
|
<span>0.5x</span>
|
|
<span>3.0x</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auto-next -->
|
|
<label class="flex items-center gap-3 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={autoNext}
|
|
class="w-4 h-4 rounded accent-amber-400"
|
|
/>
|
|
<span class="text-sm text-zinc-300">Auto-advance to next chapter</span>
|
|
</label>
|
|
|
|
<div class="flex items-center gap-3 pt-1">
|
|
<button
|
|
onclick={saveSettings}
|
|
disabled={settingsSaving}
|
|
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-60"
|
|
>
|
|
{settingsSaving ? 'Saving…' : 'Save settings'}
|
|
</button>
|
|
{#if settingsSaved}
|
|
<span class="text-sm text-green-400">Saved!</span>
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
|
|
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
|
|
<h2 class="text-lg font-semibold text-zinc-100">Active sessions</h2>
|
|
<p class="text-sm text-zinc-400">These are all devices currently signed into your account. End any session you don't recognise.</p>
|
|
|
|
{#if revokeError}
|
|
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
|
|
{revokeError}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if sessions.length === 0}
|
|
<p class="text-sm text-zinc-500 italic">No session records found. Sessions are tracked from the next login.</p>
|
|
{:else}
|
|
<ul class="space-y-2">
|
|
{#each sessions as session (session.id)}
|
|
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-amber-400/10 border border-amber-400/30' : 'bg-zinc-700/50 border border-zinc-600/50'}">
|
|
<div class="min-w-0 space-y-0.5">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span class="text-sm font-medium text-zinc-100 truncate">{parseUA(session.user_agent)}</span>
|
|
{#if session.is_current}
|
|
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-amber-400/20 text-amber-300 border border-amber-400/40">This session</span>
|
|
{/if}
|
|
</div>
|
|
{#if session.ip}
|
|
<p class="text-xs text-zinc-400 font-mono">{session.ip}</p>
|
|
{/if}
|
|
<p class="text-xs text-zinc-500">
|
|
Signed in {formatDate(session.created_at)}
|
|
{#if session.last_seen && session.last_seen !== session.created_at}
|
|
· Last seen {formatDate(session.last_seen)}
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={() => revokeSession(session)}
|
|
disabled={revokingId === session.id}
|
|
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
|
|
{session.is_current
|
|
? 'bg-red-900/40 text-red-300 border border-red-700/60 hover:bg-red-900/70'
|
|
: 'bg-zinc-600/60 text-zinc-300 border border-zinc-500/50 hover:bg-zinc-600'}"
|
|
>
|
|
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- ── Change password ──────────────────────────────────────────────────── -->
|
|
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
|
|
<h2 class="text-lg font-semibold text-zinc-100">Change password</h2>
|
|
|
|
{#if form?.error}
|
|
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
|
|
{form.error}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if pwSuccess}
|
|
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
|
|
Password changed successfully.
|
|
</div>
|
|
{/if}
|
|
|
|
<form
|
|
method="POST"
|
|
action="?/changePassword"
|
|
use:enhance={() => {
|
|
pwSubmitting = true;
|
|
return async ({ update }) => {
|
|
pwSubmitting = false;
|
|
await update();
|
|
};
|
|
}}
|
|
class="space-y-4"
|
|
>
|
|
<div class="space-y-1.5">
|
|
<label class="block text-sm font-medium text-zinc-300" for="current">Current password</label>
|
|
<input
|
|
id="current"
|
|
name="current"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
required
|
|
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<label class="block text-sm font-medium text-zinc-300" for="next">New password</label>
|
|
<input
|
|
id="next"
|
|
name="next"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
required
|
|
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
/>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<label class="block text-sm font-medium text-zinc-300" for="confirm">Confirm new password</label>
|
|
<input
|
|
id="confirm"
|
|
name="confirm"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
required
|
|
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={pwSubmitting}
|
|
class="px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-60"
|
|
>
|
|
{pwSubmitting ? 'Updating…' : 'Update password'}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
</div>
|