From 383e6340ea30763af571b4a649f90d77314d4590 Mon Sep 17 00:00:00 2001 From: lonelyhentxi Date: Thu, 6 Mar 2025 02:30:04 +0800 Subject: [PATCH] feat: add auth to webapi --- apps/proxy/.whistle/rules/files/0.konobangu | 4 +- apps/recorder/src/auth/event.ts | 41 -- apps/recorder/src/auth/guard.ts | 2 +- apps/recorder/src/main.tsx | 4 +- apps/recorder/src/web/controller/__root.tsx | 20 - apps/webui/package.json | 6 + apps/webui/rsbuild.config.ts | 32 + apps/webui/src/app.css | 18 + apps/webui/src/auth/config.ts | 37 + apps/webui/src/auth/context.ts | 12 + apps/webui/src/auth/guard.ts | 19 + apps/webui/src/auth/hooks.ts | 51 ++ .../src/components/layout/app-layout.tsx | 47 ++ .../src/components/layout/app-sidebar.tsx | 223 +++--- apps/webui/src/components/layout/nav-main.tsx | 144 ++-- .../src/components/layout/nav-projects.tsx | 7 +- apps/webui/src/components/layout/nav-user.tsx | 9 +- apps/webui/src/components/ui/pro-link.tsx | 23 + apps/webui/src/components/ui/sidebar.tsx | 666 ++++++++++-------- apps/webui/src/main.tsx | 78 +- apps/webui/src/routeTree.gen.ts | 265 ++++++- apps/webui/src/routes/__root.tsx | 33 +- apps/webui/src/routes/_app.tsx | 8 + apps/webui/src/routes/_app/explore.tsx | 9 + .../routes/_app/playground/graphql-api.tsx | 9 + .../src/routes/_app/subscriptions/create.tsx | 9 + .../subscriptions/edit/$subscription-id.tsx | 9 + .../src/routes/_app/subscriptions/manage.tsx | 9 + apps/webui/src/routes/auth/oidc/callback.tsx | 9 + apps/webui/src/routes/auth/sign-in.tsx | 9 + apps/webui/src/routes/auth/sign-up.tsx | 9 + apps/webui/src/routes/index.tsx | 1 + package.json | 45 +- pnpm-lock.yaml | 462 +++++++++++- 34 files changed, 1716 insertions(+), 613 deletions(-) delete mode 100644 apps/recorder/src/auth/event.ts create mode 100644 apps/webui/src/auth/config.ts create mode 100644 apps/webui/src/auth/context.ts create mode 100644 apps/webui/src/auth/guard.ts create mode 100644 apps/webui/src/auth/hooks.ts create mode 100644 apps/webui/src/components/layout/app-layout.tsx create mode 100644 apps/webui/src/components/ui/pro-link.tsx create mode 100644 apps/webui/src/routes/_app.tsx create mode 100644 apps/webui/src/routes/_app/explore.tsx create mode 100644 apps/webui/src/routes/_app/playground/graphql-api.tsx create mode 100644 apps/webui/src/routes/_app/subscriptions/create.tsx create mode 100644 apps/webui/src/routes/_app/subscriptions/edit/$subscription-id.tsx create mode 100644 apps/webui/src/routes/_app/subscriptions/manage.tsx create mode 100644 apps/webui/src/routes/auth/oidc/callback.tsx create mode 100644 apps/webui/src/routes/auth/sign-in.tsx create mode 100644 apps/webui/src/routes/auth/sign-up.tsx diff --git a/apps/proxy/.whistle/rules/files/0.konobangu b/apps/proxy/.whistle/rules/files/0.konobangu index dc04a88..049aa45 100644 --- a/apps/proxy/.whistle/rules/files/0.konobangu +++ b/apps/proxy/.whistle/rules/files/0.konobangu @@ -8,5 +8,5 @@ ^https://konobangu.com/api/playground*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5002/api/playground$1 ^wss://konobangu.com/api/playground*** reqHeaders://{x-forwarded.json} ws://127.0.0.1:5002/api/playground$1 ^https://konobangu.com/api*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5001/api$1 excludeFilter://^^https://konobangu.com/api/playground*** -^https://konobangu.com*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5000$1 excludeFilter://^https://konobangu.com/api*** - +^https://konobangu.com/*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5000/$1 excludeFilter://^https://konobangu.com/api*** +^wss://konobangu.com/*** reqHeaders://{x-forwarded.json} ws://127.0.0.1:5000/$1 excludeFilter://^wss://konobangu.com/api/playground*** \ No newline at end of file diff --git a/apps/recorder/src/auth/event.ts b/apps/recorder/src/auth/event.ts deleted file mode 100644 index a9490e1..0000000 --- a/apps/recorder/src/auth/event.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Observable } from '@graphiql/toolkit'; -import { InjectionToken, inject } from '@outposts/injection-js'; -import { - type AuthFeature, - EventTypes, - PublicEventsService, -} from 'oidc-client-rx'; -import { filter, shareReplay } from 'rxjs'; - -export type CheckAuthResultEventType = - | { type: EventTypes.CheckingAuthFinished } - | { - type: EventTypes.CheckingAuthFinishedWithError; - value: string; - }; -export const CHECK_AUTH_RESULT_EVENT = new InjectionToken< - Observable ->('CHECK_AUTH_RESULT_EVENT'); - -export function withCheckAuthResultEvent(): AuthFeature { - return { - ɵproviders: [ - { - provide: CHECK_AUTH_RESULT_EVENT, - useFactory: () => { - const publishEventService = inject(PublicEventsService); - - return publishEventService.registerForEvents().pipe( - filter( - (e) => - e.type === EventTypes.CheckingAuthFinishedWithError || - e.type === EventTypes.CheckingAuthFinished - ), - shareReplay(1) - ); - }, - deps: [PublicEventsService], - }, - ], - }; -} diff --git a/apps/recorder/src/auth/guard.ts b/apps/recorder/src/auth/guard.ts index b81533d..33c4188 100644 --- a/apps/recorder/src/auth/guard.ts +++ b/apps/recorder/src/auth/guard.ts @@ -1,7 +1,7 @@ import { runInInjectionContext } from '@outposts/injection-js'; import { autoLoginPartialRoutesGuard } from 'oidc-client-rx'; import { firstValueFrom } from 'rxjs'; -import type { RouterContext } from '../controllers/__root'; +import type { RouterContext } from '../web/controller/__root'; export const beforeLoadGuard = async ({ context, diff --git a/apps/recorder/src/main.tsx b/apps/recorder/src/main.tsx index 7388f62..48f3b2a 100644 --- a/apps/recorder/src/main.tsx +++ b/apps/recorder/src/main.tsx @@ -4,17 +4,17 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'; import { OidcSecurityService, provideAuth, + withCheckAuthResultEvent, withDefaultFeatures, } from 'oidc-client-rx'; +import { withTanstackRouter } from 'oidc-client-rx/adapters/@tanstack/react-router'; import { InjectorContextVoidInjector, InjectorProvider, } from 'oidc-client-rx/adapters/react'; -import { withTanstackRouter } from 'oidc-client-rx/adapters/tanstack-router'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { buildOidcConfig, isBasicAuth } from './auth/config'; -import { withCheckAuthResultEvent } from './auth/event'; import { useAuth } from './auth/hooks'; import { routeTree } from './routeTree.gen'; import './main.css'; diff --git a/apps/recorder/src/web/controller/__root.tsx b/apps/recorder/src/web/controller/__root.tsx index 107cc66..6a892f1 100644 --- a/apps/recorder/src/web/controller/__root.tsx +++ b/apps/recorder/src/web/controller/__root.tsx @@ -1,6 +1,5 @@ import type { Injector } from '@outposts/injection-js'; import { - // Link, Outlet, createRootRouteWithContext, } from '@tanstack/react-router'; @@ -26,25 +25,6 @@ export const Route = createRootRouteWithContext()({ function RootComponent() { return ( <> - {/*
- - Home - {' '} - - GraphQL - -
*/} - {/*
*/} diff --git a/apps/webui/package.json b/apps/webui/package.json index 58eeba2..72da532 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -10,18 +10,24 @@ }, "dependencies": { "@ark-ui/solid": "^4.10.2", + "@codemirror/language": "^6.10.8", "@corvu/drawer": "^0.2.3", "@corvu/otp-field": "^0.1.4", "@corvu/resizable": "^0.2.4", + "@graphiql/toolkit": "^0.11.1", "@kobalte/core": "^0.13.9", "@kobalte/tailwindcss": "^0.9.0", + "@solid-primitives/graphql": "^2.2.0", "@solid-primitives/refs": "^1.1.0", "@tailwindcss/postcss": "^4.0.9", "@tanstack/solid-router": "^1.112.2", "chart.js": "^4.4.8", "cmdk-solid": "^1.1.2", "embla-carousel-solid": "^8.5.2", + "graphiql": "^3.8.3", "lucide-solid": "^0.477.0", + "react": "^18", + "react-dom": "^18", "solid-js": "^1.9.5", "solid-sonner": "^0.2.8", "tailwindcss": "^3" diff --git a/apps/webui/rsbuild.config.ts b/apps/webui/rsbuild.config.ts index 0a94869..e37dc29 100644 --- a/apps/webui/rsbuild.config.ts +++ b/apps/webui/rsbuild.config.ts @@ -37,6 +37,38 @@ export default defineConfig({ ), }, }, + dev: { + client: { + path: '/rsbuild-hmr', + }, + setupMiddlewares: [ + (middlewares) => { + middlewares.unshift((req, res, next) => { + if (process.env.AUTH_TYPE === 'basic') { + res.setHeader('WWW-Authenticate', 'Basic realm="konobangu"'); + + const authorization = + (req.headers.authorization || '').split(' ')[1] || ''; + const [user, password] = Buffer.from(authorization, 'base64') + .toString() + .split(':'); + + if ( + user !== process.env.BASIC_USER || + password !== process.env.BASIC_PASSWORD + ) { + res.statusCode = 401; + res.write('Unauthorized'); + res.end(); + return; + } + } + next(); + }); + return middlewares; + }, + ], + }, server: { host: '0.0.0.0', port: 5000, diff --git a/apps/webui/src/app.css b/apps/webui/src/app.css index f960de6..3d968b8 100644 --- a/apps/webui/src/app.css +++ b/apps/webui/src/app.css @@ -46,6 +46,15 @@ --ring: 240 5.9% 10%; --radius: 0.5rem; + + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark, @@ -92,6 +101,15 @@ --ring: 240 4.9% 83.9%; --radius: 0.5rem; + + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } /* custom start */ diff --git a/apps/webui/src/auth/config.ts b/apps/webui/src/auth/config.ts new file mode 100644 index 0000000..d14b5b2 --- /dev/null +++ b/apps/webui/src/auth/config.ts @@ -0,0 +1,37 @@ +import { LogLevel, type OpenIdConfiguration } from 'oidc-client-rx'; + +export const isBasicAuth = process.env.AUTH_TYPE === 'basic'; + +export function buildOidcConfig(): OpenIdConfiguration { + const origin = window.location.origin; + + const resource = process.env.OIDC_AUDIENCE!; + + return { + authority: process.env.OIDC_ISSUER!, + redirectUrl: `${origin}/auth/oidc/callback`, + postLogoutRedirectUri: `${origin}/`, + clientId: process.env.OIDC_CLIENT_ID!, + clientSecret: process.env.OIDC_CLIENT_SECRET, + scope: process.env.OIDC_EXTRA_SCOPES + ? `openid profile email offline_access ${process.env.OIDC_EXTRA_SCOPES}` + : 'openid profile email offline_access', + triggerAuthorizationResultEvent: true, + responseType: 'code', + silentRenew: true, + useRefreshToken: true, + logLevel: LogLevel.Debug, + autoUserInfo: !resource, + renewUserInfoAfterTokenRenew: !resource, + customParamsAuthRequest: { + prompt: 'consent', + resource, + }, + customParamsRefreshTokenRequest: { + resource, + }, + customParamsCodeRequest: { + resource, + }, + }; +} diff --git a/apps/webui/src/auth/context.ts b/apps/webui/src/auth/context.ts new file mode 100644 index 0000000..320e7d4 --- /dev/null +++ b/apps/webui/src/auth/context.ts @@ -0,0 +1,12 @@ +import type { Injector } from '@outposts/injection-js'; +import type { OidcSecurityService } from 'oidc-client-rx'; +import { type Accessor, createSignal } from 'solid-js'; +import { isBasicAuth } from './config'; + +export const [isAuthenticated, setIsAuthenticated] = createSignal(isBasicAuth); + +export type RouterContext = { + isAuthenticated: Accessor; + injector: Injector; + oidcSecurityService: OidcSecurityService; +}; diff --git a/apps/webui/src/auth/guard.ts b/apps/webui/src/auth/guard.ts new file mode 100644 index 0000000..e7e6160 --- /dev/null +++ b/apps/webui/src/auth/guard.ts @@ -0,0 +1,19 @@ +import { runInInjectionContext } from '@outposts/injection-js'; +import { autoLoginPartialRoutesGuard } from 'oidc-client-rx'; +import { firstValueFrom } from 'rxjs'; +import type { RouterContext } from './context'; + +export const beforeLoadGuard = async ({ + context, +}: { context: RouterContext }) => { + if (!context.isAuthenticated()) { + const guard$ = runInInjectionContext(context.injector, () => + autoLoginPartialRoutesGuard() + ); + + const isAuthenticated = await firstValueFrom(guard$); + if (!isAuthenticated) { + throw !isAuthenticated; + } + } +}; diff --git a/apps/webui/src/auth/hooks.ts b/apps/webui/src/auth/hooks.ts new file mode 100644 index 0000000..cef921e --- /dev/null +++ b/apps/webui/src/auth/hooks.ts @@ -0,0 +1,51 @@ +import { CHECK_AUTH_RESULT_EVENT } from 'oidc-client-rx'; +import { + InjectorContextVoidInjector, + useOidcClient, +} from 'oidc-client-rx/adapters/solid-js'; +import { NEVER, of } from 'rxjs'; +import { createMemo, from } from 'solid-js'; +import { isBasicAuth } from './config'; + +const BASIC_AUTH_IS_AUTHENTICATED$ = of({ + isAuthenticated: true, + allConfigsAuthenticated: [], +}); + +const BASIC_AUTH_USER_DATA$ = of({ + userData: {}, + allUserData: [], +}); + +export function useAuth() { + const { oidcSecurityService, injector } = isBasicAuth + ? { oidcSecurityService: undefined, injector: InjectorContextVoidInjector } + : // biome-ignore lint/correctness/useHookAtTopLevel: + useOidcClient(); + + const isAuthenticatedObj = from( + oidcSecurityService?.isAuthenticated$ ?? BASIC_AUTH_IS_AUTHENTICATED$ + ); + + const userDataObj = from( + oidcSecurityService?.userData$ ?? BASIC_AUTH_USER_DATA$ + ); + + const isAuthenticated = createMemo( + () => isAuthenticatedObj()?.isAuthenticated ?? false + ); + + const userData = createMemo(() => userDataObj()?.userData ?? {}); + + const checkAuthResultEvent = isBasicAuth + ? NEVER + : injector.get(CHECK_AUTH_RESULT_EVENT); + + return { + oidcSecurityService, + isAuthenticated, + userData, + injector, + checkAuthResultEvent, + }; +} diff --git a/apps/webui/src/components/layout/app-layout.tsx b/apps/webui/src/components/layout/app-layout.tsx new file mode 100644 index 0000000..958275a --- /dev/null +++ b/apps/webui/src/components/layout/app-layout.tsx @@ -0,0 +1,47 @@ +import { Outlet } from '@tanstack/solid-router'; +import { AppSidebar } from '~/components/layout/app-sidebar'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from '~/components/ui/breadcrumb'; +import { Separator } from '~/components/ui/separator'; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from '~/components/ui/sidebar'; + +export function AppLayout() { + return ( + + + +
+
+ + + + + + + +
+
+
+ +
+
+
+ ); +} diff --git a/apps/webui/src/components/layout/app-sidebar.tsx b/apps/webui/src/components/layout/app-sidebar.tsx index 3252ec7..611a536 100644 --- a/apps/webui/src/components/layout/app-sidebar.tsx +++ b/apps/webui/src/components/layout/app-sidebar.tsx @@ -1,14 +1,10 @@ import { - AudioWaveform, BookOpen, Bot, - ChartPie, - Command, - Frame, - GalleryVerticalEnd, - Map as LucideMap, + Folders, Settings2, SquareTerminal, + Telescope, } from 'lucide-solid'; import type { ComponentProps } from 'solid-js'; import { @@ -20,9 +16,113 @@ import { } from '~/components/ui/sidebar'; import { AppIcon } from './app-icon'; import { NavMain } from './nav-main'; -import { NavProjects } from './nav-projects'; import { NavUser } from './nav-user'; -// This is sample data. + +const navMain = [ + { + group: 'Dashboard', + items: [ + { + title: 'Explore', + link: { + to: '/explore', + }, + icon: Telescope, + }, + { + title: 'Subscriptions', + link: { + to: '/subscriptions', + }, + icon: Folders, + children: [ + { + title: 'Manage', + link: { + to: '/subscriptions/manage', + }, + }, + { + title: 'Create', + link: { + to: '/subscriptions/create', + }, + }, + ], + }, + { + title: 'Playground', + href: '#', + icon: SquareTerminal, + isActive: true, + items: [ + { + title: 'History', + href: '#', + }, + { + title: 'Starred', + href: '#', + }, + { + title: 'Settings', + href: '#', + }, + ], + }, + { + title: 'Models', + href: '#', + icon: Bot, + items: [ + { + title: 'Genesis', + href: '#', + }, + { + title: 'Explorer', + href: '#', + }, + { + title: 'Quantum', + href: '#', + }, + ], + }, + { + title: 'Documentation', + link: { + href: 'https://github.com/dumtruck/konobangu/wiki', + target: '_blank', + }, + icon: BookOpen, + }, + { + title: 'Settings', + href: '#', + icon: Settings2, + items: [ + { + title: 'General', + href: '#', + }, + { + title: 'Team', + href: '#', + }, + { + title: 'Billing', + href: '#', + }, + { + title: 'Limits', + href: '#', + }, + ], + }, + ], + }, +]; const data = { user: { @@ -30,110 +130,6 @@ const data = { email: 'm@example.com', avatar: '/avatars/shadcn.jpg', }, - navMain: [ - { - title: 'Playground', - url: '#', - icon: SquareTerminal, - isActive: true, - items: [ - { - title: 'History', - url: '#', - }, - { - title: 'Starred', - url: '#', - }, - { - title: 'Settings', - url: '#', - }, - ], - }, - { - title: 'Models', - url: '#', - icon: Bot, - items: [ - { - title: 'Genesis', - url: '#', - }, - { - title: 'Explorer', - url: '#', - }, - { - title: 'Quantum', - url: '#', - }, - ], - }, - { - title: 'Documentation', - url: '#', - icon: BookOpen, - items: [ - { - title: 'Introduction', - url: '#', - }, - { - title: 'Get Started', - url: '#', - }, - { - title: 'Tutorials', - url: '#', - }, - { - title: 'Changelog', - url: '#', - }, - ], - }, - { - title: 'Settings', - url: '#', - icon: Settings2, - items: [ - { - title: 'General', - url: '#', - }, - { - title: 'Team', - url: '#', - }, - { - title: 'Billing', - url: '#', - }, - { - title: 'Limits', - url: '#', - }, - ], - }, - ], - projects: [ - { - name: 'Design Engineering', - url: '#', - icon: Frame, - }, - { - name: 'Sales & Marketing', - url: '#', - icon: ChartPie, - }, - { - name: 'Travel', - url: '#', - icon: LucideMap, - }, - ], }; type AppSidebarRootProps = Omit, 'collapsible'>; @@ -145,8 +141,7 @@ export const AppSidebar = (props: AppSidebarRootProps) => { - - + diff --git a/apps/webui/src/components/layout/nav-main.tsx b/apps/webui/src/components/layout/nav-main.tsx index afa7502..1cf0aa2 100644 --- a/apps/webui/src/components/layout/nav-main.tsx +++ b/apps/webui/src/components/layout/nav-main.tsx @@ -1,6 +1,7 @@ import { ChevronRight, type LucideIcon } from 'lucide-solid'; -import { For } from 'solid-js'; +import { For, Show, createSignal } from 'solid-js'; +import { useMatch, useMatches } from '@tanstack/solid-router'; import { Collapsible, CollapsibleContent, @@ -16,56 +17,105 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, } from '~/components/ui/sidebar'; +import { ProLink, type ProLinkProps } from '../ui/pro-link'; + +export interface NavMainItem { + link?: ProLinkProps; + title: string; + icon?: LucideIcon; + children?: { title: string; link: ProLinkProps }[]; +} + +export interface NavMainGroup { + group: string; + items: NavMainItem[]; +} export function NavMain({ - items, + groups, }: { - items: { - title: string; - url: string; - icon?: LucideIcon; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; - }[]; + groups: NavMainGroup[]; }) { + const matches = useMatches(); + + const isMenuMatch = (link: ProLinkProps | undefined) => { + const linkTo = link?.to; + if (!linkTo) { + return false; + } + return matches().some((match) => match.pathname.startsWith(linkTo)); + }; + + const renderSidebarMenuItemButton = (item: NavMainItem) => { + return ( + <> + {item.icon && } + {item.title} + + + ); + }; + return ( - - Platform - - - {(item) => ( - - - - {item.icon && } - {item.title} - - - - - - {(subItem) => ( - - - {subItem.title} - - - )} - - - - - - )} - - - + + {(group) => ( + + {group.group} + + + {(item) => { + return ( + + + {renderSidebarMenuItemButton(item)} + + + } + > + + + {renderSidebarMenuItemButton(item)} + + + + + {(subItem) => ( + + + {subItem.title} + + + )} + + + + + + ); + }} + + + + )} + ); } diff --git a/apps/webui/src/components/layout/nav-projects.tsx b/apps/webui/src/components/layout/nav-projects.tsx index 05c86b6..7b91ecf 100644 --- a/apps/webui/src/components/layout/nav-projects.tsx +++ b/apps/webui/src/components/layout/nav-projects.tsx @@ -1,3 +1,4 @@ +import { Link } from '@tanstack/solid-router'; import { Folder, Forward, @@ -5,7 +6,7 @@ import { MoreHorizontal, Trash2, } from 'lucide-solid'; -import { For } from 'solid-js'; +import { type ComponentProps, For } from 'solid-js'; import { DropdownMenu, @@ -28,8 +29,8 @@ export function NavProjects({ }: { projects: { name: string; - url: string; icon: LucideIcon; + link: ComponentProps; }[]; }) { return ( @@ -39,7 +40,7 @@ export function NavProjects({ {(item) => ( - + {item.name} diff --git a/apps/webui/src/components/layout/nav-user.tsx b/apps/webui/src/components/layout/nav-user.tsx index ed7932f..97ec47a 100644 --- a/apps/webui/src/components/layout/nav-user.tsx +++ b/apps/webui/src/components/layout/nav-user.tsx @@ -21,7 +21,6 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, - useSidebar, } from '~/components/ui/sidebar'; export function NavUser({ @@ -33,8 +32,6 @@ export function NavUser({ avatar: string; }; }) { - const { isMobile } = useSidebar(); - return ( @@ -54,11 +51,7 @@ export function NavUser({ - +
diff --git a/apps/webui/src/components/ui/pro-link.tsx b/apps/webui/src/components/ui/pro-link.tsx new file mode 100644 index 0000000..d45be85 --- /dev/null +++ b/apps/webui/src/components/ui/pro-link.tsx @@ -0,0 +1,23 @@ +import { type LinkComponent, createLink } from '@tanstack/solid-router'; +import { type Component, type ComponentProps, type JSX, Show } from 'solid-js'; + +type BasicLinkProps = JSX.IntrinsicElements['a']; + +const BasicLinkComponent: Component = (props) => ( + {props.children} +); + +const CreatedLinkComponent = createLink(BasicLinkComponent); + +export const ProLink: LinkComponent = (props) => { + return ( + } + > + + + ); +}; + +export type ProLinkProps = ComponentProps; diff --git a/apps/webui/src/components/ui/sidebar.tsx b/apps/webui/src/components/ui/sidebar.tsx index 2285598..6d5c6c1 100644 --- a/apps/webui/src/components/ui/sidebar.tsx +++ b/apps/webui/src/components/ui/sidebar.tsx @@ -1,133 +1,150 @@ -import type { Accessor, Component, ComponentProps, JSX, ValidComponent } from "solid-js" +import type { + Accessor, + Component, + ComponentProps, + JSX, + ValidComponent, +} from 'solid-js'; import { + Match, + Show, + Switch, createContext, createEffect, createMemo, createSignal, - Match, mergeProps, onCleanup, - Show, splitProps, - Switch, - useContext -} from "solid-js" + useContext, +} from 'solid-js'; -import type { PolymorphicProps } from "@kobalte/core" -import { Polymorphic } from "@kobalte/core" -import type { VariantProps } from "class-variance-authority" -import { cva } from "class-variance-authority" +import type { PolymorphicProps } from '@kobalte/core'; +import { Polymorphic } from '@kobalte/core'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; -import { cn } from "~/styles/utils" -import type { ButtonProps } from "~/components/ui/button" -import { Button } from "~/components/ui/button" -import { Separator } from "~/components/ui/separator" -import { Sheet, SheetContent } from "~/components/ui/sheet" -import { Skeleton } from "~/components/ui/skeleton" -import { TextField, TextFieldInput } from "~/components/ui/text-field" -import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip" +import type { ButtonProps } from '~/components/ui/button'; +import { Button } from '~/components/ui/button'; +import { Separator } from '~/components/ui/separator'; +import { Sheet, SheetContent } from '~/components/ui/sheet'; +import { Skeleton } from '~/components/ui/skeleton'; +import { TextField, TextFieldInput } from '~/components/ui/text-field'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '~/components/ui/tooltip'; +import { cn } from '~/styles/utils'; -const MOBILE_BREAKPOINT = 768 -const SIDEBAR_COOKIE_NAME = "sidebar:state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" -const SIDEBAR_WIDTH_ICON = "3rem" -const SIDEBAR_KEYBOARD_SHORTCUT = "b" +const MOBILE_BREAKPOINT = 768; +const SIDEBAR_COOKIE_NAME = 'sidebar:state'; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = '16rem'; +const SIDEBAR_WIDTH_MOBILE = '18rem'; +const SIDEBAR_WIDTH_ICON = '3rem'; +const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; type SidebarContext = { - state: Accessor<"expanded" | "collapsed"> - open: Accessor - setOpen: (open: boolean) => void - openMobile: Accessor - setOpenMobile: (open: boolean) => void - isMobile: Accessor - toggleSidebar: () => void -} + state: Accessor<'expanded' | 'collapsed'>; + open: Accessor; + setOpen: (open: boolean) => void; + openMobile: Accessor; + setOpenMobile: (open: boolean) => void; + isMobile: Accessor; + toggleSidebar: () => void; +}; -const SidebarContext = createContext(null) +const SidebarContext = createContext(null); function useSidebar() { - const context = useContext(SidebarContext) + const context = useContext(SidebarContext); if (!context) { - throw new Error("useSidebar must be used within a Sidebar.") + throw new Error('useSidebar must be used within a Sidebar.'); } - return context + return context; } export function useIsMobile(fallback = false) { - const [isMobile, setIsMobile] = createSignal(fallback) + const [isMobile, setIsMobile] = createSignal(fallback); createEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); const onChange = (e: MediaQueryListEvent | MediaQueryList) => { - setIsMobile(e.matches) - } - mql.addEventListener("change", onChange) - onChange(mql) - onCleanup(() => mql.removeEventListener("change", onChange)) - }) + setIsMobile(e.matches); + }; + mql.addEventListener('change', onChange); + onChange(mql); + onCleanup(() => mql.removeEventListener('change', onChange)); + }); - return isMobile + return isMobile; } -type SidebarProviderProps = Omit, "style"> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void - style?: JSX.CSSProperties -} +type SidebarProviderProps = Omit, 'style'> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + style?: JSX.CSSProperties; +}; const SidebarProvider: Component = (rawProps) => { - const props = mergeProps({ defaultOpen: true }, rawProps) + const props = mergeProps({ defaultOpen: true }, rawProps); const [local, others] = splitProps(props, [ - "defaultOpen", - "open", - "onOpenChange", - "class", - "style", - "children" - ]) + 'defaultOpen', + 'open', + 'onOpenChange', + 'class', + 'style', + 'children', + ]); - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = createSignal(false) + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = createSignal(false); // This is the internal state of the sidebar. // We use open and onOpenChange for control from outside the component. - const [_open, _setOpen] = createSignal(local.defaultOpen) - const open = () => local.open ?? _open() + const [_open, _setOpen] = createSignal(local.defaultOpen); + const open = () => local.open ?? _open(); const setOpen = (value: boolean | ((value: boolean) => boolean)) => { if (local.onOpenChange) { - return local.onOpenChange?.(typeof value === "function" ? value(open()) : value) + return local.onOpenChange?.( + typeof value === 'function' ? value(open()) : value + ); } - _setOpen(value) + _setOpen(value); // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${open()}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` - } + document.cookie = `${SIDEBAR_COOKIE_NAME}=${open()}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }; // Helper to toggle the sidebar. const toggleSidebar = () => { - return isMobile() ? setOpenMobile((open) => !open) : setOpen((open) => !open) - } + return isMobile() + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }; // Adds a keyboard shortcut to toggle the sidebar. createEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - toggleSidebar() + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + toggleSidebar(); } - } + }; - window.addEventListener("keydown", handleKeyDown) - onCleanup(() => window.removeEventListener("keydown", handleKeyDown)) - }) + window.addEventListener('keydown', handleKeyDown); + onCleanup(() => window.removeEventListener('keydown', handleKeyDown)); + }); // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = () => (open() ? "expanded" : "collapsed") + const state = () => (open() ? 'expanded' : 'collapsed'); const contextValue = { state, @@ -136,19 +153,19 @@ const SidebarProvider: Component = (rawProps) => { isMobile, openMobile, setOpenMobile, - toggleSidebar - } + toggleSidebar, + }; return (
= (rawProps) => { {local.children}
- ) -} + ); +}; -type SidebarProps = ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" -} +type SidebarProps = ComponentProps<'div'> & { + side?: 'left' | 'right'; + variant?: 'sidebar' | 'floating' | 'inset'; + collapsible?: 'offcanvas' | 'icon' | 'none'; +}; const Sidebar: Component = (rawProps) => { const props = mergeProps( { - side: "left", - variant: "sidebar", - collapsible: "offcanvas" + side: 'left', + variant: 'sidebar', + collapsible: 'offcanvas', }, rawProps - ) - const [local, others] = splitProps(props, ["side", "variant", "collapsible", "class", "children"]) + ); + const [local, others] = splitProps(props, [ + 'side', + 'variant', + 'collapsible', + 'class', + 'children', + ]); - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); return ( - +
= (rawProps) => { data-mobile="true" class="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" style={{ - "--sidebar-width": SIDEBAR_WIDTH_MOBILE + '--sidebar-width': SIDEBAR_WIDTH_MOBILE, }} position={local.side} > @@ -210,31 +233,31 @@ const Sidebar: Component = (rawProps) => {