feat: add layout subtitle

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-19 20:14:50 +02:00
1 parent 2f2d85018e
commit 907c342bff
11 files changed
+88 -19

No files matched your search

+10 -3
View File
@@ -54,6 +54,7 @@ function useRouteChrome() {
const path = location.pathname;
let title = t("sidebar.dashboard");
let subtitle: string | undefined;
let onBack: (() => void) | undefined;
if (path === "/") {
title = t("sidebar.dashboard");
@@ -72,7 +73,12 @@ function useRouteChrome() {
// 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");
if (tagName) {
title = t("models:tags.detail.title", { name: decodeURIComponent(tagName) });
subtitle = t("models:tags.detail.subtitle");
} else {
title = t("models:pageTitle");
}
onBack = goBack;
} else if (path.startsWith("/models/")) {
title = t("models:pageTitle");
@@ -118,7 +124,7 @@ function useRouteChrome() {
onBack = () => navigate("/admin");
}
return { title, onBack };
return { title, subtitle, onBack };
}
type ShellProps = Omit<AppLayoutProps, "muiTheme">;
@@ -144,7 +150,7 @@ function AppLayoutShell({
children,
}: ShellProps) {
const { t } = useTranslation(["app", "common"]);
const { title: routeTitle, onBack: routeOnBack } = useRouteChrome();
const { title: routeTitle, subtitle: routeSubtitle, onBack: routeOnBack } = useRouteChrome();
const [pageHeader, setPageHeader] = React.useState<PageHeader>(null);
const { refresh: refreshNotifications } = useNotifications();
@@ -171,6 +177,7 @@ function AppLayoutShell({
)}
<TopBar
title={pageHeader?.title || routeTitle}
subtitle={pageHeader?.subtitle ?? routeSubtitle}
onBack={pageHeader?.onBack ?? routeOnBack}
actions={pageHeader?.actions}
categoryId={categoryId}
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import InputBase from "@mui/material/InputBase";
@@ -40,6 +40,7 @@ type Props = {
export default function GlobalSearch({ onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const navigate = useNavigate();
const location = useLocation();
const anchorRef = useRef<HTMLDivElement | null>(null);
const [query, setQuery] = useState("");
const [focused, setFocused] = useState(false);
@@ -64,6 +65,19 @@ export default function GlobalSearch({ onUnauthorized }: Props) {
return () => observer.disconnect();
}, []);
// Clears any typed-but-not-acted-on query the moment the route actually changes -- `goTo`
// below already clears it for the "picked a result" path, but this covers every other way of
// leaving the page (a sidebar link, browser back/forward, a different page's own navigation)
// so a stale search never lingers into the next page. Keyed on pathname specifically, not the
// full location, so same-page changes (a sort tab, a search-param tweak) don't wipe it --
// only an actual change of route counts as "leaving".
useEffect(() => {
setQuery("");
setFocused(false);
// Only the pathname itself should trigger this -- see the comment above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
useEffect(() => {
if (debouncedQuery.length < MIN_QUERY_LENGTH) {
setResult(null);
@@ -1,7 +1,16 @@
import { createContext, useContext, useEffect } from "react";
import type React from "react";
export type PageHeader = { title?: string; actions?: React.ReactNode; onBack?: () => void } | null;
export type PageHeader = {
title?: string;
/** Shown smaller, under the title (see TopBar) -- the entity kind ("Collection", "Tag",
* "Model") for a detail page, so that context doesn't have to be spelled out as a
* "Collection: " prefix baked into the title text itself, competing with the name for the
* same line's limited width. */
subtitle?: string;
actions?: React.ReactNode;
onBack?: () => void;
} | null;
/** Default no-op setter so usePageHeader is a harmless no-op for any page rendered outside
* AppLayout (e.g. tests) rather than throwing. */
@@ -15,12 +24,14 @@ export const PageHeaderContext = createContext<(header: PageHeader) => void>(()
export function usePageHeader(header: PageHeader) {
const setHeader = useContext(PageHeaderContext);
const title = header?.title;
const subtitle = header?.subtitle;
const actions = header?.actions;
const onBack = header?.onBack;
useEffect(() => {
setHeader({ title, actions, onBack });
setHeader({ title, subtitle, actions, onBack });
return () => setHeader(null);
// title/actions/onBack are the only meaningful inputs; setHeader is stable from AppLayout's state setter.
// title/subtitle/actions/onBack are the only meaningful inputs; setHeader is stable from
// AppLayout's state setter.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title, actions, onBack]);
}, [title, subtitle, actions, onBack]);
}
+17 -2
View File
@@ -15,6 +15,10 @@ import GlobalSearch from "./GlobalSearch";
type Props = {
title: string;
/** Shown smaller, under the title -- the entity kind ("Collection", "Tag", "Model") for a
* detail page, kept off the title's own line so a long name gets the full line's width
* instead of sharing it with a "Collection: " prefix (see PageHeaderContext). */
subtitle?: string;
onBack?: () => void;
/** Slot for page-specific actions next to the title (an overflow "more" menu, etc.) --
* nothing currently populates it, but the header supports it the same way it will once a
@@ -39,6 +43,7 @@ type Props = {
* cluster on the right. */
export default function TopBar({
title,
subtitle,
onBack,
actions,
categoryId,
@@ -73,7 +78,10 @@ export default function TopBar({
ref={rootRef}
sx={{
display: "grid",
gridTemplateColumns: "1fr minmax(0, 480px) 1fr",
// Capped at 360px (3/4 of the original 480px) -- on narrower laptop screens the title
// column doesn't have enough room left to share with a still-wide search box before a
// long collection/tag/model name has to ellipsize hard.
gridTemplateColumns: "1fr minmax(0, 360px) 1fr",
alignItems: "center",
gap: 1.5,
// Both of these used to live on `main` (pt) / as this bar's own margin (mb) -- moved to
@@ -108,7 +116,14 @@ export default function TopBar({
</IconButton>
</Tooltip>
)}
<Typography variant="h6" fontWeight={700} noWrap>{title}</Typography>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" fontWeight={700} noWrap>{title}</Typography>
{subtitle && (
<Typography variant="caption" noWrap sx={{ display: "block", mt: "-4px", color: "text.secondary" }}>
{subtitle}
</Typography>
)}
</Box>
{actions}
</Stack>
<Box sx={{ minWidth: 0, justifySelf: "center", width: "100%" }}>
+8 -3
View File
@@ -1095,6 +1095,7 @@
"uploadedDirectly": "Uploaded directly to Thingport"
},
"detail": {
"subtitle": "Model",
"preview3d": "3D Preview",
"description": "Description",
"noDescription": "No description yet.",
@@ -1151,7 +1152,8 @@
},
"author": {
"pageTitle": "Author",
"pageTitleWithName": "Author - {{name}}",
"pageTitleWithName": "{{name}}",
"subtitle": "Author",
"myModelsPageTitle": "My models",
"bio": "Bio",
"noBio": "No bio yet.",
@@ -1195,7 +1197,8 @@
"saveFailed": "Failed to save the collection. Please try again."
},
"detail": {
"title": "Collection: {{name}}",
"title": "{{name}}",
"subtitle": "Collection",
"empty": "No models in this collection yet.",
"confirmDelete": "Delete \"{{name}}\"? Models in it are not deleted.",
"deleteFailed": "Failed to delete the collection."
@@ -1217,7 +1220,8 @@
"downloadAllZip": "Download all Tag models as zip",
"downloadZipTitle": "Download tag \"{{name}}\" as zip",
"detail": {
"title": "Tag: {{name}}",
"title": "{{name}}",
"subtitle": "Tag",
"empty": "No models with this tag yet."
}
},
@@ -1244,6 +1248,7 @@
},
"categories": {
"title": "Categories",
"subtitle": "Category",
"all": "All",
"untitled": "Untitled",
"empty": "No categories yet",
+8 -3
View File
@@ -1095,6 +1095,7 @@
"uploadedDirectly": "Įkelta tiesiai į Thingport"
},
"detail": {
"subtitle": "Modelis",
"preview3d": "3D peržiūra",
"description": "Aprašymas",
"noDescription": "Aprašymo dar nėra.",
@@ -1152,7 +1153,8 @@
},
"author": {
"pageTitle": "Autorius",
"pageTitleWithName": "Autorius - {{name}}",
"pageTitleWithName": "{{name}}",
"subtitle": "Autorius",
"myModelsPageTitle": "Mano modeliai",
"bio": "Biografija",
"noBio": "Biografijos dar nėra.",
@@ -1196,7 +1198,8 @@
"saveFailed": "Nepavyko išsaugoti kolekcijos. Bandykite dar kartą."
},
"detail": {
"title": "Kolekcija: {{name}}",
"title": "{{name}}",
"subtitle": "Kolekcija",
"empty": "Šioje kolekcijoje kol kas nėra modelių.",
"confirmDelete": "Ištrinti „{{name}}“? Joje esantys modeliai nebus ištrinti.",
"deleteFailed": "Nepavyko ištrinti kolekcijos."
@@ -1218,7 +1221,8 @@
"downloadAllZip": "Atsisiųsti visus žymos modelius kaip zip",
"downloadZipTitle": "Atsisiųsti žymą „{{name}}“ kaip zip",
"detail": {
"title": "Žyma: {{name}}",
"title": "{{name}}",
"subtitle": "Žyma",
"empty": "Su šia žyma modelių kol kas nėra."
}
},
@@ -1245,6 +1249,7 @@
},
"categories": {
"title": "Kategorijos",
"subtitle": "Kategorija",
"all": "Visos",
"untitled": "Be pavadinimo",
"empty": "Kol kas nėra kategorijų",
+5
View File
@@ -86,8 +86,13 @@ export default function AuthorPage({ theme, previewMode, onUnauthorized, viewer,
// Only overrides the title -- the route's own default onBack (see AppLayout's useRouteChrome,
// "/authors/" -> useSmartBack) already does exactly "back button goes to the previous page".
// A real (non-self) author gets a small "Author" subtitle under their name instead of an
// "Author - " prefix sharing the title line with it -- same treatment as the collection/tag/
// model/category detail headers. "My models" has no such prefix to move in the first place,
// so the self view stays plain.
usePageHeader({
title: isSelf ? t("models:author.myModelsPageTitle") : author ? t("models:author.pageTitleWithName", { name: displayName }) : undefined,
subtitle: !isSelf && author ? t("models:author.subtitle") : undefined,
});
const handleError = (err: unknown, message?: string) => {
@@ -57,6 +57,7 @@ export default function CollectionDetailPage({ theme, previewMode, onUnauthorize
usePageHeader({
title: collection ? t("models:collections.detail.title", { name: collectionDisplayName(collection, t) }) : undefined,
subtitle: collection ? t("models:collections.detail.subtitle") : undefined,
actions: collection && !collection.system_key ? (
<Stack direction="row" alignItems="center" spacing={0.5}>
<CollectionBookmarkButton
@@ -55,6 +55,7 @@ export default function ModelDetailPage({ theme, onSelectCategory, onUnauthorize
usePageHeader({
title: print ? (print.title || print.name) : undefined,
subtitle: print ? t("models:detail.subtitle") : undefined,
actions: print ? (
<Stack direction="row" alignItems="center" spacing={0.5}>
<FavoriteButton print={print} onUpdated={setPrint} onUnauthorized={onUnauthorized} />
+7 -3
View File
@@ -113,11 +113,15 @@ export default function ModelsPage({ categoryId, onSelectCategory, categoriesVer
}, [categoryId, categories]);
// Plain "Models" (and the route's default back-to-Dashboard) while nothing's selected; once a
// category is active, the title reflects it and back instead clears the filter -- "back to
// all" one level at a time, matching the sidebar's own initial-vs-filtered framing.
// category is active, the title becomes its name (with a small "Category" subtitle underneath
// instead of a "Models - " prefix competing with the name for the same line's width -- same
// treatment as the collection/tag/model detail pages' own subtitles) and back instead clears
// the filter -- "back to all" one level at a time, matching the sidebar's own
// initial-vs-filtered framing.
const selectedCategory = categoryId ? categories.find(f => f.id === categoryId) ?? null : null;
usePageHeader({
title: selectedCategory ? `${t("models:pageTitle")} - ${selectedCategory.name || t("models:categories.untitled")}` : undefined,
title: selectedCategory ? selectedCategory.name || t("models:categories.untitled") : undefined,
subtitle: selectedCategory ? t("models:categories.subtitle") : undefined,
onBack: categoryId ? () => onSelectCategory(null) : undefined,
});
@@ -64,6 +64,7 @@ export default function TagDetailPage({ theme, previewMode, onUnauthorized, onBo
usePageHeader({
title: tag ? t("models:tags.detail.title", { name: tag }) : undefined,
subtitle: tag ? t("models:tags.detail.subtitle") : undefined,
actions: tag ? (
<Stack direction="row" alignItems="center" spacing={0.5}>
<TagBookmarkButton