mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add author page
This commit is contained in:
1 parent
cdd2525085
commit
45b6bac574
13 files changed
+397
-65
No files matched your search
@@ -6,6 +6,7 @@ import { HttpError } from "./utils/fileUtils";
|
||||
import healthRoutes from "./routes/health";
|
||||
import authRoutes from "./routes/auth";
|
||||
import printsRoutes from "./routes/prints";
|
||||
import authorsRoutes from "./routes/authors";
|
||||
import platesRoutes from "./routes/plates";
|
||||
import previewImagesRoutes from "./routes/previewImages";
|
||||
import categoriesRoutes from "./routes/categories";
|
||||
@@ -37,6 +38,7 @@ export function createApp(): Express {
|
||||
app.use("/api", authRoutes);
|
||||
app.use("/api", settingsRoutes);
|
||||
app.use("/api", printsRoutes);
|
||||
app.use("/api", authorsRoutes);
|
||||
app.use("/api", platesRoutes);
|
||||
app.use("/api", previewImagesRoutes);
|
||||
app.use("/api", categoriesRoutes);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Router } from "express";
|
||||
import { prisma } from "../db";
|
||||
import { requireAuth } from "../auth";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { asyncHandler } from "../utils/asyncHandler";
|
||||
import { toAuthorOut } from "../dto";
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// Author rows aren't user-scoped -- they're shared, public creator info pulled from
|
||||
// MakerWorld/Thingiverse/Printables (see authorService.ts's deleteAuthorIfOrphaned, which already
|
||||
// checks Print.authorId across every user, not just one), so there's no per-user ownership check
|
||||
// here beyond being signed in to the instance at all.
|
||||
router.get(
|
||||
"/author/:id",
|
||||
asyncHandler(async (req, res) => {
|
||||
const author = await prisma.author.findUnique({ where: { id: req.params.id } });
|
||||
if (!author) throw new HttpError(404, "Author not found");
|
||||
res.json(toAuthorOut(author));
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -50,10 +50,12 @@ function buildPrintWhere(req: Request): Prisma.PrintWhereInput {
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
const collectionId = typeof req.query.collection_id === "string" ? req.query.collection_id.trim() : "";
|
||||
const authorId = typeof req.query.author_id === "string" ? req.query.author_id.trim() : "";
|
||||
|
||||
const where: Prisma.PrintWhereInput = { userId: req.userId };
|
||||
if (categoryIds.length === 1) where.categoryId = categoryIds[0];
|
||||
else if (categoryIds.length > 1) where.categoryId = { in: categoryIds };
|
||||
if (authorId) where.authorId = authorId;
|
||||
if (collectionId) {
|
||||
const systemKey = systemCollectionKeyForId(collectionId);
|
||||
if (systemKey === "favorites") where.favoritedAt = { not: null };
|
||||
|
||||
@@ -137,7 +137,10 @@ function AppShell({
|
||||
path="/models/:printId"
|
||||
element={<ModelDetailPage theme={resolvedTheme} onSelectCategory={setCategoryId} onUnauthorized={onUnauthorized} />}
|
||||
/>
|
||||
<Route path="/authors/:authorId" element={<AuthorPage />} />
|
||||
<Route
|
||||
path="/authors/:authorId"
|
||||
element={<AuthorPage theme={resolvedTheme} previewMode={previewMode} onUnauthorized={onUnauthorized} />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { authHeaders } from "../utils/auth";
|
||||
import { apiBase, assertOk } from "./client";
|
||||
import type { Author } from "./prints";
|
||||
|
||||
export type { Author };
|
||||
|
||||
export const authorsApi = {
|
||||
get: async (id: string): Promise<Author> => {
|
||||
const res = await fetch(`${apiBase()}/author/${id}`, { headers: authHeaders() });
|
||||
assertOk(res, "Failed to load author");
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
@@ -104,6 +104,7 @@ export const printsApi = {
|
||||
tags?: string[];
|
||||
category_id?: string | string[];
|
||||
collection_id?: string;
|
||||
author_id?: string;
|
||||
order_by?: PrintSortMode;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
@@ -115,6 +116,7 @@ export const printsApi = {
|
||||
qs.set("category_id", Array.isArray(params.category_id) ? params.category_id.join(",") : params.category_id);
|
||||
}
|
||||
if (params.collection_id) qs.set("collection_id", params.collection_id);
|
||||
if (params.author_id) qs.set("author_id", params.author_id);
|
||||
if (params.order_by) qs.set("orderBy", params.order_by);
|
||||
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
|
||||
if (typeof params.offset === "number") qs.set("offset", String(params.offset));
|
||||
|
||||
@@ -441,7 +441,8 @@
|
||||
"success": "Success",
|
||||
"retry": "Retry",
|
||||
"language": "Language",
|
||||
"more": "More"
|
||||
"more": "More",
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"library": {
|
||||
"grid": {
|
||||
@@ -1006,7 +1007,8 @@
|
||||
"card": {
|
||||
"unknownAuthor": "Unknown",
|
||||
"addedToFavorites": "Added \"{{name}}\" to Favourites",
|
||||
"removedFromFavorites": "Removed \"{{name}}\" from Favourites"
|
||||
"removedFromFavorites": "Removed \"{{name}}\" from Favourites",
|
||||
"importedFrom": "Imported from {{provider}}"
|
||||
},
|
||||
"detail": {
|
||||
"preview3d": "3D Preview",
|
||||
@@ -1025,6 +1027,7 @@
|
||||
"addToCollectionLoadFailed": "Failed to load collections. Please try again.",
|
||||
"addToCollectionFailed": "Failed to update collections. Please try again.",
|
||||
"removeFromCollection": "Remove from collection",
|
||||
"confirmRemoveFromCollection": "Remove \"{{name}}\" from this collection?",
|
||||
"removeFromCollectionFailed": "Failed to remove from collection. Please try again.",
|
||||
"openInProvider": "Open in {{provider}}",
|
||||
"openInSlicer": "Open in {{slicer}}",
|
||||
@@ -1064,7 +1067,12 @@
|
||||
},
|
||||
"author": {
|
||||
"pageTitle": "Author",
|
||||
"comingSoon": "Author pages are coming soon."
|
||||
"pageTitleWithName": "Author - {{name}}",
|
||||
"bio": "Bio",
|
||||
"noBio": "No bio yet.",
|
||||
"viewOnProvider": "View on {{provider}}",
|
||||
"notFound": "Author not found.",
|
||||
"noModels": "No models from this author yet."
|
||||
},
|
||||
"collections": {
|
||||
"pageTitle": "Collections",
|
||||
|
||||
@@ -441,7 +441,8 @@
|
||||
"success": "Pavyko",
|
||||
"retry": "Bandyti dar kartą",
|
||||
"language": "Kalba",
|
||||
"more": "Daugiau"
|
||||
"more": "Daugiau",
|
||||
"moreActions": "Daugiau veiksmų"
|
||||
},
|
||||
"library": {
|
||||
"grid": {
|
||||
@@ -1006,7 +1007,8 @@
|
||||
"card": {
|
||||
"unknownAuthor": "Nežinomas",
|
||||
"addedToFavorites": "\"{{name}}\" pridėtas prie Mėgstamiausių",
|
||||
"removedFromFavorites": "\"{{name}}\" pašalintas iš Mėgstamiausių"
|
||||
"removedFromFavorites": "\"{{name}}\" pašalintas iš Mėgstamiausių",
|
||||
"importedFrom": "Importuota iš {{provider}}"
|
||||
},
|
||||
"detail": {
|
||||
"preview3d": "3D peržiūra",
|
||||
@@ -1026,6 +1028,7 @@
|
||||
"addToCollectionLoadFailed": "Nepavyko įkelti kolekcijų. Bandykite dar kartą.",
|
||||
"addToCollectionFailed": "Nepavyko atnaujinti kolekcijų. Bandykite dar kartą.",
|
||||
"removeFromCollection": "Pašalinti iš kolekcijos",
|
||||
"confirmRemoveFromCollection": "Pašalinti „{{name}}“ iš šios kolekcijos?",
|
||||
"removeFromCollectionFailed": "Nepavyko pašalinti iš kolekcijos. Bandykite dar kartą.",
|
||||
"openInProvider": "Atverti „{{provider}}“",
|
||||
"openInSlicer": "Atverti su {{slicer}}",
|
||||
@@ -1065,7 +1068,12 @@
|
||||
},
|
||||
"author": {
|
||||
"pageTitle": "Autorius",
|
||||
"comingSoon": "Autorių puslapiai netrukus bus pasiekiami."
|
||||
"pageTitleWithName": "Autorius - {{name}}",
|
||||
"bio": "Biografija",
|
||||
"noBio": "Biografijos dar nėra.",
|
||||
"viewOnProvider": "Žiūrėti {{provider}} svetainėje",
|
||||
"notFound": "Autorius nerastas.",
|
||||
"noModels": "Iš šio autoriaus dar nėra modelių."
|
||||
},
|
||||
"collections": {
|
||||
"pageTitle": "Kolekcijos",
|
||||
|
||||
@@ -1,16 +1,243 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { authorsApi, type Author } from "../../api/authors";
|
||||
import { type Print, printsApi } from "../../api/prints";
|
||||
import { type PreviewMode } from "../../api/settings";
|
||||
import { type ResolvedTheme } from "../../constants/settingsOptions";
|
||||
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
|
||||
import { useInfiniteScroll } from "../../hooks/useInfiniteScroll";
|
||||
import { importProviderInfo } from "../../constants/importProviders";
|
||||
import { authorProfileUrl } from "../../utils/authorProfileUrl";
|
||||
import ModelCard from "../ModelsPage/ModelCard";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
const COVER_HEIGHT = 184;
|
||||
|
||||
type Props = {
|
||||
theme: ResolvedTheme;
|
||||
previewMode: PreviewMode;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** An author's own page: a cover strip, then a narrow profile column (avatar, name, @handle, a
|
||||
* provider chip linking back to their profile on the site they were imported from, and their
|
||||
* bio) beside a wide "Models" grid of every print by them this user has imported -- the models
|
||||
* route's own grid pattern, reused as-is via ModelCard. The whole thing reads as a page of its
|
||||
* own, sidebar-colored and border-free, sitting inside the app shell rather than blending into
|
||||
* it. Author rows are shared across users (not scoped to this account -- see
|
||||
* authorService.ts's doc comment on the backend), so there's nothing here to edit. */
|
||||
export default function AuthorPage({ theme, previewMode, onUnauthorized }: Props) {
|
||||
const { authorId } = useParams<{ authorId: string }>();
|
||||
const { t } = useTranslation(["models", "common"]);
|
||||
const [author, setAuthor] = useState<Author | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [items, setItems] = useState<Print[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
const displayName = author?.name || author?.handle || t("models:card.unknownAuthor");
|
||||
|
||||
// Only overrides the title -- the route's own default onBack (see AppLayout's useRouteChrome,
|
||||
// "/authors/" -> navigate(-1)) already does exactly "back button goes to the previous page".
|
||||
usePageHeader({
|
||||
title: author ? t("models:author.pageTitleWithName", { name: displayName }) : 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 (!authorId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setNotFound(false);
|
||||
(async () => {
|
||||
try {
|
||||
const [authorResult, printsResult] = await Promise.all([
|
||||
authorsApi.get(authorId),
|
||||
printsApi.list({ author_id: authorId, limit: PAGE_SIZE, offset: 0 }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setAuthor(authorResult);
|
||||
setItems(printsResult.items);
|
||||
setOffset(printsResult.items.length);
|
||||
setHasMore(printsResult.hasMore);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (handleError(err)) return;
|
||||
setNotFound(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authorId]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore || !authorId) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await printsApi.list({ author_id: authorId, 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>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFound || !author) {
|
||||
return (
|
||||
<Stack alignItems="center" spacing={1} sx={{ py: 8, color: "text.secondary" }}>
|
||||
<Typography variant="body2">{t("models:author.notFound")}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const providerInfo = importProviderInfo(author.provider);
|
||||
const profileUrl = authorProfileUrl(author);
|
||||
const bioText = author.bio || author.bio_translated;
|
||||
|
||||
// TODO: real author profile (avatar, bio, links, their models) once the backend exposes a
|
||||
// GET /author/:id endpoint. For now this just gives the route somewhere to go.
|
||||
export default function AuthorPage() {
|
||||
const { t } = useTranslation("models");
|
||||
return (
|
||||
<Stack alignItems="center" justifyContent="center" spacing={1.5} sx={{ py: 10, color: "text.secondary" }}>
|
||||
<PersonIcon sx={{ fontSize: 40 }} />
|
||||
<Typography variant="body2">{t("models:author.comingSoon")}</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ maxWidth: "1920px", mx: "auto", bgcolor: "background.paper", borderRadius: "12px", overflow: "hidden" }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: `${COVER_HEIGHT}px`,
|
||||
bgcolor: "action.hover",
|
||||
...(author.background_url && {
|
||||
// Quoted, not a bare url(...) -- an unquoted CSS url() treats the first unescaped ")"
|
||||
// as its terminator, which a data: URI (or any URL containing one) would break.
|
||||
backgroundImage: `url("${author.background_url}")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", md: "320px 1fr" },
|
||||
gap: "32px",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Avatar src={author.avatar_url ?? undefined} sx={{ width: 112, height: 112, fontSize: 40 }}>
|
||||
<PersonIcon fontSize="large" />
|
||||
</Avatar>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ mt: 2 }}>{displayName}</Typography>
|
||||
{author.handle && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>@{author.handle}</Typography>
|
||||
)}
|
||||
{providerInfo && profileUrl && (
|
||||
<Box
|
||||
component="a"
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={t("models:author.viewOnProvider", { provider: providerInfo.label })}
|
||||
sx={{
|
||||
display: "inline-block",
|
||||
mt: 1.5,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.6,
|
||||
color: "#fff",
|
||||
textDecoration: "none",
|
||||
bgcolor: providerInfo.color,
|
||||
}}
|
||||
>
|
||||
{providerInfo.label}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="subtitle1" fontWeight={700} sx={{ mt: 3, mb: 1 }}>{t("models:author.bio")}</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", color: bioText ? "text.primary" : "text.disabled" }}>
|
||||
{bioText || t("models:author.noBio")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>{t("models:pageTitle")}</Typography>
|
||||
{items.length ? (
|
||||
<Stack spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(5, 1fr)",
|
||||
columnGap: "20px",
|
||||
rowGap: "20px",
|
||||
"@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 => (
|
||||
<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)))}
|
||||
onUpdated={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>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">{t("models:author.noModels")}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
@@ -51,15 +52,15 @@ type Props = {
|
||||
iconFontSize?: number;
|
||||
};
|
||||
|
||||
/** The "..." menu for a model: Download (single file, or a plate picker / zip-all for multi-plate
|
||||
* models), Edit (opens EditModelModal, driven by a `?edit=<id>` URL param -- see openEdit/
|
||||
* closeEdit below), "Add to collection" (opens the chip-toggle picker), "Remove from collection"
|
||||
* (only while browsing one), Delete (confirm, then delete), then a divider followed by the two
|
||||
* "leaves the app" actions grouped together: "Open in {Slicer}" (launches the user's preferred
|
||||
* slicer via its own URL protocol -- disabled when no slicer is set, or it's set to "Other") and
|
||||
* -- only for an imported print -- "Open in {Provider}" linking back to the original model page.
|
||||
* Shared by the model detail page's header and the Models/Collection grids' per-card hover
|
||||
* overlay. */
|
||||
/** The "..." menu for a model: "Add to collection" (opens the chip-toggle picker), "Remove from
|
||||
* collection" (only while browsing one -- grouped right after Add, its counterpart), Download
|
||||
* (single file, or a plate picker / zip-all for multi-plate models), Edit (opens EditModelModal,
|
||||
* driven by a `?edit=<id>` URL param -- see openEdit/closeEdit below), Delete (confirm, then
|
||||
* delete), then a divider followed by the two "leaves the app" actions grouped together: "Open
|
||||
* in {Slicer}" (launches the user's preferred slicer via its own URL protocol -- disabled when
|
||||
* no slicer is set, or it's set to "Other") and -- only for an imported print -- "Open in
|
||||
* {Provider}" linking back to the original model page. Shared by the model detail page's header
|
||||
* and the Models/Collection grids' per-card hover overlay. */
|
||||
export default function ModelActionsMenu({
|
||||
print,
|
||||
onUnauthorized,
|
||||
@@ -106,6 +107,11 @@ export default function ModelActionsMenu({
|
||||
const handleRemoveFromCollection = async () => {
|
||||
closeMenu();
|
||||
if (!collectionId) return;
|
||||
const confirmed = await confirmDialog({
|
||||
message: t("models:detail.confirmRemoveFromCollection", { name: print.title || print.name }),
|
||||
confirmLabel: t("common:remove"),
|
||||
});
|
||||
if (!confirmed) return;
|
||||
setRemovingFromCollection(true);
|
||||
try {
|
||||
await collectionsApi.removeItem(collectionId, print.id);
|
||||
@@ -154,30 +160,26 @@ export default function ModelActionsMenu({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => setAnchorEl(e.currentTarget)}
|
||||
aria-label={t("common:more") ?? undefined}
|
||||
disabled={deleting || removingFromCollection}
|
||||
sx={triggerSx}
|
||||
>
|
||||
{deleting || removingFromCollection ? (
|
||||
<CircularProgress size={18} />
|
||||
) : iconFontSize ? (
|
||||
<MoreVertIcon sx={{ fontSize: iconFontSize }} />
|
||||
) : (
|
||||
<MoreVertIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
<Tooltip title={t("common:moreActions")}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={e => setAnchorEl(e.currentTarget)}
|
||||
aria-label={t("common:moreActions") ?? undefined}
|
||||
disabled={deleting || removingFromCollection}
|
||||
sx={triggerSx}
|
||||
>
|
||||
{deleting || removingFromCollection ? (
|
||||
<CircularProgress size={18} />
|
||||
) : iconFontSize ? (
|
||||
<MoreVertIcon sx={{ fontSize: iconFontSize }} />
|
||||
) : (
|
||||
<MoreVertIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
|
||||
<MenuItem onClick={onDownloadClick} disabled={downloading}>
|
||||
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("common:download")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openEdit}>
|
||||
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("common:edit")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => { closeMenu(); setAddToCollectionOpen(true); }}>
|
||||
<ListItemIcon><PlaylistAddIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("models:detail.addToCollection")}</ListItemText>
|
||||
@@ -188,6 +190,14 @@ export default function ModelActionsMenu({
|
||||
<ListItemText>{t("models:detail.removeFromCollection")}</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={onDownloadClick} disabled={downloading}>
|
||||
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("common:download")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openEdit}>
|
||||
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>{t("common:edit")}</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDelete}>
|
||||
<ListItemIcon><DeleteIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
<ListItemText sx={{ color: "error.main" }}>{t("common:delete")}</ListItemText>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@mui/material/styles";
|
||||
import Paper from "@mui/material/Paper";
|
||||
@@ -78,6 +78,20 @@ export default function CategoriesPanel({ categories, loading, selectedId, onSel
|
||||
|
||||
const untitledLabel = t("models:categories.untitled");
|
||||
|
||||
// Keeps the tree's expand state in sync with an externally-driven selectedId -- a page refresh
|
||||
// on ?category=<childId>, a browser back/forward, or any other selection that didn't originate
|
||||
// from clicking a root row here (see handleRootClick, which sets expandedId itself for the
|
||||
// click case). Without this, the selected child's parent root would stay collapsed and the
|
||||
// child would never render at all (its List lives inside a `Collapse ... unmountOnExit`), even
|
||||
// though the grid alongside it is correctly filtered. Re-runs once categories finishes loading
|
||||
// too, since selectedId is already known from the URL before that fetch resolves.
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
const selected = categories.find(c => c.id === selectedId);
|
||||
if (!selected) return;
|
||||
setExpandedId(selected.parent_id || selected.id);
|
||||
}, [selectedId, categories]);
|
||||
|
||||
const { roots, childrenByParent } = useMemo(() => {
|
||||
const childrenMap: Record<string, Category[]> = {};
|
||||
const rootList: Category[] = [];
|
||||
|
||||
@@ -135,23 +135,25 @@ export default function ModelCard({
|
||||
</Box>
|
||||
|
||||
{providerInfo && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 1,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
color: "#fff",
|
||||
bgcolor: providerInfo.color,
|
||||
}}
|
||||
>
|
||||
{providerInfo.label}
|
||||
</Box>
|
||||
<Tooltip title={t("models:card.importedFrom", { provider: providerInfo.label })}>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 1,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
color: "#fff",
|
||||
bgcolor: providerInfo.color,
|
||||
}}
|
||||
>
|
||||
{providerInfo.label}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Stack
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Author } from "../api/prints";
|
||||
|
||||
/** The author's own profile page on the site they were imported from -- what the Author page's
|
||||
* provider chip links to. MakerWorld's Author.links holds arbitrary external links the creator
|
||||
* added to their bio (Instagram, a personal site, ...), not their MakerWorld profile, so that
|
||||
* one is built from their numeric external id instead: .../u/<id> always resolves, unlike the
|
||||
* newer @handle path, which needs a handle the creator may not have set. Thingiverse and
|
||||
* Printables both already store the real profile URL as links[0] at import time (Thingiverse:
|
||||
* the official API's own creator.public_url; Printables: built from their @handle -- see
|
||||
* backend's services/{thingiverseApi,printablesApi}.ts), so those are used directly instead of
|
||||
* re-deriving a URL scheme here. */
|
||||
export function authorProfileUrl(author: Pick<Author, "provider" | "external_id" | "links">): string | null {
|
||||
if (author.provider === "makerworld") {
|
||||
return `https://makerworld.com/en/u/${author.external_id}`;
|
||||
}
|
||||
return author.links[0] ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user