mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
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:
@@ -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
4351
apps/app/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: LayoutProps) {
|
||||
export default function FilesLayout({ children }: LayoutProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
113
apps/app/src/app/settings/components/logo-input.tsx
Normal file
113
apps/app/src/app/settings/components/logo-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
39
apps/app/src/app/settings/components/settings-form.tsx
Normal file
39
apps/app/src/app/settings/components/settings-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
78
apps/app/src/app/settings/components/settings-group.tsx
Normal file
78
apps/app/src/app/settings/components/settings-group.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
apps/app/src/app/settings/components/settings-header.tsx
Normal file
44
apps/app/src/app/settings/components/settings-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
83
apps/app/src/app/settings/components/settings-input.tsx
Normal file
83
apps/app/src/app/settings/components/settings-input.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
79
apps/app/src/app/settings/constants.ts
Normal file
79
apps/app/src/app/settings/constants.ts
Normal 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"),
|
||||
});
|
168
apps/app/src/app/settings/hooks/use-settings.ts
Normal file
168
apps/app/src/app/settings/hooks/use-settings.ts
Normal 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,
|
||||
};
|
||||
}
|
18
apps/app/src/app/settings/layout.tsx
Normal file
18
apps/app/src/app/settings/layout.tsx
Normal 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}</>;
|
||||
}
|
38
apps/app/src/app/settings/page.tsx
Normal file
38
apps/app/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
apps/app/src/app/settings/types/index.ts
Normal file
43
apps/app/src/app/settings/types/index.ts
Normal 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;
|
||||
};
|
185
apps/app/src/components/ui/select.tsx
Normal file
185
apps/app/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
Reference in New Issue
Block a user