Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Layout:
- `remote/` — everything that talks to another host: the outbound client (`outbound`), `enrichment/`, `portrait`, `lrclib`, `podcast` (the feed fetcher) and `scrobblers/`.
- `http/` — HTTP outside the two API surfaces: `security_headers`, `public_share`, `reference` (the `/reference` page) and `webui`.
- `media/` — streaming, transcoding, artwork, canvases and `replay_gain`; `quality/` — the audio analysis.
- `webapp/` — the embedded React client, built to `webapp/dist` and compiled in by `rust_embed`. Under `webapp/src/`, only `main.tsx` (the router and the providers) sits at the root; everything else is filed by what it is: `api/` (the HTTP client, one file per resource over the session and transport in `client.ts`, re-exported by `index.ts` so the rest of the app imports `../api`; then the query keys and the query client), `pages/` (one screen per file), `components/` (pieces shared between screens), `player/`, `state/` (contexts: i18n, preferences, library scope), `lib/` (logic with no React in it) and `styles/` (the sheet and the design tokens). A test sits beside the file it tests.
- `webapp/` — the embedded React client, built to `webapp/dist` and compiled in by `rust_embed`. Under `webapp/src/`, only `main.tsx` (the bootstrap: the stylesheets in cascade order, the providers, `createRouter` and the render) sits at the root; everything else is filed by what it is: `app/` (the shell, the rail and its More sheet, and the routes — one file per area under `app/routes/`, assembled in matching order by `app/route-tree.ts`), `api/` (the HTTP client, one file per resource over the session and transport in `client.ts`, re-exported by `index.ts` so the rest of the app imports `../api`; then the query keys and the query client), `pages/` (one screen per file), `components/` (pieces shared between screens), `player/`, `state/` (contexts: i18n, preferences, library scope), `lib/` (logic with no React in it) and `styles/` (the sheet and the design tokens). A test sits beside the file it tests.

