feat: make print count be more interactive

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-21 20:44:33 +02:00
1 parent 5cdd06ab38
commit 8e4f7ddf47
8 files changed
+134 -22

No files matched your search

+7 -5
View File
@@ -306,14 +306,16 @@ router.post(
"/print/:id/download",
asyncHandler(async (req, res) => {
// Recorded explicitly from the detail page's download actions (single-file, per-plate, or
// "download all as zip"), rather than inside the plate-file/zip routes themselves -- those
// are shared by the 3D viewer, snapshot generation, and bulk tag/category zips, none of which
// are a user downloading *this* model.
await prisma.print.updateMany({
// "download all as zip") and "Open in {Slicer}", rather than inside the plate-file/zip routes
// themselves -- those are shared by the 3D viewer, snapshot generation, and bulk tag/category
// zips, none of which are a user downloading *this* model. Returns the updated print (like
// the favorite routes) so the caller can show the new count without refetching.
const result = await prisma.print.updateMany({
where: { id: req.params.id, userId: req.userId },
data: { printCount: { increment: 1 } },
});
res.json({ ok: true });
if (result.count === 0) throw new HttpError(404, "Print not found");
res.json(await printOutById(req.userId!, req.params.id));
}),
);
+4 -3
View File
@@ -438,9 +438,10 @@ export const printsApi = {
return res.json();
},
/** Records a completed download of this print (called once per explicit download action --
* single-file, per-plate, or "download all as zip" -- from the model detail page). */
recordDownload: async (id: string) => {
/** Records one use of this print -- bumps its print count -- once per explicit download action
* (single-file, per-plate, or "download all as zip") or "Open in {Slicer}". Returns the updated
* print so the caller can show the new count straight away. */
recordDownload: async (id: string): Promise<Print> => {
const res = await fetch(`${apiBase()}/print/${id}/download`, {
method: "POST",
headers: authHeaders(),
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from "react";
import { keyframes } from "@emotion/react";
import Box from "@mui/material/Box";
// Two sequential phases, both moving downward: the old value drops out below first, and only
// then does the new one drop in from above. No fading -- the container's overflow clip is what
// hides each value outside its slot.
const EXIT_MS = 200;
const ENTER_MS = 240;
const enterFromTop = keyframes`
from { transform: translateY(-100%); }
to { transform: translateY(0); }
`;
const exitToBottom = keyframes`
from { transform: translateY(0); }
to { transform: translateY(100%); }
`;
type Props = {
value: number;
};
/** Renders a number inline, animating each change like an odometer. The first render is static,
* so only a change seen while mounted (e.g. a print count bumped by a download) animates.
* Inherits typography from its parent, so wrap it in the Typography that styled the plain
* number before. */
export default function RollingNumber({ value }: Props) {
const lastValueRef = useRef(value);
// The value being rolled out (null when idle), and a counter that remounts both spans -- and so
// restarts their animations -- on every change, including a second one mid-animation.
const [outgoing, setOutgoing] = useState<number | null>(null);
const [rollKey, setRollKey] = useState(0);
useEffect(() => {
if (value === lastValueRef.current) return;
setOutgoing(lastValueRef.current);
setRollKey(k => k + 1);
lastValueRef.current = value;
}, [value]);
return (
<Box
component="span"
sx={{ position: "relative", display: "inline-flex", overflow: "hidden", verticalAlign: "bottom" }}
>
<Box
component="span"
key={`in-${rollKey}`}
// Keyed off rollKey rather than `outgoing` so clearing the old value when its exit ends
// doesn't strip this still-running animation. "backwards" holds it above the slot
// (clipped, so invisible) through the delay while the old value is leaving.
sx={
rollKey > 0
? {
animation: `${enterFromTop} ${ENTER_MS}ms cubic-bezier(0.2, 0.8, 0.3, 1) ${EXIT_MS}ms backwards`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
}
: undefined
}
>
{value}
</Box>
{outgoing !== null && (
<Box
component="span"
key={`out-${rollKey}`}
aria-hidden
onAnimationEnd={() => setOutgoing(null)}
sx={{
position: "absolute",
left: 0,
top: 0,
animation: `${exitToBottom} ${EXIT_MS}ms cubic-bezier(0.5, 0, 0.9, 0.4) forwards`,
"@media (prefers-reduced-motion: reduce)": { display: "none" },
}}
>
{outgoing}
</Box>
)}
</Box>
);
}
@@ -87,8 +87,8 @@ export default function ModelActionsMenu({
// Driven by the URL (?edit=<id>) rather than local state, per spec -- lets a direct link (or the
// back button) open/close it too, and lets the grid-card trigger below just navigate there.
const editOpen = searchParams.get("edit") === print.id;
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates } =
useDownloadPrint(print, onUnauthorized);
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates, recordUse } =
useDownloadPrint(print, onUnauthorized, onUpdated);
const closeMenu = () => setAnchorEl(null);
@@ -207,7 +207,15 @@ export default function ModelActionsMenu({
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
</MenuItem>
<Divider />
<MenuItem component="a" href={openInSlicerHref} onClick={closeMenu} disabled={!openInSlicerHref}>
<MenuItem
component="a"
href={openInSlicerHref}
onClick={() => {
closeMenu();
recordUse();
}}
disabled={!openInSlicerHref}
>
<ListItemIcon><LaunchIcon fontSize="small" /></ListItemIcon>
<ListItemText>
{slicerOption
@@ -22,12 +22,15 @@ import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import { dividerBorderColor } from "../../theme";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useDownloadPrint } from "./useDownloadPrint";
import RollingNumber from "../../components/RollingNumber";
import DownloadPickerDialog from "./DownloadPickerDialog";
type Props = {
print: Print;
onSelectCategory: (id: string) => void;
onUnauthorized?: () => void;
/** Receives the updated print after a download or Open in {Slicer} bumps its print count. */
onUpdated?: (print: Print) => void;
/** Only used as a fallback when the print has neither an Author nor a plain `creator` string --
* a direct upload has no import-source author at all, so it shows the viewer's own identity
* instead of "Unknown", since every print here is the viewer's own. */
@@ -41,13 +44,13 @@ type Props = {
* "Download model files" (the same picker-or-direct-download flow as ModelActionsMenu's
* Download, via useDownloadPrint so the two can't drift), view/print counts, and -- only for an
* actually-imported print, per source_provider -- when it was imported. */
export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized, viewer }: Props) {
export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized, onUpdated, viewer }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
const slicerPreference = useSlicerPreference();
const viewerAvatarUrl = useGravatarUrl(viewer?.email, 56);
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates } =
useDownloadPrint(print, onUnauthorized);
const { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates, recordUse } =
useDownloadPrint(print, onUnauthorized, onUpdated);
// "other" has no registered URL protocol to launch -- treated the same as no preference set.
const slicerOption = SLICER_OPTIONS.find(opt => opt.id === slicerPreference && opt.id !== "other");
@@ -149,6 +152,7 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
<Button
component="a"
href={openInSlicerHref}
onClick={recordUse}
startIcon={<LaunchIcon fontSize="small" />}
fullWidth
sx={{
@@ -196,7 +200,7 @@ export default function ModelSidePanel({ print, onSelectCategory, onUnauthorized
sx={{ flex: 1, py: 1, border: "1px solid", borderColor: "divider", borderRadius: 2 }}
>
<PrintIcon fontSize="small" sx={{ color: "text.secondary" }} />
<Typography variant="body2" fontWeight={600}>{print.print_count}</Typography>
<Typography variant="body2" fontWeight={600}><RollingNumber value={print.print_count} /></Typography>
</Stack>
</Stack>
+7 -1
View File
@@ -327,7 +327,13 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
it room to travel as the page scrolls instead of being stuck the moment its own
(short) content ends. */}
<Box sx={{ alignSelf: "stretch" }}>
<ModelSidePanel print={print} onSelectCategory={onSelectCategory} onUnauthorized={onUnauthorized} viewer={viewer} />
<ModelSidePanel
print={print}
onSelectCategory={onSelectCategory}
onUnauthorized={onUnauthorized}
onUpdated={setPrint}
viewer={viewer}
/>
</Box>
</Box>
@@ -7,8 +7,10 @@ import { saveResponseToDisk } from "../../utils/downloadResponse";
/** Shared download logic for a Print: a single-plate model downloads its one file directly; a
* multi-plate one opens a picker (download-all-as-zip, or pick one plate) -- see
* DownloadPickerDialog. Used by both ModelActionsMenu's "Download" menu item and the model
* detail page's own big "Download model files" button, so the two behaviors can't drift apart. */
export function useDownloadPrint(print: Print, onUnauthorized?: () => void) {
* detail page's own big "Download model files" button, so the two behaviors can't drift apart.
* `recordUse` bumps the print count for the other ways of using a model (Open in {Slicer});
* every bump hands the updated print to `onRecorded` so the new count shows up immediately. */
export function useDownloadPrint(print: Print, onUnauthorized?: () => void, onRecorded?: (print: Print) => void) {
const { t } = useTranslation(["models"]);
const [pickerOpen, setPickerOpen] = useState(false);
const [downloading, setDownloading] = useState(false);
@@ -22,6 +24,11 @@ export function useDownloadPrint(print: Print, onUnauthorized?: () => void) {
alert(t("models:detail.downloadFailed"));
};
// Fire-and-forget: a failure to count a use must never look like the download/launch failed.
const recordUse = () => {
printsApi.recordDownload(print.id).then(updated => onRecorded?.(updated)).catch(() => {});
};
const downloadPlate = async (plate: Plate) => {
setDownloading(true);
try {
@@ -30,7 +37,7 @@ export function useDownloadPrint(print: Print, onUnauthorized?: () => void) {
if (!res.ok) throw new Error("Download failed");
await saveResponseToDisk(res, plate.filename || "download");
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
recordUse();
} catch (err) {
handleDownloadError(err);
} finally {
@@ -44,7 +51,7 @@ export function useDownloadPrint(print: Print, onUnauthorized?: () => void) {
const res = await printsApi.downloadZip({ print_ids: [print.id] });
await saveResponseToDisk(res, `${print.name || "model"}.zip`);
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
recordUse();
} catch (err) {
handleDownloadError(err);
} finally {
@@ -63,5 +70,5 @@ export function useDownloadPrint(print: Print, onUnauthorized?: () => void) {
const sortedPlates = print.plates.toSorted((a, b) => a.position - b.position);
return { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates };
return { pickerOpen, setPickerOpen, downloading, handleDownload, downloadPlate, downloadAllZip, sortedPlates, recordUse };
}
+2 -1
View File
@@ -17,6 +17,7 @@ import { printProviderInfo } from "../../constants/importProviders";
import { SELF_AUTHOR_ID } from "../../constants/selfAuthor";
import { useGravatarUrl } from "../../hooks/useGravatarUrl";
import StarToggle from "../../components/StarToggle";
import RollingNumber from "../../components/RollingNumber";
import { useFavoriteToggle } from "../../hooks/useFavoriteToggle";
import ModelActionsMenu from "../ModelDetailPage/ModelActionsMenu";
import type { AuthUser } from "../../api/auth";
@@ -219,7 +220,7 @@ export default function ModelCard({
</Stack>
<Stack direction="row" alignItems="center" spacing={0.4}>
<PrintIcon sx={{ fontSize: 14 }} />
<Typography variant="caption">{item.print_count}</Typography>
<Typography variant="caption"><RollingNumber value={item.print_count} /></Typography>
</Stack>
</Stack>
</Stack>