[功能] 添加新的输入、文本区域、标签和开关组件,优化样式和功能

This commit is contained in:
ryan
2026-03-20 23:15:35 +08:00
parent 2c87254bb3
commit a6787ac30d
12 changed files with 5948 additions and 156 deletions
+136 -8
View File
@@ -1,22 +1,26 @@
@import '../styles/tokens.css';
@import 'tailwindcss';
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
* {
box-sizing: border-box;
}
html {
background: var(--surface-canvas);
}
html[data-theme='light'] {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
body {
min-height: 100vh;
background: var(--body-background);
@@ -28,17 +32,141 @@ body {
background 0.2s ease,
color 0.2s ease;
}
a {
color: inherit;
text-decoration: none;
}
button {
cursor: pointer;
}
::selection {
background: var(--selection-background);
color: var(--selection-foreground);
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--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);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--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.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+7 -3
View File
@@ -5,8 +5,12 @@ import type { ReactNode } from 'react';
import { AppProviders } from '@/components/providers/app-providers';
import { getThemeInitScript } from '@/lib/theme/theme';
import './globals.css';
import './globals.css';
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: {
default: 'OpenFlare 控制台',
@@ -22,7 +26,7 @@ interface RootLayoutProps {
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang='zh-CN' suppressHydrationWarning>
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
<body>
<Script id='theme-init' strategy='beforeInteractive'>
{getThemeInitScript()}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Label = React.forwardRef<
HTMLLabelElement,
React.ComponentProps<'label'>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-sm font-medium leading-none text-[var(--foreground-primary)] peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = 'Label';
export { Label };
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,70 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
type SwitchProps = Omit<React.ComponentProps<'button'>, 'onChange'> & {
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
};
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
(
{
className,
checked,
defaultChecked = false,
disabled,
onCheckedChange,
...props
},
ref,
) => {
const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
const isControlled = checked !== undefined;
const isChecked = isControlled ? checked : internalChecked;
const updateChecked = React.useCallback(
(nextChecked: boolean) => {
if (!isControlled) {
setInternalChecked(nextChecked);
}
onCheckedChange?.(nextChecked);
},
[isControlled, onCheckedChange],
);
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={isChecked}
data-state={isChecked ? 'checked' : 'unchecked'}
disabled={disabled}
className={cn(
'peer inline-flex h-6 w-11 shrink-0 items-center rounded-full border border-transparent bg-[var(--control-background-hover)] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--brand-primary)]',
className,
)}
onClick={() => updateChecked(!isChecked)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
updateChecked(!isChecked);
}
}}
{...props}
>
<span
data-state={isChecked ? 'checked' : 'unchecked'}
className="pointer-events-none block h-5 w-5 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-5"
/>
</button>
);
},
);
Switch.displayName = 'Switch';
export { Switch };
@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-28 w-full rounded-xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-2 text-sm text-[var(--foreground-primary)] shadow-none outline-none transition placeholder:text-[var(--foreground-muted)] focus-visible:border-[var(--border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };
@@ -1,12 +1,12 @@
'use client';
import { Input, Textarea } from '@heroui/input';
import { Select, SelectItem, SelectSection } from '@heroui/select';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { Separator } from "@/components/ui/separator"
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -14,8 +14,12 @@ import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppCard } from '@/components/ui/app-card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
getConfigVersionDiff,
publishConfigVersion,
@@ -274,20 +278,6 @@ type SectionIconProps = {
const INPUT_CLASS_NAME = 'h-10 rounded-xl px-3 py-2 text-sm';
const PANEL_CLASS_NAME =
'rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4 shadow-[var(--shadow-soft)]';
const HERO_INPUT_CLASS_NAMES = {
base: 'w-full',
mainWrapper: 'w-full',
inputWrapper:
'min-h-10 rounded-xl border border-[var(--border-default)] shadow-none',
input: 'text-sm',
} as const;
const HERO_TEXTAREA_CLASS_NAMES = {
base: 'w-full',
mainWrapper: 'w-full',
inputWrapper:
'min-h-28 rounded-xl border border-[var(--border-default)] shadow-none',
input: 'text-sm',
} as const;
const HERO_SELECT_CLASS_NAMES = {
base: 'w-full',
trigger: cn(
@@ -1078,13 +1068,7 @@ function HeroTextInput(
return (
<Input
variant="bordered"
size="sm"
radius="lg"
classNames={{
...HERO_INPUT_CLASS_NAMES,
base: cn(HERO_INPUT_CLASS_NAMES.base, className),
}}
className={cn(INPUT_CLASS_NAME, className)}
{...restProps}
/>
);
@@ -1097,13 +1081,7 @@ function HeroTextArea(
return (
<Textarea
variant="bordered"
size="sm"
radius="lg"
classNames={{
...HERO_TEXTAREA_CLASS_NAMES,
base: cn(HERO_TEXTAREA_CLASS_NAMES.base, className),
}}
className={className}
{...restProps}
/>
);
@@ -1151,6 +1129,42 @@ function HeroSelectField({
);
}
function HeroSwitchField({
id,
label,
description,
checked,
disabled,
onChange,
}: {
id: string;
label: string;
description?: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-4 rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] px-4 py-4">
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
{description ? (
<p className="text-sm leading-5 text-[var(--foreground-secondary)]">
{description}
</p>
) : null}
</div>
<Switch
id={id}
checked={checked}
disabled={disabled}
aria-label={label}
onCheckedChange={onChange}
/>
</div>
);
}
export function ProxyRoutesPage() {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
@@ -1159,7 +1173,7 @@ export function ProxyRoutesPage() {
const [matchResult, setMatchResult] =
useState<ManagedDomainMatchResult | null>(null);
const [isMatching, setIsMatching] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(true);
const form = useForm<ProxyRouteFormValues>({
resolver: zodResolver(proxyRouteSchema),
@@ -1270,23 +1284,6 @@ export function ProxyRoutesPage() {
const primaryOriginRow =
watchedOriginRows?.[0] ?? defaultValues.origin_rows[0];
const matchedOrigin = useMemo(
() =>
origins.find(
(item) =>
item.address.toLowerCase() ===
primaryOriginRow.address.trim().toLowerCase(),
) ?? null,
[origins, primaryOriginRow.address],
);
const primaryOriginPreview = buildOriginUrl(
primaryOriginRow.scheme,
primaryOriginRow.address,
primaryOriginRow.port,
watchedOriginURI,
);
const saveMutation = useMutation({
mutationFn: async (values: ProxyRouteFormValues) => {
const payload = toPayload(values, origins);
@@ -1688,8 +1685,18 @@ export function ProxyRoutesPage() {
className="space-y-6"
onSubmit={handleSubmit}
>
<HeroSwitchField
id="proxy-route-enabled"
label="启用规则"
description="关闭后该规则不会参与配置渲染与发布。"
checked={watchedEnabled}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<ProxyRuleSection
title="域名设置"
title="Domain"
icon={<GlobeIcon className="h-4 w-4" />}
>
<div className="space-y-4">
@@ -1726,7 +1733,7 @@ export function ProxyRoutesPage() {
</ProxyRuleSection>
<ProxyRuleSection
title="Origin Settings"
title="Origin"
icon={<ServerIcon className="h-4 w-4" />}
action={
<button
@@ -1841,41 +1848,20 @@ export function ProxyRoutesPage() {
?.message}
</p>
)}
{index === 0 ? (
<div>
<span className="sr-only">Origin Path / Query</span>
<HeroTextInput
aria-label="Origin Path / Query"
placeholder="/api"
{...form.register('origin_uri')}
/>
</div>
) : null}
</div>
);
})}
<div className="grid gap-4 md:grid-cols-[1fr_1fr]">
<ResourceField
label="Primary Origin Preview"
hint={
matchedOrigin
? `当前会复用已存在的源站 ${matchedOrigin.name}。`
: '如果地址尚未收录,保存规则时会自动创建一个新源站。'
}
>
<div
className={cn(
'flex items-center rounded-xl border border-dashed border-[var(--border-default)] bg-[var(--surface-panel)] px-3 font-mono text-sm text-[var(--foreground-secondary)]',
INPUT_CLASS_NAME,
)}
>
{primaryOriginPreview || '填写主源站后显示完整回源地址'}
</div>
</ResourceField>
<ResourceField
label="Origin Path / Query"
hint="保留原有高级能力,可选填写 /api 或 ?token=demo。"
error={form.formState.errors.origin_uri?.message}
>
<HeroTextInput
aria-label="Origin Path / Query"
placeholder="/api"
{...form.register('origin_uri')}
/>
</ResourceField>
</div>
</div>
</ProxyRuleSection>
@@ -1884,49 +1870,57 @@ export function ProxyRoutesPage() {
icon={<ShieldIcon className="h-4 w-4" />}
>
<div className="space-y-4">
<div className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<ToggleField
label="HTTPS Access"
description="若所选域名已有证书,系统会默认开启;开启后即可选择或调整证书。"
checked={watchedEnableHttps}
onChange={(checked) => {
form.setValue('enable_https', checked, {
shouldDirty: true,
shouldValidate: true,
});
if (checked && !form.getValues('cert_id') && selectedManagedDomain?.cert_id) {
form.setValue('cert_id', String(selectedManagedDomain.cert_id), {
<div className="rounded-2xl border border-[var(--border-default)] bg-[color:color-mix(in_srgb,var(--surface-elevated)_82%,white_18%)] p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
HTTPS Access
</p>
<p className="text-sm leading-5 text-[var(--foreground-secondary)]">
Enforce secure TLS connections for this rule.
</p>
</div>
<Switch
id="proxy-route-enable-https"
checked={watchedEnableHttps}
aria-label="HTTPS Access"
onCheckedChange={(checked) => {
form.setValue('enable_https', checked, {
shouldDirty: true,
shouldValidate: true,
});
}
if (!checked) {
form.setValue('cert_id', '', {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('redirect_http', false, {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
if (
checked &&
!form.getValues('cert_id') &&
selectedManagedDomain?.cert_id
) {
form.setValue(
'cert_id',
String(selectedManagedDomain.cert_id),
{
shouldDirty: true,
shouldValidate: true,
},
);
}
<ToggleField
label="启用规则"
description="关闭后该规则不会参与配置渲染与发布。"
checked={watchedEnabled}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
</div>
if (!checked) {
form.setValue('cert_id', '', {
shouldDirty: true,
shouldValidate: true,
});
form.setValue('redirect_http', false, {
shouldDirty: true,
shouldValidate: true,
});
}
}}
/>
</div>
{watchedEnableHttps ? (
<div className="grid gap-4 lg:grid-cols-[1.15fr_0.85fr]">
{watchedEnableHttps ? (
<div className="mt-5 space-y-4">
<ResourceField
label="Select Certificate"
hint={getMatchMessage(
@@ -1974,20 +1968,21 @@ export function ProxyRoutesPage() {
</HeroSelectField>
</ResourceField>
<ToggleField
label="HTTP 跳转 HTTPS"
description="开启后会将 HTTP 请求重定向到 HTTPS。"
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
) : null}
<ToggleField
label="HTTP 跳转 HTTPS"
description="开启后会将 HTTP 请求重定向到 HTTPS。"
checked={watchedRedirectHttp}
disabled={!watchedEnableHttps}
onChange={(checked) =>
form.setValue('redirect_http', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
) : null}
</div>
{matchResult?.matched && matchResult.candidates.length > 1 ? (
<div className="flex flex-wrap gap-2">
@@ -2016,7 +2011,7 @@ export function ProxyRoutesPage() {
<SlidersIcon className="h-4 w-4" />
</span>
<h3 className="text-sm font-semibold uppercase tracking-[0.16em]">
Advanced Settings
Advanced
</h3>
</div>
<ChevronIcon
@@ -2027,7 +2022,7 @@ export function ProxyRoutesPage() {
{isAdvancedOpen ? (
<div className={cn(PANEL_CLASS_NAME, 'space-y-4')}>
<div className="grid gap-4 lg:grid-cols-2">
<div className="grid gap-4 lg:grid-cols-1">
<div className="space-y-4">
<ResourceField
label="Origin Host Header"
@@ -2101,7 +2096,7 @@ export function ProxyRoutesPage() {
</div>
</div>
</div>
<Separator />
<div className="space-y-4">
<ToggleField
label="Enable Rule Caching"
+6
View 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 -1
View File
@@ -19,18 +19,24 @@
},
"dependencies": {
"@heroui/input": "^2.4.33",
"@heroui/react": "^2.8.10",
"@heroui/select": "^2.4.33",
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.66.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"lucide-react": "^0.577.0",
"marked": "^17.0.4",
"next": "15.2.2",
"radix-ui": "^1.4.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.54.2",
"tailwind-merge": "^3.0.2",
"shadcn": "^4.1.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
+5474 -9
View File
File diff suppressed because it is too large Load Diff