Files
gerbeur/src/components/UserListPopover.tsx
khannurien 7afb5d3f07
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
v3: added comment likes, added votes/likes list view, added db migrations mechanism, updated project dependencies
2026-06-21 14:17:56 +00:00

197 lines
5.7 KiB
TypeScript

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,
);
}