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
+48 -144
View File
@@ -3,15 +3,15 @@
import type { InspectResult } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
let imageUrl = $state('');
let inspecting = $state(false);
let deploying = $state(false);
let inspected = $state(false);
let inspectResult: InspectResult | null = $state(null);
// Form fields populated after inspect
let projectName = $state('');
let port = $state('');
let healthcheck = $state('');
@@ -19,7 +19,6 @@
let subdomain = $state('');
let envVars = $state('');
// Validation errors
let errors = $state<Record<string, string>>({});
function validateImageUrl(url: string): string {
@@ -55,7 +54,6 @@
return Object.keys(newErrors).length === 0;
}
/** Derive a project name from the image URL (last path segment before the colon). */
function deriveProjectName(image: string): string {
const withoutTag = image.split(':')[0] ?? image;
const segments = withoutTag.split('/');
@@ -69,13 +67,10 @@
return;
}
errors = {};
inspecting = true;
try {
const result = await inspectImage(imageUrl.trim());
inspectResult = result;
// Auto-fill form with inspection results
projectName = deriveProjectName(result.image);
port = result.port?.toString() ?? '';
healthcheck = result.healthcheck ?? '';
@@ -83,9 +78,9 @@
subdomain = '';
envVars = '';
inspected = true;
toasts.success('Image inspected successfully');
toasts.success($t('quickDeploy.inspectedSuccess'));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to inspect image';
const message = err instanceof Error ? err.message : $t('quickDeploy.inspectFailed');
toasts.error(message);
} finally {
inspecting = false;
@@ -94,17 +89,10 @@
async function handleDeploy() {
if (!validateAll()) return;
deploying = true;
try {
await quickDeploy({
image: imageUrl.trim(),
name: projectName.trim(),
port: parseInt(port, 10)
});
toasts.success(`Deployed ${projectName} successfully!`);
// Reset form
await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10) });
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
imageUrl = '';
inspected = false;
inspectResult = null;
@@ -115,7 +103,7 @@
subdomain = '';
envVars = '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Deployment failed';
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
toasts.error(message);
} finally {
deploying = false;
@@ -124,31 +112,28 @@
</script>
<svelte:head>
<title>Quick Deploy - Docker Watcher</title>
<title>{$t('quickDeploy.title')} - {$t('app.name')}</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Quick Deploy</h1>
<p class="mt-1 text-sm text-gray-500">
Deploy a container image with zero configuration. Paste an image URL, review the defaults,
and deploy.
</p>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('quickDeploy.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.description')}</p>
</div>
<!-- Step 1: Image URL input -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">1. Enter Image URL</h2>
<!-- Step 1 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-4 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step1')}</h2>
<div class="flex gap-3">
<div class="flex-1">
<FormField
label="Image URL"
label={$t('quickDeploy.imageUrl')}
name="imageUrl"
bind:value={imageUrl}
placeholder="registry.example.com/org/app:tag"
required
error={errors.imageUrl ?? ''}
helpText="Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)"
helpText={$t('quickDeploy.imageUrlHelp')}
disabled={inspecting}
/>
</div>
@@ -156,152 +141,71 @@
<button
onclick={handleInspect}
disabled={inspecting || !imageUrl.trim()}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-info)] px-4 py-2 text-sm font-medium text-white transition-all duration-150 hover:bg-[var(--color-info-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
>
{#if inspecting}
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Inspecting...
</span>
<IconLoader size={16} />
{$t('quickDeploy.inspecting')}
{:else}
Inspect
<IconSearch size={16} />
{$t('quickDeploy.inspect')}
{/if}
</button>
</div>
</div>
</div>
<!-- Step 2: Review and configure (shown after inspect) -->
<!-- Step 2 -->
{#if inspected}
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">2. Review Configuration</h2>
<p class="mb-4 text-sm text-gray-500">
These defaults were detected from the image. Adjust as needed before deploying.
</p>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step2')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.reviewDesc')}</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
label="Project Name"
name="projectName"
bind:value={projectName}
placeholder="my-app"
required
error={errors.projectName ?? ''}
helpText="Lowercase with hyphens"
/>
<FormField
label="Port"
name="port"
type="number"
bind:value={port}
placeholder="3000"
required
error={errors.port ?? ''}
helpText="Container port to expose (1-65535)"
/>
<FormField
label="Health Check Path"
name="healthcheck"
bind:value={healthcheck}
placeholder="/api/health"
helpText="Optional HTTP path for health verification"
/>
<div class="flex flex-col gap-1">
<label for="stage" class="text-sm font-medium text-gray-700">Stage</label>
<select
id="stage"
bind:value={stage}
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="dev">Development</option>
<option value="rel">Release</option>
<option value="prod">Production</option>
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText="Lowercase with hyphens" />
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
<FormField label={$t('quickDeploy.healthCheckPath')} name="healthcheck" bind:value={healthcheck} placeholder="/api/health" helpText={$t('quickDeploy.healthCheckHelp')} />
<div class="flex flex-col gap-1.5">
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="dev">{$t('quickDeploy.development')}</option>
<option value="rel">{$t('quickDeploy.release')}</option>
<option value="prod">{$t('quickDeploy.production')}</option>
</select>
<p class="text-xs text-gray-500">Deployment stage for this image</p>
<p class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.stageHelp')}</p>
</div>
<FormField
label="Subdomain Override"
name="subdomain"
bind:value={subdomain}
placeholder="auto-generated"
helpText="Leave empty to use the default subdomain pattern"
/>
<FormField label={$t('quickDeploy.subdomainOverride')} name="subdomain" bind:value={subdomain} placeholder="auto-generated" helpText={$t('quickDeploy.subdomainHelp')} />
</div>
<div class="mt-4">
<FormField
label="Environment Variables"
name="envVars"
type="textarea"
bind:value={envVars}
placeholder="KEY=value&#10;ANOTHER_KEY=another_value"
helpText="One per line, KEY=VALUE format"
/>
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder="KEY=value&#10;ANOTHER_KEY=another_value" helpText={$t('quickDeploy.envVarsHelp')} />
</div>
</div>
<!-- Step 3: Deploy -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">3. Deploy</h2>
<p class="mb-4 text-sm text-gray-500">
A new project will be created and the container will be deployed immediately.
</p>
<!-- Step 3 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step3')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
<div class="flex gap-3">
<button
onclick={handleDeploy}
disabled={deploying}
class="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
>
{#if deploying}
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Deploying...
</span>
<IconLoader size={16} />
{$t('projectDetail.deploying')}
{:else}
Deploy
<IconDeploy size={16} />
{$t('quickDeploy.deployBtn')}
{/if}
</button>
<button
onclick={() => {
inspected = false;
inspectResult = null;
}}
onclick={() => { inspected = false; inspectResult = null; }}
disabled={deploying}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
Cancel
{$t('common.cancel')}
</button>
</div>
</div>