From 570b6f4f2713dbd39325941b588a711d5aa815a9 Mon Sep 17 00:00:00 2001 From: apantaz Date: Sun, 23 Aug 2026 21:49:15 +0300 Subject: [PATCH] feat(frontend): add shared global filters --- docs/roadmap.md | 5 +- frontend/README.md | 2 + frontend/src/App.tsx | 29 ++- frontend/src/api/catalog.ts | 33 +++ .../src/components/DiscoverySetup.test.tsx | 10 +- frontend/src/components/DiscoverySetup.tsx | 16 +- .../src/components/GlobalFilters.test.tsx | 60 ++++++ frontend/src/components/GlobalFilters.tsx | 197 ++++++++++++++++++ frontend/src/discovery.test.ts | 21 ++ frontend/src/discovery.ts | 44 ++++ frontend/src/preferences.test.ts | 23 +- frontend/src/preferences.ts | 35 +++- frontend/src/styles.css | 25 ++- 13 files changed, 480 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/GlobalFilters.test.tsx create mode 100644 frontend/src/components/GlobalFilters.tsx create mode 100644 frontend/src/discovery.test.ts create mode 100644 frontend/src/discovery.ts diff --git a/docs/roadmap.md b/docs/roadmap.md index 71bc4da..e2a3cf1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -162,7 +162,10 @@ shell, typed local API client, catalog readiness state, explicit development CORS policy, frontend tests, and CI build validation. The second increment adds API-driven region and region-aware provider selection, validates restored guest choices against the current catalog, and persists valid preferences locally in -the browser. +the browser. The third increment adds the shared type, genre, runtime, +release-year, rating, and language filter contract that every discovery rail +will consume, including scoped reference data, safe query serialization, reset, +validation, and local persistence. Scope: diff --git a/frontend/README.md b/frontend/README.md index b4d55c3..86693c2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -39,6 +39,8 @@ Open . The initial application shell displays the API and catalog status to prove the browser-to-backend path is working. Regions and streaming services are loaded from the local API, and valid guest selections are remembered in browser-local storage without requiring an account. +Type, genre, runtime, release-year, rating, and language filters use one typed +state and the same query-parameter contract as every FastAPI discovery section. Vite reads `VITE_API_BASE_URL`; copy `.env.example` to `.env.local` only when the backend is not available at the default `http://127.0.0.1:8000` address. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b9b6434..94f0ce9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,9 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type CatalogStatus, getCatalogStatus } from "./api/catalog"; import { DiscoverySetup } from "./components/DiscoverySetup"; +import { GlobalFilters } from "./components/GlobalFilters"; +import { type CatalogScope, type GlobalFilters as FilterValue } from "./discovery"; +import { loadPreferences } from "./preferences"; import "./styles.css"; type LoadState = @@ -15,6 +18,20 @@ const formatRefresh = (value: string) => export default function App() { const [state, setState] = useState({ kind: "loading" }); + const [scope, setScope] = useState(null); + const [filters, setFilters] = useState(() => { + const saved = loadPreferences(); + return { + contentType: saved.contentType, + genreIds: saved.genreIds, + runtimeMax: saved.runtimeMax, + releaseYearFrom: saved.releaseYearFrom, + releaseYearTo: saved.releaseYearTo, + ratingMin: saved.ratingMin, + language: saved.language, + }; + }); + const handleScopeChange = useCallback((nextScope: CatalogScope | null) => setScope(nextScope), []); useEffect(() => { const controller = new AbortController(); @@ -59,8 +76,14 @@ export default function App() { )} - {state.kind === "ready" && } -

Global filters and discovery rails arrive in the next increment.

+ {state.kind === "ready" && } + {scope && scope.providers.length > 0 && ( + + )} + {scope && scope.providers.length === 0 && ( +

Select at least one streaming service to refine your catalog.

+ )} +

Discovery rails arrive in the next increment.

); } diff --git a/frontend/src/api/catalog.ts b/frontend/src/api/catalog.ts index 38f5d37..3b9cb20 100644 --- a/frontend/src/api/catalog.ts +++ b/frontend/src/api/catalog.ts @@ -1,3 +1,5 @@ +import { catalogScopeParams, type CatalogScope, type ContentType } from "../discovery"; + const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8000").replace( /\/$/, "", @@ -15,6 +17,14 @@ export type CatalogFreshness = { export type CatalogStatus = { api: "online"; freshness: CatalogFreshness }; export type RegionOption = { code: string }; export type ProviderOption = { key: string; name: string }; +export type GenreOption = { content_type: string; id: number; name: string }; +export type FilterOptions = { + content_types: string[]; + languages: string[]; + runtime_minutes: { minimum: number | null; maximum: number | null }; + release_year: { minimum: number | null; maximum: number | null }; + rating: { minimum: number | null; maximum: number | null }; +}; async function getJson(path: string, signal?: AbortSignal): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -52,3 +62,26 @@ export async function getProviders( ); return response.providers; } + +export async function getGenres( + scope: CatalogScope, + contentType: ContentType | null, + signal?: AbortSignal, +): Promise { + const response = await getJson<{ genres: GenreOption[] }>( + `/api/v1/catalog/genres?${catalogScopeParams(scope, contentType)}`, + signal, + ); + return [...new Map(response.genres.map((genre) => [genre.id, genre])).values()]; +} + +export async function getFilterOptions( + scope: CatalogScope, + contentType: ContentType | null, + signal?: AbortSignal, +): Promise { + return getJson( + `/api/v1/catalog/filter-options?${catalogScopeParams(scope, contentType)}`, + signal, + ); +} diff --git a/frontend/src/components/DiscoverySetup.test.tsx b/frontend/src/components/DiscoverySetup.test.tsx index cb338b6..1b88e95 100644 --- a/frontend/src/components/DiscoverySetup.test.tsx +++ b/frontend/src/components/DiscoverySetup.test.tsx @@ -18,7 +18,7 @@ test("loads region-aware providers and persists the guest selection", async () = { key: "disney_plus", name: "Disney+" }, ] })); - render(); + render(); const netflix = await screen.findByRole("button", { name: /Netflix/ }); fireEvent.click(netflix); @@ -39,13 +39,13 @@ test("changing region reloads providers and removes an invalid selection", async .mockImplementationOnce(() => jsonResponse({ region: "GR", providers: [{ key: "netflix", name: "Netflix" }] })) .mockImplementationOnce(() => jsonResponse({ region: "US", providers: [{ key: "hulu", name: "Hulu" }] })); - render(); + render(); expect(await screen.findByRole("button", { name: /Netflix/ })).toHaveAttribute("aria-pressed", "true"); fireEvent.change(screen.getByLabelText("Region"), { target: { value: "US" } }); expect(await screen.findByRole("button", { name: /Hulu/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Netflix/ })).not.toBeInTheDocument(); - await waitFor(() => expect(localStorage.getItem("watchpulse.discovery-preferences.v1")).toBe( - JSON.stringify({ region: "US", providers: [] }), - )); + await waitFor(() => expect(JSON.parse( + localStorage.getItem("watchpulse.discovery-preferences.v1") ?? "{}", + )).toMatchObject({ region: "US", providers: [] })); }); diff --git a/frontend/src/components/DiscoverySetup.tsx b/frontend/src/components/DiscoverySetup.tsx index 3e3c648..b357517 100644 --- a/frontend/src/components/DiscoverySetup.tsx +++ b/frontend/src/components/DiscoverySetup.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { getProviders, getRegions, type ProviderOption } from "../api/catalog"; import { loadPreferences, savePreferences } from "../preferences"; +import type { CatalogScope } from "../discovery"; type SetupState = | { kind: "loading-regions" } @@ -10,7 +11,11 @@ type SetupState = const regionNames = new Intl.DisplayNames([navigator.language], { type: "region" }); -export function DiscoverySetup() { +type DiscoverySetupProps = { + onScopeChange: (scope: CatalogScope | null) => void; +}; + +export function DiscoverySetup({ onScopeChange }: DiscoverySetupProps) { const saved = loadPreferences(); const [regions, setRegions] = useState([]); const [region, setRegion] = useState(saved.region ?? ""); @@ -55,8 +60,13 @@ export function DiscoverySetup() { }, [region]); useEffect(() => { - if (state.kind === "ready") savePreferences({ region, providers: selectedProviders }); - }, [region, selectedProviders, state.kind]); + if (state.kind === "ready") { + savePreferences({ region, providers: selectedProviders }); + onScopeChange({ region, providers: selectedProviders }); + } else { + onScopeChange(null); + } + }, [region, selectedProviders, state.kind, onScopeChange]); const toggleProvider = (key: string) => { setSelectedProviders((current) => diff --git a/frontend/src/components/GlobalFilters.test.tsx b/frontend/src/components/GlobalFilters.test.tsx new file mode 100644 index 0000000..abb329f --- /dev/null +++ b/frontend/src/components/GlobalFilters.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, expect, test, vi } from "vitest"; +import { EMPTY_FILTERS, type GlobalFilters as FilterValue } from "../discovery"; +import { GlobalFilters } from "./GlobalFilters"; + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); +}); + +const response = (value: unknown) => + Promise.resolve(new Response(JSON.stringify(value), { status: 200 })); +const SCOPE = { region: "GR", providers: ["netflix"] }; + +function Harness() { + const [filters, setFilters] = useState(EMPTY_FILTERS); + return ( + + ); +} + +test("loads scoped options, applies filters, persists them, and clears them", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch") + .mockImplementation((input) => String(input).includes("/genres?") ? response({ genres: [ + { content_type: "movie", id: 35, name: "Comedy" }, + { content_type: "movie", id: 18, name: "Drama" }, + ] }) : response({ + content_types: ["movie", "tv"], + languages: ["el", "en"], + runtime_minutes: { minimum: 22, maximum: 180 }, + release_year: { minimum: 1990, maximum: 2026 }, + rating: { minimum: 4.5, maximum: 9.1 }, + })); + + render(); + + const comedy = await screen.findByRole("button", { name: "Comedy" }); + fireEvent.click(comedy); + fireEvent.change(screen.getByLabelText("Runtime"), { target: { value: "120" } }); + fireEvent.change(screen.getByLabelText("Minimum rating"), { target: { value: "7" } }); + fireEvent.change(screen.getByLabelText("Language"), { target: { value: "el" } }); + fireEvent.change(screen.getByLabelText("Release year from"), { target: { value: "2020" } }); + + expect(comedy).toHaveAttribute("aria-pressed", "true"); + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual(expect.arrayContaining([ + expect.stringContaining("region=GR&providers=netflix"), + ])); + await waitFor(() => expect(JSON.parse( + localStorage.getItem("watchpulse.discovery-preferences.v1") ?? "{}", + )).toMatchObject({ genreIds: [35], runtimeMax: 120, ratingMin: 7, language: "el", releaseYearFrom: 2020 })); + + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + expect(screen.getByLabelText("Runtime")).toHaveValue(""); + expect(comedy).toHaveAttribute("aria-pressed", "false"); +}); diff --git a/frontend/src/components/GlobalFilters.tsx b/frontend/src/components/GlobalFilters.tsx new file mode 100644 index 0000000..db192a1 --- /dev/null +++ b/frontend/src/components/GlobalFilters.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef, useState } from "react"; +import { getFilterOptions, getGenres, type FilterOptions, type GenreOption } from "../api/catalog"; +import { EMPTY_FILTERS, type CatalogScope, type ContentType, type GlobalFilters as FilterValue } from "../discovery"; +import { savePreferences } from "../preferences"; + +type GlobalFiltersProps = { + scope: CatalogScope; + value: FilterValue; + onChange: (filters: FilterValue) => void; +}; + +type ReferenceState = + | { kind: "loading" } + | { kind: "ready"; genres: GenreOption[]; options: FilterOptions } + | { kind: "error"; message: string }; + +const update = ( + value: FilterValue, + key: K, + next: FilterValue[K], +) => ({ ...value, [key]: next }); + +const numberOrNull = (value: string) => value === "" ? null : Number(value); + +export function GlobalFilters({ scope, value, onChange }: GlobalFiltersProps) { + const [references, setReferences] = useState({ kind: "loading" }); + const valueRef = useRef(value); + const onChangeRef = useRef(onChange); + valueRef.current = value; + onChangeRef.current = onChange; + const region = scope.region; + const providers = scope.providers; + const contentType = value.contentType; + + useEffect(() => { + const controller = new AbortController(); + setReferences({ kind: "loading" }); + Promise.all([ + getGenres({ region, providers }, contentType, controller.signal), + getFilterOptions({ region, providers }, contentType, controller.signal), + ]) + .then(([genres, options]) => { + const validGenres = new Set(genres.map(({ id }) => id)); + const current = valueRef.current; + const nextGenreIds = current.genreIds.filter((id) => validGenres.has(id)); + if (nextGenreIds.length !== current.genreIds.length) { + onChangeRef.current({ ...current, genreIds: nextGenreIds }); + } + setReferences({ kind: "ready", genres, options }); + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setReferences({ kind: "error", message: error instanceof Error ? error.message : "Unable to load filters" }); + } + }); + return () => controller.abort(); + }, [region, providers, contentType]); + + useEffect(() => savePreferences(value), [value]); + + const setContentType = (contentType: ContentType | null) => + onChange({ ...value, contentType, genreIds: [] }); + + const toggleGenre = (genreId: number) => + onChange({ + ...value, + genreIds: value.genreIds.includes(genreId) + ? value.genreIds.filter((id) => id !== genreId) + : [...value.genreIds, genreId], + }); + + const setReleaseYearFrom = (next: number | null) => onChange({ + ...value, + releaseYearFrom: next, + releaseYearTo: next !== null && value.releaseYearTo !== null && next > value.releaseYearTo + ? null + : value.releaseYearTo, + }); + + const setReleaseYearTo = (next: number | null) => onChange({ + ...value, + releaseYearFrom: next !== null && value.releaseYearFrom !== null && next < value.releaseYearFrom + ? null + : value.releaseYearFrom, + releaseYearTo: next, + }); + + return ( +
+
+
+

Refine your choice

+

What are you looking for?

+
+ +
+ +
+
+ Type +
+ {([null, "movie", "tv"] as const).map((type) => ( + + ))} +
+
+ + + + + + + +
+ Release year +
+ setReleaseYearFrom(numberOrNull(event.target.value))} + /> + to + setReleaseYearTo(numberOrNull(event.target.value))} + /> +
+
+
+ +
+ Genres +
+ {references.kind === "ready" && references.genres.map((genre) => ( + + ))} +
+
+ + {references.kind === "loading" &&

Loading filter options…

} + {references.kind === "error" &&

{references.message}

} +
+ ); +} diff --git a/frontend/src/discovery.test.ts b/frontend/src/discovery.test.ts new file mode 100644 index 0000000..46aab7e --- /dev/null +++ b/frontend/src/discovery.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { discoveryParams, type GlobalFilters } from "./discovery"; + +test("serializes the shared filters using the FastAPI repeated-parameter contract", () => { + const filters: GlobalFilters = { + contentType: "movie", + genreIds: [18, 35], + runtimeMax: 120, + releaseYearFrom: 2020, + releaseYearTo: 2026, + ratingMin: 7, + language: "el", + }; + + expect(discoveryParams( + { region: "GR", providers: ["netflix", "disney_plus"] }, + filters, + ).toString()).toBe( + "region=GR&providers=netflix&providers=disney_plus&content_type=movie&genre_ids=18&genre_ids=35&runtime_max=120&release_year_from=2020&release_year_to=2026&rating_min=7&language=el", + ); +}); diff --git a/frontend/src/discovery.ts b/frontend/src/discovery.ts new file mode 100644 index 0000000..65fdec2 --- /dev/null +++ b/frontend/src/discovery.ts @@ -0,0 +1,44 @@ +export type ContentType = "movie" | "tv"; + +export type CatalogScope = { + region: string; + providers: string[]; +}; + +export type GlobalFilters = { + contentType: ContentType | null; + genreIds: number[]; + runtimeMax: number | null; + releaseYearFrom: number | null; + releaseYearTo: number | null; + ratingMin: number | null; + language: string | null; +}; + +export const EMPTY_FILTERS: GlobalFilters = { + contentType: null, + genreIds: [], + runtimeMax: null, + releaseYearFrom: null, + releaseYearTo: null, + ratingMin: null, + language: null, +}; + +export function catalogScopeParams(scope: CatalogScope, contentType?: ContentType | null) { + const params = new URLSearchParams({ region: scope.region }); + scope.providers.forEach((provider) => params.append("providers", provider)); + if (contentType) params.set("content_type", contentType); + return params; +} + +export function discoveryParams(scope: CatalogScope, filters: GlobalFilters) { + const params = catalogScopeParams(scope, filters.contentType); + filters.genreIds.forEach((genreId) => params.append("genre_ids", String(genreId))); + if (filters.runtimeMax !== null) params.set("runtime_max", String(filters.runtimeMax)); + if (filters.releaseYearFrom !== null) params.set("release_year_from", String(filters.releaseYearFrom)); + if (filters.releaseYearTo !== null) params.set("release_year_to", String(filters.releaseYearTo)); + if (filters.ratingMin !== null) params.set("rating_min", String(filters.ratingMin)); + if (filters.language) params.set("language", filters.language); + return params; +} diff --git a/frontend/src/preferences.test.ts b/frontend/src/preferences.test.ts index 3a1a076..10d9d26 100644 --- a/frontend/src/preferences.test.ts +++ b/frontend/src/preferences.test.ts @@ -1,15 +1,34 @@ import { beforeEach, expect, test } from "vitest"; import { loadPreferences, savePreferences } from "./preferences"; +import { EMPTY_FILTERS } from "./discovery"; beforeEach(() => localStorage.clear()); test("guest preferences round trip through browser storage", () => { savePreferences({ region: "GR", providers: ["netflix"] }); - expect(loadPreferences()).toEqual({ region: "GR", providers: ["netflix"] }); + expect(loadPreferences()).toEqual({ region: "GR", providers: ["netflix"], ...EMPTY_FILTERS }); }); test("invalid stored preferences safely fall back to empty values", () => { localStorage.setItem("watchpulse.discovery-preferences.v1", "not-json"); - expect(loadPreferences()).toEqual({ region: null, providers: [] }); + expect(loadPreferences()).toEqual({ region: null, providers: [], ...EMPTY_FILTERS }); +}); + +test("stored numeric filters are bounded and reversed years are repaired", () => { + localStorage.setItem("watchpulse.discovery-preferences.v1", JSON.stringify({ + runtimeMax: 0, + ratingMin: 11, + releaseYearFrom: 2026, + releaseYearTo: 2020, + language: "Greek", + })); + + expect(loadPreferences()).toMatchObject({ + runtimeMax: null, + ratingMin: null, + releaseYearFrom: null, + releaseYearTo: 2020, + language: null, + }); }); diff --git a/frontend/src/preferences.ts b/frontend/src/preferences.ts index 42e5130..d4bde21 100644 --- a/frontend/src/preferences.ts +++ b/frontend/src/preferences.ts @@ -1,27 +1,54 @@ -export type GuestPreferences = { +import { EMPTY_FILTERS, type GlobalFilters } from "./discovery"; + +export type GuestPreferences = GlobalFilters & { region: string | null; providers: string[]; }; const STORAGE_KEY = "watchpulse.discovery-preferences.v1"; -const EMPTY_PREFERENCES: GuestPreferences = { region: null, providers: [] }; +const EMPTY_PREFERENCES: GuestPreferences = { + region: null, + providers: [], + ...EMPTY_FILTERS, +}; + +const boundedNumber = (value: unknown, minimum: number, maximum: number) => + typeof value === "number" && value >= minimum && value <= maximum ? value : null; export function loadPreferences(storage: Storage = localStorage): GuestPreferences { try { const value: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? "null"); if (!value || typeof value !== "object") return EMPTY_PREFERENCES; const candidate = value as Record; + const releaseYearFrom = boundedNumber(candidate.releaseYearFrom, 1870, 2200); + const releaseYearTo = boundedNumber(candidate.releaseYearTo, 1870, 2200); return { region: typeof candidate.region === "string" ? candidate.region : null, providers: Array.isArray(candidate.providers) ? candidate.providers.filter((provider): provider is string => typeof provider === "string") : [], + contentType: candidate.contentType === "movie" || candidate.contentType === "tv" + ? candidate.contentType + : null, + genreIds: Array.isArray(candidate.genreIds) + ? candidate.genreIds.filter((genreId): genreId is number => Number.isInteger(genreId) && genreId > 0) + : [], + runtimeMax: boundedNumber(candidate.runtimeMax, 1, 1440), + releaseYearFrom: releaseYearFrom !== null && releaseYearTo !== null && releaseYearFrom > releaseYearTo ? null : releaseYearFrom, + releaseYearTo, + ratingMin: boundedNumber(candidate.ratingMin, 0, 10), + language: typeof candidate.language === "string" && /^[a-z]{2,3}$/.test(candidate.language) + ? candidate.language + : null, }; } catch { return EMPTY_PREFERENCES; } } -export function savePreferences(preferences: GuestPreferences, storage: Storage = localStorage) { - storage.setItem(STORAGE_KEY, JSON.stringify(preferences)); +export function savePreferences( + preferences: Partial, + storage: Storage = localStorage, +) { + storage.setItem(STORAGE_KEY, JSON.stringify({ ...loadPreferences(storage), ...preferences })); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index aff96f2..7c008fb 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -39,8 +39,29 @@ h2 { margin: 0; font: 700 22px Manrope, sans-serif; } .provider-fieldset:disabled { opacity: .55; } .setup-loading, .setup-error { margin: 14px 0 0; color: #898b95; font-size: 13px; } .setup-error { color: #f08492; } +.global-filters { margin-top: 24px; padding: 30px; border: 1px solid #292b34; border-radius: 18px; background: rgb(15 17 23 / 82%); } +.clear-filters { padding: 8px 12px; border: 0; color: #a9aab1; background: transparent; font: 600 13px "DM Sans", sans-serif; cursor: pointer; } +.clear-filters:hover { color: #fff; } +.filter-grid { display: grid; grid-template-columns: 1.25fr repeat(3, 1fr); gap: 16px; } +.filter-group { min-width: 0; margin: 0; padding: 0; border: 0; color: #999ba4; font-size: 13px; font-weight: 600; } +.filter-group > span, .filter-group > legend { display: block; margin-bottom: 9px; } +.filter-group select, .filter-group input { width: 100%; min-height: 43px; padding: 10px 12px; border: 1px solid #343640; border-radius: 10px; color: #eeeef0; background: #171921; font: 500 14px "DM Sans", sans-serif; } +.segmented-control { display: grid; grid-template-columns: repeat(3, 1fr); padding: 3px; border: 1px solid #343640; border-radius: 10px; background: #12141a; } +.segmented-control button { min-height: 35px; border: 0; border-radius: 7px; color: #8e9099; background: transparent; font: 600 13px "DM Sans", sans-serif; cursor: pointer; } +.segmented-control button[aria-pressed="true"] { color: #fff; background: #352249; box-shadow: 0 0 0 1px #704c9e; } +.year-filter { grid-column: span 2; } +.year-filter > div { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; } +.year-filter > div > span { color: #6e7079; } +.genre-filter { min-width: 0; margin: 24px 0 0; padding: 0; border: 0; } +.genre-filter legend { margin-bottom: 11px; color: #999ba4; font-size: 13px; font-weight: 600; } +.genre-list { display: flex; flex-wrap: wrap; gap: 8px; } +.genre-list button { padding: 8px 12px; border: 1px solid #333640; border-radius: 999px; color: #999ba4; background: #171920; font: 600 12px "DM Sans", sans-serif; cursor: pointer; } +.genre-list button:hover { border-color: #604a7d; color: #ddd; } +.genre-list button[aria-pressed="true"] { border-color: #8d5acb; color: #e7d6ff; background: #2a1c3b; } +.genre-filter:disabled { opacity: .5; } +.selection-prompt { margin: 18px 4px 0; padding: 14px 18px; border-left: 2px solid #7f50bc; color: #91939c; background: rgb(32 24 43 / 45%); font-size: 14px; } .coming-next { margin: 18px 4px 60px; color: #60626c; font-size: 13px; } @keyframes breathe { to { opacity: .3; } } -@media (max-width: 800px) { .provider-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 640px) { main { width: min(100% - 28px, 1120px); } .nav { padding: 24px 0; } .hero { padding: 9vh 0 48px; } .status-card { align-items: flex-start; flex-direction: column; padding: 22px; } .discovery-setup { padding: 22px; } .region-field { grid-template-columns: 1fr; gap: 8px; } .provider-grid { grid-template-columns: 1fr; } } +@media (max-width: 900px) { .filter-grid { grid-template-columns: repeat(2, 1fr); } .provider-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 640px) { main { width: min(100% - 28px, 1120px); } .nav { padding: 24px 0; } .hero { padding: 9vh 0 48px; } .status-card { align-items: flex-start; flex-direction: column; padding: 22px; } .discovery-setup, .global-filters { padding: 22px; } .region-field, .filter-grid { grid-template-columns: 1fr; gap: 8px; } .provider-grid { grid-template-columns: 1fr; } .year-filter { grid-column: auto; } } @media (prefers-reduced-motion: reduce) { .status-dot.loading { animation: none; } }