diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 5216e08..cd6a031 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -126,6 +126,9 @@ export function App() { onClick={() => setCurrentPage('events')} type="button" import { NotificationTimelineView } from './components/NotificationTimelineView'; +import { TemplatesPage } from './pages/TemplatesPage'; + +type Tab = 'explorer' | 'timeline' | 'templates'; import { ActivityFeed } from './components/ActivityFeed'; import { WebhookDashboardPage } from './pages/WebhookDashboardPage'; import { ExportHistoryPage } from './pages/ExportHistoryPage'; diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 826e70e..16e0285 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -4187,6 +4187,11 @@ body { } } +/* ─── Templates Page Styles ────────────────────────────────────────────────── */ +.templates-page { + max-width: 1100px; + margin: 0 auto; + padding: 24px; @media (max-width: 600px) { /* Stack rows as definition lists on very small screens */ .export-table, @@ -4583,6 +4588,30 @@ body { border-color: var(--hm-cell-hover-ring); } +.templates-preview__variable input { + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + font-size: 0.95rem; +} + +.templates-preview__content { + padding: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); +} + +.templates-preview__subject, +.templates-preview__body { + margin-top: 12px; +} + +.templates-preview__body pre { + margin: 8px 0 0; + white-space: pre-wrap; .theme-toggle:focus-visible { outline: 2px solid var(--hm-accent); outline-offset: 2px; diff --git a/dashboard/src/pages/TemplatesPage.tsx b/dashboard/src/pages/TemplatesPage.tsx new file mode 100644 index 0000000..20e932d --- /dev/null +++ b/dashboard/src/pages/TemplatesPage.tsx @@ -0,0 +1,259 @@ +import { useState, useEffect } from 'react'; +import { + NotificationTemplate, + CreateNotificationTemplateInput, + UpdateNotificationTemplateInput +} from '../types/notificationTemplate'; +import { templatesApi } from '../services/templatesApi'; + +type ViewMode = 'list' | 'create' | 'edit' | 'preview'; + +export function TemplatesPage() { + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [viewMode, setViewMode] = useState('list'); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [formData, setFormData] = useState>({ + type: 'email', + variables: [] + }); + const [previewVariables, setPreviewVariables] = useState>({}); + + useEffect(() => { + loadTemplates(); + }, []); + + async function loadTemplates() { + try { + setLoading(true); + const data = await templatesApi.getAll(); + setTemplates(data); + } catch (error) { + console.error('Failed to load templates:', error); + } finally { + setLoading(false); + } + } + + function handleCreateClick() { + setFormData({ type: 'email', variables: [] }); + setViewMode('create'); + } + + function handleEditClick(template: NotificationTemplate) { + setSelectedTemplate(template); + setFormData({ ...template }); + setViewMode('edit'); + } + + function handlePreviewClick(template: NotificationTemplate) { + setSelectedTemplate(template); + setPreviewVariables({}); + setViewMode('preview'); + } + + async function handleSave() { + try { + if (viewMode === 'create' && formData.id && formData.name && formData.body) { + await templatesApi.create(formData as CreateNotificationTemplateInput); + } else if (viewMode === 'edit' && selectedTemplate) { + await templatesApi.update(selectedTemplate.id, formData as UpdateNotificationTemplateInput); + } + await loadTemplates(); + setViewMode('list'); + setSelectedTemplate(null); + } catch (error) { + console.error('Failed to save template:', error); + } + } + + async function handleDelete(id: string) { + if (!confirm('Are you sure you want to delete this template?')) return; + try { + await templatesApi.delete(id); + await loadTemplates(); + } catch (error) { + console.error('Failed to delete template:', error); + } + } + + function renderPreview() { + if (!selectedTemplate) return null; + const renderedSubject = selectedTemplate.subject + ? selectedTemplate.subject.replace(/\{\{(\w+)\}\}/g, (_, key) => previewVariables[key] || `{{${key}}}`) + : ''; + const renderedBody = selectedTemplate.body.replace(/\{\{(\w+)\}\}/g, (_, key) => previewVariables[key] || `{{${key}}}`); + + return ( +
+
+

Preview: {selectedTemplate.name}

+ +
+
+

Variables

+ {selectedTemplate.variables?.map((varName) => ( +
+ + setPreviewVariables({ ...previewVariables, [varName]: e.target.value })} + /> +
+ ))} +
+
+

Rendered Template

+ {renderedSubject && ( +
+ Subject: {renderedSubject} +
+ )} +
+ Body: +
{renderedBody}
+
+
+
+ ); + } + + function renderForm() { + return ( +
+
+

{viewMode === 'create' ? 'Create Template' : 'Edit Template'}

+ +
+
+
+ + setFormData({ ...formData, id: e.target.value })} + disabled={viewMode === 'edit'} + /> +
+
+ + setFormData({ ...formData, name: e.target.value })} + /> +
+
+ + +
+
+ + setFormData({ ...formData, subject: e.target.value })} + /> +
+
+ +