mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: tag route
This commit is contained in:
1 parent
2afd1cc713
commit
0c7d0ec982
11 files changed
+254
-12
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user