feature: add mgraphql codegen

This commit is contained in:
master 2025-04-29 02:22:06 +08:00
parent 0300d7baf6
commit 9fdb778330
16 changed files with 3844 additions and 70 deletions

View File

@ -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,

View File

@ -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};

View File

@ -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))
}

View 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;

View File

@ -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",

View 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);
}

View 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;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
export * from './fragment-masking';
export * from './gql';

View File

@ -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,
};
}

View File

@ -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);
}

View File

@ -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 };

View File

@ -56,7 +56,6 @@ const CREATE_SUBSCRIPTION_MUTATION = gql`
sourceUrl
enabled
category
subscriberId
}
}
`;

View File

@ -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>
);
}

View File

@ -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

File diff suppressed because it is too large Load Diff