feat: book commenting system with upvote/downvote + fix profile SSR crash
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
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
This commit is contained in:
@@ -811,3 +811,184 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BookComment {
|
||||
id: string;
|
||||
slug: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
body: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface CommentVote {
|
||||
id: string;
|
||||
comment_id: string;
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
vote: 'up' | 'down';
|
||||
}
|
||||
|
||||
/**
|
||||
* List comments for a book, newest first, up to 100.
|
||||
*/
|
||||
export async function listComments(slug: string): Promise<BookComment[]> {
|
||||
const token = await getToken();
|
||||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=100`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.items ?? []) as BookComment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment. Returns the created record.
|
||||
*/
|
||||
export async function createComment(
|
||||
slug: string,
|
||||
body: string,
|
||||
userId: string | undefined,
|
||||
username: string
|
||||
): Promise<BookComment> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
body,
|
||||
user_id: userId ?? '',
|
||||
username,
|
||||
upvotes: 0,
|
||||
downvotes: 0,
|
||||
created: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`createComment failed: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json() as Promise<BookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
|
||||
*/
|
||||
export async function getCommentVote(
|
||||
commentId: string,
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<CommentVote | null> {
|
||||
const token = await getToken();
|
||||
const voterFilter = userId
|
||||
? `comment_id="${commentId}"&&user_id="${userId}"`
|
||||
: `comment_id="${commentId}"&&session_id="${sessionId}"`;
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const items = (data.items ?? []) as CommentVote[];
|
||||
return items[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast or change a vote on a comment. Handles:
|
||||
* - New vote: creates vote record, increments counter.
|
||||
* - Same vote again: removes it (toggle off), decrements counter.
|
||||
* - Changed vote: updates record, adjusts both counters.
|
||||
* Returns the updated comment.
|
||||
*/
|
||||
export async function voteComment(
|
||||
commentId: string,
|
||||
vote: 'up' | 'down',
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<BookComment> {
|
||||
const token = await getToken();
|
||||
|
||||
// Fetch current comment
|
||||
const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
|
||||
const comment = (await commentRes.json()) as BookComment;
|
||||
|
||||
const existing = await getCommentVote(commentId, sessionId, userId);
|
||||
|
||||
let upDelta = 0;
|
||||
let downDelta = 0;
|
||||
|
||||
if (!existing) {
|
||||
// New vote
|
||||
await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
|
||||
});
|
||||
vote === 'up' ? upDelta++ : downDelta++;
|
||||
} else if (existing.vote === vote) {
|
||||
// Toggle off — remove vote
|
||||
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
vote === 'up' ? upDelta-- : downDelta--;
|
||||
} else {
|
||||
// Changed vote
|
||||
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vote })
|
||||
});
|
||||
if (vote === 'up') { upDelta++; downDelta--; }
|
||||
else { upDelta--; downDelta++; }
|
||||
}
|
||||
|
||||
// Patch comment counters
|
||||
const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
|
||||
downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
|
||||
})
|
||||
});
|
||||
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
|
||||
return patchRes.json() as Promise<BookComment>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch votes cast by this session/user, keyed by comment_id.
|
||||
* Returns a map of commentId → 'up' | 'down'.
|
||||
*/
|
||||
export async function getMyVotes(
|
||||
commentIds: string[],
|
||||
sessionId: string,
|
||||
userId?: string
|
||||
): Promise<Record<string, 'up' | 'down'>> {
|
||||
if (commentIds.length === 0) return {};
|
||||
const token = await getToken();
|
||||
const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
|
||||
const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
|
||||
const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
|
||||
const res = await fetch(
|
||||
`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
if (!res.ok) return {};
|
||||
const data = await res.json();
|
||||
const map: Record<string, 'up' | 'down'> = {};
|
||||
for (const v of (data.items ?? []) as CommentVote[]) {
|
||||
map[v.comment_id] = v.vote as 'up' | 'down';
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user