diff --git a/docs/gates.md b/docs/gates.md index effe4bcc..da680464 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -114,7 +114,7 @@ project checks needs a row. | CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) | | npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 | | knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry | -| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) | +| React Compiler | the functions it skips, pinned | `src/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) | ## Accepted findings diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 39420b27..1cb8a807 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -99,7 +99,8 @@ const localeFormat = { // is a clock face with its own 12/24h switch, not a date. const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path); const collates = /\/src\/lib\/collate\.ts$/.test(path); - const component = /\/src\/components\//.test(path); + // Every folder that holds components (CLAUDE.md "Architecture shape"), not lib/ below them. + const component = /\/src\/(components|shell|modules|features|watch)\//.test(path); const importedFromI18n = (node) => { for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) { const v = scope.set.get(node.name); @@ -536,7 +537,7 @@ export default tseslint.config( // function the compiler skips over a suppression is caught by the coverage contract below. // `todo` does NOT see every such shape — a conditional, a loop // or an optional call inside a try skips the function with no lint hit at all — so the full - // per-function picture is components/compilerCoverage.contract.test.ts. + // per-function picture is src/compilerCoverage.contract.test.ts. "react-hooks/todo": "error", "react-hooks/memo-dependencies": "error", "react-hooks/invariant": "error", diff --git a/frontend/reactPlugin.ts b/frontend/reactPlugin.ts index a85a2219..cfdbfae9 100644 --- a/frontend/reactPlugin.ts +++ b/frontend/reactPlugin.ts @@ -15,7 +15,7 @@ import reactCompiler from "babel-plugin-react-compiler"; // there is no logger here, so a skipped component still works and simply re-renders everything under // it. This comment used to say "its rules are also linted"; it was false while `react-hooks/refs` was // off, and 95 functions sat uncompiled behind it. What actually holds the line now is -// src/components/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source +// src/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source // and fails on any skip not on its list; the react-hooks lint rules are the early warning. None of // them is off (three run at the preset's warn, which `--max-warnings 0` fails — docs/gates.md), but // three of the compiler's diagnostics cannot report under the installed plugin (eslint.config.js diff --git a/frontend/src/components/PlaylistEditFailures.dom.test.tsx b/frontend/src/PlaylistEditFailures.dom.test.tsx similarity index 95% rename from frontend/src/components/PlaylistEditFailures.dom.test.tsx rename to frontend/src/PlaylistEditFailures.dom.test.tsx index 0a5605c6..ce2d7c23 100644 --- a/frontend/src/components/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/PlaylistEditFailures.dom.test.tsx @@ -20,20 +20,20 @@ vi.mock("react-i18next", async (importOriginal) => { }), }; }); -vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); const { notify } = vi.hoisted(() => ({ notify: vi.fn((_n: { level: string; message: string }) => 0), })); -vi.mock("../lib/notifications", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("./lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), notify, })); -import PlexPlaylistView from "./PlexPlaylistView"; -import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist"; -import PlexPlaylistAdd from "./PlexPlaylistAdd"; -import TagManager from "./TagManager"; -import { api, HttpError, type PlexCard } from "../lib/api"; +import PlexPlaylistView from "./modules/plex/PlexPlaylistView"; +import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist"; +import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd"; +import TagManager from "./modules/channels/TagManager"; +import { api, HttpError, type PlexCard } from "./lib/api"; beforeAll(() => { class Stub { diff --git a/frontend/src/components/WriteFailureReporting.dom.test.tsx b/frontend/src/WriteFailureReporting.dom.test.tsx similarity index 93% rename from frontend/src/components/WriteFailureReporting.dom.test.tsx rename to frontend/src/WriteFailureReporting.dom.test.tsx index 1037abf7..abffcd22 100644 --- a/frontend/src/components/WriteFailureReporting.dom.test.tsx +++ b/frontend/src/WriteFailureReporting.dom.test.tsx @@ -17,19 +17,19 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); -vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); const { notify } = vi.hoisted(() => ({ notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0), })); -vi.mock("../lib/notifications", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("./lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), notify, })); -import PlexCollectionEditor from "./PlexCollectionEditor"; -import UploadDialog from "./UploadDialog"; -import DownloadDialog from "./DownloadDialog"; -import { api, HttpError } from "../lib/api"; +import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor"; +import UploadDialog from "./modules/files/UploadDialog"; +import DownloadDialog from "./features/downloads/DownloadDialog"; +import { api, HttpError } from "./lib/api"; beforeAll(() => { class Stub { diff --git a/frontend/src/components/compilerCoverage.contract.test.ts b/frontend/src/compilerCoverage.contract.test.ts similarity index 96% rename from frontend/src/components/compilerCoverage.contract.test.ts rename to frontend/src/compilerCoverage.contract.test.ts index 9c30c0fb..9d63722e 100644 --- a/frontend/src/components/compilerCoverage.contract.test.ts +++ b/frontend/src/compilerCoverage.contract.test.ts @@ -26,15 +26,15 @@ const KNOWN_SKIPS: CompileReport["skipped"] = { // The files a regression hurt most: compiled, and they must stay so (a positive check too, so a // harness that compiled nothing could not pass as "no skips"). const MUST_COMPILE: Record = { - "components/Channels.tsx": ["Channels"], + "modules/channels/Channels.tsx": ["Channels"], "components/ui/DataTable.tsx": ["DataTable"], // Under every button and every popover in the app. "components/ui/FocusSafeButton.tsx": ["FocusSafeButton"], "components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"], "components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"], // The two players — the largest components here, and the last to compile. - "components/PlexPlayer.tsx": ["PlexPlayer"], - "components/PlayerModal.tsx": ["PlayerModal"], + "modules/plex/PlexPlayer.tsx": ["PlexPlayer"], + "features/player/PlayerModal.tsx": ["PlayerModal"], "lib/playerModalHooks.ts": ["useYtProgress"], }; diff --git a/frontend/src/components/compilerSkips.testkit.ts b/frontend/src/compilerSkips.testkit.ts similarity index 99% rename from frontend/src/components/compilerSkips.testkit.ts rename to frontend/src/compilerSkips.testkit.ts index d42731e3..7d9d982b 100644 --- a/frontend/src/components/compilerSkips.testkit.ts +++ b/frontend/src/compilerSkips.testkit.ts @@ -7,7 +7,7 @@ import reactCompiler from "babel-plugin-react-compiler"; // word (`panicThreshold` is "none" and the build has no logger), so this is the only place a skip is // visible at all. Used by compilerCoverage.contract.test.ts. -const SRC = new URL("../", import.meta.url); +const SRC = new URL("./", import.meta.url); /** Every file the production build compiles: src, minus tests and test kits. */ export function shippedSources(): string[] { diff --git a/frontend/src/components/legal/LegalLayout.tsx b/frontend/src/components/legal/LegalLayout.tsx index 6fdb4b15..26892c2d 100644 --- a/frontend/src/components/legal/LegalLayout.tsx +++ b/frontend/src/components/legal/LegalLayout.tsx @@ -12,7 +12,7 @@ import { useTranslation } from "react-i18next"; import { setLanguage, type LangCode } from "../../i18n"; import { formatDate } from "../../lib/format"; import { send } from "../../lib/http"; -import LanguageSwitcher from "../LanguageSwitcher"; +import LanguageSwitcher from "../ui/LanguageSwitcher"; import { SURFACE } from "../ui/surfaces"; // The day the text last changed materially — bump it with every edit of either body. diff --git a/frontend/src/components/ConfirmProvider.tsx b/frontend/src/components/ui/ConfirmProvider.tsx similarity index 96% rename from frontend/src/components/ConfirmProvider.tsx rename to frontend/src/components/ui/ConfirmProvider.tsx index fb58a293..9017af91 100644 --- a/frontend/src/components/ConfirmProvider.tsx +++ b/frontend/src/components/ui/ConfirmProvider.tsx @@ -1,7 +1,7 @@ import { useCallback, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import Modal, { ModalFooter } from "./ui/Modal"; -import { Button } from "./ui/Button"; +import Modal, { ModalFooter } from "./Modal"; +import { Button } from "./Button"; import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext"; // The dialog behind useConfirm()/useChoice() (see confirmContext.ts for the hooks and their contract). diff --git a/frontend/src/components/FilterRail.tsx b/frontend/src/components/ui/FilterRail.tsx similarity index 94% rename from frontend/src/components/FilterRail.tsx rename to frontend/src/components/ui/FilterRail.tsx index a0d6e241..fc1968c3 100644 --- a/frontend/src/components/FilterRail.tsx +++ b/frontend/src/components/ui/FilterRail.tsx @@ -1,8 +1,8 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout"; -import SidePanel from "./ui/SidePanel"; -import PanelGroups, { type PanelItem } from "./ui/PanelGroups"; +import { defaultLayout, type PanelId, type PanelLayout } from "../../lib/panelLayout"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; // The shared filter-rail shell, extracted from the three rails that all render the same SidePanel // chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/ui/LanguageSwitcher.tsx similarity index 90% rename from frontend/src/components/LanguageSwitcher.tsx rename to frontend/src/components/ui/LanguageSwitcher.tsx index 2a848c25..9704afa8 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/ui/LanguageSwitcher.tsx @@ -1,12 +1,12 @@ import { useState } from "react"; import { Globe } from "lucide-react"; -import { MenuCheck } from "./ui/MenuCheck"; -import { LANGUAGES, type LangCode } from "../i18n"; -import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; -import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { Pressable } from "./ui/Pressable"; -import { RAIL_GLYPH, RAIL_ROW } from "./ui/NavRail"; -import { SURFACE } from "./ui/surfaces"; +import { MenuCheck } from "./MenuCheck"; +import { LANGUAGES, type LangCode } from "../../i18n"; +import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; +import { Pressable } from "./Pressable"; +import { RAIL_GLYPH, RAIL_ROW } from "./NavRail"; +import { SURFACE } from "./surfaces"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). diff --git a/frontend/src/components/ui/NavRail.dom.test.tsx b/frontend/src/components/ui/NavRail.dom.test.tsx index 7c71e99c..cd99d259 100644 --- a/frontend/src/components/ui/NavRail.dom.test.tsx +++ b/frontend/src/components/ui/NavRail.dom.test.tsx @@ -119,7 +119,7 @@ describe("NavRailAction", () => { }); it("the nav rail holds no toolbar icon button", () => { - const src = readFileSync(resolve(__dirname, "../NavSidebar.tsx"), "utf8"); + const src = readFileSync(resolve(__dirname, "../../shell/NavSidebar.tsx"), "utf8"); expect(src).not.toMatch(/]/); }); }); diff --git a/frontend/src/components/ui/Toaster.hosts.test.ts b/frontend/src/components/ui/Toaster.hosts.test.ts index c2d866de..fd1cc2f3 100644 --- a/frontend/src/components/ui/Toaster.hosts.test.ts +++ b/frontend/src/components/ui/Toaster.hosts.test.ts @@ -18,12 +18,12 @@ import { readSources } from "../../lib/rawSources.testkit"; // BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so // ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all // (found by a later review). -const sources = readSources(__dirname, ["../**/*.tsx", "./*.tsx"]); +const sources = readSources(__dirname, ["../../**/*.tsx", "!../../design-lab/**"]); // Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player // loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted // there — App renders Welcome instead of the shell). -const EXEMPT = new Set(["../PlexBrowse.tsx", "../Welcome.tsx"]); +const EXEMPT = new Set(["../../modules/plex/PlexBrowse.tsx", "../../shell/Welcome.tsx"]); /** Every class string in the file (quoted or templated), including multi-line ones. */ const classStrings = (src: string): string[] => @@ -67,9 +67,9 @@ describe("full-screen overlays host their own toasts", () => { expect(paths).toEqual( expect.arrayContaining([ "./Modal.tsx", - "../PlayerModal.tsx", - "../PlexPlayer.tsx", - "../OnboardingWizard.tsx", + "../../features/player/PlayerModal.tsx", + "../../modules/plex/PlexPlayer.tsx", + "../../features/wizard/OnboardingWizard.tsx", ]), ); }); diff --git a/frontend/src/components/ViewSwitcher.tsx b/frontend/src/components/ui/ViewSwitcher.tsx similarity index 93% rename from frontend/src/components/ViewSwitcher.tsx rename to frontend/src/components/ui/ViewSwitcher.tsx index 710bf418..9473b87b 100644 --- a/frontend/src/components/ViewSwitcher.tsx +++ b/frontend/src/components/ui/ViewSwitcher.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { type LucideIcon } from "lucide-react"; import clsx from "clsx"; -import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; -import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { MenuCheck } from "./ui/MenuCheck"; -import { SelectTrigger } from "./ui/SelectTrigger"; -import { SURFACE } from "./ui/surfaces"; +import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; +import { useAnchoredPopover } from "./anchoredPopoverHooks"; +import { MenuCheck } from "./MenuCheck"; +import { SelectTrigger } from "./SelectTrigger"; +import { SURFACE } from "./surfaces"; export interface ViewOption { id: T; diff --git a/frontend/src/components/confirmContext.ts b/frontend/src/components/ui/confirmContext.ts similarity index 100% rename from frontend/src/components/confirmContext.ts rename to frontend/src/components/ui/confirmContext.ts diff --git a/frontend/src/components/copyItems.tsx b/frontend/src/components/ui/copyItems.tsx similarity index 93% rename from frontend/src/components/copyItems.tsx rename to frontend/src/components/ui/copyItems.tsx index d46d6b9b..0f24e2b7 100644 --- a/frontend/src/components/copyItems.tsx +++ b/frontend/src/components/ui/copyItems.tsx @@ -1,9 +1,9 @@ import { useTranslation } from "react-i18next"; import { Copy, Link2 } from "lucide-react"; -import { copyText } from "../lib/clipboard"; -import { notify } from "../lib/notifications"; -import { type ActionMenuItem } from "./ui/ActionMenu"; +import { copyText } from "../../lib/clipboard"; +import { notify } from "../../lib/notifications"; +import { type ActionMenuItem } from "./ActionMenu"; // "Copy title" and "Copy link", the two rows every object's menu ends its middle group with // (DESIGN.md C1, "Text stays selectable"): on touch the long-press belongs to the context menu, so the text a diff --git a/frontend/src/components/ui/hiddenFilterCue.contract.test.ts b/frontend/src/components/ui/hiddenFilterCue.contract.test.ts index 9c8d5df8..60b98eee 100644 --- a/frontend/src/components/ui/hiddenFilterCue.contract.test.ts +++ b/frontend/src/components/ui/hiddenFilterCue.contract.test.ts @@ -6,9 +6,9 @@ import { describe, expect, it } from "vitest"; // or have no header at all. Each renders the hidden-filter cue, so no filter narrows its rows unseen. describe("every view that filters rows by column", () => { it("renders the hidden-filter cue", () => { - const root = new URL("../", import.meta.url); + const root = new URL("../../", import.meta.url); const sites = readdirSync(root, { recursive: true, encoding: "utf8" }) - .filter((f) => f.endsWith(".tsx") && !f.includes(".test.")) + .filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab")) .map((f) => [f, readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")] as const) .filter(([, src]) => /filterRows\(/.test(src)); expect(sites.length).toBeGreaterThanOrEqual(3); diff --git a/frontend/src/components/ui/pageMenuScope.contract.test.ts b/frontend/src/components/ui/pageMenuScope.contract.test.ts index b900d7f3..528961e4 100644 --- a/frontend/src/components/ui/pageMenuScope.contract.test.ts +++ b/frontend/src/components/ui/pageMenuScope.contract.test.ts @@ -7,9 +7,9 @@ import { readSources } from "../../lib/rawSources.testkit"; // stats, users, downloads) keep the browser's menu. A page that starts rendering one fails here until // that decision is taken again. const files = readSources(__dirname, [ - "../**/*.tsx", - "!../**/*.test.*", - "!../design-lab/**", + "../../**/*.tsx", + "!../../**/*.test.*", + "!../../design-lab/**", "!./**", ]) as Record; @@ -17,14 +17,14 @@ describe("the pages with a background menu", () => { it("are the five the owner chose", () => { const users = Object.entries(files) .filter(([, code]) => / path.replace(/^\.\.\//, "")) + .map(([path]) => path.replace(/^(\.\.\/)+/, "")) .sort(); expect(users).toEqual([ - "Channels.tsx", - "Feed.tsx", - "NotificationsPanel.tsx", - "Playlists.tsx", - "PlexBrowse.tsx", + "modules/channels/Channels.tsx", + "modules/feed/Feed.tsx", + "modules/notifications/NotificationsPanel.tsx", + "modules/playlists/Playlists.tsx", + "modules/plex/PlexBrowse.tsx", ]); }); }); diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index e0c2e500..69385f6a 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -10,66 +10,74 @@ import { readSources } from "../../lib/rawSources.testkit"; // someone decides. const files = readSources(__dirname, [ - "../**/*.tsx", - "!../**/*.test.*", - "!../design-lab/**", + "../../**/*.tsx", + "!../../**/*.test.*", + "!../../design-lab/**", ]) as Record; /** file → [how many plain Pressables, why they are not titles]. */ const PLAIN: Record = { - "ApiTokens.tsx": [ + "modules/settings/ApiTokens.tsx": [ 1, "a token kind's tile in the new-token dialog (an option: its text is its label)", ], - "DownloadTray.tsx": [ + "features/downloads/DownloadTray.tsx": [ 2, "the folded pill (its summary is its label); a failure's reason, which opens its details", ], - "FileCard.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"], - "FilesRail.tsx": [2, "the load-error retry; the New-folder command"], - "Home.tsx": [ + "modules/files/FileCard.tsx": [ + 1, + "a card's picture (named by aria-label; its title is the caption)", + ], + "modules/files/FilesRail.tsx": [2, "the load-error retry; the New-folder command"], + "modules/home/Home.tsx": [ 2, "the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)", ], - "LanguageSwitcher.tsx": [1, "an option row"], - "NavSidebar.tsx": [3, "the account trigger and its menu rows"], - "PaletteLauncher.tsx": [2, "launcher controls"], - "PlaylistsRail.tsx": [1, "the load-error retry"], - "PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"], - "PlexInfo.tsx": [1, "the rating filter (a number)"], - "PlexPlayerSettings.tsx": [1, "the clock's date-style options"], - "PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"], - "PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"], - "PlexSearchBox.tsx": [1, "a listbox option"], - "plexCards.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"], - "Scheduler.tsx": [1, "the interval editor trigger"], - "SettingsPanel.tsx": [1, "a colour swatch"], - "SyncStatus.tsx": [1, "the status pill trigger"], - "TagManager.tsx": [1, "a suggestion in a picker"], - "UploadDialog.tsx": [1, "the drop zone"], - "VideoEditor.tsx": [2, "timeline glyphs"], - "Welcome.tsx": [1, "a screenshot (zoom)"], - "playlistColumns.tsx": [1, "the thumbnail"], - "plexDetailUi.tsx": [2, "a switch row and the poster"], - "plexViews.tsx": [1, "the rating filter (a number)"], - "ui/ActionMenu.tsx": [1, "a menu row"], - "ui/BackToTop.tsx": [1, "a control"], - "ui/CommandPalette.tsx": [1, "a command row"], - "ui/NavRail.tsx": [ + "components/ui/LanguageSwitcher.tsx": [1, "an option row"], + "shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"], + "features/header/PaletteLauncher.tsx": [2, "launcher controls"], + "modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"], + "modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"], + "modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"], + "modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"], + "features/plexCards/PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"], + "modules/plex/PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"], + "modules/plex/PlexSearchBox.tsx": [1, "a listbox option"], + "features/plexCards/plexCards.tsx": [ + 1, + "a card's picture (named by aria-label; its title is the caption)", + ], + "modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"], + "modules/settings/SettingsPanel.tsx": [1, "a colour swatch"], + "modules/channels/SyncStatus.tsx": [1, "the status pill trigger"], + "modules/channels/TagManager.tsx": [1, "a suggestion in a picker"], + "modules/files/UploadDialog.tsx": [1, "the drop zone"], + "features/player/VideoEditor.tsx": [2, "timeline glyphs"], + "shell/Welcome.tsx": [1, "a screenshot (zoom)"], + "modules/playlists/playlistColumns.tsx": [1, "the thumbnail"], + "modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"], + "modules/plex/plexViews.tsx": [1, "the rating filter (a number)"], + "components/ui/ActionMenu.tsx": [1, "a menu row"], + "components/ui/BackToTop.tsx": [1, "a control"], + "components/ui/CommandPalette.tsx": [1, "a command row"], + "components/ui/NavRail.tsx": [ 2, "the action rows (About, Sign out) and the group row that opens its panel; a page row is a link", ], - "ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"], - "ui/SidePanel.tsx": [2, "panel controls"], - "ui/tableHeader.tsx": [1, "a column's sort header"], + "components/ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"], + "components/ui/SidePanel.tsx": [2, "panel controls"], + "components/ui/tableHeader.tsx": [1, "a column's sort header"], }; function scan(): { plain: Record; selectable: number } { const plain: Record = {}; let selectable = 0; for (const [path, code] of Object.entries(files)) { - // Glob keys are relative to this folder: `./x.tsx` is ui/, `../x.tsx` is components/. - const rel = path.startsWith("./") ? `ui/${path.slice(2)}` : path.replace(/^\.\.\//, ""); + // Glob keys are relative to this folder (`./x.tsx` is ui/); the table keys them from src/. + const rel = path.startsWith("./") + ? `components/ui/${path.slice(2)}` + : path.replace(/^(\.\.\/)+/, ""); for (const m of code.matchAll(/ ({ ...(await importOriginal()), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), })); -vi.mock("../lib/clipboard", () => ({ copyText: h.copyText })); -vi.mock("../lib/notifications", () => ({ notify: h.notify })); +vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); +vi.mock("../../lib/notifications", () => ({ notify: h.notify })); import { useCopyFeedback } from "./useCopyFeedback"; diff --git a/frontend/src/components/useCopyFeedback.ts b/frontend/src/components/ui/useCopyFeedback.ts similarity index 94% rename from frontend/src/components/useCopyFeedback.ts rename to frontend/src/components/ui/useCopyFeedback.ts index 6e05f60e..9c12c0a4 100644 --- a/frontend/src/components/useCopyFeedback.ts +++ b/frontend/src/components/ui/useCopyFeedback.ts @@ -1,8 +1,8 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { copyText } from "../lib/clipboard"; -import { notify } from "../lib/notifications"; +import { copyText } from "../../lib/clipboard"; +import { notify } from "../../lib/notifications"; // How long a Copy button reads "Copied" after it worked. const COPIED_MS = 2000; diff --git a/frontend/src/components/comfortPin.dom.test.ts b/frontend/src/features/comfort/comfortPin.dom.test.ts similarity index 98% rename from frontend/src/components/comfortPin.dom.test.ts rename to frontend/src/features/comfort/comfortPin.dom.test.ts index 79e8f7fc..19100de1 100644 --- a/frontend/src/components/comfortPin.dom.test.ts +++ b/frontend/src/features/comfort/comfortPin.dom.test.ts @@ -8,7 +8,7 @@ // disabled state shows an enabled row that no-ops. import { describe, it, expect } from "vitest"; import { pinTargetForCard, shelfMoveTarget } from "./comfortPin"; -import type { DiscoverCard } from "../lib/api"; +import type { DiscoverCard } from "../../lib/api"; const card = (over: Partial): DiscoverCard => ({ diff --git a/frontend/src/components/comfortPin.langSwitch.dom.test.tsx b/frontend/src/features/comfort/comfortPin.langSwitch.dom.test.tsx similarity index 95% rename from frontend/src/components/comfortPin.langSwitch.dom.test.tsx rename to frontend/src/features/comfort/comfortPin.langSwitch.dom.test.tsx index 568eadba..91389638 100644 --- a/frontend/src/components/comfortPin.langSwitch.dom.test.tsx +++ b/frontend/src/features/comfort/comfortPin.langSwitch.dom.test.tsx @@ -8,8 +8,8 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import type { ReactNode } from "react"; import { renderHook, act, waitFor, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { setLanguage } from "../i18n"; -import { api } from "../lib/api"; +import { setLanguage } from "../../i18n"; +import { api } from "../../lib/api"; import { useComfortPin } from "./comfortPin"; // Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded diff --git a/frontend/src/components/comfortPin.tsx b/frontend/src/features/comfort/comfortPin.tsx similarity index 96% rename from frontend/src/components/comfortPin.tsx rename to frontend/src/features/comfort/comfortPin.tsx index ce522587..1ecef7ec 100644 --- a/frontend/src/components/comfortPin.tsx +++ b/frontend/src/features/comfort/comfortPin.tsx @@ -3,12 +3,12 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pin, PinOff } from "lucide-react"; -import { plexLang } from "../i18n"; -import { api, type DiscoverCard, type PinKind } from "../lib/api"; -import { notifyActionError } from "../lib/actionErrors"; -import { notify } from "../lib/notifications"; -import { qk } from "../lib/queryKeys"; -import { type ActionMenuItem } from "./ui/ActionMenu"; +import { plexLang } from "../../i18n"; +import { api, type DiscoverCard, type PinKind } from "../../lib/api"; +import { notifyActionError } from "../../lib/actionErrors"; +import { notify } from "../../lib/notifications"; +import { qk } from "../../lib/queryKeys"; +import { type ActionMenuItem } from "../../components/ui/ActionMenu"; // "put this on my comfort shelf", from wherever you happen to be looking at the thing. // diff --git a/frontend/src/components/DebugRecorder.dom.test.tsx b/frontend/src/features/diagnostics/DebugRecorder.dom.test.tsx similarity index 95% rename from frontend/src/components/DebugRecorder.dom.test.tsx rename to frontend/src/features/diagnostics/DebugRecorder.dom.test.tsx index 7043659b..5d928c22 100644 --- a/frontend/src/components/DebugRecorder.dom.test.tsx +++ b/frontend/src/features/diagnostics/DebugRecorder.dom.test.tsx @@ -10,8 +10,8 @@ import { useState } from "react"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { DebugSession, Me } from "../lib/api"; -import { qk } from "../lib/queryKeys"; +import type { DebugSession, Me } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -58,8 +58,8 @@ const { order, session, startDebugSession, stopDebugSession, hold, past } = vi.h }); return { order, session, startDebugSession, stopDebugSession, hold, past }; }); -vi.mock("../lib/api", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../lib/api", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, api: { @@ -70,8 +70,8 @@ vi.mock("../lib/api", async (importOriginal) => { }, }; }); -vi.mock("../lib/diagnostics", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../lib/diagnostics", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, flushNow: () => { @@ -82,16 +82,21 @@ vi.mock("../lib/diagnostics", async (importOriginal) => { }); import DebugRecorder, { RecorderSettings } from "./DebugRecorder"; -import Modal from "./ui/Modal"; -import Toaster from "./ui/Toaster"; +import Modal from "../../components/ui/Modal"; +import Toaster from "../../components/ui/Toaster"; import { closeComposer, openRecorderStart, setStepPrompt, useComposerSession, -} from "../lib/debugRecorder"; -import { anyBlockingLayer, anyBlockingLayerAbove, pushLayer, removeLayer } from "../lib/layerStack"; -import { events } from "../lib/log"; +} from "../../lib/debugRecorder"; +import { + anyBlockingLayer, + anyBlockingLayerAbove, + pushLayer, + removeLayer, +} from "../../lib/layerStack"; +import { events } from "../../lib/log"; beforeEach(() => { order.length = 0; diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/features/diagnostics/DebugRecorder.tsx similarity index 95% rename from frontend/src/components/DebugRecorder.tsx rename to frontend/src/features/diagnostics/DebugRecorder.tsx index abfeb44d..91e1033f 100644 --- a/frontend/src/components/DebugRecorder.tsx +++ b/frontend/src/features/diagnostics/DebugRecorder.tsx @@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Camera, Flag, Square } from "lucide-react"; -import { api, type DebugSession, type DebugSessionLevel } from "../lib/api"; -import { canCaptureTab } from "../lib/captureTab"; +import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api"; +import { canCaptureTab } from "../../lib/captureTab"; import { closeRecorderStart, onSessionChangeElsewhere, @@ -15,21 +15,21 @@ import { useRecorderStartOpen, useStepCount, useStepPrompt, -} from "../lib/debugRecorder"; -import { formatDateTimeSec, formatDuration } from "../lib/format"; -import { useLayer } from "../lib/layerStack"; -import { notify } from "../lib/notifications"; -import { qk } from "../lib/queryKeys"; -import { dockUnderToasts } from "../lib/toastDock"; -import { useMe } from "../lib/useMe"; -import { Button } from "./ui/Button"; -import Modal, { ModalFooter } from "./ui/Modal"; -import { LoadingState, StateMessage } from "./ui/QueryState"; -import SegmentedToggle from "./ui/SegmentedToggle"; +} from "../../lib/debugRecorder"; +import { formatDateTimeSec, formatDuration } from "../../lib/format"; +import { useLayer } from "../../lib/layerStack"; +import { notify } from "../../lib/notifications"; +import { qk } from "../../lib/queryKeys"; +import { dockUnderToasts } from "../../lib/toastDock"; +import { useMe } from "../../lib/useMe"; +import { Button } from "../../components/ui/Button"; +import Modal, { ModalFooter } from "../../components/ui/Modal"; +import { LoadingState, StateMessage } from "../../components/ui/QueryState"; +import SegmentedToggle from "../../components/ui/SegmentedToggle"; import IssueComposer from "./IssueComposer"; import { useDebugRecording } from "./useDebugRecording"; -import { Input } from "./ui/Input"; -import { SURFACE } from "./ui/surfaces"; +import { Input } from "../../components/ui/Input"; +import { SURFACE } from "../../components/ui/surfaces"; // The debug recorder: Start → reproduce → Stop. // diff --git a/frontend/src/components/HistoryRow.tsx b/frontend/src/features/diagnostics/HistoryRow.tsx similarity index 94% rename from frontend/src/components/HistoryRow.tsx rename to frontend/src/features/diagnostics/HistoryRow.tsx index bf19aa8b..52b583cb 100644 --- a/frontend/src/components/HistoryRow.tsx +++ b/frontend/src/features/diagnostics/HistoryRow.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react"; -import { navItemAttrs } from "../lib/listNav"; -import { Pressable } from "./ui/Pressable"; +import { navItemAttrs } from "../../lib/listNav"; +import { Pressable } from "../../components/ui/Pressable"; // One dense row of a history list — a background run, an admin action, a log line (./LogLine draws // its lines through this): its cells on one line, and the row opens in place to its details. One frame diff --git a/frontend/src/components/IssueComposer.dom.test.tsx b/frontend/src/features/diagnostics/IssueComposer.dom.test.tsx similarity index 99% rename from frontend/src/components/IssueComposer.dom.test.tsx rename to frontend/src/features/diagnostics/IssueComposer.dom.test.tsx index 02d9fbfd..563b02d8 100644 --- a/frontend/src/components/IssueComposer.dom.test.tsx +++ b/frontend/src/features/diagnostics/IssueComposer.dom.test.tsx @@ -8,8 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { DebugDraft, DebugSession, ServerLogRow } from "../lib/api"; -import { qk } from "../lib/queryKeys"; +import type { DebugDraft, DebugSession, ServerLogRow } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -38,8 +38,8 @@ const api = vi.hoisted(() => ({ // The server's half-failure: the issue is committed, then an upload dies and the call 422s. failSendAfterCreate: false, })); -vi.mock("../lib/api", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../lib/api", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, api: { diff --git a/frontend/src/components/IssueComposer.tsx b/frontend/src/features/diagnostics/IssueComposer.tsx similarity index 96% rename from frontend/src/components/IssueComposer.tsx rename to frontend/src/features/diagnostics/IssueComposer.tsx index b10c3ec2..d45a1e15 100644 --- a/frontend/src/components/IssueComposer.tsx +++ b/frontend/src/features/diagnostics/IssueComposer.tsx @@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, FileText, Plus, Server, X } from "lucide-react"; -import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api"; -import { closeComposer } from "../lib/debugRecorder"; -import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format"; -import { notify } from "../lib/notifications"; -import { useMe } from "../lib/useMe"; +import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api"; +import { closeComposer } from "../../lib/debugRecorder"; +import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format"; +import { notify } from "../../lib/notifications"; +import { useMe } from "../../lib/useMe"; import { draftSteps, environment, @@ -15,21 +15,21 @@ import { type DraftForm, type DraftStep, type IssueText, -} from "../lib/issueDraft"; -import { moduleDef } from "../lib/modules"; -import type { Page } from "../lib/urlState"; -import { qk } from "../lib/queryKeys"; -import { focusIsUnclaimed } from "../lib/useFocusKeeper"; +} from "../../lib/issueDraft"; +import { moduleDef } from "../../lib/modules"; +import type { Page } from "../../lib/urlState"; +import { qk } from "../../lib/queryKeys"; +import { focusIsUnclaimed } from "../../lib/useFocusKeeper"; import { LEVEL_TAG, logClock } from "./logLineParts"; -import { Button } from "./ui/Button"; -import { IconButton } from "./ui/IconButton"; -import Modal, { ModalFooter } from "./ui/Modal"; -import { LoadingState, StateMessage } from "./ui/QueryState"; -import SegmentedToggle from "./ui/SegmentedToggle"; -import { Textarea, Input } from "./ui/Input"; -import { Checkbox } from "./ui/NativeControls"; -import { SURFACE } from "./ui/surfaces"; -import RemoteImage from "./ui/RemoteImage"; +import { Button } from "../../components/ui/Button"; +import { IconButton } from "../../components/ui/IconButton"; +import Modal, { ModalFooter } from "../../components/ui/Modal"; +import { LoadingState, StateMessage } from "../../components/ui/QueryState"; +import SegmentedToggle from "../../components/ui/SegmentedToggle"; +import { Textarea, Input } from "../../components/ui/Input"; +import { Checkbox } from "../../components/ui/NativeControls"; +import { SURFACE } from "../../components/ui/surfaces"; +import RemoteImage from "../../components/ui/RemoteImage"; // The issue composer: one dialog — the draft on the left (title, description, the // steps drafted from the session, the environment), what goes with it on the right (the two logs, diff --git a/frontend/src/components/LogLine.tsx b/frontend/src/features/diagnostics/LogLine.tsx similarity index 97% rename from frontend/src/components/LogLine.tsx rename to frontend/src/features/diagnostics/LogLine.tsx index 33b3c9ba..a6cf4712 100644 --- a/frontend/src/components/LogLine.tsx +++ b/frontend/src/features/diagnostics/LogLine.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -import type { Level } from "../lib/log"; +import type { Level } from "../../lib/log"; import HistoryRow from "./HistoryRow"; import { LEVEL_TAG, logClock } from "./logLineParts"; diff --git a/frontend/src/components/logLineParts.ts b/frontend/src/features/diagnostics/logLineParts.ts similarity index 95% rename from frontend/src/components/logLineParts.ts rename to frontend/src/features/diagnostics/logLineParts.ts index e2a8f7b6..571e7a6d 100644 --- a/frontend/src/components/logLineParts.ts +++ b/frontend/src/features/diagnostics/logLineParts.ts @@ -1,4 +1,4 @@ -import type { Level } from "../lib/log"; +import type { Level } from "../../lib/log"; // What a log line is drawn with — the level tags and the clock — shared by LogLine (both log // viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from diff --git a/frontend/src/components/useDebugRecording.ts b/frontend/src/features/diagnostics/useDebugRecording.ts similarity index 91% rename from frontend/src/components/useDebugRecording.ts rename to frontend/src/features/diagnostics/useDebugRecording.ts index 071ffcc1..c0a2b914 100644 --- a/frontend/src/components/useDebugRecording.ts +++ b/frontend/src/features/diagnostics/useDebugRecording.ts @@ -2,21 +2,21 @@ import { useCallback } from "react"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { api, type DebugSession, type DebugSessionLevel } from "../lib/api"; -import { notifyActionError } from "../lib/actionErrors"; -import { captureTab } from "../lib/captureTab"; +import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api"; +import { notifyActionError } from "../../lib/actionErrors"; +import { captureTab } from "../../lib/captureTab"; import { announceSessionChange, closeRecorderStart, nextStep, openComposer, setStepPrompt, -} from "../lib/debugRecorder"; -import { flushNow, liveSession } from "../lib/diagnostics"; -import { log } from "../lib/log"; -import { notify } from "../lib/notifications"; -import { qk } from "../lib/queryKeys"; -import { updateMeCache, useMe } from "../lib/useMe"; +} from "../../lib/debugRecorder"; +import { flushNow, liveSession } from "../../lib/diagnostics"; +import { log } from "../../lib/log"; +import { notify } from "../../lib/notifications"; +import { qk } from "../../lib/queryKeys"; +import { updateMeCache, useMe } from "../../lib/useMe"; /** The debug recording's actions, shared by everything that drives it: the pill, Settings → * Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/features/downloads/DownloadButton.tsx similarity index 93% rename from frontend/src/components/DownloadButton.tsx rename to frontend/src/features/downloads/DownloadButton.tsx index f68d5f38..a2cbc10f 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/features/downloads/DownloadButton.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense, useState } from "react"; -import { qk } from "../lib/queryKeys"; +import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Download } from "lucide-react"; @@ -7,8 +7,8 @@ import clsx from "clsx"; // Lazy: the download dialog (+ its profile editor) loads only when a card's download button is // clicked, so it never ships in the feed chunk for the majority who just browse. const DownloadDialog = lazy(() => import("./DownloadDialog")); -import { api } from "../lib/api"; -import { IconButton } from "./ui/IconButton"; +import { api } from "../../lib/api"; +import { IconButton } from "../../components/ui/IconButton"; // Self-contained download affordance for a video card / the player. Reflects the video's current state from // the shared per-user download index (one polled query shared across every card). diff --git a/frontend/src/components/DownloadDialog.dom.test.tsx b/frontend/src/features/downloads/DownloadDialog.dom.test.tsx similarity index 91% rename from frontend/src/components/DownloadDialog.dom.test.tsx rename to frontend/src/features/downloads/DownloadDialog.dom.test.tsx index 119e2100..27991fd3 100644 --- a/frontend/src/components/DownloadDialog.dom.test.tsx +++ b/frontend/src/features/downloads/DownloadDialog.dom.test.tsx @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; -import { api, type DownloadProfile } from "../lib/api"; +import { api, type DownloadProfile } from "../../lib/api"; const h = vi.hoisted(() => ({ prefs: new Map(), @@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), })); -vi.mock("../lib/usePref", () => ({ +vi.mock("../../lib/usePref", () => ({ usePref: (key: string, initial: T) => { const [value, setValue] = useState(() => h.prefs.has(key) ? (h.prefs.get(key) as T) : initial, @@ -33,11 +33,11 @@ vi.mock("../lib/usePref", () => ({ ]; }, })); -vi.mock("../lib/notifications", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), notify: h.notify, })); -vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray })); +vi.mock("../../lib/downloadQueue", () => ({ showInTray: h.showInTray })); import DownloadDialog from "./DownloadDialog"; diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/features/downloads/DownloadDialog.tsx similarity index 84% rename from frontend/src/components/DownloadDialog.tsx rename to frontend/src/features/downloads/DownloadDialog.tsx index 4b7e7d2b..c680672d 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/features/downloads/DownloadDialog.tsx @@ -1,22 +1,22 @@ import { lazy, Suspense, useState } from "react"; -import { qk } from "../lib/queryKeys"; +import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Modal, { ModalFooter } from "./ui/Modal"; -import { Button } from "./ui/Button"; -import { api } from "../lib/api"; -import { invalidateDownloads } from "../lib/downloads"; +import Modal, { ModalFooter } from "../../components/ui/Modal"; +import { Button } from "../../components/ui/Button"; +import { api } from "../../lib/api"; +import { invalidateDownloads } from "../../lib/downloads"; // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. -const ProfileEditor = lazy(() => import("./ProfileEditor")); -import { notify } from "../lib/notifications"; -import { notifyActionError } from "../lib/actionErrors"; -import { showInTray } from "../lib/downloadQueue"; -import { profileName } from "../lib/vocab"; -import { LS } from "../lib/storage"; -import { usePref } from "../lib/usePref"; -import FilterSelect from "./ui/FilterSelect"; -import { Input } from "./ui/Input"; +const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor")); +import { notify } from "../../lib/notifications"; +import { notifyActionError } from "../../lib/actionErrors"; +import { showInTray } from "../../lib/downloadQueue"; +import { profileName } from "../../lib/vocab"; +import { LS } from "../../lib/storage"; +import { usePref } from "../../lib/usePref"; +import FilterSelect from "../../components/ui/FilterSelect"; +import { Input } from "../../components/ui/Input"; // Preset picker shown when the user hits Download on a card / in the player, or brings a link (the // Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it. diff --git a/frontend/src/components/DownloadIntake.dom.test.tsx b/frontend/src/features/downloads/DownloadIntake.dom.test.tsx similarity index 95% rename from frontend/src/components/DownloadIntake.dom.test.tsx rename to frontend/src/features/downloads/DownloadIntake.dom.test.tsx index 1ec1c64f..0147eabe 100644 --- a/frontend/src/components/DownloadIntake.dom.test.tsx +++ b/frontend/src/features/downloads/DownloadIntake.dom.test.tsx @@ -21,10 +21,10 @@ vi.mock("./DownloadDialog", () => ({ })); import DownloadIntake from "./DownloadIntake"; -import { LinkBox } from "./LinkBox"; -import { clearPendingDownload, requestLinkBox } from "../lib/downloadIntake"; -import { ensureNavId } from "../lib/history"; -import { pushLayer, removeLayer } from "../lib/layerStack"; +import { LinkBox } from "../../modules/files/LinkBox"; +import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake"; +import { ensureNavId } from "../../lib/history"; +import { pushLayer, removeLayer } from "../../lib/layerStack"; afterEach(() => { cleanup(); diff --git a/frontend/src/components/DownloadIntake.tsx b/frontend/src/features/downloads/DownloadIntake.tsx similarity index 97% rename from frontend/src/components/DownloadIntake.tsx rename to frontend/src/features/downloads/DownloadIntake.tsx index 4297c9ae..abccd2b0 100644 --- a/frontend/src/components/DownloadIntake.tsx +++ b/frontend/src/features/downloads/DownloadIntake.tsx @@ -5,7 +5,7 @@ import { pasteIsForThePage, requestDownload, usePendingDownload, -} from "../lib/downloadIntake"; +} from "../../lib/downloadIntake"; const DownloadDialog = lazy(() => import("./DownloadDialog")); diff --git a/frontend/src/components/DownloadTray.dom.test.tsx b/frontend/src/features/downloads/DownloadTray.dom.test.tsx similarity index 94% rename from frontend/src/components/DownloadTray.dom.test.tsx rename to frontend/src/features/downloads/DownloadTray.dom.test.tsx index b9775866..69096443 100644 --- a/frontend/src/components/DownloadTray.dom.test.tsx +++ b/frontend/src/features/downloads/DownloadTray.dom.test.tsx @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; -import { api, type QueueJob } from "../lib/api"; +import { api, type QueueJob } from "../../lib/api"; const h = vi.hoisted(() => ({ jobs: [] as unknown[], @@ -28,7 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => ({ `${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`, }), })); -vi.mock("../lib/useLiveQuery", () => ({ +vi.mock("../../lib/useLiveQuery", () => ({ useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => ( (h.intervalMs = opts.intervalMs), { @@ -42,7 +42,7 @@ vi.mock("../lib/useLiveQuery", () => ({ })); // The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How // usePref stores them is its own business (usePref.dom.test). -vi.mock("../lib/usePref", () => ({ +vi.mock("../../lib/usePref", () => ({ usePref: (key: string, initial: T) => { const [value, setValue] = useState(() => h.prefs.has(key) ? (h.prefs.get(key) as T) : initial, @@ -56,21 +56,23 @@ vi.mock("../lib/usePref", () => ({ ]; }, })); -vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) })); -vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); -vi.mock("../lib/downloads", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../../modules/files/filesContext", () => ({ + useFilesActions: () => ({ openFile: h.openFile }), +})); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("../../lib/downloads", async (importOriginal) => ({ + ...(await importOriginal()), quickShareLink: h.share, })); -vi.mock("../lib/notifications", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), notify: h.notify, })); import DownloadTray from "./DownloadTray"; -import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue"; -import { pushLayer, removeLayer } from "../lib/layerStack"; -import { qk } from "../lib/queryKeys"; +import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue"; +import { pushLayer, removeLayer } from "../../lib/layerStack"; +import { qk } from "../../lib/queryKeys"; beforeEach(() => { resetQueueWatch(); diff --git a/frontend/src/components/DownloadTray.menu.dom.test.tsx b/frontend/src/features/downloads/DownloadTray.menu.dom.test.tsx similarity index 95% rename from frontend/src/components/DownloadTray.menu.dom.test.tsx rename to frontend/src/features/downloads/DownloadTray.menu.dom.test.tsx index a28411e3..7b1caecd 100644 --- a/frontend/src/components/DownloadTray.menu.dom.test.tsx +++ b/frontend/src/features/downloads/DownloadTray.menu.dom.test.tsx @@ -10,8 +10,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; -import { api, type DownloadJob } from "../lib/api"; -import { menuGroups } from "./ui/menuGroups"; +import { api, type DownloadJob } from "../../lib/api"; +import { menuGroups } from "../../components/ui/menuGroups"; const mocks = vi.hoisted(() => ({ jobs: [] as unknown[], @@ -28,8 +28,8 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), }; }); -vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); -vi.mock("../lib/useLiveQuery", () => ({ +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm })); +vi.mock("../../lib/useLiveQuery", () => ({ useLiveQuery: (key: readonly unknown[]) => ({ data: key[0] === "downloads" ? mocks.jobs : undefined, isError: false, @@ -37,10 +37,12 @@ vi.mock("../lib/useLiveQuery", () => ({ refetch: vi.fn(), }), })); -vi.mock("../lib/usePref", () => ({ +vi.mock("../../lib/usePref", () => ({ usePref: (_key: string, initial: T) => useState(initial), })); -vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) })); +vi.mock("../../modules/files/filesContext", () => ({ + useFilesActions: () => ({ openFile: vi.fn() }), +})); globalThis.ResizeObserver ??= class { observe() {} diff --git a/frontend/src/components/DownloadTray.tsx b/frontend/src/features/downloads/DownloadTray.tsx similarity index 91% rename from frontend/src/components/DownloadTray.tsx rename to frontend/src/features/downloads/DownloadTray.tsx index bee55acc..abf825b0 100644 --- a/frontend/src/components/DownloadTray.tsx +++ b/frontend/src/features/downloads/DownloadTray.tsx @@ -13,9 +13,9 @@ import { RotateCw, X, } from "lucide-react"; -import type { QueueJob } from "../lib/api"; -import { invalidateDownloads, quickShareLink } from "../lib/downloads"; -import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; +import type { QueueJob } from "../../lib/api"; +import { invalidateDownloads, quickShareLink } from "../../lib/downloads"; +import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure"; import { IN_FLIGHT, DOWNLOAD_PHASES, @@ -27,31 +27,31 @@ import { useFinished, useRevealing, useUnannounced, -} from "../lib/downloadQueue"; -import { anyBlockingLayer } from "../lib/layerStack"; -import { focusNavItem, navItemAttrs } from "../lib/listNav"; -import { notify } from "../lib/notifications"; -import { qk } from "../lib/queryKeys"; -import { LS } from "../lib/storage"; -import { useLiveQuery } from "../lib/useLiveQuery"; -import { usePref } from "../lib/usePref"; +} from "../../lib/downloadQueue"; +import { anyBlockingLayer } from "../../lib/layerStack"; +import { focusNavItem, navItemAttrs } from "../../lib/listNav"; +import { notify } from "../../lib/notifications"; +import { qk } from "../../lib/queryKeys"; +import { LS } from "../../lib/storage"; +import { useLiveQuery } from "../../lib/useLiveQuery"; +import { usePref } from "../../lib/usePref"; import { ErrorModal } from "./downloadModals"; import { jobTitle } from "./downloadTitle"; -import { sourceLabel } from "./fileFolders"; -import { useFilesActions } from "./filesContext"; +import { sourceLabel } from "../../modules/files/fileFolders"; +import { useFilesActions } from "../../modules/files/filesContext"; import { useDownloadActions } from "./useDownloadActions"; -import { type ActionMenuItem } from "./ui/ActionMenu"; -import { setCornerLift } from "./ui/backToTopState"; -import { Button } from "./ui/Button"; -import { ContextMenu } from "./ui/ContextMenu"; -import { IconButton } from "./ui/IconButton"; -import { menuGroups } from "./ui/menuGroups"; -import { MoreButton } from "./ui/MoreButton"; -import NavGrid from "./ui/NavGrid"; -import { Pressable } from "./ui/Pressable"; -import { CONTENT_ID } from "./ui/landmarks"; -import { SURFACE } from "./ui/surfaces"; -import { useContextMenu } from "./ui/useContextMenu"; +import { type ActionMenuItem } from "../../components/ui/ActionMenu"; +import { setCornerLift } from "../../components/ui/backToTopState"; +import { Button } from "../../components/ui/Button"; +import { ContextMenu } from "../../components/ui/ContextMenu"; +import { IconButton } from "../../components/ui/IconButton"; +import { menuGroups } from "../../components/ui/menuGroups"; +import { MoreButton } from "../../components/ui/MoreButton"; +import NavGrid from "../../components/ui/NavGrid"; +import { Pressable } from "../../components/ui/Pressable"; +import { CONTENT_ID } from "../../components/ui/landmarks"; +import { SURFACE } from "../../components/ui/surfaces"; +import { useContextMenu } from "../../components/ui/useContextMenu"; // The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner, // up only while something waits, runs or failed — and what finished in this tab, "Done · Open", diff --git a/frontend/src/components/downloadModals.error.dom.test.tsx b/frontend/src/features/downloads/downloadModals.error.dom.test.tsx similarity index 96% rename from frontend/src/components/downloadModals.error.dom.test.tsx rename to frontend/src/features/downloads/downloadModals.error.dom.test.tsx index 91873dce..20654c66 100644 --- a/frontend/src/components/downloadModals.error.dom.test.tsx +++ b/frontend/src/features/downloads/downloadModals.error.dom.test.tsx @@ -4,7 +4,7 @@ // downloader did not understand (an update on the server cures it). Never for an unclassified one. import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { DownloadJob } from "../lib/api"; +import type { DownloadJob } from "../../lib/api"; import { ErrorModal } from "./downloadModals"; vi.mock("react-i18next", async (importOriginal) => ({ diff --git a/frontend/src/components/downloadModals.tsx b/frontend/src/features/downloads/downloadModals.tsx similarity index 94% rename from frontend/src/components/downloadModals.tsx rename to frontend/src/features/downloads/downloadModals.tsx index 5f901257..97deb656 100644 --- a/frontend/src/components/downloadModals.tsx +++ b/frontend/src/features/downloads/downloadModals.tsx @@ -1,14 +1,14 @@ import { useState } from "react"; -import { invalidateDownloads } from "../lib/downloads"; +import { invalidateDownloads } from "../../lib/downloads"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, Trash2 } from "lucide-react"; -import Modal, { ModalFooter } from "./ui/Modal"; -import { api, type DownloadJob } from "../lib/api"; -import { Button } from "./ui/Button"; +import Modal, { ModalFooter } from "../../components/ui/Modal"; +import { api, type DownloadJob } from "../../lib/api"; +import { Button } from "../../components/ui/Button"; import { IconBtn } from "./downloadRow"; -import { failCopyKeys, failTitleKey } from "../lib/downloadFailure"; -import { Input } from "./ui/Input"; +import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure"; +import { Input } from "../../components/ui/Input"; // Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed // download's reasons (the tray). diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/features/downloads/downloadRow.dom.test.tsx similarity index 98% rename from frontend/src/components/downloadRow.dom.test.tsx rename to frontend/src/features/downloads/downloadRow.dom.test.tsx index f7b17dec..47e7b335 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/features/downloads/downloadRow.dom.test.tsx @@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import { JobThumb } from "./downloadRow"; -import type { DownloadJob } from "../lib/api"; +import type { DownloadJob } from "../../lib/api"; const baseJob: DownloadJob = { id: 1, diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/features/downloads/downloadRow.tsx similarity index 95% rename from frontend/src/components/downloadRow.tsx rename to frontend/src/features/downloads/downloadRow.tsx index b9969c2f..97685ec7 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/features/downloads/downloadRow.tsx @@ -2,11 +2,11 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { File, Film, Music } from "lucide-react"; import clsx from "clsx"; -import { DOWNLOAD_PHASES } from "../lib/downloadQueue"; -import { api, type DownloadJob } from "../lib/api"; -import { IconButton } from "./ui/IconButton"; -import { MediaProgress } from "./ui/MediaCard"; -import RemoteImage from "./ui/RemoteImage"; +import { DOWNLOAD_PHASES } from "../../lib/downloadQueue"; +import { api, type DownloadJob } from "../../lib/api"; +import { IconButton } from "../../components/ui/IconButton"; +import { MediaProgress } from "../../components/ui/MediaCard"; +import RemoteImage from "../../components/ui/RemoteImage"; // The small shared primitives of a download (status pill, icon button, the job's picture), all purely // prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the diff --git a/frontend/src/components/downloadTitle.ts b/frontend/src/features/downloads/downloadTitle.ts similarity index 94% rename from frontend/src/components/downloadTitle.ts rename to frontend/src/features/downloads/downloadTitle.ts index df212595..0246725f 100644 --- a/frontend/src/components/downloadTitle.ts +++ b/frontend/src/features/downloads/downloadTitle.ts @@ -1,4 +1,4 @@ -import type { DownloadJob } from "../lib/api"; +import type { DownloadJob } from "../../lib/api"; // A download job's display title (a user rename wins over the extracted asset title), shared by the // tray, the Files list, the admin Storage tab and the library player. diff --git a/frontend/src/components/useDownloadActions.tsx b/frontend/src/features/downloads/useDownloadActions.tsx similarity index 91% rename from frontend/src/components/useDownloadActions.tsx rename to frontend/src/features/downloads/useDownloadActions.tsx index c7e62e56..436f3b95 100644 --- a/frontend/src/components/useDownloadActions.tsx +++ b/frontend/src/features/downloads/useDownloadActions.tsx @@ -1,14 +1,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Ban, Pause, Play, RefreshCw } from "lucide-react"; -import { api, type DownloadJob, type DownloadJobKind } from "../lib/api"; -import { notifyActionError } from "../lib/actionErrors"; -import { invalidateDownloads } from "../lib/downloads"; -import { useConfirm } from "./confirmContext"; -import { useCopyItems } from "./copyItems"; +import { api, type DownloadJob, type DownloadJobKind } from "../../lib/api"; +import { notifyActionError } from "../../lib/actionErrors"; +import { invalidateDownloads } from "../../lib/downloads"; +import { useConfirm } from "../../components/ui/confirmContext"; +import { useCopyItems } from "../../components/ui/copyItems"; import { jobTitle } from "./downloadTitle"; -import { type ActionMenuItem } from "./ui/ActionMenu"; -import { menuGroups } from "./ui/menuGroups"; +import { type ActionMenuItem } from "../../components/ui/ActionMenu"; +import { menuGroups } from "../../components/ui/menuGroups"; // A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The // row keeps its primary action (pause / resume / retry) and its state (the error and its details) in diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/features/filters/ActiveFilterChips.tsx similarity index 92% rename from frontend/src/components/ActiveFilterChips.tsx rename to frontend/src/features/filters/ActiveFilterChips.tsx index 15aeb6b7..34e203e5 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/features/filters/ActiveFilterChips.tsx @@ -1,16 +1,16 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Filter, X } from "lucide-react"; -import type { ActiveFilter } from "../lib/useActiveFilters"; -import { decideChipLayout } from "../lib/chipOverflow"; -import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit"; -import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; -import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { Button } from "./ui/Button"; -import { Chip } from "./ui/Chip"; -import { chipClass } from "./ui/chipStyles"; -import { ChipLabelPending } from "./ui/Skeleton"; -import { SURFACE } from "./ui/surfaces"; +import type { ActiveFilter } from "../../lib/useActiveFilters"; +import { decideChipLayout } from "../../lib/chipOverflow"; +import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit"; +import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover"; +import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; +import { Button } from "../../components/ui/Button"; +import { Chip } from "../../components/ui/Chip"; +import { chipClass } from "../../components/ui/chipStyles"; +import { ChipLabelPending } from "../../components/ui/Skeleton"; +import { SURFACE } from "../../components/ui/surfaces"; // The applied-filter row above the feed: every narrowing currently in force, each removable on its // own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to diff --git a/frontend/src/components/PersonalViewsModal.dom.test.tsx b/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx similarity index 98% rename from frontend/src/components/PersonalViewsModal.dom.test.tsx rename to frontend/src/features/filters/PersonalViewsModal.dom.test.tsx index fa944ae5..7180e1bd 100644 --- a/frontend/src/components/PersonalViewsModal.dom.test.tsx +++ b/frontend/src/features/filters/PersonalViewsModal.dom.test.tsx @@ -63,7 +63,7 @@ const CTX = { toggleDefault: vi.fn(async () => {}), }; -vi.mock("./savedViewsContext", () => ({ +vi.mock("../../modules/feed/savedViewsContext", () => ({ useSavedViews: () => ({ ...CTX, activeViewId: h.activeViewId, @@ -80,8 +80,8 @@ vi.mock("./savedViewsContext", () => ({ })); import PersonalViewsModal from "./PersonalViewsModal"; -import { api } from "../lib/api"; -import { qk } from "../lib/queryKeys"; +import { api } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; function mount() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/features/filters/PersonalViewsModal.tsx similarity index 93% rename from frontend/src/components/PersonalViewsModal.tsx rename to frontend/src/features/filters/PersonalViewsModal.tsx index 8976ee66..e1e5e52a 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/features/filters/PersonalViewsModal.tsx @@ -17,21 +17,26 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; -import Modal from "./ui/Modal"; -import { Button } from "./ui/Button"; -import { IconButton } from "./ui/IconButton"; -import { useSavedViews, asFilters } from "./savedViewsContext"; -import { useActiveFilters } from "../lib/useActiveFilters"; -import { buildPlexActiveFilters } from "./usePlexActiveFilters"; -import { useUndoable } from "../lib/useUndoable"; -import UndoToolbar from "./ui/UndoToolbar"; -import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; -import { DragHandle } from "./ui/DragHandle"; -import { OrderButtons } from "./ui/OrderButtons"; -import { Pressable } from "./ui/Pressable"; -import { DismissibleChip } from "./ui/Chip"; -import { Input } from "./ui/Input"; +import { + coercePlexFilters, + type FeedFilters, + type PlexFilters, + type SavedView, +} from "../../lib/api"; +import Modal from "../../components/ui/Modal"; +import { Button } from "../../components/ui/Button"; +import { IconButton } from "../../components/ui/IconButton"; +import { useSavedViews, asFilters } from "../../modules/feed/savedViewsContext"; +import { useActiveFilters } from "../../lib/useActiveFilters"; +import { buildPlexActiveFilters } from "../../modules/plex/usePlexActiveFilters"; +import { useUndoable } from "../../lib/useUndoable"; +import UndoToolbar from "../../components/ui/UndoToolbar"; +import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y"; +import { DragHandle } from "../../components/ui/DragHandle"; +import { OrderButtons } from "../../components/ui/OrderButtons"; +import { Pressable } from "../../components/ui/Pressable"; +import { DismissibleChip } from "../../components/ui/Chip"; +import { Input } from "../../components/ui/Input"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder, or the selected one's order buttons on the right) plus a "save the current filters" input; diff --git a/frontend/src/components/SavedViewsPopover.dom.test.tsx b/frontend/src/features/filters/SavedViewsPopover.dom.test.tsx similarity index 98% rename from frontend/src/components/SavedViewsPopover.dom.test.tsx rename to frontend/src/features/filters/SavedViewsPopover.dom.test.tsx index 97bf282b..81c76217 100644 --- a/frontend/src/components/SavedViewsPopover.dom.test.tsx +++ b/frontend/src/features/filters/SavedViewsPopover.dom.test.tsx @@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); -vi.mock("./savedViewsContext", () => ({ +vi.mock("../../modules/feed/savedViewsContext", () => ({ useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }), })); diff --git a/frontend/src/components/SavedViewsPopover.tsx b/frontend/src/features/filters/SavedViewsPopover.tsx similarity index 87% rename from frontend/src/components/SavedViewsPopover.tsx rename to frontend/src/features/filters/SavedViewsPopover.tsx index 0ba58e3c..8d04147b 100644 --- a/frontend/src/components/SavedViewsPopover.tsx +++ b/frontend/src/features/filters/SavedViewsPopover.tsx @@ -1,13 +1,13 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Settings2 } from "lucide-react"; -import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover"; -import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { useSavedViews } from "./savedViewsContext"; -import { IconButton } from "./ui/IconButton"; -import { MenuCheck } from "./ui/MenuCheck"; -import { SURFACE } from "./ui/surfaces"; -import { SelectTrigger } from "./ui/SelectTrigger"; +import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover"; +import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; +import { useSavedViews } from "../../modules/feed/savedViewsContext"; +import { IconButton } from "../../components/ui/IconButton"; +import { MenuCheck } from "../../components/ui/MenuCheck"; +import { SURFACE } from "../../components/ui/surfaces"; +import { SelectTrigger } from "../../components/ui/SelectTrigger"; // The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as // their trigger: click → a popover list of the user's saved views (apply on click, the active one diff --git a/frontend/src/components/SelectedViewChip.tsx b/frontend/src/features/filters/SelectedViewChip.tsx similarity index 94% rename from frontend/src/components/SelectedViewChip.tsx rename to frontend/src/features/filters/SelectedViewChip.tsx index 4f8ba066..dd89be80 100644 --- a/frontend/src/components/SelectedViewChip.tsx +++ b/frontend/src/features/filters/SelectedViewChip.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { Bookmark } from "lucide-react"; import SavedViewsPopover from "./SavedViewsPopover"; -import { useSavedViews } from "./savedViewsContext"; +import { useSavedViews } from "../../modules/feed/savedViewsContext"; // The active personal view, shown as the leading pill before the active-filter chip row. Styled like // the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged diff --git a/frontend/src/components/PaletteLauncher.dom.test.tsx b/frontend/src/features/header/PaletteLauncher.dom.test.tsx similarity index 83% rename from frontend/src/components/PaletteLauncher.dom.test.tsx rename to frontend/src/features/header/PaletteLauncher.dom.test.tsx index 928060b2..24649211 100644 --- a/frontend/src/components/PaletteLauncher.dom.test.tsx +++ b/frontend/src/features/header/PaletteLauncher.dom.test.tsx @@ -11,24 +11,24 @@ vi.mock("react-i18next", async (importOriginal) => ({ })); let page = "home"; -vi.mock("./navigationContext", () => ({ useNavigation: () => ({ page }) })); -vi.mock("./feedFiltersContext", () => ({ +vi.mock("../navigation/navigationContext", () => ({ useNavigation: () => ({ page }) })); +vi.mock("../../modules/feed/feedFiltersContext", () => ({ useFeedFilters: () => ({ q: "", sort: "newest" }), useFeedFiltersActions: () => ({ setFilters: vi.fn() }), })); -vi.mock("./playlistsContext", () => ({ +vi.mock("../../modules/playlists/playlistsContext", () => ({ usePlaylists: () => ({ search: "" }), usePlaylistsActions: () => ({ setSearch: vi.fn() }), })); -vi.mock("./channelsContext", () => ({ +vi.mock("../../modules/channels/channelsContext", () => ({ useChannels: () => ({ search: "" }), useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }), })); -vi.mock("./SyncStatus", () => ({ default: () => null })); +vi.mock("../../modules/channels/SyncStatus", () => ({ default: () => null })); -import Header from "./Header"; -import { closePalette, usePaletteOpen } from "../lib/keymap"; -import type { Me } from "../lib/api"; +import Header from "../../shell/Header"; +import { closePalette, usePaletteOpen } from "../../lib/keymap"; +import type { Me } from "../../lib/api"; afterEach(() => { cleanup(); diff --git a/frontend/src/components/PaletteLauncher.tsx b/frontend/src/features/header/PaletteLauncher.tsx similarity index 93% rename from frontend/src/components/PaletteLauncher.tsx rename to frontend/src/features/header/PaletteLauncher.tsx index b486e42a..a6c4e804 100644 --- a/frontend/src/components/PaletteLauncher.tsx +++ b/frontend/src/features/header/PaletteLauncher.tsx @@ -1,10 +1,10 @@ import { useTranslation } from "react-i18next"; import { Command } from "lucide-react"; -import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap"; -import { Pressable } from "./ui/Pressable"; +import { SHORTCUTS, formatChord, openPalette } from "../../lib/keymap"; +import { Pressable } from "../../components/ui/Pressable"; import clsx from "clsx"; -import { fieldClass } from "./ui/fieldStyles"; +import { fieldClass } from "../../components/ui/fieldStyles"; // The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and // `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat diff --git a/frontend/src/components/SearchBar.dom.test.tsx b/frontend/src/features/header/SearchBar.dom.test.tsx similarity index 100% rename from frontend/src/components/SearchBar.dom.test.tsx rename to frontend/src/features/header/SearchBar.dom.test.tsx diff --git a/frontend/src/components/SearchBar.keys.dom.test.tsx b/frontend/src/features/header/SearchBar.keys.dom.test.tsx similarity index 95% rename from frontend/src/components/SearchBar.keys.dom.test.tsx rename to frontend/src/features/header/SearchBar.keys.dom.test.tsx index 1a1b5914..6b88743b 100644 --- a/frontend/src/components/SearchBar.keys.dom.test.tsx +++ b/frontend/src/features/header/SearchBar.keys.dom.test.tsx @@ -5,9 +5,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render, screen } from "@testing-library/react"; import SearchBar from "./SearchBar"; -import KeyboardHelp from "./ui/KeyboardHelp"; -import { closeKeyboardHelp, toggleKeyboardHelp } from "../lib/keymap"; -import { pushLayer, removeLayer } from "../lib/layerStack"; +import KeyboardHelp from "../../components/ui/KeyboardHelp"; +import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap"; +import { pushLayer, removeLayer } from "../../lib/layerStack"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/features/header/SearchBar.tsx similarity index 95% rename from frontend/src/components/SearchBar.tsx rename to frontend/src/features/header/SearchBar.tsx index c617b389..3a295325 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/features/header/SearchBar.tsx @@ -8,12 +8,12 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { Search, X } from "lucide-react"; -import { useSearchFocusKey } from "../lib/keymap"; +import { useSearchFocusKey } from "../../lib/keymap"; import { PaletteKeycap } from "./PaletteLauncher"; -import { Button } from "./ui/Button"; -import { IconButton } from "./ui/IconButton"; -import { InnerInput } from "./ui/Input"; -import { fieldClass } from "./ui/fieldStyles"; +import { Button } from "../../components/ui/Button"; +import { IconButton } from "../../components/ui/IconButton"; +import { InnerInput } from "../../components/ui/Input"; +import { fieldClass } from "../../components/ui/fieldStyles"; // The floating search pill in the top header. One component for every module that searches // (feed, Plex, channels, playlists): a 40px glass pill with the A4 field edge (the page's one hero diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx similarity index 98% rename from frontend/src/components/NavigationProvider.dom.test.tsx rename to frontend/src/features/navigation/NavigationProvider.dom.test.tsx index 61b6b5ea..55684824 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -27,12 +27,12 @@ vi.mock("react-i18next", async (importOriginal) => { import { NavigationProvider } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./navigationContext"; -import { useBackToClose } from "../lib/history"; -import { PageHeading } from "./ui/Headings"; +import { useBackToClose } from "../../lib/history"; +import { PageHeading } from "../../components/ui/Headings"; -import { qk } from "../lib/queryKeys"; -import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../lib/storage"; -import { api } from "../lib/api"; +import { qk } from "../../lib/queryKeys"; +import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../../lib/storage"; +import { api } from "../../lib/api"; import { PAGE_KEY } from "./navigationInit"; /** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx similarity index 96% rename from frontend/src/components/NavigationProvider.tsx rename to frontend/src/features/navigation/NavigationProvider.tsx index 8075b0ab..439e433c 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -1,20 +1,20 @@ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; -import { useLatest } from "../lib/useLatest"; +import { useLatest } from "../../lib/useLatest"; import { useTranslation } from "react-i18next"; -import { useMe } from "../lib/useMe"; -import { resolvePage, retiredParams, urlWithParams, type Page } from "../lib/urlState"; -import { ACTIVITY_KEYS } from "../lib/activityUrlState"; -import { pageTitleKey } from "../lib/pageMeta"; -import { useDocumentObjectTitle } from "../lib/documentTitle"; -import { getAccountRaw, setAccountRaw } from "../lib/storage"; +import { useMe } from "../../lib/useMe"; +import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState"; +import { ACTIVITY_KEYS } from "../../lib/activityUrlState"; +import { pageTitleKey } from "../../lib/pageMeta"; +import { useDocumentObjectTitle } from "../../lib/documentTitle"; +import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { backStaysInDocument, closeOverlaysThenNavigate, nextNavId, notifyNav, rememberNavPosition, -} from "../lib/history"; -import { reachablePage } from "../lib/modules"; +} from "../../lib/history"; +import { reachablePage } from "../../lib/modules"; import { channelViewFrom, loadInitialPage, @@ -23,7 +23,7 @@ import { START_ON_HOME_PREF, type ChannelView, } from "./navigationInit"; -import { usePref } from "../lib/usePref"; +import { usePref } from "../../lib/usePref"; import { NavigationActionsContext, NavigationStateContext, diff --git a/frontend/src/components/navigation.dom.test.ts b/frontend/src/features/navigation/navigation.dom.test.ts similarity index 96% rename from frontend/src/components/navigation.dom.test.ts rename to frontend/src/features/navigation/navigation.dom.test.ts index 721adbe7..4470b574 100644 --- a/frontend/src/components/navigation.dom.test.ts +++ b/frontend/src/features/navigation/navigation.dom.test.ts @@ -11,13 +11,13 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; const { stored } = vi.hoisted(() => ({ stored: {} as Record })); -vi.mock("../lib/storage", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../lib/storage", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null }; }); import { loadInitialPage, channelViewFrom } from "./navigationInit"; -import { LS } from "../lib/storage"; +import { LS } from "../../lib/storage"; const setUrl = (search: string) => window.history.replaceState(null, "", search || "/"); diff --git a/frontend/src/components/navigationContext.ts b/frontend/src/features/navigation/navigationContext.ts similarity index 98% rename from frontend/src/components/navigationContext.ts rename to frontend/src/features/navigation/navigationContext.ts index 5c3b1684..b033ae96 100644 --- a/frontend/src/components/navigationContext.ts +++ b/frontend/src/features/navigation/navigationContext.ts @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { Page } from "../lib/urlState"; +import type { Page } from "../../lib/urlState"; import type { ChannelView } from "./navigationInit"; // The navigation contexts + their hooks, split out of NavigationProvider so that file exports only diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/features/navigation/navigationInit.ts similarity index 95% rename from frontend/src/components/navigationInit.ts rename to frontend/src/features/navigation/navigationInit.ts index d820940e..6473857c 100644 --- a/frontend/src/components/navigationInit.ts +++ b/frontend/src/features/navigation/navigationInit.ts @@ -2,9 +2,9 @@ // provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing // module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the // channelView seed and the onPop handler. -import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState"; -import { getAccountRaw, LS } from "../lib/storage"; -import type { UsePrefOptions } from "../lib/usePref"; +import { hasFilterParams, readPage, resolvePage, type Page } from "../../lib/urlState"; +import { getAccountRaw, LS } from "../../lib/storage"; +import type { UsePrefOptions } from "../../lib/usePref"; export const PAGE_KEY = LS.page; /** The landing preference — start on Home, or on the module left open — an account preference diff --git a/frontend/src/components/ClipPanel.dom.test.tsx b/frontend/src/features/player/ClipPanel.dom.test.tsx similarity index 94% rename from frontend/src/components/ClipPanel.dom.test.tsx rename to frontend/src/features/player/ClipPanel.dom.test.tsx index 0665b4cb..5fd20636 100644 --- a/frontend/src/components/ClipPanel.dom.test.tsx +++ b/frontend/src/features/player/ClipPanel.dom.test.tsx @@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; -import type { ClipRequest } from "../lib/api"; -import type { ClipRange } from "../lib/clipRange"; +import type { ClipRequest } from "../../lib/api"; +import type { ClipRange } from "../../lib/clipRange"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -16,20 +16,20 @@ vi.mock("react-i18next", async (importOriginal) => { }); const enqueueClip = vi.fn<(body: ClipRequest) => Promise>(); -vi.mock("../lib/api", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../lib/api", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } }; }); const notify = vi.fn(); -vi.mock("../lib/notifications", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../lib/notifications", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, notify: (n: unknown) => notify(n) }; }); const showInTray = vi.fn(); -vi.mock("../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) })); +vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) })); const notifyActionError = vi.fn(); -vi.mock("../lib/actionErrors", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../lib/actionErrors", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) }; }); diff --git a/frontend/src/components/ClipPanel.tsx b/frontend/src/features/player/ClipPanel.tsx similarity index 93% rename from frontend/src/components/ClipPanel.tsx rename to frontend/src/features/player/ClipPanel.tsx index f92194be..9e1ee6cd 100644 --- a/frontend/src/components/ClipPanel.tsx +++ b/frontend/src/features/player/ClipPanel.tsx @@ -2,24 +2,24 @@ import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent } import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react"; -import { api, type ClipRequest } from "../lib/api"; -import { DISPLAY_NAME_MAX } from "../lib/api/downloads"; -import { invalidateDownloads } from "../lib/downloads"; -import { notify } from "../lib/notifications"; -import { notifyActionError } from "../lib/actionErrors"; -import { showInTray } from "../lib/downloadQueue"; -import { log } from "../lib/log"; -import { formatTimecode } from "../lib/timecode"; -import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange"; -import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry"; -import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack"; -import { CropFrame } from "./ui/CropFrame"; -import SegmentedToggle from "./ui/SegmentedToggle"; -import { Button } from "./ui/Button"; -import { IconButton } from "./ui/IconButton"; -import { Input } from "./ui/Input"; -import { TimecodeError, TimecodeField } from "./ui/TimecodeField"; -import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField"; +import { api, type ClipRequest } from "../../lib/api"; +import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; +import { invalidateDownloads } from "../../lib/downloads"; +import { notify } from "../../lib/notifications"; +import { notifyActionError } from "../../lib/actionErrors"; +import { showInTray } from "../../lib/downloadQueue"; +import { log } from "../../lib/log"; +import { formatTimecode } from "../../lib/timecode"; +import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange"; +import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry"; +import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack"; +import { CropFrame } from "../../components/ui/CropFrame"; +import SegmentedToggle from "../../components/ui/SegmentedToggle"; +import { Button } from "../../components/ui/Button"; +import { IconButton } from "../../components/ui/IconButton"; +import { Input } from "../../components/ui/Input"; +import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField"; +import { useTimecodeField, type TimecodeDraft } from "../../components/ui/useTimecodeField"; // The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives // IN the player (the YouTube card under the video, the Plex player's control band), so the video diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/features/player/PlayerModal.dom.test.tsx similarity index 98% rename from frontend/src/components/PlayerModal.dom.test.tsx rename to frontend/src/features/player/PlayerModal.dom.test.tsx index 40d61133..701cecc0 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.dom.test.tsx @@ -23,16 +23,18 @@ vi.mock("react-i18next", async (importOriginal) => { }), }; }); -vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) })); +vi.mock("../navigation/navigationContext", () => ({ + useNavigationActions: () => ({ openChannel: vi.fn() }), +})); // Their own queries and popovers are not what is under test here. -vi.mock("./AddToPlaylist", () => ({ default: () => null })); -vi.mock("./DownloadButton", () => ({ default: () => null })); +vi.mock("../video/AddToPlaylist", () => ({ default: () => null })); +vi.mock("../downloads/DownloadButton", () => ({ default: () => null })); import PlayerModal from "./PlayerModal"; -import { ConfirmProvider } from "./ConfirmProvider"; -import { useConfirm } from "./confirmContext"; -import { api, type Video } from "../lib/api"; -import { clearAll, notify } from "../lib/notifications"; +import { ConfirmProvider } from "../../components/ui/ConfirmProvider"; +import { useConfirm } from "../../components/ui/confirmContext"; +import { api, type Video } from "../../lib/api"; +import { clearAll, notify } from "../../lib/notifications"; beforeAll(() => { class Stub { diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/features/player/PlayerModal.tsx similarity index 97% rename from frontend/src/components/PlayerModal.tsx rename to frontend/src/features/player/PlayerModal.tsx index da8dcd8d..b0afdd7c 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/features/player/PlayerModal.tsx @@ -10,12 +10,12 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; -import { useNavigationActions } from "./navigationContext"; +import { useNavigationActions } from "../navigation/navigationContext"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { qk } from "../lib/queryKeys"; -import { usePref } from "../lib/usePref"; +import { qk } from "../../lib/queryKeys"; +import { usePref } from "../../lib/usePref"; import { PlayerDescription, PlayerTransportControls } from "./playerModalControls"; -import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes"; +import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes"; import { AlertTriangle, ArrowLeft, @@ -37,52 +37,52 @@ import { VolumeX, X, } from "lucide-react"; -import Avatar from "./ui/Avatar"; -import { useLatest, useStableCallback } from "../lib/useLatest"; -import AddToPlaylist from "./AddToPlaylist"; -import { PopoverSurface } from "./ui/AnchoredPopover"; -import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import Avatar from "../../components/ui/Avatar"; +import { useLatest, useStableCallback } from "../../lib/useLatest"; +import AddToPlaylist from "../video/AddToPlaylist"; +import { PopoverSurface } from "../../components/ui/AnchoredPopover"; +import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; import { pushLayer, removeLayer, anyBlockingLayerAbove, anyLayerAbove, escapeHandled, -} from "../lib/layerStack"; -import { trapTab, useDialogChrome } from "./ui/dialogFocus"; -import DownloadButton from "./DownloadButton"; +} from "../../lib/layerStack"; +import { trapTab, useDialogChrome } from "../../components/ui/dialogFocus"; +import DownloadButton from "../downloads/DownloadButton"; import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel"; -import { markInAt, markOutAt, openAt, type ClipRange } from "../lib/clipRange"; -import { api, type Video, type VideoStatus } from "../lib/api"; -import type { YTPlayer } from "../lib/youtube"; +import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange"; +import { api, type Video, type VideoStatus } from "../../lib/api"; +import type { YTPlayer } from "../../lib/youtube"; import { channelYouTubeUrl, formatDate, formatDuration, formatCompact, relativeTime, -} from "../lib/format"; -import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen"; -import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; -import { useBackToClose } from "../lib/history"; -import ChannelNameLink from "./ChannelNameLink"; -import Toaster from "./ui/Toaster"; -import { useOverlayOrder } from "./ui/overlayOrder"; -import { afterRemoval } from "../lib/playerQueue"; +} from "../../lib/format"; +import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen"; +import { dispatch, isActivationTarget, useKeyScope } from "../../lib/keymap"; +import { useBackToClose } from "../../lib/history"; +import ChannelNameLink from "../video/ChannelNameLink"; +import Toaster from "../../components/ui/Toaster"; +import { useOverlayOrder } from "../../components/ui/overlayOrder"; +import { afterRemoval } from "../../lib/playerQueue"; import { useNativeControlsYield, useYtBigView, useYtProgress, useYtVolume, YT_API_LOAD_FAILED, -} from "../lib/playerModalHooks"; -import { useScrollFade } from "../lib/useScrollFade"; -import { Button } from "./ui/Button"; -import { IconButton } from "./ui/IconButton"; -import { SURFACE } from "./ui/surfaces"; -import { IconLink, LinkButton } from "./ui/LinkButton"; -import { SectionLabel } from "./ui/Headings"; -import { useMediaQuery } from "../lib/useMediaQuery"; +} from "../../lib/playerModalHooks"; +import { useScrollFade } from "../../lib/useScrollFade"; +import { Button } from "../../components/ui/Button"; +import { IconButton } from "../../components/ui/IconButton"; +import { SURFACE } from "../../components/ui/surfaces"; +import { IconLink, LinkButton } from "../../components/ui/LinkButton"; +import { SectionLabel } from "../../components/ui/Headings"; +import { useMediaQuery } from "../../lib/useMediaQuery"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position diff --git a/frontend/src/components/VideoEditor.partial.dom.test.tsx b/frontend/src/features/player/VideoEditor.partial.dom.test.tsx similarity index 97% rename from frontend/src/components/VideoEditor.partial.dom.test.tsx rename to frontend/src/features/player/VideoEditor.partial.dom.test.tsx index 5ea98ab4..e694fd7c 100644 --- a/frontend/src/components/VideoEditor.partial.dom.test.tsx +++ b/frontend/src/features/player/VideoEditor.partial.dom.test.tsx @@ -14,14 +14,14 @@ vi.mock("react-i18next", async (importOriginal) => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); const { notify } = vi.hoisted(() => ({ notify: vi.fn() })); -vi.mock("../lib/notifications", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), notify, })); import VideoEditor from "./VideoEditor"; -import { api, HttpError, type DownloadJob } from "../lib/api"; -import { markReported } from "../lib/api/core"; +import { api, HttpError, type DownloadJob } from "../../lib/api"; +import { markReported } from "../../lib/api/core"; beforeAll(() => { class Stub { diff --git a/frontend/src/components/VideoEditor.tsx b/frontend/src/features/player/VideoEditor.tsx similarity index 95% rename from frontend/src/components/VideoEditor.tsx rename to frontend/src/features/player/VideoEditor.tsx index a52bac94..b42441c2 100644 --- a/frontend/src/components/VideoEditor.tsx +++ b/frontend/src/features/player/VideoEditor.tsx @@ -1,27 +1,27 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; -import { invalidateDownloads } from "../lib/downloads"; -import { qk } from "../lib/queryKeys"; +import { invalidateDownloads } from "../../lib/downloads"; +import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react"; import clsx from "clsx"; -import Modal, { ModalFooter } from "./ui/Modal"; -import { notify } from "../lib/notifications"; -import { api, type DownloadJob, type EditSpec } from "../lib/api"; -import { cropToSourcePx, type Frac } from "../lib/cropGeometry"; -import { CropFrame } from "./ui/CropFrame"; -import { Button } from "./ui/Button"; -import { failureReason, notifyActionError } from "../lib/actionErrors"; -import { markReported } from "../lib/api/core"; -import { IconButton } from "./ui/IconButton"; -import { Pressable } from "./ui/Pressable"; -import { Input } from "./ui/Input"; -import { TimecodeError, TimecodeField } from "./ui/TimecodeField"; -import { useTimecodeField } from "./ui/useTimecodeField"; -import { SURFACE } from "./ui/surfaces"; -import { RangeTrack } from "./ui/RangeTrack"; -import { trackLeft } from "./ui/trackPosition"; -import { formatTimecode } from "../lib/timecode"; +import Modal, { ModalFooter } from "../../components/ui/Modal"; +import { notify } from "../../lib/notifications"; +import { api, type DownloadJob, type EditSpec } from "../../lib/api"; +import { cropToSourcePx, type Frac } from "../../lib/cropGeometry"; +import { CropFrame } from "../../components/ui/CropFrame"; +import { Button } from "../../components/ui/Button"; +import { failureReason, notifyActionError } from "../../lib/actionErrors"; +import { markReported } from "../../lib/api/core"; +import { IconButton } from "../../components/ui/IconButton"; +import { Pressable } from "../../components/ui/Pressable"; +import { Input } from "../../components/ui/Input"; +import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField"; +import { useTimecodeField } from "../../components/ui/useTimecodeField"; +import { SURFACE } from "../../components/ui/surfaces"; +import { RangeTrack } from "../../components/ui/RangeTrack"; +import { trackLeft } from "../../components/ui/trackPosition"; +import { formatTimecode } from "../../lib/timecode"; type Seg = { id: number; start: number; end: number; keep: boolean }; diff --git a/frontend/src/components/descriptionLinks.dom.test.tsx b/frontend/src/features/player/descriptionLinks.dom.test.tsx similarity index 100% rename from frontend/src/components/descriptionLinks.dom.test.tsx rename to frontend/src/features/player/descriptionLinks.dom.test.tsx diff --git a/frontend/src/components/descriptionLinks.tsx b/frontend/src/features/player/descriptionLinks.tsx similarity index 99% rename from frontend/src/components/descriptionLinks.tsx rename to frontend/src/features/player/descriptionLinks.tsx index 31973922..b22ce848 100644 --- a/frontend/src/components/descriptionLinks.tsx +++ b/frontend/src/features/player/descriptionLinks.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import type { TFunction } from "i18next"; -import { Pressable } from "./ui/Pressable"; +import { Pressable } from "../../components/ui/Pressable"; // Turn a video description into clickable nodes: // - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/features/player/playerModalControls.tsx similarity index 95% rename from frontend/src/components/playerModalControls.tsx rename to frontend/src/features/player/playerModalControls.tsx index 1ad217ab..3dba6a2c 100644 --- a/frontend/src/components/playerModalControls.tsx +++ b/frontend/src/features/player/playerModalControls.tsx @@ -1,11 +1,11 @@ import type { CSSProperties, Ref } from "react"; import { useTranslation } from "react-i18next"; import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; -import type { VideoDetail } from "../lib/api"; -import type { AutoMode, LoopMode } from "../lib/playerModes"; -import { Button } from "./ui/Button"; -import { StateMessage } from "./ui/QueryState"; -import { ClampedText } from "./ui/ClampedText"; +import type { VideoDetail } from "../../lib/api"; +import type { AutoMode, LoopMode } from "../../lib/playerModes"; +import { Button } from "../../components/ui/Button"; +import { StateMessage } from "../../components/ui/QueryState"; +import { ClampedText } from "../../components/ui/ClampedText"; import { renderDescription } from "./descriptionLinks"; // Presentational controls for the YouTube modal player (PlayerModal), split out of the god component — diff --git a/frontend/src/components/PlexCollectionAdd.tsx b/frontend/src/features/plexCards/PlexCollectionAdd.tsx similarity index 91% rename from frontend/src/components/PlexCollectionAdd.tsx rename to frontend/src/features/plexCards/PlexCollectionAdd.tsx index 1ace05cb..992a527f 100644 --- a/frontend/src/components/PlexCollectionAdd.tsx +++ b/frontend/src/features/plexCards/PlexCollectionAdd.tsx @@ -1,13 +1,13 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { qk } from "../lib/queryKeys"; -import { api, HttpError, type PlexCollection } from "../lib/api"; -import { notifyActionError } from "../lib/actionErrors"; -import { PopoverSurface } from "./ui/AnchoredPopover"; -import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { MembershipMenu } from "./ui/MembershipMenu"; -import { SURFACE } from "./ui/surfaces"; +import { qk } from "../../lib/queryKeys"; +import { api, HttpError, type PlexCollection } from "../../lib/api"; +import { notifyActionError } from "../../lib/actionErrors"; +import { PopoverSurface } from "../../components/ui/AnchoredPopover"; +import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; +import { MembershipMenu } from "../../components/ui/MembershipMenu"; +import { SURFACE } from "../../components/ui/surfaces"; // "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title // into or out of its library's manual collections, and names a new one — created with this title in diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/features/plexCards/PlexPlaylistAdd.tsx similarity index 92% rename from frontend/src/components/PlexPlaylistAdd.tsx rename to frontend/src/features/plexCards/PlexPlaylistAdd.tsx index f9a2ebc6..c7b49d73 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/features/plexCards/PlexPlaylistAdd.tsx @@ -1,17 +1,17 @@ import { useState } from "react"; -import { qk } from "../lib/queryKeys"; +import { qk } from "../../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus } from "lucide-react"; -import { MenuCheck } from "./ui/MenuCheck"; -import { api, type PlexPlaylist } from "../lib/api"; -import { notifyActionError } from "../lib/actionErrors"; -import { useScrollFade } from "../lib/useScrollFade"; -import { Button } from "./ui/Button"; -import Modal from "./ui/Modal"; -import { Pressable } from "./ui/Pressable"; -import { Input } from "./ui/Input"; -import { SURFACE } from "./ui/surfaces"; +import { MenuCheck } from "../../components/ui/MenuCheck"; +import { api, type PlexPlaylist } from "../../lib/api"; +import { notifyActionError } from "../../lib/actionErrors"; +import { useScrollFade } from "../../lib/useScrollFade"; +import { Button } from "../../components/ui/Button"; +import Modal from "../../components/ui/Modal"; +import { Pressable } from "../../components/ui/Pressable"; +import { Input } from "../../components/ui/Input"; +import { SURFACE } from "../../components/ui/surfaces"; // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/features/plexCards/plexCards.dom.test.tsx similarity index 99% rename from frontend/src/components/plexCards.dom.test.tsx rename to frontend/src/features/plexCards/plexCards.dom.test.tsx index a2a1cc7f..d5216126 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/features/plexCards/plexCards.dom.test.tsx @@ -22,8 +22,8 @@ vi.mock("react-i18next", async (importOriginal) => { }); import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards"; -import { iconButtonClass } from "./ui/buttonStyles"; -import { api, type PlexCard, type PlexSeasonDetail } from "../lib/api"; +import { iconButtonClass } from "../../components/ui/buttonStyles"; +import { api, type PlexCard, type PlexSeasonDetail } from "../../lib/api"; // A show card asks the server which episode it would play once its preview is up: a query client. const render = (ui: ReactElement) => diff --git a/frontend/src/components/plexCards.lang.dom.test.tsx b/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx similarity index 97% rename from frontend/src/components/plexCards.lang.dom.test.tsx rename to frontend/src/features/plexCards/plexCards.lang.dom.test.tsx index 219603ed..62d7fc95 100644 --- a/frontend/src/components/plexCards.lang.dom.test.tsx +++ b/frontend/src/features/plexCards/plexCards.lang.dom.test.tsx @@ -17,7 +17,7 @@ vi.mock("react-i18next", async (importOriginal) => { }); import { PlexPosterCard, SeasonCard } from "./plexCards"; -import type { PlexCard, PlexSeasonDetail } from "../lib/api"; +import type { PlexCard, PlexSeasonDetail } from "../../lib/api"; // A show card asks the server which episode it would play once its preview is up: a query client. const render = (ui: ReactElement) => diff --git a/frontend/src/components/plexCards.langSwitch.dom.test.tsx b/frontend/src/features/plexCards/plexCards.langSwitch.dom.test.tsx similarity index 94% rename from frontend/src/components/plexCards.langSwitch.dom.test.tsx rename to frontend/src/features/plexCards/plexCards.langSwitch.dom.test.tsx index f493d87e..d3280069 100644 --- a/frontend/src/components/plexCards.langSwitch.dom.test.tsx +++ b/frontend/src/features/plexCards/plexCards.langSwitch.dom.test.tsx @@ -7,9 +7,9 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import { render as rtlRender, screen, cleanup, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactElement } from "react"; -import { setLanguage } from "../i18n"; +import { setLanguage } from "../../i18n"; import { PlexPosterCard } from "./plexCards"; -import type { PlexCard } from "../lib/api"; +import type { PlexCard } from "../../lib/api"; // A show card asks the server which episode it would play once its preview is up: a query client. const render = (ui: ReactElement) => diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/features/plexCards/plexCards.tsx similarity index 95% rename from frontend/src/components/plexCards.tsx rename to frontend/src/features/plexCards/plexCards.tsx index 7286c885..0127f0df 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/features/plexCards/plexCards.tsx @@ -3,21 +3,21 @@ import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react"; import clsx from "clsx"; import { type ReactNode, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; -import { api } from "../lib/api"; -import { qk } from "../lib/queryKeys"; -import { plexLang } from "../i18n"; -import { formatDecimal, formatRuntime } from "../lib/format"; -import { Carousel } from "./ui/Carousel"; -import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; -import { episodeLine, episodeName, episodeNumbers, seasonTitle } from "../lib/plexDetail"; -import { navItemAttrs } from "../lib/listNav"; -import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; +import { api } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; +import { plexLang } from "../../i18n"; +import { formatDecimal, formatRuntime } from "../../lib/format"; +import { Carousel } from "../../components/ui/Carousel"; +import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../../lib/api"; +import { episodeLine, episodeName, episodeNumbers, seasonTitle } from "../../lib/plexDetail"; +import { navItemAttrs } from "../../lib/listNav"; +import { endsTextSelection, movedPastSlop } from "../../lib/textSelection"; import { plexAddLabelKey, plexStarted } from "./usePlexCardActions"; -import { IconButton } from "./ui/IconButton"; -import { MoreButton } from "./ui/MoreButton"; -import { Pressable } from "./ui/Pressable"; -import { HOVER_YIELD } from "./ui/reveal"; -import { MediaResumeButtons } from "./MediaResumeButtons"; +import { IconButton } from "../../components/ui/IconButton"; +import { MoreButton } from "../../components/ui/MoreButton"; +import { Pressable } from "../../components/ui/Pressable"; +import { HOVER_YIELD } from "../../components/ui/reveal"; +import { MediaResumeButtons } from "../video/MediaResumeButtons"; import { MEDIA_BADGE, MEDIA_BAR, @@ -27,13 +27,13 @@ import { MEDIA_PICTURE, mediaPictureBox, mediaTitleClass, -} from "./ui/mediaCardStyles"; -import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard"; -import { MediaPreview } from "./ui/MediaPreview"; -import { useMediaPreview } from "./ui/useMediaPreview"; -import { SURFACE } from "./ui/surfaces"; -import { cardImage } from "../lib/plexImage"; -import RemoteImage from "./ui/RemoteImage"; +} from "../../components/ui/mediaCardStyles"; +import { MediaCard, MediaProgress, MediaPlayGlyph } from "../../components/ui/MediaCard"; +import { MediaPreview } from "../../components/ui/MediaPreview"; +import { useMediaPreview } from "../../components/ui/useMediaPreview"; +import { SURFACE } from "../../components/ui/surfaces"; +import { cardImage } from "../../lib/plexImage"; +import RemoteImage from "../../components/ui/RemoteImage"; // Presentational Plex cards + strips, split out of PlexBrowse (prop-driven, no shared state; the one // read is a show card asking which episode it would play, once its preview is up). Rendered by the diff --git a/frontend/src/components/usePlexCardActions.tsx b/frontend/src/features/plexCards/usePlexCardActions.tsx similarity index 96% rename from frontend/src/components/usePlexCardActions.tsx rename to frontend/src/features/plexCards/usePlexCardActions.tsx index 88d19b13..c32284a5 100644 --- a/frontend/src/components/usePlexCardActions.tsx +++ b/frontend/src/features/plexCards/usePlexCardActions.tsx @@ -3,13 +3,13 @@ import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, Info, Layers, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react"; -import { type PlexCard } from "../lib/api"; -import { qk } from "../lib/queryKeys"; +import { type PlexCard } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd"; -import { pinPlex, useComfortPin } from "./comfortPin"; -import { useCopyItems } from "./copyItems"; +import { pinPlex, useComfortPin } from "../comfort/comfortPin"; +import { useCopyItems } from "../../components/ui/copyItems"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; -import { type ActionMenuItem } from "./ui/ActionMenu"; +import { type ActionMenuItem } from "../../components/ui/ActionMenu"; // A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the // long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail diff --git a/frontend/src/components/PrefsProvider.dom.test.tsx b/frontend/src/features/prefs/PrefsProvider.dom.test.tsx similarity index 95% rename from frontend/src/components/PrefsProvider.dom.test.tsx rename to frontend/src/features/prefs/PrefsProvider.dom.test.tsx index 4c5d6d5d..e4c2c8c2 100644 --- a/frontend/src/components/PrefsProvider.dom.test.tsx +++ b/frontend/src/features/prefs/PrefsProvider.dom.test.tsx @@ -10,9 +10,9 @@ import { act, cleanup, render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PrefsProvider } from "./PrefsProvider"; import { usePrefs } from "./prefsContext"; -import { api } from "../lib/api"; -import { claimLocalData, LS } from "../lib/storage"; -import { qk } from "../lib/queryKeys"; +import { api } from "../../lib/api"; +import { claimLocalData, LS } from "../../lib/storage"; +import { qk } from "../../lib/queryKeys"; const seen: { perf: boolean; hints: boolean }[] = []; const controls: ReturnType[] = []; diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/features/prefs/PrefsProvider.tsx similarity index 93% rename from frontend/src/components/PrefsProvider.tsx rename to frontend/src/features/prefs/PrefsProvider.tsx index 2b374c8d..109f70e4 100644 --- a/frontend/src/components/PrefsProvider.tsx +++ b/frontend/src/features/prefs/PrefsProvider.tsx @@ -7,20 +7,24 @@ import { useState, type ReactNode, } from "react"; -import { qk } from "../lib/queryKeys"; +import { qk } from "../../lib/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; -import { api, type Me } from "../lib/api"; +import { api, type Me } from "../../lib/api"; import { applyTheme, loadLocalTheme, normalizeTheme, saveLocalTheme, type ThemePrefs, -} from "../lib/theme"; -import { configureNotifications, getNotifSettings, type NotifSettings } from "../lib/notifications"; -import { hintsEnabled, setHintsEnabled } from "../lib/hints"; -import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; -import type { PrefsController } from "./SettingsPanel"; +} from "../../lib/theme"; +import { + configureNotifications, + getNotifSettings, + type NotifSettings, +} from "../../lib/notifications"; +import { hintsEnabled, setHintsEnabled } from "../../lib/hints"; +import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage"; +import type { PrefsController } from "../../modules/settings/SettingsPanel"; import { PrefsContext } from "./prefsContext"; const PERF_KEY = LS.perfMode; diff --git a/frontend/src/components/prefsContext.ts b/frontend/src/features/prefs/prefsContext.ts similarity index 75% rename from frontend/src/components/prefsContext.ts rename to frontend/src/features/prefs/prefsContext.ts index 4849aa05..adea2d68 100644 --- a/frontend/src/components/prefsContext.ts +++ b/frontend/src/features/prefs/prefsContext.ts @@ -1,7 +1,7 @@ import { createContext, useContext } from "react"; -import { DEFAULT_THEME } from "../lib/theme"; -import { getNotifSettings } from "../lib/notifications"; -import type { PrefsController } from "./SettingsPanel"; +import { DEFAULT_THEME } from "../../lib/theme"; +import { getNotifSettings } from "../../lib/notifications"; +import type { PrefsController } from "../../modules/settings/SettingsPanel"; // The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only // components (Fast Refresh). diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/features/video/AddToPlaylist.tsx similarity index 90% rename from frontend/src/components/AddToPlaylist.tsx rename to frontend/src/features/video/AddToPlaylist.tsx index 8ad527a0..7f88b1e0 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/features/video/AddToPlaylist.tsx @@ -1,17 +1,17 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { qk } from "../lib/queryKeys"; +import { qk } from "../../lib/queryKeys"; import { ListPlus } from "lucide-react"; -import { YouTubeIcon } from "../lib/brandIcons"; -import { api, type Playlist } from "../lib/api"; -import { notifyActionError } from "../lib/actionErrors"; -import { playlistName } from "../lib/playlistName"; -import { PopoverSurface } from "./ui/AnchoredPopover"; -import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; -import { IconButton } from "./ui/IconButton"; -import { MembershipMenu } from "./ui/MembershipMenu"; -import { SURFACE } from "./ui/surfaces"; +import { YouTubeIcon } from "../../lib/brandIcons"; +import { api, type Playlist } from "../../lib/api"; +import { notifyActionError } from "../../lib/actionErrors"; +import { playlistName } from "../../lib/playlistName"; +import { PopoverSurface } from "../../components/ui/AnchoredPopover"; +import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; +import { IconButton } from "../../components/ui/IconButton"; +import { MembershipMenu } from "../../components/ui/MembershipMenu"; +import { SURFACE } from "../../components/ui/surfaces"; // A small anchored menu for toggling a video's membership in the user's playlists, with inline // "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's). diff --git a/frontend/src/components/ChannelNameLink.dom.test.tsx b/frontend/src/features/video/ChannelNameLink.dom.test.tsx similarity index 100% rename from frontend/src/components/ChannelNameLink.dom.test.tsx rename to frontend/src/features/video/ChannelNameLink.dom.test.tsx diff --git a/frontend/src/components/ChannelNameLink.tsx b/frontend/src/features/video/ChannelNameLink.tsx similarity index 95% rename from frontend/src/components/ChannelNameLink.tsx rename to frontend/src/features/video/ChannelNameLink.tsx index 2be89068..1d129e5e 100644 --- a/frontend/src/components/ChannelNameLink.tsx +++ b/frontend/src/features/video/ChannelNameLink.tsx @@ -1,7 +1,7 @@ import { forwardRef, type AnchorHTMLAttributes } from "react"; import { useTranslation } from "react-i18next"; -import { isPlainLeftClick } from "../lib/nav"; -import { urlWithParams } from "../lib/urlState"; +import { isPlainLeftClick } from "../../lib/nav"; +import { urlWithParams } from "../../lib/urlState"; // A channel name that is a REAL link. The player showed it as a `