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
- 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>
263 lines
8.4 KiB
TypeScript
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>;
|
|
}
|
|
}
|