feat: add author hover popover preview card

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-21 21:23:07 +02:00
1 parent 63133ebd15
commit 864ec1cacd
9 files changed
+345 -58

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ export function createApp(): Express {
cors({
origin: origins.includes("*") ? true : origins,
credentials: true,
exposedHeaders: ["X-Has-More", "X-Next-Offset"],
exposedHeaders: ["X-Has-More", "X-Next-Offset", "X-Total-Count"],
}),
);
+3
View File
@@ -248,6 +248,9 @@ router.get(
const hasMore = sorted.length > start + paged.length;
res.setHeader("X-Has-More", hasMore ? "true" : "false");
res.setHeader("X-Next-Offset", String(start + paged.length));
// Every match, not just this page -- e.g. an author hover card's "N models" alongside a
// 4-item preview, without a second request.
res.setHeader("X-Total-Count", String(sorted.length));
}
const out = paged.map((p) => {
+5 -1
View File
@@ -88,6 +88,8 @@ export type ListPrintsResult = {
items: Print[];
hasMore: boolean;
nextOffset?: number;
/** Total matches across all pages -- only sent when `limit` is. */
total?: number;
};
export type UploadPrintsResult = {
@@ -147,7 +149,9 @@ export const printsApi = {
const hasMore = (res.headers.get("X-Has-More") || "").toLowerCase() === "true";
const nextOffsetRaw = res.headers.get("X-Next-Offset");
const nextOffset = nextOffsetRaw ? Number(nextOffsetRaw) : undefined;
return { items, hasMore, nextOffset };
const totalRaw = res.headers.get("X-Total-Count");
const total = totalRaw ? Number(totalRaw) : undefined;
return { items, hasMore, nextOffset, total };
},
get: async (id: string): Promise<Print> => {
+247
View File
@@ -0,0 +1,247 @@
import { type ReactElement, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import ButtonBase from "@mui/material/ButtonBase";
import Skeleton from "@mui/material/Skeleton";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import PersonIcon from "@mui/icons-material/Person";
import { authorsApi, type Author } from "../api/authors";
import type { AuthUser } from "../api/auth";
import { type Print, printsApi } from "../api/prints";
import { printProviderInfo } from "../constants/importProviders";
import { SELF_AUTHOR_ID } from "../constants/selfAuthor";
import { useGravatarUrl } from "../hooks/useGravatarUrl";
const PREVIEW_MODEL_COUNT = 3;
// The card's width follows from its one-row model grid: PREVIEW_MODEL_COUNT squares of
// MODEL_SQUARE_PX, the 6px gaps between them (gap: 0.75 below), and 12px side padding (p: 1.5).
const MODEL_SQUARE_PX = 70;
const CARD_WIDTH = PREVIEW_MODEL_COUNT * MODEL_SQUARE_PX + (PREVIEW_MODEL_COUNT - 1) * 6 + 2 * 12;
const COVER_HEIGHT = 120;
// Long enough that sweeping the pointer across a grid of cards doesn't pop cards open, short
// enough to feel like a response to deliberately resting on an author.
const ENTER_DELAY_MS = 450;
// Short grace period so the pointer can travel from the link onto the card itself.
const LEAVE_DELAY_MS = 150;
// Re-hovering the same author within this window reuses the last load (counts stay near-live
// without a request on every hover).
const CACHE_TTL_MS = 60_000;
type AuthorPreview = {
author: Author | null; // null for the viewer's own uploads (SELF_AUTHOR_ID) -- no Author row
models: Print[];
total: number;
};
const previewCache = new Map<string, { at: number; promise: Promise<AuthorPreview> }>();
function loadAuthorPreview(authorId: string): Promise<AuthorPreview> {
const cached = previewCache.get(authorId);
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.promise;
const promise = Promise.all([
authorId === SELF_AUTHOR_ID ? Promise.resolve(null) : authorsApi.get(authorId),
printsApi.list({ author_id: authorId, order_by: "newest", limit: PREVIEW_MODEL_COUNT, offset: 0 }),
]).then(([author, list]) => ({ author, models: list.items, total: list.total ?? list.items.length }));
// A failed load must not stick in the cache -- the next hover should just try again.
promise.catch(() => previewCache.delete(authorId));
previewCache.set(authorId, { at: Date.now(), promise });
return promise;
}
function modelThumbUrl(print: Print): string | null {
const url = print.thumb_url || print.preview_images[0]?.url;
return url ? printsApi.fileUrl(url) : null;
}
type CardProps = {
authorId: string;
viewer?: AuthUser | null;
};
function AuthorPreviewCard({ authorId, viewer }: CardProps) {
const { t } = useTranslation(["models"]);
const navigate = useNavigate();
const isSelf = authorId === SELF_AUTHOR_ID;
const viewerAvatarUrl = useGravatarUrl(isSelf ? viewer?.email : undefined, 88);
const [preview, setPreview] = useState<AuthorPreview | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
loadAuthorPreview(authorId).then(
result => { if (!cancelled) setPreview(result); },
() => { if (!cancelled) setFailed(true); },
);
return () => { cancelled = true; };
}, [authorId]);
const author = preview?.author ?? null;
const providerInfo = author ? printProviderInfo(author.provider) : null;
const name = isSelf ? viewer?.display_name : author?.name || author?.handle;
const handle = isSelf ? null : author?.handle;
const avatarUrl = isSelf ? viewerAvatarUrl : author?.avatar_url ?? undefined;
const coverUrl = isSelf ? viewer?.background_url : author?.background_url;
const loading = !preview && !failed;
const countLabel = preview
? isSelf
? t("models:author.popup.selfModelCount", { count: preview.total })
: t("models:author.popup.modelCount", { count: preview.total, provider: providerInfo!.label })
: null;
return (
// Portal-rendered, but React still bubbles its clicks through the component tree -- stop
// them here so clicking inside the card doesn't also trigger the link/card it's anchored to
// (e.g. a model grid card's own navigate-to-model onClick).
<Box onClick={e => e.stopPropagation()} sx={{ width: CARD_WIDTH }}>
<Box
sx={{
position: "relative",
height: COVER_HEIGHT,
bgcolor: providerInfo?.color ?? "primary.main",
backgroundImage: coverUrl
? `url("${coverUrl}")`
: "linear-gradient(135deg, rgba(255,255,255,0.18), rgba(0,0,0,0.25))",
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<ButtonBase
onClick={() => navigate(`/authors/${authorId}`)}
sx={{
position: "absolute",
left: 12,
right: 12,
bottom: 12,
justifyContent: "flex-start",
gap: 1.25,
p: 1,
borderRadius: 2,
textAlign: "left",
bgcolor: "background.paper",
boxShadow: 2,
"&:hover .author-popup-name": { color: "primary.main" },
}}
>
<Avatar src={avatarUrl || undefined} sx={{ width: 44, height: 44, flexShrink: 0 }}>
<PersonIcon />
</Avatar>
<Box sx={{ minWidth: 0 }}>
{loading ? (
<>
<Skeleton width={140} />
<Skeleton width={90} />
</>
) : (
<>
<Typography
className="author-popup-name"
variant="subtitle2"
fontWeight={700}
noWrap
sx={{ color: theme => theme.thingport.headingText, transition: "color .15s ease" }}
>
{name || t("models:card.unknownAuthor")}
</Typography>
{handle && (
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: "block" }}>
@{handle}
</Typography>
)}
</>
)}
</Box>
</ButtonBase>
</Box>
<Box sx={{ p: 1.5 }}>
{failed ? (
<Typography variant="body2" color="text.secondary">{t("models:author.popup.loadFailed")}</Typography>
) : (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.25 }}>
{countLabel ?? <Skeleton width={180} />}
</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: `repeat(${PREVIEW_MODEL_COUNT}, 1fr)`, gap: 0.75 }}>
{loading
? Array.from({ length: PREVIEW_MODEL_COUNT }, (_, idx) => (
<Skeleton key={idx} variant="rounded" sx={{ width: "100%", height: "auto", aspectRatio: "1 / 1" }} />
))
: preview!.models.map(model => {
const thumb = modelThumbUrl(model);
return (
<Tooltip key={model.id} title={model.title || model.name} disableInteractive>
<ButtonBase
onClick={() => navigate(`/models/${model.id}`)}
sx={{
aspectRatio: "1 / 1",
borderRadius: 1.5,
overflow: "hidden",
bgcolor: "action.hover",
transition: "transform .15s ease, box-shadow .15s ease",
"&:hover": { transform: "translateY(-1px)", boxShadow: 3 },
}}
>
{thumb && (
<Box
component="img"
src={thumb}
alt={model.title || model.name}
sx={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
)}
</ButtonBase>
</Tooltip>
);
})}
</Box>
</>
)}
</Box>
</Box>
);
}
type Props = CardProps & {
/** The author link (name and/or avatar) the card anchors to -- must be able to hold a ref. */
children: ReactElement;
/** Skip the card entirely, e.g. for an "Unknown" author with nothing to preview. */
disabled?: boolean;
};
/** Wraps an author link so resting the pointer on it (or focusing it) opens a preview card: the
* author's cover, avatar/name/@handle, how many of their models are in this library, and their
* latest few. `authorId` may be SELF_AUTHOR_ID for the viewer's own uploads (pass `viewer`).
* Nothing is fetched until the card actually opens. */
export default function AuthorHoverCard({ authorId, viewer, children, disabled }: Props) {
if (disabled) return children;
return (
<Tooltip
title={<AuthorPreviewCard authorId={authorId} viewer={viewer} />}
enterDelay={ENTER_DELAY_MS}
enterNextDelay={ENTER_DELAY_MS}
leaveDelay={LEAVE_DELAY_MS}
placement="bottom-start"
slotProps={{
tooltip: {
sx: {
p: 0,
maxWidth: "none",
overflow: "hidden",
borderRadius: 3,
bgcolor: "background.paper",
color: "text.primary",
boxShadow: 8,
border: "1px solid",
borderColor: "divider",
},
},
}}
>
{children}
</Tooltip>
);
}
+8 -1
View File
@@ -1190,7 +1190,14 @@
"linkConfirmMessage": "This links \"{{name}}\" as your own identity. Your \"My models\" page will also list their models, and your profile bio/cover picture will be filled in from theirs if you don't already have one set.",
"linkConfirmLabel": "Yes, it's me",
"linkSuccess": "Linked \"{{name}}\" to your account",
"linkFailed": "Failed to link this author to your account"
"linkFailed": "Failed to link this author to your account",
"popup": {
"modelCount_one": "{{count}} model imported from {{provider}}",
"modelCount_other": "{{count}} models imported from {{provider}}",
"selfModelCount_one": "{{count}} model uploaded",
"selfModelCount_other": "{{count}} models uploaded",
"loadFailed": "Couldn't load this author."
}
},
"collections": {
"pageTitle": "Collections",
+10 -1
View File
@@ -1191,7 +1191,16 @@
"linkConfirmMessage": "Tai susies „{{name}}“ kaip jūsų pačių tapatybę. Jūsų „Mano modeliai“ puslapyje taip pat bus rodomi jų modeliai, o jūsų profilio biografija / viršelio nuotrauka bus užpildyta iš jų, jei dar neturite savo nustatytos.",
"linkConfirmLabel": "Taip, tai aš",
"linkSuccess": "„{{name}}“ susieta su jūsų paskyra",
"linkFailed": "Nepavyko susieti šio autoriaus su jūsų paskyra"
"linkFailed": "Nepavyko susieti šio autoriaus su jūsų paskyra",
"popup": {
"modelCount_one": "{{count}} modelis importuotas iš {{provider}}",
"modelCount_few": "{{count}} modeliai importuoti iš {{provider}}",
"modelCount_other": "{{count}} modelių importuota iš {{provider}}",
"selfModelCount_one": "{{count}} modelis įkeltas",
"selfModelCount_few": "{{count}} modeliai įkelti",
"selfModelCount_other": "{{count}} modelių įkelta",
"loadFailed": "Nepavyko įkelti autoriaus."
}
},
"collections": {
"pageTitle": "Kolekcijos",
@@ -14,6 +14,7 @@ import StarIcon from "@mui/icons-material/Star";
import PersonIcon from "@mui/icons-material/Person";
import { dashboardApi, type DashboardAuthor } from "../../api/dashboard";
import SeeMoreDialog from "./SeeMoreDialog";
import AuthorHoverCard from "../../components/AuthorHoverCard";
type Props = {
authors: DashboardAuthor[];
@@ -26,18 +27,20 @@ function AuthorRow({ author, rank, modelCountLabel, onClick }: {
onClick: () => void;
}) {
return (
<ListItemButton onClick={onClick} sx={{ borderRadius: 1, px: 1 }}>
<Typography sx={{ width: 24, flexShrink: 0, color: "text.secondary", fontWeight: 600 }}>{rank}</Typography>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar src={author.avatar_url ?? undefined} sx={{ width: 40, height: 40 }}>
<PersonIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
<ListItemText primary={author.name || author.handle || "—"} primaryTypographyProps={{ noWrap: true }} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{modelCountLabel(author.model_count)}
</Typography>
</ListItemButton>
<AuthorHoverCard authorId={author.id}>
<ListItemButton onClick={onClick} sx={{ borderRadius: 1, px: 1 }}>
<Typography sx={{ width: 24, flexShrink: 0, color: "text.secondary", fontWeight: 600 }}>{rank}</Typography>
<ListItemAvatar sx={{ minWidth: 48 }}>
<Avatar src={author.avatar_url ?? undefined} sx={{ width: 40, height: 40 }}>
<PersonIcon fontSize="small" />
</Avatar>
</ListItemAvatar>
<ListItemText primary={author.name || author.handle || "—"} primaryTypographyProps={{ noWrap: true }} />
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, pl: 1 }}>
{modelCountLabel(author.model_count)}
</Typography>
</ListItemButton>
</AuthorHoverCard>
);
}
@@ -23,6 +23,7 @@ import { dividerBorderColor } from "../../theme";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useDownloadPrint } from "./useDownloadPrint";
import RollingNumber from "../../components/RollingNumber";
import AuthorHoverCard from "../../components/AuthorHoverCard";
import DownloadPickerDialog from "./DownloadPickerDialog";
type Props = {
@@ -114,28 +115,34 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
{t("models:detail.author")}
</Typography>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
width: "fit-content",
cursor: print.author || showViewerAsAuthor ? "pointer" : "default",
color: (muiTheme) => muiTheme.thingport.headingText,
...(print.author || showViewerAsAuthor ? { "&:hover": { color: "primary.main" } } : undefined),
}}
onClick={() => {
if (print.author) navigate(`/authors/${print.author.id}`);
else if (showViewerAsAuthor) navigate(`/authors/${SELF_AUTHOR_ID}`);
}}
<AuthorHoverCard
authorId={print.author ? print.author.id : SELF_AUTHOR_ID}
viewer={viewer}
disabled={!print.author && !showViewerAsAuthor}
>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
width: "fit-content",
cursor: print.author || showViewerAsAuthor ? "pointer" : "default",
color: (muiTheme) => muiTheme.thingport.headingText,
...(print.author || showViewerAsAuthor ? { "&:hover": { color: "primary.main" } } : undefined),
}}
onClick={() => {
if (print.author) navigate(`/authors/${print.author.id}`);
else if (showViewerAsAuthor) navigate(`/authors/${SELF_AUTHOR_ID}`);
}}
>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
</AuthorHoverCard>
</Box>
{print.category_id && print.category_name && (
+28 -21
View File
@@ -19,6 +19,7 @@ import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import StarToggle from "../../components/StarToggle";
import HoverSlideshow from "../../components/media/HoverSlideshow";
import AuthorHoverCard from "../../components/AuthorHoverCard";
import RollingNumber from "../../components/RollingNumber";
import { useFavoriteToggle } from "../../hooks/useFavoriteToggle";
import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu";
@@ -204,28 +205,34 @@ export default function ModelCard({
{item.title || item.name}
</Typography>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 0.75 }}>
<Stack
direction="row"
alignItems="center"
spacing={0.75}
sx={{
minWidth: 0,
color: "#858585",
...(author || showViewerAsAuthor ? { cursor: "pointer", "&:hover": { color: "#00b800" } } : undefined),
}}
onClick={e => {
if (!author && !showViewerAsAuthor) return;
e.stopPropagation();
navigate(`/authors/${author ? author.id : SELF_AUTHOR_ID}`);
}}
<AuthorHoverCard
authorId={author ? author.id : SELF_AUTHOR_ID}
viewer={viewer}
disabled={!author && !showViewerAsAuthor}
>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 20, height: 20, fontSize: 11, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="caption" noWrap sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={0.75}
sx={{
minWidth: 0,
color: "#858585",
...(author || showViewerAsAuthor ? { cursor: "pointer", "&:hover": { color: "#00b800" } } : undefined),
}}
onClick={e => {
if (!author && !showViewerAsAuthor) return;
e.stopPropagation();
navigate(`/authors/${author ? author.id : SELF_AUTHOR_ID}`);
}}
>
<Avatar src={authorAvatarUrl || undefined} sx={{ width: 20, height: 20, fontSize: 11, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="caption" noWrap sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
</AuthorHoverCard>
<Stack direction="row" spacing={1.5} sx={{ color: "#858585", flexShrink: 0 }}>
<Stack direction="row" alignItems="center" spacing={0.4}>
<VisibilityIcon sx={{ fontSize: 14 }} />