refactor: refactor webui

This commit is contained in:
2025-04-26 01:43:23 +08:00
parent b20f7cd1ad
commit ee1b1ae5e6
104 changed files with 934 additions and 827 deletions

View File

@@ -0,0 +1,6 @@
import { AppNotFoundComponent } from '@/views/components/layout/app-not-found';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/404')({
component: AppNotFoundComponent,
});

View File

@@ -0,0 +1,15 @@
import type {
RouteStateDataOption,
RouterContext,
} from '@/infra/routes/traits';
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import { Home } from 'lucide-react';
export const Route = createRootRouteWithContext<RouterContext>()({
component: Outlet,
staticData: {
breadcrumb: {
icon: Home,
},
} satisfies RouteStateDataOption,
});

View File

@@ -0,0 +1,15 @@
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/_explore/explore')({
component: ExploreRouteComponent,
staticData: {
breadcrumb: {
label: 'Explore',
},
} satisfies RouteStateDataOption,
});
function ExploreRouteComponent() {
return <div>Hello "/_app/explore"!</div>;
}

View File

@@ -0,0 +1,15 @@
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/_explore/feed')({
component: FeedRouteComponent,
staticData: {
breadcrumb: {
label: 'Feed',
},
} satisfies RouteStateDataOption,
});
function FeedRouteComponent() {
return <div>Hello "/_app/feed"!</div>;
}

View File

@@ -0,0 +1,35 @@
import { useAuth } from '@/app/auth/hooks';
import { type Fetcher, createGraphiQLFetcher } from '@graphiql/toolkit';
import { createLazyFileRoute } from '@tanstack/react-router';
import { GraphiQL } from 'graphiql';
import { useCallback } from 'react';
import 'graphiql/graphiql.css';
import { firstValueFrom } from 'rxjs';
export const Route = createLazyFileRoute('/_app/playground/graphql-api')({
component: PlaygroundGraphQLApiRouteComponent,
});
function PlaygroundGraphQLApiRouteComponent() {
const { authProvider } = useAuth();
const fetcher: Fetcher = useCallback(
async (props) => {
const authHeaders = await firstValueFrom(authProvider.getAuthHeaders());
return createGraphiQLFetcher({
url: '/api/graphql',
headers: authHeaders,
})(props);
},
[authProvider]
);
return (
<div
data-id="graphiql-playground-container"
className="h-full overflow-hidden rounded-lg"
>
<GraphiQL fetcher={fetcher} />
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { buildLeafRouteStaticData } from '@/infra/routes/utils';
import { AppSkeleton } from '@/views/components/layout/app-skeleton';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/playground/graphql-api')({
staticData: buildLeafRouteStaticData({ title: 'GraphQL Api' }),
pendingComponent: AppSkeleton,
});

View File

@@ -0,0 +1,8 @@
import { buildVirtualBranchRouteOptions } from '@/infra/routes/utils';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/playground')(
buildVirtualBranchRouteOptions({
title: 'Playground',
})
);

View File

@@ -0,0 +1,16 @@
import { beforeLoadGuard } from '@/app/auth/guard';
import { AppAside } from '@/views/components/layout/app-layout';
import { Outlet, createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app')({
component: AppLayoutRoute,
beforeLoad: beforeLoadGuard,
});
function AppLayoutRoute() {
return (
<AppAside extractBreadcrumbFromRoutes>
<Outlet />
</AppAside>
);
}

View File

@@ -0,0 +1,13 @@
import { buildLeafRouteStaticData } from '@/infra/routes/utils';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/settings/downloader')({
component: SettingsDownloaderRouteComponent,
staticData: buildLeafRouteStaticData({
title: 'Downloader',
}),
});
function SettingsDownloaderRouteComponent() {
return <div>Hello "/_app/settings/downloader"!</div>;
}

View File

@@ -0,0 +1,8 @@
import { buildVirtualBranchRouteOptions } from '@/infra/routes/utils';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/settings')(
buildVirtualBranchRouteOptions({
title: 'Settings',
})
);

View File

