refactor(alerts): move to lib/client

- also remove reusable request wrapper
This commit is contained in:
Sam Chau
2025-11-03 17:05:48 +10:30
parent 3a2e778b98
commit 7df6d1eec3
17 changed files with 111 additions and 194 deletions

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import { Check, X, Loader2, Save, Wifi, Trash2 } from 'lucide-svelte';
import { apiRequest } from '$api';
import TagInput from '$components/form/TagInput.svelte';
import Modal from '$components/modal/Modal.svelte';
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import type { ArrInstance } from '$db/queries/arrInstances.ts';
// Props
@@ -56,14 +55,20 @@
connectionError = '';
try {
await apiRequest<{ success: boolean; error?: string }>('/arr/test', {
const response = await fetch('/arr/test', {
method: 'POST',
body: JSON.stringify({ type, url, apiKey }),
showSuccessToast: true,
successMessage: 'Connection successful!'
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, url, apiKey })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Connection test failed');
}
connectionStatus = 'success';
alertStore.add('success', 'Connection successful!');
} catch (error) {
connectionStatus = 'error';
connectionError = error instanceof Error ? error.message : 'Connection test failed';
@@ -106,9 +111,9 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add('error', (result.data as { error?: string }).error || errorMessage);
alertStore.add('error', (result.data as { error?: string }).error || errorMessage);
} else if (result.type === 'redirect') {
toastStore.add('success', successMessage);
alertStore.add('success', successMessage);
}
await update();
};
@@ -351,12 +356,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete instance'
);
} else if (result.type === 'redirect') {
toastStore.add('success', 'Instance deleted successfully');
alertStore.add('success', 'Instance deleted successfully');
}
await update();
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import DiscordConfiguration from './DiscordConfiguration.svelte';
import { siDiscord } from 'simple-icons';
@@ -53,12 +53,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || `Failed to ${mode} service`
);
} else if (result.type === 'redirect') {
toastStore.add('success', `Notification service ${mode === 'create' ? 'created' : 'updated'} successfully`);
alertStore.add('success', `Notification service ${mode === 'create' ? 'created' : 'updated'} successfully`);
}
await update();
};

View File

@@ -1,12 +0,0 @@
<script lang="ts">
import { toastStore } from '$stores/toast';
import Toast from './Toast.svelte';
</script>
<div class="pointer-events-none fixed top-20 right-4 z-50 flex flex-col gap-3">
{#each $toastStore as toast (toast.id)}
<div class="pointer-events-auto">
<Toast id={toast.id} type={toast.type} message={toast.message} />
</div>
{/each}
</div>

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-svelte';
import type { ToastType } from '$stores/toast';
import { toastStore } from '$stores/toast';
import type { AlertType } from './store';
import { alertStore } from './store';
import { fade, fly } from 'svelte/transition';
export let id: string;
export let type: ToastType;
export let type: AlertType;
export let message: string;
// Icon mapping
@@ -38,7 +38,7 @@
const Icon = icons[type];
function dismiss() {
toastStore.remove(id);
alertStore.remove(id);
}
</script>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { alertStore } from './store';
import Alert from './Alert.svelte';
</script>
<div class="pointer-events-none fixed top-20 right-4 z-50 flex flex-col gap-3">
{#each $alertStore as alert (alert.id)}
<div class="pointer-events-auto">
<Alert id={alert.id} type={alert.type} message={alert.message} />
</div>
{/each}
</div>

View File

@@ -0,0 +1,41 @@
import { writable } from 'svelte/store';
export type AlertType = 'success' | 'error' | 'warning' | 'info';
export interface Alert {
id: string;
type: AlertType;
message: string;
duration?: number; // Auto-dismiss duration in ms (default: 5000)
}
function createAlertStore() {
const { subscribe, update } = writable<Alert[]>([]);
return {
subscribe,
add: (type: AlertType, message: string, duration = 5000) => {
const id = crypto.randomUUID();
const alert: Alert = { id, type, message, duration };
update((alerts) => [...alerts, alert]);
// Auto-dismiss after duration
if (duration > 0) {
setTimeout(() => {
update((alerts) => alerts.filter((a) => a.id !== id));
}, duration);
}
return id;
},
remove: (id: string) => {
update((alerts) => alerts.filter((a) => a.id !== id));
},
clear: () => {
update(() => []);
}
};
}
export const alertStore = createAlertStore();

View File

@@ -1,41 +0,0 @@
import { writable } from 'svelte/store';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number; // Auto-dismiss duration in ms (default: 5000)
}
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
return {
subscribe,
add: (type: ToastType, message: string, duration = 5000) => {
const id = crypto.randomUUID();
const toast: Toast = { id, type, message, duration };
update((toasts) => [...toasts, toast]);
// Auto-dismiss after duration
if (duration > 0) {
setTimeout(() => {
update((toasts) => toasts.filter((t) => t.id !== id));
}, duration);
}
return id;
},
remove: (id: string) => {
update((toasts) => toasts.filter((t) => t.id !== id));
},
clear: () => {
update(() => []);
}
};
}
export const toastStore = createToastStore();

View File

@@ -3,7 +3,7 @@
import logo from '$assets/logo.svg';
import Navbar from '$components/navigation/navbar/navbar.svelte';
import PageNav from '$components/navigation/pageNav/pageNav.svelte';
import ToastContainer from '$components/toast/ToastContainer.svelte';
import AlertContainer from '$alerts/AlertContainer.svelte';
</script>
<svelte:head>
@@ -13,7 +13,7 @@
<Navbar />
<PageNav />
<ToastContainer />
<AlertContainer />
<main class="pt-16 pl-72">
<slot />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import { Download, Plus, Trash2, RotateCcw, Database, Upload } from 'lucide-svelte';
import Modal from '$components/modal/Modal.svelte';
import type { PageData } from './$types';
@@ -93,12 +93,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to upload backup'
);
} else if (result.type === 'success') {
toastStore.add('success', 'Backup uploaded successfully');
alertStore.add('success', 'Backup uploaded successfully');
fileInput.value = ''; // Reset file input
}
await update();
@@ -135,12 +135,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to create backup'
);
} else if (result.type === 'success') {
toastStore.add('success', 'Backup created successfully');
alertStore.add('success', 'Backup created successfully');
}
await update();
};
@@ -247,12 +247,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to restore backup'
);
} else if (result.type === 'success') {
toastStore.add(
alertStore.add(
'success',
'Backup restored successfully. Please restart the application.'
);
@@ -282,12 +282,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete backup'
);
} else if (result.type === 'success') {
toastStore.add('success', 'Backup deleted successfully');
alertStore.add('success', 'Backup deleted successfully');
}
await update();
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import { Save } from 'lucide-svelte';
import NumberInput from '$components/form/NumberInput.svelte';
import type { BackupSettings } from './types';
@@ -29,9 +29,9 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add('error', (result.data as { error?: string }).error || 'Failed to save');
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save');
} else if (result.type === 'success') {
toastStore.add('success', 'Backup settings saved successfully!');
alertStore.add('success', 'Backup settings saved successfully!');
}
await update();
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import { Save, RotateCcw } from 'lucide-svelte';
import NumberInput from '$components/form/NumberInput.svelte';
import type { LogSettings } from './types';
@@ -47,9 +47,9 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add('error', (result.data as { error?: string }).error || 'Failed to save');
alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save');
} else if (result.type === 'success') {
toastStore.add('success', 'Log settings saved successfully!');
alertStore.add('success', 'Log settings saved successfully!');
}
await update();
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import { Save } from 'lucide-svelte';
import type { PageData } from './$types';
@@ -29,12 +29,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to update job'
);
} else if (result.type === 'redirect') {
toastStore.add('success', 'Job updated successfully!');
alertStore.add('success', 'Job updated successfully!');
}
await update();
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import { Play, CheckCircle, XCircle, AlertCircle, Edit2, Power } from 'lucide-svelte';
export let job: {
@@ -107,12 +107,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to trigger job'
);
} else if (result.type === 'success') {
toastStore.add('success', `Job "${job.name}" triggered successfully`);
alertStore.add('success', `Job "${job.name}" triggered successfully`);
}
await update();
};
@@ -136,12 +136,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to update job'
);
} else if (result.type === 'success') {
toastStore.add('success', `Job ${job.enabled ? 'disabled' : 'enabled'}`);
alertStore.add('success', `Job ${job.enabled ? 'disabled' : 'enabled'}`);
}
await update();
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Search, Download, RefreshCw, Eye, Copy } from 'lucide-svelte';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import Modal from '$components/modal/Modal.svelte';
import type { PageData } from './$types';
@@ -80,9 +80,9 @@
try {
await navigator.clipboard.writeText(logText);
toastStore.add('success', 'Log entry copied to clipboard');
alertStore.add('success', 'Log entry copied to clipboard');
} catch (err) {
toastStore.add('error', 'Failed to copy to clipboard');
alertStore.add('error', 'Failed to copy to clipboard');
}
}

View File

@@ -2,7 +2,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { toastStore } from '$stores/toast';
import { alertStore } from '$alerts/store';
import { Plus, Trash2, Bell, BellOff, MessageSquare, Send, Loader2, Pencil } from 'lucide-svelte';
import Modal from '$components/modal/Modal.svelte';
import NotificationHistory from '$components/notifications/NotificationHistory.svelte';
@@ -206,12 +206,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to update service'
);
} else if (result.type === 'success') {
toastStore.add('success', 'Service updated successfully');
alertStore.add('success', 'Service updated successfully');
}
await update();
};
@@ -277,12 +277,12 @@
testingServiceId = service.id;
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to send test notification'
);
} else if (result.type === 'success') {
toastStore.add('success', 'Test notification sent successfully');
alertStore.add('success', 'Test notification sent successfully');
}
testingServiceId = null;
await update();
@@ -320,12 +320,12 @@
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
toastStore.add(
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to delete service'
);
} else if (result.type === 'success') {
toastStore.add('success', 'Service deleted successfully');
alertStore.add('success', 'Service deleted successfully');
}
await update();
};

View File

@@ -1,88 +0,0 @@
import { toastStore } from '$stores/toast';
export interface ApiRequestOptions extends RequestInit {
showSuccessToast?: boolean; // Show toast on success (default: false)
showErrorToast?: boolean; // Show toast on error (default: true)
successMessage?: string; // Custom success message
errorMessage?: string; // Custom error message (overrides server error)
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
/**
* Wrapper for fetch API with automatic toast notifications
* @param url - Request URL
* @param options - Request options with toast configuration
* @returns Response data (automatically parsed as JSON)
*/
export async function apiRequest<T = unknown>(
url: string,
options: ApiRequestOptions = {}
): Promise<T> {
const {
showSuccessToast = false,
showErrorToast = true,
successMessage,
errorMessage,
headers,
...fetchOptions
} = options;
try {
// Set default headers
const defaultHeaders: HeadersInit = {
'Content-Type': 'application/json'
};
const response = await fetch(url, {
...fetchOptions,
headers: {
...defaultHeaders,
...headers
}
});
// Parse response
const data = await response.json();
// Handle HTTP errors
if (!response.ok) {
const message = errorMessage || data.error || data.message || `HTTP ${response.status}`;
if (showErrorToast) {
toastStore.add('error', message);
}
throw new ApiError(message, response.status, data);
}
// Show success toast if requested
if (showSuccessToast && successMessage) {
toastStore.add('success', successMessage);
}
return data as T;
} catch (error) {
// Handle network errors or other exceptions
if (error instanceof ApiError) {
throw error;
}
const message = errorMessage || (error instanceof Error ? error.message : 'Network error');
if (showErrorToast) {
toastStore.add('error', message);
}
throw new ApiError(message, 0);
}
}

View File

@@ -16,11 +16,11 @@ const config = {
$stores: './src/lib/client/stores',
$components: './src/components',
$assets: './src/lib/client/assets',
$alerts: './src/lib/client/alerts',
$server: './src/server',
$db: './src/db',
$arr: './src/utils/arr',
$http: './src/utils/http',
$api: './src/utils/api/request.ts',
$utils: './src/utils',
$notifications: './src/notifications',
}