Files
kandev/apps/web/components/settings/layouts/layout-settings.tsx
T
2026-08-05 14:07:52 +01:00

273 lines
9.1 KiB
TypeScript

"use client";
import { useState } from "react";
import {
IconAlertTriangle,
IconLayoutDashboard,
IconRestore,
IconTrash,
} from "@tabler/icons-react";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { LayoutEditor } from "./layout-editor";
import { LayoutProfileList } from "./layout-profile-list";
import { useLayoutSettings } from "./use-layout-settings";
import { useTranslation } from "react-i18next";
import { SettingsTarget } from "@/components/settings/settings-target";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general";
type Controller = ReturnType<typeof useLayoutSettings>;
/** Returns a catalog key; the caller resolves it so the copy follows locale switches. */
function defaultActionHelpKey(selectedSavedDefault: boolean, selectedIsDefault: boolean) {
if (selectedSavedDefault) return "settings:makeTheOriginalDefaultLayoutThe";
if (selectedIsDefault) return "settings:thisLayoutIsUsedAsThe";
return "settings:useThisLayoutAsTheStarting";
}
function LayoutSettingsHeader() {
const { t } = useTranslation();
return (
<>
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-2xl font-bold">
<IconLayoutDashboard className="h-5 w-5" />
{t("settings:layouts")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("settings:configureTheInitialDesktopTaskWorkbench")}
</p>
</div>
<Separator />
</>
);
}
function ResetBuiltInButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 cursor-pointer sm:min-h-8"
aria-label={t("settings:resetBuiltInLayout")}
onClick={onClick}
>
<IconRestore className="h-4 w-4" /> {t("settings:reset")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings:restoreTheOriginalBuiltInLayout")}</TooltipContent>
</Tooltip>
);
}
function DeleteProfileButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="outline"
className="min-h-11 min-w-11 cursor-pointer sm:min-h-8 sm:min-w-8"
aria-label={t("settings:deleteLayoutProfile")}
onClick={onClick}
>
<IconTrash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings:deleteThisCustomLayoutAfterConfirmation")}</TooltipContent>
</Tooltip>
);
}
function SelectedLayoutHeader({
controller,
onDelete,
}: {
controller: Controller;
onDelete: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
{controller.selectedCustom ? (
<Input
aria-label={t("settings:layoutProfileName")}
value={controller.selectedCustom.name}
onChange={(event) => controller.updateSelected({ name: event.target.value })}
className="min-h-11 max-w-md sm:min-h-9"
/>
) : (
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">{controller.selectedName}</h3>
<Badge variant="outline">{t("settings:builtIn")}</Badge>
{controller.selectedBuiltInOverride && (
<Badge variant="secondary">{t("settings:customized")}</Badge>
)}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={controller.defaultActionDisabled ? 0 : -1} className="inline-flex">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 cursor-pointer sm:min-h-8"
disabled={controller.defaultActionDisabled}
onClick={controller.setDefault}
>
{controller.defaultActionLabel}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t(defaultActionHelpKey(controller.selectedSavedDefault, controller.selectedIsDefault))}
</TooltipContent>
</Tooltip>
{controller.selectedBuiltInOverride && (
<ResetBuiltInButton onClick={controller.resetBuiltIn} />
)}
{controller.selectedCustom && <DeleteProfileButton onClick={onDelete} />}
</div>
</div>
);
}
function SelectedLayoutEditor({ controller }: { controller: Controller }) {
const { t } = useTranslation();
const editorKey = `${controller.selection.kind}:${controller.selection.id}:${controller.editorReset}`;
if (!controller.editorLayout) {
return (
<Alert>
<IconAlertTriangle className="h-4 w-4" />
<AlertTitle>{t("settings:visualEditorUnavailable")}</AlertTitle>
<AlertDescription>
{controller.compatibility?.issues.map((issue) => issue.message).join(". ")}
</AlertDescription>
</Alert>
);
}
return (
<LayoutEditor
key={editorKey}
layout={controller.editorLayout}
editable
onChange={controller.updateLayout}
/>
);
}
function DeleteProfileDialog({
controller,
open,
onOpenChange,
}: {
controller: Controller;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const confirm = () => {
controller.deleteSelected();
onOpenChange(false);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
{/* The profile name is user data — interpolated, never translated. */}
<AlertDialogTitle>
{t("settings:deleteLayoutProfileNamed", {
name: controller.selectedCustom?.name ?? t("settings:layoutProfile"),
})}
</AlertDialogTitle>
<AlertDialogDescription>
{controller.selectedCustom?.is_default
? t("settings:theBuiltInDefaultLayoutWill")
: t("settings:thisProfileWillBeRemovedWhen")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("settings:cancel")}</AlertDialogCancel>
<AlertDialogAction className="cursor-pointer" onClick={confirm}>
{t("settings:delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function LayoutSettings() {
const { t } = useTranslation();
const controller = useLayoutSettings();
const [deleteOpen, setDeleteOpen] = useState(false);
const invalidName = controller.profiles.some((profile) => !profile.name.trim());
useSettingsSaveContributor({
id: "layout-profiles",
revision: controller.profilesKey,
isDirty: controller.isDirty,
canSave: !invalidName,
invalidReason: invalidName ? t("settings:layoutProfileNamesMustNotBe") : undefined,
save: controller.save,
discard: controller.cancel,
});
return (
<SettingsTarget
targetId={GENERAL_SETTINGS_TARGETS.layoutProfiles}
className="min-w-0 space-y-6"
data-testid="layout-settings"
>
<LayoutSettingsHeader />
{controller.error && (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
<AlertTitle>{t("settings:layoutProfilesWereNotSaved")}</AlertTitle>
<AlertDescription>{controller.error}</AlertDescription>
</Alert>
)}
<div className="grid min-w-0 gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<LayoutProfileList
profiles={controller.profiles}
selection={controller.selection}
onSelect={controller.setSelection}
onCreate={controller.create}
onDuplicate={controller.duplicate}
/>
<section
className="min-w-0 space-y-3"
aria-label={t("settings:layoutEditorForProfile", { name: controller.selectedName })}
>
<SelectedLayoutHeader controller={controller} onDelete={() => setDeleteOpen(true)} />
<SelectedLayoutEditor controller={controller} />
</section>
</div>
<DeleteProfileDialog controller={controller} open={deleteOpen} onOpenChange={setDeleteOpen} />
</SettingsTarget>
);
}