feat(settings): add settings page with layout, form, and components

Introduce a new settings page with a structured layout, form components, and hooks for managing application settings. This includes the addition of settings-specific types, constants, and UI components such as `SettingsForm`, `SettingsGroup`, and `SettingsInput`. The `useSettings` hook handles configuration loading and updates, while the `LogoInput` component manages logo uploads and removal. The `Select` component from Radix UI is also added to support dropdown functionality.
This commit is contained in:
Daniel Luiz Alves
2025-04-11 15:47:42 -03:00
parent b4cecf9e32
commit 5cd7acc158
14 changed files with 2960 additions and 2282 deletions

View File

@@ -20,6 +20,7 @@
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.4",

4351
apps/app/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,6 @@ export async function generateMetadata(): Promise<Metadata> {
};
}
export default function DashboardLayout({ children }: LayoutProps) {
export default function FilesLayout({ children }: LayoutProps) {
return <>{children}</>;
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useAppInfo } from "@/contexts/app-info-context";
import { removeLogo, uploadLogo } from "@/http/endpoints";
import { Button } from "@/components/ui/button";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { IconCloudUpload, IconTrash } from "@tabler/icons-react";
import { toast } from "sonner";
interface LogoInputProps {
value?: string;
onChange: (value: string) => void;
isDisabled?: boolean;
}
export function LogoInput({ value, onChange, isDisabled }: LogoInputProps) {
const t = useTranslations();
const [isUploading, setIsUploading] = useState(false);
const [currentLogo, setCurrentLogo] = useState(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const { refreshAppInfo, appLogo } = useAppInfo();
useEffect(() => {
setCurrentLogo(appLogo);
}, [appLogo]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setIsUploading(true);
const response = await uploadLogo({ file: file });
const newLogoUrl = response.data.logo;
setCurrentLogo(newLogoUrl);
onChange(newLogoUrl);
await refreshAppInfo();
toast.success(t("logo.messages.uploadSuccess"));
} catch (error: any) {
toast.error(error.response?.data?.error || t("logo.errors.uploadFailed"));
console.error(error);
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleRemoveLogo = async () => {
try {
setIsUploading(true);
await removeLogo();
setCurrentLogo("");
onChange("");
await refreshAppInfo();
toast.success(t("logo.messages.removeSuccess"));
} catch (error: any) {
toast.error(error.response?.data?.error || t("logo.errors.removeFailed"));
console.error(error);
} finally {
setIsUploading(false);
}
};
return (
<div className="flex flex-col gap-4">
<input
ref={fileInputRef}
accept="image/*"
className="hidden"
disabled={isDisabled}
type="file"
onChange={handleFileSelect}
/>
{currentLogo ? (
<div className="flex flex-col items-center gap-4">
<div className="relative max-w-[200px] max-h-[200px] flex">
<img
alt={t("logo.labels.appLogo")}
className="rounded-lg"
src={currentLogo}
sizes="200px"
/>
</div>
<Button
variant="destructive"
disabled={isDisabled}
onClick={handleRemoveLogo}
>
{!isUploading && <IconTrash className="mr-2 h-4 w-4" />}
{t("logo.buttons.remove")}
</Button>
</div>
) : (
<Button
className="w-full py-8"
variant="outline"
disabled={isDisabled}
onClick={() => fileInputRef.current?.click()}
>
{!isUploading && <IconCloudUpload className="mr-2 h-5 w-5" />}
{t("logo.buttons.upload")}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { ValidGroup } from "../types";
import { SettingsFormProps } from "../types";
import { SettingsGroup } from "./settings-group";
const GROUP_ORDER: ValidGroup[] = ["general", "email", "security", "storage"];
export function SettingsForm({
groupedConfigs,
collapsedGroups,
groupForms,
onGroupSubmit,
onToggleCollapse,
}: SettingsFormProps) {
const sortedGroups = Object.entries(groupedConfigs).sort(([a], [b]) => {
const indexA = GROUP_ORDER.indexOf(a as ValidGroup);
const indexB = GROUP_ORDER.indexOf(b as ValidGroup);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
return (
<div className="flex flex-col gap-6">
{sortedGroups.map(([group, configs]) => (
<SettingsGroup
key={group}
configs={configs}
form={groupForms[group as ValidGroup]}
group={group}
isCollapsed={collapsedGroups[group]}
onSubmit={(data) => onGroupSubmit(group as ValidGroup, data)}
onToggleCollapse={() => onToggleCollapse(group as ValidGroup)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { createGroupMetadata, createFieldDescriptions } from "../constants";
import { SettingsGroupProps } from "../types";
import { SettingsInput } from "./settings-input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "next-intl";
import React from "react";
import { IconChevronDown, IconChevronUp, IconDeviceFloppy } from "@tabler/icons-react";
export function SettingsGroup({ group, configs, form, isCollapsed, onToggleCollapse, onSubmit }: SettingsGroupProps) {
const t = useTranslations();
const GROUP_METADATA = createGroupMetadata(t);
const FIELD_DESCRIPTIONS = createFieldDescriptions(t);
const metadata = GROUP_METADATA[group as keyof typeof GROUP_METADATA] || {
title: group,
description: t("settings.groups.defaultDescription"),
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card className="p-6 gap-0">
<CardHeader className="flex flex-row items-center justify-between cursor-pointer p-0" onClick={onToggleCollapse}>
<div className="flex flex-row items-center gap-8">
{metadata.icon && React.createElement(metadata.icon, { className: "text-xl text-muted-foreground" })}
<div className="flex flex-col gap-1">
<h2 className="text-xl font-semibold">
{t(`settings.groups.${group}.title`, { defaultValue: metadata.title })}
</h2>
<p className="text-sm text-muted-foreground">
{t(`settings.groups.${group}.description`, { defaultValue: metadata.description })}
</p>
</div>
</div>
{isCollapsed ? <IconChevronDown className="text-muted-foreground" /> : <IconChevronUp className="text-muted-foreground" />}
</CardHeader>
<CardContent className={`${isCollapsed ? "hidden" : "block"} px-0`}>
<Separator className="my-6" />
<div className="flex flex-col gap-4">
{configs.map((config) => (
<div key={config.key} className="space-y-2 mb-3">
<SettingsInput
config={config}
error={form.formState.errors.configs?.[config.key]}
register={form.register}
smtpEnabled={form.watch("configs.smtpEnabled")}
watch={form.watch}
/>
<p className="text-xs text-muted-foreground ml-1">
{t(`settings.fields.${config.key}.description`, {
defaultValue:
FIELD_DESCRIPTIONS[config.key as keyof typeof FIELD_DESCRIPTIONS] ||
config.description ||
t("settings.fields.noDescription"),
})}
</p>
</div>
))}
</div>
<div className="flex justify-end mt-4">
<Button
variant="default"
disabled={form.formState.isSubmitting}
className="flex items-center gap-2"
type="submit"
>
{!form.formState.isSubmitting && <IconDeviceFloppy className="h-4 w-4" />}
{t("settings.buttons.save", {
group: t(`settings.groups.${group}.title`, { defaultValue: metadata.title }),
})}
</Button>
</div>
</CardContent>
</Card>
</form>
);
}

View File

@@ -0,0 +1,44 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "next-intl";
import { IconSettings } from "@tabler/icons-react";
import { IconLayoutDashboard } from "@tabler/icons-react";
import Link from "next/link";
export function SettingsHeader() {
const t = useTranslations();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row items-center gap-2">
<IconSettings className="text-xl" />
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
</div>
<Separator />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/dashboard" className="flex items-center">
<IconLayoutDashboard size={20} className="mr-2" />
{t("navigation.dashboard")}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<span className="flex items-center gap-2">
<IconSettings size={20} /> {t("settings.breadcrumb")}
</span>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useTranslations } from "next-intl";
import { createFieldTitles } from "../constants";
import { Config } from "../types";
import { LogoInput } from "./logo-input";
import { Input } from "@/components/ui/input";
import { UseFormRegister, UseFormWatch } from "react-hook-form";
export interface ConfigInputProps {
config: Config;
register: UseFormRegister<any>;
watch: UseFormWatch<any>;
error?: any;
smtpEnabled?: string;
}
export function SettingsInput({ config, register, watch, error, smtpEnabled }: ConfigInputProps) {
const t = useTranslations();
const FIELD_TITLES = createFieldTitles(t);
const isSmtpField = config.group === "email" && config.key !== "smtpEnabled";
const isDisabled = isSmtpField && smtpEnabled === "false";
const friendlyLabel = FIELD_TITLES[config.key as keyof ReturnType<typeof createFieldTitles>] || config.key;
if (config.key === "appLogo") {
const value = watch(`configs.${config.key}`);
return (
<div className="space-y-2">
<label className="block text-sm font-semibold">{friendlyLabel}</label>
<LogoInput
isDisabled={isDisabled}
value={value}
onChange={(value) => {
register(`configs.${config.key}`).onChange({
target: { value },
});
}}
/>
{error && <p className="text-danger text-xs mt-1">{error.message}</p>}
</div>
);
}
switch (config.type) {
case "boolean":
return (
<select
{...register(`configs.${config.key}`)}
className="w-full rounded-md border border-input bg-transparent px-3 py-2"
disabled={isDisabled}
>
<option value="true">{t("common.yes")}</option>
<option value="false">{t("common.no")}</option>
</select>
);
case "number":
case "bigint":
return (
<Input
{...register(`configs.${config.key}`, {
valueAsNumber: true,
})}
className="w-full"
disabled={isDisabled}
aria-invalid={!!error}
type="number"
/>
);
case "text":
default:
return (
<Input
{...register(`configs.${config.key}`)}
className="w-full"
disabled={isDisabled}
aria-invalid={!!error}
type="text"
/>
);
}
}

View File

@@ -0,0 +1,79 @@
import { createTranslator } from 'next-intl';
import { IconMail, IconSettings, IconShield, IconDatabase } from '@tabler/icons-react';
export const createGroupMetadata = (t: ReturnType<typeof createTranslator>) => ({
email: {
title: t('settings.groups.email.title'),
description: t('settings.groups.email.description'),
icon: IconMail,
},
general: {
title: t('settings.groups.general.title'),
description: t('settings.groups.general.description'),
icon: IconSettings,
},
security: {
title: t('settings.groups.security.title'),
description: t('settings.groups.security.description'),
icon: IconShield,
},
storage: {
title: t('settings.groups.storage.title'),
description: t('settings.groups.storage.description'),
icon: IconDatabase,
},
});
export const createFieldDescriptions = (t: ReturnType<typeof createTranslator>) => ({
// General settings
appLogo: t("settings.fields.appLogo.description"),
appName: t("settings.fields.appName.description"),
appDescription: t("settings.fields.appDescription.description"),
showHomePage: t("settings.fields.showHomePage.description"),
// Email settings
smtpEnabled: t("settings.fields.smtpEnabled.description"),
smtpHost: t("settings.fields.smtpHost.description"),
smtpPort: t("settings.fields.smtpPort.description"),
smtpUser: t("settings.fields.smtpUser.description"),
smtpPass: t("settings.fields.smtpPass.description"),
smtpFromName: t("settings.fields.smtpFromName.description"),
smtpFromEmail: t("settings.fields.smtpFromEmail.description"),
// Security settings
maxLoginAttempts: t("settings.fields.maxLoginAttempts.description"),
loginBlockDuration: t("settings.fields.loginBlockDuration.description"),
passwordMinLength: t("settings.fields.passwordMinLength.description"),
passwordResetTokenExpiration: t("settings.fields.passwordResetTokenExpiration.description"),
// Storage settings
maxFileSize: t("settings.fields.maxFileSize.description"),
maxTotalStoragePerUser: t("settings.fields.maxTotalStoragePerUser.description"),
});
export const createFieldTitles = (t: ReturnType<typeof createTranslator>) => ({
// General settings
appLogo: t("settings.fields.appLogo.title"),
appName: t("settings.fields.appName.title"),
appDescription: t("settings.fields.appDescription.title"),
showHomePage: t("settings.fields.showHomePage.title"),
// Email settings
smtpEnabled: t("settings.fields.smtpEnabled.title"),
smtpHost: t("settings.fields.smtpHost.title"),
smtpPort: t("settings.fields.smtpPort.title"),
smtpUser: t("settings.fields.smtpUser.title"),
smtpPass: t("settings.fields.smtpPass.title"),
smtpFromName: t("settings.fields.smtpFromName.title"),
smtpFromEmail: t("settings.fields.smtpFromEmail.title"),
// Security settings
maxLoginAttempts: t("settings.fields.maxLoginAttempts.title"),
loginBlockDuration: t("settings.fields.loginBlockDuration.title"),
passwordMinLength: t("settings.fields.passwordMinLength.title"),
passwordResetTokenExpiration: t("settings.fields.passwordResetTokenExpiration.title"),
// Storage settings
maxFileSize: t("settings.fields.maxFileSize.title"),
maxTotalStoragePerUser: t("settings.fields.maxTotalStoragePerUser.title"),
});

View File

@@ -0,0 +1,168 @@
"use client";
import { ConfigType, GroupFormData } from "../types";
import { Config } from "../types";
import { useShareContext } from "@/contexts/share-context";
import { useAppInfo } from "@/contexts/app-info-context";
import { getAllConfigs, bulkUpdateConfigs } from "@/http/endpoints";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useTranslations } from "next-intl";
const createSchemas = () => ({
settingsSchema: z.object({
configs: z.record(
z.union([z.string(), z.number()]).transform((val) => String(val))
),
}),
});
export function useSettings() {
const t = useTranslations();
const { settingsSchema } = createSchemas();
const [isLoading, setIsLoading] = useState(true);
const [configs, setConfigs] = useState<Record<string, string>>({});
const [groupedConfigs, setGroupedConfigs] = useState<Record<string, Config[]>>({});
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({
general: true,
email: true,
security: true,
storage: true,
});
const { refreshAppInfo } = useAppInfo();
const { refreshShareContext } = useShareContext();
const groupForms = {
general: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
email: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
security: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
storage: useForm<GroupFormData>({ resolver: zodResolver(settingsSchema) }),
} as const;
type ValidGroup = keyof typeof groupForms;
const loadConfigs = async () => {
try {
const response = await getAllConfigs();
const configsData = response.data.configs.reduce((acc: Record<string, string>, config) => {
acc[config.key] = config.value;
return acc;
}, {});
const grouped = response.data.configs.reduce((acc: Record<string, Config[]>, config) => {
const group = config.group || "general";
if (!acc[group]) acc[group] = [];
acc[group].push({
...config,
type: (config.type as ConfigType) || "text",
});
// Sort configs by key to maintain consistent order
acc[group].sort((a, b) => {
// Para o grupo general, coloca appLogo primeiro
if (group === "general") {
if (a.key === "appLogo") return -1;
if (b.key === "appLogo") return 1;
}
// Para o grupo email, coloca smtpEnabled primeiro
if (group === "email") {
if (a.key === "smtpEnabled") return -1;
if (b.key === "smtpEnabled") return 1;
}
// Ordenação padrão alfabética para os demais casos
return a.key.localeCompare(b.key);
});
return acc;
}, {});
setConfigs(configsData);
setGroupedConfigs(grouped);
Object.entries(grouped).forEach(([groupName, groupConfigs]) => {
if (groupName === "general" || groupName === "email" || groupName === "security" || groupName === "storage") {
const group = groupName as ValidGroup;
const groupConfigData = groupConfigs.reduce(
(acc, config) => {
acc[config.key] = configsData[config.key];
return acc;
},
{} as Record<string, string>
);
groupForms[group].reset({ configs: groupConfigData });
}
});
} catch (error) {
toast.error(t("settings.errors.loadFailed"));
console.error(error);
} finally {
setIsLoading(false);
}
};
const onGroupSubmit = async (group: ValidGroup, data: GroupFormData) => {
try {
const groupConfigKeys = groupedConfigs[group].map((config) => config.key);
const configsToUpdate = Object.entries(data.configs)
.filter(([key, newValue]) => {
const currentValue = configs[key];
return groupConfigKeys.includes(key) && String(newValue) !== currentValue;
})
.map(([key, value]) => ({
key,
value: String(value),
}));
if (configsToUpdate.length === 0) {
toast.info(t("settings.messages.noChanges"));
return;
}
await bulkUpdateConfigs(configsToUpdate);
toast.success(t("settings.messages.updateSuccess", { group: t(`settings.groups.${group}`) }));
await loadConfigs();
if (group === "email") {
await refreshShareContext();
}
await refreshAppInfo();
} catch (error) {
toast.error(t("settings.errors.updateFailed"));
console.error(error);
}
};
const toggleCollapse = (group: string) => {
setCollapsedGroups((prev) => ({
...prev,
[group]: !prev[group],
}));
};
useEffect(() => {
loadConfigs();
}, []);
return {
isLoading,
groupedConfigs,
collapsedGroups,
groupForms,
toggleCollapse,
onGroupSubmit,
};
}

View File

@@ -0,0 +1,18 @@
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
interface LayoutProps {
children: React.ReactNode;
}
export async function generateMetadata(): Promise<Metadata> {
const translate = await getTranslations();
return {
title: translate("settings.pageTitle"),
};
}
export default function SettingsLayout({ children }: LayoutProps) {
return <>{children}</>;
}

View File

@@ -0,0 +1,38 @@
"use client"
import { ProtectedRoute } from "@/components/auth/protected-route";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { Navbar } from "@/components/layout/navbar";
import { DefaultFooter } from "@/components/ui/default-footer";
import { SettingsForm } from "./components/settings-form";
import { SettingsHeader } from "./components/settings-header";
import { useSettings } from "./hooks/use-settings";
export default function SettingsPage() {
const settings = useSettings();
if (settings.isLoading) {
return <LoadingScreen />;
}
return (
<ProtectedRoute>
<div className="w-full h-screen flex flex-col">
<Navbar />
<div className="flex-1 max-w-7xl mx-auto w-full px-6 py-8">
<div className="flex flex-col gap-8">
<SettingsHeader />
<SettingsForm
collapsedGroups={settings.collapsedGroups}
groupForms={settings.groupForms}
groupedConfigs={settings.groupedConfigs}
onGroupSubmit={settings.onGroupSubmit}
onToggleCollapse={settings.toggleCollapse}
/>
</div>
</div>
<DefaultFooter />
</div>
</ProtectedRoute>
);
}

View File

@@ -0,0 +1,43 @@
import { UseFormReturn } from "react-hook-form";
export type ValidGroup = "security" | "email" | "general" | "storage";
export interface SettingsFormProps {
groupedConfigs: Record<string, Config[]>;
collapsedGroups: Record<string, boolean>;
groupForms: Record<ValidGroup, UseFormReturn<any>>;
onGroupSubmit: (group: ValidGroup, data: any) => Promise<void>;
onToggleCollapse: (group: ValidGroup) => void;
}
export interface SettingsGroupProps {
group: string;
configs: Config[];
form: UseFormReturn<{
configs: Record<string, string>;
}>;
isCollapsed: boolean;
onToggleCollapse: () => void;
onSubmit: (data: any) => Promise<void>;
}
export interface ConfigInputProps {
config: Config;
register: UseFormReturn<any>["register"];
error?: any;
smtpEnabled?: string;
}
export type GroupFormData = {
configs: Record<string, string | number>;
};
export type ConfigType = "text" | "number" | "boolean" | "bigint";
export type Config = {
key: string;
value: string;
group: string;
description?: string;
type: ConfigType;
};

View File

@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border 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)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}