feat: add author page

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-12 12:34:06 +02:00
1 parent cdd2525085
commit 45b6bac574
13 files changed
+397 -65

No files matched your search

+2
View File
@@ -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);
+24
View File
@@ -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;
+2
View File
@@ -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 };
+4 -1
View File
@@ -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={
+13
View File
@@ -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();
},
};
+2
View File
@@ -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));
+11 -3
View File
@@ -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",
+11 -3
View File
@@ -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",
+235 -8
View File
@@ -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[] = [];
+19 -17
View File
@@ -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
+17
View File
@@ -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;
}