- 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.
390 lines
14 KiB
TypeScript
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]")}
|
|
/>
|
|
);
|
|
}
|