refactor(templates): adopt the 2-panel resizable layout of the other detail pages

templates/{id} now mirrors knowledges/{id} and settings/prompts/{id}: a left
ResizablePanel (intro + title + the Presets panel, in the spot where the prompt
page shows "Available variables") and a right panel holding the Code/Plain
editor that fills the space. Save moves to the header (FormSubmitButton
form="template-form"); the right-side Presets sidebar/Sheet, the PanelRight
toggle, the in-input save button, and the Enter-to-submit handler are removed.

Live-verified on the stand: 2-panel render (create + edit), preset apply,
create->DB->reload byte-fidelity (incl. the blank line, reconstructed from
.cm-line), the Code editor filling the right panel, delete. tsc/eslint/build
+ 652 frontend tests green; console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-28 01:41:26 +07:00
co-authored by Claude Opus 4.8
parent d3c46c7158
commit 18839b3487
+196 -246
View File
@@ -8,9 +8,8 @@ import {
Ellipsis,
FileSymlink,
FileText,
GripVertical,
Loader2,
PanelRightClose,
PanelRightOpen,
Pencil,
Save,
Trash,
@@ -43,10 +42,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form';
import { FormSubmitButton } from '@/components/ui/form-submit-button';
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextareaAutosize } from '@/components/ui/input-group';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useTemplateDetailNavigation } from '@/features/templates/use-template-detail-navigation';
import { FlowTemplateDocument } from '@/graphql/types';
@@ -253,7 +253,7 @@ function Template() {
const { templateId } = useParams<{ templateId?: string }>();
const { createTemplate, deleteTemplate, updateTemplate } = useTemplates();
const { isMobile } = useBreakpoint();
const { isDesktop, isMobile } = useBreakpoint();
const isNew = templateId === 'new';
// Pass `null` while creating a new template — there is no "current item"
@@ -261,7 +261,6 @@ function Template() {
// below by `canShowActions`).
const templateNav = useTemplateDetailNavigation(isNew ? null : templateId);
const [isAsideOpen, setIsAsideOpen] = useState(false);
const [expandedPresetIndex, setExpandedPresetIndex] = useState<null | number>(null);
const [isReplaceConfirmOpen, setIsReplaceConfirmOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -372,17 +371,6 @@ function Template() {
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const { ctrlKey, key, metaKey, shiftKey } = event;
if (isSaving || key !== 'Enter' || shiftKey || ctrlKey || metaKey) {
return;
}
event.preventDefault();
handleFormSubmit(handleSubmit)();
};
const handleApplyPreset = useCallback(
(preset: { text: string; title: string }) => {
const current = getValues();
@@ -456,13 +444,18 @@ function Template() {
sheetTitle="Templates"
/>
)}
<Button
onClick={() => setIsAsideOpen((open) => !open)}
size="icon"
variant="ghost"
>
{isAsideOpen ? <PanelRightClose /> : <PanelRightOpen />}
</Button>
{(isNew || !!templateData?.flowTemplate) && (
<FormSubmitButton
disabled={isSaving || !formState.isValid || (!isNew && !hasUnsavedChanges)}
form="template-form"
icon={<Save className="size-4" />}
loading={isSaving}
size="sm"
variant="secondary"
>
{isNew ? 'Create' : 'Save'}
</FormSubmitButton>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -542,143 +535,161 @@ function Template() {
</>
);
const asideContent = useMemo(
const presetsPanel = useMemo(
() => (
<div className="flex w-full min-w-0 flex-col gap-2 p-2">
{PRESET_TEMPLATES.map((preset, index) => (
<Collapsible
className="w-full min-w-0"
key={index}
onOpenChange={(open) => setExpandedPresetIndex(open ? index : null)}
open={expandedPresetIndex === index}
>
<Card className="w-full min-w-0">
<div className="flex w-full min-w-0">
<Button
className={cn(
'h-auto min-w-0 flex-1 justify-start rounded-none rounded-tl-[0.6875rem] px-3 py-2 text-left text-start',
expandedPresetIndex !== index ? 'rounded-bl-[0.6875rem]' : 'whitespace-normal',
)}
onClick={() => handleApplyPreset(preset)}
variant="ghost"
>
<span className={cn('min-w-0', expandedPresetIndex !== index && 'truncate')}>
{preset.title}
</span>
</Button>
<CollapsibleTrigger asChild>
<div className="bg-card overflow-hidden rounded-lg border">
<div className="border-b px-4 py-3">
<h4 className="flex items-center gap-2 text-sm font-medium">
Preset templates
<Badge
className="ml-auto font-normal tabular-nums"
variant="secondary"
>
{PRESET_TEMPLATES.length}
</Badge>
</h4>
<p className="text-muted-foreground mt-1 text-xs">
Click a preset to fill the form, or expand it to preview the content.
</p>
</div>
<div className="flex w-full min-w-0 flex-col gap-2 p-2">
{PRESET_TEMPLATES.map((preset, index) => (
<Collapsible
className="w-full min-w-0"
key={index}
onOpenChange={(open) => setExpandedPresetIndex(open ? index : null)}
open={expandedPresetIndex === index}
>
<Card className="w-full min-w-0">
<div className="flex w-full min-w-0">
<Button
className={cn(
'h-auto shrink-0 rounded-none rounded-tr-[0.6875rem] border-l px-2 py-2',
expandedPresetIndex !== index && 'rounded-br-[0.6875rem]',
'h-auto min-w-0 flex-1 justify-start rounded-none rounded-tl-[0.6875rem] px-3 py-2 text-left text-start',
expandedPresetIndex !== index
? 'rounded-bl-[0.6875rem]'
: 'whitespace-normal',
)}
onClick={() => handleApplyPreset(preset)}
variant="ghost"
>
<ChevronDown
className={cn(
'transition-transform',
expandedPresetIndex === index && 'rotate-180',
)}
/>
<span className={cn('min-w-0', expandedPresetIndex !== index && 'truncate')}>
{preset.title}
</span>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<CardContent className="border-t px-3 py-2">
<p className="text-muted-foreground text-sm break-words whitespace-pre-wrap">
{preset.text}
</p>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
<CollapsibleTrigger asChild>
<Button
className={cn(
'h-auto shrink-0 rounded-none rounded-tr-[0.6875rem] border-l px-2 py-2',
expandedPresetIndex !== index && 'rounded-br-[0.6875rem]',
)}
variant="ghost"
>
<ChevronDown
className={cn(
'transition-transform',
expandedPresetIndex === index && 'rotate-180',
)}
/>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<CardContent className="border-t px-3 py-2">
<p className="text-muted-foreground text-sm break-words whitespace-pre-wrap">
{preset.text}
</p>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
</div>
),
[expandedPresetIndex, handleApplyPreset],
);
const aside = useMemo(
() =>
isMobile ? (
<Sheet
onOpenChange={setIsAsideOpen}
open={isAsideOpen}
>
<SheetContent
// The Sheet body is just a list of presets with no
// descriptive sub-text — opt out of the Radix
// Description warning explicitly.
aria-describedby={undefined}
className="flex w-full max-w-sm flex-col gap-0 p-0 sm:max-w-sm"
side="right"
>
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 pr-8 text-base">
<FileText className="size-4" />
<span>Preset templates</span>
<Badge
className="ml-auto font-normal tabular-nums"
variant="secondary"
>
{PRESET_TEMPLATES.length}
</Badge>
</SheetTitle>
</SheetHeader>
{/* Plain overflow-y-auto instead of Radix ScrollArea —
ScrollArea's Viewport wraps children in a
`display: table` div whose width grows to fit
intrinsic content, defeating `w-full min-w-0` on
inner flex rows and pushing the chevron buttons
off-screen. */}
<div className="min-w-0 flex-1 overflow-y-auto">{asideContent}</div>
</SheetContent>
</Sheet>
) : (
<aside
className={cn(
'bg-background shrink-0 overflow-hidden transition-[width] duration-200',
isAsideOpen ? 'w-80 border-l sm:w-96' : 'w-0',
)}
>
{isAsideOpen ? (
<div className="flex h-full w-80 min-w-0 flex-col sm:w-96">
<div className="border-b p-4">
<h3 className="flex items-center gap-2 text-base font-semibold">
<FileText className="size-4" />
<span>Preset templates</span>
<Badge
className="ml-auto font-normal tabular-nums"
variant="secondary"
>
{PRESET_TEMPLATES.length}
</Badge>
</h3>
</div>
<div className="min-w-0 flex-1 overflow-y-auto">{asideContent}</div>
</div>
) : null}
</aside>
),
[isMobile, isAsideOpen, asideContent],
const introBlock = (
<div className="flex flex-col gap-2 text-center">
<h2 className="text-2xl font-semibold">{isNew ? 'Create a new template' : 'Edit template'}</h2>
<p className="text-muted-foreground">Add a title and content, or start from a preset.</p>
</div>
);
const titleField = (
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
autoFocus={isNew}
disabled={isSaving}
placeholder="Title"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
);
const textEditor = (
<FormField
control={control}
name="text"
render={({ field }) => (
<FormItem className="flex min-h-0 flex-1 flex-col">
<FormControl>
{viewMode === 'code' ? (
<Suspense
fallback={
<div className="flex min-h-0 flex-1 items-center justify-center rounded-md border">
<Spinner variant="circle" />
</div>
}
>
<CodeEditor
className="min-h-0 flex-1"
disabled={isSaving}
onBlur={field.onBlur}
onChange={field.onChange}
placeholder="Content"
value={field.value}
/>
</Suspense>
) : (
<Textarea
{...field}
autoSize={false}
className="min-h-[640px] flex-1 resize-none font-mono text-sm"
disabled={isSaving}
placeholder="Content"
/>
)}
</FormControl>
</FormItem>
)}
/>
);
if (!isNew && isLoadingTemplate) {
return (
<>
<div className={isDesktop ? 'flex h-[100dvh] min-h-0 flex-col' : 'flex min-h-[100dvh] flex-col'}>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<Spinner variant="circle" />
</div>
</>
</div>
);
}
if (!isNew && !isLoadingTemplate && !templateData?.flowTemplate) {
return (
<>
<div className={isDesktop ? 'flex h-[100dvh] min-h-0 flex-col' : 'flex min-h-[100dvh] flex-col'}>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center p-4">
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
<h2 className="text-xl font-semibold">Template not found</h2>
@@ -687,121 +698,60 @@ function Template() {
</CardContent>
</Card>
</div>
</>
</div>
);
}
return (
<>
<div className={isDesktop ? 'flex h-[100dvh] min-h-0 flex-col' : 'flex min-h-[100dvh] flex-col'}>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)]">
<div className="flex min-w-0 flex-1 items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col gap-4 pt-6">
<div className="text-center">
<h1 className="text-2xl font-semibold">
{isNew ? 'Create a new template' : 'Edit template'}
</h1>
<p className="text-muted-foreground mt-2">
Add title and content for your template or use a
<Button
className="h-auto px-1.5 py-0 text-base"
onClick={() => setIsAsideOpen((open) => !open)}
variant="link"
>
Preset template
</Button>
</p>
</div>
<Form {...form}>
<form
className="flex flex-col gap-4"
onSubmit={handleFormSubmit(handleSubmit)}
<Form {...form}>
<form
className="flex min-h-0 flex-1 flex-col"
id="template-form"
onSubmit={handleFormSubmit(handleSubmit)}
>
{isDesktop ? (
<div className="flex min-h-0 w-full max-w-full flex-1 overflow-hidden">
<ResizablePanelGroup
className="w-full"
orientation="horizontal"
>
<ResizablePanel
defaultSize={45}
minSize={30}
>
<FormField
control={control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
autoFocus={isNew}
disabled={isSaving}
placeholder="Title"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={control}
name="text"
render={({ field }) => (
<FormItem>
<FormControl>
<InputGroup className="block h-auto">
{viewMode === 'code' ? (
<Suspense
fallback={
<div className="flex h-[50vh] max-h-[600px] min-h-[240px] items-center justify-center">
<Spinner variant="circle" />
</div>
}
>
<CodeEditor
className="h-[50vh] max-h-[600px] min-h-[240px] rounded-none border-0 shadow-none"
disabled={isSaving}
onBlur={field.onBlur}
onChange={field.onChange}
placeholder="Content"
value={field.value}
/>
</Suspense>
) : (
<InputGroupTextareaAutosize
{...field}
className="min-h-0"
disabled={isSaving}
maxRows={9}
minRows={1}
onKeyDown={handleKeyDown}
placeholder="Content"
/>
)}
<InputGroupAddon align="block-end">
<InputGroupButton
aria-label={isNew ? 'Create template' : 'Save template'}
className="ml-auto"
disabled={
isSaving ||
!formState.isValid ||
(!isNew && !hasUnsavedChanges)
}
size="icon-xs"
title={isNew ? 'Create template' : 'Save template'}
type="submit"
variant="default"
>
{isSaving ? (
<Spinner variant="circle" />
) : (
<Save aria-hidden="true" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</CardContent>
</Card>
</div>
{aside}
</div>
<div className="h-full min-h-0 overflow-y-auto">
<Card className="mx-auto min-h-full w-full max-w-2xl rounded-none border-0">
<CardContent className="flex flex-col gap-6 py-6">
{introBlock}
{titleField}
{presetsPanel}
</CardContent>
</Card>
</div>
</ResizablePanel>
<ResizableHandle withHandle>
<GripVertical className="size-4" />
</ResizableHandle>
<ResizablePanel
defaultSize={55}
minSize={30}
>
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-4">{textEditor}</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
{introBlock}
{titleField}
{textEditor}
{presetsPanel}
</div>
)}
</form>
</Form>
<ConfirmationDialog
confirmIcon={<FileSymlink />}
confirmText="Replace"
@@ -827,7 +777,7 @@ function Template() {
itemName={templateName ?? undefined}
itemType="template"
/>
</>
</div>
);
}