Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
29 changes: 29 additions & 0 deletions dashboard/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
259 changes: 259 additions & 0 deletions dashboard/src/pages/TemplatesPage.tsx
Original file line number Diff line number Diff line change
@@ -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<NotificationTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedTemplate, setSelectedTemplate] = useState<NotificationTemplate | null>(null);
const [formData, setFormData] = useState<Partial<CreateNotificationTemplateInput>>({
type: 'email',
variables: []
});
const [previewVariables, setPreviewVariables] = useState<Record<string, string>>({});

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 (
<div className="templates-preview">
<div className="templates-preview__header">
<h3>Preview: {selectedTemplate.name}</h3>
<button
type="button"
onClick={() => setViewMode('list')}
>
Back to List
</button>
</div>
<div className="templates-preview__variables">
<h4>Variables</h4>
{selectedTemplate.variables?.map((varName) => (
<div key={varName} className="templates-preview__variable">
<label>{varName}:</label>
<input
type="text"
value={previewVariables[varName] || ''}
onChange={(e) => setPreviewVariables({ ...previewVariables, [varName]: e.target.value })}
/>
</div>
))}
</div>
<div className="templates-preview__content">
<h4>Rendered Template</h4>
{renderedSubject && (
<div className="templates-preview__subject">
<strong>Subject:</strong> {renderedSubject}
</div>
)}
<div className="templates-preview__body">
<strong>Body:</strong>
<pre>{renderedBody}</pre>
</div>
</div>
</div>
);
}

function renderForm() {
return (
<div className="templates-form">
<div className="templates-form__header">
<h3>{viewMode === 'create' ? 'Create Template' : 'Edit Template'}</h3>
<button
type="button"
onClick={() => {
setViewMode('list');
setSelectedTemplate(null);
}}
>
Cancel
</button>
</div>
<div className="templates-form__fields">
<div className="templates-form__field">
<label>ID</label>
<input
type="text"
value={formData.id || ''}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
disabled={viewMode === 'edit'}
/>
</div>
<div className="templates-form__field">
<label>Name</label>
<input
type="text"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="templates-form__field">
<label>Type</label>
<select
value={formData.type || 'email'}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
>
<option value="email">Email</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="telegram">Telegram</option>
</select>
</div>
<div className="templates-form__field">
<label>Subject (optional)</label>
<input
type="text"
value={formData.subject || ''}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
/>
</div>
<div className="templates-form__field">
<label>Body</label>
<textarea
value={formData.body || ''}
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
rows={10}
/>
</div>
<div className="templates-form__field">
<label>Variables (comma-separated)</label>
<input
type="text"
value={(formData.variables || []).join(', ')}
onChange={(e) => setFormData({
...formData,
variables: e.target.value.split(',').map((v) => v.trim()).filter(Boolean)
})}
/>
</div>
</div>
<button
type="button"
onClick={handleSave}
disabled={!formData.id || !formData.name || !formData.body}
>
Save Template
</button>
</div>
);
}

function renderList() {
if (loading) return <div>Loading templates...</div>;

return (
<div className="templates-list">
<div className="templates-list__header">
<h3>Templates</h3>
<button type="button" onClick={handleCreateClick}>
Create Template
</button>
</div>
<div className="templates-list__items">
{templates.map((template) => (
<div key={template.id} className="templates-list__item">
<div className="templates-list__item-info">
<h4>{template.name}</h4>
<p>Type: {template.type}</p>
{template.subject && <p>Subject: {template.subject}</p>}
<p className="templates-list__item-body">{template.body.substring(0, 100)}...</p>
</div>
<div className="templates-list__item-actions">
<button type="button" onClick={() => handlePreviewClick(template)}>
Preview
</button>
<button type="button" onClick={() => handleEditClick(template)}>
Edit
</button>
<button type="button" onClick={() => handleDelete(template.id)}>
Delete
</button>
</div>
</div>
))}
{templates.length === 0 && (
<p className="templates-list__empty">No templates yet. Create your first template!</p>
)}
</div>
</div>
);
}

return (
<div className="templates-page">
{viewMode === 'list' && renderList()}
{(viewMode === 'create' || viewMode === 'edit') && renderForm()}
{viewMode === 'preview' && renderPreview()}
</div>
);
}
48 changes: 48 additions & 0 deletions dashboard/src/services/templatesApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
NotificationTemplate,
CreateNotificationTemplateInput,
UpdateNotificationTemplateInput
} from '../types/notificationTemplate';

const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';

export const templatesApi = {
async getAll(): Promise<NotificationTemplate[]> {
const response = await fetch(`${API_BASE}/templates`);
if (!response.ok) throw new Error('Failed to fetch templates');
return response.json();
},

async getById(id: string): Promise<NotificationTemplate> {
const response = await fetch(`${API_BASE}/templates/${encodeURIComponent(id)}`);
if (!response.ok) throw new Error('Failed to fetch template');
return response.json();
},

async create(input: CreateNotificationTemplateInput): Promise<NotificationTemplate> {
const response = await fetch(`${API_BASE}/templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
});
if (!response.ok) throw new Error('Failed to create template');
return response.json();
},

async update(id: string, input: UpdateNotificationTemplateInput): Promise<NotificationTemplate> {
const response = await fetch(`${API_BASE}/templates/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
});
if (!response.ok) throw new Error('Failed to update template');
return response.json();
},

async delete(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/templates/${encodeURIComponent(id)}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete template');
}
};
30 changes: 30 additions & 0 deletions dashboard/src/types/notificationTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface NotificationTemplate {
id: string;
name: string;
type: string;
subject?: string;
body: string;
variables?: string[];
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}

export interface CreateNotificationTemplateInput {
id: string;
name: string;
type: string;
subject?: string;
body: string;
variables?: string[];
metadata?: Record<string, unknown>;
}

export interface UpdateNotificationTemplateInput {
name?: string;
type?: string;
subject?: string;
body?: string;
variables?: string[];
metadata?: Record<string, unknown>;
}
Loading
Loading