feat(ui): add global feedback and agent menu
This commit is contained in:
@@ -95,6 +95,25 @@ cards collapse from four columns to two and then one; hover color alone never
|
|||||||
replaces their textual destination labels, focus-visible treatment, or pressed
|
replaces their textual destination labels, focus-visible treatment, or pressed
|
||||||
feedback.
|
feedback.
|
||||||
|
|
||||||
|
Authenticated desktop account destinations are consolidated into one
|
||||||
|
agent-status menu at the right edge of the global header. Its trigger combines
|
||||||
|
the agent avatar, display name, online state, unread count, and an explicit
|
||||||
|
expanded marker. The menu contains My Profile, Notifications, and Sign Out;
|
||||||
|
these actions are not duplicated as standalone desktop header links. The menu
|
||||||
|
uses the same square, compact control-surface treatment as the rest of the
|
||||||
|
header, with orange reserved for the open/current state and unread count and
|
||||||
|
danger color reserved for the Sign Out hover state.
|
||||||
|
|
||||||
|
The account trigger exposes its expanded and controlled-menu state. Opening the
|
||||||
|
menu moves keyboard focus to its first destination; Escape closes it and
|
||||||
|
returns focus to the trigger, while an outside click dismisses it. Mobile keeps
|
||||||
|
Profile, Notifications, and Sign Out as explicit navigation actions and shows
|
||||||
|
the avatar in the signed-in identity block. Avatar imagery supplements rather
|
||||||
|
than replaces the textual identity, state, and unread indicators. User avatars
|
||||||
|
are always rendered as circular portraits with the restrained orange SHD ring
|
||||||
|
used by the Agent Profile; the source image remains square and uses
|
||||||
|
`object-fit: cover` so authenticated surfaces crop it consistently.
|
||||||
|
|
||||||
Contact notifications use the mandatory ISAC visual identity because they are
|
Contact notifications use the mandatory ISAC visual identity because they are
|
||||||
operational inbound messages. The notification carries a concise preview and a
|
operational inbound messages. The notification carries a concise preview and a
|
||||||
specific action to an authorization-checked full record. Full messages never
|
specific action to an authorization-checked full record. Full messages never
|
||||||
@@ -1392,3 +1411,34 @@ Weapon 1 · Top, and Weapon 2 · Bottom. Always include the Top/Bottom context
|
|||||||
when presenting numbered Weapon Talent slots to users. The filter remains
|
when presenting numbered Weapon Talent slots to users. The filter remains
|
||||||
additive within the group and wraps to two columns on narrow screens; do not
|
additive within the group and wraps to two columns on narrow screens; do not
|
||||||
introduce search or dropdown controls for this four-option set.
|
introduce search or dropdown controls for this four-option set.
|
||||||
|
|
||||||
|
Notification delivery preferences are a temporary profile control surface.
|
||||||
|
After a successful save, close the interface and return focus to the inbox
|
||||||
|
context. Consume a unique server result token only once so later actions or
|
||||||
|
reopening the panel do not close it again. Validation and server errors keep
|
||||||
|
the interface open with the submitted controls available for correction.
|
||||||
|
|
||||||
|
### Global toast feedback
|
||||||
|
|
||||||
|
Use the root-mounted `ToastViewport` and the shared API in `src/lib/toast.ts`
|
||||||
|
for transient action feedback. Enhanced server mutations should normally use
|
||||||
|
`PendingForm`, which derives success and failure feedback from the action
|
||||||
|
result. Client-only operations call `toast.success`, `toast.error`,
|
||||||
|
`toast.warning`, or `toast.info` directly. Do not create page-specific toast
|
||||||
|
implementations.
|
||||||
|
|
||||||
|
Toasts occupy the top-right safe area below the global header and show no more
|
||||||
|
than four messages. Each uses a textual state label, tone-colored top and left
|
||||||
|
rails, a concise title, optional supporting text, and an explicit dismiss
|
||||||
|
control. Success is green, errors use danger red, warnings use SHD orange, and
|
||||||
|
informational feedback uses restrained blue-gray. Color never carries state
|
||||||
|
alone.
|
||||||
|
|
||||||
|
Use success to confirm completed mutations, error for failed requests, warning
|
||||||
|
when attention is required, and info for neutral client or system state. Keep
|
||||||
|
inline validation beside affected fields even when an error toast is also
|
||||||
|
emitted. Avoid toasts for passive navigation, filtering, hover, unpersisted
|
||||||
|
selection changes, and background refreshes. Toasts automatically dismiss
|
||||||
|
according to severity, remain keyboard dismissible, use `status` or `alert`
|
||||||
|
semantics, and disable entrance motion with reduced motion. On mobile they span
|
||||||
|
the safe viewport width below the header without obscuring navigation.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let { user, isacAccess, notificationSummary, children }: {
|
let { user, isacAccess, notificationSummary, children }: {
|
||||||
user: { username: string; displayName: string; role: string } | null;
|
user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
|
||||||
isacAccess: boolean;
|
isacAccess: boolean;
|
||||||
notificationSummary: { unread: number };
|
notificationSummary: { unread: number };
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
let showCookieNotice = $state(false);
|
let showCookieNotice = $state(false);
|
||||||
let mobileOpen = $state(false);
|
let mobileOpen = $state(false);
|
||||||
let mobilePanel = $state<HTMLElement | null>(null);
|
let mobilePanel = $state<HTMLElement | null>(null);
|
||||||
|
let accountOpen = $state(false);
|
||||||
|
let accountButton = $state<HTMLButtonElement | null>(null);
|
||||||
|
let accountMenu = $state<HTMLElement | null>(null);
|
||||||
let unreadNotifications = $state(0);
|
let unreadNotifications = $state(0);
|
||||||
$effect(() => { unreadNotifications = notificationSummary.unread; });
|
$effect(() => { unreadNotifications = notificationSummary.unread; });
|
||||||
|
|
||||||
@@ -70,15 +73,33 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
closeMobileNavigation();
|
closeMobileNavigation();
|
||||||
}
|
}
|
||||||
|
if (event.key === 'Escape' && accountOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
accountOpen = false;
|
||||||
|
accountButton?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAccountMenu() {
|
||||||
|
accountOpen = !accountOpen;
|
||||||
|
if (accountOpen) {
|
||||||
|
await tick();
|
||||||
|
accountMenu?.querySelector<HTMLElement>('a')?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAccountFromOutside(event: MouseEvent) {
|
||||||
|
if (accountOpen && !(event.target as Element).closest('.desktop-agent-menu')) accountOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
page.url.pathname;
|
page.url.pathname;
|
||||||
mobileOpen = false;
|
mobileOpen = false;
|
||||||
|
accountOpen = false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleNavigationKey}/>
|
<svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/>
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="brand" href="/" aria-label="SHD.build home">
|
<a class="brand" href="/" aria-label="SHD.build home">
|
||||||
@@ -88,18 +109,36 @@
|
|||||||
{#each links as link}
|
{#each links as link}
|
||||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
|
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if user}
|
|
||||||
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
|
|
||||||
{/if}
|
|
||||||
{#if isacAccess}
|
{#if isacAccess}
|
||||||
<a class:active={page.url.pathname.startsWith('/isac')} href="/isac">ISAC</a>
|
<a class:active={page.url.pathname.startsWith('/isac')} href="/isac">ISAC</a>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="account">
|
<div class="account">
|
||||||
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
|
<div class="desktop-agent-menu">
|
||||||
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
|
<button
|
||||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
|
class="desktop-agent-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={accountOpen}
|
||||||
|
aria-controls="desktop-agent-menu"
|
||||||
|
bind:this={accountButton}
|
||||||
|
onclick={toggleAccountMenu}
|
||||||
|
>
|
||||||
|
<img src={user.avatarUrl} alt=""/>
|
||||||
|
<span><small><i></i> AGENT ONLINE</small><b>{user.displayName}</b></span>
|
||||||
|
{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{/if}
|
||||||
|
<strong aria-hidden="true">{accountOpen ? '−' : '+'}</strong>
|
||||||
|
</button>
|
||||||
|
{#if accountOpen}
|
||||||
|
<div id="desktop-agent-menu" class="desktop-agent-panel" role="menu" bind:this={accountMenu}>
|
||||||
|
<header><img src={user.avatarUrl} alt=""/><div><small>ACTIVE AGENT</small><b>{user.displayName}</b><span>{user.role.toUpperCase()} ACCESS</span></div></header>
|
||||||
|
<a role="menuitem" class:active={page.url.pathname === '/account'} href="/account"><span>MY PROFILE</span><b>AGENT DOSSIER →</b></a>
|
||||||
|
<a role="menuitem" class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><span>NOTIFICATIONS</span><b>{unreadNotifications ? `${unreadNotifications > 99 ? '99+' : unreadNotifications} UNREAD` : 'INBOX CLEAR'} →</b></a>
|
||||||
|
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button role="menuitem" class="desktop-agent-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="sign-in" href="/sign-in">SIGN IN →</a>
|
<a class="sign-in" href="/sign-in">SIGN IN →</a>
|
||||||
@@ -117,14 +156,14 @@
|
|||||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
|
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if user}
|
{#if user}
|
||||||
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Notifications</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
||||||
<a class:active={page.url.pathname === '/account'} href="/account"><small>{String(links.length + 2).padStart(2, '0')}</small><span>My Profile</span><b>›</b></a>
|
<a class:active={page.url.pathname === '/account'} href="/account"><small>{String(links.length + 2).padStart(2, '0')}</small><span>My Profile</span><b>›</b></a>
|
||||||
{:else}
|
{:else}
|
||||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Sign In</span><b>›</b></a>
|
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Sign In</span><b>›</b></a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>{String(links.length + 3).padStart(2, '0')}</small><span>ISAC</span><b>›</b></a>{/if}
|
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>{String(links.length + 3).padStart(2, '0')}</small><span>ISAC</span><b>›</b></a>{/if}
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
<div class="mobile-agent-state"><img src={user.avatarUrl} alt=""/><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
||||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { actionFeedback } from '$lib/toast';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
let { action, method = 'POST', enctype, class: className = '', children }: { action: string; method?: 'POST'; enctype?: 'multipart/form-data'; class?: string; children: Snippet<[boolean]> } = $props();
|
let {
|
||||||
|
action,
|
||||||
|
method = 'POST',
|
||||||
|
enctype,
|
||||||
|
class: className = '',
|
||||||
|
toast = true,
|
||||||
|
successMessage,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
action: string;
|
||||||
|
method?: 'POST';
|
||||||
|
enctype?: 'multipart/form-data';
|
||||||
|
class?: string;
|
||||||
|
toast?: boolean;
|
||||||
|
successMessage?: string;
|
||||||
|
children: Snippet<[boolean]>;
|
||||||
|
} = $props();
|
||||||
let pending = $state(false);
|
let pending = $state(false);
|
||||||
</script>
|
</script>
|
||||||
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
|
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ result, update }) => { try { if (toast) actionFeedback(result, action, successMessage); await update(); } finally { pending = false; } }; }}>
|
||||||
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
|
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { dismissToast, toasts, type Toast } from '$lib/toast';
|
||||||
|
|
||||||
|
const toneLabel = (toast: Toast) => ({
|
||||||
|
success: 'OPERATION COMPLETE',
|
||||||
|
error: 'ACTION FAILED',
|
||||||
|
warning: 'ATTENTION REQUIRED',
|
||||||
|
info: 'SYSTEM UPDATE'
|
||||||
|
})[toast.tone];
|
||||||
|
|
||||||
|
function startTimer(_node: HTMLElement, toast: Toast) {
|
||||||
|
const timer = window.setTimeout(() => dismissToast(toast.id), toast.duration);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="toast-viewport" aria-label="Application feedback">
|
||||||
|
{#each $toasts as item (item.id)}
|
||||||
|
<article
|
||||||
|
class={`toast-message ${item.tone}`}
|
||||||
|
role={item.tone === 'error' || item.tone === 'warning' ? 'alert' : 'status'}
|
||||||
|
aria-atomic="true"
|
||||||
|
use:startTimer={item}
|
||||||
|
>
|
||||||
|
<i aria-hidden="true"></i>
|
||||||
|
<div>
|
||||||
|
<span>{toneLabel(item)}</span>
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
{#if item.message}<p>{item.message}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" aria-label={`Dismiss ${item.title}`} onclick={() => dismissToast(item.id)}>×</button>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export type ToastTone = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
export type Toast = {
|
||||||
|
id: string;
|
||||||
|
tone: ToastTone;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = writable<Toast[]>([]);
|
||||||
|
export const toasts = { subscribe: store.subscribe };
|
||||||
|
|
||||||
|
const defaultDuration: Record<ToastTone, number> = {
|
||||||
|
success: 5000,
|
||||||
|
info: 6000,
|
||||||
|
warning: 8000,
|
||||||
|
error: 10000
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dismissToast(id: string) {
|
||||||
|
store.update((items) => items.filter((item) => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushToast(input: {
|
||||||
|
tone?: ToastTone;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
}) {
|
||||||
|
const tone = input.tone ?? 'info';
|
||||||
|
const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
||||||
|
const toast: Toast = {
|
||||||
|
id,
|
||||||
|
tone,
|
||||||
|
title: input.title,
|
||||||
|
message: input.message,
|
||||||
|
duration: input.duration ?? defaultDuration[tone]
|
||||||
|
};
|
||||||
|
store.update((items) => [...items.slice(-3), toast]);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
success: (title: string, message?: string) => pushToast({ tone: 'success', title, message }),
|
||||||
|
error: (title: string, message?: string) => pushToast({ tone: 'error', title, message }),
|
||||||
|
warning: (title: string, message?: string) => pushToast({ tone: 'warning', title, message }),
|
||||||
|
info: (title: string, message?: string) => pushToast({ tone: 'info', title, message })
|
||||||
|
};
|
||||||
|
|
||||||
|
const readableKey = (key: string) =>
|
||||||
|
key
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replaceAll('_', ' ')
|
||||||
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||||
|
|
||||||
|
const successfulKey = /(saved|created|updated|changed|removed|deleted|unlinked|linked|added|sent|cleared|approved|rejected|reverted|archived|unarchived|queued|enabled|disabled|restored)$/i;
|
||||||
|
|
||||||
|
export function actionFeedback(
|
||||||
|
result: { type: string; data?: unknown },
|
||||||
|
action: string,
|
||||||
|
successMessage?: string
|
||||||
|
) {
|
||||||
|
const data = result.data && typeof result.data === 'object' ? result.data as Record<string, unknown> : {};
|
||||||
|
if (result.type === 'failure' || result.type === 'error') {
|
||||||
|
const message = Object.entries(data).find(([key, value]) =>
|
||||||
|
/error|message/i.test(key) && typeof value === 'string'
|
||||||
|
)?.[1];
|
||||||
|
toast.error('REQUEST NOT COMPLETED', typeof message === 'string' ? message : 'Review the form and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.type !== 'success' && result.type !== 'redirect') return;
|
||||||
|
const explicit = typeof data.toastMessage === 'string'
|
||||||
|
? data.toastMessage
|
||||||
|
: typeof data.message === 'string'
|
||||||
|
? data.message
|
||||||
|
: successMessage;
|
||||||
|
if (explicit) {
|
||||||
|
toast.success(explicit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resultKey = Object.entries(data).find(([key, value]) => value === true && successfulKey.test(key))?.[0];
|
||||||
|
if (resultKey) {
|
||||||
|
toast.success(readableKey(resultKey));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actionName = action.split('?/')[1]?.split(/[?#]/)[0] ?? '';
|
||||||
|
const actionMessages: Record<string, string> = {
|
||||||
|
save: 'Changes saved',
|
||||||
|
saveTalent: 'Talent saved',
|
||||||
|
saveTrigger: 'Trigger saved',
|
||||||
|
savePreferences: 'Preferences saved',
|
||||||
|
saveCoreRanges: 'Configuration saved',
|
||||||
|
saveProfile: 'Profile saved',
|
||||||
|
approve: 'Approved successfully',
|
||||||
|
reject: 'Rejected successfully',
|
||||||
|
correct: 'Corrections saved',
|
||||||
|
revert: 'Revert completed',
|
||||||
|
remove: 'Removed successfully',
|
||||||
|
unlinkDiscord: 'Discord connection unlinked',
|
||||||
|
removePasskey: 'Passkey removed',
|
||||||
|
removeEmail: 'Email sign-in removed',
|
||||||
|
removeCustomAvatar: 'Custom avatar removed',
|
||||||
|
changePassword: 'Password updated',
|
||||||
|
changeDisplayName: 'Display name updated',
|
||||||
|
changeLoginUsername: 'Login username updated',
|
||||||
|
requestEmailVerification: 'Verification email sent',
|
||||||
|
markAllRead: 'All messages marked as read',
|
||||||
|
markRead: 'Message marked as read',
|
||||||
|
send: 'Message sent'
|
||||||
|
};
|
||||||
|
if (actionMessages[actionName]) toast.success(actionMessages[actionName]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import AppShell from '$lib/components/AppShell.svelte';
|
import AppShell from '$lib/components/AppShell.svelte';
|
||||||
|
import ToastViewport from '$lib/components/ToastViewport.svelte';
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -9,3 +10,4 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell>
|
<AppShell user={data.user} isacAccess={data.isacAccess} notificationSummary={data.notifications}>{@render children()}</AppShell>
|
||||||
|
<ToastViewport/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
import PageBreadcrumb from '$lib/components/PageBreadcrumb.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||||
|
import { toast } from '$lib/toast';
|
||||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
|
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
@@ -64,9 +65,11 @@
|
|||||||
const result = await verification.json();
|
const result = await verification.json();
|
||||||
if (!verification.ok || !result.verified) throw new Error(result.message || 'Passkey registration failed.');
|
if (!verification.ok || !result.verified) throw new Error(result.message || 'Passkey registration failed.');
|
||||||
passkeyMessage = 'Passkey registered. Reloading account methods…';
|
passkeyMessage = 'Passkey registered. Reloading account methods…';
|
||||||
|
toast.success('Passkey registered', 'Your new sign-in method is ready.');
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
passkeyClientError = error instanceof Error ? error.message : 'Passkey registration was cancelled.';
|
passkeyClientError = error instanceof Error ? error.message : 'Passkey registration was cancelled.';
|
||||||
|
toast.error('Passkey not registered', passkeyClientError);
|
||||||
passkeyPending = false;
|
passkeyPending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export const actions: Actions = {
|
|||||||
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
|
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
|
||||||
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
|
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
|
||||||
}
|
}
|
||||||
return { notificationPreferencesSaved: true };
|
return {
|
||||||
|
notificationPreferencesSaved: true,
|
||||||
|
notificationPreferenceSaveToken: crypto.randomUUID()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
let settingsOpen = $state(false);
|
let settingsOpen = $state(false);
|
||||||
|
let consumedPreferenceSaveToken = $state<string | null>(null);
|
||||||
let selected = $state<any>(null);
|
let selected = $state<any>(null);
|
||||||
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
|
let browserPermission = $state(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission);
|
||||||
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
|
const labels: Record<string, string> = { isac: 'ISAC', contributions: 'CONTRIBUTIONS', comments: 'COMMENTS', builds: 'BUILDS', guides: 'GUIDES', system: 'SYSTEM' };
|
||||||
@@ -45,6 +46,13 @@
|
|||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
|
document.querySelector<HTMLFormElement>('.notification-filter-console')?.requestSubmit();
|
||||||
}
|
}
|
||||||
|
$effect(() => {
|
||||||
|
const token = form?.notificationPreferenceSaveToken;
|
||||||
|
if (token && token !== consumedPreferenceSaveToken) {
|
||||||
|
consumedPreferenceSaveToken = token;
|
||||||
|
settingsOpen = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
|
<svelte:head><title>Notification inbox — SHD.build</title></svelte:head>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
import RangeValueControl from '$lib/components/RangeValueControl.svelte';
|
||||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
import { toast } from '$lib/toast';
|
||||||
import {
|
import {
|
||||||
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
|
blankRoll, clamp, createDraft, gearBaseStatRange, gearSlots, levelScaledRange, remapRangeValue, scaled, weaponBaseRange,
|
||||||
type LoadoutDraft, type Roll
|
type LoadoutDraft, type Roll
|
||||||
@@ -326,8 +327,8 @@
|
|||||||
});
|
});
|
||||||
function saveDraft() {
|
function saveDraft() {
|
||||||
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
|
localStorage.setItem('shd-build-loadout-draft-v1', JSON.stringify(draft));
|
||||||
saved = true; message = 'Draft saved on this device.';
|
saved = true;
|
||||||
setTimeout(() => (message = ''), 2200);
|
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user