v3: added comment likes, added votes/likes list view, added db migrations mechanism, updated project dependencies
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s

This commit is contained in:
khannurien
2026-06-21 14:17:56 +00:00
parent 888aae45cf
commit 7afb5d3f07
25 changed files with 1475 additions and 840 deletions

View File

@@ -14,10 +14,12 @@ import { deserializeComment, parseAPIResponse } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
import { LikeButton } from "./LikeButton.tsx";
import { relativeTime } from "../utils/relativeTime.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
import { useWS } from "../hooks/useWS.ts";
interface CommentThreadProps {
dumpId: string;
@@ -75,6 +77,9 @@ function CommentNode({
const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } =
useWS();
const children = tree.get(comment.id) ?? [];
async function handleReply(e?: React.SubmitEvent) {
@@ -285,6 +290,14 @@ function CommentNode({
)
: <Markdown className="comment-body">{comment.body}</Markdown>}
<div className="comment-actions">
<LikeButton
commentId={comment.id}
count={likeCounts[comment.id] ?? comment.likeCount}
liked={myLikes.has(comment.id)}
disabled={!currentUser}
onLike={castCommentLike}
onUnlike={removeCommentLike}
/>
{currentUser && !editOpen && (
<button
type="button"

View File

@@ -0,0 +1,57 @@
import { useRef, useState } from "react";
import { API_URL } from "../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx";
interface LikeButtonProps {
commentId: string;
count: number;
liked: boolean;
disabled?: boolean;
onLike: (commentId: string) => void;
onUnlike: (commentId: string) => void;
}
export function LikeButton(
{ commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps,
) {
const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null);
return (
<>
<button
type="button"
className={`comment-action-btn${
liked ? " comment-action-btn--liked" : ""
}`}
onClick={() => liked ? onUnlike(commentId) : onLike(commentId)}
disabled={disabled}
aria-label={liked ? "Remove like" : "Like"}
title={disabled ? "Log in to like" : undefined}
>
{" "}
{count > 0
? (
<span
ref={countRef}
className="like-count-clickable"
onClick={(e) => {
e.stopPropagation();
setPopoverOpen((o) => !o);
}}
>
{count}
</span>
)
: count}
</button>
{popoverOpen && (
<UserListPopover
anchorRef={countRef}
onClose={() => setPopoverOpen(false)}
fetchUrl={`${API_URL}/api/comments/${commentId}/likers`}
/>
)}
</>
);
}

View File

@@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Avatar } from "./Avatar.tsx";
import { useAuth } from "../hooks/useAuth.ts";
import { DEFAULT_PAGE_SIZE } from "../config/api.ts";
import {
deserializePublicUser,
type PaginatedData,
parseAPIResponse,
type PublicUser,
type RawPublicUser,
} from "../model.ts";
interface UserListPopoverProps {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
fetchUrl: string;
}
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
users: PublicUser[];
page: number;
hasMore: boolean;
loadingMore: boolean;
};
export function UserListPopover(
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps,
) {
const { token } = useAuth();
const [state, setState] = useState<State>({ status: "loading" });
const [rect, setRect] = useState<DOMRect | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
// Position via the anchor's viewport rect (mirrors Tooltip.tsx) — fixed +
// portaled to <body> so the popover can't be clipped by a card's overflow.
useEffect(() => {
setRect(anchorRef.current?.getBoundingClientRect() ?? null);
}, [anchorRef]);
useEffect(() => {
function onMouseDown(e: MouseEvent) {
const target = e.target as Node;
if (
popoverRef.current?.contains(target) ||
anchorRef.current?.contains(target)
) {
return;
}
onClose();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
function onScroll() {
onClose();
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("scroll", onScroll, true);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("scroll", onScroll, true);
};
}, [onClose, anchorRef]);
useEffect(() => {
const controller = new AbortController();
fetch(`${fetchUrl}?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const apiResponse = parseAPIResponse<PaginatedData<RawPublicUser>>(
body,
);
if (!apiResponse.success) {
setState({ status: "error", error: apiResponse.error.message });
return;
}
setState({
status: "loaded",
users: apiResponse.data.items.map(deserializePublicUser),
page: 1,
hasMore: apiResponse.data.hasMore,
loadingMore: false,
});
})
.catch(() => {
if (controller.signal.aborted) return;
setState({ status: "error", error: t`Could not load.` });
});
return () => controller.abort();
}, [fetchUrl, token]);
const handleLoadMore = () => {
if (state.status !== "loaded" || state.loadingMore) return;
const nextPage = state.page + 1;
setState({ ...state, loadingMore: true });
fetch(`${fetchUrl}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const apiResponse = parseAPIResponse<PaginatedData<RawPublicUser>>(
body,
);
setState((prev) => {
if (prev.status !== "loaded") return prev;
if (!apiResponse.success) return { ...prev, loadingMore: false };
return {
status: "loaded",
users: [
...prev.users,
...apiResponse.data.items.map(deserializePublicUser),
],
page: nextPage,
hasMore: apiResponse.data.hasMore,
loadingMore: false,
};
});
});
};
if (!rect) return null;
return createPortal(
<div
className="user-list-popover"
role="menu"
ref={popoverRef}
style={{
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
}}
>
{state.status === "loading" && (
<p className="user-list-popover-status">
<Trans>Loading</Trans>
</p>
)}
{state.status === "error" && (
<p className="user-list-popover-status">{state.error}</p>
)}
{state.status === "loaded" && state.users.length === 0 && (
<p className="user-list-popover-status">
<Trans>No one yet.</Trans>
</p>
)}
{state.status === "loaded" && state.users.length > 0 && (
<>
{state.users.map((u) => (
<Link
key={u.id}
to={`/users/${u.username}`}
className="user-list-popover-item"
role="menuitem"
onClick={onClose}
>
<Avatar
userId={u.id}
username={u.username}
hasAvatar={!!u.avatarMime}
size={24}
/>
<span>{u.username}</span>
</Link>
))}
{state.hasMore && (
<button
type="button"
className="user-list-popover-load-more"
onClick={handleLoadMore}
disabled={state.loadingMore}
>
{state.loadingMore
? <Trans>Loading</Trans>
: <Trans>Load more</Trans>}
</button>
)}
</>
)}
</div>,
document.body,
);
}

View File

@@ -1,3 +1,7 @@
import { useRef, useState } from "react";
import { API_URL } from "../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx";
interface VoteButtonProps {
dumpId: string;
count: number;
@@ -10,16 +14,42 @@ interface VoteButtonProps {
export function VoteButton(
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
) {
const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null);
return (
<button
type="button"
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
disabled={disabled}
aria-label={voted ? "Remove vote" : "Upvote"}
title={disabled ? "Log in to vote" : undefined}
>
{count}
</button>
<>
<button
type="button"
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
disabled={disabled}
aria-label={voted ? "Remove vote" : "Upvote"}
title={disabled ? "Log in to vote" : undefined}
>
{" "}
{count > 0
? (
<span
ref={countRef}
className="vote-count-clickable"
onClick={(e) => {
e.stopPropagation();
setPopoverOpen((o) => !o);
}}
>
{count}
</span>
)
: count}
</button>
{popoverOpen && (
<UserListPopover
anchorRef={countRef}
onClose={() => setPopoverOpen(false)}
fetchUrl={`${API_URL}/api/dumps/${dumpId}/voters`}
/>
)}
</>
);
}