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:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user