mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 14:42:29 -05:00
feat: add layout subtitle
This commit is contained in:
1 parent
2f2d85018e
commit
907c342bff
11 files changed
+88
-19
No files matched your search
@@ -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]);
|
||||
}
|
||||
@@ -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%" }}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ų",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user