freezmo/components/Sidebar.tsx
Martin Dvorak df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- axe is clean across every screen in both themes, phone and desktop. Fixed:
  the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
  labelled buttons, so they should not be in the accessibility tree), the
  heading order (deck/homework/smart-review cards are h2 under the page h1;
  LearningSection takes a level so it stays h2 on the library and h3 inside
  the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
  cannot judge text on blurred glass — it reports those nodes as incomplete.
  It skips disabled controls (1.4.3 exempts them) and content behind an open
  sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
  yourself" exercise, labelled "Report exercise", without a skip (there is no
  queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
  quiet nav rows — once the library is full they were the only way to start
  something on desktop and read as just more navigation. "Make my homework"
  is "New homework" wherever it is an action (rail, empty state, share
  chooser, manifest shortcut); the page title and mode switch keep their
  wording.

Version 0.1.6 (three task bumps), SW -> freezmo-v52.
2026-10-12 21:15:56 +00:00

390 lines
14 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { SidebarChatSection } from "@/components/chat/SidebarChatSection";
import {
ChatIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
FlameIcon,
FolderIcon,
GearIcon,
LayersIcon,
MoonIcon,
PlusIcon,
SparklesIcon,
SunIcon,
} from "@/components/icons";
import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { IS_DEV_INSTANCE } from "@/lib/dev-mode";
import { useI18n } from "@/lib/i18n";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { UNORGANIZED_ID } from "@/lib/schemas";
import { deckStore } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
/**
* Desktop navigation rail (lg and up). Phones use the bottom tab bar instead —
* there is no drawer any more, so this is the only consumer of the rail.
*/
function NavContent({ collapsed = false }: { collapsed?: boolean }) {
const pathname = usePathname();
const onChat = pathname === "/chat";
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore(
folderStore.subscribe,
folderStore.getSnapshot,
folderStore.getServerSnapshot,
);
const selected = useSelectedFolder();
const { theme, toggle } = useTheme();
const { t } = useI18n();
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const sortedFolders = useMemo(
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
[folders],
);
const submit = () => {
const result = createFolder(draft);
if ("error" in result) {
setError(result.error);
return;
}
folderSelection.set(result.folder.id);
setDraft("");
setError(null);
setCreating(false);
};
/** Counts of 0 are noise — only show a count when there is something in it. */
const count = (value: number) =>
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null;
/** One shared view row (All decks / Unorganized). */
const viewRow = (id: string, label: string, icon: ReactNode, trailing: ReactNode) => (
<button
type="button"
onClick={() => folderSelection.set(id)}
aria-pressed={selected === id}
title={label}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
{icon}
{!collapsed ? <span className="flex-1 truncate text-left">{label}</span> : null}
{trailing}
</button>
);
const brandMark = (
// eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed
<img
src="/icons/icon-192.png"
alt=""
aria-hidden
className="size-7 shrink-0 rounded-lg shadow-[0_1px_3px_rgb(14_21_40/0.35)]"
/>
);
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* brand ---------------------------------------------------------- */}
<div className={cn("mb-1 flex items-center", collapsed ? "justify-center" : "justify-between")}>
<Link
href="/"
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
aria-label={t("header.home")}
title="Freezmo"
>
{brandMark}
{!collapsed ? (
<>
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
{IS_DEV_INSTANCE ? (
<span className="rounded-md border border-amber-400/60 bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-300">
DEV
</span>
) : null}
</>
) : null}
</Link>
</div>
{/* collapse / expand: its own row directly below the logo, in both
states, so the arrow never jumps around the rail. */}
<button
type="button"
onClick={() => sidebarCollapse.set(!collapsed)}
aria-label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
>
{collapsed ? <ChevronRightIcon className="size-4 shrink-0" /> : <ChevronLeftIcon className="size-4 shrink-0" />}
{!collapsed ? <span className="flex-1 truncate text-left">{t("sidebar.collapse")}</span> : null}
</button>
{/* new deck: a button, not a nav row — it stays visible when the
library is already full (see .nav-action in globals.css) --------- */}
<Link
href="/create"
className={cn("nav-action mb-2.5", collapsed && "justify-center px-2")}
title={t("common.newDeck")}
>
<PlusIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
</Link>
{/* new homework: the same button ------------------------------------ */}
<Link
href="/homework"
className={cn("nav-action mb-3", collapsed && "justify-center px-2")}
title={t("homework.nav")}
>
<SparklesIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
</Link>
{/* chat with your notes --------------------------------------------- */}
<Link
href="/chat"
aria-current={onChat ? "page" : undefined}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("chat.nav")}
>
<ChatIcon className="size-4 shrink-0" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("chat.nav")}</span> : null}
</Link>
{/* On the Chat route the chat list takes the deck navigation's place. */}
{onChat ? <SidebarChatSection collapsed={collapsed} /> : null}
<nav
aria-label={t("sidebar.library")}
className={cn("thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto", onChat && "hidden")}
>
{/* All decks + Unorganized are views, so they sit above Folders. */}
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(
ALL_FOLDERS,
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
null,
)}
</div>
{!collapsed ? (
<div className="flex items-center justify-between px-2 pb-1 pt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("common.folders")}
</span>
<button
type="button"
onClick={() => setCreating(true)}
aria-label={t("common.newFolder")}
title={t("common.newFolder")}
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-3.5" />
</button>
</div>
) : null}
{sortedFolders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => folderSelection.set(folder.id)}
aria-pressed={selected === folder.id}
title={folder.name}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
</button>
))}
{creating && !collapsed ? (
<div className="flex items-center gap-1 px-1 pt-1">
<input
autoFocus
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") submit();
if (event.key === "Escape") {
setCreating(false);
setDraft("");
setError(null);
}
}}
placeholder={t("common.folderName")}
aria-label={t("common.newFolderName")}
maxLength={60}
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-2.5 py-1.5 text-[13px] text-[var(--text)] outline-none focus:border-[var(--text-subtle)]"
/>
<button type="button" onClick={submit} className="btn-primary h-8 px-3 text-[12px]">
{t("common.add")}
</button>
</div>
) : null}
{error ? <p className="px-2 pt-1 text-[12px] text-rose-600 dark:text-rose-300">{error}</p> : null}
</nav>
{/* footer ---------------------------------------------------------- */}
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
<Link
href="/stats"
title={t("nav.stats")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<FlameIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("nav.stats")}</span> : null}
</Link>
<Link
href="/exam"
title={t("nav.exam")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<CompassIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("nav.exam")}</span> : null}
</Link>
<button
type="button"
onClick={() => openSettings()}
title={t("common.settings")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<GearIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.settings")}</span> : null}
</button>
<button
type="button"
onClick={() => {
haptic("toggle");
toggle();
}}
aria-label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
{theme === "dark" ? (
<SunIcon className="size-4 shrink-0 opacity-70" />
) : (
<MoonIcon className="size-4 shrink-0 opacity-70" />
)}
{!collapsed ? (
<span className="flex-1 truncate text-left">
{theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
</span>
) : null}
</button>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Collapsed state (desktop rail) */
/* ------------------------------------------------------------------ */
let collapsed = false;
const collapseListeners = new Set<() => void>();
export const sidebarCollapse = {
subscribe(listener: () => void): () => void {
collapseListeners.add(listener);
return () => collapseListeners.delete(listener);
},
getSnapshot(): boolean {
return collapsed;
},
getServerSnapshot(): boolean {
return false;
},
set(value: boolean): void {
collapsed = value;
try {
window.localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0");
} catch {
/* private mode */
}
collapseListeners.forEach((listener) => listener());
},
hydrate(): void {
try {
collapsed = window.localStorage.getItem(COLLAPSE_KEY) === "1";
} catch {
collapsed = false;
}
collapseListeners.forEach((listener) => listener());
},
};
function useCollapsed(): boolean {
return useSyncExternalStore(
sidebarCollapse.subscribe,
sidebarCollapse.getSnapshot,
sidebarCollapse.getServerSnapshot,
);
}
/* ------------------------------------------------------------------ */
/* Desktop rail */
/* ------------------------------------------------------------------ */
export function Sidebar() {
const isCollapsed = useCollapsed();
const { t } = useI18n();
return (
<aside
aria-label={t("sidebar.label")}
className={cn(
"fixed inset-y-0 left-0 z-40 hidden p-3 transition-[width] duration-200 lg:flex",
isCollapsed ? "w-[84px]" : "w-[268px]",
)}
>
{/* One tall inset glass panel, so the ice scene shows around it. The
min-w-0 keeps it inside the rail: as a flex item its automatic
minimum size is the content's min-content width, and wide content
(e.g. the chat list's search input) would otherwise stretch the
panel past the rail and paint over the page. */}
<div className="sidebar-surface flex min-h-0 min-w-0 flex-1 flex-col px-2.5 py-3">
<NavContent collapsed={isCollapsed} />
</div>
</aside>
);
}
/** Spacer so page content clears the fixed rail (desktop only). */
export function SidebarOffset() {
const isCollapsed = useCollapsed();
return (
<div
aria-hidden
className={cn("hidden shrink-0 transition-[width] duration-200 lg:block", isCollapsed ? "w-[84px]" : "w-[268px]")}
/>
);
}