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