From b43aef0f73f9159e7062f27265b0825c538bfe92 Mon Sep 17 00:00:00 2001 From: lashman Date: Wed, 1 Apr 2026 12:23:33 +0300 Subject: [PATCH] app shell and routing --- src/App.css | 184 ----------------- src/App.tsx | 378 ++++++++++++++++++++++++----------- src/index.css | 494 ++++++++++++++++++++++++++++++++++++---------- src/main.tsx | 11 +- src/vite-env.d.ts | 10 + 5 files changed, 674 insertions(+), 403 deletions(-) delete mode 100644 src/App.css create mode 100644 src/vite-env.d.ts diff --git a/src/App.css b/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/src/App.tsx b/src/App.tsx index a66b5ef..b16da19 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,122 +1,268 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' +import { useState, useEffect, lazy, Suspense } from 'react' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' +import { AnimatePresence, motion } from 'framer-motion' +import { jellyfinClient } from './api/jellyfin' +import type { AuthState } from './api/types' +import { usePreferencesStore } from './stores/preferences-store' +import { isTauri } from './lib/tauri' +import ErrorBoundary from './components/ui/ErrorBoundary' +import AppShell from './components/layout/AppShell' +import Titlebar from './components/layout/Titlebar' +import LoginPage from './pages/LoginPage' +import HomePage from './pages/HomePage' +import { useNewReleaseNotifications } from './hooks/use-new-releases' -function App() { - const [count, setCount] = useState(0) +const LibraryPage = lazy(() => import('./pages/LibraryPage')) +const DetailPage = lazy(() => import('./pages/DetailPage')) +const MusicPage = lazy(() => import('./pages/MusicPage')) +const SearchPage = lazy(() => import('./pages/SearchPage')) +const DiscoverPage = lazy(() => import('./pages/DiscoverPage')) +const SettingsPage = lazy(() => import('./pages/SettingsPage')) +const PlayerPage = lazy(() => import('./pages/PlayerPage')) +const PersonPage = lazy(() => import('./pages/PersonPage')) +const CollectionPage = lazy(() => import('./pages/CollectionPage')) +const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage')) +const ProfilePage = lazy(() => import('./pages/ProfilePage')) +const StatsPage = lazy(() => import('./pages/StatsPage')) +const DuplicatesPage = lazy(() => import('./pages/DuplicatesPage')) +const DownloadsPage = lazy(() => import('./pages/DownloadsPage')) +const LiveTvPage = lazy(() => import('./pages/LiveTvPage')) +const RequestsPage = lazy(() => import('./pages/RequestsPage')) + +const pageVariants = { + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -4 }, +} + +const pageTransition = { + duration: 0.2, + ease: [0, 0, 0.2, 1] as [number, number, number, number], +} + +/** + * Wraps the entire window so every state of the app (loading, login, main + * shell) shares the same titlebar + content layout. The titlebar is only + * mounted when running inside Tauri - in a browser, the wrapper just shrinks + * to nothing and the content fills the viewport like before. + */ +function WindowFrame({ children }: { children: React.ReactNode }) { + const [fullscreen, setFullscreen] = useState(false) + + useEffect(() => { + const sync = () => setFullscreen(Boolean(document.fullscreenElement)) + sync() + document.addEventListener('fullscreenchange', sync) + return () => document.removeEventListener('fullscreenchange', sync) + }, []) return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- +
+ {isTauri && !fullscreen && } +
{children}
+
) } -export default App +function PageMotion({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function PageFallback() { + return ( +
+
+ + Loading +
+
+ ) +} + +/** + * Renders the landing page chosen in Settings ("home" / "movies" / "shows"). + * Reads the pref synchronously so initial mount doesn't flicker through Home. + */ +function LandingRedirect() { + const landing = usePreferencesStore(s => s.defaultLanding) + if (landing === 'movies') return + if (landing === 'shows') return + return +} + +export default function App() { + const [auth, setAuth] = useState(undefined) + const [loading, setLoading] = useState(true) + const location = useLocation() + const uiZoom = usePreferencesStore(s => s.uiZoom) + const pushNotifications = usePreferencesStore(s => s.pushNotifications) + useNewReleaseNotifications(pushNotifications) + + useEffect(() => { + jellyfinClient.tryAutoLogin().then(result => { + setAuth(result) + setLoading(false) + }) + }, []) + + // Apply the user's interface-zoom preference. In Tauri we use the native + // webview zoom API (Chromium's setZoom), which is the same path Ctrl+/Ctrl- + // takes - it reflows layout and re-evaluates media queries, so a 1.5x + // zoom collapses multi-column grids exactly the way a smaller window would. + // CSS `zoom` only scales rendered pixels (no reflow) so it's only used as + // the browser fallback where the OS-level webview API isn't available. + useEffect(() => { + const z = Number.isFinite(uiZoom) && uiZoom > 0 ? uiZoom : 1 + if (isTauri) { + // Clear any leftover CSS zoom from a previous session + document.documentElement.style.zoom = '' + // Lazy-import so the @tauri-apps/api dep doesn't get pulled into a + // pure-browser build that doesn't need it. + import('@tauri-apps/api/webview').then(({ getCurrentWebview }) => { + getCurrentWebview().setZoom(z).catch(() => {}) + }).catch(() => {}) + } else { + document.documentElement.style.zoom = String(z) + } + }, [uiZoom]) + + // Apply custom accent color to CSS variables so the entire UI picks it up. + const accentColor = usePreferencesStore(s => s.accentColor) + useEffect(() => { + const root = document.documentElement + if (!accentColor || accentColor === '#F5B642') { + root.style.removeProperty('--color-accent') + root.style.removeProperty('--color-accent-hover') + root.style.removeProperty('--color-accent-press') + root.style.removeProperty('--color-accent-dim') + root.style.removeProperty('--color-accent-glow') + root.style.removeProperty('--color-accent-text') + root.style.removeProperty('--color-accent-deep') + } else { + root.style.setProperty('--color-accent', accentColor) + // Derive related shades from the base color. We use simple HSL + // shifts so the palette stays coherent without pulling in a color + // math library. + try { + const hex = accentColor.replace('#', '') + const r = parseInt(hex.slice(0, 2), 16) / 255 + const g = parseInt(hex.slice(2, 4), 16) / 255 + const b = parseInt(hex.slice(4, 6), 16) / 255 + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + let h = 0 + const l = (max + min) / 2 + const s = max === min ? 0 : (max - min) / (1 - Math.abs(2 * l - 1)) + if (max !== min) { + if (max === r) h = ((g - b) / (max - min) + (g < b ? 6 : 0)) * 60 + else if (max === g) h = ((b - r) / (max - min) + 2) * 60 + else h = ((r - g) / (max - min) + 4) * 60 + } + const hover = `hsl(${h} ${Math.round(s * 100)}% ${Math.min(100, Math.round(l * 100) + 10)}%)` + const press = `hsl(${h} ${Math.round(s * 100)}% ${Math.max(0, Math.round(l * 100) - 8)}%)` + const dim = `hsla(${h} ${Math.round(s * 100)}% ${Math.round(l * 100)}% / 0.14)` + const glow = `hsla(${h} ${Math.round(s * 100)}% ${Math.round(l * 100)}% / 0.22)` + const text = `hsl(${h} ${Math.round(s * 100)}% ${Math.min(100, Math.round(l * 100) + 22)}%)` + const deep = `hsl(${h} ${Math.round(s * 100)}% ${Math.max(0, Math.round(l * 100) - 22)}%)` + root.style.setProperty('--color-accent-hover', hover) + root.style.setProperty('--color-accent-press', press) + root.style.setProperty('--color-accent-dim', dim) + root.style.setProperty('--color-accent-glow', glow) + root.style.setProperty('--color-accent-text', text) + root.style.setProperty('--color-accent-deep', deep) + } catch { + // If the color value is malformed, fall back to just the base. + } + } + }, [accentColor]) + + if (loading) { + return ( + +
+
+
+
+ +
+
+ j +
+ +
+ + Connecting to your server +
+
+ + ) + } + + if (!auth) { + return ( + + + + ) + } + + return ( + + + + {/* AppShell renders its own complete chrome (AppHeader includes + window controls + drag region), so it doesn't get wrapped in + WindowFrame - that would stack two titlebars. */} + { jellyfinClient.logout(); setAuth(null) }} />}> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Player needs the simple Titlebar so the user can still close + the window during playback. */} + + }> + + + + } + /> + + + + ) +} diff --git a/src/index.css b/src/index.css index 5fb3313..f3e42de 100644 --- a/src/index.css +++ b/src/index.css @@ -1,111 +1,403 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; +/* Self-hosted-style fonts via Bunny (privacy-respecting Google Fonts proxy) */ +@import url('https://fonts.bunny.net/css?family=geist:400,500,600,700|geist-mono:400,500,600|unbounded:400,500,600,700,800,900&display=swap'); +@import "tailwindcss"; +@import "leaflet/dist/leaflet.css"; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; +@theme { + /* ─── Surfaces (layered dark with warm undertone) ─────────────── */ + --color-void: #07080A; + --color-surface: #0F1114; + --color-elevated: #15181D; + --color-higher: #1D2127; + --color-muted: #2A2F38; + --color-line: #3A3F4A; - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + /* Glass surfaces (use with backdrop-blur) */ + --color-glass: rgba(15, 17, 20, 0.72); + --color-glass-strong: rgba(15, 17, 20, 0.88); + --color-glass-light: rgba(255, 255, 255, 0.04); - @media (max-width: 1024px) { - font-size: 16px; + /* ─── Text (luminance-disciplined dark mode) ─────────────────── */ + --color-text-1: rgba(255, 255, 255, 0.95); + --color-text-2: rgba(255, 255, 255, 0.68); + --color-text-3: rgba(255, 255, 255, 0.46); + --color-text-4: rgba(255, 255, 255, 0.28); + --color-text-5: rgba(255, 255, 255, 0.16); + + /* ─── Accent (refined warm amber, the Jellyfin heritage) ─────── */ + --color-accent: #F5B642; + --color-accent-hover: #FFC656; + --color-accent-press: #E5A82E; + --color-accent-dim: rgba(245, 182, 66, 0.14); + --color-accent-glow: rgba(245, 182, 66, 0.22); + --color-accent-text: #FFD17A; + --color-accent-deep: #B07A1C; + + /* Secondary cool accent for contrast moments */ + --color-cool: #6BA3FF; + --color-cool-dim: rgba(107, 163, 255, 0.12); + + /* ─── Semantic ───────────────────────────────────────────────── */ + --color-success: #4ADE80; + --color-warning: #FBBF24; + --color-error: #FB7185; + --color-info: #60A5FA; + + /* ─── Borders (hairline progression) ─────────────────────────── */ + --color-border: rgba(255, 255, 255, 0.07); + --color-border-hover: rgba(255, 255, 255, 0.12); + --color-border-strong: rgba(255, 255, 255, 0.18); + + /* ─── Skeleton ───────────────────────────────────────────────── */ + --color-skeleton-base: rgba(255, 255, 255, 0.035); + --color-skeleton-shine: rgba(255, 255, 255, 0.085); + + /* ─── Typography ─────────────────────────────────────────────── */ + --font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'Geist Mono', 'JetBrains Mono', 'Cascadia Code', Consolas, monospace; + --font-display: 'Unbounded', 'Geist', system-ui, sans-serif; + + /* ─── Radii (the rounded-but-intentional scale) ──────────────── */ + --radius-xs: 4px; + --radius-sm: 8px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 18px; + --radius-2xl: 24px; + --radius-pill: 999px; + + /* ─── Shadow (cinematic layered depth) ───────────────────────── */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-sm: 0 2px 6px -1px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 8px 20px -6px rgba(0, 0, 0, 0.5), 0 2px 6px -2px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.65), 0 6px 16px -8px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 40px 80px -20px rgba(0, 0, 0, 0.75), 0 12px 24px -12px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 0 1px var(--color-accent-glow), 0 0 32px -4px var(--color-accent-glow); + --shadow-inset-edge: inset 0 1px 0 rgba(255, 255, 255, 0.05); + + /* ─── Motion (spring + snappy duo) ───────────────────────────── */ + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --duration-instant: 100ms; + --duration-fast: 160ms; + --duration-base: 220ms; + --duration-slow: 320ms; + --duration-slower: 480ms; + --duration-cinema: 700ms; + + /* ─── Z-index layering ───────────────────────────────────────── */ + --z-base: 0; + --z-sticky: 25; + --z-sidebar: 10; + --z-mini-player: 20; + --z-dropdown: 30; + --z-modal-scrim: 40; + --z-modal: 50; + --z-toast: 60; + --z-video-overlay: 70; + --z-fullscreen: 80; + + /* ─── Keyframes ──────────────────────────────────────────────── */ + @keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } + } + @keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); } + 50% { box-shadow: 0 0 0 6px transparent; } + } + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes rise-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes slow-zoom { + from { transform: scale(1); } + to { transform: scale(1.06); } + } + @keyframes spin-soft { + to { transform: rotate(360deg); } + } + @keyframes equalizer-bar { + 0%, 100% { transform: scaleY(0.35); } + 50% { transform: scaleY(1); } } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; +/* ──────────────────────────────────────────────────────────────── */ +/* Base layer */ +/* ──────────────────────────────────────────────────────────────── */ +@layer base { + *, *::before, *::after { + box-sizing: border-box; } - #social .button-icon { - filter: invert(1) brightness(2); + html, body, #root { + height: 100%; + overflow: hidden; + } + + html { + -webkit-text-size-adjust: 100%; + text-rendering: optimizeLegibility; + } + + body { + font-family: var(--font-sans); + font-feature-settings: 'ss01', 'ss02', 'cv11', 'cv05'; + font-optical-sizing: auto; + background: + radial-gradient(1200px 800px at 12% -8%, rgba(245, 182, 66, 0.04), transparent 60%), + radial-gradient(900px 600px at 92% 6%, rgba(107, 163, 255, 0.025), transparent 55%), + var(--color-void); + color: var(--color-text-1); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + letter-spacing: -0.005em; + } + + ::selection { + background: var(--color-accent-glow); + color: var(--color-text-1); + } + + /* Hide focus ring for mouse, keep for keyboard */ + :focus { outline: none; } + :focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: var(--radius-sm); + } + + /* ─── Scrollbars ─────────────────────────────────────────────── */ + .content-scroll::-webkit-scrollbar { width: 10px; height: 10px; } + .content-scroll::-webkit-scrollbar-track { background: transparent; } + .content-scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.06); + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 999px; + transition: background var(--duration-base); + } + .content-scroll:hover::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); background-clip: padding-box; } + .content-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.22); background-clip: padding-box; } + + .hide-scrollbar::-webkit-scrollbar { display: none; } + .hide-scrollbar { scrollbar-width: none; -ms-overflow-style: none; } + + /* ─── Player video fill ──────────────────────────────────── + * Override vidstack's default 16:9 aspect-ratio so the video + * element fills its container and adapts to the source's actual + * aspect ratio (4:3, 21:9, vertical, etc). + */ + .player-fill { + aspect-ratio: unset !important; + } + .player-fill [data-media-provider] { + aspect-ratio: unset !important; + height: 100%; + } + .player-fill video { + width: 100% !important; + height: 100% !important; + max-width: 100%; + max-height: 100%; + aspect-ratio: unset !important; + object-fit: contain !important; + } + + /* ─── Subtitle font size via CSS custom property ────────── */ + [style*="--cue-font-size"] [data-cue] { + font-size: var(--cue-font-size); + } + @media (min-width: 768px) { + [style*="--cue-font-size-md"] [data-cue] { + font-size: var(--cue-font-size-md); + } + } + + /* ─── Fullscreen: hide every chrome element that lives outside + * the fullscreen target. The video element is what goes fullscreen + * (via vidstack's request), so a plain `:fullscreen .app-titlebar` + * descendant selector misses - the titlebar is a SIBLING of the + * fullscreen target, not a descendant. `body:has(:fullscreen)` + * inverts the relationship so we can hide anywhere in the body + * while a fullscreen element exists. + */ + body:has(:fullscreen) .app-header, + body:has(:fullscreen) .app-sidebar, + body:has(:fullscreen) .app-titlebar { + display: none !important; + } + + /* ─── Theater / zen mode ───────────────────────────────────── + * When data-theater="true" on the player root, hide the bottom + * controls bar entirely and keep only a slim scrubber strip plus + * the click-capture surface. The top bar fades out via opacity so + * window controls are still reachable on a focus event. + */ + [data-theater="true"] [data-player-top-bar] { + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; + } + [data-theater="true"]:hover [data-player-top-bar] { + opacity: 1; + pointer-events: auto; + } + [data-theater="true"] [data-player-bottom-bar] { + transform: translateY(calc(100% - 6px)); + transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1); + } + [data-theater="true"] [data-player-bottom-bar]:hover { + transform: translateY(0); + } + + /* ─── Subtitle size slider ───────────────────────────────── */ + .subtitle-size-slider::-webkit-slider-track { + height: 4px; + border-radius: 2px; + background: rgba(255,255,255,0.12); + } + .subtitle-size-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + margin-top: -6px; + box-shadow: 0 1px 4px rgba(0,0,0,0.4); + cursor: pointer; + } + .subtitle-size-slider::-moz-range-track { + height: 4px; + border-radius: 2px; + background: rgba(255,255,255,0.12); + border: none; + } + .subtitle-size-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 4px rgba(0,0,0,0.4); + cursor: pointer; + border: none; + } + + /* ─── Utilities ──────────────────────────────────────────────── */ + .skeleton { + background: linear-gradient( + 90deg, + var(--color-skeleton-base) 25%, + var(--color-skeleton-shine) 50%, + var(--color-skeleton-base) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.6s infinite ease-in-out; + border-radius: var(--radius-md); + } + + .glass { + background: var(--color-glass); + backdrop-filter: blur(24px) saturate(140%); + -webkit-backdrop-filter: blur(24px) saturate(140%); + border: 1px solid var(--color-border); + } + + .glass-strong { + background: var(--color-glass-strong); + backdrop-filter: blur(32px) saturate(160%); + -webkit-backdrop-filter: blur(32px) saturate(160%); + } + + .noise { + position: relative; + isolation: isolate; + } + .noise::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; + opacity: 0.04; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml;utf8,"); + } + + .focus-ring { + transition: box-shadow var(--duration-fast) var(--ease-standard); + } + .focus-ring:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--color-void), 0 0 0 4px var(--color-accent); + } + + /* Animated gradient stroke for hero / featured chrome */ + .accent-stroke { + background: linear-gradient( + 120deg, + transparent 30%, + var(--color-accent-glow) 50%, + transparent 70% + ); + background-size: 200% 100%; + animation: shimmer 4s infinite linear; + } + + /* Text gradient for titles */ + .text-luxe { + background: linear-gradient(180deg, rgba(255,255,255,1), rgba(255,255,255,0.78)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + } + + /* Range input styling baseline (used by sliders) */ + input[type="range"].slider { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; + height: 16px; + } + input[type="range"].slider::-webkit-slider-runnable-track { + height: 4px; + background: rgba(255, 255, 255, 0.10); + border-radius: 999px; + } + input[type="range"].slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 999px; + background: var(--color-accent); + margin-top: -5px; + box-shadow: 0 0 0 4px transparent; + transition: box-shadow var(--duration-fast), transform var(--duration-fast); + } + input[type="range"].slider:hover::-webkit-slider-thumb { + box-shadow: 0 0 0 6px var(--color-accent-glow); + transform: scale(1.05); + } + input[type="range"].slider:active::-webkit-slider-thumb { + transform: scale(1.15); + } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } } - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} diff --git a/src/main.tsx b/src/main.tsx index bef5202..082ea97 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,17 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClientProvider } from '@tanstack/react-query' +import { queryClient } from './lib/query-client' +import App from './App' import './index.css' -import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + + + , ) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..5cd211b --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_TMDB_API_KEY?: string + readonly VITE_FANART_API_KEY?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}