feat(notifications): show full notification details with actions
Long announcements were clamped in the list and had nowhere to open, so notifications now open a detail dialog with the full message, type, theme, sender and timestamp, plus a shortcut into the Community Workshop.
This commit is contained in:
1 parent
520ad5c4f1
commit
fc128b7178
3 files changed
+171
-6
No files matched your search
@@ -11,6 +11,8 @@
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
LoaderCircle,
|
||||
Megaphone,
|
||||
RefreshCw,
|
||||
@@ -19,6 +21,8 @@
|
||||
} from '@lucide/svelte';
|
||||
import { Portal } from 'bits-ui';
|
||||
|
||||
let { onOpenWorkshop }: { onOpenWorkshop?: () => void } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let loading = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
@@ -27,8 +31,21 @@
|
||||
let unreadCount = $state(0);
|
||||
let hasMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedNotification = $state<UserNotification | null>(null);
|
||||
|
||||
const unreadLabel = $derived(unreadCount > 9 ? '9+' : String(unreadCount));
|
||||
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
|
||||
theme_hidden: 'Theme hidden',
|
||||
theme_restored: 'Theme restored',
|
||||
theme_taken_down: 'Theme taken down',
|
||||
report_resolved: 'Report resolved',
|
||||
report_dismissed: 'Report dismissed',
|
||||
system: 'Announcement',
|
||||
};
|
||||
|
||||
function notificationTypeLabel(type: string): string {
|
||||
return NOTIFICATION_TYPE_LABELS[type] ?? 'Notification';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const userId = $currentUser?.id;
|
||||
@@ -75,6 +92,7 @@
|
||||
|
||||
function openHub() {
|
||||
if (!$currentUser) return;
|
||||
selectedNotification = null;
|
||||
open = true;
|
||||
void loadNotifications();
|
||||
}
|
||||
@@ -105,9 +123,13 @@
|
||||
if (notification.readAt) return;
|
||||
try {
|
||||
await markNotificationRead(notification.id);
|
||||
const readAt = new Date().toISOString();
|
||||
notifications = notifications.map((item) =>
|
||||
item.id === notification.id ? { ...item, readAt: new Date().toISOString() } : item
|
||||
item.id === notification.id ? { ...item, readAt } : item
|
||||
);
|
||||
if (selectedNotification?.id === notification.id) {
|
||||
selectedNotification = { ...selectedNotification, readAt };
|
||||
}
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
@@ -124,6 +146,12 @@
|
||||
...item,
|
||||
readAt: item.readAt ?? now,
|
||||
}));
|
||||
if (selectedNotification) {
|
||||
selectedNotification = {
|
||||
...selectedNotification,
|
||||
readAt: selectedNotification.readAt ?? now,
|
||||
};
|
||||
}
|
||||
unreadCount = 0;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
@@ -132,6 +160,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openNotification(notification: UserNotification) {
|
||||
error = null;
|
||||
selectedNotification = notification;
|
||||
void markRead(notification);
|
||||
}
|
||||
|
||||
function openWorkshopFromNotification() {
|
||||
if (!selectedNotification?.themeId || !onOpenWorkshop) return;
|
||||
onOpenWorkshop();
|
||||
selectedNotification = null;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
@@ -260,10 +301,10 @@
|
||||
{#each notifications as notification (notification.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {notification.readAt
|
||||
class="group w-full rounded-lg border p-3 text-left transition-colors {notification.readAt
|
||||
? 'border-transparent bg-transparent hover:bg-theme-900/50'
|
||||
: 'border-primary/20 bg-primary/5 hover:bg-primary/10'}"
|
||||
onclick={() => void markRead(notification)}
|
||||
onclick={() => openNotification(notification)}
|
||||
>
|
||||
<div class="flex items-start gap-2.5">
|
||||
<div
|
||||
@@ -289,12 +330,19 @@
|
||||
<span class="mt-1 size-1.5 shrink-0 rounded-full bg-primary"></span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 text-[10px] leading-relaxed text-muted-foreground">
|
||||
<p class="mt-1 line-clamp-2 text-[10px] leading-relaxed text-muted-foreground">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p class="mt-1.5 text-[9px] text-muted-foreground/70">
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2">
|
||||
<p class="text-[9px] text-muted-foreground/70">
|
||||
{formatDate(notification.createdAt)}
|
||||
</p>
|
||||
<span
|
||||
class="flex items-center gap-1 text-[9px] text-muted-foreground/60 transition-colors group-hover:text-theme-100"
|
||||
>
|
||||
Details <ChevronRight class="size-3" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -320,5 +368,116 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedNotification}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[95] cursor-default bg-black/60 backdrop-blur-sm"
|
||||
aria-label="Close notification details"
|
||||
onclick={() => (selectedNotification = null)}
|
||||
></button>
|
||||
<div class="pointer-events-none fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="pointer-events-auto flex max-h-[min(42rem,calc(100vh-2rem))] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-theme-800 bg-theme-950 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="notification-detail-title"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 border-b border-theme-800/70 p-4">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full {selectedNotification.type ===
|
||||
'theme_hidden' || selectedNotification.type === 'theme_taken_down'
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: selectedNotification.type === 'report_resolved'
|
||||
? 'bg-emerald-500/10 text-emerald-300'
|
||||
: selectedNotification.type === 'system'
|
||||
? 'bg-cyan-500/10 text-cyan-300'
|
||||
: 'bg-amber-500/10 text-amber-300'}"
|
||||
>
|
||||
{#if selectedNotification.type === 'system'}
|
||||
<Megaphone class="size-4" />
|
||||
{:else}
|
||||
<ShieldAlert class="size-4" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[9px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{notificationTypeLabel(selectedNotification.type)}
|
||||
</p>
|
||||
<h2 id="notification-detail-title" class="mt-1 text-sm font-semibold text-white">
|
||||
{selectedNotification.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-white"
|
||||
title="Close notification"
|
||||
onclick={() => (selectedNotification = null)}
|
||||
>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<p
|
||||
class="whitespace-pre-wrap break-words text-xs leading-relaxed text-theme-100 select-text"
|
||||
>
|
||||
{selectedNotification.message}
|
||||
</p>
|
||||
{#if error}
|
||||
<p
|
||||
class="mt-3 rounded-md border border-red-900/50 bg-red-950/30 p-2 text-[10px] text-red-300"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
<dl
|
||||
class="mt-4 space-y-1.5 border-t border-theme-800/60 pt-3 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{#if selectedNotification.themeName}
|
||||
<div class="flex gap-1.5">
|
||||
<dt>Theme:</dt>
|
||||
<dd class="min-w-0 truncate text-theme-100">{selectedNotification.themeName}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedNotification.actorName}
|
||||
<div class="flex gap-1.5">
|
||||
<dt>From:</dt>
|
||||
<dd class="text-theme-100">{selectedNotification.actorName}</dd>
|
||||
</div>
|
||||
{:else if selectedNotification.actorId}
|
||||
<div class="flex gap-1.5">
|
||||
<dt>From:</dt>
|
||||
<dd>Staff</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-1.5">
|
||||
<dt>Received:</dt>
|
||||
<dd>{formatDate(selectedNotification.createdAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t border-theme-800/70 p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
onclick={() => (selectedNotification = null)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{#if selectedNotification.themeId && onOpenWorkshop}
|
||||
<Button size="sm" class="h-8 gap-1.5 text-xs" onclick={openWorkshopFromNotification}>
|
||||
<ExternalLink class="size-3.5" />
|
||||
Open Community Workshop
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Portal>
|
||||
{/if}
|
||||
@@ -16,6 +16,7 @@ export interface UserNotification {
|
||||
themeId: string | null;
|
||||
themeName: string | null;
|
||||
actorId: number | null;
|
||||
actorName?: string | null;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -2178,7 +2178,12 @@
|
||||
<ThemeIcon name="settings" set={iconSet} class="size-5!" />
|
||||
</Button>
|
||||
<div class="mt-auto flex flex-col items-center gap-2">
|
||||
<NotificationHub />
|
||||
<NotificationHub
|
||||
onOpenWorkshop={() => {
|
||||
selectedView = 'THEMES';
|
||||
activeThemesTab = 'workshop';
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger disabled={$launching}>
|
||||
<Avatar.Root class="size-10">
|
||||
|
||||
Reference in new issue
Block a user