ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
import type { AuthSession, AuthUser } from '@pipu/core';
|
|
import {
|
|
createRootRouteWithContext,
|
|
createRoute,
|
|
createRouter,
|
|
Outlet,
|
|
redirect,
|
|
type ParsedLocation,
|
|
} from '@tanstack/react-router';
|
|
import { lazy } from 'react';
|
|
|
|
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
|
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
|
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
|
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
|
|
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
|
|
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
|
|
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
|
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
|
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
|
import type { DesktopHandle } from '../desktop';
|
|
import { AlbumPage } from '../pages/AlbumPage';
|
|
import { ConnectPage } from '../pages/ConnectPage';
|
|
import { TrackPage } from '../pages/TrackPage';
|
|
import { ArtistPage } from '../pages/ArtistPage';
|
|
import { CollectionPage } from '../pages/CollectionPage';
|
|
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
|
import { HomePage } from '../pages/HomePage';
|
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
|
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;
|
|
desktop: DesktopHandle | null;
|
|
}
|
|
|
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
|
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, desktop: DesktopHandle | null): AuthUser {
|
|
const user = signedInUser(session);
|
|
if (!user) {
|
|
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
|
throw redirect({ to: '/login', search: { redirect: location.href } });
|
|
}
|
|
return user;
|
|
}
|
|
|
|
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|
component: Outlet,
|
|
errorComponent: RouteError,
|
|
});
|
|
|
|
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
|
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
|
|
|
const connectRoute = createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
path: '/connect',
|
|
beforeLoad: ({ context }) => {
|
|
if (!context.desktop) throw redirect({ to: '/login' });
|
|
if (context.desktop.server) throw redirect({ to: '/login' });
|
|
},
|
|
component: ConnectPage,
|
|
});
|
|
|
|
const loginRoute = createRoute({
|
|
getParentRoute: () => rootRoute,
|
|
path: '/login',
|
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
|
beforeLoad: ({ context, search }) => {
|
|
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
|
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, context.desktop);
|
|
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, context.desktop);
|
|
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, context.desktop);
|
|
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 trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
|
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
|
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
|
|
|
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: ({ context }) => {
|
|
if (!context.user.reviews_uploads) throw redirect({ to: '/' });
|
|
},
|
|
component: UploadQueuePage,
|
|
});
|
|
|
|
const adminSettingsRoute = createRoute({
|
|
getParentRoute: () => appRoute,
|
|
path: '/admin/settings',
|
|
beforeLoad: adminOnly,
|
|
component: AdminSettingsPage,
|
|
});
|
|
|
|
const routeTree = rootRoute.addChildren([
|
|
androidAppRoute,
|
|
connectRoute,
|
|
loginRoute,
|
|
setupAdminRoute,
|
|
setupPasswordRoute,
|
|
appRoute.addChildren([
|
|
homeRoute,
|
|
trackRoute,
|
|
playlistsRoute,
|
|
playlistRoute,
|
|
settingsRoute,
|
|
searchRoute,
|
|
historyRoute,
|
|
likesRoute,
|
|
dislikesRoute,
|
|
artistRoute,
|
|
albumRoute,
|
|
usersRoute,
|
|
auditRoute,
|
|
sourcesRoute,
|
|
patternsRoute,
|
|
libraryRoute,
|
|
suggestRoute,
|
|
myUploadsRoute,
|
|
uploadQueueRoute,
|
|
adminSettingsRoute,
|
|
]),
|
|
]);
|
|
|
|
export function createAppRouter(
|
|
session: AuthSession,
|
|
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
|
) {
|
|
const { desktop = null, ...rest } = opts;
|
|
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
|
session.subscribe(() => void router.invalidate());
|
|
return router;
|
|
}
|
|
|
|
declare module '@tanstack/react-router' {
|
|
interface Register {
|
|
router: ReturnType<typeof createAppRouter>;
|
|
}
|
|
}
|