feat: Phases 4-7 — Full Feature Expansion (26 features)
Phase 4 — New Widget Types: - Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown, Metric/Counter, Link Group, Camera/Stream widgets - Backend services with caching for each data source - Full creation form with dynamic config fields per type Phase 5 — Visual & Styling Enhancements: - Glassmorphism card style (solid/glass/outline) - Board-level themes with per-board hue/saturation - Animated SVG status rings replacing static dots - Card size options (compact/medium/large) - Custom CSS injection (admin + per-board, sanitized) - Wallpaper backgrounds with blur/overlay/parallax Phase 6 — Functional Features: - Favorites bar with drag-and-drop reordering - Recent apps tracking with privacy toggle - Uptime dashboard page (/status, guest-accessible) - Notifications system (Discord/Slack/Telegram/HTTP webhooks) - App tags with filtering in board view - Multi-URL app cards with expandable sub-links - Personal API tokens with scoped permissions - Audit log with retention and admin viewer Phase 7 — Quality of Life: - Onboarding wizard (5-step first-launch setup) - App URL health preview with favicon/title detection - Board templates (4 built-in + custom import/export) - Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help) 212 files changed, 15641 insertions, 980 deletions. Build, lint, type check, and 222 tests all pass.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { notifications } from '$lib/stores/notifications.svelte.js';
|
||||
|
||||
let showDropdown = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
notifications.load();
|
||||
notifications.startPolling();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
notifications.stopPolling();
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.notification-bell-container')) {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function eventLabel(event: string): string {
|
||||
switch (event) {
|
||||
case 'app_online':
|
||||
return 'Online';
|
||||
case 'app_offline':
|
||||
return 'Offline';
|
||||
case 'app_degraded':
|
||||
return 'Degraded';
|
||||
default:
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
function eventColor(event: string): string {
|
||||
switch (event) {
|
||||
case 'app_online':
|
||||
return 'text-green-500';
|
||||
case 'app_offline':
|
||||
return 'text-red-500';
|
||||
case 'app_degraded':
|
||||
return 'text-yellow-500';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<div class="notification-bell-container relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="relative inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<!-- Bell Icon -->
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
|
||||
<!-- Unread Badge -->
|
||||
{#if notifications.hasUnread}
|
||||
<span
|
||||
class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground"
|
||||
>
|
||||
{notifications.unreadCount > 99 ? '99+' : notifications.unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-popover-foreground">Notifications</h3>
|
||||
{#if notifications.hasUnread}
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary transition-colors hover:text-primary/80"
|
||||
onclick={() => notifications.markAllAsRead()}
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Notification List -->
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#if notifications.items.length === 0}
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">No notifications yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each notifications.items as notification (notification.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50 {notification.readAt === null ? 'bg-primary/5' : ''}"
|
||||
onclick={() => {
|
||||
if (notification.readAt === null) {
|
||||
notifications.markAsRead(notification.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<!-- Unread dot -->
|
||||
<span class="mt-1.5 flex-shrink-0">
|
||||
{#if notification.readAt === null}
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-primary"></span>
|
||||
{:else}
|
||||
<span class="inline-block h-2 w-2"></span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium {eventColor(notification.event)}">
|
||||
{eventLabel(notification.event)}
|
||||
</span>
|
||||
{#if notification.app}
|
||||
<span class="truncate text-xs text-muted-foreground">
|
||||
{notification.app.name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-popover-foreground">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p class="mt-1 text-[10px] text-muted-foreground">
|
||||
{formatTime(notification.sentAt)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-border p-2">
|
||||
<a
|
||||
href="/settings/notifications"
|
||||
class="block rounded-md px-3 py-1.5 text-center text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onclick={() => (showDropdown = false)}
|
||||
>
|
||||
Manage notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import { NotificationType } from '$lib/utils/constants.js';
|
||||
|
||||
interface ChannelData {
|
||||
readonly id?: string;
|
||||
readonly type: string;
|
||||
readonly config: string;
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
channel?: ChannelData | null;
|
||||
onSave: (data: { type: string; config: string; enabled: boolean }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { channel = null, onSave, onCancel }: Props = $props();
|
||||
|
||||
let channelType = $state(channel?.type ?? NotificationType.DISCORD);
|
||||
let enabled = $state(channel?.enabled ?? true);
|
||||
let testing = $state(false);
|
||||
let testResult = $state<string | null>(null);
|
||||
|
||||
// Dynamic config fields
|
||||
let discordWebhookUrl = $state('');
|
||||
let slackWebhookUrl = $state('');
|
||||
let telegramBotToken = $state('');
|
||||
let telegramChatId = $state('');
|
||||
let httpUrl = $state('');
|
||||
let httpMethod = $state('POST');
|
||||
|
||||
// Parse existing config
|
||||
if (channel?.config) {
|
||||
try {
|
||||
const parsed = JSON.parse(channel.config);
|
||||
switch (channel.type) {
|
||||
case 'discord':
|
||||
discordWebhookUrl = parsed.webhookUrl ?? '';
|
||||
break;
|
||||
case 'slack':
|
||||
slackWebhookUrl = parsed.webhookUrl ?? '';
|
||||
break;
|
||||
case 'telegram':
|
||||
telegramBotToken = parsed.botToken ?? '';
|
||||
telegramChatId = parsed.chatId ?? '';
|
||||
break;
|
||||
case 'http':
|
||||
httpUrl = parsed.url ?? '';
|
||||
httpMethod = parsed.method ?? 'POST';
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Invalid config
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfig(): string {
|
||||
switch (channelType) {
|
||||
case 'discord':
|
||||
return JSON.stringify({ webhookUrl: discordWebhookUrl });
|
||||
case 'slack':
|
||||
return JSON.stringify({ webhookUrl: slackWebhookUrl });
|
||||
case 'telegram':
|
||||
return JSON.stringify({ botToken: telegramBotToken, chatId: telegramChatId });
|
||||
case 'http':
|
||||
return JSON.stringify({ url: httpUrl, method: httpMethod });
|
||||
default:
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
onSave({
|
||||
type: channelType,
|
||||
config: buildConfig(),
|
||||
enabled
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
if (!channel?.id) return;
|
||||
testing = true;
|
||||
testResult = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notifications/channels/${channel.id}/test`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (res.ok) {
|
||||
testResult = 'Test notification sent successfully!';
|
||||
} else {
|
||||
const json = await res.json();
|
||||
testResult = `Failed: ${json.error ?? 'Unknown error'}`;
|
||||
}
|
||||
} catch {
|
||||
testResult = 'Failed: Network error';
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-border bg-card p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-card-foreground">
|
||||
{channel ? 'Edit Channel' : 'Add Notification Channel'}
|
||||
</h3>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<!-- Channel Type -->
|
||||
<div>
|
||||
<label for="channel-type" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Channel Type
|
||||
</label>
|
||||
<select
|
||||
id="channel-type"
|
||||
bind:value={channelType}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="http">HTTP Webhook</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Fields -->
|
||||
{#if channelType === 'discord'}
|
||||
<div>
|
||||
<label for="discord-url" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
id="discord-url"
|
||||
type="url"
|
||||
bind:value={discordWebhookUrl}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if channelType === 'slack'}
|
||||
<div>
|
||||
<label for="slack-url" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
id="slack-url"
|
||||
type="url"
|
||||
bind:value={slackWebhookUrl}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if channelType === 'telegram'}
|
||||
<div>
|
||||
<label for="tg-token" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Bot Token
|
||||
</label>
|
||||
<input
|
||||
id="tg-token"
|
||||
type="text"
|
||||
bind:value={telegramBotToken}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tg-chat" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Chat ID
|
||||
</label>
|
||||
<input
|
||||
id="tg-chat"
|
||||
type="text"
|
||||
bind:value={telegramChatId}
|
||||
placeholder="-1001234567890"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if channelType === 'http'}
|
||||
<div>
|
||||
<label for="http-url" class="mb-1 block text-sm font-medium text-foreground">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
id="http-url"
|
||||
type="url"
|
||||
bind:value={httpUrl}
|
||||
placeholder="https://example.com/webhook"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="http-method" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Method
|
||||
</label>
|
||||
<select
|
||||
id="http-method"
|
||||
bind:value={httpMethod}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Enabled Toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="channel-enabled"
|
||||
type="checkbox"
|
||||
bind:checked={enabled}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label for="channel-enabled" class="text-sm text-foreground">Enabled</label>
|
||||
</div>
|
||||
|
||||
<!-- Test Result -->
|
||||
{#if testResult}
|
||||
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-green-500'}">
|
||||
{testResult}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{channel ? 'Update' : 'Create'} Channel
|
||||
</button>
|
||||
{#if channel?.id}
|
||||
<button
|
||||
type="button"
|
||||
onclick={sendTest}
|
||||
disabled={testing}
|
||||
class="rounded-md border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{testing ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface NotificationItem {
|
||||
readonly id: string;
|
||||
readonly appId: string | null;
|
||||
readonly event: string;
|
||||
readonly message: string;
|
||||
readonly sentAt: string;
|
||||
readonly readAt: string | null;
|
||||
readonly app?: {
|
||||
readonly name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
let allNotifications = $state<NotificationItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let currentPage = $state(1);
|
||||
let hasMore = $state(false);
|
||||
let filterEvent = $state('');
|
||||
let filterAppId = $state('');
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
async function loadNotifications(page: number = 1) {
|
||||
loading = true;
|
||||
try {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String((page - 1) * PAGE_SIZE)
|
||||
});
|
||||
if (filterEvent) params.set('event', filterEvent);
|
||||
if (filterAppId) params.set('appId', filterAppId);
|
||||
|
||||
const res = await fetch(`/api/notifications?${params.toString()}`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.success && Array.isArray(json.data)) {
|
||||
allNotifications = json.data;
|
||||
hasMore = json.data.length === PAGE_SIZE;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadNotifications();
|
||||
});
|
||||
|
||||
function changePage(delta: number) {
|
||||
currentPage = Math.max(1, currentPage + delta);
|
||||
loadNotifications(currentPage);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
currentPage = 1;
|
||||
loadNotifications(1);
|
||||
}
|
||||
|
||||
function eventLabel(event: string): string {
|
||||
switch (event) {
|
||||
case 'app_online': return 'Online';
|
||||
case 'app_offline': return 'Offline';
|
||||
case 'app_degraded': return 'Degraded';
|
||||
default: return event;
|
||||
}
|
||||
}
|
||||
|
||||
function eventBadgeClass(event: string): string {
|
||||
switch (event) {
|
||||
case 'app_online': return 'bg-green-500/10 text-green-500';
|
||||
case 'app_offline': return 'bg-red-500/10 text-red-500';
|
||||
case 'app_degraded': return 'bg-yellow-500/10 text-yellow-500';
|
||||
default: return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
bind:value={filterEvent}
|
||||
onchange={applyFilters}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="">All Events</option>
|
||||
<option value="app_online">Online</option>
|
||||
<option value="app_offline">Offline</option>
|
||||
<option value="app_degraded">Degraded</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
{#if loading}
|
||||
<div class="py-12 text-center text-muted-foreground">Loading...</div>
|
||||
{:else if allNotifications.length === 0}
|
||||
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
|
||||
<p class="text-muted-foreground">No notifications found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Time</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Event</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">App</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Message</th>
|
||||
<th class="px-4 py-3 font-medium text-muted-foreground">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each allNotifications as notification (notification.id)}
|
||||
<tr class="border-b border-border last:border-0">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted-foreground">
|
||||
{new Date(notification.sentAt).toLocaleString()}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-block rounded-full px-2 py-0.5 text-xs font-medium {eventBadgeClass(notification.event)}">
|
||||
{eventLabel(notification.event)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-foreground">
|
||||
{notification.app?.name ?? '—'}
|
||||
</td>
|
||||
<td class="max-w-xs truncate px-4 py-3 text-sm text-foreground">
|
||||
{notification.message}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if notification.readAt}
|
||||
<span class="text-xs text-muted-foreground">Read</span>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-primary">Unread</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => changePage(-1)}
|
||||
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-sm text-muted-foreground">Page {currentPage}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMore}
|
||||
onclick={() => changePage(1)}
|
||||
class="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user