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:
2026-03-27 23:53:09 +03:00
parent d4659146fc
commit a3aa5912d9
74 changed files with 2954 additions and 1750 deletions
+57 -94
View File
@@ -1,17 +1,21 @@
<script lang="ts">
import type { Project } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { IconPlus } from '$lib/components/icons';
import FormField from '$lib/components/FormField.svelte';
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
let projects = $state<Project[]>([]);
let loading = $state(true);
let error = $state('');
let showAddForm = $state(false);
// Add project form state.
let formName = $state('');
let formImage = $state('');
let formRegistry = $state('');
let formPort = $state(3000);
let formPort = $state('3000');
let formHealthcheck = $state('');
let formSubmitting = $state(false);
let formError = $state('');
@@ -22,7 +26,7 @@
try {
projects = await api.listProjects();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects';
error = e instanceof Error ? e.message : $t('projects.loadFailed');
} finally {
loading = false;
}
@@ -30,7 +34,7 @@
async function handleAddProject() {
if (!formName.trim() || !formImage.trim()) {
formError = 'Name and image are required.';
formError = $t('projects.nameRequired');
return;
}
@@ -41,19 +45,18 @@
name: formName.trim(),
image: formImage.trim(),
registry: formRegistry.trim(),
port: formPort,
port: parseInt(formPort, 10) || 3000,
healthcheck: formHealthcheck.trim()
});
// Reset form.
formName = '';
formImage = '';
formRegistry = '';
formPort = 3000;
formPort = '3000';
formHealthcheck = '';
showAddForm = false;
await loadProjects();
} catch (e) {
formError = e instanceof Error ? e.message : 'Failed to create project';
formError = e instanceof Error ? e.message : $t('projects.createFailed');
} finally {
formSubmitting = false;
}
@@ -65,92 +68,51 @@
</script>
<svelte:head>
<title>Projects - Docker Watcher</title>
<title>{$t('projects.title')} - {$t('app.name')}</title>
</svelte:head>
<div>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Projects</h1>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('projects.title')}</h1>
<button
type="button"
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 {showAddForm ? 'border border-[var(--border-primary)] bg-[var(--surface-card)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]' : 'bg-[var(--color-brand-600)] text-white shadow-sm hover:bg-[var(--color-brand-700)]'} px-4 py-2.5 text-sm font-medium transition-all duration-150 active:animate-press"
onclick={() => { showAddForm = !showAddForm; }}
>
{showAddForm ? 'Cancel' : 'Add Project'}
{#if !showAddForm}<IconPlus size={16} />{/if}
{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}
</button>
</div>
<!-- Add project form -->
{#if showAddForm}
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">New Project</h2>
<div class="rounded-xl border border-[var(--color-brand-200)] bg-[var(--color-brand-50)]/30 p-6 animate-scale-in">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projects.newProject')}</h2>
{#if formError}
<div class="mt-3 rounded-md bg-red-50 p-3">
<p class="text-sm text-red-700">{formError}</p>
<div class="mt-3 rounded-lg bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{formError}</p>
</div>
{/if}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name *</label>
<input
id="name"
type="text"
bind:value={formName}
placeholder="my-web-app"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="image" class="block text-sm font-medium text-gray-700">Image *</label>
<input
id="image"
type="text"
bind:value={formImage}
placeholder="registry.example.com/org/app"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="registry" class="block text-sm font-medium text-gray-700">Registry</label>
<input
id="registry"
type="text"
bind:value={formRegistry}
placeholder="gitea"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="port" class="block text-sm font-medium text-gray-700">Port</label>
<input
id="port"
type="number"
bind:value={formPort}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<FormField label="{$t('projects.name')} *" name="name" bind:value={formName} placeholder="my-web-app" required />
<FormField label="{$t('projects.image')} *" name="image" bind:value={formImage} placeholder="registry.example.com/org/app" required />
<FormField label={$t('projects.registry')} name="registry" bind:value={formRegistry} placeholder="gitea" />
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} />
<div class="sm:col-span-2">
<label for="healthcheck" class="block text-sm font-medium text-gray-700">Healthcheck Path</label>
<input
id="healthcheck"
type="text"
bind:value={formHealthcheck}
placeholder="/api/health"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
<FormField label={$t('projects.healthcheck')} name="healthcheck" bind:value={formHealthcheck} placeholder="/api/health" />
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all duration-150 active:animate-press"
disabled={formSubmitting}
onclick={handleAddProject}
>
{formSubmitting ? 'Creating...' : 'Create Project'}
{formSubmitting ? $t('projects.creating') : $t('projects.createProject')}
</button>
</div>
</div>
@@ -158,57 +120,58 @@
<!-- Projects list -->
{#if loading}
<div class="mt-6 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>
<SkeletonTable rows={4} cols={5} />
{:else if error}
<div class="mt-6 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={loadProjects}>
Retry
<div class="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={loadProjects}>
{$t('common.retry')}
</button>
</div>
{:else if projects.length === 0}
<div class="mt-6 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<p class="text-sm text-gray-500">No projects configured yet.</p>
<p class="mt-1 text-sm text-gray-400">Click "Add Project" to get started.</p>
</div>
<EmptyState
title={$t('empty.noProjects')}
description={$t('empty.noProjectsDesc')}
actionLabel={$t('projects.addProject')}
onaction={() => { showAddForm = true; }}
icon="projects"
/>
{:else}
<div class="mt-6 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<div class="overflow-hidden 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>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Image</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Port</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Registry</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.name')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.image')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each projects as project (project.id)}
<tr class="hover:bg-gray-50">
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
<td class="whitespace-nowrap px-6 py-4">
<a href="/projects/{project.id}" class="font-medium text-indigo-600 hover:text-indigo-800">
<a href="/projects/{project.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{project.name}
</a>
</td>
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-gray-500">
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-[var(--text-tertiary)]">
{project.image}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{project.port || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{project.registry || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
{new Date(project.created_at).toLocaleDateString()}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
<a href="/projects/{project.id}" class="text-indigo-600 hover:text-indigo-800">
View
<a href="/projects/{project.id}" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
{$t('projects.view')}
</a>
</td>
</tr>