chore(sidebar): replace static mode with a library-content fallback
The rail had two navigation grammars for the same destinations: a compact icon strip on recognized routes, and StaticNavigation — full labeled rows — on everything else (/about, /403, /404, wildcard). Crossing onto one of those pages rearranged the entire rail, and any route nobody remembered to classify silently inherited the alternate layout. Make the frame invariant instead: header, global strip, contextual content, footer — on every route. Pages without a specific sidebar tenant now fall back to the default library content (the home collection for signed-in users, explore for guests), following the convention that error and utility pages render inside the persistent shell rather than degrading it. The memo-detail route gets the same fallback while the page has not yet published its descriptor, instead of a blank scroll area. Off the collection routes the library is display-plus-navigation, so StatisticsView and TagsSection accept a navigationTarget: calendar and tag clicks land on / or /explore with the filter in the query string, which RootLayout's route-change effect would otherwise wipe. StaticNavigation is deleted, About returns to living only in the user menu (the showAbout prop is gone), and the guest strip now marks About active on /about.
This commit is contained in:
parent
bdf87e8699
commit
ec689c3a03
5 changed files with 52 additions and 108 deletions
|
|
@ -62,7 +62,7 @@ import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_se
|
|||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemosLogo from "../MemosLogo";
|
||||
import { getSidebarRouteKind } from "./routes";
|
||||
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "./SidebarRow";
|
||||
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "./SidebarRow";
|
||||
import SidebarSectionHeader from "./SidebarSectionHeader";
|
||||
import TagsSection from "./TagsSection";
|
||||
|
||||
|
|
@ -237,14 +237,19 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
|
|||
|
||||
const showViews = !!currentUser && (context === "home" || context === "archived" || context === "explore");
|
||||
|
||||
// Off the collection routes (the library shown as fallback content), calendar and tag
|
||||
// clicks must land somewhere that renders the filtered feed.
|
||||
const onCollectionRoute = isMemoScopeRoute(location.pathname) || !!profileMatch;
|
||||
const filterTarget = onCollectionRoute ? undefined : context === "explore" ? ROUTES.EXPLORE : ROUTES.HOME;
|
||||
|
||||
return (
|
||||
<div className="space-y-3.5">
|
||||
{context === "profile" && <ProfileMode />}
|
||||
<section>
|
||||
<StatisticsView statisticsData={statistics} onDateSelect={() => setMobileOpen(false)} />
|
||||
<StatisticsView statisticsData={statistics} navigationTarget={filterTarget} onDateSelect={() => setMobileOpen(false)} />
|
||||
</section>
|
||||
{showViews && <ViewsSection />}
|
||||
<TagsSection tagCount={tags} onSelect={() => setMobileOpen(false)} />
|
||||
<TagsSection tagCount={tags} navigationTarget={filterTarget} onSelect={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -380,6 +385,8 @@ const MemoDetailSidebarContent = () => {
|
|||
|
||||
const RouteSidebarContent = () => {
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
const { memoDetail } = useAppSidebar();
|
||||
const kind = getSidebarRouteKind(location.pathname);
|
||||
if (kind === "home" || kind === "archived" || kind === "explore" || kind === "profile") {
|
||||
return <CollectionSidebarContent context={kind} />;
|
||||
|
|
@ -388,8 +395,10 @@ const RouteSidebarContent = () => {
|
|||
if (kind === "attachments") return <AttachmentsSidebarContent />;
|
||||
if (kind === "inbox") return <InboxSidebarContent />;
|
||||
if (kind === "settings") return <SettingsSidebarContent />;
|
||||
if (kind === "memo") return <MemoDetailSidebarContent />;
|
||||
return null;
|
||||
if (kind === "memo" && memoDetail) return <MemoDetailSidebarContent />;
|
||||
// Routes without a specific tenant (about, error pages, unknown paths, memo detail
|
||||
// before the page publishes its descriptor) fall back to the default library content.
|
||||
return <CollectionSidebarContent context={currentUser ? "home" : "explore"} />;
|
||||
};
|
||||
|
||||
interface GlobalNavItem {
|
||||
|
|
@ -467,7 +476,7 @@ const GlobalNavigation = () => {
|
|||
icon: EarthIcon,
|
||||
active: routeKind === "explore" || routeKind === "profile" || routeKind === "memo",
|
||||
},
|
||||
{ id: "about", label: t("common.about"), path: ROUTES.ABOUT, icon: InfoIcon, active: false },
|
||||
{ id: "about", label: t("common.about"), path: ROUTES.ABOUT, icon: InfoIcon, active: location.pathname === ROUTES.ABOUT },
|
||||
];
|
||||
|
||||
const scopeTrigger = (
|
||||
|
|
@ -584,80 +593,10 @@ const GlobalNavigation = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const StaticNavigation = () => {
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
const { data: notifications = [] } = useNotifications();
|
||||
const { setMobileOpen } = useAppSidebar();
|
||||
const unreadCount = notifications.filter((notification) => notification.status === UserNotification_Status.UNREAD).length;
|
||||
const primaryItems: Array<{ id: string; label: string; path: string; icon: LucideIcon }> = currentUser
|
||||
? [
|
||||
{ id: "home", label: t("common.home"), path: ROUTES.HOME, icon: HouseIcon },
|
||||
{ id: "explore", label: t("common.explore"), path: ROUTES.EXPLORE, icon: EarthIcon },
|
||||
]
|
||||
: [{ id: "explore", label: t("common.explore"), path: ROUTES.EXPLORE, icon: EarthIcon }];
|
||||
const personalItems: Array<{ id: string; label: string; path: string; icon: LucideIcon; count?: number }> = currentUser
|
||||
? [
|
||||
{ id: "attachments", label: t("common.attachments"), path: ROUTES.ATTACHMENTS, icon: PaperclipIcon },
|
||||
{ id: "inbox", label: t("common.inbox"), path: ROUTES.INBOX, icon: BellIcon, count: unreadCount },
|
||||
]
|
||||
: [];
|
||||
|
||||
const renderLink = (item: (typeof primaryItems)[number] | (typeof personalItems)[number]) => {
|
||||
const Icon = item.icon;
|
||||
const count = "count" in item ? item.count : undefined;
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
|
||||
>
|
||||
<Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_CLASSES}>{count}</span>}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<nav className={cn("pt-1", SIDEBAR_HORIZONTAL_PADDING)} aria-label="Primary">
|
||||
<div className="space-y-0.5">{primaryItems.map(renderLink)}</div>
|
||||
{personalItems.length > 0 && (
|
||||
<>
|
||||
<div className="mx-2 my-2 border-t border-border/70" />
|
||||
<div className="space-y-0.5">{personalItems.map(renderLink)}</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<nav className={cn("mt-auto pb-2", SIDEBAR_HORIZONTAL_PADDING)} aria-label="Utility">
|
||||
<Link
|
||||
to={ROUTES.ABOUT}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
aria-current={location.pathname === ROUTES.ABOUT ? "page" : undefined}
|
||||
className={cn(
|
||||
SIDEBAR_ROW_CLASSES,
|
||||
location.pathname === ROUTES.ABOUT
|
||||
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<InfoIcon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
||||
<span className="min-w-0 flex-1 truncate">{t("common.about")}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppSidebar = ({ className }: { className?: string }) => {
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
|
||||
const staticMode = getSidebarRouteKind(location.pathname) === "empty";
|
||||
return (
|
||||
<aside className={cn("flex h-full w-full select-none flex-col bg-sidebar text-sidebar-foreground", className)}>
|
||||
<div className={cn("flex h-13 shrink-0 items-center justify-between gap-2", SIDEBAR_HORIZONTAL_PADDING)}>
|
||||
|
|
@ -680,22 +619,14 @@ const AppSidebar = ({ className }: { className?: string }) => {
|
|||
<SearchIcon className="size-4" strokeWidth={1.8} />
|
||||
</Button>
|
||||
</div>
|
||||
{staticMode ? (
|
||||
<StaticNavigation />
|
||||
) : (
|
||||
<>
|
||||
<GlobalNavigation />
|
||||
<div className="mx-3 mt-2 border-t border-border/70" />
|
||||
<div
|
||||
className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-3 [scrollbar-width:thin]", SIDEBAR_HORIZONTAL_PADDING)}
|
||||
>
|
||||
<RouteSidebarContent />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<GlobalNavigation />
|
||||
<div className="mx-3 mt-2 border-t border-border/70" />
|
||||
<div className={cn("min-h-0 flex-1 overflow-y-auto overflow-x-hidden pt-2 pb-3 [scrollbar-width:thin]", SIDEBAR_HORIZONTAL_PADDING)}>
|
||||
<RouteSidebarContent />
|
||||
</div>
|
||||
<footer className="shrink-0 border-t border-border/70">
|
||||
{currentUser ? (
|
||||
<UserMenu showAbout={!staticMode} />
|
||||
<UserMenu />
|
||||
) : (
|
||||
<Link
|
||||
to={ROUTES.AUTH}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { HashIcon, ListIcon, ListTreeIcon } from "lucide-react";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { replaceFiltersByFactor, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { useLocalStorage, useOverflowTitle } from "@/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -12,6 +13,8 @@ import SidebarSectionHeader from "./SidebarSectionHeader";
|
|||
interface Props {
|
||||
tagCount: Record<string, number>;
|
||||
onSelect?: () => void;
|
||||
/** When set, tag clicks land on this route with the tag filter instead of filtering the current one. */
|
||||
navigationTarget?: string;
|
||||
}
|
||||
|
||||
const TagPath = forwardRef<HTMLSpanElement, { tag: string }>(({ tag }, ref) => {
|
||||
|
|
@ -55,9 +58,10 @@ const FlatTagRow = ({ tag, amount, active, onClick }: FlatTagRowProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
const TagsSection = ({ tagCount, onSelect }: Props) => {
|
||||
const TagsSection = ({ tagCount, onSelect, navigationTarget }: Props) => {
|
||||
const t = useTranslate();
|
||||
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
|
||||
const navigate = useNavigate();
|
||||
const { filters, setFilters, getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
|
||||
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
||||
const activeTags = new Set(getFiltersByFactor("tagSearch").map((filter) => filter.value));
|
||||
const activeTag = activeTags.values().next().value as string | undefined;
|
||||
|
|
@ -68,6 +72,13 @@ const TagsSection = ({ tagCount, onSelect }: Props) => {
|
|||
}
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
if (navigationTarget) {
|
||||
const nextFilters = replaceFiltersByFactor(filters, "tagSearch", [{ factor: "tagSearch", value: tag }]);
|
||||
setFilters(nextFilters);
|
||||
navigate({ pathname: navigationTarget, search: `?filter=${stringifyFilters(nextFilters)}` });
|
||||
onSelect?.();
|
||||
return;
|
||||
}
|
||||
const active = activeTags.has(tag);
|
||||
if (active) {
|
||||
removeFilter((filter) => filter.factor === "tagSearch" && filter.value === tag);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,15 @@ import { MonthNavigator } from "./MonthNavigator";
|
|||
interface Props {
|
||||
statisticsData: StatisticsData;
|
||||
onDateSelect?: () => void;
|
||||
/** When set, day clicks land on this route with the date filter instead of filtering the current one. */
|
||||
navigationTarget?: string;
|
||||
}
|
||||
|
||||
const StatisticsView = (props: Props) => {
|
||||
const { statisticsData } = props;
|
||||
const { activityStats, timeBasis } = statisticsData;
|
||||
const { filters } = useMemoFilterContext();
|
||||
const navigateToDateFilter = useDateFilterNavigation();
|
||||
const navigateToDateFilter = useDateFilterNavigation(props.navigationTarget);
|
||||
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM"));
|
||||
const selectedDate = filters.find((filter) => filter.factor === "displayTime")?.value;
|
||||
|
||||
|
|
|
|||
|
|
@ -34,11 +34,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|||
|
||||
interface Props {
|
||||
collapsed?: boolean;
|
||||
showAbout?: boolean;
|
||||
}
|
||||
|
||||
const UserMenu = (props: Props) => {
|
||||
const { collapsed, showAbout = true } = props;
|
||||
const { collapsed } = props;
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { setMobileOpen } = useAppSidebar();
|
||||
|
|
@ -179,12 +178,10 @@ const UserMenu = (props: Props) => {
|
|||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{showAbout && (
|
||||
<DropdownMenuItem onClick={() => navigateFromMenu(Routes.ABOUT)}>
|
||||
<InfoIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.about")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => navigateFromMenu(Routes.ABOUT)}>
|
||||
<InfoIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.about")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigateFromMenu(Routes.SETTING)}>
|
||||
<SettingsIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.settings")}
|
||||
|
|
|
|||
|
|
@ -124,20 +124,21 @@ describe("App sidebar logo", () => {
|
|||
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows common destinations instead of empty route content for an authenticated user", () => {
|
||||
it("falls back to the library content on a route without a specific tenant", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/404"]}>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "common.home" })).toHaveAttribute("href", "/");
|
||||
expect(screen.getByRole("link", { name: "common.explore" })).toHaveAttribute("href", "/explore");
|
||||
expect(screen.getByText("Calendar")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.views")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tags")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "common.attachments" })).toHaveAttribute("href", "/attachments");
|
||||
expect(screen.getByRole("link", { name: "common.inbox" })).toHaveAttribute("href", "/inbox");
|
||||
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
|
||||
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "common.about" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("User menu").closest("footer")).not.toBeNull();
|
||||
expect(screen.queryByText("Calendar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a visitor sidebar for a guest on a route without contextual content", () => {
|
||||
|
|
@ -156,7 +157,8 @@ describe("App sidebar logo", () => {
|
|||
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks About active on the About page", () => {
|
||||
it("marks About active for a guest on the About page", () => {
|
||||
authState.currentUser = undefined;
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/about"]}>
|
||||
<AppSidebar />
|
||||
|
|
@ -164,6 +166,7 @@ describe("App sidebar logo", () => {
|
|||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByText("Calendar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a compact scope menu and places views below the calendar", async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue