From 3be784d67517c51b69b4683a6887fda7a7c46d67 Mon Sep 17 00:00:00 2001 From: lashman Date: Tue, 31 Mar 2026 09:53:25 +0300 Subject: [PATCH] settings page --- src/pages/settings/_ui.tsx | 303 ++++++++++++++++++ src/pages/settings/sections/About.tsx | 37 +++ src/pages/settings/sections/Arr.tsx | 265 +++++++++++++++ src/pages/settings/sections/Audio.tsx | 133 ++++++++ src/pages/settings/sections/Detail.tsx | 48 +++ src/pages/settings/sections/Discovery.tsx | 46 +++ src/pages/settings/sections/Display.tsx | 116 +++++++ src/pages/settings/sections/Episodes.tsx | 20 ++ src/pages/settings/sections/Fanart.tsx | 45 +++ src/pages/settings/sections/Home.tsx | 53 +++ src/pages/settings/sections/Personal.tsx | 168 ++++++++++ src/pages/settings/sections/Playback.tsx | 110 +++++++ src/pages/settings/sections/Privacy.tsx | 40 +++ src/pages/settings/sections/Server.tsx | 50 +++ .../settings/sections/ServerDashboard.tsx | 145 +++++++++ src/pages/settings/sections/Servers.tsx | 258 +++++++++++++++ src/pages/settings/sections/Shortcuts.tsx | 187 +++++++++++ src/pages/settings/sections/Tmdb.tsx | 49 +++ src/pages/settings/sections/Trakt.tsx | 215 +++++++++++++ 19 files changed, 2288 insertions(+) create mode 100644 src/pages/settings/_ui.tsx create mode 100644 src/pages/settings/sections/About.tsx create mode 100644 src/pages/settings/sections/Arr.tsx create mode 100644 src/pages/settings/sections/Audio.tsx create mode 100644 src/pages/settings/sections/Detail.tsx create mode 100644 src/pages/settings/sections/Discovery.tsx create mode 100644 src/pages/settings/sections/Display.tsx create mode 100644 src/pages/settings/sections/Episodes.tsx create mode 100644 src/pages/settings/sections/Fanart.tsx create mode 100644 src/pages/settings/sections/Home.tsx create mode 100644 src/pages/settings/sections/Personal.tsx create mode 100644 src/pages/settings/sections/Playback.tsx create mode 100644 src/pages/settings/sections/Privacy.tsx create mode 100644 src/pages/settings/sections/Server.tsx create mode 100644 src/pages/settings/sections/ServerDashboard.tsx create mode 100644 src/pages/settings/sections/Servers.tsx create mode 100644 src/pages/settings/sections/Shortcuts.tsx create mode 100644 src/pages/settings/sections/Tmdb.tsx create mode 100644 src/pages/settings/sections/Trakt.tsx diff --git a/src/pages/settings/_ui.tsx b/src/pages/settings/_ui.tsx new file mode 100644 index 0000000..42fe6fb --- /dev/null +++ b/src/pages/settings/_ui.tsx @@ -0,0 +1,303 @@ +import { createContext, useContext, useEffect, useLayoutEffect, useState, type ComponentType, type ReactNode } from 'react' +import { motion } from 'framer-motion' +import { AlertCircle, Check } from '../../lib/icons' +import type { AppSettings } from '../../api/types' +import { usePreferencesStore } from '../../stores/preferences-store' + +/* Shared types ───────────────────────────────────────────── */ + +export type PrefsLike = ReturnType + +export type BoolPrefKey = { + [K in keyof AppSettings]: AppSettings[K] extends boolean ? K : never +}[keyof AppSettings] + +/* Search context ─────────────────────────────────────────── */ + +export interface SettingsSearchValue { + query: string + matches: (haystack: string) => boolean +} + +export const SettingsSearchContext = createContext({ + query: '', + matches: () => true, +}) + +export function useSettingsSearch() { + return useContext(SettingsSearchContext) +} + +/* Section + SubHeading ───────────────────────────────────── */ + +export function Section({ + id, + title, + description, + children, +}: { + id: string + title: string + description?: string + children: ReactNode +}) { + const { query, matches } = useSettingsSearch() + const titleMatches = matches(`${title} ${description || ''}`) + const [hasVisibleRow, setHasVisibleRow] = useState(true) + // useLayoutEffect runs after DOM mutations but before paint, so we read + // the up-to-date count of visible rows in the same frame the query + // changed - no flash where the section appears or stays hidden + // incorrectly while a deferred rAF catches up. + useLayoutEffect(() => { + if (!query) { + setHasVisibleRow(true) + return + } + const root = document.querySelector(`[data-settings-section="${title}"]`) + if (!root) return + const visible = root.querySelectorAll('[data-settings-row]').length + setHasVisibleRow(visible > 0) + }, [query, title]) + const hidden = !!query && !titleMatches && !hasVisibleRow + return ( +
+
+
+ +

{title}

+
+ {description &&

{description}

} +
+
+
{children}
+
+
+ ) +} + +/** + * Small uppercase divider for chunking long toggle lists. Returns null + * during search so the divide-y separator cascade collapses cleanly + * between visible Rows. + */ +export function SubHeading({ label }: { label: string }) { + const { query } = useSettingsSearch() + if (query) return null + return ( +
+

+ {label} +

+
+ ) +} + +/* Row primitive ──────────────────────────────────────────── */ + +export function Row({ + label, + hint, + danger, + children, +}: { + label: string + hint?: string + danger?: boolean + children: ReactNode +}) { + const { query, matches } = useSettingsSearch() + const haystack = `${label} ${hint || ''}` + const visible = matches(haystack) + if (query && !visible) return null + return ( +
+
+

+ {label} +

+ {hint &&

{hint}

} +
+
{children}
+
+ ) +} + +/* Controls ───────────────────────────────────────────────── */ + +export function Input({ + value, + onChange, + placeholder, + type = 'text', + width = 'w-32', +}: { + value: string + onChange: (v: string) => void + placeholder?: string + type?: string + width?: string +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={`h-9 px-3 ${width} bg-void/50 hover:bg-void/70 rounded-md text-[12.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/15 transition-all duration-200 font-mono tabular-nums`} + /> + ) +} + +export function Toggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +/** + * Zoom range slider - the preview tracks the thumb but the actual page + * zoom is only applied when the user releases the slider, so users can + * preview without applying. + */ +export function ZoomSlider({ + value, + onCommit, +}: { + value: number + onCommit: (v: number) => void +}) { + const min = 0.75 + const max = 1.5 + const step = 0.05 + const safeValue = Number.isFinite(value) && value > 0 ? value : 1 + const [draft, setDraft] = useState(safeValue) + + useEffect(() => { + setDraft(safeValue) + }, [safeValue]) + + function commit() { + const next = Number(draft.toFixed(2)) + if (Math.abs(next - safeValue) > 0.001) onCommit(next) + } + + return ( +
+ setDraft(Number(e.target.value))} + onPointerUp={commit} + onKeyUp={commit} + onBlur={commit} + aria-label="Interface zoom" + className="slider w-44" + /> + + {Math.round(draft * 100)}% + +
+ ) +} + +export function Segmented({ + value, + options, + onChange, +}: { + value: T + options: { value: T; label: string }[] + onChange: (v: T) => void +}) { + return ( +
+ {options.map(opt => { + const isActive = value === opt.value + return ( + + ) + })} +
+ ) +} + +/** + * Two-tap confirm button for destructive actions. First click arms it + * for 3 seconds; second click commits. Resets if the user leaves it + * armed without confirming. + */ +export function DangerButton({ + icon: Icon, + label, + onConfirm, +}: { + icon: ComponentType<{ size?: number; stroke?: number; className?: string }> + label: string + onConfirm: () => void +}) { + const [armed, setArmed] = useState(false) + useEffect(() => { + if (!armed) return + const t = setTimeout(() => setArmed(false), 3000) + return () => clearTimeout(t) + }, [armed]) + + return ( + + ) +} diff --git a/src/pages/settings/sections/About.tsx b/src/pages/settings/sections/About.tsx new file mode 100644 index 0000000..e1cef5b --- /dev/null +++ b/src/pages/settings/sections/About.tsx @@ -0,0 +1,37 @@ +import { ExternalLink } from '../../../lib/icons' +import { Section, Row } from '../_ui' + +export function AboutSection() { + return ( +
+ + 0.1.0 + + + GPL-2.0 + + + + jellyfin/jellyfin + + + + + + jellyfin.org + + + +
+ ) +} diff --git a/src/pages/settings/sections/Arr.tsx b/src/pages/settings/sections/Arr.tsx new file mode 100644 index 0000000..1c21e89 --- /dev/null +++ b/src/pages/settings/sections/Arr.tsx @@ -0,0 +1,265 @@ +import { useState, type ReactNode } from 'react' +import { AlertCircle, Check, Plus, Trash2 } from '../../../lib/icons' +import { Section, Row, SubHeading } from '../_ui' +import Select, { type SelectOption } from '../../../components/ui/Select' +import { useArrInstances, newArrInstance, type ArrInstance } from '../../../stores/arr-instances-store' +import { useArrSystemStatus, useArrProfiles, useArrRootFolders, useSonarrLanguageProfiles } from '../../../hooks/use-arr' +import OverrideRulesEditor from '../../../components/settings/OverrideRulesEditor' + +export function ArrSection() { + const instances = useArrInstances(s => s.instances) + const upsert = useArrInstances(s => s.upsert) + const remove = useArrInstances(s => s.remove) + + function ensure(kind: 'sonarr' | 'radarr', tier: 'default' | '4k') { + const existing = instances.find(x => x.kind === kind && x.tier === tier) + if (existing) return existing + const fresh = newArrInstance(kind, tier) + upsert(fresh) + return fresh + } + + const radarr = instances.find(x => x.kind === 'radarr' && x.tier === 'default') + const radarr4k = instances.find(x => x.kind === 'radarr' && x.tier === '4k') + const sonarr = instances.find(x => x.kind === 'sonarr' && x.tier === 'default') + const sonarr4k = instances.find(x => x.kind === 'sonarr' && x.tier === '4k') + + return ( +
+ + ensure('radarr', 'default')} onRemove={() => radarr && remove(radarr.id)} /> + ensure('radarr', '4k')} onRemove={() => radarr4k && remove(radarr4k.id)} /> + + + ensure('sonarr', 'default')} onRemove={() => sonarr && remove(sonarr.id)} /> + ensure('sonarr', '4k')} onRemove={() => sonarr4k && remove(sonarr4k.id)} /> + + +
+ +
+
+ ) +} + +function ArrInstanceRow({ + instance, + kind, + tier, + onAdd, + onRemove, +}: { + instance: ArrInstance | null + kind: 'sonarr' | 'radarr' + tier: 'default' | '4k' + onAdd: () => void + onRemove: () => void +}) { + const [expanded, setExpanded] = useState(false) + const label = `${kind === 'radarr' ? 'Radarr' : 'Sonarr'}${tier === '4k' ? ' (4K)' : ''}` + const hint = !instance + ? `Not connected. Click + to set up the ${tier === '4k' ? '4K ' : ''}${kind === 'radarr' ? 'Radarr' : 'Sonarr'} instance.` + : instance.lastTestVersion + ? `Connected - v${instance.lastTestVersion}` + : instance.baseUrl + ? 'Configured but not yet tested' + : 'Configured' + + return ( + <> + + {!instance ? ( + + ) : ( +
+ + +
+ )} +
+ {instance && expanded && } + + ) +} + +function ArrInstanceEditor({ instance }: { instance: ArrInstance }) { + const upsert = useArrInstances(s => s.upsert) + const [draft, setDraft] = useState(instance) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState<'ok' | 'fail' | null>(null) + + const status = useArrSystemStatus(testResult === 'ok' ? draft : null) + const profiles = useArrProfiles(testResult === 'ok' ? draft : null) + const rootFolders = useArrRootFolders(testResult === 'ok' ? draft : null) + const langProfiles = useSonarrLanguageProfiles(testResult === 'ok' && draft.kind === 'sonarr' ? draft : null) + + function save(patch: Partial) { + const next = { ...draft, ...patch } + setDraft(next) + upsert(next) + } + + async function runTest() { + if (!draft.baseUrl || !draft.apiKey) { + setTestResult('fail') + return + } + setTesting(true) + try { + const res = await fetch( + `${draft.baseUrl.replace(/\/+$/, '')}/api/v3/system/status`, + { headers: { 'X-Api-Key': draft.apiKey } }, + ) + if (!res.ok) { + setTestResult('fail') + save({ lastTestError: `HTTP ${res.status}`, lastTestVersion: undefined, lastTestedAt: new Date().toISOString() }) + } else { + const data = await res.json() + setTestResult('ok') + save({ + lastTestVersion: data?.version, + lastTestError: undefined, + lastTestedAt: new Date().toISOString(), + }) + } + } catch (e) { + setTestResult('fail') + save({ lastTestError: String(e), lastTestVersion: undefined, lastTestedAt: new Date().toISOString() }) + } finally { + setTesting(false) + } + } + + return ( +
+ + save({ baseUrl: e.target.value.trim() })} + placeholder="http://localhost:7878" + className="w-full h-9 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight font-mono" + /> + + + save({ apiKey: e.target.value.trim() })} + placeholder="32-character key" + className="w-full h-9 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight font-mono" + /> + + + save({ name: e.target.value })} + className="w-full h-9 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight" + /> + + +
+ + {testResult === 'ok' && ( + + + Connected - v{status.data?.version || draft.lastTestVersion || '?'} + + )} + {testResult === 'fail' && ( + + + {draft.lastTestError || 'Connection failed'} + + )} +
+ + {testResult === 'ok' && (profiles.data || rootFolders.data) && ( +
+ + save({ defaultRootFolder: v || undefined })} + placeholder="Pick a folder" + options={(rootFolders.data || []).map>(r => ({ + value: r.path, + label: {r.path}, + }))} + /> + + {draft.kind === 'sonarr' && langProfiles.data && langProfiles.data.length > 0 && ( + + prefs.setPreference('subtitleLanguage', v)} placeholder="eng" width="w-24" /> + + + prefs.setPreference('audioLanguage', v)} placeholder="eng" width="w-24" /> + + + prefs.setPreference('volumeBoost', Number(v))} + options={[ + { value: '1', label: '100%' }, + { value: '1.5', label: '150%' }, + { value: '2', label: '200%' }, + { value: '3', label: '300%' }, + ]} + /> + + + prefs.setPreference('nightMode', v)} /> + + + prefs.setPreference('audioPassthrough', v)} /> + + + + + prefs.setPreference('subtitleFontSize', Number(e.target.value))} + className="subtitle-size-slider w-full max-w-[200px] h-7 appearance-none bg-transparent cursor-pointer" + /> + + + prefs.setPreference('subtitleBackground', v)} + options={[ + { value: 'none', label: 'None' }, + { value: 'subtle', label: 'Subtle' }, + { value: 'solid', label: 'Solid' }, + ]} + /> + + + prefs.setPreference('subtitleEdge', v)} + options={[ + { value: 'none', label: 'None' }, + { value: 'shadow', label: 'Shadow' }, + { value: 'outline', label: 'Outline' }, + ]} + /> + + + prefs.setPreference('subtitlePosition', v)} + options={[ + { value: 'bottom', label: 'Bottom' }, + { value: 'top', label: 'Top' }, + ]} + /> + + + prefs.setPreference('subtitleColor', v)} + options={[ + { value: 'white', label: 'White' }, + { value: 'yellow', label: 'Yellow' }, + { value: 'cyan', label: 'Cyan' }, + ]} + /> + + + prefs.setPreference('subtitleFontFamily', v)} + options={[ + { value: 'sans', label: 'Sans' }, + { value: 'serif', label: 'Display' }, + { value: 'mono', label: 'Mono' }, + ]} + /> + + +
+

