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:
smueller
2026-07-22 13:56:33 +02:00
parent 7bc1684566
commit 04e04eb11d
39 changed files with 2394 additions and 14 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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