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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user