feat(docker-watcher): phase 14 - frontend polish & modern UI
Design system with CSS custom properties (light/dark themes). 38 Lucide SVG icon components. Dark mode with system preference. EN/RU localization with i18n store. Skeleton loaders, empty states, toggle switches, micro-interactions. Responsive sidebar with mobile hamburger menu. All pages polished with consistent styling.
This commit is contained in:
+75
-54
@@ -2,6 +2,10 @@
|
||||
import type { Project, Instance } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { IconDeploy, IconBox, IconServer, IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
||||
@@ -14,11 +18,9 @@
|
||||
try {
|
||||
projects = await api.listProjects();
|
||||
|
||||
// Fetch instances for each project by loading the project detail.
|
||||
const detailPromises = projects.map(async (p) => {
|
||||
try {
|
||||
const detail = await api.getProject(p.id);
|
||||
// Fetch instances for each stage.
|
||||
const stageInstances = await Promise.all(
|
||||
detail.stages.map((s) => api.listInstances(p.id, s.id))
|
||||
);
|
||||
@@ -35,7 +37,7 @@
|
||||
}
|
||||
instancesByProject = mapped;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load dashboard';
|
||||
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -59,71 +61,90 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - Docker Watcher</title>
|
||||
<title>{$t('dashboard.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('dashboard.title')}</h1>
|
||||
<a
|
||||
href="/deploy"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
||||
>
|
||||
Quick Deploy
|
||||
<IconDeploy size={16} />
|
||||
{$t('dashboard.quickDeploy')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Total Projects</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{totalProjects}</p>
|
||||
<!-- Stats cards -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<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-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||
<IconBox size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.totalProjects')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold text-[var(--text-primary)]">{totalProjects}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Running Instances</p>
|
||||
<p class="mt-1 text-3xl font-bold text-green-600">{totalRunning}</p>
|
||||
<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">
|
||||
<IconServer size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.runningInstances')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold text-emerald-600">{totalRunning}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Failed Instances</p>
|
||||
<p class="mt-1 text-3xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-gray-900'}">
|
||||
{totalFailed}
|
||||
</p>
|
||||
<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'}">
|
||||
<IconAlert size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.failedInstances')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-[var(--text-primary)]'}">{totalFailed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project cards -->
|
||||
<h2 class="mt-8 text-lg font-semibold text-gray-900">Projects</h2>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.projects')}</h2>
|
||||
|
||||
{#if loading}
|
||||
<div class="mt-4 flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="mt-4 rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm text-red-700">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-sm font-medium text-red-700 underline"
|
||||
onclick={loadDashboard}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
|
||||
<p class="text-sm text-gray-500">No projects yet.</p>
|
||||
<a
|
||||
href="/projects"
|
||||
class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Add your first project
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each projects as project (project.id)}
|
||||
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(3) as _}
|
||||
<SkeletonCard />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="mt-4 rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline"
|
||||
onclick={loadDashboard}
|
||||
>
|
||||
{$t('dashboard.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="mt-4">
|
||||
<EmptyState
|
||||
title={$t('empty.noProjects')}
|
||||
description={$t('empty.noProjectsDesc')}
|
||||
actionLabel={$t('empty.createProject')}
|
||||
actionHref="/projects"
|
||||
icon="projects"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each projects as project (project.id)}
|
||||
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user