Merge pull request #4 from multica-ai/naiyuan/monorepo-setup
Add monorepo setup with Turborepo and Web client
This commit is contained in:
commit
f7d03aa427
46 changed files with 8622 additions and 2 deletions
41
apps/web/.gitignore
vendored
Normal file
41
apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
apps/web/README.md
Normal file
36
apps/web/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
BIN
apps/web/app/favicon.ico
Normal file
BIN
apps/web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
126
apps/web/app/globals.css
Normal file
126
apps/web/app/globals.css
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.51 0.23 277);
|
||||
--primary-foreground: oklch(0.96 0.02 272);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.79 0.10 275);
|
||||
--chart-2: oklch(0.68 0.16 277);
|
||||
--chart-3: oklch(0.59 0.20 277);
|
||||
--chart-4: oklch(0.51 0.23 277);
|
||||
--chart-5: oklch(0.46 0.21 277);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.51 0.23 277);
|
||||
--sidebar-primary-foreground: oklch(0.96 0.02 272);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.59 0.20 277);
|
||||
--primary-foreground: oklch(0.96 0.02 272);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.79 0.10 275);
|
||||
--chart-2: oklch(0.68 0.16 277);
|
||||
--chart-3: oklch(0.59 0.20 277);
|
||||
--chart-4: oklch(0.51 0.23 277);
|
||||
--chart-5: oklch(0.46 0.21 277);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.68 0.16 277);
|
||||
--sidebar-primary-foreground: oklch(0.96 0.02 272);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
36
apps/web/app/layout.tsx
Normal file
36
apps/web/app/layout.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/page.tsx
Normal file
5
apps/web/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ComponentExample } from "@/components/component-example";
|
||||
|
||||
export default function Page() {
|
||||
return <ComponentExample />;
|
||||
}
|
||||
24
apps/web/components.json
Normal file
24
apps/web/components.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "hugeicons",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
471
apps/web/components/component-example.tsx
Normal file
471
apps/web/components/component-example.tsx
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import {
|
||||
Example,
|
||||
ExampleWrapper,
|
||||
} from "@/components/example"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { PlusSignIcon, BluetoothIcon, MoreVerticalCircle01Icon, FileIcon, FolderIcon, FolderOpenIcon, CodeIcon, MoreHorizontalCircle01Icon, SearchIcon, FloppyDiskIcon, DownloadIcon, EyeIcon, LayoutIcon, PaintBoardIcon, SunIcon, MoonIcon, ComputerIcon, UserIcon, CreditCardIcon, SettingsIcon, KeyboardIcon, LanguageCircleIcon, NotificationIcon, MailIcon, ShieldIcon, HelpCircleIcon, File01Icon, LogoutIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
export function ComponentExample() {
|
||||
return (
|
||||
<ExampleWrapper>
|
||||
<CardExample />
|
||||
<FormExample />
|
||||
</ExampleWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function CardExample() {
|
||||
return (
|
||||
<Example title="Card" className="items-center justify-center">
|
||||
<Card className="relative w-full max-w-sm overflow-hidden pt-0">
|
||||
<div className="bg-primary absolute inset-0 z-30 aspect-video opacity-50 mix-blend-color" />
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1604076850742-4c7221f3101b?q=80&w=1887&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt="Photo by mymind on Unsplash"
|
||||
title="Photo by mymind on Unsplash"
|
||||
className="relative z-20 aspect-video w-full object-cover brightness-60 grayscale"
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardTitle>Observability Plus is replacing Monitoring</CardTitle>
|
||||
<CardDescription>
|
||||
Switch to the improved way to explore your data, with natural
|
||||
language. Monitoring will no longer be available on the Pro plan in
|
||||
November, 2025
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button />}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} strokeWidth={2} data-icon="inline-start" />
|
||||
Show Dialog
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia>
|
||||
<HugeiconsIcon icon={BluetoothIcon} strokeWidth={2} />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Do you want to allow the USB accessory to connect to this
|
||||
device?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Don't allow</AlertDialogCancel>
|
||||
<AlertDialogAction>Allow</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
Warning
|
||||
</Badge>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
|
||||
const frameworks = [
|
||||
"Next.js",
|
||||
"SvelteKit",
|
||||
"Nuxt.js",
|
||||
"Remix",
|
||||
"Astro",
|
||||
] as const
|
||||
|
||||
const roleItems = [
|
||||
{ label: "Developer", value: "developer" },
|
||||
{ label: "Designer", value: "designer" },
|
||||
{ label: "Manager", value: "manager" },
|
||||
{ label: "Other", value: "other" },
|
||||
]
|
||||
|
||||
function FormExample() {
|
||||
const [notifications, setNotifications] = React.useState({
|
||||
email: true,
|
||||
sms: false,
|
||||
push: true,
|
||||
})
|
||||
const [theme, setTheme] = React.useState("light")
|
||||
|
||||
return (
|
||||
<Example title="Form">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>User Information</CardTitle>
|
||||
<CardDescription>Please fill in your details below</CardDescription>
|
||||
<CardAction>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={<Button variant="ghost" size="icon" />}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreVerticalCircle01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">More options</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>File</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={FileIcon} strokeWidth={2} />
|
||||
New File
|
||||
<DropdownMenuShortcut>⌘N</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={FolderIcon} strokeWidth={2} />
|
||||
New Folder
|
||||
<DropdownMenuShortcut>⇧⌘N</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={FolderOpenIcon} strokeWidth={2} />
|
||||
Open Recent
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Recent Projects</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
|
||||
Project Alpha
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
|
||||
Project Beta
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />
|
||||
More Projects
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
|
||||
Project Gamma
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={CodeIcon} strokeWidth={2} />
|
||||
Project Delta
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={SearchIcon} strokeWidth={2} />
|
||||
Browse...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} strokeWidth={2} />
|
||||
Save
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={DownloadIcon} strokeWidth={2} />
|
||||
Export
|
||||
<DropdownMenuShortcut>⇧⌘E</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>View</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={notifications.email}
|
||||
onCheckedChange={(checked) =>
|
||||
setNotifications({
|
||||
...notifications,
|
||||
email: checked === true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={EyeIcon} strokeWidth={2} />
|
||||
Show Sidebar
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={notifications.sms}
|
||||
onCheckedChange={(checked) =>
|
||||
setNotifications({
|
||||
...notifications,
|
||||
sms: checked === true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={LayoutIcon} strokeWidth={2} />
|
||||
Show Status Bar
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={PaintBoardIcon} strokeWidth={2} />
|
||||
Theme
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={theme}
|
||||
onValueChange={setTheme}
|
||||
>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
<HugeiconsIcon icon={SunIcon} strokeWidth={2} />
|
||||
Light
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
<HugeiconsIcon icon={MoonIcon} strokeWidth={2} />
|
||||
Dark
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">
|
||||
<HugeiconsIcon icon={ComputerIcon} strokeWidth={2} />
|
||||
System
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={UserIcon} strokeWidth={2} />
|
||||
Profile
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={CreditCardIcon} strokeWidth={2} />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={SettingsIcon} strokeWidth={2} />
|
||||
Settings
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Preferences</DropdownMenuLabel>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={KeyboardIcon} strokeWidth={2} />
|
||||
Keyboard Shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={LanguageCircleIcon} strokeWidth={2} />
|
||||
Language
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={NotificationIcon} strokeWidth={2} />
|
||||
Notifications
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
Notification Types
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={notifications.push}
|
||||
onCheckedChange={(checked) =>
|
||||
setNotifications({
|
||||
...notifications,
|
||||
push: checked === true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={NotificationIcon} strokeWidth={2} />
|
||||
Push Notifications
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={notifications.email}
|
||||
onCheckedChange={(checked) =>
|
||||
setNotifications({
|
||||
...notifications,
|
||||
email: checked === true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={MailIcon} strokeWidth={2} />
|
||||
Email Notifications
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={ShieldIcon} strokeWidth={2} />
|
||||
Privacy & Security
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={HelpCircleIcon} strokeWidth={2} />
|
||||
Help & Support
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<HugeiconsIcon icon={File01Icon} strokeWidth={2} />
|
||||
Documentation
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<HugeiconsIcon icon={LogoutIcon} strokeWidth={2} />
|
||||
Sign Out
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="small-form-name">Name</FieldLabel>
|
||||
<Input
|
||||
id="small-form-name"
|
||||
placeholder="Enter your name"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="small-form-role">Role</FieldLabel>
|
||||
<Select items={roleItems} defaultValue={null}>
|
||||
<SelectTrigger id="small-form-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{roleItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="small-form-framework">
|
||||
Framework
|
||||
</FieldLabel>
|
||||
<Combobox items={frameworks}>
|
||||
<ComboboxInput
|
||||
id="small-form-framework"
|
||||
placeholder="Select a framework"
|
||||
required
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No frameworks found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item} value={item}>
|
||||
{item}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="small-form-comments">Comments</FieldLabel>
|
||||
<Textarea
|
||||
id="small-form-comments"
|
||||
placeholder="Add any additional comments"
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation="horizontal">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
56
apps/web/components/example.tsx
Normal file
56
apps/web/components/example.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ExampleWrapper({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div className="bg-background w-full">
|
||||
<div
|
||||
data-slot="example-wrapper"
|
||||
className={cn(
|
||||
"mx-auto grid min-h-screen w-full max-w-5xl min-w-0 content-center items-start gap-8 p-4 pt-2 sm:gap-12 sm:p-6 md:grid-cols-2 md:gap-8 lg:p-12 2xl:max-w-6xl",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Example({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
title?: string
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="example"
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-lg min-w-0 flex-col gap-1 self-stretch lg:max-w-none",
|
||||
containerClassName
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-muted-foreground px-1.5 py-2 text-xs font-medium">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-slot="example-content"
|
||||
className={cn(
|
||||
"bg-background text-foreground flex min-w-0 flex-1 flex-col items-start gap-6 border border-dashed p-4 sm:p-6 *:[div:not([class*='w-'])]:w-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ExampleWrapper, Example }
|
||||
175
apps/web/components/ui/alert-dialog.tsx
Normal file
175
apps/web/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
48
apps/web/components/ui/badge.tsx
Normal file
48
apps/web/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ className, variant })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
53
apps/web/components/ui/button.tsx
Normal file
53
apps/web/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
94
apps/web/components/ui/card.tsx
Normal file
94
apps/web/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
293
apps/web/components/ui/combobox.tsx
Normal file
293
apps/web/components/ui/combobox.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowDown01Icon, Cancel01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} className="text-muted-foreground size-4 pointer-events-none" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 max-h-72 min-w-36 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0 overflow-y-auto overscroll-contain",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn("text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn("dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn(
|
||||
"min-w-16 flex-1 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
253
apps/web/components/ui/dropdown-menu.tsx
Normal file
253
apps/web/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-[inset]:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
227
apps/web/components/ui/field.tsx
Normal file
227
apps/web/components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn("gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn("mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"gap-5 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4 group/field-group @container/field-group flex w-full flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva("data-[invalid=true]:text-destructive gap-2 group/field flex w-full", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical:
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
})
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"has-data-checked:bg-primary/5 has-data-checked:border-primary dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit leading-snug",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-left text-sm [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn("-my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
149
apps/web/components/ui/input-group.tsx
Normal file
149
apps/web/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
|
||||
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
|
||||
"block-start":
|
||||
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
|
||||
"block-end":
|
||||
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"gap-2 text-sm shadow-none flex items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
20
apps/web/components/ui/input.tsx
Normal file
20
apps/web/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
apps/web/components/ui/label.tsx
Normal file
20
apps/web/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
192
apps/web/components/ui/select.tsx
Normal file
192
apps/web/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { UnfoldMoreIcon, Tick02Icon, ArrowUp01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} className="text-muted-foreground size-4 pointer-events-none" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
25
apps/web/components/ui/separator.tsx
Normal file
25
apps/web/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
18
apps/web/components/ui/textarea.tsx
Normal file
18
apps/web/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
18
apps/web/eslint.config.mjs
Normal file
18
apps/web/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
6
apps/web/lib/utils.ts
Normal file
6
apps/web/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
7
apps/web/next.config.ts
Normal file
7
apps/web/next.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
35
apps/web/package.json
Normal file
35
apps/web/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@multica/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/sdk": "workspace:*",
|
||||
"@base-ui/react": "^1.1.0",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shadcn": "^3.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
apps/web/public/file.svg
Normal file
1
apps/web/public/file.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
apps/web/public/globe.svg
Normal file
1
apps/web/public/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
1
apps/web/public/next.svg
Normal file
1
apps/web/public/next.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
apps/web/public/vercel.svg
Normal file
1
apps/web/public/vercel.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
apps/web/public/window.svg
Normal file
1
apps/web/public/window.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
34
apps/web/tsconfig.json
Normal file
34
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@
|
|||
"dev": "tsx src/index.ts",
|
||||
"dev:gateway": "tsx --watch src/gateway/main.ts",
|
||||
"dev:console": "tsx --watch src/console/main.ts",
|
||||
"build": "tsc",
|
||||
"dev:web": "pnpm --filter @multica/web dev",
|
||||
"build": "turbo build",
|
||||
"build:sdk": "pnpm --filter @multica/sdk build",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "turbo typecheck"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
@ -20,6 +22,7 @@
|
|||
"@types/node": "^25.0.10",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.3.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
26
packages/sdk/package.json
Normal file
26
packages/sdk/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@multica/sdk",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"socket.io-client": "^4.8.3",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^11.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
14
packages/sdk/src/actions/hello.ts
Normal file
14
packages/sdk/src/actions/hello.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/** Hello Action - 测试用的问候消息 */
|
||||
|
||||
export const HelloAction = "hello" as const;
|
||||
export const HelloResponseAction = "hello_response" as const;
|
||||
|
||||
/** Hello 请求 payload */
|
||||
export interface HelloPayload {
|
||||
greeting: string;
|
||||
}
|
||||
|
||||
/** Hello 响应 payload */
|
||||
export interface HelloResponsePayload {
|
||||
reply: string;
|
||||
}
|
||||
19
packages/sdk/src/actions/index.ts
Normal file
19
packages/sdk/src/actions/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export {
|
||||
HelloAction,
|
||||
HelloResponseAction,
|
||||
type HelloPayload,
|
||||
type HelloResponsePayload,
|
||||
} from "./hello.js";
|
||||
|
||||
export {
|
||||
RequestAction,
|
||||
ResponseAction,
|
||||
type RequestPayload,
|
||||
type ResponsePayload,
|
||||
type ResponseSuccessPayload,
|
||||
type ResponseErrorPayload,
|
||||
isResponseSuccess,
|
||||
isResponseError,
|
||||
} from "./rpc.js";
|
||||
|
||||
export { StreamAction, type StreamPayload } from "./stream.js";
|
||||
55
packages/sdk/src/actions/rpc.ts
Normal file
55
packages/sdk/src/actions/rpc.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/** RPC Actions - 请求/响应模式 */
|
||||
|
||||
export const RequestAction = "request" as const;
|
||||
export const ResponseAction = "response" as const;
|
||||
|
||||
/** 请求帧 payload */
|
||||
export interface RequestPayload<T = unknown> {
|
||||
/** 调用的方法名 */
|
||||
method: string;
|
||||
/** 方法参数 */
|
||||
params?: T;
|
||||
}
|
||||
|
||||
/** 响应帧 payload - 成功 */
|
||||
export interface ResponseSuccessPayload<T = unknown> {
|
||||
/** 与请求消息 ID 匹配 */
|
||||
requestId: string;
|
||||
/** 是否成功 */
|
||||
ok: true;
|
||||
/** 返回数据 */
|
||||
payload: T;
|
||||
}
|
||||
|
||||
/** 响应帧 payload - 失败 */
|
||||
export interface ResponseErrorPayload {
|
||||
/** 与请求消息 ID 匹配 */
|
||||
requestId: string;
|
||||
/** 是否成功 */
|
||||
ok: false;
|
||||
/** 错误信息 */
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** 响应帧 payload(联合类型) */
|
||||
export type ResponsePayload<T = unknown> =
|
||||
| ResponseSuccessPayload<T>
|
||||
| ResponseErrorPayload;
|
||||
|
||||
/** 类型守卫:判断响应是否成功 */
|
||||
export function isResponseSuccess<T>(
|
||||
response: ResponsePayload<T>
|
||||
): response is ResponseSuccessPayload<T> {
|
||||
return response.ok === true;
|
||||
}
|
||||
|
||||
/** 类型守卫:判断响应是否失败 */
|
||||
export function isResponseError(
|
||||
response: ResponsePayload
|
||||
): response is ResponseErrorPayload {
|
||||
return response.ok === false;
|
||||
}
|
||||
11
packages/sdk/src/actions/stream.ts
Normal file
11
packages/sdk/src/actions/stream.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** Stream Action - 流式消息传输 */
|
||||
|
||||
export const StreamAction = "stream" as const;
|
||||
|
||||
/** 流消息 payload */
|
||||
export interface StreamPayload<T = unknown> {
|
||||
/** 流 ID,用于关联同一个流的所有消息 */
|
||||
streamId: string;
|
||||
/** 数据 */
|
||||
data: T;
|
||||
}
|
||||
253
packages/sdk/src/client.ts
Normal file
253
packages/sdk/src/client.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { io, Socket } from "socket.io-client";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type {
|
||||
GatewayClientOptions,
|
||||
GatewayClientCallbacks,
|
||||
ConnectionState,
|
||||
RoutedMessage,
|
||||
RegisteredResponse,
|
||||
SendErrorResponse,
|
||||
PingPayload,
|
||||
DeviceType,
|
||||
} from "./types.js";
|
||||
import { GatewayEvents } from "./types.js";
|
||||
|
||||
interface ResolvedOptions {
|
||||
url: string;
|
||||
path: string;
|
||||
deviceId: string;
|
||||
deviceType: DeviceType;
|
||||
autoReconnect: boolean;
|
||||
reconnectDelay: number;
|
||||
}
|
||||
|
||||
export class GatewayClient {
|
||||
private socket: Socket | null = null;
|
||||
private options: ResolvedOptions;
|
||||
private callbacks: GatewayClientCallbacks = {};
|
||||
private _state: ConnectionState = "disconnected";
|
||||
|
||||
constructor(options: GatewayClientOptions) {
|
||||
if (!options.deviceId) {
|
||||
throw new Error("deviceId is required");
|
||||
}
|
||||
|
||||
this.options = {
|
||||
url: options.url,
|
||||
path: options.path ?? "/ws",
|
||||
deviceId: options.deviceId,
|
||||
deviceType: options.deviceType,
|
||||
autoReconnect: options.autoReconnect ?? true,
|
||||
reconnectDelay: options.reconnectDelay ?? 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前连接状态 */
|
||||
get state(): ConnectionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/** 设备ID */
|
||||
get deviceId(): string {
|
||||
return this.options.deviceId;
|
||||
}
|
||||
|
||||
/** 设备类型 */
|
||||
get deviceType(): DeviceType {
|
||||
return this.options.deviceType;
|
||||
}
|
||||
|
||||
/** Socket ID(连接后可用) */
|
||||
get socketId(): string | undefined {
|
||||
return this.socket?.id;
|
||||
}
|
||||
|
||||
/** 是否已连接 */
|
||||
get isConnected(): boolean {
|
||||
return this._state === "connected" || this._state === "registered";
|
||||
}
|
||||
|
||||
/** 是否已注册 */
|
||||
get isRegistered(): boolean {
|
||||
return this._state === "registered";
|
||||
}
|
||||
|
||||
/** 连接到服务器,deviceId 和 deviceType 通过 query 传递 */
|
||||
connect(): this {
|
||||
if (this.socket) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.setState("connecting");
|
||||
|
||||
const query: Record<string, string> = {
|
||||
deviceId: this.options.deviceId,
|
||||
deviceType: this.options.deviceType,
|
||||
};
|
||||
|
||||
this.socket = io(this.options.url, {
|
||||
path: this.options.path,
|
||||
query,
|
||||
reconnection: this.options.autoReconnect,
|
||||
reconnectionDelay: this.options.reconnectDelay,
|
||||
});
|
||||
|
||||
this.setupListeners();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
disconnect(): this {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
this.setState("disconnected");
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 发送消息给指定设备 */
|
||||
send<T = unknown>(
|
||||
to: string,
|
||||
action: string,
|
||||
payload: T,
|
||||
messageId?: string
|
||||
): string {
|
||||
if (!this.socket || !this.isRegistered) {
|
||||
throw new Error("Not registered");
|
||||
}
|
||||
|
||||
const id = messageId ?? this.generateMessageId();
|
||||
const message: RoutedMessage<T> = {
|
||||
id,
|
||||
uid: null,
|
||||
from: this.options.deviceId,
|
||||
to,
|
||||
action,
|
||||
payload,
|
||||
};
|
||||
|
||||
this.socket.emit(GatewayEvents.SEND, message);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 发送 ping */
|
||||
ping(data: PingPayload = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.socket || !this.isConnected) {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit(
|
||||
GatewayEvents.PING,
|
||||
data,
|
||||
(response: { event: string; data: string }) => {
|
||||
resolve(response.data);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 注册连接回调 */
|
||||
onConnect(callback: (socketId: string) => void): this {
|
||||
this.callbacks.onConnect = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册断开回调 */
|
||||
onDisconnect(callback: (reason: string) => void): this {
|
||||
this.callbacks.onDisconnect = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册成功回调 */
|
||||
onRegistered(callback: (deviceId: string) => void): this {
|
||||
this.callbacks.onRegistered = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册消息回调 */
|
||||
onMessage(callback: (message: RoutedMessage) => void): this {
|
||||
this.callbacks.onMessage = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册发送失败回调 */
|
||||
onSendError(callback: (error: SendErrorResponse) => void): this {
|
||||
this.callbacks.onSendError = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册 pong 回调 */
|
||||
onPong(callback: (data: string) => void): this {
|
||||
this.callbacks.onPong = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册错误回调 */
|
||||
onError(callback: (error: Error) => void): this {
|
||||
this.callbacks.onError = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 注册状态变化回调 */
|
||||
onStateChange(callback: (state: ConnectionState) => void): this {
|
||||
this.callbacks.onStateChange = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState): void {
|
||||
if (this._state !== state) {
|
||||
this._state = state;
|
||||
this.callbacks.onStateChange?.(state);
|
||||
}
|
||||
}
|
||||
|
||||
private generateMessageId(): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
this.setState("connected");
|
||||
this.callbacks.onConnect?.(this.socket!.id!);
|
||||
// 服务端在连接时从 query 自动注册,等待 registered 事件即可
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", (reason: string) => {
|
||||
this.setState("disconnected");
|
||||
this.callbacks.onDisconnect?.(reason);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
GatewayEvents.REGISTERED,
|
||||
(response: RegisteredResponse) => {
|
||||
if (response.success) {
|
||||
this.setState("registered");
|
||||
this.callbacks.onRegistered?.(response.deviceId);
|
||||
} else {
|
||||
this.callbacks.onError?.(new Error(response.error ?? "Registration failed"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(GatewayEvents.RECEIVE, (message: RoutedMessage) => {
|
||||
this.callbacks.onMessage?.(message);
|
||||
});
|
||||
|
||||
this.socket.on(GatewayEvents.SEND_ERROR, (error: SendErrorResponse) => {
|
||||
this.callbacks.onSendError?.(error);
|
||||
});
|
||||
|
||||
this.socket.on(GatewayEvents.PONG, (data: string) => {
|
||||
this.callbacks.onPong?.(data);
|
||||
});
|
||||
|
||||
this.socket.on("connect_error", (error: Error) => {
|
||||
this.callbacks.onError?.(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
17
packages/sdk/src/index.ts
Normal file
17
packages/sdk/src/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export { GatewayClient } from "./client.js";
|
||||
export {
|
||||
GatewayEvents,
|
||||
type DeviceType,
|
||||
type DeviceInfo,
|
||||
type RegisteredResponse,
|
||||
type RoutedMessage,
|
||||
type SendErrorResponse,
|
||||
type GatewayClientOptions,
|
||||
type GatewayClientCallbacks,
|
||||
type ConnectionState,
|
||||
type PingPayload,
|
||||
type PongResponse,
|
||||
} from "./types.js";
|
||||
|
||||
// Actions
|
||||
export * from "./actions/index.js";
|
||||
106
packages/sdk/src/types.ts
Normal file
106
packages/sdk/src/types.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/** WebSocket 事件名称 */
|
||||
export const GatewayEvents = {
|
||||
// 系统事件
|
||||
PING: "ping",
|
||||
PONG: "pong",
|
||||
REGISTERED: "registered",
|
||||
|
||||
// 消息路由
|
||||
SEND: "send",
|
||||
RECEIVE: "receive",
|
||||
SEND_ERROR: "send_error",
|
||||
} as const;
|
||||
|
||||
// ============ 设备相关 ============
|
||||
|
||||
/** 设备类型 */
|
||||
export type DeviceType = "client" | "agent";
|
||||
|
||||
/** 设备信息 */
|
||||
export interface DeviceInfo {
|
||||
deviceId: string;
|
||||
deviceType: DeviceType;
|
||||
}
|
||||
|
||||
/** 注册响应 */
|
||||
export interface RegisteredResponse {
|
||||
success: boolean;
|
||||
deviceId: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============ 消息路由 ============
|
||||
|
||||
/** 路由消息 */
|
||||
export interface RoutedMessage<T = unknown> {
|
||||
/** 消息唯一ID (UUID v7,包含时间戳) */
|
||||
id: string;
|
||||
/** 用户ID(登录后填充) */
|
||||
uid: string | null;
|
||||
/** 发送者 deviceId */
|
||||
from: string;
|
||||
/** 接收者 deviceId */
|
||||
to: string;
|
||||
/** 动作类型 */
|
||||
action: string;
|
||||
/** 消息内容 */
|
||||
payload: T;
|
||||
}
|
||||
|
||||
/** 发送失败响应 */
|
||||
export interface SendErrorResponse {
|
||||
messageId: string;
|
||||
error: string;
|
||||
code: "DEVICE_NOT_FOUND" | "NOT_REGISTERED" | "INVALID_MESSAGE";
|
||||
}
|
||||
|
||||
// ============ Ping/Pong ============
|
||||
|
||||
/** Ping 请求 */
|
||||
export interface PingPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Ping 响应 */
|
||||
export interface PongResponse {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
// ============ 客户端配置 ============
|
||||
|
||||
/** 连接配置 */
|
||||
export interface GatewayClientOptions {
|
||||
/** 服务器地址,如 http://localhost:3000 */
|
||||
url: string;
|
||||
/** WebSocket 路径,默认 /ws */
|
||||
path?: string | undefined;
|
||||
/** 设备ID */
|
||||
deviceId: string;
|
||||
/** 设备类型 */
|
||||
deviceType: DeviceType;
|
||||
/** 自动重连,默认 true */
|
||||
autoReconnect?: boolean | undefined;
|
||||
/** 重连延迟(毫秒),默认 1000 */
|
||||
reconnectDelay?: number | undefined;
|
||||
}
|
||||
|
||||
/** 连接状态 */
|
||||
export type ConnectionState =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "registered";
|
||||
|
||||
/** 事件回调类型 */
|
||||
export interface GatewayClientCallbacks {
|
||||
onConnect?: (socketId: string) => void;
|
||||
onDisconnect?: (reason: string) => void;
|
||||
onRegistered?: (deviceId: string) => void;
|
||||
onMessage?: (message: RoutedMessage) => void;
|
||||
onSendError?: (error: SendErrorResponse) => void;
|
||||
onPong?: (data: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onStateChange?: (state: ConnectionState) => void;
|
||||
}
|
||||
|
||||
19
packages/sdk/tsconfig.json
Normal file
19
packages/sdk/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
5604
pnpm-lock.yaml
generated
5604
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
16
turbo.json
Normal file
16
turbo.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue