app shell and routing
This commit is contained in:
+262
-116
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user