Preview

+
+
+
+ The signal cuts through the static, just for a moment. +
+
+
+
+ + ) +} diff --git a/src/pages/settings/sections/Detail.tsx b/src/pages/settings/sections/Detail.tsx new file mode 100644 index 0000000..ea37348 --- /dev/null +++ b/src/pages/settings/sections/Detail.tsx @@ -0,0 +1,48 @@ +import { Section, Row, SubHeading, Toggle, type PrefsLike, type BoolPrefKey } from '../_ui' +import type { DetailShowSettings, EndVideoShowSettings } from '../../../api/types' + +export function DetailPageSection({ prefs }: { prefs: PrefsLike }) { + function topRow(key: K, label: string, hint: string) { + return ( + + prefs.setPreference(key, v)} /> + + ) + } + function detailRow(key: keyof DetailShowSettings, label: string, hint: string) { + return ( + + prefs.setDetailShow(key, v)} /> + + ) + } + function endVideoRow(key: keyof EndVideoShowSettings, label: string, hint: string) { + return ( + + prefs.setEndVideoShow(key, v)} /> + + ) + } + return ( +
+ + {topRow('hoverTrailers', 'Hover trailers', 'Auto-play TMDB trailers in poster cards after a brief hover')} + {topRow('prebufferOnHover', 'Pre-buffer on hover', "Warm Jellyfin's stream when you pause on a poster so Play feels instant")} + {topRow('quickLookEnabled', 'Quick-look modal', 'Right-click / long-press a card to peek at it without navigating')} + {detailRow('collectionMeter', 'Collection meter on cards', 'On-hover meter showing how much of a collection you have')} + + + {endVideoRow('moreLikeThis', '"More like this" row', 'TMDB recommendations on the end-of-video card')} + {endVideoRow('antiRec', '"Different vibe" row', 'Anti-recommendation row swapping to an opposite genre')} + + + {detailRow('awards', 'Awards', 'Wikidata-sourced prize chips below the cast')} + {detailRow('filmingLocations', 'Filming locations', 'Map of locations sourced from Wikidata')} + {detailRow('trivia', 'Production trivia', 'Wikipedia "Production" section extract + keywords')} + {detailRow('videos', 'Trailers + featurettes', 'Tabbed YouTube videos from TMDB')} + {detailRow('personal', 'Personal section', 'Your rating, sticky note, and rewatch counter')} + {detailRow('diary', 'Diary section', 'Per-watch entries with rating + note + emoji')} + {detailRow('episodeExtras', 'Per-episode extras', 'Guest cast + director / writer on episode pages')} +
+ ) +} diff --git a/src/pages/settings/sections/Discovery.tsx b/src/pages/settings/sections/Discovery.tsx new file mode 100644 index 0000000..055d0fd --- /dev/null +++ b/src/pages/settings/sections/Discovery.tsx @@ -0,0 +1,46 @@ +import { Globe } from '../../../lib/icons' +import { Section, Row, Toggle, Segmented, type PrefsLike } from '../_ui' +import Select, { type SelectOption } from '../../../components/ui/Select' +import { regionForUser, countryLabel } from '../../../lib/format' + +const REGIONS = [ + 'US', 'GB', 'CA', 'AU', 'NZ', 'IE', + 'DE', 'FR', 'ES', 'IT', 'NL', 'BE', 'CH', 'AT', 'PT', 'PL', 'SE', 'NO', 'DK', 'FI', + 'JP', 'KR', 'IN', 'SG', 'HK', 'TW', + 'BR', 'MX', 'AR', 'CL', 'CO', + 'ZA', +] + +export function DiscoverySection({ prefs }: { prefs: PrefsLike }) { + return ( +
+ + prefs.setPreference('accentColor', e.target.value)} + className="w-7 h-7 p-0 border-0 rounded-full overflow-hidden cursor-pointer focus-ring" + aria-label="Custom accent color" + /> + { + const v = e.target.value.trim() + if (!v || isHexColor(v)) prefs.setPreference('accentColor', v || '#F5B642') + }} + placeholder="#F5B642" + className="w-20 h-7 px-2 rounded-md bg-elevated/40 border border-border text-[11.5px] text-text-1 font-mono tracking-tight focus:outline-none focus:border-accent/60" + aria-label="Custom hex color" + /> +
+
+ + + prefs.setPreference('density', v)} + options={[ + { value: 'comfortable', label: 'Comfortable' }, + { value: 'compact', label: 'Compact' }, + ]} + /> + + + prefs.setPreference('uiZoom', v)} /> + + + prefs.setPreference('reduceMotion', v)} /> + + + + + prefs.setPreference('showTechBadges', v)} /> + + + prefs.setPreference('showTmdbRatings', v)} /> + + + prefs.setEpisodeShow('spoilerBlur', v)} /> + + + + + prefs.setPreference('heroAutoAdvance', v)} /> + + + prefs.setPreference('fanartApiKey', v)} + placeholder={usingBuiltIn ? 'Optional - leave blank to use built-in' : 'Paste your personal key'} + width="w-64" + /> + +
+ Don't have one yet? + + Get a Fanart.tv key + + +
+ + ) +} diff --git a/src/pages/settings/sections/Home.tsx b/src/pages/settings/sections/Home.tsx new file mode 100644 index 0000000..85128f5 --- /dev/null +++ b/src/pages/settings/sections/Home.tsx @@ -0,0 +1,53 @@ +import { Section, Row, SubHeading, Toggle, type PrefsLike } from '../_ui' +import type { HomeShowSettings } from '../../../api/types' + +export function HomePageSection({ prefs }: { prefs: PrefsLike }) { + function row(key: keyof HomeShowSettings, label: string, hint: string) { + return ( + + prefs.setHomeShow(key, v)} /> + + ) + } + return ( +
+ + {row('continueWatching', 'Continue watching', 'Pick up where you left off')} + {row('nextUp', 'Next up', 'The next episodes for shows you watch')} + {row('recentlyAdded', 'Recently added', 'Fresh from your library')} + {row('trendingWeek', 'Trending this week', 'TMDB weekly trending titles')} + {row('topRated', 'Top rated in your library', 'The cream of your crop')} + {row('watchedRecently', 'Watched recently', 'Revisit something you finished')} + {row('surpriseMe', 'Surprise me', 'A random shake-up from your library')} + + + {row('watchlist', 'Watchlist', 'Saved-for-later items pulled from your Jellyfin "Watchlist" playlist')} + {row('becauseYouWatched', 'Because you watched', 'Recommendations sourced from your most recent finish')} + {row('personSpotlights', 'Director / actor spotlights', 'Filmographies of people you watch most')} + {row('untouched', 'Added but not started', "Recent arrivals you haven't opened")} + {row('hiddenGems', 'Hidden gems', 'Highly rated unwatched library items')} + + + {row('trendingToday', 'Trending today', 'Daily TMDB trending row')} + {row('criticallyAcclaimed', 'Critically acclaimed - missing', 'Top-rated TMDB titles not in your library')} + {row('genreDeepDive', 'Genre deep-dive', 'Canon picks from your most-watched genre')} + {row('cultClassics', 'Cult classics', 'Highly rated, low-popularity TMDB picks')} + {row('yearEndBestOf', 'Year-end best-ofs', 'Top-rated movies released this year')} + {row('foreignCinema', 'Foreign cinema', 'Highly rated non-English films')} + {row('documentaryPicks', 'Documentary picks', 'Top-rated documentaries on TMDB')} + {row('awardWinnersMissing', 'Award winners - missing', 'Best Picture winners not in your library')} + {row('discoverCanon', 'Discover canon', 'Bundled AFI / Sight & Sound / IMDb canon lists')} + {row('letterboxdLists', 'Letterboxd lists', 'Lists you imported via the Letterboxd add affordance')} + {row('comingSoon', 'Coming soon', 'TMDB upcoming releases for your region')} + {row('studios', 'Studio rows', 'A row per major film studio - Marvel, A24, Disney, etc.')} + {row('networks', 'Network rows', 'A row per major TV network - Netflix, HBO, Apple TV+, etc.')} + + + {row('moodPicker', "What's your mood?", 'Mood picker that swaps a row of matching picks')} + {row('smartShelves', 'Smart shelves', 'Rule-based custom shelves you create with the wizard')} + {row('timeOfDay', 'Time-of-day picks', 'Morning / afternoon / evening / late-night recommendations')} + {row('decadeRows', 'Decade rows', '1990s / 2000s / 2010s shelves auto-generated from your library')} + {row('featuredGenres', 'Featured genre rows', 'The Action / Drama / Comedy etc. random shelves')} +
+ ) +} diff --git a/src/pages/settings/sections/Personal.tsx b/src/pages/settings/sections/Personal.tsx new file mode 100644 index 0000000..aaf4d58 --- /dev/null +++ b/src/pages/settings/sections/Personal.tsx @@ -0,0 +1,168 @@ +import { Trash2, Download, Search } from '../../../lib/icons' +import { useNavigate } from 'react-router-dom' +import { Section, Row, DangerButton } from '../_ui' +import { useSavedSearches } from '../../../stores/saved-searches-store' +import { useSmartShelves } from '../../../stores/smart-shelves-store' +import { useLetterboxdLists } from '../../../stores/letterboxd-lists-store' +import { useDiary } from '../../../stores/diary-store' +import { usePersonalData } from '../../../stores/personal-data-store' +import { exportDiary, type ExportFormat } from '../../../lib/diary-export' +import { toast } from '../../../stores/toast-store' + +export function PersonalSettingsSection() { + const navigate = useNavigate() + const personalEntries = usePersonalData(s => s.entries) + const personalCount = Object.keys(personalEntries).length + const diary = useDiary(s => s.entries) + const savedSearches = useSavedSearches(s => s.searches) + const smartShelves = useSmartShelves(s => s.shelves) + const letterboxdLists = useLetterboxdLists(s => s.lists) + + function clearStore(name: string, run: () => void) { + if (!confirm(`Permanently delete all ${name}? This can't be undone.`)) return + run() + } + + function clearLocalStoragePrefixes(prefixes: string[]) { + try { + const keys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k && prefixes.some(p => k.startsWith(p))) keys.push(k) + } + for (const k of keys) localStorage.removeItem(k) + } catch { /* noop */ } + } + + return ( +
+ + + clearStore('personal ratings, notes, and rewatch counters', () => { + const ids = Object.keys(usePersonalData.getState().entries) + for (const id of ids) usePersonalData.getState().clear(id) + }) + } + /> + + + + clearStore('diary entries', () => { + const all = useDiary.getState().entries.map(e => e.id) + for (const id of all) useDiary.getState().remove(id) + }) + } + /> + + + + + + + clearStore('saved library searches', () => { + const all = useSavedSearches.getState().searches.map(s => s.id) + for (const id of all) useSavedSearches.getState().remove(id) + }) + } + /> + + + + clearStore('smart shelves', () => { + const all = useSmartShelves.getState().shelves.map(s => s.id) + for (const id of all) useSmartShelves.getState().remove(id) + }) + } + /> + + + + clearStore('imported Letterboxd lists', () => { + const all = useLetterboxdLists.getState().lists.map(l => l.url) + for (const url of all) useLetterboxdLists.getState().remove(url) + }) + } + /> + + + + + + + clearStore('saved layout, ordering, and tracking preferences', () => { + clearLocalStoragePrefixes([ + 'episodeOrder:', + 'lib_layout:', + 'row_layout:', + 'jf_skip_count_', + 'jf_skip_dismissed_', + 'jf_time_saved_', + ]) + try { + localStorage.removeItem('home_mood') + localStorage.removeItem('jf_sidebar_pinned') + } catch { /* noop */ } + }) + } + /> + +
+ ) +} + +function ExportButtons({ disabled }: { disabled: boolean }) { + function run(format: ExportFormat, label: string) { + if (disabled) return + try { + exportDiary(useDiary.getState().entries, format) + toast(`Exported diary as ${label}`, 'success') + } catch { + toast('Export failed', 'error') + } + } + const formats: { format: ExportFormat; label: string; short: string }[] = [ + { format: 'markdown', label: 'Markdown', short: 'MD' }, + { format: 'json', label: 'JSON', short: 'JSON' }, + { format: 'csv', label: 'Letterboxd CSV', short: 'CSV' }, + ] + return ( +
+ {formats.map(f => ( + + ))} +
+ ) +} diff --git a/src/pages/settings/sections/Playback.tsx b/src/pages/settings/sections/Playback.tsx new file mode 100644 index 0000000..2d767f3 --- /dev/null +++ b/src/pages/settings/sections/Playback.tsx @@ -0,0 +1,110 @@ +import { Section, Row, SubHeading, Toggle, Segmented, type PrefsLike } from '../_ui' +import Select from '../../../components/ui/Select' + +export function PlaybackSection({ prefs }: { prefs: PrefsLike }) { + return ( +
+ + + prefs.setPreference('autoplayNext', v)} /> + + + prefs.setPreference('skipIntros', v)} /> + + + prefs.setPreference('skipCredits', v)} /> + + + + + prefs.setEpisodeRecap('gapDays', parseInt(v, 10))} + width="min-w-[140px]" + options={[ + { value: '3', label: '3 days' }, + { value: '7', label: '1 week' }, + { value: '14', label: '2 weeks' }, + { value: '30', label: '1 month' }, + { value: '90', label: '3 months' }, + ]} + ariaLabel="Recap gap days" + /> + + + + + onChange(e.target.value)} + placeholder={placeholder} + autoFocus={autoFocus} + className={`w-full h-10 ${Icon ? 'pl-9' : 'pl-3'} ${trailing ? 'pr-10' : 'pr-3'} bg-void/55 rounded-md text-[12.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 transition-all`} + /> + {trailing &&
{trailing}
} + + + ) +} diff --git a/src/pages/settings/sections/Shortcuts.tsx b/src/pages/settings/sections/Shortcuts.tsx new file mode 100644 index 0000000..661d502 --- /dev/null +++ b/src/pages/settings/sections/Shortcuts.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState } from 'react' +import { Trash2, RotateCcw } from '../../../lib/icons' +import { Section, Row, SubHeading } from '../_ui' +import { usePreferencesStore } from '../../../stores/preferences-store' +import { SHORTCUTS, eventToBinding, normalizeBinding, type ShortcutCategory } from '../../../lib/player-shortcuts' +import { toast } from '../../../stores/toast-store' + +/** + * Keyboard shortcut remapping. The bindings live in + * `prefs.keyboardShortcuts` keyed by shortcut id. If a shortcut has an + * entry, it overrides the default keys; if not, the shortcut's built-in + * defaults apply. + * + * Editing flow: click a row to arm capture, press a key combo, the new + * binding is normalised + stored. Esc aborts the capture without + * changing anything. The capture intentionally ignores modifier-only + * presses so users can't accidentally lock themselves into a binding + * like `Ctrl` alone. + */ + +const CATEGORY_LABELS: Record = { + playback: 'Playback', + audio: 'Audio', + subtitles: 'Subtitles', + view: 'View', + tools: 'Tools', + navigation: 'Navigation', +} + +const CATEGORY_ORDER: ShortcutCategory[] = [ + 'playback', + 'audio', + 'subtitles', + 'view', + 'tools', + 'navigation', +] + +function prettyToken(t: string): string { + if (t === ' ' || t.toLowerCase() === 'space') return 'Space' + if (t === 'ArrowLeft') return '←' + if (t === 'ArrowRight') return '→' + if (t === 'ArrowUp') return '↑' + if (t === 'ArrowDown') return '↓' + if (t === 'Escape') return 'Esc' + return t +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function BindingChips({ binding }: { binding: string }) { + return ( + + {binding.split('+').map((tok, i) => ( + {prettyToken(tok)} + ))} + + ) +} + +export function ShortcutsSection() { + const overrides = usePreferencesStore(s => s.keyboardShortcuts) + const setPreference = usePreferencesStore(s => s.setPreference) + const [capturingId, setCapturingId] = useState(null) + + function commit(id: string, binding: string) { + const conflict = Object.entries({ ...overrides }) + .filter(([k]) => k !== id) + .find(([, keys]) => (keys as string[]).some(k => normalizeBinding(k) === binding)) + const defaultConflict = SHORTCUTS.find(sc => sc.id !== id && sc.keys.some(k => normalizeBinding(k) === binding) && !overrides[sc.id]) + if (conflict) { + const other = SHORTCUTS.find(s => s.id === conflict[0]) + toast(`That combo is bound to "${other?.description || conflict[0]}"`, 'error') + return + } + if (defaultConflict) { + toast(`That combo is bound to "${defaultConflict.description}"`, 'error') + return + } + setPreference('keyboardShortcuts', { ...overrides, [id]: [binding] }) + toast('Shortcut updated', 'success') + } + + function reset(id: string) { + const next = { ...overrides } + delete next[id] + setPreference('keyboardShortcuts', next) + } + + function resetAll() { + setPreference('keyboardShortcuts', {}) + toast('All shortcuts reset to defaults', 'success') + } + + useEffect(() => { + if (!capturingId) return + const id = capturingId + function onKey(e: KeyboardEvent) { + e.preventDefault() + e.stopPropagation() + if (e.key === 'Escape') { + setCapturingId(null) + return + } + if (e.key === 'Shift' || e.key === 'Control' || e.key === 'Alt' || e.key === 'Meta') { + return + } + const binding = eventToBinding(e) + setCapturingId(null) + commit(id, binding) + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [capturingId, overrides]) + + return ( +
+ + + + + {CATEGORY_ORDER.map(cat => { + const items = SHORTCUTS.filter(s => s.category === cat) + if (items.length === 0) return null + return ( +
+ + {items.map(sc => { + const override = overrides[sc.id] + const keys = override?.length ? override : sc.keys + const isCustom = !!override?.length + const isCapturing = capturingId === sc.id + return ( + +
+ + {isCustom && !isCapturing && ( + + )} +
+
+ ) + })} +
+ ) + })} +
+ ) +} diff --git a/src/pages/settings/sections/Tmdb.tsx b/src/pages/settings/sections/Tmdb.tsx new file mode 100644 index 0000000..862944e --- /dev/null +++ b/src/pages/settings/sections/Tmdb.tsx @@ -0,0 +1,49 @@ +import { ExternalLink } from '../../../lib/icons' +import { Section, Row, Input, type PrefsLike } from '../_ui' +import { writeSecret } from '../../../lib/sensitive-storage' + +export function TmdbSection({ prefs }: { prefs: PrefsLike }) { + const builtIn = (import.meta.env.VITE_TMDB_API_KEY || '').trim() + const usingBuiltIn = !prefs.tmdbApiKey && !!builtIn + return ( +
+ {usingBuiltIn && ( +
+ A built-in TMDB key is bundled with this build, so metadata, posters, trailers, and discovery rows all work without setup. Add your own key below only if you want to use a personal key. +
+ )} + + { + prefs.setPreference('tmdbApiKey', v) + try { writeSecret('jf_tmdb_key', v) } catch { /* noop */ } + }} + placeholder={usingBuiltIn ? 'Optional - leave blank to use built-in' : 'Paste your key'} + width="w-64" + /> + +
+ Don't have one yet? + + Get a TMDB API key + + +
+
+ ) +} diff --git a/src/pages/settings/sections/Trakt.tsx b/src/pages/settings/sections/Trakt.tsx new file mode 100644 index 0000000..fa297b0 --- /dev/null +++ b/src/pages/settings/sections/Trakt.tsx @@ -0,0 +1,215 @@ +import { useState, useEffect, useRef } from 'react' +import { ExternalLink, Loader2, Check, Trash2 } from '../../../lib/icons' +import { Section, Row, Input, Toggle } from '../_ui' +import { useTrakt } from '../../../stores/trakt-store' +import { useWatchlist } from '../../../hooks/use-watchlist' +import { requestDeviceCode, pollDeviceToken, fetchTraktWatchlist, addToTraktWatchlist } from '../../../lib/trakt' +import { toast } from '../../../stores/toast-store' +import { useLibraryByTmdbId } from '../../../hooks/use-jellyfin' + +type Stage = + | { kind: 'idle' } + | { kind: 'pending'; userCode: string; verificationUrl: string } + | { kind: 'success' } + | { kind: 'error'; message: string } + +export function TraktSection() { + const clientId = useTrakt(s => s.clientId) + const clientSecret = useTrakt(s => s.clientSecret) + const tokens = useTrakt(s => s.tokens) + const enabled = useTrakt(s => s.enabled) + const setCredentials = useTrakt(s => s.setCredentials) + const setEnabled = useTrakt(s => s.setEnabled) + const setTokens = useTrakt(s => s.setTokens) + const disconnect = useTrakt(s => s.disconnect) + + const [stage, setStage] = useState({ kind: 'idle' }) + const cancelRef = useRef(false) + const watchlist = useWatchlist() + const libraryByTmdb = useLibraryByTmdbId() + const [syncing, setSyncing] = useState(false) + + useEffect(() => () => { cancelRef.current = true }, []) + + async function syncWatchlistFromTrakt() { + if (!tokens?.accessToken) { + toast('Connect Trakt first', 'error') + return + } + setSyncing(true) + try { + const traktItems = await fetchTraktWatchlist() + let added = 0 + for (const t of traktItems) { + const tmdbId = t.ids.tmdb ? String(t.ids.tmdb) : null + if (!tmdbId) continue + const local = libraryByTmdb.data?.get(tmdbId) + if (local?.id && watchlist.playlistId) { + await watchlist.addToWatchlist(local.id) + added++ + } + } + toast(`Synced ${added} items from Trakt watchlist`, 'success') + } catch { + toast('Sync failed', 'error') + } finally { + setSyncing(false) + } + } + + async function startDeviceFlow() { + if (!clientId || !clientSecret) { + toast('Add your Trakt client ID and secret first', 'error') + return + } + cancelRef.current = false + setStage({ kind: 'idle' }) + try { + const code = await requestDeviceCode() + setStage({ kind: 'pending', userCode: code.user_code, verificationUrl: code.verification_url }) + const deadline = Date.now() + code.expires_in * 1000 + const interval = Math.max(2, code.interval) * 1000 + while (!cancelRef.current && Date.now() < deadline) { + await new Promise(r => setTimeout(r, interval)) + if (cancelRef.current) return + try { + const tok = await pollDeviceToken(code.device_code) + if (tok) { + setTokens({ + accessToken: tok.access_token, + refreshToken: tok.refresh_token, + expiresAt: new Date(Date.now() + tok.expires_in * 1000).toISOString(), + }) + setStage({ kind: 'success' }) + toast('Trakt connected', 'success') + return + } + } catch (err: any) { + setStage({ kind: 'error', message: err?.message || 'Authorisation failed' }) + return + } + } + if (!cancelRef.current) { + setStage({ kind: 'error', message: 'Code expired - try again' }) + } + } catch (err: any) { + setStage({ kind: 'error', message: err?.message || 'Could not start authorisation' }) + } + } + + function cancelFlow() { + cancelRef.current = true + setStage({ kind: 'idle' }) + } + + const connected = !!tokens + + return ( +
+ + + + + setCredentials(v, clientSecret)} + placeholder="abc123..." + width="w-64" + /> + + + setCredentials(clientId, v)} + placeholder="def456..." + width="w-64" + /> + + + {connected ? ( + <> + + + + + + + + ) : ( + + {stage.kind === 'pending' ? ( +
+ + Code: {stage.userCode} + + + Open + + + +
+ ) : ( + + )} +
+ )} + + {stage.kind === 'error' && ( +

+ {stage.message} +

+ )} + +
+ Need an app? + + Create one on Trakt + + +
+
+ ) +}