feat: nav counter badges, login backdrop, events i18n + misc fixes
Build / build (push) Successful in 10m29s
Build / build (push) Successful in 10m29s
Nav & UI polish
- Sidebar nav items show monospace count badges (projects, sites, stacks,
proxies). Events badge shows error count only, styled red as actionable
- New $lib/stores/navCounts.ts polls all counts in parallel every 60s and
refreshes on route change so badges track mutations
- Login page gets a dynamic forge backdrop: rotating conic glow, drifting
embers, dot-grid texture, vignette — all pure CSS, reduced-motion safe
- main element gets scrollbar-gutter: stable so Settings tab switching no
longer shifts horizontally when content heights differ
Events i18n
- events.source.* dictionary rewritten to match actually-emitted backend
sources (deploy, static_site, stale_scanner, stale_cleanup, admin);
dead keys (container, proxy, system) removed
- EventLogFilter.allSources + /events default sources state updated to match
- Localize "{N} total" via events.totalCount in the page hero toolbar
Backend
- Stage API accepts enable_proxy on create/update (defaults to true) so
proxy registration can be opted out per stage
Concurrency
- api.ts: queued request waiters no longer double-increment the inflight
counter; releasing a slot hands it off directly
Reactive effects
- project detail / env / volumes pages wrap side-effect calls in untrack()
to prevent $effect feedback loops when their loaders mutate tracked state
This commit is contained in:
+4
-1
@@ -57,7 +57,10 @@ function acquireSlot(signal?: AbortSignal | null): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const entry = () => { inflight++; resolve(); };
|
||||
// A queued waiter inherits the releasing request's slot, so it
|
||||
// must not increment `inflight` again — `releaseSlot` skips the
|
||||
// decrement when it hands the slot off, keeping the count stable.
|
||||
const entry = () => { resolve(); };
|
||||
queue.push(entry);
|
||||
|
||||
signal?.addEventListener('abort', () => {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
const allSeverities = ['info', 'warn', 'error'] as const;
|
||||
const allSources = ['deploy', 'container', 'proxy', 'system'] as const;
|
||||
const allSources = ['deploy', 'static_site', 'stale_scanner', 'stale_cleanup', 'admin'] as const;
|
||||
|
||||
const dateRangeOptions = [
|
||||
{ value: '1h', labelKey: 'events.filter.lastHour' },
|
||||
|
||||
@@ -817,6 +817,7 @@
|
||||
"noEventsDesc": "Events will appear here as they occur.",
|
||||
"loadMore": "Load more",
|
||||
"newEvents": "new events",
|
||||
"totalCount": "{count} total",
|
||||
"clearAll": "Clear All",
|
||||
"clearAllTitle": "Clear Event Log",
|
||||
"clearAllMessage": "This will permanently delete all event log entries. This cannot be undone.",
|
||||
@@ -840,9 +841,10 @@
|
||||
},
|
||||
"source": {
|
||||
"deploy": "Deploy",
|
||||
"container": "Container",
|
||||
"proxy": "Proxy",
|
||||
"system": "System"
|
||||
"static_site": "Static Site",
|
||||
"stale_scanner": "Stale Scanner",
|
||||
"stale_cleanup": "Stale Cleanup",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"metadata": "Details"
|
||||
},
|
||||
|
||||
@@ -817,6 +817,7 @@
|
||||
"noEventsDesc": "События будут отображаться здесь по мере их возникновения.",
|
||||
"loadMore": "Загрузить ещё",
|
||||
"newEvents": "новых событий",
|
||||
"totalCount": "всего {count}",
|
||||
"clearAll": "Очистить всё",
|
||||
"clearAllTitle": "Очистить журнал событий",
|
||||
"clearAllMessage": "Все записи журнала событий будут удалены безвозвратно.",
|
||||
@@ -840,9 +841,10 @@
|
||||
},
|
||||
"source": {
|
||||
"deploy": "Развёртывание",
|
||||
"container": "Контейнер",
|
||||
"proxy": "Прокси",
|
||||
"system": "Система"
|
||||
"static_site": "Статический сайт",
|
||||
"stale_scanner": "Сканер устаревших",
|
||||
"stale_cleanup": "Очистка устаревших",
|
||||
"admin": "Администратор"
|
||||
},
|
||||
"metadata": "Подробности"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Small store that exposes counts for sidebar nav badges.
|
||||
*
|
||||
* Values reflect the last successful poll. Individual sources fail
|
||||
* independently — a failure keeps the previous value and flips `stale` true
|
||||
* so the UI can dim the badge if desired. The poller is intentionally
|
||||
* forgiving: if the user is unauthenticated or the backend isn't ready,
|
||||
* it silently retries on the next tick.
|
||||
*/
|
||||
|
||||
import { writable, type Readable } from 'svelte/store';
|
||||
import * as api from '$lib/api';
|
||||
import { isAuthenticated } from '$lib/auth';
|
||||
|
||||
export interface NavCounts {
|
||||
projects: number | null;
|
||||
sites: number | null;
|
||||
stacks: number | null;
|
||||
proxies: number | null;
|
||||
/** Error-severity events only; dashboard surfaces total separately. */
|
||||
eventsErrors: number | null;
|
||||
}
|
||||
|
||||
const EMPTY: NavCounts = {
|
||||
projects: null,
|
||||
sites: null,
|
||||
stacks: null,
|
||||
proxies: null,
|
||||
eventsErrors: null
|
||||
};
|
||||
|
||||
const store = writable<NavCounts>(EMPTY);
|
||||
|
||||
export const navCounts: Readable<NavCounts> = { subscribe: store.subscribe };
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let inFlight = false;
|
||||
|
||||
async function refreshOnce(): Promise<void> {
|
||||
if (inFlight || !isAuthenticated()) return;
|
||||
inFlight = true;
|
||||
try {
|
||||
const [projects, sites, stacks, proxies, eventStats] = await Promise.allSettled([
|
||||
api.listProjects(),
|
||||
api.listStaticSites(),
|
||||
api.listStacks(),
|
||||
api.listProxyRoutes(),
|
||||
api.fetchEventLogStats()
|
||||
]);
|
||||
|
||||
store.update((prev) => ({
|
||||
projects: projects.status === 'fulfilled' ? projects.value.length : prev.projects,
|
||||
sites: sites.status === 'fulfilled' ? sites.value.length : prev.sites,
|
||||
stacks: stacks.status === 'fulfilled' ? stacks.value.length : prev.stacks,
|
||||
proxies: proxies.status === 'fulfilled' ? proxies.value.length : prev.proxies,
|
||||
eventsErrors: eventStats.status === 'fulfilled' ? eventStats.value.error : prev.eventsErrors
|
||||
}));
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic polling of nav counts. Safe to call repeatedly —
|
||||
* subsequent calls are no-ops until `stopNavCountsPolling()` is called.
|
||||
*/
|
||||
export function startNavCountsPolling(intervalMs = 60_000): void {
|
||||
if (pollTimer) return;
|
||||
void refreshOnce();
|
||||
pollTimer = setInterval(() => void refreshOnce(), intervalMs);
|
||||
}
|
||||
|
||||
export function stopNavCountsPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Trigger an out-of-band refresh (e.g. after a mutation). */
|
||||
export function refreshNavCounts(): void {
|
||||
void refreshOnce();
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
import { logout as apiLogout, getHealth } from '$lib/api';
|
||||
import type { DockerHealth, ProxyHealth } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
@@ -20,16 +21,25 @@
|
||||
|
||||
const { children }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'eventsErrors';
|
||||
|
||||
const navItems: ReadonlyArray<{
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: string;
|
||||
countKey?: NavCountKey;
|
||||
/** When true the badge uses a danger style (red). */
|
||||
alert?: boolean;
|
||||
}> = [
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
||||
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe' },
|
||||
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks' },
|
||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
|
||||
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
|
||||
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
|
||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
||||
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
|
||||
] as const;
|
||||
];
|
||||
|
||||
function isActive(href: string, pathname: string): boolean {
|
||||
if (href === '/') return pathname === '/';
|
||||
@@ -122,6 +132,16 @@
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
// Keep nav badges fresh. Poll kicks off once the user is authenticated;
|
||||
// a route change also triggers a one-shot refresh so badges reflect any
|
||||
// mutations just performed on the page we're leaving.
|
||||
$effect(() => {
|
||||
void $page.url.pathname;
|
||||
if (!isAuthenticated()) return;
|
||||
startNavCountsPolling();
|
||||
refreshNavCounts();
|
||||
});
|
||||
|
||||
// Start health polling when authenticated.
|
||||
// Uses $effect to react to route changes (e.g., after login navigation).
|
||||
$effect(() => {
|
||||
@@ -148,6 +168,7 @@
|
||||
if (healthInterval) clearInterval(healthInterval);
|
||||
if (clockTimer) clearInterval(clockTimer);
|
||||
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
|
||||
stopNavCountsPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -211,9 +232,21 @@
|
||||
{:else if item.icon === 'settings'}
|
||||
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{/if}
|
||||
{$t(item.labelKey)}
|
||||
<span class="nav-label">{$t(item.labelKey)}</span>
|
||||
|
||||
{#if item.countKey}
|
||||
{@const count = $navCounts[item.countKey]}
|
||||
{#if count !== null && count > 0}
|
||||
<span class="nav-badge" class:nav-badge-alert={item.alert} class:nav-badge-active={active}>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
{:else if count !== null && !item.alert}
|
||||
<span class="nav-badge nav-badge-muted" class:nav-badge-active={active}>0</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if active}
|
||||
<div class="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--color-brand-600)]"></div>
|
||||
<div class="nav-active-dot"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -375,6 +408,10 @@
|
||||
}
|
||||
|
||||
.nav-item :global(svg) { flex-shrink: 0; }
|
||||
.nav-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.nav-active {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary) !important;
|
||||
@@ -388,6 +425,50 @@
|
||||
background: var(--color-brand-600);
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
.nav-active-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand-600);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Nav count badges ──────────────────────────────────────── */
|
||||
.nav-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.35rem;
|
||||
height: 1.1rem;
|
||||
padding: 0 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-badge-muted {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.nav-badge-alert {
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
}
|
||||
:global([data-theme='dark']) .nav-badge-alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
|
||||
color: #fca5a5;
|
||||
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
}
|
||||
.nav-active .nav-badge:not(.nav-badge-alert) {
|
||||
background: var(--surface-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Sidebar footline (version + live UTC clock) ───────────── */
|
||||
.forge-footline {
|
||||
@@ -469,6 +550,10 @@
|
||||
:global(main) {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
/* Reserve space for the scrollbar even when content fits, so switching
|
||||
between short and tall pages (e.g. inside Settings) doesn't shift the
|
||||
layout horizontally by the scrollbar width. */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
:global(main)::before {
|
||||
content: '';
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
// Filters
|
||||
let severities = $state<string[]>(['info', 'warn', 'error']);
|
||||
let sources = $state<string[]>(['deploy', 'container', 'proxy', 'system']);
|
||||
let sources = $state<string[]>(['deploy', 'static_site', 'stale_scanner', 'stale_cleanup', 'admin']);
|
||||
let dateRange = $state('all');
|
||||
let searchText = $state('');
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
<div class="space-y-4">
|
||||
{#snippet heroToolbar()}
|
||||
{#if stats.total > 0}
|
||||
<span class="forge-pill"><span class="pulse"></span>{stats.total} total</span>
|
||||
<span class="forge-pill"><span class="pulse"></span>{$t('events.totalCount', { count: String(stats.total) })}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showClearConfirm = true; }}
|
||||
|
||||
@@ -82,9 +82,21 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--surface-page)] px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 shadow-[var(--shadow-lg)]">
|
||||
<div class="login-shell">
|
||||
<!-- Drifting forge-ember backdrop (pure CSS, decorative only) -->
|
||||
<div class="fx-layer" aria-hidden="true">
|
||||
<div class="fx-glow"></div>
|
||||
<div class="fx-grid"></div>
|
||||
<div class="fx-embers">
|
||||
{#each Array(24) as _, i (i)}
|
||||
<span class="ember" style="--i: {i}"></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="fx-vignette"></div>
|
||||
</div>
|
||||
|
||||
<div class="login-card-wrap">
|
||||
<div class="rounded-2xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 shadow-[var(--shadow-lg)] login-card">
|
||||
<!-- Logo -->
|
||||
<div class="mb-6 text-center">
|
||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-600)] shadow-md">
|
||||
@@ -172,3 +184,162 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
background: var(--surface-page);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* ── FX layer sits behind the card ──────────────────────────── */
|
||||
.fx-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-card-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
.login-card {
|
||||
backdrop-filter: saturate(1.2);
|
||||
animation: card-rise 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
@keyframes card-rise {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ── Slowly rotating "forge glow" behind the card ──────────── */
|
||||
.fx-glow {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 130vmin; height: 130vmin;
|
||||
transform: translate(-50%, -50%);
|
||||
background:
|
||||
conic-gradient(
|
||||
from 0deg at 50% 50%,
|
||||
transparent 0deg,
|
||||
color-mix(in srgb, var(--color-brand-500) 22%, transparent) 40deg,
|
||||
color-mix(in srgb, var(--color-brand-600) 28%, transparent) 80deg,
|
||||
transparent 140deg,
|
||||
transparent 220deg,
|
||||
color-mix(in srgb, var(--color-brand-400) 18%, transparent) 280deg,
|
||||
transparent 360deg
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(circle at center, #000 0%, #000 28%, transparent 70%);
|
||||
mask-image: radial-gradient(circle at center, #000 0%, #000 28%, transparent 70%);
|
||||
filter: blur(40px);
|
||||
opacity: 0.9;
|
||||
animation: glow-spin 40s linear infinite;
|
||||
}
|
||||
@keyframes glow-spin {
|
||||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Dot-grid texture (matches the main app backdrop) ─────── */
|
||||
.fx-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 26px 26px;
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, #000 0%, transparent 75%);
|
||||
mask-image: radial-gradient(ellipse at center, #000 0%, transparent 75%);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ── Drifting embers — small dots rising from below ───────── */
|
||||
.fx-embers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fx-embers .ember {
|
||||
position: absolute;
|
||||
bottom: -16px;
|
||||
width: 4px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand-500);
|
||||
box-shadow:
|
||||
0 0 6px color-mix(in srgb, var(--color-brand-500) 80%, transparent),
|
||||
0 0 14px color-mix(in srgb, var(--color-brand-400) 60%, transparent);
|
||||
opacity: 0;
|
||||
/* Each ember is placed in its own horizontal lane via --i.
|
||||
Delay / duration / scale are varied below with nth-child for a natural feel. */
|
||||
left: calc((var(--i) * 4.1%) + 2%);
|
||||
animation: ember-rise 11s linear infinite;
|
||||
animation-delay: calc(var(--i) * -0.9s);
|
||||
}
|
||||
/* Duration variety (5 buckets) */
|
||||
.fx-embers .ember:nth-child(5n) { animation-duration: 9s; }
|
||||
.fx-embers .ember:nth-child(5n+1) { animation-duration: 11s; }
|
||||
.fx-embers .ember:nth-child(5n+2) { animation-duration: 13s; }
|
||||
.fx-embers .ember:nth-child(5n+3) { animation-duration: 15s; }
|
||||
.fx-embers .ember:nth-child(5n+4) { animation-duration: 17s; }
|
||||
/* Size variety (3 buckets) */
|
||||
.fx-embers .ember:nth-child(3n) { transform: scale(0.7); }
|
||||
.fx-embers .ember:nth-child(3n+1) { transform: scale(1); width: 5px; height: 5px; }
|
||||
.fx-embers .ember:nth-child(3n+2) { transform: scale(0.5); }
|
||||
/* Horizontal drift variety (2 buckets) */
|
||||
.fx-embers .ember:nth-child(even) { --drift: 22px; }
|
||||
.fx-embers .ember:nth-child(odd) { --drift: -18px; }
|
||||
|
||||
@keyframes ember-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
10% { opacity: 0.9; }
|
||||
50% {
|
||||
transform: translate3d(calc(var(--drift, 0px) * 0.5), -55vh, 0);
|
||||
}
|
||||
90% { opacity: 0.4; }
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(var(--drift, 0px), -110vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Soft vignette keeps the center where the eye lands ───── */
|
||||
.fx-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
transparent 0%,
|
||||
transparent 40%,
|
||||
color-mix(in srgb, var(--surface-page) 55%, transparent) 80%,
|
||||
var(--surface-page) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Dark mode: richer glow, cooler cast ──────────────────── */
|
||||
:global([data-theme='dark']) .fx-glow { opacity: 0.7; filter: blur(56px); }
|
||||
:global([data-theme='dark']) .fx-grid { opacity: 0.35; }
|
||||
:global([data-theme='dark']) .fx-embers .ember {
|
||||
background: var(--color-brand-400);
|
||||
box-shadow:
|
||||
0 0 8px color-mix(in srgb, var(--color-brand-400) 90%, transparent),
|
||||
0 0 18px color-mix(in srgb, var(--color-brand-500) 60%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-card,
|
||||
.fx-glow,
|
||||
.fx-embers .ember {
|
||||
animation: none !important;
|
||||
}
|
||||
.fx-embers .ember { opacity: 0.35; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Project, Stage, Instance, Deploy, LocalImage } from '$lib/types';
|
||||
@@ -380,7 +381,9 @@
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
if (!deleted) loadProject();
|
||||
untrack(() => {
|
||||
if (!deleted) loadProject();
|
||||
});
|
||||
|
||||
return () => {
|
||||
loadController?.abort();
|
||||
|
||||
+5
-3
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Stage, StageEnv } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
@@ -197,12 +198,13 @@
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
loadProject();
|
||||
untrack(() => loadProject());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedStageId) {
|
||||
loadStageEnv(selectedStageId);
|
||||
const sid = selectedStageId;
|
||||
if (sid) {
|
||||
untrack(() => loadStageEnv(sid));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Volume, VolumeScopeInfo, VolumeScope } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
@@ -28,7 +29,7 @@
|
||||
|
||||
let volumeDeleteTarget = $state<string | null>(null);
|
||||
|
||||
const projectId = $derived($page.params.id);
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
|
||||
const newScopeNeedsName = $derived(scopes.find(s => s.scope === newScope)?.needs_name ?? false);
|
||||
const editScopeNeedsName = $derived(scopes.find(s => s.scope === editScope)?.needs_name ?? false);
|
||||
@@ -138,7 +139,7 @@
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
loadVolumes();
|
||||
untrack(() => loadVolumes());
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user