app shell and routing

This commit is contained in:
2026-04-01 12:23:33 +03:00
parent c79604bc69
commit b43aef0f73
5 changed files with 674 additions and 403 deletions
+262 -116
View File
@@ -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 (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
<div className="h-screen flex flex-col bg-void">
{isTauri && !fullscreen && <Titlebar />}
<div className="flex-1 relative overflow-hidden">{children}</div>
</div>
)
}
export default App
function PageMotion({ children }: { children: React.ReactNode }) {
return (
<motion.div
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={pageTransition}
className="min-h-full"
>
{children}
</motion.div>
)
}
function PageFallback() {
return (
<div className="h-full grid place-items-center">
<div className="flex items-center gap-2 text-text-3 text-[12.5px] font-medium">
<span className="w-1 h-1 rounded-full bg-accent animate-pulse" />
Loading
</div>
</div>
)
}
/**
* 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 <Navigate to="/movies" replace />
if (landing === 'shows') return <Navigate to="/shows" replace />
return <HomePage />
}
export default function App() {
const [auth, setAuth] = useState<AuthState | null | undefined>(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 (
<WindowFrame>
<div className="absolute inset-0 flex flex-col items-center justify-center bg-void overflow-hidden">
<div className="absolute inset-0 -z-10">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-accent/8 blur-[120px]" />
</div>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
className="relative w-12 h-12 mb-4"
>
<div className="absolute inset-0 rounded-xl bg-accent-glow blur-md" />
<div className="relative w-full h-full rounded-xl bg-gradient-to-br from-accent to-accent-press grid place-items-center text-void font-bold text-xl font-display">
j
</div>
</motion.div>
<div className="flex items-center gap-2 text-text-3 text-[12.5px] font-medium">
<span className="w-1 h-1 rounded-full bg-accent animate-pulse" />
Connecting to your server
</div>
</div>
</WindowFrame>
)
}
if (!auth) {
return (
<WindowFrame>
<LoginPage onLogin={setAuth} />
</WindowFrame>
)
}
return (
<ErrorBoundary>
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
{/* 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. */}
<Route element={<AppShell auth={auth} onLogout={() => { jellyfinClient.logout(); setAuth(null) }} />}>
<Route index element={<PageMotion><LandingRedirect /></PageMotion>} />
<Route path="movies" element={<PageMotion><LibraryPage type="movies" /></PageMotion>} />
<Route path="shows" element={<PageMotion><LibraryPage type="shows" /></PageMotion>} />
<Route path="music" element={<PageMotion><MusicPage /></PageMotion>} />
<Route path="playlists" element={<PageMotion><PlaylistsPage /></PageMotion>} />
<Route path="item/:id" element={<PageMotion><DetailPage /></PageMotion>} />
<Route path="person/:id" element={<PageMotion><PersonPage /></PageMotion>} />
<Route path="collection/:id" element={<PageMotion><CollectionPage /></PageMotion>} />
<Route path="search" element={<PageMotion><SearchPage /></PageMotion>} />
<Route path="discover" element={<PageMotion><DiscoverPage /></PageMotion>} />
<Route path="profile" element={<PageMotion><ProfilePage /></PageMotion>} />
<Route path="stats" element={<PageMotion><StatsPage /></PageMotion>} />
<Route path="duplicates" element={<PageMotion><DuplicatesPage /></PageMotion>} />
<Route path="downloads" element={<PageMotion><DownloadsPage /></PageMotion>} />
<Route path="live" element={<PageMotion><LiveTvPage /></PageMotion>} />
<Route path="requests" element={<PageMotion><RequestsPage /></PageMotion>} />
<Route path="settings" element={<PageMotion><SettingsPage /></PageMotion>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
{/* Player needs the simple Titlebar so the user can still close
the window during playback. */}
<Route
path="play/:id"
element={
<WindowFrame>
<Suspense fallback={<PageFallback />}>
<PlayerPage />
</Suspense>
</WindowFrame>
}
/>
</Routes>
</AnimatePresence>
</ErrorBoundary>
)
}