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};
|
use crate::{app::AppContextTrait, auth::AuthServiceTrait};
|
||||||
|
|
||||||
pub async fn header_www_authenticate_middleware(
|
pub async fn auth_middleware(
|
||||||
State(ctx): State<Arc<dyn AppContextTrait>>,
|
State(ctx): State<Arc<dyn AppContextTrait>>,
|
||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
|
@ -7,5 +7,5 @@ pub mod service;
|
|||||||
|
|
||||||
pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig};
|
pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig};
|
||||||
pub use errors::AuthError;
|
pub use errors::AuthError;
|
||||||
pub use middleware::header_www_authenticate_middleware;
|
pub use middleware::auth_middleware;
|
||||||
pub use service::{AuthService, AuthServiceTrait, AuthUserInfo};
|
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 super::core::Controller;
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppContextTrait,
|
app::{AppContextTrait, Environment},
|
||||||
auth::{AuthUserInfo, header_www_authenticate_middleware},
|
auth::{AuthUserInfo, auth_middleware},
|
||||||
errors::RecorderResult,
|
errors::RecorderResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -25,9 +25,51 @@ async fn graphql_handler(
|
|||||||
graphql_service.schema.execute(req).await.into()
|
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> {
|
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()
|
let router = Router::<Arc<dyn AppContextTrait>>::new()
|
||||||
.route("/", post(graphql_handler))
|
.route(
|
||||||
.layer(from_fn_with_state(ctx, header_www_authenticate_middleware));
|
"/",
|
||||||
|
post(graphql_handler).layer(from_fn_with_state(ctx, auth_middleware)),
|
||||||
|
)
|
||||||
|
.route("/introspection", introspection_handler);
|
||||||
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router))
|
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": {
|
"scripts": {
|
||||||
"build": "rsbuild build",
|
"build": "rsbuild build",
|
||||||
"dev": "rsbuild dev",
|
"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": {
|
"dependencies": {
|
||||||
"@abraham/reflection": "^0.13.0",
|
"@abraham/reflection": "^0.13.0",
|
||||||
@ -45,6 +47,7 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||||
"@radix-ui/react-tooltip": "^1.2.6",
|
"@radix-ui/react-tooltip": "^1.2.6",
|
||||||
"@rsbuild/plugin-react": "^1.2.0",
|
"@rsbuild/plugin-react": "^1.2.0",
|
||||||
|
"@tanstack/react-query": "^5.75.6",
|
||||||
"@tanstack/react-router": "^1.112.13",
|
"@tanstack/react-router": "^1.112.13",
|
||||||
"@tanstack/router-devtools": "^1.112.13",
|
"@tanstack/router-devtools": "^1.112.13",
|
||||||
"arktype": "^2.1.6",
|
"arktype": "^2.1.6",
|
||||||
@ -59,11 +62,10 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jotai": "^2.12.3",
|
"jotai": "^2.12.3",
|
||||||
"jotai-signal": "^0.9.0",
|
"jotai-signal": "^0.9.0",
|
||||||
"lucide-react": "^0.508.0",
|
"lucide-react": "^0.509.0",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"oidc-client-rx": "0.1.0-alpha.9",
|
"oidc-client-rx": "0.1.0-alpha.9",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "9.6.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.56.3",
|
"react-hook-form": "^7.56.3",
|
||||||
"react-resizable-panels": "^3.0.1",
|
"react-resizable-panels": "^3.0.1",
|
||||||
@ -78,6 +80,10 @@
|
|||||||
"zod": "^3.24.4"
|
"zod": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@rsbuild/core": "^1.2.15",
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.0.9",
|
||||||
"@tanstack/react-router": "^1.112.0",
|
"@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';
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
export function provideStyles() {
|
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 { DOCUMENT } from '@/infra/platform/injection';
|
||||||
import { LocalStorageService } from '@/infra/storage/web-storage.service';
|
import { LocalStorageService } from '@/infra/storage/web-storage.service';
|
||||||
import { Injectable, inject } from '@outposts/injection-js';
|
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 PreferColorSchemaType = 'dark' | 'light' | 'system';
|
||||||
export type PreferColorSchemaClass = 'dark' | 'light';
|
export type PreferColorSchemaClass = 'dark' | 'light';
|
||||||
|
|
||||||
@ -9,6 +19,71 @@ export type PreferColorSchemaClass = 'dark' | 'light';
|
|||||||
export class ThemeService {
|
export class ThemeService {
|
||||||
document = inject(DOCUMENT);
|
document = inject(DOCUMENT);
|
||||||
localStorage = inject(LocalStorageService);
|
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 {
|
get systemColorSchema(): PreferColorSchemaClass {
|
||||||
return this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)')
|
return this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)')
|
||||||
@ -17,24 +92,16 @@ export class ThemeService {
|
|||||||
: 'light';
|
: '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() {
|
get colorSchema() {
|
||||||
const theme = this.localStorage.getItem('prefers-color-scheme');
|
return this.colorSchema$.value;
|
||||||
return this.getColorSchemaByType(theme as PreferColorSchemaType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set colorSchema(themeType: PreferColorSchemaType) {
|
set colorSchema(themeType: PreferColorSchemaType) {
|
||||||
this.localStorage.setItem('prefers-color-scheme', themeType);
|
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.remove('dark', 'light');
|
||||||
this.document.documentElement.classList.add(themeClass);
|
this.document.documentElement.classList.add(themeClass);
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "@/infra/styles/context";
|
||||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
import { CSSProperties } from "react";
|
||||||
|
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme = "system" } = useTheme()
|
const { colorTheme = "system" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={colorTheme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--normal-bg": "var(--popover)",
|
"--normal-bg": "var(--popover)",
|
||||||
"--normal-text": "var(--popover-foreground)",
|
"--normal-text": "var(--popover-foreground)",
|
||||||
"--normal-border": "var(--border)",
|
"--normal-border": "var(--border)",
|
||||||
} as React.CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Toaster }
|
export { Toaster };
|
||||||
|
@ -56,7 +56,6 @@ const CREATE_SUBSCRIPTION_MUTATION = gql`
|
|||||||
sourceUrl
|
sourceUrl
|
||||||
enabled
|
enabled
|
||||||
category
|
category
|
||||||
subscriberId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -1,13 +1,218 @@
|
|||||||
|
import type { GetSubscriptionsQuery } from '@/infra/graphql/gql/graphql';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
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 { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/subscriptions/manage')({
|
export const Route = createFileRoute('/_app/subscriptions/manage')({
|
||||||
component: SubscriptionManage,
|
component: SubscriptionManageRouteComponent,
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: { label: 'Manage' },
|
breadcrumb: { label: 'Manage' },
|
||||||
} satisfies RouteStateDataOption,
|
} satisfies RouteStateDataOption,
|
||||||
});
|
});
|
||||||
|
|
||||||
function SubscriptionManage() {
|
// GraphQL query
|
||||||
return <div>Hello "/subscriptions/manage"!</div>;
|
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:
|
dev-deps-clean:
|
||||||
docker compose -f devdeps.compose.yaml down -v
|
docker compose -f devdeps.compose.yaml down -v
|
||||||
|
|
||||||
|
dev-codegen:
|
||||||
|
pnpm run --filter=webui codegen
|
||||||
|
|
||||||
dev-all:
|
dev-all:
|
||||||
zellij --layout dev.kdl
|
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