fix: frontend UX improvements (SSE status, responsive tables, dark mode, login toggle, theme)

- Add SSE connection status banner showing when real-time updates are lost (UX-H8, UX-M1)
- Add password visibility toggle on login page (UX-H10)
- Add dark mode variants to stat card backgrounds (UX-M11)
- Add overflow-x-auto to tables for mobile responsiveness (UX-H9)
- Add flex-wrap to stage header for mobile overflow (UX-H11)
- Fix theme store system preference listener reactivity (UX-M12)
- Parallelize registry health checks (UX-L4)
This commit is contained in:
2026-04-04 12:53:39 +03:00
parent fa62e9c20f
commit 3f6858513f
9 changed files with 61 additions and 22 deletions
+19 -5
View File
@@ -26,16 +26,30 @@ themeMode.subscribe((value) => {
}
});
/**
* Tracks system color-scheme preference changes so that the derived store
* re-evaluates when the OS theme changes while mode is 'system'.
*/
const systemDark = writable(
typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
);
if (typeof window !== 'undefined') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', (e) => {
systemDark.set(e.matches);
});
}
/**
* Resolved theme based on mode and system preference.
* Returns 'light' or 'dark'.
*/
export const resolvedTheme = derived(themeMode, ($mode) => {
export const resolvedTheme = derived([themeMode, systemDark], ([$mode, $dark]) => {
if ($mode === 'system') {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
return $dark ? 'dark' : 'light';
}
return $mode;
});
+14
View File
@@ -43,6 +43,7 @@
let healthChecked = $state(false);
let healthInterval: ReturnType<typeof setInterval> | null = null;
let hintsExpanded = $state(false);
let sseConnected = $state(true);
const dockerConnected = $derived(dockerHealth?.connected ?? false);
@@ -90,6 +91,12 @@
},
onDeployStatus(payload) {
instanceStatusStore.notifyDeploy(payload);
},
onOpen() {
sseConnected = true;
},
onError() {
sseConnected = false;
}
});
@@ -278,6 +285,13 @@
<span class="text-sm font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
</header>
<!-- SSE connection status banner -->
{#if !sseConnected}
<div class="bg-amber-500 text-white text-center text-xs py-1 px-4">
Real-time connection lost. Reconnecting...
</div>
{/if}
<!-- Page content -->
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8">
+2 -2
View File
@@ -97,7 +97,7 @@
</div>
</div>
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
<IconServer size={24} />
</div>
<div>
@@ -106,7 +106,7 @@
</div>
</div>
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalFailed > 0 ? 'bg-red-50 text-red-600' : 'bg-gray-50 text-gray-400'}">
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalFailed > 0 ? 'bg-red-50 dark:bg-red-950/30 text-red-600' : 'bg-gray-50 dark:bg-gray-800/30 text-gray-400'}">
<IconAlert size={24} />
</div>
<div>
+18 -8
View File
@@ -11,6 +11,7 @@
let password = $state('');
let error = $state('');
let loading = $state(false);
let showPassword = $state(false);
// Apply theme on login page too.
$effect(() => {
@@ -106,14 +107,23 @@
<div class="flex flex-col gap-1.5">
<label for="password" class="text-sm font-medium text-[var(--text-primary)]">{$t('login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
required
autocomplete="current-password"
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
/>
<div class="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
bind:value={password}
required
autocomplete="current-password"
class="w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2.5 pr-14 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
onclick={() => { showPassword = !showPassword; }}
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
</div>
<button
+1 -1
View File
@@ -220,7 +220,7 @@
icon="projects"
/>
{:else}
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
+1 -1
View File
@@ -380,7 +380,7 @@
{@const stageInstances = instancesByStage[stage.id] ?? []}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
<!-- Stage header -->
<div class="flex items-center justify-between border-b border-[var(--border-secondary)] px-5 py-4">
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
+2 -2
View File
@@ -192,7 +192,7 @@
{#if Object.keys(projectEnv).length > 0}
<div>
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
<div class="mt-2 overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead>
<tr>
@@ -231,7 +231,7 @@
<span class="text-sm">{$t('common.loading')}</span>
</div>
{:else}
<div class="mt-2 overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
@@ -127,7 +127,7 @@
</button>
</div>
{:else}
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
@@ -78,7 +78,7 @@
}
async function checkAllHealth() {
for (const reg of registries) {
const checks = registries.map(async (reg) => {
healthStatus[reg.id] = 'checking';
try {
await testRegistry(reg.id);
@@ -86,7 +86,8 @@
} catch {
healthStatus[reg.id] = 'unhealthy';
}
}
});
await Promise.allSettled(checks);
}
$effect(() => { loadRegistryList(); });