Files
pipu-music/apps/web/src/app/router.tsx
T
mrleo1nidandClaude Opus 5.5 a3f212d253
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m4s
ci / ts / ts (packages + web) (push) Successful in 1m23s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m51s
feat(web): suggest tracks, my uploads and admin upload queue screens (M5.5)
- Suggest tracks: drag-and-drop of files and folders with relative
  paths, streaming SHA-256 in a Web Worker (hash-wasm), duplicate
  precheck, batch reservation and tus-js-client upload (8 MB chunks,
  4 in parallel, token refresh on 401, resume of failed files).
- My uploads: statuses, rejection reasons, withdraw, quota meter.
- Upload queue (admin): status tabs, possible-duplicate filter, batches,
  metadata and storage editing, bulk approve / reject with a reason,
  import retry, and the queue settings form.
- Sidebar section for moderators and admins; ru / en strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:46:18 +03:00

263 lines
8.4 KiB
TypeScript

import type { AuthSession, AuthUser } from '@pipu/core';
import {
createRootRouteWithContext,
createRoute,
createRouter,
Outlet,
redirect,
type ParsedLocation,
} from '@tanstack/react-router';
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
import { SuggestPage } from '../pages/uploads/SuggestPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
export interface RouterContext {
session: AuthSession;
}
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
export function homeFor(user: AuthUser): '/setup/admin' | '/setup/password' | '/' {
if (user.must_change_credentials) return '/setup/admin';
if (user.must_change_password) return '/setup/password';
return '/';
}
function signedInUser(session: AuthSession): AuthUser | null {
return session.state.status === 'signed_in' ? session.state.user : null;
}
/** Внутренний путь из ?redirect=; внешние адреса игнорируются. */
export function safeRedirect(target: unknown): string {
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
const user = signedInUser(session);
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
return user;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: Outlet,
errorComponent: RouteError,
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
beforeLoad: ({ context, search }) => {
const user = signedInUser(context.session);
if (!user) return;
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
throw redirect({ href: safeRedirect(search.redirect) });
},
component: LoginPage,
});
const setupAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/admin',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="admin" />,
});
const setupPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/password',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="password" />,
});
/** Каркас приложения: только для вошедших, прошедших обязательные шаги. */
const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
return { user };
},
component: AppShell,
});
const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
if (context.user.role !== 'admin') throw redirect({ to: '/' });
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
const usersRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/users',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: UsersPage,
});
const auditRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/audit',
beforeLoad: adminOnly,
component: AuditPage,
});
const historyRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/history',
component: () => <CollectionPage tab="history" />,
});
const likesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/likes',
component: () => <CollectionPage tab="likes" />,
});
const dislikesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/dislikes',
component: () => <CollectionPage tab="dislikes" />,
});
const searchRoute = createRoute({
getParentRoute: () => appRoute,
path: '/search',
validateSearch: (search: Record<string, unknown>): { q: string; type?: Exclude<SearchType, 'all'> } => {
const type = search.type;
return {
q: typeof search.q === 'string' ? search.q : '',
...(type === 'artists' || type === 'albums' || type === 'tracks' ? { type } : {}),
};
},
component: function SearchRoute() {
const { q, type } = searchRoute.useSearch();
return <SearchPage q={q} type={type ?? 'all'} />;
},
});
const artistRoute = createRoute({
getParentRoute: () => appRoute,
path: '/artist/$id',
component: function ArtistRoute() {
const { id } = artistRoute.useParams();
return <ArtistPage key={id} id={id} />;
},
});
const albumRoute = createRoute({
getParentRoute: () => appRoute,
path: '/album/$id',
component: function AlbumRoute() {
const { id } = albumRoute.useParams();
return <AlbumPage key={id} id={id} />;
},
});
const sourcesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources',
beforeLoad: adminOnly,
component: SourcesPage,
});
const patternsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources/$id/patterns',
beforeLoad: adminOnly,
component: function PatternsRoute() {
const { id } = patternsRoute.useParams();
return <PatternsPage id={id} />;
},
});
const libraryRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/library',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: LibraryPage,
});
/** Предложение треков — модераторы и админ (docs/uploads.md#1). */
const staffOnly = ({ context }: { context: { user: AuthUser } }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
};
const suggestRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/new', beforeLoad: staffOnly, component: SuggestPage });
const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/uploads/mine', beforeLoad: staffOnly, component: MyUploadsPage });
const uploadQueueRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/uploads',
beforeLoad: adminOnly,
component: UploadQueuePage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
settingsRoute,
searchRoute,
historyRoute,
likesRoute,
dislikesRoute,
artistRoute,
albumRoute,
usersRoute,
auditRoute,
sourcesRoute,
patternsRoute,
libraryRoute,
suggestRoute,
myUploadsRoute,
uploadQueueRoute,
]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
session.subscribe(() => void router.invalidate());
return router;
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}