mirror of
https://github.com/idanoo/autobrr
synced 2025-07-24 17:29:12 +00:00
feat(web): move from react-router to @tanstack/router (#1338)
* fix(auth): invalid cookie handling and wrongful basic auth invalidation * fix(auth): fix test to reflect new HTTP status code * fix(auth/web): do not throw on error * fix(http): replace http codes in middleware to prevent basic auth invalidation fix typo in comment * fix test * fix(web): api client handle 403 * refactor(http): auth_test use testify.assert * refactor(http): set session opts after valid login * refactor(http): send more client headers * fix(http): test * refactor(web): move router to tanstack/router * refactor(web): use route loaders and suspense * refactor(web): useSuspense for settings * refactor(web): invalidate cookie in middleware * fix: loclfile * fix: load filter/id * fix(web): login, onboard, types, imports * fix(web): filter load * fix(web): build errors * fix(web): ts-expect-error * fix(tests): filter_test.go * fix(filters): tests * refactor: remove duplicate spinner components refactor: ReleaseTable.tsx loading animation refactor: remove dedicated `pendingComponent` for `settingsRoute` * fix: refactor missed SectionLoader to RingResizeSpinner * fix: substitute divides with borders to account for unloaded elements * fix(api): action status URL param * revert: action status URL param add comment * fix(routing): notfound handling and split files * fix(filters): notfound get params * fix(queries): colon * fix(queries): comments ts-ignore * fix(queries): extract queryKeys * fix(queries): remove err * fix(routes): move zob schema inline * fix(auth): middleware and redirect to login * fix(auth): failing test * fix(logs): invalidate correct key * fix(logs): invalidate correct key * fix(logs): invalidate correct key * fix: JSX element stealing focus from searchbar * reimplement empty release table state text * fix(context): use deep-copy * fix(releases): empty state and filter input warnings * fix(releases): empty states * fix(auth): onboarding * fix(cache): invalidate queries --------- Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
parent
cc9656cd41
commit
1a23b69bcf
64 changed files with 2543 additions and 2091 deletions
377
web/src/routes.tsx
Normal file
377
web/src/routes.tsx
Normal file
|
@ -0,0 +1,377 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import {
|
||||
createRootRouteWithContext,
|
||||
createRoute,
|
||||
createRouter,
|
||||
ErrorComponent,
|
||||
notFound,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { Actions, Advanced, External, General, MoviesTv, Music } from "@screens/filters/sections";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import { Login, Onboarding } from "@screens/auth";
|
||||
import ReleaseSettings from "@screens/settings/Releases";
|
||||
import { NotFound } from "@components/alerts/NotFound";
|
||||
import { FilterDetails, FilterNotFound, Filters } from "@screens/filters";
|
||||
import { Settings } from "@screens/Settings";
|
||||
import {
|
||||
ApikeysQueryOptions,
|
||||
ConfigQueryOptions,
|
||||
DownloadClientsQueryOptions,
|
||||
FeedsQueryOptions,
|
||||
FilterByIdQueryOptions,
|
||||
IndexersQueryOptions,
|
||||
IrcQueryOptions,
|
||||
NotificationsQueryOptions
|
||||
} from "@api/queries";
|
||||
import LogSettings from "@screens/settings/Logs";
|
||||
import NotificationSettings from "@screens/settings/Notifications";
|
||||
import ApplicationSettings from "@screens/settings/Application";
|
||||
import { Logs } from "@screens/Logs";
|
||||
import IrcSettings from "@screens/settings/Irc";
|
||||
import { Header } from "@components/header";
|
||||
import { RingResizeSpinner } from "@components/Icons";
|
||||
import APISettings from "@screens/settings/Api";
|
||||
import { Releases } from "@screens/Releases";
|
||||
import IndexerSettings from "@screens/settings/Indexer";
|
||||
import DownloadClientSettings from "@screens/settings/DownloadClient";
|
||||
import FeedSettings from "@screens/settings/Feed";
|
||||
import { Dashboard } from "@screens/Dashboard";
|
||||
import AccountSettings from "@screens/settings/Account";
|
||||
import { AuthContext, AuthCtx, localStorageUserKey, SettingsContext } from "@utils/Context";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { queryClient } from "@api/QueryClient";
|
||||
|
||||
const DashboardRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: '/',
|
||||
loader: () => {
|
||||
// https://tanstack.com/router/v1/docs/guide/deferred-data-loading#deferred-data-loading-with-defer-and-await
|
||||
// TODO load stats
|
||||
|
||||
// TODO load recent releases
|
||||
|
||||
return {}
|
||||
},
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
const FiltersRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'filters'
|
||||
});
|
||||
|
||||
const FilterIndexRoute = createRoute({
|
||||
getParentRoute: () => FiltersRoute,
|
||||
path: '/',
|
||||
component: Filters,
|
||||
});
|
||||
|
||||
export const FilterGetByIdRoute = createRoute({
|
||||
getParentRoute: () => FiltersRoute,
|
||||
path: '$filterId',
|
||||
parseParams: (params) => ({
|
||||
filterId: z.number().int().parse(Number(params.filterId)),
|
||||
}),
|
||||
stringifyParams: ({filterId}) => ({filterId: `${filterId}`}),
|
||||
loader: async ({context, params}) => {
|
||||
try {
|
||||
const filter = await context.queryClient.ensureQueryData(FilterByIdQueryOptions(params.filterId))
|
||||
return { filter }
|
||||
} catch (e) {
|
||||
throw notFound()
|
||||
}
|
||||
},
|
||||
component: FilterDetails,
|
||||
notFoundComponent: () => {
|
||||
return <FilterNotFound />
|
||||
},
|
||||
});
|
||||
|
||||
export const FilterGeneralRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: '/',
|
||||
component: General
|
||||
});
|
||||
|
||||
export const FilterMoviesTvRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'movies-tv',
|
||||
component: MoviesTv
|
||||
});
|
||||
|
||||
export const FilterMusicRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'music',
|
||||
component: Music
|
||||
});
|
||||
|
||||
export const FilterAdvancedRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'advanced',
|
||||
component: Advanced
|
||||
});
|
||||
|
||||
export const FilterExternalRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'external',
|
||||
component: External
|
||||
});
|
||||
|
||||
export const FilterActionsRoute = createRoute({
|
||||
getParentRoute: () => FilterGetByIdRoute,
|
||||
path: 'actions',
|
||||
component: Actions
|
||||
});
|
||||
|
||||
const ReleasesRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'releases'
|
||||
});
|
||||
|
||||
// type ReleasesSearch = z.infer<typeof releasesSearchSchema>
|
||||
|
||||
export const ReleasesIndexRoute = createRoute({
|
||||
getParentRoute: () => ReleasesRoute,
|
||||
path: '/',
|
||||
component: Releases,
|
||||
validateSearch: (search) => z.object({
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
filter: z.string().optional(),
|
||||
q: z.string().optional(),
|
||||
action_status: z.enum(['PUSH_APPROVED', 'PUSH_REJECTED', 'PUSH_ERROR', '']).optional(),
|
||||
// filters: z.array().catch(''),
|
||||
// sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
|
||||
}).parse(search),
|
||||
});
|
||||
|
||||
export const SettingsRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'settings',
|
||||
pendingMs: 3000,
|
||||
component: Settings
|
||||
});
|
||||
|
||||
export const SettingsIndexRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: '/',
|
||||
component: ApplicationSettings
|
||||
});
|
||||
|
||||
export const SettingsLogRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'logs',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(ConfigQueryOptions()),
|
||||
component: LogSettings
|
||||
});
|
||||
|
||||
export const SettingsIndexersRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'indexers',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(IndexersQueryOptions()),
|
||||
component: IndexerSettings
|
||||
});
|
||||
|
||||
export const SettingsIrcRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'irc',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(IrcQueryOptions()),
|
||||
component: IrcSettings
|
||||
});
|
||||
|
||||
export const SettingsFeedsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'feeds',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(FeedsQueryOptions()),
|
||||
component: FeedSettings
|
||||
});
|
||||
|
||||
export const SettingsClientsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'clients',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(DownloadClientsQueryOptions()),
|
||||
component: DownloadClientSettings
|
||||
});
|
||||
|
||||
export const SettingsNotificationsRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'notifications',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(NotificationsQueryOptions()),
|
||||
component: NotificationSettings
|
||||
});
|
||||
|
||||
export const SettingsApiRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'api',
|
||||
loader: (opts) => opts.context.queryClient.ensureQueryData(ApikeysQueryOptions()),
|
||||
component: APISettings
|
||||
});
|
||||
|
||||
export const SettingsReleasesRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'releases',
|
||||
component: ReleaseSettings
|
||||
});
|
||||
|
||||
export const SettingsAccountRoute = createRoute({
|
||||
getParentRoute: () => SettingsRoute,
|
||||
path: 'account',
|
||||
component: AccountSettings
|
||||
});
|
||||
|
||||
export const LogsRoute = createRoute({
|
||||
getParentRoute: () => AuthIndexRoute,
|
||||
path: 'logs',
|
||||
component: Logs
|
||||
});
|
||||
|
||||
export const OnboardRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
path: 'onboard',
|
||||
beforeLoad: async () => {
|
||||
// Check if onboarding is available for this instance
|
||||
// and redirect if needed
|
||||
try {
|
||||
await APIClient.auth.canOnboard()
|
||||
} catch (e) {
|
||||
console.error("onboarding not available, redirect to login")
|
||||
|
||||
throw redirect({
|
||||
to: LoginRoute.to,
|
||||
})
|
||||
}
|
||||
},
|
||||
component: Onboarding
|
||||
});
|
||||
|
||||
export const LoginRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
path: 'login',
|
||||
validateSearch: z.object({
|
||||
redirect: z.string().optional(),
|
||||
}),
|
||||
beforeLoad: ({ navigate}) => {
|
||||
// handle canOnboard
|
||||
APIClient.auth.canOnboard().then(() => {
|
||||
console.info("onboarding available, redirecting")
|
||||
|
||||
navigate({ to: OnboardRoute.to })
|
||||
}).catch(() => {
|
||||
console.info("onboarding not available, please login")
|
||||
})
|
||||
},
|
||||
}).update({component: Login});
|
||||
|
||||
export const AuthRoute = createRoute({
|
||||
getParentRoute: () => RootRoute,
|
||||
id: 'auth',
|
||||
// Before loading, authenticate the user via our auth context
|
||||
// This will also happen during prefetching (e.g. hovering over links, etc.)
|
||||
beforeLoad: ({context, location}) => {
|
||||
// If the user is not logged in, check for item in localStorage
|
||||
if (!context.auth.isLoggedIn) {
|
||||
const storage = localStorage.getItem(localStorageUserKey);
|
||||
if (storage) {
|
||||
try {
|
||||
const json = JSON.parse(storage);
|
||||
if (json === null) {
|
||||
console.warn(`JSON localStorage value for '${localStorageUserKey}' context state is null`);
|
||||
} else {
|
||||
context.auth.isLoggedIn = json.isLoggedIn
|
||||
context.auth.username = json.username
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`auth Failed to merge ${localStorageUserKey} context state: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// If the user is logged out, redirect them to the login page
|
||||
throw redirect({
|
||||
to: LoginRoute.to,
|
||||
search: {
|
||||
// Use the current location to power a redirect after login
|
||||
// (Do not use `router.state.resolvedLocation` as it can
|
||||
// potentially lag behind the actual current location)
|
||||
redirect: location.href,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return the user in context
|
||||
return {
|
||||
username: AuthContext.username,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function AuthenticatedLayout() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header/>
|
||||
<Outlet/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthIndexRoute = createRoute({
|
||||
getParentRoute: () => AuthRoute,
|
||||
component: AuthenticatedLayout,
|
||||
id: 'authenticated-routes',
|
||||
});
|
||||
|
||||
export const RootComponent = () => {
|
||||
const settings = SettingsContext.useValue();
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Outlet/>
|
||||
{settings.debug ? (
|
||||
<>
|
||||
<TanStackRouterDevtools/>
|
||||
<ReactQueryDevtools initialIsOpen={false}/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const RootRoute = createRootRouteWithContext<{
|
||||
auth: AuthCtx,
|
||||
queryClient: QueryClient
|
||||
}>()({
|
||||
component: RootComponent,
|
||||
notFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
|
||||
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsReleasesRoute, SettingsAccountRoute])
|
||||
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute.addChildren([ReleasesIndexRoute]), settingsRouteTree, LogsRoute])])
|
||||
const routeTree = RootRoute.addChildren([
|
||||
authenticatedTree,
|
||||
LoginRoute,
|
||||
OnboardRoute
|
||||
]);
|
||||
|
||||
export const Router = createRouter({
|
||||
routeTree,
|
||||
defaultPendingComponent: () => (
|
||||
<div className="absolute top-1/4 left-1/2 !border-0">
|
||||
<RingResizeSpinner className="text-blue-500 size-24"/>
|
||||
</div>
|
||||
),
|
||||
defaultErrorComponent: ({error}) => <ErrorComponent error={error}/>,
|
||||
context: {
|
||||
auth: undefined!, // We'll inject this when we render
|
||||
queryClient
|
||||
},
|
||||
});
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue