refactor(notifs): remove custom table, replace with reusable table, use responsive styling for mobile

This commit is contained in:
Sam Chau
2026-01-29 01:35:48 +10:30
parent 64cb093b8b
commit dee5d93b13
2 changed files with 200 additions and 355 deletions

View File

@@ -4,11 +4,23 @@
import { Plus, Trash2, Bell, BellOff, MessageSquare, Send, Loader2, Pencil } from 'lucide-svelte';
import Modal from '$ui/modal/Modal.svelte';
import NotificationHistory from './components/NotificationHistory.svelte';
import Table from '$ui/table/Table.svelte';
import Badge from '$ui/badge/Badge.svelte';
import type { Column } from '$ui/table/types';
import { siDiscord } from 'simple-icons';
import type { PageData } from './$types';
export let data: PageData;
type Service = (typeof data.services)[0];
const columns: Column<Service>[] = [
{ key: 'name', header: 'Service', sortable: true },
{ key: 'service_type', header: 'Type', sortable: true },
{ key: 'enabled', header: 'Status', sortable: true },
{ key: 'stats', header: 'Stats' }
];
// Modal state
let showDeleteModal = false;
let selectedService: string | null = null;
@@ -91,10 +103,10 @@
<div class="p-8">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-3xl font-bold text-neutral-900 dark:text-neutral-50">Notifications</h1>
<p class="mt-3 text-lg text-neutral-600 dark:text-neutral-400">
<h1 class="text-2xl font-bold text-neutral-900 md:text-3xl dark:text-neutral-50">Notifications</h1>
<p class="mt-2 text-base text-neutral-600 md:mt-3 md:text-lg dark:text-neutral-400">
Manage notification services and delivery settings
</p>
</div>
@@ -102,7 +114,7 @@
<!-- Add Service Button -->
<a
href="/settings/notifications/new"
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600"
class="flex w-fit items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 dark:bg-accent-500 dark:hover:bg-accent-600"
>
<Plus size={16} />
Add Service
@@ -110,252 +122,134 @@
</div>
</div>
<!-- Services List -->
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
<!-- Services Table -->
<Table
{columns}
data={data.services}
emptyMessage="No notification services configured. Click 'Add Service' to get started."
compact
responsive
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-center gap-2">
<Bell size={18} class="text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Notification Services
</h2>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'name'}
<span class="font-medium">{row.name}</span>
{:else if column.key === 'service_type'}
<div class="flex items-center gap-2">
{#if row.service_type === 'discord'}
<svg
role="img"
viewBox="0 0 24 24"
class="h-4 w-4 text-neutral-600 dark:text-neutral-400"
fill="currentColor"
>
Service
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Type
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Status
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Enabled Types
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Statistics
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Actions
</th>
</tr>
</thead>
<tbody class="[&_tr:last-child_td]:border-b-0">
{#each data.services as service (service.id)}
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800">
<!-- Service Name -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
<span class="font-medium">{service.name}</span>
</td>
<!-- Type -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
<div class="flex items-center gap-2">
{#if service.service_type === 'discord'}
<svg
role="img"
viewBox="0 0 24 24"
class="h-4 w-4 text-neutral-600 dark:text-neutral-400"
fill="currentColor"
>
<path d={siDiscord.path} />
</svg>
{:else}
<svelte:component
this={getServiceIcon(service.service_type)}
size={16}
class="text-neutral-600 dark:text-neutral-400"
/>
{/if}
<span>{getServiceTypeName(service.service_type)}</span>
</div>
</td>
<!-- Status -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
<form
method="POST"
action="?/toggleEnabled"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to update service'
);
} else if (result.type === 'success') {
alertStore.add('success', 'Service updated successfully');
}
await update();
};
}}
>
<input type="hidden" name="id" value={service.id} />
<input type="hidden" name="enabled" value={service.enabled ? 'false' : 'true'} />
<button
type="submit"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors {service.enabled
? 'bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200'
: 'bg-neutral-100 text-neutral-800 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-200'}"
>
<svelte:component this={service.enabled ? Bell : BellOff} size={12} />
{service.enabled ? 'Enabled' : 'Disabled'}
</button>
</form>
</td>
<!-- Enabled Types -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
<div class="flex max-w-sm flex-wrap gap-1">
{#each getEnabledTypes(service.enabled_types) as type}
<span
class="inline-flex items-center rounded-full bg-accent-100 px-2 py-0.5 text-xs font-medium text-accent-800 dark:bg-accent-900 dark:text-accent-200"
>
{formatNotificationType(type)}
</span>
{:else}
<span class="text-xs text-neutral-400 dark:text-neutral-500">None</span>
{/each}
</div>
</td>
<!-- Statistics -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
{#if service.successCount + service.failedCount > 0}
<div class="text-xs">
<span class="text-green-600 dark:text-green-400"
>{service.successCount} sent</span
>
<span class="text-red-600 dark:text-red-400">{service.failedCount} failed</span>
<span>{formatSuccessRate(service.successRate)}</span>
</div>
{:else}
<span class="text-neutral-400 dark:text-neutral-500">No notifications sent</span>
{/if}
</td>
<!-- Actions -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
<div class="flex items-center gap-2">
<!-- Test Button -->
<form
method="POST"
action="?/testNotification"
use:enhance={() => {
testingServiceId = service.id;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error ||
'Failed to send test notification'
);
} else if (result.type === 'success') {
alertStore.add('success', 'Test notification sent successfully');
}
testingServiceId = null;
await update();
};
}}
>
<input type="hidden" name="id" value={service.id} />
<button
type="submit"
disabled={testingServiceId === service.id}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-accent-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-accent-400 dark:hover:bg-neutral-700"
title="Send test notification"
>
{#if testingServiceId === service.id}
<Loader2 size={14} class="animate-spin" />
{:else}
<Send size={14} />
{/if}
</button>
</form>
<!-- Edit Button -->
<a
href="/settings/notifications/edit/{service.id}"
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Edit service"
>
<Pencil size={14} />
</a>
<!-- Delete Button -->
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete service'
);
} else if (result.type === 'success') {
alertStore.add('success', 'Service deleted successfully');
}
await update();
};
}}
>
<input type="hidden" name="id" value={service.id} />
<button
type="button"
on:click={(e) => {
const form = e.currentTarget.closest('form');
if (form) openDeleteModal(service.id, service.name, form);
}}
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
title="Delete service"
>
<Trash2 size={14} />
</button>
</form>
</div>
</td>
</tr>
<path d={siDiscord.path} />
</svg>
{:else}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No notification services configured. Click "Add Service" to get started.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<svelte:component
this={getServiceIcon(row.service_type)}
size={16}
class="text-neutral-600 dark:text-neutral-400"
/>
{/if}
<span>{getServiceTypeName(row.service_type)}</span>
</div>
{:else if column.key === 'enabled'}
<Badge variant={row.enabled ? 'success' : 'neutral'} icon={row.enabled ? Bell : BellOff}>
{row.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{:else if column.key === 'stats'}
{#if row.successCount + row.failedCount > 0}
<span class="text-xs">
<span class="text-green-600 dark:text-green-400">{row.successCount}</span>
/
<span class="text-red-600 dark:text-red-400">{row.failedCount}</span>
</span>
{:else}
<span class="text-neutral-400 dark:text-neutral-500">-</span>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center gap-1">
<!-- Test Button -->
<form
method="POST"
action="?/testNotification"
use:enhance={() => {
testingServiceId = row.id;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to send test notification'
);
} else if (result.type === 'success') {
alertStore.add('success', 'Test notification sent successfully');
}
testingServiceId = null;
await update();
};
}}
>
<input type="hidden" name="id" value={row.id} />
<button
type="submit"
disabled={testingServiceId === row.id}
class="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-neutral-300 bg-white text-accent-600 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-accent-400 dark:hover:bg-neutral-700"
title="Send test notification"
>
{#if testingServiceId === row.id}
<Loader2 size={12} class="animate-spin" />
{:else}
<Send size={12} />
{/if}
</button>
</form>
<!-- Edit Button -->
<a
href="/settings/notifications/edit/{row.id}"
class="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-neutral-300 bg-white text-neutral-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
title="Edit service"
>
<Pencil size={12} />
</a>
<!-- Delete Button -->
<form
method="POST"
action="?/delete"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete service'
);
} else if (result.type === 'success') {
alertStore.add('success', 'Service deleted successfully');
}
await update();
};
}}
>
<input type="hidden" name="id" value={row.id} />
<button
type="button"
on:click={(e) => {
const form = e.currentTarget.closest('form');
if (form) openDeleteModal(row.id, row.name, form);
}}
class="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-neutral-300 bg-white text-red-600 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-neutral-700"
title="Delete service"
>
<Trash2 size={12} />
</button>
</form>
</div>
</svelte:fragment>
</Table>
<!-- Notification History Component -->
<div class="mt-8">

View File

@@ -1,23 +1,48 @@
<script lang="ts">
import { Bell } from 'lucide-svelte';
import { Bell, CheckCircle, XCircle } from 'lucide-svelte';
import { parseUTC } from '$shared/utils/dates';
import type { NotificationHistoryRecord } from '$db/queries/notificationHistory.ts';
import Table from '$ui/table/Table.svelte';
import Badge from '$ui/badge/Badge.svelte';
import type { Column } from '$ui/table/types';
export let history: NotificationHistoryRecord[];
export let services: Array<{ id: string; name: string }>;
const columns: Column<NotificationHistoryRecord>[] = [
{ key: 'title', header: 'Title', sortable: true },
{ key: 'service_id', header: 'Service', sortable: true },
{ key: 'notification_type', header: 'Type', sortable: true },
{ key: 'status', header: 'Status', sortable: true },
{ key: 'sent_at', header: 'Time', sortable: true }
];
function formatDateTime(date: string): string {
const d = parseUTC(date);
return d ? d.toLocaleString() : '-';
}
function getRelativeTime(date: string): string {
const d = parseUTC(date);
if (!d) return '-';
const now = new Date();
const diff = now.getTime() - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'Just now';
}
function getServiceName(serviceId: string): string {
const service = services.find((s) => s.id === serviceId);
return service?.name || 'Unknown';
}
function formatNotificationType(type: string): string {
// Convert 'job.create_backup.success' to 'Backup Success'
const parts = type.split('.');
if (parts.length >= 3) {
const action = parts[1].replace(/_/g, ' ');
@@ -28,112 +53,38 @@
}
</script>
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-center gap-2">
<Bell size={18} class="text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
Recent Notifications
</h2>
</div>
<div class="space-y-4">
<div class="flex items-center gap-2">
<Bell size={18} class="text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 md:text-xl dark:text-neutral-50">
Recent Notifications
</h2>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Time
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Service
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Type
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Title
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Status
</th>
</tr>
</thead>
<tbody class="[&_tr:last-child_td]:border-b-0">
{#each history as record (record.id)}
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800">
<!-- Time -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
{formatDateTime(record.sent_at)}
</td>
<!-- Service -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
{getServiceName(record.service_id)}
</td>
<!-- Type -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
<span
class="inline-flex items-center rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-800 dark:bg-neutral-800 dark:text-neutral-200"
>
{formatNotificationType(record.notification_type)}
</span>
</td>
<!-- Title -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
{record.title}
</td>
<!-- Status -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
{#if record.status === 'success'}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200"
>
Success
</span>
{:else}
<span
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200"
title={record.error || 'Unknown error'}
>
Failed
</span>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No notification history available yet.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Table
{columns}
data={history}
emptyMessage="No notification history available yet."
compact
responsive
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'title'}
<span class="font-medium">{row.title}</span>
{:else if column.key === 'service_id'}
{getServiceName(row.service_id)}
{:else if column.key === 'notification_type'}
<Badge variant="neutral">{formatNotificationType(row.notification_type)}</Badge>
{:else if column.key === 'status'}
<Badge
variant={row.status === 'success' ? 'success' : 'danger'}
icon={row.status === 'success' ? CheckCircle : XCircle}
>
{row.status === 'success' ? 'Success' : 'Failed'}
</Badge>
{:else if column.key === 'sent_at'}
<Badge variant="neutral" mono>{getRelativeTime(row.sent_at)}</Badge>
{/if}
</svelte:fragment>
</Table>
</div>