From f26a430b1b8f92e43340bb54d97b2d5b7187b23f Mon Sep 17 00:00:00 2001 From: abnzrdev Date: Sun, 11 Oct 2026 12:05:59 +0500 Subject: [PATCH 1/3] feat(comments): integrate guarded server-driven like toggles --- src/api/comments.ts | 15 ++++- src/components/CommentLikeControl.ts | 96 ++++++++++++++++++++++++++++ src/components/GameComments.ts | 14 +++- 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/components/CommentLikeControl.ts diff --git a/src/api/comments.ts b/src/api/comments.ts index 3784e0b..461af80 100644 --- a/src/api/comments.ts +++ b/src/api/comments.ts @@ -1,4 +1,4 @@ -import { MutationFailure, postSessionMutation } from './mutations'; +import { MutationFailure, postSessionMutation, readToggleResult } from './mutations'; import type { AppSession } from '../utils/appSession'; import { commentAuthorName, validateCommentText } from '../utils/commentValidation'; @@ -9,6 +9,7 @@ export interface GameComment { authorName: string; text: string; likesCount: number; + isLikedByCurrentUser?: boolean; createdAt: string; } @@ -60,3 +61,15 @@ export async function submitComment( } return data as GameComment; } + +export async function toggleCommentLike( + commentId: string, + session: AppSession +): Promise<{ active: boolean; likesCount: number }> { + const data = await postSessionMutation( + `/api/comments/${encodeURIComponent(commentId)}/like`, + {}, + session + ); + return readToggleResult(data, 'isLikedByCurrentUser'); +} diff --git a/src/components/CommentLikeControl.ts b/src/components/CommentLikeControl.ts new file mode 100644 index 0000000..6dead6c --- /dev/null +++ b/src/components/CommentLikeControl.ts @@ -0,0 +1,96 @@ +import { toggleCommentLike, type GameComment } from '../api/comments'; +import { MutationFailure } from '../api/mutations'; +import { appSession } from '../utils/session'; +import { ensureActiveSession } from '../utils/protectedAction'; +import { showSnackbar } from './Snackbar'; + +export function initializeCommentLike( + row: HTMLElement, + comment: GameComment, + refresh: () => Promise +): () => void { + const button = row.querySelector('.game-details__like')!; + const count = button.querySelector('[data-comment-likes]')!; + const status = document.createElement('p'); + status.className = 'game-details__like-status'; + status.setAttribute('role', 'status'); + status.hidden = true; + row.append(status); + let live = true; + let pending = false; + let unknown = false; + let active = Boolean(appSession.check() && comment.isLikedByCurrentUser); + function apply(next: boolean, likesCount?: number): void { + active = next; + button.classList.toggle('game-details__like--active', next); + button.setAttribute('aria-pressed', String(next)); + if (likesCount !== undefined) count.textContent = String(likesCount); + button.setAttribute( + 'aria-label', + `${next ? 'Unlike' : 'Like'} comment, ${count.textContent} likes` + ); + } + apply(active, comment.likesCount); + const unsubscribe = appSession.subscribe((session) => { + if (!session) { + apply(false); + unknown = false; + button.disabled = pending; + status.hidden = true; + } + }); + async function click(): Promise { + if (pending || unknown || !ensureActiveSession()) return; + const session = appSession.check()!; + pending = true; + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + status.hidden = false; + status.textContent = 'Updating like…'; + try { + const result = await toggleCommentLike(comment.commentId, session); + const current = appSession.check(); + if ( + !live || + current?.email !== session.email || + current.authenticatedAt !== session.authenticatedAt + ) + return; + apply(result.active, result.likesCount); + status.hidden = true; + showSnackbar(result.active ? 'Comment liked.' : 'Comment like removed.', 'success'); + } catch (error) { + if (!live || appSession.check()?.authenticatedAt !== session.authenticatedAt) return; + unknown = !(error instanceof MutationFailure) || error.kind === 'unknown'; + const message = unknown + ? 'Like result is unknown. Refresh comments before trying again.' + : 'Comment like was rejected. Please try again.'; + status.textContent = message; + status.hidden = false; + if (unknown) { + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'game-details__retry'; + retry.textContent = 'Refresh comments'; + // Reconciliation reads personalized state; it never repeats the ambiguous toggle. + retry.addEventListener('click', () => void refresh()); + status.append(retry); + } + showSnackbar(message, 'error'); + } finally { + pending = false; + if (live) { + button.disabled = unknown; + button.removeAttribute('aria-busy'); + apply(appSession.check() ? active : false); + } + } + } + button.addEventListener('click', click); + return () => { + live = false; + unsubscribe(); + button.removeEventListener('click', click); + status.remove(); + }; +} diff --git a/src/components/GameComments.ts b/src/components/GameComments.ts index 95a12d5..5f36036 100644 --- a/src/components/GameComments.ts +++ b/src/components/GameComments.ts @@ -4,6 +4,7 @@ import { relativeTime } from '../utils/relativeTime'; import favoriteIcon from '../assets/games/favorite.svg'; import { showSnackbar } from './Snackbar'; import { commenterInitial, createCommentAvatarColors } from '../utils/commentAvatar'; +import { initializeCommentLike } from './CommentLikeControl'; import { appSession } from '../utils/session'; function commentMarkup(comment: GameComment, color: string): string { @@ -21,10 +22,10 @@ function commentMarkup(comment: GameComment, color: string): string {

-