feat: tag route

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-11 20:22:26 +02:00
1 parent 2afd1cc713
commit 0c7d0ec982
11 files changed
+254 -12

No files matched your search

+5
View File
@@ -12,6 +12,7 @@ import ModelsPage from "./pages/ModelsPage";
import ModelDetailPage from "./pages/ModelDetailPage";
import CollectionsPage from "./pages/CollectionsPage";
import CollectionDetailPage from "./pages/CollectionDetailPage";
import TagDetailPage from "./pages/TagDetailPage";
import AuthorPage from "./pages/AuthorPage";
import AuthPage from "./pages/AuthPage";
import VerifyEmailPage from "./pages/VerifyEmailPage";
@@ -120,6 +121,10 @@ function AppShell({
path="/models/collections/:collectionId"
element={<CollectionDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
/>
<Route
path="/models/tags/:tagName"
element={<TagDetailPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
/>
<Route
path="/models/:printId"
element={<ModelDetailPage theme={resolvedTheme} onSelectFolder={setFolderId} onUnauthorized={onUnauthorized} />}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

@@ -60,6 +60,13 @@ function useRouteChrome() {
// Overridden by CollectionDetailPage's usePageHeader once the collection loads.
title = t("models:collections.pageTitle");
onBack = () => navigate(-1);
} else if (path.startsWith("/models/tags/")) {
// The tag name is already known from the URL (unlike a collection, a tag isn't a fetched
// entity), so this is set directly rather than waiting on TagDetailPage's usePageHeader --
// avoids a "Models" title flash before that effect runs.
const tagName = path.slice("/models/tags/".length);
title = tagName ? t("models:tags.detail.title", { name: decodeURIComponent(tagName) }) : t("models:pageTitle");
onBack = () => navigate(-1);
} else if (path.startsWith("/models/")) {
title = t("models:pageTitle");
onBack = () => navigate(-1);
+7
View File
@@ -1112,6 +1112,7 @@
"saveFailed": "Failed to save the collection. Please try again."
},
"detail": {
"title": "Collection: {{name}}",
"empty": "No models in this collection yet.",
"confirmDelete": "Delete \"{{name}}\"? Models in it are not deleted.",
"deleteFailed": "Failed to delete the collection."
@@ -1121,6 +1122,12 @@
"notFound": "Collection not found."
}
},
"tags": {
"detail": {
"title": "Tag: {{name}}",
"empty": "No models with this tag yet."
}
},
"errors": {
"loadFailed": "Unable to load models.",
"loadCategoriesFailed": "Unable to load categories.",
+7
View File
@@ -1113,6 +1113,7 @@
"saveFailed": "Nepavyko išsaugoti kolekcijos. Bandykite dar kartą."
},
"detail": {
"title": "Kolekcija: {{name}}",
"empty": "Šioje kolekcijoje kol kas nėra modelių.",
"confirmDelete": "Ištrinti „{{name}}“? Joje esantys modeliai nebus ištrinti.",
"deleteFailed": "Nepavyko ištrinti kolekcijos."
@@ -1122,6 +1123,12 @@
"notFound": "Kolekcija nerasta."
}
},
"tags": {
"detail": {
"title": "Žyma: {{name}}",
"empty": "Su šia žyma modelių kol kas nėra."
}
},
"errors": {
"loadFailed": "Nepavyko įkelti modelių.",
"loadCategoriesFailed": "Nepavyko įkelti kategorijų.",
@@ -52,7 +52,7 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
const goBack = () => navigate("/models/collections");
usePageHeader({
title: collection ? collectionDisplayName(collection, t) : undefined,
title: collection ? t("models:collections.detail.title", { name: collectionDisplayName(collection, t) }) : undefined,
actions: collection && !collection.system_key ? (
<CollectionActionsMenu
collection={collection}
@@ -150,10 +150,11 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
gridTemplateColumns: "repeat(6, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(2, 1fr)" },
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(6, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(2, 1fr)" },
}}
>
{items.map(item => (
+13 -1
View File
@@ -75,7 +75,19 @@ export default function CollectionsPage({ onUnauthorized, theme, previewMode }:
<CircularProgress size={22} />
</Stack>
) : collections.length ? (
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", columnGap: "20px", rowGap: "20px" }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(6, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(2, 1fr)" },
}}
>
{collections.map(collection => (
<CollectionCard
key={collection.id}
@@ -59,7 +59,7 @@ export default function ModelSidePanel({ print, onSelectFolder, onUnauthorized }
const authorName = print.author?.name || print.author?.handle || print.creator || null;
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: "12px", position: "sticky", top: 16 }}>
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: "12px", position: { xs: "static", md: "sticky" }, top: 16 }}>
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
+8 -1
View File
@@ -246,6 +246,7 @@ export default function ModelDetailPage({ theme, onSelectFolder, onUnauthorized
label={tag}
size="small"
variant="outlined"
onClick={() => navigate(`/models/tags/${encodeURIComponent(tag)}`)}
sx={{ bgcolor: "background.paper", borderColor: "divider", color: "text.primary" }}
/>
))}
@@ -256,7 +257,13 @@ export default function ModelDetailPage({ theme, onSelectFolder, onUnauthorized
</Paper>
</Box>
<ModelSidePanel print={print} onSelectFolder={onSelectFolder} onUnauthorized={onUnauthorized} />
{/* alignSelf: stretch overrides the grid's own alignItems: "start" just for this column,
so this box is as tall as the (taller) left column -- giving the sticky panel inside
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} onSelectFolder={onSelectFolder} onUnauthorized={onUnauthorized} />
</Box>
</Box>
{previewOpen && <Model3DPreviewModal print={print} onClose={() => setPreviewOpen(false)} />}
+5 -4
View File
@@ -220,10 +220,11 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
gridTemplateColumns: "repeat(5, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(2, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(1, 1fr)" },
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(2, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(1, 1fr)" },
}}
>
{items.map(item => (
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams, useSearchParams } from "react-router-dom";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Print, type PrintSortMode, printsApi } from "../../api/prints";
import { type Collection, collectionsApi } from "../../api/collections";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
import ModelCard from "../ModelsPage/ModelCard";
import SortTabs from "../ModelsPage/SortTabs";
import CollectionCard from "../CollectionsPage/CollectionCard";
const PAGE_SIZE = 24;
type Props = {
theme: ResolvedTheme;
previewMode: PreviewMode;
onUnauthorized?: () => void;
};
/** Same list/grid/sort/infinite-scroll shape as CollectionDetailPage, but for a tag: tags aren't
* entities with their own id/description/menu, just a string carried in the URL, so there's no
* fetch-by-id step (or "not found" state) -- the page header title comes directly from the
* route param and the list is prints filtered by that tag, plus (since collections can carry
* tags too) any collections carrying it, shown first in the same grid as CollectionCards. */
export default function TagDetailPage({ theme, previewMode, onUnauthorized }: Props) {
const { tagName } = useParams<{ tagName: string }>();
const tag = tagName ? decodeURIComponent(tagName) : "";
const { t } = useTranslation(["models", "common"]);
const [items, setItems] = useState<Print[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const sortModeParam = searchParams.get("orderBy");
const sortMode: PrintSortMode = sortModeParam === "popular" || sortModeParam === "downloads" ? sortModeParam : "newest";
const setSortMode = (mode: PrintSortMode) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
if (mode === "newest") next.delete("orderBy");
else next.set("orderBy", mode);
return next;
});
};
usePageHeader({ title: tag ? t("models:tags.detail.title", { name: tag }) : undefined });
const handleError = (err: unknown, message?: string) => {
if (err instanceof UnauthorizedError) {
onUnauthorized?.();
return true;
}
console.error(err);
if (message) alert(message);
return false;
};
useEffect(() => {
if (!tag) return;
let cancelled = false;
setLoading(true);
(async () => {
try {
const result = await printsApi.list({ tags: [tag], order_by: sortMode, limit: PAGE_SIZE, offset: 0 });
if (cancelled) return;
setItems(result.items);
setOffset(result.items.length);
setHasMore(result.hasMore);
} catch (err) {
if (cancelled) return;
handleError(err, t("models:errors.loadFailed"));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tag, sortMode]);
// Collections list has no server-side tag filter (and isn't paginated -- CollectionsPage
// fetches all of them too), so this filters client-side and refetches only on tag change, not
// sortMode -- collections here aren't sorted, just shown ahead of the prints grid.
useEffect(() => {
if (!tag) return;
let cancelled = false;
(async () => {
try {
const all = await collectionsApi.list();
if (cancelled) return;
setCollections(all.filter(c => c.tags.includes(tag)));
} catch (err) {
if (cancelled) return;
handleError(err, t("models:errors.loadFailed"));
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tag]);
const loadMore = async () => {
if (loadingMore || !hasMore || !tag) return;
setLoadingMore(true);
try {
const result = await printsApi.list({ tags: [tag], order_by: sortMode, limit: PAGE_SIZE, offset });
setItems(prev => [...prev, ...result.items]);
setOffset(offset + result.items.length);
setHasMore(result.hasMore);
} catch (err) {
handleError(err, t("models:errors.loadFailed"));
} finally {
setLoadingMore(false);
}
};
const loadMoreSentinelRef = useInfiniteScroll(loadMore, hasMore, loading || loadingMore);
if (loading) {
return (
<Stack alignItems="center" sx={{ py: 8 }}>
<CircularProgress size={22} />
</Stack>
);
}
return (
<Stack spacing={2} sx={{ maxWidth: "1920px", mx: "auto" }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<SortTabs value={sortMode} onChange={setSortMode} />
</Box>
{collections.length || items.length ? (
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(6, 1fr)",
columnGap: "20px",
rowGap: "20px",
"@media (max-width: 1979px)": { gridTemplateColumns: "repeat(6, 1fr)" },
"@media (max-width: 1684px)": { gridTemplateColumns: "repeat(5, 1fr)" },
"@media (max-width: 1404px)": { gridTemplateColumns: "repeat(4, 1fr)" },
"@media (max-width: 1124px)": { gridTemplateColumns: "repeat(3, 1fr)" },
"@media (max-width: 860px)": { gridTemplateColumns: "repeat(2, 1fr)" },
}}
>
{collections.map(collection => (
<CollectionCard
key={collection.id}
collection={collection}
theme={theme}
previewMode={previewMode}
onUpdated={updated => setCollections(prev => prev.map(c => (c.id === updated.id ? updated : c)))}
onDeleted={deletedId => setCollections(prev => prev.filter(c => c.id !== deletedId))}
onUnauthorized={onUnauthorized}
/>
))}
{items.map(item => (
<ModelCard
key={item.id}
item={item}
theme={theme}
previewMode={previewMode}
onDeleted={deletedId => setItems(prev => prev.filter(i => i.id !== deletedId))}
onFavoriteChange={updated => setItems(prev => prev.map(i => (i.id === updated.id ? updated : i)))}
onUnauthorized={onUnauthorized}
/>
))}
</Box>
{hasMore && (
<Stack ref={loadMoreSentinelRef} direction="row" justifyContent="center" sx={{ py: 1 }}>
{loadingMore && (
<Stack direction="row" alignItems="center" spacing={1} sx={{ color: "text.secondary" }}>
<CircularProgress size={14} />
<Typography variant="caption">{t("models:grid.loadingMore")}</Typography>
</Stack>
)}
</Stack>
)}
</Stack>
) : (
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
<Typography variant="body2">{t("models:tags.detail.empty")}</Typography>
</Stack>
)}
</Stack>
);
}