Update fun module configuration and enhance CSS styles
- Added 'enabled' property to FunConfigSchema with a default value of true for better configuration management. - Refactored getFunConfig to return a complete default configuration when no data is found. - Improved globals.css by expanding CSS variables for light and dark themes, enhancing styling consistency across the application.
This commit is contained in:
30
apps/webui/src/components/layout/dashboard-shell.tsx
Normal file
30
apps/webui/src/components/layout/dashboard-shell.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DashboardGuild, SessionUser } from '@nexumi/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ServerSwitcher } from './server-switcher';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { UserMenu } from './user-menu';
|
||||
|
||||
interface DashboardShellProps {
|
||||
guildId: string;
|
||||
currentGuild: DashboardGuild;
|
||||
otherGuilds: DashboardGuild[];
|
||||
user: SessionUser;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardShell({ guildId, currentGuild, otherGuilds, user, children }: DashboardShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar guildId={guildId} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center justify-between border-b border-border px-6">
|
||||
<ServerSwitcher currentGuild={currentGuild} otherGuilds={otherGuilds} />
|
||||
<UserMenu user={user} />
|
||||
</header>
|
||||
<main className="flex-1 px-6 py-8">
|
||||
<div className="mx-auto w-full max-w-[1200px]">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/webui/src/components/layout/server-switcher.tsx
Normal file
77
apps/webui/src/components/layout/server-switcher.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import type { DashboardGuild } from '@nexumi/shared';
|
||||
import { ChevronsUpDown, LayoutGrid } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
function guildIconUrl(guild: Pick<DashboardGuild, 'id' | 'icon'>): string | undefined {
|
||||
return guild.icon ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=64` : undefined;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
interface ServerSwitcherProps {
|
||||
currentGuild: DashboardGuild;
|
||||
otherGuilds: DashboardGuild[];
|
||||
}
|
||||
|
||||
export function ServerSwitcher({ currentGuild, otherGuilds }: ServerSwitcherProps) {
|
||||
const t = useTranslations();
|
||||
const botGuilds = otherGuilds.filter((guild) => guild.botPresent);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium outline-none hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={guildIconUrl(currentGuild)} alt={currentGuild.name} />
|
||||
<AvatarFallback>{initials(currentGuild.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-40 truncate">{currentGuild.name}</span>
|
||||
<ChevronsUpDown className="size-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel>{t('serverSwitcher.switchServer')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{botGuilds.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-sm text-muted-foreground">{t('serverSwitcher.noOtherServers')}</p>
|
||||
) : (
|
||||
botGuilds.map((guild) => (
|
||||
<DropdownMenuItem key={guild.id} asChild>
|
||||
<Link href={`/dashboard/${guild.id}`} className="flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={guildIconUrl(guild)} alt={guild.name} />
|
||||
<AvatarFallback>{initials(guild.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{guild.name}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<LayoutGrid className="size-4" />
|
||||
{t('serverSwitcher.allServers')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
86
apps/webui/src/components/layout/sidebar.tsx
Normal file
86
apps/webui/src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { DASHBOARD_MODULES, type DashboardModuleGroup } from '@nexumi/shared';
|
||||
import { LayoutGrid, Settings, ShieldCheck, SlidersHorizontal } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MODULE_GROUP_ORDER: DashboardModuleGroup[] = [
|
||||
'moderation',
|
||||
'engagement',
|
||||
'community',
|
||||
'utility',
|
||||
'integrations'
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ guildId }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
const base = `/dashboard/${guildId}`;
|
||||
|
||||
const groups = MODULE_GROUP_ORDER.map((group) => ({
|
||||
group,
|
||||
modules: DASHBOARD_MODULES.filter((module) => module.group === group)
|
||||
}));
|
||||
|
||||
return (
|
||||
<nav className="flex h-full w-60 flex-col gap-6 overflow-y-auto border-r border-border px-3 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<NavLink href={base} active={pathname === base}>
|
||||
<LayoutGrid className="size-4" />
|
||||
{t('nav.overview')}
|
||||
</NavLink>
|
||||
<NavLink href={`${base}/settings`} active={pathname === `${base}/settings`}>
|
||||
<Settings className="size-4" />
|
||||
{t('nav.settings')}
|
||||
</NavLink>
|
||||
<NavLink href={`${base}/modules`} active={pathname === `${base}/modules`}>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
{t('nav.modules')}
|
||||
</NavLink>
|
||||
<NavLink href={`${base}/access`} active={pathname === `${base}/access`}>
|
||||
<ShieldCheck className="size-4" />
|
||||
{t('nav.access')}
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
{groups.map(({ group, modules }) => (
|
||||
<div key={group} className="flex flex-col gap-1">
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(`moduleGroups.${group}`)}
|
||||
</p>
|
||||
{modules.map((module) => {
|
||||
const href = `${base}/${module.href}`;
|
||||
return (
|
||||
<NavLink key={module.id} href={href} active={pathname === href}>
|
||||
{t(`modules.${module.id}.label`)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
12
apps/webui/src/components/layout/theme-provider.tsx
Normal file
12
apps/webui/src/components/layout/theme-provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
73
apps/webui/src/components/layout/user-menu.tsx
Normal file
73
apps/webui/src/components/layout/user-menu.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import type { SessionUser } from '@nexumi/shared';
|
||||
import { LogOut, Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
function userAvatarUrl(user: SessionUser): string | undefined {
|
||||
return user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=64` : undefined;
|
||||
}
|
||||
|
||||
function initials(user: SessionUser): string {
|
||||
const name = user.globalName ?? user.username;
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function UserMenu({ user }: { user: SessionUser }) {
|
||||
const t = useTranslations();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Logout failed');
|
||||
}
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(t('errors.generic'));
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={userAvatarUrl(user)} alt={user.username} />
|
||||
<AvatarFallback>{initials(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="truncate">{user.globalName ?? user.username}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
{theme === 'dark' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut} variant="destructive">
|
||||
<LogOut className="size-4" />
|
||||
{isLoggingOut ? t('userMenu.loggingOut') : t('userMenu.logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
63
apps/webui/src/components/locale-provider.tsx
Normal file
63
apps/webui/src/components/locale-provider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
||||
import type { Locale, Messages } from '@/lib/i18n';
|
||||
|
||||
interface LocaleContextValue {
|
||||
locale: Locale;
|
||||
messages: Messages;
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function LocaleProvider({
|
||||
locale,
|
||||
messages,
|
||||
children
|
||||
}: {
|
||||
locale: Locale;
|
||||
messages: Messages;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo<LocaleContextValue>(() => ({ locale, messages }), [locale, messages]);
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
|
||||
}
|
||||
|
||||
function useLocaleContext(): LocaleContextValue {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTranslations/useLocale must be used within a LocaleProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function resolve(messages: Messages, key: string): string | undefined {
|
||||
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === 'object' && part in (acc as Record<string, unknown>)) {
|
||||
return (acc as Record<string, unknown>)[part];
|
||||
}
|
||||
return undefined;
|
||||
}, messages as unknown);
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) {
|
||||
return template;
|
||||
}
|
||||
return Object.entries(vars).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
template
|
||||
);
|
||||
}
|
||||
|
||||
export type TranslateFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export function useTranslations(): TranslateFn {
|
||||
const { messages } = useLocaleContext();
|
||||
return (key, vars) => interpolate(resolve(messages, key) ?? key, vars);
|
||||
}
|
||||
|
||||
export function useLocale(): Locale {
|
||||
return useLocaleContext().locale;
|
||||
}
|
||||
95
apps/webui/src/components/settings/general-settings-form.tsx
Normal file
95
apps/webui/src/components/settings/general-settings-form.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import type { GuildGeneralSettings } from '@nexumi/shared';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from './settings-form';
|
||||
import { SettingsForm } from './settings-form';
|
||||
|
||||
interface GeneralSettingsFormProps {
|
||||
guildId: string;
|
||||
initialSettings: GuildGeneralSettings;
|
||||
}
|
||||
|
||||
async function saveGeneralSettings(guildId: string, value: GuildGeneralSettings): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
export function GeneralSettingsForm({ guildId, initialSettings }: GeneralSettingsFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<GuildGeneralSettings>
|
||||
initialValue={initialSettings}
|
||||
onSave={(value) => saveGeneralSettings(guildId, value)}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.general.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.general.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="general-locale">{t('settings.general.localeLabel')}</Label>
|
||||
<Select
|
||||
value={value.locale}
|
||||
onValueChange={(locale) => setValue((prev) => ({ ...prev, locale: locale as 'de' | 'en' }))}
|
||||
>
|
||||
<SelectTrigger id="general-locale" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="de">Deutsch</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.general.localeHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="general-timezone">{t('settings.general.timezoneLabel')}</Label>
|
||||
<Input
|
||||
id="general-timezone"
|
||||
className="w-72"
|
||||
value={value.timezone}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, timezone: event.target.value }))}
|
||||
placeholder="Europe/Berlin"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.general.timezoneHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="general-moderation">{t('settings.general.moderationLabel')}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.general.moderationHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="general-moderation"
|
||||
checked={value.moderationEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
43
apps/webui/src/components/settings/save-bar.tsx
Normal file
43
apps/webui/src/components/settings/save-bar.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SaveBarProps {
|
||||
visible: boolean;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
export function SaveBar({ visible, saving, onSave, onDiscard }: SaveBarProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!visible}
|
||||
className={cn(
|
||||
'pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4 pb-4 transition-transform duration-200',
|
||||
visible ? 'translate-y-0' : 'translate-y-full'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full max-w-3xl items-center justify-between gap-4 rounded-lg border border-border bg-card px-4 py-3 shadow-lg',
|
||||
visible && 'pointer-events-auto'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{t('common.unsavedChanges')}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onDiscard} disabled={saving}>
|
||||
{t('common.discard')}
|
||||
</Button>
|
||||
<Button type="button" onClick={onSave} disabled={saving}>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
apps/webui/src/components/settings/settings-form.tsx
Normal file
73
apps/webui/src/components/settings/settings-form.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, useTransition, type ReactNode } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { SaveBar } from './save-bar';
|
||||
|
||||
export type SettingsSaveResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export interface SettingsFormRenderProps<T> {
|
||||
value: T;
|
||||
setValue: (updater: T | ((previous: T) => T)) => void;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SettingsFormProps<T> {
|
||||
initialValue: T;
|
||||
onSave: (value: T) => Promise<SettingsSaveResult>;
|
||||
children: (props: SettingsFormRenderProps<T>) => ReactNode;
|
||||
isEqual?: (a: T, b: T) => boolean;
|
||||
}
|
||||
|
||||
function defaultIsEqual<T>(a: T, b: T): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic settings framework used by every module settings page: tracks a
|
||||
* local draft against the last-saved baseline, shows a sticky save bar while
|
||||
* dirty, and surfaces inline + toast errors on save failure.
|
||||
*/
|
||||
export function SettingsForm<T>({ initialValue, onSave, children, isEqual = defaultIsEqual }: SettingsFormProps<T>) {
|
||||
const t = useTranslations();
|
||||
const [baseline, setBaseline] = useState(initialValue);
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isDirty = !isEqual(baseline, value);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setValue(baseline);
|
||||
setError(null);
|
||||
}, [baseline]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await onSave(value);
|
||||
if (result.ok) {
|
||||
setBaseline(value);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
} else {
|
||||
setError(result.error);
|
||||
toast.error(result.error || t('common.saveError'));
|
||||
}
|
||||
} catch {
|
||||
setError(t('common.saveError'));
|
||||
toast.error(t('common.saveError'));
|
||||
}
|
||||
});
|
||||
}, [onSave, t, value]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20">
|
||||
{children({ value, setValue, isDirty, isSaving: isPending, error })}
|
||||
<SaveBar visible={isDirty} saving={isPending} onSave={handleSave} onDiscard={handleDiscard} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/webui/src/components/ui/avatar.tsx
Normal file
43
apps/webui/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="avatar"
|
||||
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square size-full', className)} {...props} />
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
29
apps/webui/src/components/ui/badge.tsx
Normal file
29
apps/webui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
outline: 'border-border text-foreground',
|
||||
success: 'border-transparent bg-emerald-500/15 text-emerald-500',
|
||||
destructive: 'border-transparent bg-destructive/15 text-destructive'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
53
apps/webui/src/components/ui/button.tsx
Normal file
53
apps/webui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-6',
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
46
apps/webui/src/components/ui/card.tsx
Normal file
46
apps/webui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn('rounded-lg border border-border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn('flex flex-col gap-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3 data-slot="card-title" className={cn('text-base font-semibold leading-none', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p data-slot="card-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div data-slot="card-content" className={cn('p-6 pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div data-slot="card-footer" className={cn('flex items-center gap-2 p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
175
apps/webui/src/components/ui/dropdown-menu.tsx
Normal file
175
apps/webui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-32 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-48 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
>(({ className, inset, variant = 'default', ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
variant === 'destructive' && 'text-destructive focus:bg-destructive/10 focus:text-destructive',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
function DropdownMenuShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup
|
||||
};
|
||||
22
apps/webui/src/components/ui/input.tsx
Normal file
22
apps/webui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
23
apps/webui/src/components/ui/label.tsx
Normal file
23
apps/webui/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
148
apps/webui/src/components/ui/select.tsx
Normal file
148
apps/webui/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton
|
||||
};
|
||||
26
apps/webui/src/components/ui/separator.tsx
Normal file
26
apps/webui/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
data-slot="separator"
|
||||
className={cn(
|
||||
'bg-border shrink-0',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
14
apps/webui/src/components/ui/skeleton.tsx
Normal file
14
apps/webui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn('animate-pulse rounded-md bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
29
apps/webui/src/components/ui/switch.tsx
Normal file
29
apps/webui/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
Reference in New Issue
Block a user