The desktop app at [`InstaZDLL/WaveFlow`](https://github.com/InstaZDLL/WaveFlow) is a separate repository. It consumes this API but is not part of this codebase.

Expand Down
100 changes: 100 additions & 0 deletions webapp/src/app/more-sheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// The sheet behind the phone bar's More slot: what the bar has no room for.
import { Link } from "@tanstack/react-router";
import type { MouseEvent, RefObject } from "react";

import { Icon } from "../components/icons";
import { useI18n } from "../state/i18n";
import type { NavigationTarget, navigation } from "./navigation-entries";

/**
* What the seventh slot opens.
*
* A native `<dialog>` opened with `showModal`, because that is where the focus
* trap, the Escape key and the inertness of the page behind come from. Writing
* those by hand is how an accessibility gate starts failing, and this one is
* measured — the suite runs axe over the sheet while it is open.
*
* Rendered only when there is something behind it: an account that may neither
* upload nor administer still has six entries hidden, but a listing with
* nothing in it would be a control that does nothing.
*/
export function MoreSheet({
sheet,
entries,
pendingTab,
onNavigate,
}: {
sheet: RefObject<HTMLDialogElement | null>;
entries: typeof navigation;
pendingTab: NavigationTarget | null;
onNavigate: (
event: MouseEvent<HTMLAnchorElement>,
to: NavigationTarget,
) => void;
}) {
const { t } = useI18n();

return (
<>
{/* `onClick` on the dialog itself catches the backdrop: a click landing
on the element rather than on anything inside it is a click outside
the sheet, which is the only thing the backdrop can be.

The keyboard equivalent of dismissing by the backdrop is Escape, and
`<dialog>` already answers it — which is the whole reason the element
was chosen over a hand-built overlay. An `onKeyDown` here would
duplicate a behaviour the platform provides, and a second closer is a
second thing to get out of step. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: dismissing by keyboard is Escape, which <dialog> handles natively */}
<dialog
ref={sheet}
className="more-sheet"
aria-label={t("nav.moreTitle")}
onClick={(event) => {
if (event.target === sheet.current) sheet.current?.close();
}}
>
<header>
<div>
<h2>{t("nav.moreTitle")}</h2>
<p className="muted">{t("nav.moreDetail")}</p>
</div>
<button
type="button"
aria-label={t("nav.moreClose")}
onClick={() => sheet.current?.close()}
>
<Icon name="close" />
</button>
</header>
<nav>
{entries.map((item) => (
<Link
key={item.to}
to={item.to}
aria-busy={pendingTab === item.to}
// Closed on the way out, not on arrival: the sheet would
// otherwise stay open over the page it just navigated to.
onClick={(event) => {
onNavigate(event, item.to);
if (
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
) {
sheet.current?.close();
}
}}
activeOptions={{ exact: item.to === "/" }}
>
<Icon name={item.icon} />
<span>{t(item.labelKey)}</span>
</Link>
))}
</nav>
</dialog>
</>
);
}
155 changes: 155 additions & 0 deletions webapp/src/app/navigation-entries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// The rail's entries: their groups, labels, icons and addresses.
import type { IconName } from "../components/icons";
import type { TranslationKey } from "../state/i18n";

/**
* The rail's sections, in the order it shows them.
*
* `pinned` is the four places reached for most, drawn as tiles two to a row;
* the others carry a label.
*
* Settings is not among them. It sits in the rail's foot, beside the account
* it belongs to and above Sign out, where it stays in view however long the
* rail above it grows. It used to head a fourth group, "Account", of five
* entries; those are the Settings page's own sections now.
*/
export const NAVIGATION_GROUPS = ["pinned", "library", "listen"] as const;
type NavigationGroup = (typeof NAVIGATION_GROUPS)[number] | "footer";

export const GROUP_LABELS: Record<
Exclude<NavigationGroup, "pinned" | "footer">,
TranslationKey
> = {
library: "nav.group.library",
listen: "nav.group.listen",
};

export const navigation: Array<{
to:
| "/"
| "/artists"
| "/songs"
| "/folders"
| "/maintenance"
| "/genres"
| "/search"
| "/favourites"
| "/history"
| "/stats"
| "/random"
| "/playing"
| "/playlists"
| "/queue"
| "/offline"
| "/radio"
| "/podcasts"
| "/settings";
labelKey: TranslationKey;
icon: IconName;
/**
* Shown only when at least one of Maintenance's sections is this account's
* to use in the active library; each keeps the rule its own entry had.
*/
maintenance?: boolean;
primary?: boolean;
/** The rail section it is filed under. */
group: NavigationGroup;
}> = [
{
to: "/",
labelKey: "nav.albums",
icon: "albums",
primary: true,
group: "pinned",
},
{ to: "/artists", labelKey: "nav.artists", icon: "artists", group: "pinned" },
{ to: "/songs", labelKey: "nav.songs", icon: "songs", group: "pinned" },
{ to: "/genres", labelKey: "nav.genres", icon: "genres", group: "library" },
{
to: "/folders",
labelKey: "nav.folders",
icon: "folder",
group: "library",
},
// One entry where three were: "Needs attention", "Missing files" and
// "Quality" are the sections of Maintenance, each behind its own rule.
{
to: "/maintenance",
labelKey: "nav.maintenance",
icon: "maintenance",
maintenance: true,
group: "library",
},
{
to: "/search",
labelKey: "nav.search",
icon: "search",
primary: true,
group: "pinned",
},
{
to: "/playing",
labelKey: "nav.playing",
icon: "lyrics",
primary: true,
group: "listen",
},
{ to: "/random", labelKey: "nav.random", icon: "random", group: "listen" },
{
to: "/history",
labelKey: "nav.history",
icon: "history",
group: "library",
},
{
to: "/stats",
labelKey: "nav.stats",
icon: "stats",
group: "library",
},
{
to: "/favourites",
labelKey: "nav.favourites",
icon: "heart",
primary: true,
group: "library",
},
{
to: "/playlists",
labelKey: "nav.playlists",
icon: "playlists",
primary: true,
group: "library",
},
{
to: "/queue",
labelKey: "nav.queue",
icon: "queue",
primary: true,
group: "listen",
},
{
to: "/offline",
labelKey: "nav.offline",
icon: "download",
group: "listen",
},
{ to: "/radio", labelKey: "nav.radio", icon: "radio", group: "listen" },
{
to: "/podcasts",
labelKey: "nav.podcasts",
icon: "podcasts",
group: "listen",
},
// One entry for everything about the account, where five used to be: the
// sections behind it (shares, scrobbling, upload, administration) are the
// Settings page's own menu now, and each keeps its gate there.
{
to: "/settings",
labelKey: "nav.settings",
icon: "admin",
group: "footer",
},
];

export type NavigationTarget = (typeof navigation)[number]["to"];
Loading
Loading