Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit e15d5d6

Browse files
committed
feat(admin): redesign /assets/template page with master-detail layout
- Replace top-level email/markdown tabs with MasterDetailLayout slim rail - Add view toggle (Split / Code / Preview) in detail pane header - Move sample props from static aside to editable JSON popover with live re-render - Register Monaco ejs-html language + completion provider sourced from props keys - Switch CodeEditor primitive to GitHub light/dark themes following app theme - Add Test SMTP button hooked into /health/email/test - Replace window.confirm with confirmDialog modal for reset - Provide admin-side fallback demo props per template type so previews stay resilient when backend response is missing keys (e.g. newsletter detail_link)
1 parent 8ac2311 commit e15d5d6

16 files changed

Lines changed: 1222 additions & 247 deletions

apps/admin/src/features/templates/components/TemplateCodeEditor.tsx

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,44 @@
1+
import { useEffect, useMemo } from 'react'
2+
13
import { CodeEditor } from '~/ui/primitives/code-editor'
24

3-
export function TemplateCodeEditor(props: {
5+
import {
6+
EJS_HTML_LANGUAGE_ID,
7+
ensureEjsHtmlRegistered,
8+
setPropsKeysProvider,
9+
} from '../lib/ejs-monaco'
10+
11+
interface TemplateCodeEditorProps {
412
dirty: boolean
513
onChange: (value: string) => void
614
onSave: () => void
15+
propsKeys: string[]
716
saving: boolean
817
value: string
9-
}) {
18+
}
19+
20+
export function TemplateCodeEditor(props: TemplateCodeEditorProps) {
21+
const keysSignature = useMemo(
22+
() => props.propsKeys.join('|'),
23+
[props.propsKeys],
24+
)
25+
26+
useEffect(() => {
27+
ensureEjsHtmlRegistered()
28+
}, [])
29+
30+
useEffect(() => {
31+
setPropsKeysProvider(() => props.propsKeys)
32+
}, [keysSignature])
33+
1034
return (
1135
<CodeEditor
1236
dirty={props.dirty}
13-
language="html"
37+
language={EJS_HTML_LANGUAGE_ID}
1438
onChange={props.onChange}
1539
onSave={props.onSave}
1640
saving={props.saving}
17-
title="html / ejs"
41+
title="ejs · html"
1842
value={props.value}
1943
/>
2044
)
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { Loader2, MailCheck, RefreshCw, RotateCcw, Save } from 'lucide-react'
2+
import { useMemo } from 'react'
3+
import type { TemplateType, TemplateViewMode } from '../types/templates'
4+
5+
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
6+
import { DESKTOP_MEDIA_QUERY, useMediaQuery } from '~/hooks/use-media-query'
7+
import { useI18n } from '~/i18n'
8+
import { Button } from '~/ui/primitives/button'
9+
import { cn } from '~/utils/cn'
10+
11+
import { templateDescriptors } from '../constants'
12+
import { TemplateCodeEditor } from './TemplateCodeEditor'
13+
import { TemplatePreview } from './TemplatePreview'
14+
import { TemplatePropsPopover } from './TemplatePropsPopover'
15+
import { TemplateSkeleton } from './TemplateSkeleton'
16+
import { TemplateViewToggle } from './TemplateViewToggle'
17+
18+
interface TemplateDetailPaneProps {
19+
defaultProps: unknown
20+
dirty: boolean
21+
loading: boolean
22+
onChangeSource: (value: string) => void
23+
onChangeProps: (next: unknown) => void
24+
onChangeView: (next: TemplateViewMode) => void
25+
onRefresh: () => void
26+
onReset: () => void
27+
onSave: () => void
28+
onTestSmtp: () => void
29+
previewError: string
30+
previewHtml: string
31+
propsKeys: string[]
32+
propsValue: unknown
33+
refreshing: boolean
34+
resetting: boolean
35+
saving: boolean
36+
source: string
37+
testing: boolean
38+
type: TemplateType
39+
viewMode: TemplateViewMode
40+
}
41+
42+
export function TemplateDetailPane(props: TemplateDetailPaneProps) {
43+
const { t } = useI18n()
44+
const isDesktop = useMediaQuery(DESKTOP_MEDIA_QUERY)
45+
const descriptor = useMemo(
46+
() =>
47+
templateDescriptors.find((entry) => entry.value === props.type) ??
48+
templateDescriptors[0],
49+
[props.type],
50+
)
51+
const Icon = descriptor.icon
52+
53+
const effectiveViewMode: TemplateViewMode = isDesktop
54+
? props.viewMode
55+
: props.viewMode === 'split'
56+
? 'code'
57+
: props.viewMode
58+
59+
return (
60+
<section className="flex h-full min-h-0 flex-col bg-white dark:bg-neutral-950">
61+
<header
62+
className={cn(
63+
'flex shrink-0 items-center justify-between gap-3 border-b border-neutral-200 px-4 dark:border-neutral-800',
64+
APP_SHELL_HEADER_HEIGHT_CLASS,
65+
)}
66+
>
67+
<div className="flex min-w-0 items-center gap-3">
68+
<h2 className="inline-flex min-w-0 items-center gap-2 text-sm font-medium">
69+
<Icon aria-hidden="true" className="size-4 shrink-0" />
70+
<span className="truncate">{t(descriptor.labelKey)}</span>
71+
</h2>
72+
<TemplateViewToggle
73+
hideSplit={!isDesktop}
74+
onChange={props.onChangeView}
75+
value={effectiveViewMode}
76+
/>
77+
</div>
78+
<div className="flex shrink-0 items-center gap-2">
79+
<TemplatePropsPopover
80+
defaultProps={props.defaultProps}
81+
onChange={props.onChangeProps}
82+
value={props.propsValue}
83+
/>
84+
<Button
85+
disabled={props.testing}
86+
onClick={props.onTestSmtp}
87+
type="button"
88+
variant="subtle"
89+
>
90+
{props.testing ? (
91+
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
92+
) : (
93+
<MailCheck aria-hidden="true" className="size-4" />
94+
)}
95+
<span className="hidden lg:inline">
96+
{t('templates.action.testSmtp')}
97+
</span>
98+
</Button>
99+
<Button
100+
disabled={props.refreshing}
101+
onClick={props.onRefresh}
102+
type="button"
103+
variant="subtle"
104+
>
105+
<RefreshCw
106+
aria-hidden="true"
107+
className={cn('size-4', props.refreshing && 'animate-spin')}
108+
/>
109+
<span className="hidden lg:inline">{t('common.refresh')}</span>
110+
</Button>
111+
<Button
112+
disabled={props.resetting}
113+
onClick={props.onReset}
114+
type="button"
115+
variant="subtle"
116+
>
117+
{props.resetting ? (
118+
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
119+
) : (
120+
<RotateCcw aria-hidden="true" className="size-4" />
121+
)}
122+
<span className="hidden lg:inline">{t('templates.reset')}</span>
123+
</Button>
124+
<Button
125+
disabled={props.saving || props.loading || !props.dirty}
126+
onClick={props.onSave}
127+
type="button"
128+
>
129+
{props.saving ? (
130+
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
131+
) : (
132+
<Save aria-hidden="true" className="size-4" />
133+
)}
134+
<span className="hidden lg:inline">
135+
{props.dirty ? t('common.save') : t('templates.saved')}
136+
</span>
137+
</Button>
138+
</div>
139+
</header>
140+
141+
<div className="flex min-h-0 flex-1 overflow-hidden">
142+
{props.loading ? (
143+
<div className="min-h-0 flex-1">
144+
<TemplateSkeleton />
145+
</div>
146+
) : (
147+
<TemplateBody
148+
error={props.previewError}
149+
html={props.previewHtml}
150+
onChangeSource={props.onChangeSource}
151+
onSave={props.onSave}
152+
propsKeys={props.propsKeys}
153+
saving={props.saving}
154+
source={props.source}
155+
sourceDirty={props.dirty}
156+
viewMode={effectiveViewMode}
157+
/>
158+
)}
159+
</div>
160+
</section>
161+
)
162+
}
163+
164+
interface TemplateBodyProps {
165+
error: string
166+
html: string
167+
onChangeSource: (value: string) => void
168+
onSave: () => void
169+
propsKeys: string[]
170+
saving: boolean
171+
source: string
172+
sourceDirty: boolean
173+
viewMode: TemplateViewMode
174+
}
175+
176+
function TemplateBody(props: TemplateBodyProps) {
177+
if (props.viewMode === 'code') {
178+
return (
179+
<div className="min-h-0 flex-1">
180+
<TemplateCodeEditor
181+
dirty={props.sourceDirty}
182+
onChange={props.onChangeSource}
183+
onSave={props.onSave}
184+
propsKeys={props.propsKeys}
185+
saving={props.saving}
186+
value={props.source}
187+
/>
188+
</div>
189+
)
190+
}
191+
192+
if (props.viewMode === 'preview') {
193+
return (
194+
<div className="min-h-0 flex-1">
195+
<TemplatePreview error={props.error} html={props.html} />
196+
</div>
197+
)
198+
}
199+
200+
return (
201+
<div className="grid min-h-0 flex-1 grid-cols-2 overflow-hidden">
202+
<div className="min-h-0 border-r border-neutral-200 dark:border-neutral-800">
203+
<TemplateCodeEditor
204+
dirty={props.sourceDirty}
205+
onChange={props.onChangeSource}
206+
onSave={props.onSave}
207+
propsKeys={props.propsKeys}
208+
saving={props.saving}
209+
value={props.source}
210+
/>
211+
</div>
212+
<div className="min-h-0">
213+
<TemplatePreview error={props.error} html={props.html} />
214+
</div>
215+
</div>
216+
)
217+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { TemplateType } from '../types/templates'
2+
3+
import { APP_SHELL_HEADER_HEIGHT_CLASS } from '~/constants/layout'
4+
import { useI18n } from '~/i18n'
5+
import { Scroll } from '~/ui/primitives/scroll'
6+
import { cn } from '~/utils/cn'
7+
8+
import { templateDescriptors } from '../constants'
9+
10+
interface TemplateListPaneProps {
11+
dirtyType: TemplateType | null
12+
onSelect: (value: TemplateType) => void
13+
selected: TemplateType
14+
}
15+
16+
export function TemplateListPane(props: TemplateListPaneProps) {
17+
const { t } = useI18n()
18+
19+
return (
20+
<section className="flex h-full min-h-0 flex-col bg-white dark:bg-neutral-950">
21+
<div
22+
className={cn(
23+
'flex shrink-0 items-center justify-between gap-2 border-b border-neutral-200 px-4 dark:border-neutral-800',
24+
APP_SHELL_HEADER_HEIGHT_CLASS,
25+
)}
26+
>
27+
<h2 className="text-sm font-medium">{t('templates.title')}</h2>
28+
<span className="text-xs text-neutral-500 dark:text-neutral-400">
29+
{templateDescriptors.length}
30+
</span>
31+
</div>
32+
33+
<Scroll className="flex-1">
34+
<ul role="list">
35+
{templateDescriptors.map((entry) => {
36+
const Icon = entry.icon
37+
const active = entry.value === props.selected
38+
const dirty = props.dirtyType === entry.value
39+
return (
40+
<li key={entry.value}>
41+
<button
42+
aria-current={active ? 'true' : undefined}
43+
className={cn(
44+
'group relative flex w-full items-center gap-3 border-l-2 px-4 py-3 text-left text-sm transition-colors',
45+
active
46+
? 'border-l-neutral-950 bg-neutral-100 text-neutral-950 dark:border-l-neutral-50 dark:bg-neutral-900 dark:text-neutral-50'
47+
: 'border-l-transparent text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-900/60',
48+
)}
49+
onClick={() => props.onSelect(entry.value)}
50+
type="button"
51+
>
52+
<Icon
53+
aria-hidden="true"
54+
className={cn(
55+
'size-4 shrink-0',
56+
active
57+
? 'text-neutral-700 dark:text-neutral-200'
58+
: 'text-neutral-400',
59+
)}
60+
/>
61+
<span className="min-w-0 flex-1 truncate font-medium">
62+
{t(entry.labelKey)}
63+
</span>
64+
{dirty ? (
65+
<span
66+
aria-label={t('templates.unsaved')}
67+
className="size-1.5 shrink-0 rounded-full bg-amber-500"
68+
title={t('templates.unsaved')}
69+
/>
70+
) : null}
71+
</button>
72+
</li>
73+
)
74+
})}
75+
</ul>
76+
</Scroll>
77+
</section>
78+
)
79+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { AlertCircle, Eye } from 'lucide-react'
2+
3+
import { useI18n } from '~/i18n'
4+
5+
interface TemplatePreviewProps {
6+
error: string
7+
html: string
8+
}
9+
10+
export function TemplatePreview(props: TemplatePreviewProps) {
11+
const { t } = useI18n()
12+
13+
return (
14+
<section className="flex h-full min-h-0 flex-col bg-neutral-50 dark:bg-neutral-900">
15+
<div className="flex h-10 shrink-0 items-center justify-between border-b border-neutral-200 px-3 dark:border-neutral-800">
16+
<span className="inline-flex items-center gap-2 text-xs font-medium uppercase text-neutral-500">
17+
<Eye aria-hidden="true" className="size-4" />
18+
{t('templates.preview.title')}
19+
</span>
20+
{props.error ? (
21+
<span className="inline-flex items-center gap-1 text-xs text-red-600">
22+
<AlertCircle aria-hidden="true" className="size-3.5" />
23+
{t('templates.previewFailed')}
24+
</span>
25+
) : null}
26+
</div>
27+
{props.error ? (
28+
<div className="min-h-0 flex-1 overflow-auto p-4">
29+
<pre className="whitespace-pre-wrap rounded border border-red-200 bg-red-50 p-3 text-xs leading-5 text-red-800 dark:border-red-950 dark:bg-red-950/30 dark:text-red-200">
30+
{props.error}
31+
</pre>
32+
</div>
33+
) : (
34+
<iframe
35+
className="min-h-0 w-full flex-1 bg-white"
36+
sandbox=""
37+
srcDoc={props.html}
38+
title={t('templates.preview.title')}
39+
/>
40+
)}
41+
</section>
42+
)
43+
}

0 commit comments

Comments
 (0)