feature: add mgraphql codegen
This commit is contained in:
parent
0300d7baf6
commit
9fdb778330
@ -9,7 +9,7 @@ use axum::{
|
||||
|
||||
use crate::{app::AppContextTrait, auth::AuthServiceTrait};
|
||||
|
||||
pub async fn header_www_authenticate_middleware(
|
||||
pub async fn auth_middleware(
|
||||
State(ctx): State<Arc<dyn AppContextTrait>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
|
@ -7,5 +7,5 @@ pub mod service;
|
||||
|
||||
pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig};
|
||||
pub use errors::AuthError;
|
||||
pub use middleware::header_www_authenticate_middleware;
|
||||
pub use middleware::auth_middleware;
|
||||
pub use service::{AuthService, AuthServiceTrait, AuthUserInfo};
|
||||
|
@ -5,8 +5,8 @@ use axum::{Extension, Router, extract::State, middleware::from_fn_with_state, ro
|
||||
|
||||
use super::core::Controller;
|
||||
use crate::{
|
||||
app::AppContextTrait,
|
||||
auth::{AuthUserInfo, header_www_authenticate_middleware},
|
||||
app::{AppContextTrait, Environment},
|
||||
auth::{AuthUserInfo, auth_middleware},
|
||||
errors::RecorderResult,
|
||||
};
|
||||
|
||||
@ -25,9 +25,51 @@ async fn graphql_handler(
|
||||
graphql_service.schema.execute(req).await.into()
|
||||
}
|
||||
|
||||
// 检查是否是 introspection 查询
|
||||
fn is_introspection_query(req: &async_graphql::Request) -> bool {
|
||||
if let Some(operation) = &req.operation_name {
|
||||
if operation.starts_with("__") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查查询内容是否包含 introspection 字段
|
||||
let query = req.query.as_str();
|
||||
query.contains("__schema") || query.contains("__type") || query.contains("__typename")
|
||||
}
|
||||
|
||||
async fn graphql_introspection_handler(
|
||||
State(ctx): State<Arc<dyn AppContextTrait>>,
|
||||
req: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
let graphql_service = ctx.graphql();
|
||||
let req = req.into_inner();
|
||||
|
||||
if !is_introspection_query(&req) {
|
||||
return GraphQLResponse::from(async_graphql::Response::from_errors(vec![
|
||||
async_graphql::ServerError::new(
|
||||
"Only introspection queries are allowed on this endpoint",
|
||||
None,
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
graphql_service.schema.execute(req).await.into()
|
||||
}
|
||||
|
||||
pub async fn create(ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller> {
|
||||
let mut introspection_handler = post(graphql_introspection_handler);
|
||||
|
||||
if !matches!(ctx.environment(), Environment::Development) {
|
||||
introspection_handler =
|
||||
introspection_handler.layer(from_fn_with_state(ctx.clone(), auth_middleware));
|
||||
}
|
||||
|
||||
let router = Router::<Arc<dyn AppContextTrait>>::new()
|
||||
.route("/", post(graphql_handler))
|
||||
.layer(from_fn_with_state(ctx, header_www_authenticate_middleware));
|
||||
.route(
|
||||
"/",
|
||||
post(graphql_handler).layer(from_fn_with_state(ctx, auth_middleware)),
|
||||
)
|
||||
.route("/introspection", introspection_handler);
|
||||
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router))
|
||||
}
|
||||
|
17
apps/webui/graphql-codegen.ts
Normal file
17
apps/webui/graphql-codegen.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli';
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: 'http://127.0.0.1:5001/api/graphql/introspection',
|
||||
documents: ['src/**/*.{ts,tsx}'],
|
||||
generates: {
|
||||
'./src/infra/graphql/gql/': {
|
||||
plugins: [],
|
||||
preset: 'client',
|
||||
presetConfig: {
|
||||
gqlTagName: 'gql',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"dev": "rsbuild dev",
|
||||
"preview": "rsbuild preview"
|
||||
"preview": "rsbuild preview",
|
||||
"codegen": "graphql-codegen --config graphql-codegen.ts",
|
||||
"codegen-watch": "graphql-codegen --config graphql-codegen.ts --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abraham/reflection": "^0.13.0",
|
||||
@ -45,6 +47,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.6",
|
||||
"@rsbuild/plugin-react": "^1.2.0",
|
||||
"@tanstack/react-query": "^5.75.6",
|
||||
"@tanstack/react-router": "^1.112.13",
|
||||
"@tanstack/router-devtools": "^1.112.13",
|
||||
"arktype": "^2.1.6",
|
||||
@ -59,11 +62,10 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"jotai": "^2.12.3",
|
||||
"jotai-signal": "^0.9.0",
|
||||
"lucide-react": "^0.508.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"lucide-react": "^0.509.0",
|
||||
"oidc-client-rx": "0.1.0-alpha.9",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-day-picker": "9.6.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-resizable-panels": "^3.0.1",
|
||||
@ -78,6 +80,10 @@
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^5.0.6",
|
||||
"@graphql-codegen/client-preset": "^4.8.1",
|
||||
"@graphql-codegen/typescript": "^4.1.6",
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"@rsbuild/core": "^1.2.15",
|
||||
"@tailwindcss/postcss": "^4.0.9",
|
||||
"@tanstack/react-router": "^1.112.0",
|
||||
|
110
apps/webui/src/infra/graphql/gql/fragment-masking.ts
Normal file
110
apps/webui/src/infra/graphql/gql/fragment-masking.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/* eslint-disable */
|
||||
import type {
|
||||
DocumentTypeDecoration,
|
||||
ResultOf,
|
||||
TypedDocumentNode,
|
||||
} from '@graphql-typed-document-node/core';
|
||||
import type { FragmentDefinitionNode } from 'graphql';
|
||||
import type { Incremental } from './graphql';
|
||||
|
||||
export type FragmentType<
|
||||
TDocumentType extends DocumentTypeDecoration<any, any>,
|
||||
> = TDocumentType extends DocumentTypeDecoration<infer TType, any>
|
||||
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
|
||||
? TKey extends string
|
||||
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
// return non-nullable if `fragmentType` is non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
|
||||
): TType;
|
||||
// return nullable if `fragmentType` is undefined
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
|
||||
): TType | undefined;
|
||||
// return nullable if `fragmentType` is nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
|
||||
): TType | null;
|
||||
// return nullable if `fragmentType` is nullable or undefined
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| FragmentType<DocumentTypeDecoration<TType, any>>
|
||||
| null
|
||||
| undefined
|
||||
): TType | null | undefined;
|
||||
// return array of non-nullable if `fragmentType` is array of non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
): Array<TType>;
|
||||
// return array of nullable if `fragmentType` is array of nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| Array<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| null
|
||||
| undefined
|
||||
): Array<TType> | null | undefined;
|
||||
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
): ReadonlyArray<TType>;
|
||||
// return readonly array of nullable if `fragmentType` is array of nullable
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| null
|
||||
| undefined
|
||||
): ReadonlyArray<TType> | null | undefined;
|
||||
export function useFragment<TType>(
|
||||
_documentNode: DocumentTypeDecoration<TType, any>,
|
||||
fragmentType:
|
||||
| FragmentType<DocumentTypeDecoration<TType, any>>
|
||||
| Array<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
|
||||
| null
|
||||
| undefined
|
||||
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
|
||||
return fragmentType as any;
|
||||
}
|
||||
|
||||
export function makeFragmentData<
|
||||
F extends DocumentTypeDecoration<any, any>,
|
||||
FT extends ResultOf<F>,
|
||||
>(data: FT, _fragment: F): FragmentType<F> {
|
||||
return data as FragmentType<F>;
|
||||
}
|
||||
export function isFragmentReady<TQuery, TFrag>(
|
||||
queryNode: DocumentTypeDecoration<TQuery, any>,
|
||||
fragmentNode: TypedDocumentNode<TFrag>,
|
||||
data:
|
||||
| FragmentType<TypedDocumentNode<Incremental<TFrag>, any>>
|
||||
| null
|
||||
| undefined
|
||||
): data is FragmentType<typeof fragmentNode> {
|
||||
const deferredFields = (
|
||||
queryNode as {
|
||||
__meta__?: { deferredFields: Record<string, (keyof TFrag)[]> };
|
||||
}
|
||||
).__meta__?.deferredFields;
|
||||
|
||||
if (!deferredFields) return true;
|
||||
|
||||
const fragDef = fragmentNode.definitions[0] as
|
||||
| FragmentDefinitionNode
|
||||
| undefined;
|
||||
const fragName = fragDef?.name?.value;
|
||||
|
||||
const fields = (fragName && deferredFields[fragName]) || [];
|
||||
return fields.length > 0 && fields.every((field) => data && field in data);
|
||||
}
|
59
apps/webui/src/infra/graphql/gql/gql.ts
Normal file
59
apps/webui/src/infra/graphql/gql/gql.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
/* eslint-disable */
|
||||
import * as types from './graphql';
|
||||
|
||||
/**
|
||||
* Map of all GraphQL operations in the project.
|
||||
*
|
||||
* This map has several performance disadvantages:
|
||||
* 1. It is not tree-shakeable, so it will include all operations in the project.
|
||||
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
|
||||
* 3. It does not support dead code elimination, so it will add unused operations.
|
||||
*
|
||||
* Therefore it is highly recommended to use the babel or swc plugin for production.
|
||||
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
|
||||
*/
|
||||
type Documents = {
|
||||
'\n mutation CreateSubscription($input: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $input) {\n id\n displayName\n sourceUrl\n enabled\n category\n subscriberId\n }\n }\n': typeof types.CreateSubscriptionDocument;
|
||||
'\n query GetSubscriptions($page: Int!, $pageSize: Int!) {\n subscriptions(\n pagination: {\n page: {\n page: $page,\n limit: $pageSize\n }\n }\n ) {\n nodes {\n id\n displayName\n category\n enabled\n bangumi {\n nodes {\n id\n displayName\n posterLink\n season\n fansub\n homepage\n }\n }\n }\n }\n }\n': typeof types.GetSubscriptionsDocument;
|
||||
};
|
||||
const documents: Documents = {
|
||||
'\n mutation CreateSubscription($input: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $input) {\n id\n displayName\n sourceUrl\n enabled\n category\n subscriberId\n }\n }\n':
|
||||
types.CreateSubscriptionDocument,
|
||||
'\n query GetSubscriptions($page: Int!, $pageSize: Int!) {\n subscriptions(\n pagination: {\n page: {\n page: $page,\n limit: $pageSize\n }\n }\n ) {\n nodes {\n id\n displayName\n category\n enabled\n bangumi {\n nodes {\n id\n displayName\n posterLink\n season\n fansub\n homepage\n }\n }\n }\n }\n }\n':
|
||||
types.GetSubscriptionsDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = gql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
|
||||
* ```
|
||||
*
|
||||
* The query argument is unknown!
|
||||
* Please regenerate the types.
|
||||
*/
|
||||
export function gql(source: string): unknown;
|
||||
|
||||
/**
|
||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function gql(
|
||||
source: '\n mutation CreateSubscription($input: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $input) {\n id\n displayName\n sourceUrl\n enabled\n category\n subscriberId\n }\n }\n'
|
||||
): (typeof documents)['\n mutation CreateSubscription($input: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $input) {\n id\n displayName\n sourceUrl\n enabled\n category\n subscriberId\n }\n }\n'];
|
||||
/**
|
||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function gql(
|
||||
source: '\n query GetSubscriptions($page: Int!, $pageSize: Int!) {\n subscriptions(\n pagination: {\n page: {\n page: $page,\n limit: $pageSize\n }\n }\n ) {\n nodes {\n id\n displayName\n category\n enabled\n bangumi {\n nodes {\n id\n displayName\n posterLink\n season\n fansub\n homepage\n }\n }\n }\n }\n }\n'
|
||||
): (typeof documents)['\n query GetSubscriptions($page: Int!, $pageSize: Int!) {\n subscriptions(\n pagination: {\n page: {\n page: $page,\n limit: $pageSize\n }\n }\n ) {\n nodes {\n id\n displayName\n category\n enabled\n bangumi {\n nodes {\n id\n displayName\n posterLink\n season\n fansub\n homepage\n }\n }\n }\n }\n }\n'];
|
||||
|
||||
export function gql(source: string) {
|
||||
return (documents as any)[source] ?? {};
|
||||
}
|
||||
|
||||
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> =
|
||||
TDocumentNode extends DocumentNode<infer TType, any> ? TType : never;
|
1538
apps/webui/src/infra/graphql/gql/graphql.ts
Normal file
1538
apps/webui/src/infra/graphql/gql/graphql.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
apps/webui/src/infra/graphql/gql/index.ts
Normal file
2
apps/webui/src/infra/graphql/gql/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './fragment-masking';
|
||||
export * from './gql';
|
@ -1,3 +1,7 @@
|
||||
import type { Injector } from '@outposts/injection-js';
|
||||
import { atomWithObservable } from 'jotai/utils';
|
||||
import { useInjector } from 'oidc-client-rx/adapters/react';
|
||||
import { useMemo } from 'react';
|
||||
import { ThemeService } from './theme.service';
|
||||
|
||||
export function provideStyles() {
|
||||
@ -8,3 +12,37 @@ export function provideStyles() {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function themeContextFromInjector(injector: Injector) {
|
||||
const themeService = injector.get(ThemeService);
|
||||
const systemColorSchema$ = atomWithObservable(
|
||||
() => themeService.systemColorSchema$
|
||||
);
|
||||
return {
|
||||
themeService,
|
||||
systemColorSchema$,
|
||||
};
|
||||
}
|
||||
|
||||
export function setupThemeContext(injector: Injector) {
|
||||
const { themeService } = themeContextFromInjector(injector);
|
||||
themeService.setup();
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const injector = useInjector();
|
||||
|
||||
const { themeService } = useMemo(() => {
|
||||
return themeContextFromInjector(injector);
|
||||
}, [injector]);
|
||||
|
||||
const colorTheme = useMemo(
|
||||
() => atomWithObservable(() => themeService.colorSchema$),
|
||||
[themeService]
|
||||
);
|
||||
|
||||
return {
|
||||
themeService,
|
||||
colorTheme,
|
||||
};
|
||||
}
|
||||
|
@ -1,7 +1,17 @@
|
||||
import { DOCUMENT } from '@/infra/platform/injection';
|
||||
import { LocalStorageService } from '@/infra/storage/web-storage.service';
|
||||
import { Injectable, inject } from '@outposts/injection-js';
|
||||
|
||||
import {
|
||||
BehaviorSubject,
|
||||
ReplaySubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
fromEvent,
|
||||
map,
|
||||
shareReplay,
|
||||
startWith,
|
||||
} from 'rxjs';
|
||||
export type PreferColorSchemaType = 'dark' | 'light' | 'system';
|
||||
export type PreferColorSchemaClass = 'dark' | 'light';
|
||||
|
||||
@ -9,6 +19,71 @@ export type PreferColorSchemaClass = 'dark' | 'light';
|
||||
export class ThemeService {
|
||||
document = inject(DOCUMENT);
|
||||
localStorage = inject(LocalStorageService);
|
||||
systemColorSchema$ = new BehaviorSubject(this.systemColorSchema);
|
||||
storageColorSchema$ = new BehaviorSubject(
|
||||
this.getColorSchemaType(this.localStorage.getItem('prefers-color-scheme'))
|
||||
);
|
||||
colorSchema$ = new BehaviorSubject(
|
||||
this.getColorSchemaByType(
|
||||
this.storageColorSchema$.value,
|
||||
this.systemColorSchema$.value
|
||||
)
|
||||
);
|
||||
|
||||
setup() {
|
||||
const mediaQuery = this.document.defaultView?.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
);
|
||||
|
||||
if (mediaQuery) {
|
||||
fromEvent(mediaQuery, 'change')
|
||||
.pipe(
|
||||
map(() => (mediaQuery.matches ? 'dark' : 'light')),
|
||||
startWith(this.systemColorSchema),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(this.systemColorSchema$);
|
||||
}
|
||||
|
||||
if (this.document.defaultView?.localStorage) {
|
||||
fromEvent(this.document.defaultView, 'storage')
|
||||
.pipe(
|
||||
filter(
|
||||
(e): e is StorageEvent =>
|
||||
(e as StorageEvent)?.key === 'prefers-color-scheme'
|
||||
),
|
||||
map((event) => this.getColorSchemaType(event.newValue)),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(this.storageColorSchema$);
|
||||
}
|
||||
|
||||
combineLatest({
|
||||
system: this.systemColorSchema$,
|
||||
storage: this.storageColorSchema$,
|
||||
})
|
||||
.pipe(
|
||||
map(({ system, storage }) => this.getColorSchemaByType(storage, system))
|
||||
)
|
||||
.subscribe(this.colorSchema$);
|
||||
}
|
||||
|
||||
private getColorSchemaType(themeType: string | null): PreferColorSchemaType {
|
||||
if (themeType === 'dark' || themeType === 'light') {
|
||||
return themeType as PreferColorSchemaType;
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
private getColorSchemaByType(
|
||||
themeType: PreferColorSchemaType,
|
||||
systemColorSchema: PreferColorSchemaClass
|
||||
): PreferColorSchemaClass {
|
||||
if (themeType === 'dark' || themeType === 'light') {
|
||||
return themeType;
|
||||
}
|
||||
return systemColorSchema;
|
||||
}
|
||||
|
||||
get systemColorSchema(): PreferColorSchemaClass {
|
||||
return this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)')
|
||||
@ -17,24 +92,16 @@ export class ThemeService {
|
||||
: 'light';
|
||||
}
|
||||
|
||||
private getColorSchemaByType(
|
||||
themeType: PreferColorSchemaType
|
||||
): PreferColorSchemaClass {
|
||||
this.document.documentElement.classList.remove('dark', 'light');
|
||||
if (themeType === 'dark' || themeType === 'light') {
|
||||
return themeType;
|
||||
}
|
||||
return this.systemColorSchema;
|
||||
}
|
||||
|
||||
get colorSchema() {
|
||||
const theme = this.localStorage.getItem('prefers-color-scheme');
|
||||
return this.getColorSchemaByType(theme as PreferColorSchemaType);
|
||||
return this.colorSchema$.value;
|
||||
}
|
||||
|
||||
set colorSchema(themeType: PreferColorSchemaType) {
|
||||
this.localStorage.setItem('prefers-color-scheme', themeType);
|
||||
const themeClass = this.getColorSchemaByType(themeType);
|
||||
const themeClass = this.getColorSchemaByType(
|
||||
themeType,
|
||||
this.systemColorSchema
|
||||
);
|
||||
this.document.documentElement.classList.remove('dark', 'light');
|
||||
this.document.documentElement.classList.add(themeClass);
|
||||
}
|
||||
|
@ -1,23 +1,24 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from "@/infra/styles/context";
|
||||
import { CSSProperties } from "react";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { colorTheme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={colorTheme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
} as CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
@ -56,7 +56,6 @@ const CREATE_SUBSCRIPTION_MUTATION = gql`
|
||||
sourceUrl
|
||||
enabled
|
||||
category
|
||||
subscriberId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -1,13 +1,218 @@
|
||||
import type { GetSubscriptionsQuery } from '@/infra/graphql/gql/graphql';
|
||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||
import { Badge } from '@/views/components/ui/badge';
|
||||
import { Button } from '@/views/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/views/components/ui/card';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/views/components/ui/hover-card';
|
||||
import { Image } from '@/views/components/ui/image';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/views/components/ui/select';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { gql } from '@apollo/client';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute('/_app/subscriptions/manage')({
|
||||
component: SubscriptionManage,
|
||||
component: SubscriptionManageRouteComponent,
|
||||
staticData: {
|
||||
breadcrumb: { label: 'Manage' },
|
||||
} satisfies RouteStateDataOption,
|
||||
});
|
||||
|
||||
function SubscriptionManage() {
|
||||
return <div>Hello "/subscriptions/manage"!</div>;
|
||||
// GraphQL query
|
||||
const GET_SUBSCRIPTIONS = gql`
|
||||
query GetSubscriptions($page: Int!, $pageSize: Int!) {
|
||||
subscriptions(
|
||||
pagination: {
|
||||
page: {
|
||||
page: $page,
|
||||
limit: $pageSize
|
||||
}
|
||||
}
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
displayName
|
||||
category
|
||||
enabled
|
||||
bangumi {
|
||||
nodes {
|
||||
id
|
||||
displayName
|
||||
posterLink
|
||||
season
|
||||
fansub
|
||||
homepage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function SubscriptionManageRouteComponent() {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [sortBy, setSortBy] = useState('createdAt');
|
||||
const [sortOrder, setSortOrder] = useState('desc');
|
||||
|
||||
const { loading, error, data } = useQuery<GetSubscriptionsQuery>(
|
||||
GET_SUBSCRIPTIONS,
|
||||
{
|
||||
variables: {
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
const { nodes: items } = data?.subscriptions ?? {};
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-4 p-4">
|
||||
{/* Filters and sorting controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={pageSize.toString()}
|
||||
onValueChange={(value) => setPageSize(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Items per page" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="12">12 items/page</SelectItem>
|
||||
<SelectItem value="24">24 items/page</SelectItem>
|
||||
<SelectItem value="48">48 items/page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="createdAt">Created At</SelectItem>
|
||||
<SelectItem value="displayName">Name</SelectItem>
|
||||
<SelectItem value="season">Season</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort order" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">Ascending</SelectItem>
|
||||
<SelectItem value="desc">Descending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Subscription list */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{items.map((subscription) => (
|
||||
<Card key={subscription.id} className="overflow-hidden">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<div className="relative aspect-[2/3] w-full">
|
||||
<Image
|
||||
src={subscription.bangumi.posterLink || '/placeholder.png'}
|
||||
alt={subscription.bangumi.displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80">
|
||||
<Image
|
||||
src={subscription.bangumi.posterLink || '/placeholder.png'}
|
||||
alt={subscription.bangumi.displayName}
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-2">
|
||||
{subscription.bangumi.extra?.nameZh ||
|
||||
subscription.bangumi.displayName}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant={subscription.enabled ? 'default' : 'secondary'}>
|
||||
{subscription.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{subscription.bangumi.fansub || 'Unknown Group'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate({ to: `/subscriptions/${subscription.id}` })
|
||||
}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({ to: `/subscriptions/${subscription.id}/edit` })
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="py-2">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
3
justfile
3
justfile
@ -21,5 +21,8 @@ dev-deps:
|
||||
dev-deps-clean:
|
||||
docker compose -f devdeps.compose.yaml down -v
|
||||
|
||||
dev-codegen:
|
||||
pnpm run --filter=webui codegen
|
||||
|
||||
dev-all:
|
||||
zellij --layout dev.kdl
|
1755
pnpm-lock.yaml
generated
1755
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user