From e257e2aca07dff7fda32eec134f4dad80ce78e7d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 21 Jul 2026 02:26:40 +0200 Subject: [PATCH] style(frontend): Prettier config + one-time repo-wide format pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass (`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha. --- frontend/.prettierignore | 6 + frontend/.prettierrc.json | 6 + frontend/e2e/README.md | 2 +- frontend/e2e/channel.spec.ts | 29 +- frontend/e2e/feed.spec.ts | 4 +- frontend/e2e/global.setup.ts | 12 +- frontend/e2e/nav.spec.ts | 12 +- frontend/src/App.tsx | 79 +-- frontend/src/components/About.tsx | 5 +- frontend/src/components/AddToPlaylist.tsx | 24 +- frontend/src/components/AdminUsers.tsx | 26 +- frontend/src/components/BackToTop.tsx | 7 +- frontend/src/components/Banner.tsx | 12 +- frontend/src/components/ChannelAbout.tsx | 14 +- frontend/src/components/ChannelDiscovery.tsx | 16 +- frontend/src/components/ChannelLayoutGrid.tsx | 4 +- frontend/src/components/ChannelPage.tsx | 398 ++++++----- frontend/src/components/Channels.tsx | 483 +++++++------ frontend/src/components/ChannelsProvider.tsx | 10 +- frontend/src/components/ChatDock.tsx | 24 +- frontend/src/components/ChatThread.tsx | 8 +- frontend/src/components/ColumnSortControl.tsx | 6 +- frontend/src/components/ConfigPanel.tsx | 245 ++++--- frontend/src/components/ConfirmProvider.tsx | 14 +- frontend/src/components/DataTable.tsx | 14 +- frontend/src/components/DownloadButton.tsx | 2 +- frontend/src/components/DownloadCenter.tsx | 475 ++++++++----- frontend/src/components/ErrorBoundary.tsx | 4 +- frontend/src/components/Feed.tsx | 205 +++--- frontend/src/components/FeedViewProvider.tsx | 4 +- frontend/src/components/GlassTuner.tsx | 84 ++- frontend/src/components/KeyGate.tsx | 19 +- frontend/src/components/LanguageSwitcher.tsx | 12 +- frontend/src/components/Messages.tsx | 98 ++- frontend/src/components/Modal.tsx | 2 +- frontend/src/components/NavSidebar.tsx | 352 ++++----- .../src/components/NavigationProvider.tsx | 3 +- .../src/components/NotificationsPanel.tsx | 187 ++--- frontend/src/components/OnboardingWizard.tsx | 241 ++++--- frontend/src/components/PanelGroup.tsx | 4 +- frontend/src/components/PlayerModal.tsx | 670 ++++++++++-------- frontend/src/components/Playlists.tsx | 57 +- frontend/src/components/PlaylistsProvider.tsx | 9 +- frontend/src/components/PlaylistsRail.tsx | 20 +- frontend/src/components/PlexBrowse.tsx | 188 ++++- .../src/components/PlexCollectionEditor.tsx | 13 +- frontend/src/components/PlexInfo.tsx | 550 +++++++------- frontend/src/components/PlexPlayer.tsx | 224 ++++-- frontend/src/components/PlexPlaylistAdd.tsx | 26 +- frontend/src/components/PlexPlaylistView.tsx | 121 +++- frontend/src/components/PlexProvider.tsx | 9 +- frontend/src/components/PlexSidebar.tsx | 42 +- frontend/src/components/ProfileEditor.tsx | 111 ++- frontend/src/components/SavedViewsWidget.tsx | 4 +- frontend/src/components/Scheduler.tsx | 70 +- frontend/src/components/SettingsPanel.tsx | 161 +++-- frontend/src/components/SetupWizard.tsx | 93 ++- frontend/src/components/ShareDialog.tsx | 70 +- frontend/src/components/Sidebar.tsx | 6 +- frontend/src/components/Stats.tsx | 41 +- frontend/src/components/SyncStatus.tsx | 7 +- frontend/src/components/TagManager.tsx | 2 +- frontend/src/components/Toaster.tsx | 21 +- frontend/src/components/Tooltip.tsx | 4 +- frontend/src/components/UndoToolbar.tsx | 23 +- frontend/src/components/VideoCard.tsx | 59 +- frontend/src/components/VideoEditor.tsx | 140 +++- frontend/src/components/ViewSwitcher.tsx | 6 +- frontend/src/components/VirtualGrid.tsx | 12 +- frontend/src/components/WatchPage.tsx | 11 +- frontend/src/components/Welcome.tsx | 43 +- frontend/src/components/WizardProvider.tsx | 10 +- frontend/src/components/channelColumns.tsx | 4 +- frontend/src/components/legal/LegalLayout.tsx | 16 +- .../src/components/legal/PrivacyPolicy.tsx | 54 +- frontend/src/components/legal/Terms.tsx | 32 +- frontend/src/components/tableHeader.tsx | 22 +- frontend/src/i18n/locales/en/plex.json | 2 +- frontend/src/i18n/locales/hu/plex.json | 2 +- frontend/src/index.css | 261 +++++-- frontend/src/lib/api.ts | 226 ++++-- frontend/src/lib/chatDock.ts | 30 +- frontend/src/lib/descriptionLinks.tsx | 28 +- frontend/src/lib/e2ee.ts | 57 +- frontend/src/lib/feedSort.ts | 5 +- frontend/src/lib/feedView.ts | 45 +- frontend/src/lib/linkify.tsx | 2 +- frontend/src/lib/messagesSocket.ts | 6 +- frontend/src/lib/modules.ts | 39 +- frontend/src/lib/notifications.ts | 38 +- frontend/src/lib/playlistView.test.ts | 16 +- frontend/src/lib/playlistView.ts | 2 +- frontend/src/lib/plexDetailUi.tsx | 37 +- frontend/src/lib/releaseNotes.ts | 91 +-- frontend/src/lib/storage.ts | 5 +- frontend/src/lib/useActiveFilters.ts | 2 +- frontend/src/lib/useCardPager.ts | 3 +- frontend/src/lib/useLiveQuery.ts | 2 +- frontend/src/lib/useScrollFade.ts | 11 +- frontend/src/lib/useUndoable.ts | 4 +- frontend/src/main.tsx | 12 +- 101 files changed, 4388 insertions(+), 2641 deletions(-) create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..29837d5 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +playwright-report +test-results +e2e/.auth +package-lock.json diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..e256e24 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "singleQuote": false, + "semi": true, + "trailingComma": "all" +} diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md index bdb022d..5c7c6bb 100644 --- a/frontend/e2e/README.md +++ b/frontend/e2e/README.md @@ -2,7 +2,7 @@ Playwright tests that lock the app's five core flows — **feed** (scroll + filter), **channel** (open → back), **player** (open/close), **settings** (dirty-save + guard), and **page switching** — -so the Platform refactor can *prove* it changed nothing. They assert current, correct behavior; +so the Platform refactor can _prove_ it changed nothing. They assert current, correct behavior; they deliberately do **not** encode the known Platform bugs (those get their own specs when the sprints that fix them land). diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts index 586e615..7106bd1 100644 --- a/frontend/e2e/channel.spec.ts +++ b/frontend/e2e/channel.spec.ts @@ -59,7 +59,9 @@ test.describe("channel view", () => { // Bug 2 (Platform S5): opening a channel no longer FORCES Live/Upcoming on — the channel view // seeds its content-type prefs (normal / shorts / live) from the global feed. Locked both ways so // a future refactor can't silently re-hardcode it. - test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({ page }) => { + test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({ + page, + }) => { await openApp(page); await expect(page.getByTestId("video-card").first()).toBeVisible(); const feedLive = page.getByTestId("feed-content-includeLive"); @@ -68,7 +70,10 @@ test.describe("channel view", () => { // Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on. await page.getByTestId("video-card-channel").first().click(); await expect(page.getByTestId("channel-title")).toBeVisible(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( + "aria-pressed", + "false", + ); await page.getByTestId("channel-back").click(); await expect(page.getByTestId("channel-title")).toHaveCount(0); @@ -78,7 +83,10 @@ test.describe("channel view", () => { await expect(page.getByTestId("video-card").first()).toBeVisible(); await page.getByTestId("video-card-channel").first().click(); await expect(page.getByTestId("channel-title")).toBeVisible(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( + "aria-pressed", + "true", + ); }); // Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there @@ -87,18 +95,27 @@ test.describe("channel view", () => { test("a channel's content toggle does not leak into the feed", async ({ page }) => { await openApp(page); await expect(page.getByTestId("video-card").first()).toBeVisible(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( + "aria-pressed", + "false", + ); // Turn Live/Upcoming ON inside the channel view. await page.getByTestId("video-card-channel").first().click(); await expect(page.getByTestId("channel-title")).toBeVisible(); await page.getByTestId("feed-content-includeLive").click(); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( + "aria-pressed", + "true", + ); // Back on the feed, its Live/Upcoming is still off — the channel's change stayed local. await page.getByTestId("channel-back").click(); await expect(page.getByTestId("channel-title")).toHaveCount(0); - await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false"); + await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute( + "aria-pressed", + "false", + ); }); // Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts index 724652c..937f667 100644 --- a/frontend/e2e/feed.spec.ts +++ b/frontend/e2e/feed.spec.ts @@ -74,8 +74,6 @@ test.describe("feed", () => { // Switch the show filter → a different result set → back to the top. await page.getByTestId("feed-show-all").click(); - await expect - .poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)) - .toBeLessThan(50); + await expect.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop)).toBeLessThan(50); }); }); diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts index ba904a9..5c40de1 100644 --- a/frontend/e2e/global.setup.ts +++ b/frontend/e2e/global.setup.ts @@ -37,7 +37,17 @@ setup("seed + authenticate", async ({ page }) => { try { const out = execFileSync( "docker", - ["exec", "-i", "-e", `E2E_EMAIL=${EMAIL}`, "-e", `E2E_PASSWORD=${PASSWORD}`, CONTAINER, "python", "-"], + [ + "exec", + "-i", + "-e", + `E2E_EMAIL=${EMAIL}`, + "-e", + `E2E_PASSWORD=${PASSWORD}`, + CONTAINER, + "python", + "-", + ], { input: readFileSync(SEED_SCRIPT) }, ); console.log(`[e2e seed] ${out.toString().trim()}`); diff --git a/frontend/e2e/nav.spec.ts b/frontend/e2e/nav.spec.ts index 42d91e9..4427e1b 100644 --- a/frontend/e2e/nav.spec.ts +++ b/frontend/e2e/nav.spec.ts @@ -7,7 +7,17 @@ import { openApp } from "./helpers"; test.describe("page switching", () => { // Core + admin modules (the E2E account is an admin, matching the real primary user), so this // also exercises the render ternary's admin branches (scheduler / config / users / audit). - const RAIL = ["channels", "playlists", "stats", "scheduler", "config", "users", "audit", "settings", "feed"]; + const RAIL = [ + "channels", + "playlists", + "stats", + "scheduler", + "config", + "users", + "audit", + "settings", + "feed", + ]; test("each rail item activates its page", async ({ page }) => { await openApp(page); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 995d164..b84dea2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { - api, - getActiveAccount, - setActiveAccount, - setUnauthorizedHandler, -} from "./lib/api"; +import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; import { hasFilterParams, stripUrlParams } from "./lib/urlState"; import type { Page } from "./lib/urlState"; @@ -19,12 +14,7 @@ import { type PanelLayout, } from "./lib/panelLayout"; import { notify, removeByMetaKind } from "./lib/notifications"; -import { - LS, - readAccount, - setAccountRaw, - writeAccount, -} from "./lib/storage"; +import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; import { useChannelLayout } from "./components/ChannelsProvider"; @@ -99,15 +89,15 @@ export default function App() { // initial render synchronously so nothing flashes open before the server prefs arrive. // Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice. const [navCollapsed, setNavCollapsedState] = useState(() => - Boolean(readAccount(LS.navCollapsed, true)) + Boolean(readAccount(LS.navCollapsed, true)), ); // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice. const [filterCollapsed, setFilterCollapsedState] = useState(() => - Boolean(readAccount(LS.filterCollapsed, true)) + Boolean(readAccount(LS.filterCollapsed, true)), ); // The Playlists rail has its own collapse flag (it's a list/navigator, not filters). const [playlistsCollapsed, setPlaylistsCollapsedState] = useState(() => - Boolean(readAccount(LS.playlistsCollapsed, true)) + Boolean(readAccount(LS.playlistsCollapsed, true)), ); function setNavCollapsed(next: boolean) { setNavCollapsedState(next); @@ -136,7 +126,6 @@ export default function App() { } }, []); - const queryClient = useQueryClient(); // First-run install gate: a fresh instance reports configured=false and runs in setup mode. // We check this before anything else and render the wizard; `me` is held back until we know the @@ -364,38 +353,38 @@ export default function App() {
{channelView ? ( - - { - // Keep the rest of the feed's filters — the point is "this channel, the way I - // normally read": unwatched, my tags, my date window. - // ADD to the picked set, don't replace it: channels OR together now, so arriving - // from a second channel page should widen the feed, not silently drop the first. - // Deduped — clicking it again for a channel already picked is a no-op. - setFilters({ - ...filters, - channelIds: [...new Set([...filters.channelIds, channelView.id])], - }); - // setPage already leaves an open channel page and pushes a clean history entry. - // Calling closeChannel() first ran history.back(), whose popstate landed AFTER this - // and restored whatever page the channel had been opened from. - setPage("feed"); - }} - /> - + + { + // Keep the rest of the feed's filters — the point is "this channel, the way I + // normally read": unwatched, my tags, my date window. + // ADD to the picked set, don't replace it: channels OR together now, so arriving + // from a second channel page should widen the feed, not silently drop the first. + // Deduped — clicking it again for a channel already picked is a no-op. + setFilters({ + ...filters, + channelIds: [...new Set([...filters.channelIds, channelView.id])], + }); + // setPage already leaves an open channel page and pushes a clean history entry. + // Calling closeChannel() first ran history.back(), whose popstate landed AFTER this + // and restored whatever page the channel had been opened from. + setPage("feed"); + }} + /> + ) : ( } banners={ <> @@ -404,9 +393,9 @@ export default function App() { } > - - - + + + )} {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx index 6310ae8..79e6eb4 100644 --- a/frontend/src/components/About.tsx +++ b/frontend/src/components/About.tsx @@ -24,7 +24,10 @@ export default function About({ const { data } = useQuery({ queryKey: ["version"], queryFn: api.version, staleTime: 5 * 60_000 }); const rows: [string, string][] = [ - [t("about.frontend"), FRONTEND_VERSION + (FRONTEND_SHA !== "unknown" ? ` · ${FRONTEND_SHA}` : "")], + [ + t("about.frontend"), + FRONTEND_VERSION + (FRONTEND_SHA !== "unknown" ? ` · ${FRONTEND_SHA}` : ""), + ], [ t("about.backend"), data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…", diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index d7e3bce..4a84bff 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -133,14 +133,14 @@ export default function AddToPlaylist({ className="z-overlay glass-menu rounded-xl p-2 shadow-2xl" onClick={(e) => e.stopPropagation()} > -
- {t("playlists.addToPlaylist")} -
-
+
{t("playlists.addToPlaylist")}
+
{lists.length === 0 && !membership.isLoading && ( -
- {t("playlists.noneYet")} -
+
{t("playlists.noneYet")}
)} {lists.map((pl) => (
, - document.body + document.body, )} ); diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index ce61822..92a1f94 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -45,7 +45,13 @@ export default function AdminUsers() { ); } -function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) { +function Badge({ + tone, + children, +}: { + tone: "accent" | "muted" | "warning"; + children: React.ReactNode; +}) { const cls = tone === "accent" ? "border-accent/40 text-accent" @@ -53,7 +59,9 @@ function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; child ? "border-amber-500/40 text-amber-500" : "border-border text-muted"; return ( - {children} + + {children} + ); } @@ -209,17 +217,25 @@ function UsersRoles({ me }: { me: Me }) { - + , - document.body + document.body, ); } diff --git a/frontend/src/components/Banner.tsx b/frontend/src/components/Banner.tsx index d789078..3656d4c 100644 --- a/frontend/src/components/Banner.tsx +++ b/frontend/src/components/Banner.tsx @@ -5,8 +5,16 @@ import { X } from "lucide-react"; type BannerTone = "accent" | "warning"; const TONES: Record = { - accent: { bar: "bg-accent/15 border-accent/30", icon: "text-accent", action: "bg-accent text-accent-fg" }, - warning: { bar: "bg-amber-500/15 border-amber-500/30", icon: "text-amber-500", action: "bg-amber-500 text-black" }, + accent: { + bar: "bg-accent/15 border-accent/30", + icon: "text-accent", + action: "bg-accent text-accent-fg", + }, + warning: { + bar: "bg-amber-500/15 border-amber-500/30", + icon: "text-amber-500", + action: "bg-amber-500 text-black", + }, }; // Shared top-of-app notification bar. VersionBanner and DemoBanner are thin wrappers over this; diff --git a/frontend/src/components/ChannelAbout.tsx b/frontend/src/components/ChannelAbout.tsx index cc19b35..bed97c3 100644 --- a/frontend/src/components/ChannelAbout.tsx +++ b/frontend/src/components/ChannelAbout.tsx @@ -6,7 +6,10 @@ import { linkify } from "../lib/linkify"; // --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) --- function displayName(code: string, lang: string, type: "region" | "language"): string { try { - return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code; + return ( + new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? + code + ); } catch { return code; } @@ -47,7 +50,9 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { return ( <> {ch.description ? ( -

{linkify(ch.description)}

+

+ {linkify(ch.description)} +

) : (

{t("channel.noDescription")}

)} @@ -75,7 +80,10 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) { {/^[A-Za-z]{2}$/.test(ch.country) && ( // Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji. - + )} {displayName(ch.country, i18n.language, "region")} diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index e0f7f50..7e9acf1 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -91,7 +91,7 @@ export default function ChannelDiscovery({ const q = search.trim().toLowerCase(); const rows = (query.data ?? []).filter( - (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q) + (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q), ); const columns: Column[] = [ @@ -107,9 +107,7 @@ export default function ChannelDiscovery({ render: (c) => ( -
- -
- {/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner - paints ABOVE it (the banner's positioned container would otherwise cover it). */} -
- -
-
-

{name}

- {ch?.blocked ? ( - - {t("channel.blockedBadge")} - - ) : ( - ch?.explored && - !ch?.subscribed && ( - - {t("channel.exploringBadge")} - - ) - )} -
-
- {metaParts.map((part, i) => ( - - {i > 0 && ·} - {part} - - ))} -
-
+ )} +
- {exploring && ( -
- - {t("channel.loadingVideos")} +
+ {/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner + paints ABOVE it (the banner's positioned container would otherwise cover it). */} +
+ +
+
+

+ {name} +

+ {ch?.blocked ? ( + + {t("channel.blockedBadge")} + + ) : ( + ch?.explored && + !ch?.subscribed && ( + + {t("channel.exploringBadge")} + + ) + )} +
+
+ {metaParts.map((part, i) => ( + + {i > 0 && ·} + {part} + + ))} +
+
- )} - {/* Tabs — the channel actions ride along here rather than off in the header's top-right + {exploring && ( +
+ + {t("channel.loadingVideos")} +
+ )} + + {/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} -
- - -
- - - - {ch?.subscribed && ( - + +
+ - - - )} - {!me.is_demo && ( - - )} - {!me.is_demo && - !ch?.blocked && - (ch?.subscribed ? ( + + + {ch?.subscribed && ( - ) : ( + )} + {!me.is_demo && ( - ))} + )} + {!me.is_demo && + !ch?.blocked && + (ch?.subscribed ? ( + + ) : ( + + ))} +
-
- {/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome, + {/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome, just under the tabs. */} -
+
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override; only the grid (and About) render here, in the scroller. */} - {tab === "videos" ? ( - <> - {}} - onExitYtSearch={() => {}} - channelScoped - /> - {!ch?.subscribed && nextToken && ( -
- -
- )} - - ) : ( -
- {ch ? ( - - ) : ( -

{t("channel.noDescription")}

- )} -
- )} + {tab === "videos" ? ( + <> + {}} + onExitYtSearch={() => {}} + channelScoped + /> + {!ch?.subscribed && nextToken && ( +
+ +
+ )} + + ) : ( +
+ {ch ? ( + + ) : ( +

{t("channel.noDescription")}

+ )} +
+ )}
); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index c6e10bc..c71bb4f 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -82,8 +82,14 @@ export default function Channels() { focusToken: focusChannelToken, } = useChannels(); const layout = useChannelLayout(); - const { setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel: onFocusChannel } = - useChannelsActions(); + const { + setSearch, + setStatusFilter, + setView, + setLayout, + setSort, + focusChannel: onFocusChannel, + } = useChannelsActions(); const { openChannel: onViewChannel, setPage } = useNavigationActions(); const feedFilters = useFeedFilters(); const { setFilters: setFeedFilters } = useFeedFiltersActions(); @@ -144,7 +150,7 @@ export default function Channels() { const bumpPriority = (id: string, current: number, delta: number) => { const next = current + delta; qc.setQueryData(["channels"], (old) => - (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)) + (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)), ); api .updateChannel(id, { priority: next }) @@ -159,23 +165,22 @@ export default function Channels() { ch.id === id ? { ...ch, - tag_ids: hasTag - ? ch.tag_ids.filter((x) => x !== tagId) - : [...ch.tag_ids, tagId], + tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId], } - : ch - ) + : ch, + ), ); - const call = hasTag - ? api.detachChannelTag(id, tagId) - : api.attachChannelTag(id, tagId); + const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { invalidate(); - notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); + notify({ + level: "success", + message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }), + }); }, onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard), }); @@ -186,7 +191,8 @@ export default function Channels() { qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) }); }, - onError: (e) => notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), + onError: (e) => + notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard), }); const resetBackfill = useMutation({ mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id), @@ -207,7 +213,8 @@ export default function Channels() { message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, - onError: (e) => notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), + onError: (e) => + notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard), }); // Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied @@ -237,7 +244,7 @@ export default function Channels() { ? c.backfill_done : statusFilter === "hidden" ? c.hidden - : true + : true, ); const q = search.trim().toLowerCase(); const visibleChannels = channels.filter((c) => { @@ -338,7 +345,9 @@ export default function Channels() { nowrap: true, sortable: true, sortValue: (c) => c.stored_videos, - render: (c) => {c.stored_videos.toLocaleString()}, + render: (c) => ( + {c.stored_videos.toLocaleString()} + ), }, subsColumn(t), { @@ -356,7 +365,11 @@ export default function Channels() { nowrap: true, sortable: true, sortValue: (c) => c.total_duration_seconds, - render: (c) => {formatTotalHours(c.total_duration_seconds)}, + render: (c) => ( + + {formatTotalHours(c.total_duration_seconds)} + + ), }, { key: "types", @@ -381,7 +394,12 @@ export default function Channels() { { value: "fully", label: t("channels.filters.fullySynced") }, { value: "needsFull", label: t("channels.filters.needsFull") }, ], - test: (c, v) => (v === "fully" ? c.recent_synced && c.backfill_done : v === "needsFull" ? !c.backfill_done : true), + test: (c, v) => + v === "fully" + ? c.recent_synced && c.backfill_done + : v === "needsFull" + ? !c.backfill_done + : true, }, render: (c) => , }, @@ -431,7 +449,11 @@ export default function Channels() {
{s && ( <> - + )} - + - {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never + {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */} - - {tabs} - {view === "subscribed" && ( -
- {/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name + + {tabs} + {view === "subscribed" && ( +
+ {/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name search lives in the shared top SearchBar, tag create/delete in the manager. */} -
-
- - - {t("channels.tags.yourTags")} - - - {userTags.map((tg) => { - const active = tagFilter.includes(tg.id); - return ( - - ); - })} - {tagFilter.length > 0 && ( - - )} - -
- {actionButtons} -
-
- )} - {/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card - layout has no such row, so surface the same status chips (left) and its pager (right) here. */} - {view === "subscribed" && layout === "cards" && ( -
- {statusChips} - {cardPager} -
- )} -
- {tagManagerOpen && ( - setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> - )} - - {view === "discovery" ? ( - - ) : view === "blocked" ? ( - /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */ -
- {(blockedQuery.data?.length ?? 0) === 0 ? ( -

{t("channels.blocked.empty")}

- ) : ( - <> -
- - - {t("channels.blocked.title")} - -
-
- {blockedQuery.data!.map((c) => ( - - {c.title ?? c.id} +
+
+ + + {t("channels.tags.yourTags")} + + + {userTags.map((tg) => { + const active = tagFilter.includes(tg.id); + return ( + + ); + })} + {tagFilter.length > 0 && ( - - ))} + )} + +
+ {actionButtons}
-

{t("channels.blocked.hint")}

- +
)} -
- ) : ( - /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when + {/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card + layout has no such row, so surface the same status chips (left) and its pager (right) here. */} + {view === "subscribed" && layout === "cards" && ( +
+ {statusChips} + {cardPager} +
+ )} + + {tagManagerOpen && ( + setTagManagerOpen(false)} onFocusChannel={onFocusChannel} /> + )} + + {view === "discovery" ? ( + + ) : view === "blocked" ? ( + /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */ +
+ {(blockedQuery.data?.length ?? 0) === 0 ? ( +

{t("channels.blocked.empty")}

+ ) : ( + <> +
+ + + {t("channels.blocked.title")} + +
+
+ {blockedQuery.data!.map((c) => ( + + {c.title ?? c.id} + + + ))} +
+

{t("channels.blocked.hint")}

+ + )} +
+ ) : ( + /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when `position: sticky` engages on the first scroll (the band's controls row above gives the visual gap). */ -
- {/* Channel table — only the rows scroll; the header row is sticky, and the controls row +
+ {/* Channel table — only the rows scroll; the header row is sticky, and the controls row (status chips + pager) rides in the fixed band right above it (controlsInBand). The card layout swaps the table for a virtualized grid over the same rows (its pager and status chips ride in the fixed band above instead). */} - {channelsQuery.isLoading ? ( -
{t("channels.loading")}
- ) : layout === "table" ? ( - c.id} - persistKey={accountKey(LS.channelsTable) ?? undefined} - controlsPosition="both" - controlsLeading={statusChips} - controlsInBand - controlsBandClassName="px-4 max-w-[96rem] mx-auto" - sort={sort} - onSortChange={setSort} - rowClassName={(c) => (c.hidden ? "opacity-60" : "")} - emptyText={t("channels.empty")} - /> - ) : ( - c.id} - leadKey="priority" - aboutKey="about" - actionsKey="actions" - rowClassName={(c) => (c.hidden ? "opacity-60" : "")} - emptyText={t("channels.empty")} - /> - )} -
- )} + {channelsQuery.isLoading ? ( +
{t("channels.loading")}
+ ) : layout === "table" ? ( + c.id} + persistKey={accountKey(LS.channelsTable) ?? undefined} + controlsPosition="both" + controlsLeading={statusChips} + controlsInBand + controlsBandClassName="px-4 max-w-[96rem] mx-auto" + sort={sort} + onSortChange={setSort} + rowClassName={(c) => (c.hidden ? "opacity-60" : "")} + emptyText={t("channels.empty")} + /> + ) : ( + c.id} + leadKey="priority" + aboutKey="about" + actionsKey="actions" + rowClassName={(c) => (c.hidden ? "opacity-60" : "")} + emptyText={t("channels.empty")} + /> + )} +
+ )} ); } -function Stat({ - label, - value, - hint, -}: { - label: string; - value: string | number; - hint?: string; -}) { +function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) { return ( @@ -751,16 +769,30 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str ); } -function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) { +function PriorityCell({ + c, + onPriority, +}: { + c: ManagedChannel; + onPriority: (delta: number) => void; +}) { const { t } = useTranslation(); return (
- {c.priority} -
@@ -768,30 +800,51 @@ function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta ); } - // Status only — the backfill *action* lives in the Actions column. When both recent and // full history are in, collapse to a single "fully synced" chip; otherwise show what's // present plus the missing/queued state. function SyncCell({ c }: { c: ManagedChannel }) { const { t } = useTranslation(); if (c.recent_synced && c.backfill_done) { - return ; + return ( + + ); } return (
{c.backfill_done ? ( ) : c.deep_requested ? ( - + ) : c.deep_in_queue ? ( - + ) : ( - + )}
); @@ -814,7 +867,9 @@ function TagsCell({ // `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a // `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew // UNDER the next card. Same hazard, same blessed fix as the players in E3. - const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(null); + const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>( + null, + ); const fade = useScrollFade(); const ref = useRef(null); const popRef = useRef(null); @@ -839,9 +894,7 @@ function TagsCell({ const w = popRef.current?.offsetWidth ?? 0; const left = w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left; - return flip - ? { left, bottom: window.innerHeight - r.top + 4 } - : { left, top: r.bottom + 4 }; + return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 }; }, []); const place = useCallback(() => { @@ -939,33 +992,39 @@ function TagsCell({ {open && coords && ( -
- {userTags.map((tg) => { - const on = c.tag_ids.includes(tg.id); - return ( - - ); - })} -
+ + {on && } + + {tg.name} + + ); + })} +
)}
@@ -1025,7 +1084,11 @@ function ActionsCell({ - diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 467beab..6b6680c 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -209,7 +209,15 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { [search, statusFilter, view, sort, filtersResetToken, focusName, focusToken], ); const actions = useMemo( - () => ({ setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory }), + () => ({ + setSearch, + setStatusFilter, + setView, + setLayout, + setSort, + focusChannel, + goToFullHistory, + }), [setSearch, setStatusFilter, setView, setLayout, setSort, focusChannel, goToFullHistory], ); diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index 21e6c6e..9d7cba2 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -2,7 +2,14 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { ChevronDown, ChevronUp, X } from "lucide-react"; -import { closeChat, initDock, notifyIncoming, toggleMinimize, useDockChats, type DockChat } from "../lib/chatDock"; +import { + closeChat, + initDock, + notifyIncoming, + toggleMinimize, + useDockChats, + type DockChat, +} from "../lib/chatDock"; import { onMessage, onUnread } from "../lib/messagesSocket"; import * as e2ee from "../lib/e2ee"; import Avatar from "./Avatar"; @@ -37,7 +44,7 @@ export default function ChatDock({ meId }: { meId: number }) { notifyIncoming({ id: from.id, name: from.name, avatar_url: from.avatar_url }); } }), - [qc, meId] + [qc, meId], ); // Live badge sync: another tab/device read a thread → refresh this tab's unread count + list. @@ -47,7 +54,7 @@ export default function ChatDock({ meId }: { meId: number }) { qc.invalidateQueries({ queryKey: ["message-unread"] }); qc.invalidateQueries({ queryKey: ["conversations"] }); }), - [qc] + [qc], ); if (chats.length === 0) return null; @@ -68,7 +75,10 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */} {chat.flash > 0 && ( -
+
)}