@@ -0,0 +1,234 @@
import { useAuth } from '@/app/auth/hooks';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { Button } from '@/views/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/views/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/views/components/ui/form';
import { Input } from '@/views/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/views/components/ui/select';
import { Switch } from '@/views/components/ui/switch';
import { gql, useMutation } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
export const Route = createFileRoute('/_app/subscriptions/create')({
component: SubscriptionCreateRouteComponent,
staticData: {
breadcrumb: { label: 'Create' },
} satisfies RouteStateDataOption,
});
type SubscriptionFormValues = {
displayName: string;
sourceUrl: string;
category: string;
enabled: boolean;
};
const CREATE_SUBSCRIPTION_MUTATION = gql`
mutation CreateSubscription($input: SubscriptionsInsertInput!) {
subscriptionsCreateOne(data: $input) {
id
displayName
sourceUrl
enabled
category
}
}
`;
function SubscriptionCreateRouteComponent() {
const { userData } = useAuth();
console.log(JSON.stringify(userData, null, 2));
const [isSubmitting, setIsSubmitting] = useState(false);
const navigate = useNavigate();
const form = useForm<SubscriptionFormValues>({
defaultValues: {
displayName: '',
sourceUrl: '',
category: 'Mikan',
enabled: true,
},
});
const [createSubscription] = useMutation(CREATE_SUBSCRIPTION_MUTATION);
const onSubmit = async (data: SubscriptionFormValues) => {
try {
setIsSubmitting(true);
const response = await createSubscription({
variables: {
input: {
category: data.category,
displayName: data.displayName,
sourceUrl: data.sourceUrl,
enabled: data.enabled,
},
},
});
if (response.errors) {
throw new Error(
response.errors[0]?.message || 'Failed to create subscription'
);
}
toast.success('Subscription created successfully');
navigate({ to: '/subscriptions/manage' });
} catch (error) {
console.error('Failed to create subscription:', error);
toast.error(
`Subscription creation failed: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
} finally {
setIsSubmitting(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Create Bangumi Subscription</CardTitle>
<CardDescription>Add a new bangumi subscription source</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Source Type</FormLabel>
<Select
disabled
value={field.value}
onValueChange={field.onChange}
defaultValue="Mikan"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select source type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Mikan">Mikan</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Currently only Mikan source is supported
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input
placeholder="Enter subscription display name"
{...field}
/>
</FormControl>
<FormDescription>
Set an easily recognizable name for this subscription
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sourceUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Source URL</FormLabel>
<FormControl>
<Input
placeholder="Enter subscription source URL"
{...field}
/>
</FormControl>
<FormDescription>
Copy the RSS subscription link from the source website, e.g.
https://mikanani.me/RSS/Bangumi?bangumiId=3141&subgroupid=370
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable Subscription
</FormLabel>
<FormDescription>
Enable this subscription immediately after creation
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
<CardFooter className="flex justify-between">
<Button
variant="outline"
onClick={() => navigate({ to: '/subscriptions/manage' })}
>
Cancel
</Button>
<Button
type="submit"
onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting}
>
{isSubmitting ? 'Creating...' : 'Create Subscription'}
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,15 @@
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute(
'/_app/subscriptions/edit/$subscription-id'
)({
component: RouteComponent,
staticData: {
breadcrumb: { label: 'Edit' },
} satisfies RouteStateDataOption,
});
function RouteComponent() {
return <div>Hello "/subscriptions/edit/$subscription-id"!</div>;
}

View File

@@ -0,0 +1,13 @@
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/subscriptions/manage')({
component: SubscriptionManage,
staticData: {
breadcrumb: { label: 'Manage' },
} satisfies RouteStateDataOption,
});
function SubscriptionManage() {
return <div>Hello "/subscriptions/manage"!</div>;
}

View File

@@ -0,0 +1,8 @@
import { buildVirtualBranchRouteOptions } from '@/infra/routes/utils';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/subscriptions')(
buildVirtualBranchRouteOptions({
title: 'Subscriptions',
})
);

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/about')({
component: About,
});
function About() {
return <div class="p-2">Hello from About!</div>;
}

View File

@@ -0,0 +1,94 @@
import { authContextFromInjector } from '@/app/auth/context';
import { useAuth } from '@/app/auth/hooks';
import { AUTH_METHOD } from '@/infra/auth/defs';
import { ProLink } from '@/views/components/ui/pro-link';
import { Spinner } from '@/views/components/ui/spinner';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { useAtom } from 'jotai/react';
import { atomWithObservable } from 'jotai/utils';
import { EventTypes } from 'oidc-client-rx';
import { useMemo } from 'react';
import { filter, map } from 'rxjs';
export const Route = createFileRoute('/auth/oidc/callback')({
component: OidcCallbackRouteComponent,
beforeLoad: ({ context }) => {
const { authProvider } = authContextFromInjector(context.injector);
if (authProvider.authMethod !== AUTH_METHOD.OIDC) {
throw redirect({ to: '/' });
}
},
});
function OidcCallbackRouteComponent() {
const { authService } = useAuth();
const isLoading = useAtom(
useMemo(
() =>
atomWithObservable(() =>
authService.checkAuthResultEvent$.pipe(map(Boolean))
),
[authService.checkAuthResultEvent$]
)
);
const checkAuthResultError = useAtom(
useMemo(
() =>
atomWithObservable(() =>
authService.checkAuthResultEvent$.pipe(
filter(
(
e
): e is {
type: EventTypes.CheckingAuthFinishedWithError;
value: string;
} => e.type === EventTypes.CheckingAuthFinishedWithError
),
map((e) => e.value)
)
),
[authService.checkAuthResultEvent$]
)
);
const renderBackToHome = () => {
return (
<ProLink
to="/"
className="inline-flex h-10 items-center rounded-md border border-gray-200 bg-white px-8 font-medium text-sm shadow-sm transition-colors hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:focus-visible:ring-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-50"
>
Return to website
</ProLink>
);
};
return (
<div className="flex h-svh items-center px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
<div className="w-full space-y-6 text-center">
<div className="space-y-6">
<div className="flex justify-center font-bold text-4xl tracking-tighter sm:text-5xl">
<Spinner variant="circle-filled" size="48" />
</div>
{isLoading && (
<p className="text-gray-500">
Processing OIDC authentication callback...
</p>
)}
{checkAuthResultError && (
<p className="text-gray-500">
Failed to handle OIDC callback: {checkAuthResultError}
</p>
)}
{!isLoading && !checkAuthResultError && (
<p className="text-gray-500">
Succeed to handle OIDC authentication callback.
</p>
)}
{renderBackToHome()}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/auth/sign-in')({
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello "/auth/sign-in"!</div>;
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/auth/sign-up')({
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello "/auth/sign-up"!</div>;
}

View File

@@ -0,0 +1,15 @@
import { AppAside } from '@/views/components/layout/app-layout';
import { AppSkeleton } from '@/views/components/layout/app-skeleton';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
component: HomeRouteComponent,
});
function HomeRouteComponent() {
return (
<AppAside extractBreadcrumbFromRoutes>
<AppSkeleton />
</AppAside>
);
}