Add iOS app, SvelteKit JSON API endpoints, and Gitea CI workflow
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 11s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 23s
CI / Scraper / Build (pull_request) Successful in 24s
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 11s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 23s
CI / Scraper / Build (pull_request) Successful in 24s
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
- iOS SwiftUI app (ios/LibNovel/) targeting iOS 17+, generated via xcodegen - Full feature set: auth, home, library, book detail, chapter reader, browse, audio player, profile - Kingfisher for image loading, swift-markdown-ui for chapter rendering - Base URL: https://v2.libnovel.kalekber.cc - SvelteKit JSON API routes (ui/src/routes/api/) for iOS consumption: auth/login, auth/register, auth/me, auth/logout, auth/change-password, home, library, book/[slug], chapter/[slug]/[n], search, ranking, progress/[slug], presign/audio (updated) - Gitea Actions CI: .gitea/workflows/ios.yaml (build + test on macos-latest) - justfile: ios-gen, ios-build, ios-test recipes
This commit is contained in:
47
ui/src/routes/api/auth/change-password/+server.ts
Normal file
47
ui/src/routes/api/auth/change-password/+server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { changePassword } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/auth/change-password
|
||||
* Body: { currentPassword: string, newPassword: string }
|
||||
* Requires authentication.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
let body: { currentPassword?: string; newPassword?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
const currentPassword = body.currentPassword ?? '';
|
||||
const newPassword = body.newPassword ?? '';
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
error(400, 'currentPassword and newPassword are required');
|
||||
}
|
||||
|
||||
if (newPassword.length < 4) {
|
||||
error(400, 'New password must be at least 4 characters');
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await changePassword(locals.user.id, currentPassword, newPassword);
|
||||
if (!ok) {
|
||||
error(401, 'Current password is incorrect');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
// Re-throw SvelteKit errors as-is
|
||||
if (e && typeof e === 'object' && 'status' in e) throw e;
|
||||
log.error('api/auth/change-password', 'unexpected error', { err: String(e) });
|
||||
error(500, 'An error occurred. Please try again.');
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
75
ui/src/routes/api/auth/login/+server.ts
Normal file
75
ui/src/routes/api/auth/login/+server.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Body: { username: string, password: string }
|
||||
* Returns: { token: string, user: { id, username, role } }
|
||||
*
|
||||
* Sets the libnovel_auth cookie and returns the raw token value so the
|
||||
* iOS app can persist it for subsequent requests.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies, locals }) => {
|
||||
let body: { username?: string; password?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
const username = (body.username ?? '').trim();
|
||||
const password = body.password ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
error(400, 'Username and password are required');
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await loginUser(username, password);
|
||||
} catch (e) {
|
||||
log.error('api/auth/login', 'unexpected error', { username, err: String(e) });
|
||||
error(500, 'An error occurred. Please try again.');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
error(401, 'Invalid username or password');
|
||||
}
|
||||
|
||||
// Merge anonymous session progress (non-fatal)
|
||||
mergeSessionProgress(locals.sessionId, user.id).catch((e) =>
|
||||
log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
||||
log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
return json({
|
||||
token,
|
||||
user: { id: user.id, username: user.username, role: user.role ?? 'user' }
|
||||
});
|
||||
};
|
||||
15
ui/src/routes/api/auth/logout/+server.ts
Normal file
15
ui/src/routes/api/auth/logout/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Clears the auth cookie and returns { ok: true }.
|
||||
* Does not revoke the session record from PocketBase —
|
||||
* for full revocation use DELETE /api/sessions/[id] first.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
cookies.delete(AUTH_COOKIE, { path: '/' });
|
||||
return json({ ok: true });
|
||||
};
|
||||
19
ui/src/routes/api/auth/me/+server.ts
Normal file
19
ui/src/routes/api/auth/me/+server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Returns the currently authenticated user from the request's auth cookie.
|
||||
* Returns 401 if not authenticated.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
error(401, 'Not authenticated');
|
||||
}
|
||||
return json({
|
||||
id: locals.user.id,
|
||||
username: locals.user.username,
|
||||
role: locals.user.role,
|
||||
created: locals.user.created ?? ''
|
||||
});
|
||||
};
|
||||
84
ui/src/routes/api/auth/register/+server.ts
Normal file
84
ui/src/routes/api/auth/register/+server.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Body: { username: string, password: string }
|
||||
* Returns: { token: string, user: { id, username, role } }
|
||||
*
|
||||
* Sets the libnovel_auth cookie and returns the raw token value so the
|
||||
* iOS app can persist it for subsequent requests.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies, locals }) => {
|
||||
let body: { username?: string; password?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON body');
|
||||
}
|
||||
|
||||
const username = (body.username ?? '').trim();
|
||||
const password = body.password ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
error(400, 'Username and password are required');
|
||||
}
|
||||
if (username.length < 3 || username.length > 32) {
|
||||
error(400, 'Username must be between 3 and 32 characters');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
error(400, 'Username may only contain letters, numbers, underscores and hyphens');
|
||||
}
|
||||
if (password.length < 8) {
|
||||
error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await createUser(username, password);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Registration failed.';
|
||||
if (msg.includes('Username already taken')) {
|
||||
error(409, 'That username is already taken');
|
||||
}
|
||||
log.error('api/auth/register', 'unexpected error', { username, err: String(e) });
|
||||
error(500, 'An error occurred. Please try again.');
|
||||
}
|
||||
|
||||
// Merge anonymous session progress (non-fatal)
|
||||
mergeSessionProgress(locals.sessionId, user.id).catch((e) =>
|
||||
log.warn('api/auth/register', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
||||
log.warn('api/auth/register', 'createUserSession failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
return json({
|
||||
token,
|
||||
user: { id: user.id, username: user.username, role: user.role ?? 'user' }
|
||||
});
|
||||
};
|
||||
105
ui/src/routes/api/book/[slug]/+server.ts
Normal file
105
ui/src/routes/api/book/[slug]/+server.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
interface PreviewChapter {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/book/[slug]
|
||||
* Returns book metadata, chapter list, progress, and library status.
|
||||
* Falls back to a live scraper preview if the book is not in PocketBase.
|
||||
*
|
||||
* Response shape mirrors BookDetailResponse in the iOS APIClient.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { slug } = params;
|
||||
|
||||
// Try PocketBase first
|
||||
let book = await getBook(slug).catch((e) => {
|
||||
log.error('api/book', 'getBook failed', { slug, err: String(e) });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (book) {
|
||||
let chapters, progress, saved;
|
||||
try {
|
||||
[chapters, progress, saved] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||
isBookSaved(locals.sessionId, slug, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('api/book', 'failed to load book detail data', { slug, err: String(e) });
|
||||
error(500, 'Failed to load book');
|
||||
}
|
||||
|
||||
return json({
|
||||
book,
|
||||
chapters,
|
||||
preview_chapters: null,
|
||||
in_lib: true,
|
||||
saved,
|
||||
last_chapter: progress?.chapter ?? null
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to live scraper preview
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
log.warn('api/book', 'book-preview returned error', { slug, status: res.status });
|
||||
error(404, `Book "${slug}" not found`);
|
||||
}
|
||||
const preview: {
|
||||
in_lib: boolean;
|
||||
meta: {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[];
|
||||
summary: string;
|
||||
total_chapters: number;
|
||||
source_url: string;
|
||||
};
|
||||
chapters: PreviewChapter[];
|
||||
} = await res.json();
|
||||
|
||||
const previewBook = {
|
||||
id: '',
|
||||
slug: preview.meta.slug || slug,
|
||||
title: preview.meta.title,
|
||||
author: preview.meta.author,
|
||||
cover: preview.meta.cover,
|
||||
status: preview.meta.status,
|
||||
genres: preview.meta.genres ?? [],
|
||||
summary: preview.meta.summary,
|
||||
total_chapters: preview.meta.total_chapters,
|
||||
source_url: preview.meta.source_url,
|
||||
ranking: 0,
|
||||
meta_updated: ''
|
||||
};
|
||||
|
||||
return json({
|
||||
book: previewBook,
|
||||
chapters: [],
|
||||
preview_chapters: preview.chapters,
|
||||
in_lib: preview.in_lib,
|
||||
saved: false,
|
||||
last_chapter: null
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('api/book', 'book-preview fetch failed', { slug, err: String(e) });
|
||||
error(404, `Book "${slug}" not found`);
|
||||
}
|
||||
};
|
||||
125
ui/src/routes/api/chapter/[slug]/[n]/+server.ts
Normal file
125
ui/src/routes/api/chapter/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { marked } from 'marked';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
|
||||
import { presignChapter } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/chapter/[slug]/[n]
|
||||
* Returns rendered chapter HTML, navigation info, and voice list.
|
||||
* Supports ?preview=1&chapter_url=...&title=... for un-scraped books.
|
||||
*
|
||||
* Response shape mirrors ChapterResponse in the iOS APIClient.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const n = parseInt(params.n, 10);
|
||||
|
||||
if (!n || n < 1) error(400, 'Invalid chapter number');
|
||||
|
||||
const isPreview = url.searchParams.get('preview') === '1';
|
||||
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
|
||||
const chapterTitle = url.searchParams.get('title') ?? '';
|
||||
|
||||
if (isPreview) {
|
||||
// Preview path: scrape live, nothing from PocketBase/MinIO
|
||||
const previewParams = new URLSearchParams();
|
||||
if (chapterUrl) previewParams.set('chapter_url', chapterUrl);
|
||||
if (chapterTitle) previewParams.set('title', chapterTitle);
|
||||
|
||||
let chapterData: { slug: string; number: number; title: string; text: string; url: string };
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
log.error('api/chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
|
||||
error(404, `Chapter ${n} not found`);
|
||||
}
|
||||
chapterData = await res.json();
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('api/chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) });
|
||||
error(502, 'Could not fetch chapter preview');
|
||||
}
|
||||
|
||||
const html = chapterData.text
|
||||
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
|
||||
: '';
|
||||
|
||||
let voices: string[] = [];
|
||||
try {
|
||||
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
|
||||
if (vRes.ok) {
|
||||
const d = (await vRes.json()) as { voices: string[] };
|
||||
voices = d.voices ?? [];
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
const pb = await getBook(slug).catch(() => null);
|
||||
|
||||
return json({
|
||||
book: { slug, title: pb?.title ?? slug, cover: pb?.cover ?? '' },
|
||||
chapter: { id: '', slug, number: n, title: chapterData.title || `Chapter ${n}`, date_label: '' },
|
||||
html,
|
||||
voices,
|
||||
prev: null,
|
||||
next: null,
|
||||
chapters: [],
|
||||
is_preview: true
|
||||
});
|
||||
}
|
||||
|
||||
// Normal path: PocketBase + MinIO
|
||||
const [book, chapters, voicesRes] = await Promise.all([
|
||||
getBook(slug),
|
||||
listChapterIdx(slug),
|
||||
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
|
||||
]);
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
|
||||
const chapterIdx = chapters.find((c) => c.number === n);
|
||||
if (!chapterIdx) error(404, `Chapter ${n} not found`);
|
||||
|
||||
let voices: string[] = [];
|
||||
try {
|
||||
if (voicesRes?.ok) {
|
||||
const data = (await voicesRes.json()) as { voices: string[] };
|
||||
voices = data.voices ?? [];
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
let html = '';
|
||||
try {
|
||||
const presignUrl = await presignChapter(slug, n);
|
||||
const res = await fetch(presignUrl);
|
||||
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
|
||||
const markdown = await res.text();
|
||||
html = await marked(markdown, { async: true });
|
||||
} catch (e) {
|
||||
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||
}
|
||||
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
|
||||
return json({
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||
chapter: chapterIdx,
|
||||
html,
|
||||
voices,
|
||||
prev: prevChapter ? prevChapter.number : null,
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
is_preview: false
|
||||
});
|
||||
};
|
||||
48
ui/src/routes/api/home/+server.ts
Normal file
48
ui/src/routes/api/home/+server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listBooks, recentlyAddedBooks, allProgress, getHomeStats } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/home
|
||||
* Returns home screen data: continue-reading list, recently updated books, and stats.
|
||||
* Requires authentication (enforced by layout guard).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
let allBooks: Book[] = [];
|
||||
let recentBooks: Book[] = [];
|
||||
let progressList: Progress[] = [];
|
||||
let stats = { totalBooks: 0, totalChapters: 0 };
|
||||
|
||||
try {
|
||||
[allBooks, recentBooks, progressList, stats] = await Promise.all([
|
||||
listBooks(),
|
||||
recentlyAddedBooks(8),
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getHomeStats()
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('api/home', 'failed to load home data', { err: String(e) });
|
||||
}
|
||||
|
||||
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
|
||||
|
||||
const continueReading = progressList
|
||||
.filter((p) => bookMap.has(p.slug))
|
||||
.slice(0, 6)
|
||||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||||
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
return json({
|
||||
continue_reading: continueReading,
|
||||
recently_updated: recentlyUpdated,
|
||||
stats: {
|
||||
totalBooks: stats.totalBooks,
|
||||
totalChapters: stats.totalChapters,
|
||||
booksInProgress: continueReading.length
|
||||
}
|
||||
});
|
||||
};
|
||||
61
ui/src/routes/api/library/+server.ts
Normal file
61
ui/src/routes/api/library/+server.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/library
|
||||
* Returns the user's library: books they have started reading or explicitly saved.
|
||||
* Each item includes the book record, the last chapter read, and saved_at timestamp.
|
||||
*
|
||||
* Response shape mirrors LibraryItem in the iOS APIClient.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
let allBooks: Awaited<ReturnType<typeof listBooks>>;
|
||||
let progressList: Awaited<ReturnType<typeof allProgress>>;
|
||||
let savedSlugs: Set<string>;
|
||||
|
||||
try {
|
||||
[allBooks, progressList, savedSlugs] = await Promise.all([
|
||||
listBooks(),
|
||||
allProgress(locals.sessionId, locals.user?.id),
|
||||
getSavedSlugs(locals.sessionId, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('api/library', 'failed to load library data', { err: String(e) });
|
||||
allBooks = [];
|
||||
progressList = [];
|
||||
savedSlugs = new Set();
|
||||
}
|
||||
|
||||
const progressMap: Record<string, number> = {};
|
||||
const progressUpdatedMap: Record<string, string> = {};
|
||||
for (const p of progressList) {
|
||||
progressMap[p.slug] = p.chapter;
|
||||
progressUpdatedMap[p.slug] = p.updated;
|
||||
}
|
||||
|
||||
const progressSlugs = new Set(progressList.map((p) => p.slug));
|
||||
const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug));
|
||||
|
||||
const withProgress = books.filter((b) => progressSlugs.has(b.slug));
|
||||
const savedOnly = books
|
||||
.filter((b) => !progressSlugs.has(b.slug))
|
||||
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
|
||||
|
||||
withProgress.sort((a, b) => {
|
||||
const ta = progressUpdatedMap[a.slug] ?? '';
|
||||
const tb = progressUpdatedMap[b.slug] ?? '';
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
|
||||
const ordered = [...withProgress, ...savedOnly];
|
||||
|
||||
const items = ordered.map((book) => ({
|
||||
book,
|
||||
last_chapter: progressMap[book.slug] ?? null,
|
||||
saved_at: progressUpdatedMap[book.slug] ?? new Date().toISOString()
|
||||
}));
|
||||
|
||||
return json(items);
|
||||
};
|
||||
@@ -11,7 +11,8 @@ import { log } from '$lib/server/logger';
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
const n = parseInt(url.searchParams.get('n') ?? '', 10);
|
||||
// Accept both 'n' (web) and 'chapter' (iOS) as the chapter number param
|
||||
const n = parseInt(url.searchParams.get('n') ?? url.searchParams.get('chapter') ?? '', 10);
|
||||
const voice = url.searchParams.get('voice') ?? undefined;
|
||||
|
||||
if (!slug || !n || n < 1) {
|
||||
|
||||
34
ui/src/routes/api/progress/[slug]/+server.ts
Normal file
34
ui/src/routes/api/progress/[slug]/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* POST /api/progress/[slug]
|
||||
* Body: { chapter: number }
|
||||
* Records the user's reading position for a specific book.
|
||||
*
|
||||
* This is a slug-in-path variant of POST /api/progress (which takes slug in body).
|
||||
* Used by the iOS app where slug is part of the URL path.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { slug } = params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || typeof body.chapter !== 'number') {
|
||||
error(400, 'Invalid body — expected { chapter: number }');
|
||||
}
|
||||
|
||||
try {
|
||||
await setProgress(locals.sessionId, slug, body.chapter, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('api/progress/[slug]', 'setProgress failed', {
|
||||
slug,
|
||||
chapter: body.chapter,
|
||||
err: String(e)
|
||||
});
|
||||
error(500, 'Failed to save progress');
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
27
ui/src/routes/api/ranking/+server.ts
Normal file
27
ui/src/routes/api/ranking/+server.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/ranking
|
||||
* Proxies to the Go scraper's /api/ranking endpoint.
|
||||
* Returns the top-ranked novels list as JSON.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const res = await fetch(`${SCRAPER_URL}/api/ranking`);
|
||||
if (!res.ok) {
|
||||
log.error('api/ranking', 'scraper returned error', { status: res.status });
|
||||
error(502, `Ranking fetch failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return json(data);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('api/ranking', 'network error', { err: String(e) });
|
||||
error(502, 'Could not load ranking');
|
||||
}
|
||||
};
|
||||
36
ui/src/routes/api/search/+server.ts
Normal file
36
ui/src/routes/api/search/+server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
/**
|
||||
* GET /api/search?q=<query>
|
||||
* Proxies to the Go scraper's /api/search endpoint.
|
||||
* Returns: { results, local_count, remote_count }
|
||||
*
|
||||
* Response shape mirrors SearchResponse in the iOS APIClient.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
|
||||
if (q.trim().length < 2) {
|
||||
return json({ results: [], local_count: 0, remote_count: 0 });
|
||||
}
|
||||
|
||||
const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(q.trim())}`;
|
||||
try {
|
||||
const res = await fetch(apiURL);
|
||||
if (!res.ok) {
|
||||
log.error('api/search', 'scraper returned error', { status: res.status, q });
|
||||
error(502, `Search failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return json(data);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
log.error('api/search', 'network error', { q, err: String(e) });
|
||||
error(502, 'Could not reach search service');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user