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
+27 -34
View File
@@ -1,7 +1,12 @@
<!--
Task 5: Instance card with inline status badges, icon action buttons, improved layout.
-->
<script lang="ts">
import type { Instance } from '$lib/types';
import StatusBadge from './StatusBadge.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink } from '$lib/components/icons';
import { t } from '$lib/i18n';
import * as api from '$lib/api';
interface Props {
@@ -53,7 +58,7 @@
}
onchange?.();
} catch (e) {
error = e instanceof Error ? e.message : 'Action failed';
error = e instanceof Error ? e.message : $t('instance.actionFailed');
} finally {
loading = false;
}
@@ -62,19 +67,13 @@
function requestConfirm(action: 'stop' | 'restart' | 'remove') {
confirmAction = action;
}
const confirmMessages: Record<string, string> = {
stop: 'This will stop the running container. The instance can be started again later.',
restart: 'This will restart the container, causing brief downtime.',
remove: 'This will permanently remove the container and its proxy configuration. This cannot be undone.'
};
</script>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)]">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-mono text-sm font-medium text-gray-900">
<span class="truncate font-mono text-sm font-medium text-[var(--text-primary)]">
{instance.image_tag}
</span>
<StatusBadge status={instance.status} size="sm" />
@@ -85,78 +84,72 @@
href={subdomainUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-1 block truncate text-xs text-indigo-600 hover:text-indigo-800"
class="mt-1.5 inline-flex items-center gap-1 text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
>
{instance.subdomain}
<IconExternalLink size={12} />
</a>
{/if}
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
<span>Port {instance.port}</span>
<div class="mt-1.5 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{instance.port}</span>
<span>{timeSinceCreated()}</span>
</div>
</div>
<!-- Action buttons -->
<div class="ml-3 flex items-center gap-1">
{#if instance.status === 'running'}
<button
type="button"
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-yellow-600 disabled:opacity-50"
title="Stop"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-amber-50 hover:text-amber-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.stop')}
disabled={loading}
onclick={() => requestConfirm('stop')}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
<IconStop size={16} />
</button>
<button
type="button"
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-600 disabled:opacity-50"
title="Restart"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.restart')}
disabled={loading}
onclick={() => requestConfirm('restart')}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182" />
</svg>
<IconRestart size={16} />
</button>
{:else if instance.status === 'stopped'}
<button
type="button"
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-green-600 disabled:opacity-50"
title="Start"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.start')}
disabled={loading}
onclick={() => handleAction('start')}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
<IconPlay size={16} />
</button>
{/if}
<button
type="button"
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600 disabled:opacity-50"
title="Remove"
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
title={$t('common.remove')}
disabled={loading}
onclick={() => requestConfirm('remove')}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
<IconTrash size={16} />
</button>
</div>
</div>
{#if error}
<p class="mt-2 text-xs text-red-600">{error}</p>
<p class="mt-2 text-xs text-[var(--color-danger)]">{error}</p>
{/if}
</div>
<ConfirmDialog
open={confirmAction !== null}
title="{confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''} Instance"
message={confirmAction ? confirmMessages[confirmAction] ?? '' : ''}
message={confirmAction ? $t(`instance.${confirmAction}Confirm`) : ''}
confirmLabel={confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''}
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
onconfirm={() => { if (confirmAction) handleAction(confirmAction); }}