From 473658cde854a13ab67772f3d692f5bb4d0cb4d6 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:32:46 -0400 Subject: [PATCH 001/303] fix(web): send empty Home and library tabs to the right settings Home's "Customize Home Screen" link pointed at /settings/home, which is not routed, so the settings catch-all dropped people on Playback. It now opens /settings/home-screen. A library's Recommended tab was blank when the library had media but no sections. It now says so and links to Home Screen settings, which edits library pages through its scope picker; admins also get Admin Sections. The empty-library state still wins when the library holds nothing. The library Collections tab told every viewer to create collections in the admin area. Admins now get a link to Admin Collections with this library selected; everyone else gets neutral copy. Co-Authored-By: Claude Opus 5.5 (1M context) --- web/src/pages/Home.test.tsx | 18 ++++++- web/src/pages/Home.tsx | 5 +- web/src/pages/LibraryCollections.test.tsx | 49 +++++++++++++++++++ web/src/pages/LibraryCollections.tsx | 18 +++++-- web/src/pages/LibraryRecommended.test.tsx | 59 ++++++++++++++++++++++- web/src/pages/LibraryRecommended.tsx | 41 ++++++++++++++++ 6 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 web/src/pages/LibraryCollections.test.tsx diff --git a/web/src/pages/Home.test.tsx b/web/src/pages/Home.test.tsx index 43bf7c51e0..5ed414cbd1 100644 --- a/web/src/pages/Home.test.tsx +++ b/web/src/pages/Home.test.tsx @@ -38,7 +38,7 @@ vi.mock("@/hooks/useDocumentTitle", () => ({ })); vi.mock("react-router", () => ({ - Link: ({ children }: { children: ReactNode }) => {children}, + Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, })); vi.mock("@/components/TasteSeedBanner", () => ({ @@ -105,6 +105,22 @@ describe("Home", () => { invalidateQueries.mockRestore(); }); + it("links an empty Home to the Home Screen settings page", async () => { + await act(async () => { + root.render( + + + , + ); + await Promise.resolve(); + }); + + const link = Array.from(container.querySelectorAll("a")).find( + (anchor) => anchor.textContent === "Customize Home Screen", + ); + expect(link?.getAttribute("href")).toBe("/settings/home-screen"); + }); + it("keeps section items cached past the client-wide gc time", async () => { mockUseHomeLayout.mockReturnValue({ data: { diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index a77b0e24e6..47ff66674d 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -304,7 +304,10 @@ export default function Home() {

Your home screen is empty. Customize it by adding sections to display your media.

- + Customize Home Screen diff --git a/web/src/pages/LibraryCollections.test.tsx b/web/src/pages/LibraryCollections.test.tsx new file mode 100644 index 0000000000..a0a75db018 --- /dev/null +++ b/web/src/pages/LibraryCollections.test.tsx @@ -0,0 +1,49 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ isActingAdmin: false })); + +vi.mock("@/hooks/useIsActingAdmin", () => ({ + useIsActingAdmin: () => mocks.isActingAdmin, +})); + +vi.mock("@/hooks/queries/libraryCollections", () => ({ + useLibraryCollections: () => ({ + data: { groups: [], ungrouped: { collections: [], sort_order: 0 } }, + isLoading: false, + }), +})); + +import LibraryCollections from "./LibraryCollections"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("LibraryCollections empty state", () => { + beforeEach(() => { + mocks.isActingAdmin = false; + }); + + it("gives regular users a plain message without admin directions", () => { + const markup = render(); + + expect(markup).toContain("No collections yet"); + expect(markup).not.toContain("admin"); + expect(markup).not.toContain(" { + mocks.isActingAdmin = true; + + const markup = render(); + + expect(markup).toContain("No collections yet"); + expect(markup).toContain('href="/admin/collections?libraryId=7"'); + }); +}); diff --git a/web/src/pages/LibraryCollections.tsx b/web/src/pages/LibraryCollections.tsx index ac77f1c46e..96e941ae6d 100644 --- a/web/src/pages/LibraryCollections.tsx +++ b/web/src/pages/LibraryCollections.tsx @@ -1,5 +1,7 @@ +import { Link } from "react-router"; import type { LibraryTabCollection, LibraryTabGroup, LibraryTabUngrouped } from "@/api/types"; import { useLibraryCollections } from "@/hooks/queries/libraryCollections"; +import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { CollectionPosterCard } from "@/components/collections/CollectionPosterCard"; @@ -12,6 +14,7 @@ interface LibraryCollectionsProps { export default function LibraryCollections({ libraryId }: LibraryCollectionsProps) { const { data, isLoading } = useLibraryCollections(libraryId); + const actingAdmin = useIsActingAdmin(); const { cardPresentation } = useUICustomization(); const gridClasses = cardGridClasses(cardPresentation.poster_size); @@ -40,9 +43,18 @@ export default function LibraryCollections({ libraryId }: LibraryCollectionsProp

No collections yet

-

- Create library collections from the admin area to feature curated shelves here. -

+ {actingAdmin ? ( + + Create collections for this library + + ) : ( +

+ Collections for this library will show up here. +

+ )}
diff --git a/web/src/pages/LibraryRecommended.test.tsx b/web/src/pages/LibraryRecommended.test.tsx index c88205d676..7a6d9faa37 100644 --- a/web/src/pages/LibraryRecommended.test.tsx +++ b/web/src/pages/LibraryRecommended.test.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import LibraryRecommended from "./LibraryRecommended"; @@ -20,6 +21,11 @@ const mockFetchLibrarySectionItems = vi.fn(); const mockUseSidebarPins = vi.fn(); const mockUseLibraryCollectionItems = vi.fn(); const mockUseLibraryHasItems = vi.fn(); +const mockUseIsActingAdmin = vi.fn(); + +vi.mock("@/hooks/useIsActingAdmin", () => ({ + useIsActingAdmin: () => mockUseIsActingAdmin(), +})); vi.mock("@/hooks/queries/catalog", async (importOriginal) => ({ ...(await importOriginal()), @@ -202,6 +208,7 @@ describe("LibraryRecommended", () => { mockUseLibraryCollectionItems.mockReturnValue(collectionItemsResult([])); mockUseLibraryHasItems.mockReset(); mockUseLibraryHasItems.mockReturnValue({ data: undefined }); + mockUseIsActingAdmin.mockReturnValue(false); }); afterEach(async () => { @@ -214,7 +221,11 @@ describe("LibraryRecommended", () => { async function render(ui: ReactNode) { const queryClient = new QueryClient(); await act(async () => { - root.render({ui}); + root.render( + + {ui} + , + ); await Promise.resolve(); await Promise.resolve(); }); @@ -461,6 +472,52 @@ describe("LibraryRecommended", () => { }); }); + describe("library with media but no sections", () => { + beforeEach(() => { + mockUseLibraryLayout.mockReturnValue({ data: { sections: [] }, isLoading: false }); + mockUseLibraryHasItems.mockReturnValue({ data: true }); + }); + + function linkHrefs() { + return Array.from(container.querySelectorAll("a")).map((link) => link.getAttribute("href")); + } + + it("points viewers at Home Screen settings", async () => { + await render(); + + expect(container.textContent).toContain("No sections yet"); + expect(linkHrefs()).toEqual(["/settings/home-screen"]); + expect(container.querySelector('[data-kind="library-empty"]')).toBeNull(); + }); + + it("also points admins at Admin Sections", async () => { + mockUseIsActingAdmin.mockReturnValue(true); + + await render(); + + expect(container.textContent).toContain("No sections yet"); + expect(linkHrefs()).toEqual(["/admin/sections", "/settings/home-screen"]); + }); + + it("waits for the library check before choosing an empty state", async () => { + mockUseLibraryHasItems.mockReturnValue({ data: undefined }); + + await render(); + + expect(container.textContent).not.toContain("No sections yet"); + expect(container.querySelector('[data-kind="library-empty"]')).toBeNull(); + }); + + it("shows the empty-library state when the library holds nothing", async () => { + mockUseLibraryHasItems.mockReturnValue({ data: false }); + + await render(); + + expect(container.querySelector('[data-kind="library-empty"]')).not.toBeNull(); + expect(container.textContent).not.toContain("No sections yet"); + }); + }); + it("does not check library emptiness while sections have items", async () => { await render(); diff --git a/web/src/pages/LibraryRecommended.tsx b/web/src/pages/LibraryRecommended.tsx index 1dc4c8c623..2f4fb450c5 100644 --- a/web/src/pages/LibraryRecommended.tsx +++ b/web/src/pages/LibraryRecommended.tsx @@ -1,6 +1,8 @@ import { buildLibraryCollectionCatalogHref } from "./catalogSearchParams"; import { useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { Link } from "react-router"; +import { LayoutDashboard } from "lucide-react"; import type { HomeSectionItemsResponse, ResolvedSection } from "@/api/types"; import { useLibraryHasItems } from "@/hooks/queries/catalog"; import { useLibraryCollectionItems } from "@/hooks/queries/libraryCollections"; @@ -26,6 +28,7 @@ import { isAudiobookLibraryType } from "./libraryPageSearchParams"; import { useUICustomization } from "@/hooks/useUICustomization"; import { carouselCardWidthClasses } from "@/lib/uiCustomization"; import { useSectionRefreshSignal } from "./homeSurfaceRefresh"; +import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; interface LibraryRecommendedProps { libraryId: number; @@ -257,6 +260,11 @@ export default function LibraryRecommended({ /> ) : null} + {layout.length === 0 && libraryHasItems ? ( +
+ +
+ ) : null} {viewModel.rows.map((slot) => { if (slot.state === "empty") { return null; @@ -335,6 +343,39 @@ function renderHeroSlot( return ; } +// The library has media but this page has no sections. Settings > Home Screen +// edits a library page once that library is picked as its scope. +function NoSectionsState() { + const actingAdmin = useIsActingAdmin(); + + return ( +
+
+ ); +} + function SectionLoadingRow({ title }: { title: string }) { return ( From f2322954ff1c93f460e51bcc495921a88d2f5960 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:38:43 -0400 Subject: [PATCH 002/303] fix(web): redirect /profile/customize-home to Settings > Home Screen /profile/customize-home was a second profile Home editor with no in-app link. It duplicated Settings > Home Screen with fewer features, and its reset and delete actions ran without confirmation. Settings > Home Screen is now the only profile Home editor. The old URL redirects there and replaces the history entry, so existing bookmarks still land somewhere useful. Co-Authored-By: Claude Opus 5.5 (1M context) --- web/eslint.config.js | 1 - web/src/App.routes.test.tsx | 68 ++++ web/src/App.tsx | 4 +- web/src/components/ProfileSectionRow.test.tsx | 44 --- web/src/components/ProfileSectionRow.tsx | 77 ---- .../RecipeGallery/RecipeGalleryModal.tsx | 5 +- web/src/pages/ProfileCustomizeHome.test.tsx | 183 ---------- web/src/pages/ProfileCustomizeHome.tsx | 337 ------------------ 8 files changed, 72 insertions(+), 647 deletions(-) create mode 100644 web/src/App.routes.test.tsx delete mode 100644 web/src/components/ProfileSectionRow.test.tsx delete mode 100644 web/src/components/ProfileSectionRow.tsx delete mode 100644 web/src/pages/ProfileCustomizeHome.test.tsx delete mode 100644 web/src/pages/ProfileCustomizeHome.tsx diff --git a/web/eslint.config.js b/web/eslint.config.js index f7330467b6..f1b11cd403 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -68,7 +68,6 @@ export default tseslint.config( "src/pages/Recommendations.tsx", "src/pages/RecommendationsSection.tsx", "src/components/RecommendationGrid.tsx", - "src/pages/ProfileCustomizeHome.tsx", "src/pages/setup-wizard/steps/ProfileStep.tsx", "src/player/utils/subtitleChoicePersistence.ts", "src/hooks/queries/catalogRead.ts", diff --git a/web/src/App.routes.test.tsx b/web/src/App.routes.test.tsx new file mode 100644 index 0000000000..cb78bdad84 --- /dev/null +++ b/web/src/App.routes.test.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { Outlet, type createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let initialEntry = "/"; +let appRouter: ReturnType | null = null; + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + // App builds a data router from the real history; start it at the entry + // under test instead. + createBrowserRouter: ((routes: Parameters[0]) => { + appRouter = actual.createMemoryRouter(routes, { initialEntries: [initialEntry] }); + return appRouter; + }) as typeof actual.createBrowserRouter, + }; +}); + +// A signed-in account with a selected profile, so the route gates let the +// navigation through; the session itself is not under test. +vi.mock("@/hooks/useAuth", async () => { + const actual = await vi.importActual("@/hooks/useAuth"); + const auth = { + user: { id: 1, username: "alex", role: "user" }, + profile: { id: "profile-1", name: "Alex" }, + loading: false, + setupLoading: false, + setupRequired: false, + pendingPasswordChange: false, + isImpersonating: false, + }; + return { + ...actual, + AuthProvider: ({ children }: { children: ReactNode }) => <>{children}, + useAuth: () => auth, + useOptionalAuth: () => auth, + }; +}); + +vi.mock("@/components/Layout", () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("@/pages/SettingsLayout", () => ({ default: () => })); +vi.mock("@/pages/settings/HomeScreenSettings", () => ({ + default: () =>
Home screen settings
, +})); + +import App from "@/App"; + +describe("App routes", () => { + beforeEach(() => { + appRouter = null; + }); + + it("redirects the retired profile home editor to Settings > Home Screen", async () => { + initialEntry = "/profile/customize-home"; + + render(); + + expect(await screen.findByText("Home screen settings")).toBeInTheDocument(); + expect(appRouter?.state.location.pathname).toBe("/settings/home-screen"); + // Replaced, not pushed: Back must not return to the old URL and bounce again. + expect(appRouter?.state.historyAction).toBe("REPLACE"); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 6bcd14000c..cb1c623252 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -158,7 +158,6 @@ const WatchPartyHub = lazy(() => import("@/pages/watchtogether/WatchPartyHub")); const WatchPartyInvite = lazy(() => import("@/pages/watchtogether/WatchPartyInvite")); const WatchTogetherRoomPage = lazy(() => import("@/pages/watchtogether/WatchTogetherRoomPage")); const WatchRoute = lazy(() => import("@/pages/WatchRoute")); -const ProfileCustomizeHome = lazy(() => import("@/pages/ProfileCustomizeHome")); /** * Routes a browsing session reaches within the first few interactions. Home @@ -709,9 +708,10 @@ function AppRoutes() { /> } /> } /> + {/* Retired second profile Home editor; keep old links working. */} } + element={} /> } /> diff --git a/web/src/components/ProfileSectionRow.test.tsx b/web/src/components/ProfileSectionRow.test.tsx deleted file mode 100644 index 5bf9346bf9..0000000000 --- a/web/src/components/ProfileSectionRow.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import ProfileSectionRow from "./ProfileSectionRow"; - -describe("ProfileSectionRow", () => { - it("shows server-default badge for admin sections and a Hide button", async () => { - const onHide = vi.fn(); - render( -