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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
This commit is contained in:
@@ -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"
|
||||
|
||||
57
src/components/LikeButton.tsx
Normal file
57
src/components/LikeButton.tsx
Normal 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`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
196
src/components/UserListPopover.tsx
Normal file
196
src/components/UserListPopover.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user