mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(app): Phase C.1 — extract AppRoutes + RequireAuth from App.tsx (#559)
Continues the App.tsx slim-down. Two pieces move out of the monolith: - `src/app/AppRoutes.tsx` — the route table and its 32 lazy page imports. AppShell now renders `<AppRoutes />` inside the existing `<Suspense>`; the `perfFlags.disableMainRouteContentMount` placeholder stays in AppShell because that branch is a layout concern, not a routing one. `useIsMobile()` moves inside AppRoutes so the `/now-playing` mobile swap stays self-contained. - `src/app/RequireAuth.tsx` — the 4-line auth guard, with a focused test that covers all three reject paths (no login, no active server id, empty server list) plus the happy path. MainApp imports it from the new file instead of routing through the App.tsx re-export. Side-cleanup of imports that B.2 had already orphaned in App.tsx (`version`, `initAudioListeners`, `lazy`, `Routes`, `Route`, `Navigate`, `MobilePlayerView`). `App.tsx` 1308 → 1232 LOC. `AppShell` stays in App.tsx for Phase C.2. Pre-PR check: PASS (frontend tests, tsc, coverage gates, prod build, backend tests, clippy, backend coverage gates).
This commit is contained in:
committed by
GitHub
parent
f09da2d2a3
commit
2b1ad1542a
+4
-80
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useRef, Suspense } from 'react';
|
||||
import { showToast } from './utils/toast';
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
@@ -9,46 +9,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import PlayerBar from './components/PlayerBar';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import MobilePlayerView from './components/MobilePlayerView';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import LiveSearch from './components/LiveSearch';
|
||||
import NowPlayingDropdown from './components/NowPlayingDropdown';
|
||||
import QueuePanel from './components/QueuePanel';
|
||||
|
||||
// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
|
||||
// the entry chunk; each page is fetched when its route is first visited.
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Albums = lazy(() => import('./pages/Albums'));
|
||||
const Artists = lazy(() => import('./pages/Artists'));
|
||||
const ArtistDetail = lazy(() => import('./pages/ArtistDetail'));
|
||||
const Composers = lazy(() => import('./pages/Composers'));
|
||||
const ComposerDetail = lazy(() => import('./pages/ComposerDetail'));
|
||||
const NewReleases = lazy(() => import('./pages/NewReleases'));
|
||||
const Favorites = lazy(() => import('./pages/Favorites'));
|
||||
const RandomMix = lazy(() => import('./pages/RandomMix'));
|
||||
const RandomLanding = lazy(() => import('./pages/RandomLanding'));
|
||||
const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
|
||||
const MostPlayed = lazy(() => import('./pages/MostPlayed'));
|
||||
const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums'));
|
||||
const RandomAlbums = lazy(() => import('./pages/RandomAlbums'));
|
||||
const LuckyMixPage = lazy(() => import('./pages/LuckyMix'));
|
||||
const SearchResults = lazy(() => import('./pages/SearchResults'));
|
||||
const Playlists = lazy(() => import('./pages/Playlists'));
|
||||
const PlaylistDetail = lazy(() => import('./pages/PlaylistDetail'));
|
||||
const NowPlayingPage = lazy(() => import('./pages/NowPlaying'));
|
||||
const Tracks = lazy(() => import('./pages/Tracks'));
|
||||
const Settings = lazy(() => import('./pages/Settings'));
|
||||
const Statistics = lazy(() => import('./pages/Statistics'));
|
||||
const Help = lazy(() => import('./pages/Help'));
|
||||
const WhatsNew = lazy(() => import('./pages/WhatsNew'));
|
||||
const DeviceSync = lazy(() => import('./pages/DeviceSync'));
|
||||
const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary'));
|
||||
const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
|
||||
const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
|
||||
const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
|
||||
const InternetRadio = lazy(() => import('./pages/InternetRadio'));
|
||||
const Genres = lazy(() => import('./pages/Genres'));
|
||||
const GenreDetail = lazy(() => import('./pages/GenreDetail'));
|
||||
import AppRoutes from './app/AppRoutes';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
import SongInfoModal from './components/SongInfoModal';
|
||||
@@ -71,7 +36,6 @@ import { useOrbitGuest } from './hooks/useOrbitGuest';
|
||||
import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit';
|
||||
import { useOrbitStore } from './store/orbitStore';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
|
||||
import { version } from '../package.json';
|
||||
import { useConnectionStatus } from './hooks/useConnectionStatus';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import {
|
||||
@@ -86,7 +50,6 @@ import { switchActiveServer } from './utils/switchActiveServer';
|
||||
import {
|
||||
usePlayerStore,
|
||||
getPlaybackProgressSnapshot,
|
||||
initAudioListeners,
|
||||
songToTrack,
|
||||
shuffleArray,
|
||||
flushPlayQueuePosition,
|
||||
@@ -126,12 +89,6 @@ function persistSidebarCollapsed(collapsed: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid grabbing the queue resizer when aiming at the main overlay scrollbar.
|
||||
* Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that).
|
||||
@@ -679,40 +636,7 @@ export function AppShell() {
|
||||
{perfFlags.disableMainRouteContentMount ? (
|
||||
<div style={{ minHeight: '60vh' }} />
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/tracks" element={<Tracks />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/composers" element={<Composers />} />
|
||||
<Route path="/composer/:id" element={<ComposerDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/lucky-mix" element={<LuckyMixPage />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/lossless-albums" element={<LosslessAlbums />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
<AppRoutes />
|
||||
)}
|
||||
</Suspense>
|
||||
</OverlayScrollArea>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { lazy } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import MobilePlayerView from '../components/MobilePlayerView';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
|
||||
// the entry chunk; each page is fetched when its route is first visited.
|
||||
const Home = lazy(() => import('../pages/Home'));
|
||||
const Albums = lazy(() => import('../pages/Albums'));
|
||||
const Artists = lazy(() => import('../pages/Artists'));
|
||||
const ArtistDetail = lazy(() => import('../pages/ArtistDetail'));
|
||||
const Composers = lazy(() => import('../pages/Composers'));
|
||||
const ComposerDetail = lazy(() => import('../pages/ComposerDetail'));
|
||||
const NewReleases = lazy(() => import('../pages/NewReleases'));
|
||||
const Favorites = lazy(() => import('../pages/Favorites'));
|
||||
const RandomMix = lazy(() => import('../pages/RandomMix'));
|
||||
const RandomLanding = lazy(() => import('../pages/RandomLanding'));
|
||||
const AlbumDetail = lazy(() => import('../pages/AlbumDetail'));
|
||||
const MostPlayed = lazy(() => import('../pages/MostPlayed'));
|
||||
const LosslessAlbums = lazy(() => import('../pages/LosslessAlbums'));
|
||||
const RandomAlbums = lazy(() => import('../pages/RandomAlbums'));
|
||||
const LuckyMixPage = lazy(() => import('../pages/LuckyMix'));
|
||||
const SearchResults = lazy(() => import('../pages/SearchResults'));
|
||||
const Playlists = lazy(() => import('../pages/Playlists'));
|
||||
const PlaylistDetail = lazy(() => import('../pages/PlaylistDetail'));
|
||||
const NowPlayingPage = lazy(() => import('../pages/NowPlaying'));
|
||||
const Tracks = lazy(() => import('../pages/Tracks'));
|
||||
const Settings = lazy(() => import('../pages/Settings'));
|
||||
const Statistics = lazy(() => import('../pages/Statistics'));
|
||||
const Help = lazy(() => import('../pages/Help'));
|
||||
const WhatsNew = lazy(() => import('../pages/WhatsNew'));
|
||||
const DeviceSync = lazy(() => import('../pages/DeviceSync'));
|
||||
const OfflineLibrary = lazy(() => import('../pages/OfflineLibrary'));
|
||||
const LabelAlbums = lazy(() => import('../pages/LabelAlbums'));
|
||||
const AdvancedSearch = lazy(() => import('../pages/AdvancedSearch'));
|
||||
const FolderBrowser = lazy(() => import('../pages/FolderBrowser'));
|
||||
const InternetRadio = lazy(() => import('../pages/InternetRadio'));
|
||||
const Genres = lazy(() => import('../pages/Genres'));
|
||||
const GenreDetail = lazy(() => import('../pages/GenreDetail'));
|
||||
|
||||
/**
|
||||
* The main application route table. Rendered inside `AppShell`'s scroll
|
||||
* viewport. `/now-playing` swaps to the mobile player view on narrow widths;
|
||||
* `MobilePlayerView` is intentionally not lazy because the mobile breakpoint
|
||||
* is detected synchronously and the layout swap should be flicker-free.
|
||||
*/
|
||||
export default function AppRoutes() {
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/albums" element={<Albums />} />
|
||||
<Route path="/tracks" element={<Tracks />} />
|
||||
<Route path="/random" element={<RandomLanding />} />
|
||||
<Route path="/random/albums" element={<RandomAlbums />} />
|
||||
<Route path="/album/:id" element={<AlbumDetail />} />
|
||||
<Route path="/artists" element={<Artists />} />
|
||||
<Route path="/artist/:id" element={<ArtistDetail />} />
|
||||
<Route path="/composers" element={<Composers />} />
|
||||
<Route path="/composer/:id" element={<ComposerDetail />} />
|
||||
<Route path="/new-releases" element={<NewReleases />} />
|
||||
<Route path="/favorites" element={<Favorites />} />
|
||||
<Route path="/random/mix" element={<RandomMix />} />
|
||||
<Route path="/lucky-mix" element={<LuckyMixPage />} />
|
||||
<Route path="/label/:name" element={<LabelAlbums />} />
|
||||
<Route path="/search" element={<SearchResults />} />
|
||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
<Route path="/most-played" element={<MostPlayed />} />
|
||||
<Route path="/lossless-albums" element={<LosslessAlbums />} />
|
||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/whats-new" element={<WhatsNew />} />
|
||||
<Route path="/help" element={<Help />} />
|
||||
<Route path="/offline" element={<OfflineLibrary />} />
|
||||
<Route path="/genres" element={<Genres />} />
|
||||
<Route path="/genres/:name" element={<GenreDetail />} />
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
<Route path="/device-sync" element={<DeviceSync />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -14,7 +14,8 @@ import { initAudioListeners } from '../store/playerStore';
|
||||
import { initHotCachePrefetch } from '../hotCachePrefetch';
|
||||
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
import { AppShell, RequireAuth, TauriEventBridge } from '../App';
|
||||
import { AppShell, TauriEventBridge } from '../App';
|
||||
import RequireAuth from './RequireAuth';
|
||||
|
||||
const Login = lazy(() => import('../pages/Login'));
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Route guard for the main webview. Verifies that each auth-store precondition
|
||||
* (logged-in flag, an active server id, at least one stored server) is enforced
|
||||
* independently before the children render.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
const { authState } = vi.hoisted(() => ({
|
||||
authState: {
|
||||
isLoggedIn: true,
|
||||
activeServerId: 'srv-1',
|
||||
servers: [{ id: 'srv-1', name: 'home' }],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../store/authStore', () => ({
|
||||
useAuthStore: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
Navigate: ({ to }: { to: string }) => <div data-testid="redirect" data-to={to} />,
|
||||
}));
|
||||
|
||||
import { RequireAuth } from './RequireAuth';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
authState.isLoggedIn = true;
|
||||
authState.activeServerId = 'srv-1';
|
||||
authState.servers = [{ id: 'srv-1', name: 'home' }];
|
||||
});
|
||||
|
||||
describe('RequireAuth', () => {
|
||||
it('renders children when fully authenticated', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<RequireAuth>
|
||||
<div data-testid="protected" />
|
||||
</RequireAuth>,
|
||||
);
|
||||
expect(getByTestId('protected')).toBeTruthy();
|
||||
expect(queryByTestId('redirect')).toBeNull();
|
||||
});
|
||||
|
||||
it('redirects to /login when not logged in', () => {
|
||||
authState.isLoggedIn = false;
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<RequireAuth>
|
||||
<div data-testid="protected" />
|
||||
</RequireAuth>,
|
||||
);
|
||||
expect(queryByTestId('protected')).toBeNull();
|
||||
expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login');
|
||||
});
|
||||
|
||||
it('redirects to /login when no active server id is set', () => {
|
||||
authState.activeServerId = null as unknown as string;
|
||||
const { queryByTestId, getByTestId } = render(
|
||||
<RequireAuth>
|
||||
<div data-testid="protected" />
|
||||
</RequireAuth>,
|
||||
);
|
||||
expect(queryByTestId('protected')).toBeNull();
|
||||
expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login');
|
||||
});
|
||||
|
||||
it('redirects to /login when the server list is empty', () => {
|
||||
authState.servers = [];
|
||||
const { queryByTestId, getByTestId } = render(
|
||||
<RequireAuth>
|
||||
<div data-testid="protected" />
|
||||
</RequireAuth>,
|
||||
);
|
||||
expect(queryByTestId('protected')).toBeNull();
|
||||
expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
/**
|
||||
* Route guard for everything under `/*`. Redirects to `/login` until the user
|
||||
* has at least one configured server and is marked logged-in. Kept as its own
|
||||
* file so MainApp can wrap `<AppShell />` without pulling the rest of App.tsx
|
||||
* through a re-export.
|
||||
*/
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { isLoggedIn, servers, activeServerId } = useAuthStore();
|
||||
if (!isLoggedIn || !activeServerId || servers.length === 0) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default RequireAuth;
|
||||
Reference in New Issue
Block a user