mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: make print count be more interactive
This commit is contained in:
1 parent
5cdd06ab38
commit
8e4f7ddf47
8 files changed
+134
-22
No files matched your search
@@ -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));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user