diff --git a/scraper/internal/storage/pocketbase.go b/scraper/internal/storage/pocketbase.go index 3eef72b..94cf579 100644 --- a/scraper/internal/storage/pocketbase.go +++ b/scraper/internal/storage/pocketbase.go @@ -449,6 +449,16 @@ func (s *PocketBaseStore) EnsureCollections(ctx context.Context) error { {"name": "vote", "type": "text", "required": true}, // "up" | "down" }, }, + { + // follower_id follows followee_id + "name": "user_subscriptions", + "type": "base", + "fields": []map[string]interface{}{ + {"name": "follower_id", "type": "text", "required": true}, + {"name": "followee_id", "type": "text", "required": true}, + {"name": "created", "type": "date"}, + }, + }, } for _, col := range collections { name, _ := col["name"].(string) diff --git a/ui/src/lib/components/CommentsSection.svelte b/ui/src/lib/components/CommentsSection.svelte index 89e73c7..4bff8bb 100644 --- a/ui/src/lib/components/CommentsSection.svelte +++ b/ui/src/lib/components/CommentsSection.svelte @@ -354,7 +354,11 @@ {initials(comment.username)} {/if} - {comment.username || 'Anonymous'} + {#if comment.username} + {comment.username} + {:else} + Anonymous + {/if} · {formatDate(comment.created)} @@ -491,7 +495,11 @@ {initials(reply.username)} {/if} - {reply.username || 'Anonymous'} + {#if reply.username} + {reply.username} + {:else} + Anonymous + {/if} · {formatDate(reply.created)} diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 687cd8b..34fb7f6 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -1079,3 +1079,231 @@ export async function getMyVotes( } return map; } + +// ─── User subscriptions ─────────────────────────────────────────────────────── + +export interface UserSubscription { + id: string; + follower_id: string; + followee_id: string; + created: string; +} + +/** + * Returns the subscription record if follower_id follows followee_id, else null. + */ +export async function getSubscription( + followerId: string, + followeeId: string +): Promise { + const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`); + const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>( + `/api/collections/user_subscriptions/records?filter=${filter}&perPage=1` + ).catch(() => null); + return res?.items?.[0] ?? null; +} + +/** + * Subscribe follower_id to followee_id. No-ops if already subscribed. + * Returns the subscription record. + */ +export async function subscribe(followerId: string, followeeId: string): Promise { + const existing = await getSubscription(followerId, followeeId); + if (existing) return; + const res = await pbPost('/api/collections/user_subscriptions/records', { + follower_id: followerId, + followee_id: followeeId, + created: new Date().toISOString() + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Failed to subscribe: ${res.status} — ${body}`); + } +} + +/** + * Unsubscribe follower_id from followee_id. No-ops if not subscribed. + */ +export async function unsubscribe(followerId: string, followeeId: string): Promise { + const existing = await getSubscription(followerId, followeeId); + if (!existing) return; + const token = await getToken(); + await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); +} + +/** + * Returns the list of user IDs that followerId is subscribed to. + */ +export async function getFollowingIds(followerId: string): Promise { + const items = await listAll( + 'user_subscriptions', + `follower_id="${followerId}"`, + '-created' + ).catch(() => [] as UserSubscription[]); + return items.map((s) => s.followee_id); +} + +/** + * Returns the count of subscribers (followers) for a given user. + */ +export async function getFollowerCount(followeeId: string): Promise { + return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0); +} + +/** + * Returns the count of accounts a user is following. + */ +export async function getFollowingCount(followerId: string): Promise { + return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0); +} + +/** + * Public profile data for a user. + */ +export interface PublicProfile { + id: string; + username: string; + avatar_url?: string; + created: string; + followerCount: number; + followingCount: number; +} + +/** + * Returns a user's public profile (no sensitive fields) by username. + */ +export async function getPublicProfile(username: string): Promise { + const user = await getUserByUsername(username); + if (!user) return null; + const [followerCount, followingCount] = await Promise.all([ + getFollowerCount(user.id), + getFollowingCount(user.id) + ]); + return { + id: user.id, + username: user.username, + avatar_url: user.avatar_url, + created: user.created, + followerCount, + followingCount + }; +} + +/** + * Returns a user's public library: books they have saved or are reading. + * Only includes books with progress or explicit saves (user_library). + */ +export async function getUserPublicLibrary( + userId: string +): Promise> { + const [allBooks, progressList, savedEntries] = await Promise.all([ + listBooks(), + listAll('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]), + listAll<{ id: string; slug: string; saved_at: string }>( + 'user_library', + `user_id="${userId}"`, + '-saved_at' + ).catch(() => [] as { id: string; slug: string; saved_at: string }[]) + ]); + + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = []; + const seen = new Set(); + + // Books with progress first (most recently read) + for (const p of progressList) { + const book = bookMap.get(p.slug); + if (!book || seen.has(p.slug)) continue; + seen.add(p.slug); + result.push({ book, chapter: p.chapter, saved: false }); + } + + // Saved-only books next + for (const e of savedEntries) { + const book = bookMap.get(e.slug); + if (!book || seen.has(e.slug)) continue; + seen.add(e.slug); + result.push({ book, chapter: null, saved: true }); + } + + // Mark saved flag for books that are both in progress AND saved + const savedSlugs = new Set(savedEntries.map((e) => e.slug)); + return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) })); +} + +/** + * Returns the currently-reading books (books with progress, not completed) + * for a given user ID. + */ +export async function getUserCurrentlyReading( + userId: string +): Promise> { + const [allBooks, progressList] = await Promise.all([ + listBooks(), + listAll('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]) + ]); + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + return progressList + .filter((p) => { + const book = bookMap.get(p.slug); + return book && p.chapter > 0 && p.chapter < book.total_chapters; + }) + .slice(0, 10) + .map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter })); +} + +/** + * Returns recently-updated books from ALL users that followerId is subscribed to. + * Deduplicates across followed users; sorts by most recently updated. + */ +export async function getSubscriptionFeed( + followerId: string, + limit = 12 +): Promise> { + const followingIds = await getFollowingIds(followerId); + if (followingIds.length === 0) return []; + + // Fetch all users we follow (for display names) + const token = await getToken(); + const userFetches = followingIds.map((id) => + fetch(`${PB_URL}/api/collections/app_users/records/${id}`, { + headers: { Authorization: `Bearer ${token}` } + }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null) + ); + const users = (await Promise.all(userFetches)).filter(Boolean) as User[]; + const userMap = new Map(users.map((u) => [u.id, u])); + + // Fetch progress for each followed user + const progressFetches = followingIds.map((id) => + listAll('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[]) + ); + const allProgressArrays = await Promise.all(progressFetches); + + const allBooks = await listBooks(); + const bookMap = new Map(allBooks.map((b) => [b.slug, b])); + + // Merge: per slug take the most-recent progress entry + const seen = new Set(); + const feed: Array<{ book: Book; readerUsername: string; updated: string }> = []; + + for (let i = 0; i < followingIds.length; i++) { + const uid = followingIds[i]; + const username = userMap.get(uid)?.username ?? 'unknown'; + for (const p of allProgressArrays[i]) { + if (seen.has(p.slug)) continue; + const book = bookMap.get(p.slug); + if (!book) continue; + seen.add(p.slug); + feed.push({ book, readerUsername: username, updated: p.updated }); + } + } + + // Sort by most recently read across all followed users + feed.sort((a, b) => b.updated.localeCompare(a.updated)); + return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername })); +} diff --git a/ui/src/routes/+page.server.ts b/ui/src/routes/+page.server.ts index d95ac80..abd05b8 100644 --- a/ui/src/routes/+page.server.ts +++ b/ui/src/routes/+page.server.ts @@ -3,7 +3,8 @@ import { listBooks, recentlyAddedBooks, allProgress, - getHomeStats + getHomeStats, + getSubscriptionFeed } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; import type { Book, Progress } from '$lib/server/pocketbase'; @@ -38,9 +39,18 @@ export const load: PageServerLoad = async ({ locals }) => { const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug)); const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6); + // Subscription feed — only when logged in + const subscriptionFeed = locals.user + ? await getSubscriptionFeed(locals.user.id, 12).catch((e) => { + log.error('home', 'failed to load subscription feed', { err: String(e) }); + return [] as Awaited>; + }) + : []; + return { continueReading, recentlyUpdated, + subscriptionFeed, stats: { ...stats, booksInProgress: continueReading.length diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index 3916a35..4c2cc23 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -147,3 +147,56 @@ {/if} + + +{#if data.subscriptionFeed.length > 0} +
+
+

From People You Follow

+
+ +
+{/if} diff --git a/ui/src/routes/api/users/[username]/+server.ts b/ui/src/routes/api/users/[username]/+server.ts new file mode 100644 index 0000000..7c8f5b5 --- /dev/null +++ b/ui/src/routes/api/users/[username]/+server.ts @@ -0,0 +1,46 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getPublicProfile, getSubscription } from '$lib/server/pocketbase'; +import { presignAvatarUrl } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; + +/** + * GET /api/users/[username] + * Returns public profile info + whether the current user is subscribed. + */ +export const GET: RequestHandler = async ({ params, locals }) => { + const { username } = params; + + try { + const profile = await getPublicProfile(username); + if (!profile) error(404, `User "${username}" not found`); + + // Resolve avatar presigned URL if set + let avatarUrl: string | null = null; + if (profile.avatar_url) { + avatarUrl = await presignAvatarUrl(profile.id).catch(() => null); + } + + // Is the current logged-in user subscribed? + let isSubscribed = false; + if (locals.user && locals.user.id !== profile.id) { + const sub = await getSubscription(locals.user.id, profile.id).catch(() => null); + isSubscribed = !!sub; + } + + return json({ + id: profile.id, + username: profile.username, + avatarUrl, + created: profile.created, + followerCount: profile.followerCount, + followingCount: profile.followingCount, + isSubscribed, + isSelf: locals.user?.id === profile.id + }); + } catch (e) { + if ((e as { status?: number }).status === 404) throw e; + log.error('api/users', 'failed to load profile', { username, err: String(e) }); + error(500, 'Failed to load profile'); + } +}; diff --git a/ui/src/routes/api/users/[username]/subscribe/+server.ts b/ui/src/routes/api/users/[username]/subscribe/+server.ts new file mode 100644 index 0000000..c381dfc --- /dev/null +++ b/ui/src/routes/api/users/[username]/subscribe/+server.ts @@ -0,0 +1,48 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + getUserByUsername, + subscribe, + unsubscribe, + getSubscription +} from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +/** + * POST /api/users/[username]/subscribe — subscribe to a user + * DELETE /api/users/[username]/subscribe — unsubscribe + * Requires authentication. + */ +export const POST: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Login required'); + + const { username } = params; + const target = await getUserByUsername(username).catch(() => null); + if (!target) error(404, `User "${username}" not found`); + if (locals.user.id === target.id) error(400, 'Cannot subscribe to yourself'); + + try { + await subscribe(locals.user.id, target.id); + const sub = await getSubscription(locals.user.id, target.id); + return json({ subscribed: true, subId: sub?.id ?? null }); + } catch (e) { + log.error('api/users/subscribe', 'subscribe failed', { username, err: String(e) }); + error(500, 'Failed to subscribe'); + } +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.user) error(401, 'Login required'); + + const { username } = params; + const target = await getUserByUsername(username).catch(() => null); + if (!target) error(404, `User "${username}" not found`); + + try { + await unsubscribe(locals.user.id, target.id); + return json({ subscribed: false }); + } catch (e) { + log.error('api/users/subscribe', 'unsubscribe failed', { username, err: String(e) }); + error(500, 'Failed to unsubscribe'); + } +}; diff --git a/ui/src/routes/users/[username]/+page.server.ts b/ui/src/routes/users/[username]/+page.server.ts new file mode 100644 index 0000000..fa9cfe6 --- /dev/null +++ b/ui/src/routes/users/[username]/+page.server.ts @@ -0,0 +1,59 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { + getPublicProfile, + getSubscription, + getUserPublicLibrary, + getUserCurrentlyReading +} from '$lib/server/pocketbase'; +import { presignAvatarUrl } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ params, locals }) => { + const { username } = params; + + const profile = await getPublicProfile(username).catch(() => null); + if (!profile) error(404, `User "${username}" not found`); + + // Resolve avatar + let avatarUrl: string | null = null; + if (profile.avatar_url) { + avatarUrl = await presignAvatarUrl(profile.id).catch(() => null); + } + + // Subscription state for the logged-in visitor + let isSubscribed = false; + const isSelf = locals.user?.id === profile.id; + if (locals.user && !isSelf) { + const sub = await getSubscription(locals.user.id, profile.id).catch(() => null); + isSubscribed = !!sub; + } + + // Load public library + currently reading in parallel + const [library, currentlyReading] = await Promise.all([ + getUserPublicLibrary(profile.id).catch((e) => { + log.error('users/profile', 'getUserPublicLibrary failed', { username, err: String(e) }); + return [] as Awaited>; + }), + getUserCurrentlyReading(profile.id).catch((e) => { + log.error('users/profile', 'getUserCurrentlyReading failed', { username, err: String(e) }); + return [] as Awaited>; + }) + ]); + + return { + profile: { + id: profile.id, + username: profile.username, + created: profile.created, + followerCount: profile.followerCount, + followingCount: profile.followingCount + }, + avatarUrl, + isSubscribed, + isSelf, + isLoggedIn: !!locals.user, + library, + currentlyReading + }; +}; diff --git a/ui/src/routes/users/[username]/+page.svelte b/ui/src/routes/users/[username]/+page.svelte new file mode 100644 index 0000000..b6e2338 --- /dev/null +++ b/ui/src/routes/users/[username]/+page.svelte @@ -0,0 +1,224 @@ + + + + {data.profile.username} — libnovel + + + +
+ +
+ {#if data.avatarUrl} + {data.profile.username} + {:else} +
+ {initials(data.profile.username)} +
+ {/if} +
+ + +
+

{data.profile.username}

+

Joined {joinDate(data.profile.created)}

+ + +
+ + {followerCount} + followers + + + {data.profile.followingCount} + following + +
+ + + {#if data.isLoggedIn && !data.isSelf} + + {:else if !data.isLoggedIn} + + Follow + + {/if} +
+
+ + +{#if data.currentlyReading.length > 0} +
+

Currently Reading

+ +
+{/if} + + +{#if data.library.length > 0} +
+

+ Library + ({data.library.length}) +

+ +
+{/if} + + +{#if data.library.length === 0 && data.currentlyReading.length === 0} +
+ + + +

No books in library yet.

+
+{/if}