diff --git a/docs/ui-ux-implementation.md b/docs/ui-ux-implementation.md index 6eb6a53..ea43363 100644 --- a/docs/ui-ux-implementation.md +++ b/docs/ui-ux-implementation.md @@ -33,17 +33,19 @@ The changes prioritize clear operational outcomes, reviewed action scope, recove | 23. Blueprint coverage | Existing/queued inventory is distinguished from healthy playable servers, with linked coverage details. Collections group assignments and show a sticky key count and jump-to-missing-key action. Creation remains limited to the target shortfall. | | 24. Mobile overhead | Fleet avoids redundant headings/zero campaign summaries, empty libraries emphasize their next action, and Leaderboard totals collapse on phones with a compact selected-stat table. Long forms retain reachable actions and focus spacing. | | 25. Consistent hierarchy | Editable controls use stronger dedicated boundaries. Routes have descriptive titles, operational language is clearer, keyboard shortcuts have a help panel, and selected overflow tabs scroll into view without moving the page. | -| 26. Investigation/handoffs | Logs support literal search/highlighting, copy, tail download, deep links, explicit follow/refresh state and jump-to-latest. A bounded recent-notifications panel retains results in the current browser tab. Beta rows include labels, age and blocker context. | +| 26. Investigation/handoffs | Logs support literal search/highlighting, copy, tail download, deep links, explicit follow/refresh state and jump-to-latest. Beta rows include labels, age and blocker context. | The review explicitly made overlay-priority reordering and Leaderboard player history/recent-period views conditional on further design or operator demand. Those product extensions remain future discovery work; the composition explanations and mobile Leaderboard changes are implemented. +Activity and notifications have been removed. Copy results appear next to their controls; GitHub imports retain their inline completion and error states. + ## API and operational boundaries - Promotion uses `GET /api/betas/:id/promote` to review impact and `POST` with the returned token as `previewToken` to apply it. A changed plan returns HTTP 409 and needs renewed review. - Reconciliation uses `POST /api/drift` with `dryRun: true` and optional `servers`, then executes with the returned `expectedFingerprint`. Omitted servers explicitly mean the entire fleet. A stale review returns HTTP 409; changes detected during application are reported as skipped files. - `POST /api/deployments/:id/cancel` only withdraws a waiting removal. It does not restore a server after deletion has started. - System status exposes no credential values. Its optional connection check verifies Komodo network reachability, not credential validity. Remote game-file volumes remain explicitly unverified. -- Drafts contain nonsecret beta setup. Launch keys are not stored in browser drafts. Recent notifications use tab-scoped session storage, capped at 50 entries, and do not imply background push delivery. +- Drafts contain nonsecret beta setup. Launch keys are not stored in browser drafts. - Log search/export covers the loaded tail, not an entire remote file or a full diagnostic archive. ## Validation diff --git a/docs/ui-ux-review.md b/docs/ui-ux-review.md index 891c391..49214d8 100644 --- a/docs/ui-ux-review.md +++ b/docs/ui-ux-review.md @@ -320,7 +320,7 @@ Add log search/highlighting, copy/download, and a deep link preserving the selec Acceptance: operators can return to a completed notification or share a precise diagnostic view without reconstructing the investigation. -Sources: [LogPanel](../src/lib/ui/components/LogPanel.svelte); [notification store](../src/lib/ui/toast.ts); [watch handling](../src/lib/ui/stores/fleet.ts); [beta list](../src/routes/betas/+page.svelte); [Leaderboard](../src/routes/leaderboard/+page.svelte). +Sources: [LogPanel](../src/lib/ui/components/LogPanel.svelte); [watch handling](../src/lib/ui/stores/fleet.ts); [beta list](../src/routes/betas/+page.svelte); [Leaderboard](../src/routes/leaderboard/+page.svelte). ## Suggested layout for operational detail pages diff --git a/src/lib/ui/components/CopyValue.svelte b/src/lib/ui/components/CopyValue.svelte index d7b52c7..b2b0457 100644 --- a/src/lib/ui/components/CopyValue.svelte +++ b/src/lib/ui/components/CopyValue.svelte @@ -1,11 +1,13 @@ -
{value}
- +
{value}{#if copyStatus}{copyStatus}{/if}
+ diff --git a/src/lib/ui/components/LogPanel.svelte b/src/lib/ui/components/LogPanel.svelte index c923f37..7bf5dcf 100644 --- a/src/lib/ui/components/LogPanel.svelte +++ b/src/lib/ui/components/LogPanel.svelte @@ -2,7 +2,6 @@ import { tick, untrack, onMount, onDestroy } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { toast } from '$ui/toast.js'; import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js'; import Tabs from './primitives/Tabs.svelte'; import Button from './primitives/Button.svelte'; @@ -33,11 +32,12 @@ let loading = $state(false); let autoRefresh = $state(true); let refreshError = $state(''); + let copyStatus = $state(''); let bodyEl = $state(null); let stickToBottom = $state(true); const refresh = createLogRefresh({ - onReset: () => { data = {}; refreshError = ''; }, + onReset: () => { data = {}; refreshError = ''; copyStatus = ''; }, onLoading: value => { loading = value; }, onError: message => { refreshError = message; }, onData: (key, body) => { @@ -67,8 +67,9 @@ await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight; } async function copy(value: string, message: string) { - try { await navigator.clipboard.writeText(value); toast(message); } - catch { toast('Could not copy. Select the log text to copy manually.', { variant: 'danger' }); } + copyStatus = ''; + try { await navigator.clipboard.writeText(value); copyStatus = message; } + catch { copyStatus = 'Could not copy. Select the log text or browser address to copy manually.'; } } function copyLink() { const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active); @@ -134,6 +135,7 @@
+ {#if copyStatus}

{copyStatus}

{/if}
{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}{#if !stickToBottom || query}{/if}
@@ -167,6 +169,7 @@ .log-tools { align-items: end; } .log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); } .log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; } + .copy-status { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); } .follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); } mark { background: var(--state-warning); color: var(--surface-sunken); } .log-panel { diff --git a/src/lib/ui/components/NotificationHistory.svelte b/src/lib/ui/components/NotificationHistory.svelte deleted file mode 100644 index 9efad05..0000000 --- a/src/lib/ui/components/NotificationHistory.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - - {#if $notificationHistory.length} -
    {#each $notificationHistory as item (item.id)}
  1. {item.message}

    {#if item.href} open = false}>View details →{/if}
  2. {/each}
- {:else}

No recent notifications. Completed lobby watches and action results will appear here.

{/if} - {#snippet actions()}{/snippet} -
- - diff --git a/src/lib/ui/components/PluginGithubImport.svelte b/src/lib/ui/components/PluginGithubImport.svelte index 8a34c3e..3baded2 100644 --- a/src/lib/ui/components/PluginGithubImport.svelte +++ b/src/lib/ui/components/PluginGithubImport.svelte @@ -6,7 +6,6 @@ import Card from './primitives/Card.svelte'; import Button from './primitives/Button.svelte'; import Badge from './primitives/Badge.svelte'; - import { toast } from '$ui/toast.js'; let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: { pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise; @@ -71,12 +70,12 @@ }); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Import failed.'); - if (!active) { toast('Plugin imported. Review its version when ready.', { variant: 'success', href: `/plugins/${body.pluginId}` }); return; } + if (!active) return; success = body; await onimport?.(body); if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl); else if (pluginId) await checkUpdates(); - } catch (e) { error = (e as Error).message; if (!active) toast(`Plugin import failed: ${error}`, { variant: 'danger', href: '/plugins' }); } + } catch (e) { error = (e as Error).message; } finally { busy = false; } } diff --git a/src/lib/ui/components/Toaster.svelte b/src/lib/ui/components/Toaster.svelte deleted file mode 100644 index d204fd7..0000000 --- a/src/lib/ui/components/Toaster.svelte +++ /dev/null @@ -1,88 +0,0 @@ - - -
- {#each $toasts as t (t.id)} -
- - {t.message} - {#if t.href} dismiss(t.id)}>View{/if} - -
- {/each} -
- - diff --git a/src/lib/ui/toast.ts b/src/lib/ui/toast.ts deleted file mode 100644 index 678df4e..0000000 --- a/src/lib/ui/toast.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { writable } from 'svelte/store'; - -// Small global toast queue. -// -// Any part of the UI can drop a message and it appears in the corner for a -// couple of seconds, then quietly dismisses. Replaces the ad-hoc "flash" -// strings scattered across pages when the message doesn't need to live inline -// with a form. - -export type ToastVariant = 'info' | 'success' | 'warning' | 'danger'; - -export interface Toast { - id: number; - message: string; - variant: ToastVariant; - ttl: number; - href?: string; -} - -export const toasts = writable([]); -export interface Notice extends Toast { createdAt: string; read: boolean; } -export const notificationHistory = writable([]); -const historyKey = 'electric-cherry:notifications'; - -export function startNotificationHistory(): () => void { - try { - const saved: unknown = JSON.parse(sessionStorage.getItem(historyKey) ?? '[]'); - if (Array.isArray(saved)) { - const valid = saved.filter((item): item is Notice => item && typeof item.id === 'number' && Number.isSafeInteger(item.id) - && typeof item.message === 'string' && ['info', 'success', 'warning', 'danger'].includes(item.variant) - && typeof item.createdAt === 'string' && Number.isFinite(Date.parse(item.createdAt)) - && typeof item.read === 'boolean' && (!item.href || (typeof item.href === 'string' && item.href.startsWith('/') && !item.href.startsWith('//')))).slice(0, 50); - notificationHistory.set(valid); - nextId = Math.max(nextId, ...valid.map(item => item.id + 1)); - } - } catch { /* Notifications still work when browser storage is unavailable. */ } - return notificationHistory.subscribe(items => { try { sessionStorage.setItem(historyKey, JSON.stringify(items)); } catch { /* Optional persistence. */ } }); -} - -export function markNotificationsRead() { notificationHistory.update(items => items.map(item => ({ ...item, read: true }))); } -export function clearNotifications() { notificationHistory.set([]); } - -let nextId = 1; - -export function toast( - message: string, - opts: { variant?: ToastVariant; ttl?: number; href?: string } = {} -): void { - const t: Toast = { - id: nextId++, - message, - variant: opts.variant ?? 'info', - ttl: opts.ttl ?? 3500, - href: opts.href - }; - toasts.update((arr) => [...arr, t]); - if (t.href || t.ttl === 0 || t.variant !== 'info') { - notificationHistory.update(items => [{ ...t, createdAt: new Date().toISOString(), read: false }, ...items].slice(0, 50)); - } - if (t.ttl > 0) { - setTimeout(() => dismiss(t.id), t.ttl); - } -} - -export function dismiss(id: number): void { - toasts.update((arr) => arr.filter((t) => t.id !== id)); -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a987647..0703968 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,11 +7,8 @@ import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js'; import { page } from '$app/stores'; import BrandMark from '$ui/components/BrandMark.svelte'; - import Toaster from '$ui/components/Toaster.svelte'; import Confirmation from '$ui/components/Confirmation.svelte'; - import NotificationHistory from '$ui/components/NotificationHistory.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte'; - import { notificationHistory, startNotificationHistory } from '$ui/toast.js'; import { resolveConfirmation } from '$ui/confirm.js'; import '$ui/styles/components.css'; import { enableViewTransitions } from '$ui/motion/viewTransition.js'; @@ -28,9 +25,7 @@ let accountOpen = $state(false); let signingOut = $state(false); let signOutError = $state(''); - let notificationsOpen = $state(false); let helpOpen = $state(false); - const unread = $derived($notificationHistory.filter(item => !item.read).length); let menuButton = $state(); let accountButton = $state(); const accountName = $derived(data.user?.displayName || data.user?.username || 'Account'); @@ -40,10 +35,9 @@ if (event.target instanceof Element && !event.target.closest('.account-menu')) accountOpen = false; } - function openUtility(panel: 'notifications' | 'help') { + function openHelp() { if (menuOpen) { menuButton?.focus(); menuOpen = false; } - if (panel === 'notifications') notificationsOpen = true; - else helpOpen = true; + helpOpen = true; } beforeNavigate(() => performance.mark('ec:navigation-start')); @@ -64,18 +58,15 @@ try { const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); } - try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ } - notificationHistory.set([]); window.location.assign('/login'); } catch (cause) { signOutError = (cause as Error).message; signingOut = false; } } $effect(() => { if (!sessionReady) return; - const stopNotifications = startNotificationHistory(); const stopPipeline = startPipelineStream(); const stopServers = startServerStream(); - return () => { stopNotifications(); stopPipeline?.(); stopServers?.(); }; + return () => { stopPipeline?.(); stopServers?.(); }; }); $effect(() => { @@ -103,7 +94,7 @@ const target = e.target as HTMLElement; if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return; if (e.metaKey || e.ctrlKey || e.altKey) return; - if (e.key === '?') { e.preventDefault(); openUtility('help'); return; } + if (e.key === '?') { e.preventDefault(); openHelp(); return; } if (!armed && e.key === 'g') { armed = true; @@ -215,7 +206,7 @@ {/each}
{/if} @@ -225,9 +216,7 @@ {#if sessionReady && data.user?.role === 'viewer'}{/if} {@render children()} - -
{#each [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}
{keys}
{label}
{/each}
diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 5c91508..6c8d4ea 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -24,7 +24,6 @@ try { const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); } - try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional history. */ } window.location.assign('/login'); } catch (cause) { error = (cause as Error).message; signingOut = false; } } diff --git a/src/routes/provisioning/[id]/+page.svelte b/src/routes/provisioning/[id]/+page.svelte index c94425c..313eb41 100644 --- a/src/routes/provisioning/[id]/+page.svelte +++ b/src/routes/provisioning/[id]/+page.svelte @@ -7,7 +7,6 @@ import { timeFormat } from '$ui/stores/time-zone.js'; import { pollVisible } from '$ui/poll.js'; import { serverTitle } from '$ui/server-labels.js'; - import { toast } from '$ui/toast.js'; import { createProvisioningRefresh, type ProvisioningRefreshState } from '$ui/provisioning-refresh.js'; import { elapsedTime, isProvisioningDetail, provisioningOutcome, provisioningReturn } from '$ui/provisioning-presentation.js'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; @@ -30,6 +29,7 @@ }); let now = $state(Date.parse(initial.generatedAt)); let launchOpen = $state(false); + let copyStatus = $state(''); const controller = createProvisioningRefresh({ validate: isProvisioningDetail, onChange: value => { view = value; } }); const detail = $derived(view.detail); const deployment = $derived(detail?.deployment); @@ -48,6 +48,7 @@ const snapshot = data.detail; untrack(() => { launchOpen = false; + copyStatus = ''; now = Date.parse(snapshot.generatedAt); controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt); }); @@ -56,12 +57,13 @@ onDestroy(() => controller.dispose()); function fmt(value: string | null | undefined) { return value ? $timeFormat.dateTime(value) : 'Not recorded'; } async function copyLink() { + copyStatus = ''; try { const url = new URL($page.url); url.search = ''; url.hash = ''; await navigator.clipboard.writeText(url.toString()); - toast('Operation link copied.', { variant: 'success' }); - } catch { toast('Copy unavailable. Copy this page’s address from your browser.', { variant: 'warning' }); } + copyStatus = 'Operation link copied.'; + } catch { copyStatus = 'Copy unavailable. Copy this page’s address from your browser.'; } } @@ -74,7 +76,7 @@
{#if deployment}
Operation
{/if} - +
{#if view.loadError}{/if}