Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

BIN
lib/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,77 @@
/**
* Svelte action for column resize handles
*
* Usage:
* <div class="resize-handle" use:columnResize={{ onResize, onResizeEnd, minWidth }} />
*/
export interface ColumnResizeParams {
onResize: (width: number) => void;
onResizeEnd: (width: number) => void;
minWidth?: number;
}
export function columnResize(node: HTMLElement, params: ColumnResizeParams) {
let startX: number;
let startWidth: number;
let currentWidth: number;
let currentParams = params;
let isLeftHandle = false;
function onMouseDown(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
// Get the parent th/td element's width
const parent = node.parentElement;
if (!parent) return;
// Check if this is a left-side resize handle
isLeftHandle = node.classList.contains('resize-handle-left');
startX = e.clientX;
startWidth = parent.offsetWidth;
currentWidth = startWidth;
// Set cursor for entire document during drag
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e: MouseEvent) {
let delta = e.clientX - startX;
// For left-side handles, invert the delta (drag left = wider)
if (isLeftHandle) {
delta = -delta;
}
currentWidth = Math.max(currentParams.minWidth ?? 50, startWidth + delta);
currentParams.onResize(currentWidth);
}
function onMouseUp() {
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
// Use the calculated width, not the rendered width
currentParams.onResizeEnd(currentWidth);
}
node.addEventListener('mousedown', onMouseDown);
return {
update(newParams: ColumnResizeParams) {
currentParams = newParams;
},
destroy() {
node.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
};
}

1
lib/assets/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,274 @@
<script lang="ts">
import Cropper from 'svelte-easy-crop';
import { Button } from '$lib/components/ui/button';
import { ZoomIn, ZoomOut, X, Check } from 'lucide-svelte';
interface Props {
show: boolean;
imageUrl: string;
onCancel: () => void;
onSave: (dataUrl: string) => void;
}
let { show, imageUrl, onCancel, onSave }: Props = $props();
// Cropper state
let crop = $state({ x: 0, y: 0 });
let zoom = $state(1);
let croppedAreaPixels = $state<{ x: number; y: number; width: number; height: number } | null>(null);
let imageLoaded = $state(false);
let saving = $state(false);
// Reset state when imageUrl changes
$effect(() => {
if (imageUrl) {
crop = { x: 0, y: 0 };
zoom = 1;
croppedAreaPixels = null;
imageLoaded = false;
// Trigger a zoom change to force the cropcomplete event to fire
setTimeout(() => {
imageLoaded = true;
zoom = 1.01;
setTimeout(() => {
zoom = 1;
}, 100);
}, 500);
}
});
function onCropComplete(e: CustomEvent) {
const detail = e.detail;
// svelte-easy-crop returns data in different property names depending on version
if (detail.pixels) {
croppedAreaPixels = detail.pixels;
} else if (detail.croppedAreaPixels) {
croppedAreaPixels = detail.croppedAreaPixels;
} else if (detail.pixelCrop) {
croppedAreaPixels = detail.pixelCrop;
} else if (detail.x !== undefined && detail.y !== undefined && detail.width !== undefined && detail.height !== undefined) {
// Fallback: use the detail itself if it has the right properties
croppedAreaPixels = detail;
}
}
function onMediaLoaded() {
imageLoaded = true;
}
async function computeCropArea(): Promise<{ x: number; y: number; width: number; height: number } | null> {
return new Promise((resolve) => {
const image = new Image();
image.src = imageUrl;
image.onload = () => {
// Get the cropper container
const cropperContainer = document.querySelector('.cropper-container');
if (!cropperContainer) {
resolve(null);
return;
}
const containerWidth = cropperContainer.clientWidth;
const containerHeight = cropperContainer.clientHeight;
// Calculate how the image is displayed (object-fit: contain)
const imageAspect = image.width / image.height;
const containerAspect = containerWidth / containerHeight;
let mediaWidth, mediaHeight;
if (imageAspect > containerAspect) {
mediaWidth = containerWidth;
mediaHeight = containerWidth / imageAspect;
} else {
mediaHeight = containerHeight;
mediaWidth = containerHeight * imageAspect;
}
// Apply zoom
mediaWidth *= zoom;
mediaHeight *= zoom;
// Calculate crop area
const cropSize = Math.min(containerWidth, containerHeight);
const scale = image.width / mediaWidth;
const cropCenterX = containerWidth / 2;
const cropCenterY = containerHeight / 2;
const mediaX = (containerWidth - mediaWidth) / 2;
const mediaY = (containerHeight - mediaHeight) / 2;
const cropLeftInMedia = cropCenterX - mediaX - crop.x - cropSize / 2;
const cropTopInMedia = cropCenterY - mediaY - crop.y - cropSize / 2;
const x = cropLeftInMedia * scale;
const y = cropTopInMedia * scale;
const size = cropSize * scale;
resolve({
x: Math.max(0, Math.round(x)),
y: Math.max(0, Math.round(y)),
width: Math.min(Math.round(size), image.width),
height: Math.min(Math.round(size), image.height)
});
};
image.onerror = () => resolve(null);
});
}
async function getCroppedImage(): Promise<string> {
// If no crop data from event, compute it manually
let cropData = croppedAreaPixels;
if (!cropData) {
cropData = await computeCropArea();
}
if (!cropData) {
throw new Error('No crop data available');
}
return new Promise((resolve, reject) => {
const image = new Image();
image.src = imageUrl;
image.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
// Set canvas size to output size (256x256 for avatar)
canvas.width = 256;
canvas.height = 256;
// Ensure we use a square crop area to avoid stretching
// Center the square within the original crop area
const size = Math.min(cropData!.width, cropData!.height);
const offsetX = (cropData!.width - size) / 2;
const offsetY = (cropData!.height - size) / 2;
// Draw the cropped image
ctx.drawImage(
image,
cropData!.x + offsetX,
cropData!.y + offsetY,
size,
size,
0,
0,
256,
256
);
// Convert to data URL
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
resolve(dataUrl);
};
image.onerror = () => {
reject(new Error('Failed to load image'));
};
});
}
async function handleSave() {
saving = true;
try {
const dataUrl = await getCroppedImage();
onSave(dataUrl);
} catch (err) {
console.error('Failed to crop image:', err);
} finally {
saving = false;
}
}
function handleCancel() {
crop = { x: 0, y: 0 };
zoom = 1;
croppedAreaPixels = null;
imageLoaded = false;
onCancel();
}
// Handle ESC key
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && show) {
handleCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if show && imageUrl}
<div class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
<!-- Header -->
<div class="p-4 border-b">
<h3 class="text-lg font-semibold">Crop avatar</h3>
<p class="text-sm text-muted-foreground mt-1">
Drag to reposition. Use the slider to zoom.
</p>
</div>
<!-- Cropper Container -->
<div class="cropper-container relative flex-1 bg-muted min-h-[400px]">
<Cropper
image={imageUrl}
bind:crop
bind:zoom
aspect={1}
cropShape="round"
showGrid={false}
on:cropcomplete={onCropComplete}
on:mediaLoaded={onMediaLoaded}
/>
</div>
<!-- Zoom Controls -->
<div class="p-4 border-t">
<div class="flex items-center gap-3">
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
<input
type="range"
min="1"
max="3"
step="0.1"
bind:value={zoom}
class="flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<ZoomIn class="w-5 h-5 text-muted-foreground shrink-0" />
</div>
</div>
<!-- Actions -->
<div class="p-4 border-t flex gap-3">
<Button
variant="outline"
class="flex-1"
onclick={handleCancel}
disabled={saving}
>
<X class="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
class="flex-1"
onclick={handleSave}
disabled={saving || !imageLoaded}
>
<Check class="w-4 h-4 mr-2" />
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
</Button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,332 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Progress } from '$lib/components/ui/progress';
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
import { onDestroy } from 'svelte';
const progressText: Record<string, string> = {
remove: 'removing',
start: 'starting',
stop: 'stopping',
restart: 'restarting',
down: 'stopping'
};
// Local type definitions (matching server types)
type ItemStatus = 'pending' | 'processing' | 'success' | 'error' | 'cancelled';
type BatchEvent =
| { type: 'start'; total: number }
| { type: 'progress'; id: string; name: string; status: ItemStatus; message?: string; error?: string; current: number; total: number }
| { type: 'complete'; summary: { total: number; success: number; failed: number } }
| { type: 'error'; error: string };
interface Props {
open: boolean;
title: string;
operation: string;
entityType: 'containers' | 'images' | 'volumes' | 'networks' | 'stacks';
items: Array<{ id: string; name: string }>;
envId?: number;
options?: Record<string, any>;
onClose: () => void;
onComplete: () => void;
}
let {
open = $bindable(),
title,
operation,
entityType,
items,
envId,
options = {},
onClose,
onComplete
}: Props = $props();
// State
type ItemState = {
id: string;
name: string;
status: ItemStatus;
error?: string;
};
let itemStates = $state<ItemState[]>([]);
let isRunning = $state(false);
let isComplete = $state(false);
let successCount = $state(0);
let failCount = $state(0);
let cancelledCount = $state(0);
let abortController: AbortController | null = null;
// Progress calculation
const progress = $derived(() => {
if (itemStates.length === 0) return 0;
const completed = itemStates.filter(i => i.status === 'success' || i.status === 'error' || i.status === 'cancelled').length;
return Math.round((completed / itemStates.length) * 100);
});
// Initialize when modal opens
$effect(() => {
if (open && items.length > 0 && !isRunning && !isComplete) {
startOperation();
}
});
// Cleanup on destroy
onDestroy(() => {
if (abortController) {
abortController.abort();
}
});
async function startOperation() {
// Initialize item states
itemStates = items.map(item => ({
id: item.id,
name: item.name,
status: 'pending' as ItemStatus
}));
isRunning = true;
isComplete = false;
successCount = 0;
failCount = 0;
cancelledCount = 0;
abortController = new AbortController();
try {
const response = await fetch(`/api/batch${envId ? `?env=${envId}` : ''}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
operation,
entityType,
items,
options
}),
signal: abortController.signal
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Request failed');
}
if (!response.body) {
throw new Error('No response body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event: BatchEvent = JSON.parse(line.slice(6));
handleEvent(event);
} catch {
// Ignore parse errors
}
}
}
}
} catch (error: any) {
if (error.name === 'AbortError') {
// User cancelled - mark remaining as cancelled
let cancelled = 0;
itemStates = itemStates.map(item => {
if (item.status === 'pending' || item.status === 'processing') {
cancelled++;
return { ...item, status: 'cancelled' as ItemStatus };
}
return item;
});
cancelledCount = cancelled;
} else {
console.error('Batch operation error:', error);
}
} finally {
isRunning = false;
isComplete = true;
abortController = null;
}
}
function handleEvent(event: BatchEvent) {
switch (event.type) {
case 'progress':
itemStates = itemStates.map(item =>
item.id === event.id
? { ...item, status: event.status, error: event.error }
: item
);
if (event.status === 'success') successCount++;
if (event.status === 'error') failCount++;
break;
case 'complete':
successCount = event.summary.success;
failCount = event.summary.failed;
break;
}
}
function handleCancel() {
if (abortController) {
abortController.abort();
}
}
function handleClose() {
if (isRunning) {
// Confirm before closing during operation
if (!confirm('Operation is still running. Cancel and close?')) {
return;
}
handleCancel();
}
open = false;
// Reset state for next use
itemStates = [];
isRunning = false;
isComplete = false;
successCount = 0;
failCount = 0;
cancelledCount = 0;
onClose();
if (isComplete) {
onComplete();
}
}
function handleOk() {
open = false;
itemStates = [];
isRunning = false;
isComplete = false;
successCount = 0;
failCount = 0;
cancelledCount = 0;
onClose();
onComplete();
}
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
<Dialog.Content class="w-full max-w-lg" onInteractOutside={(e) => isRunning && e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{#if isRunning}
Processing {items.length} {entityType}...
{:else if isComplete}
Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
{:else}
Preparing to {operation} {items.length} {entityType}...
{/if}
</Dialog.Description>
</Dialog.Header>
<!-- Progress bar -->
<div class="py-2">
<Progress value={progress()} class="h-2" />
<div class="text-xs text-muted-foreground mt-1 text-right">
{progress()}%
</div>
</div>
<!-- Items list -->
<div class="max-h-80 overflow-y-auto border rounded-md">
{#each itemStates as item (item.id)}
<div class="px-3 py-2 border-b last:border-b-0 text-sm {item.status === 'error' ? 'bg-red-50 dark:bg-red-950/20' : ''} {item.status === 'cancelled' ? 'bg-amber-50 dark:bg-amber-950/20' : ''}">
<div class="flex items-center gap-2">
<!-- Status icon -->
<div class="w-5 h-5 flex items-center justify-center flex-shrink-0">
{#if item.status === 'pending'}
<Circle class="w-4 h-4 text-muted-foreground" />
{:else if item.status === 'processing'}
<Loader2 class="w-4 h-4 text-blue-500 animate-spin" />
{:else if item.status === 'success'}
<Check class="w-4 h-4 text-green-500" />
{:else if item.status === 'error'}
<X class="w-4 h-4 text-red-500" />
{:else if item.status === 'cancelled'}
<Ban class="w-4 h-4 text-amber-500" />
{/if}
</div>
<!-- Item name -->
<span class="flex-1 truncate font-mono text-xs" title={item.name}>
{item.name}
</span>
<!-- Status text -->
<span class="text-xs text-muted-foreground flex-shrink-0">
{#if item.status === 'pending'}
pending
{:else if item.status === 'processing'}
{progressText[operation] ?? operation}...
{:else if item.status === 'success'}
done
{:else if item.status === 'error'}
<span class="text-red-500">failed</span>
{:else if item.status === 'cancelled'}
<span class="text-amber-500">cancelled</span>
{/if}
</span>
</div>
<!-- Error message on separate line -->
{#if item.status === 'error' && item.error}
<div class="mt-1 ml-7 text-xs text-red-600 dark:text-red-400 break-words">
{item.error}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer: Summary + Button in one row -->
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-3 text-sm">
<div class="flex items-center gap-1" title="Succeeded">
<Check class="w-4 h-4 text-green-500" />
<span class="tabular-nums">{successCount}</span>
</div>
<div class="flex items-center gap-1" title="Failed">
<X class="w-4 h-4 text-red-500" />
<span class="tabular-nums">{failCount}</span>
</div>
<div class="flex items-center gap-1" title="Cancelled">
<Ban class="w-4 h-4 text-amber-500" />
<span class="tabular-nums">{cancelledCount}</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground" title="Pending">
<Circle class="w-4 h-4" />
<span class="tabular-nums">{items.length - successCount - failCount - cancelledCount}</span>
</div>
</div>
{#if isRunning}
<Button variant="outline" size="sm" onclick={handleCancel}>
Cancel
</Button>
{:else}
<Button size="sm" onclick={handleOk}>
OK
</Button>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,821 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
// Docker Compose keywords for autocomplete
const COMPOSE_TOP_LEVEL = ['services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'version'];
const COMPOSE_SERVICE_KEYS = [
'annotations', 'attach', 'build', 'blkio_config', 'cap_add', 'cap_drop', 'cgroup', 'cgroup_parent',
'command', 'configs', 'container_name', 'cpu_count', 'cpu_percent', 'cpu_period', 'cpu_quota',
'cpu_rt_period', 'cpu_rt_runtime', 'cpu_shares', 'cpus', 'cpuset', 'credential_spec',
'depends_on', 'deploy', 'develop', 'device_cgroup_rules', 'devices', 'dns', 'dns_opt', 'dns_search',
'domainname', 'driver_opts', 'entrypoint', 'env_file', 'environment', 'expose', 'extends',
'external_links', 'extra_hosts', 'gpus', 'group_add', 'healthcheck', 'hostname', 'image', 'init',
'ipc', 'isolation', 'labels', 'label_file', 'links', 'logging', 'mac_address', 'mem_limit',
'mem_reservation', 'mem_swappiness', 'memswap_limit', 'models', 'network_mode', 'networks',
'oom_kill_disable', 'oom_score_adj', 'pid', 'pids_limit', 'platform', 'ports', 'post_start',
'pre_stop', 'privileged', 'profiles', 'provider', 'pull_policy', 'read_only', 'restart', 'runtime',
'scale', 'secrets', 'security_opt', 'shm_size', 'stdin_open', 'stop_grace_period', 'stop_signal',
'storage_opt', 'sysctls', 'tmpfs', 'tty', 'ulimits', 'use_api_socket', 'user', 'userns_mode', 'uts',
'volumes', 'volumes_from', 'working_dir'
];
const COMPOSE_BUILD_KEYS = [
'context', 'dockerfile', 'dockerfile_inline', 'args', 'ssh', 'cache_from', 'cache_to',
'extra_hosts', 'isolation', 'labels', 'no_cache', 'pull', 'shm_size', 'target', 'secrets',
'tags', 'platforms', 'privileged', 'network'
];
const COMPOSE_DEPLOY_KEYS = [
'mode', 'replicas', 'endpoint_mode', 'labels', 'placement', 'resources', 'restart_policy',
'rollback_config', 'update_config'
];
const COMPOSE_HEALTHCHECK_KEYS = [
'test', 'interval', 'timeout', 'retries', 'start_period', 'start_interval', 'disable'
];
const COMPOSE_LOGGING_KEYS = ['driver', 'options'];
const COMPOSE_NETWORK_TOP_LEVEL = [
'driver', 'driver_opts', 'attachable', 'enable_ipv6', 'external', 'internal', 'ipam', 'labels', 'name'
];
const COMPOSE_VOLUME_TOP_LEVEL = [
'driver', 'driver_opts', 'external', 'labels', 'name'
];
const COMPOSE_DEPENDS_ON_VALUES = ['service_started', 'service_healthy', 'service_completed_successfully'];
const COMPOSE_RESTART_VALUES = ['no', 'always', 'on-failure', 'unless-stopped'];
const COMPOSE_PULL_POLICY_VALUES = ['always', 'never', 'missing', 'build', 'daily', 'weekly'];
const COMPOSE_NETWORK_MODE_VALUES = ['none', 'host', 'bridge'];
// All Docker Compose keywords combined for autocomplete
const ALL_COMPOSE_KEYWORDS = [
...COMPOSE_TOP_LEVEL,
...COMPOSE_SERVICE_KEYS,
...COMPOSE_BUILD_KEYS,
...COMPOSE_DEPLOY_KEYS,
...COMPOSE_HEALTHCHECK_KEYS,
...COMPOSE_LOGGING_KEYS,
...COMPOSE_NETWORK_TOP_LEVEL,
...COMPOSE_VOLUME_TOP_LEVEL
].filter((v, i, a) => a.indexOf(v) === i).sort(); // Remove duplicates and sort
// Docker Compose autocomplete source - always suggest all keywords
function composeCompletions(context: CompletionContext): CompletionResult | null {
// Get word before cursor
const word = context.matchBefore(/[a-z_]*/);
if (!word) return null;
// Only show completions if typing (not empty) or explicitly requested
if (word.from === word.to && !context.explicit) return null;
const line = context.state.doc.lineAt(context.pos);
const textBefore = line.text.slice(0, context.pos - line.from);
// Don't show in value position (after colon with content)
if (textBefore.match(/:\s*\S/)) return null;
return {
from: word.from,
options: ALL_COMPOSE_KEYWORDS.map(label => ({
label,
type: 'keyword',
apply: label + ':'
})),
validFor: /^[a-z_]*$/
};
}
// Value completions for specific keys
function composeValueCompletions(context: CompletionContext): CompletionResult | null {
const line = context.state.doc.lineAt(context.pos);
const textBefore = line.text.slice(0, context.pos - line.from);
// Check if we're after a key: pattern (value position)
const valueMatch = textBefore.match(/^\s*([a-z_]+):\s*/);
if (!valueMatch) return null;
const key = valueMatch[1];
// Get word at cursor for value
const word = context.matchBefore(/[a-z_-]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options: string[] = [];
switch (key) {
case 'restart':
options = COMPOSE_RESTART_VALUES;
break;
case 'pull_policy':
options = COMPOSE_PULL_POLICY_VALUES;
break;
case 'network_mode':
options = COMPOSE_NETWORK_MODE_VALUES;
break;
case 'condition':
options = COMPOSE_DEPENDS_ON_VALUES;
break;
default:
return null;
}
return {
from: word.from,
options: options.map(label => ({
label,
type: 'value'
})),
validFor: /^[a-z_-]*$/
};
}
// Language imports
import { yaml } from '@codemirror/lang-yaml';
import { json } from '@codemirror/lang-json';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { markdown } from '@codemirror/lang-markdown';
import { xml } from '@codemirror/lang-xml';
import { sql } from '@codemirror/lang-sql';
export interface VariableMarker {
name: string;
type: 'required' | 'optional' | 'missing';
value?: string; // The value provided in env vars editor
isSecret?: boolean; // Whether to mask the value
defaultValue?: string; // The default value from compose syntax (e.g., ${VAR:-default})
}
interface Props {
value: string;
language?: string;
readonly?: boolean;
theme?: 'dark' | 'light';
onchange?: (value: string) => void;
class?: string;
variableMarkers?: VariableMarker[];
}
let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers = [] }: Props = $props();
let container: HTMLDivElement;
let view: EditorView | null = null;
// Mutable ref for callback - allows updating without recreating editor
let onchangeRef: ((value: string) => void) | undefined = onchange;
// Keep callback ref updated when prop changes
$effect(() => {
onchangeRef = onchange;
});
// Variable marker gutter icons
class VariableGutterMarker extends GutterMarker {
type: 'required' | 'optional' | 'missing';
hasValue: boolean;
constructor(type: 'required' | 'optional' | 'missing', hasValue: boolean = false) {
super();
this.type = type;
this.hasValue = hasValue;
}
toDOM() {
const wrapper = document.createElement('span');
wrapper.className = 'var-marker-wrapper';
// The colored dot
const dot = document.createElement('span');
dot.className = `var-marker var-marker-${this.type}`;
dot.title = this.type === 'missing' ? 'Missing required variable'
: this.type === 'required' ? 'Required variable (defined)'
: 'Optional variable (has default)';
wrapper.appendChild(dot);
// Checkmark if value is provided
if (this.hasValue) {
const check = document.createElement('span');
check.className = 'var-marker-check';
check.innerHTML = '✓';
check.title = 'Value provided';
wrapper.appendChild(check);
}
return wrapper;
}
}
// Widget to show variable value as inline overlay
// Supports three states: provided (green), default (blue), missing (red)
class VariableValueWidget extends WidgetType {
value: string;
isSecret: boolean;
variant: 'provided' | 'default' | 'missing';
constructor(value: string, isSecret: boolean = false, variant: 'provided' | 'default' | 'missing' = 'provided') {
super();
this.value = value;
this.isSecret = isSecret;
this.variant = variant;
}
toDOM() {
const span = document.createElement('span');
span.className = `var-value-overlay var-value-${this.variant}`;
if (this.variant === 'missing') {
// Red MISSING badge with icon
span.innerHTML = '⚠ MISSING';
span.title = 'Required variable not defined';
} else {
span.textContent = this.isSecret ? '••••••' : this.value;
span.title = this.isSecret ? 'Secret value' : this.value;
}
return span;
}
eq(other: VariableValueWidget) {
return this.value === other.value && this.isSecret === other.isSecret && this.variant === other.variant;
}
}
// Create inline value decorations
function createValueDecorations(doc: any, markers: VariableMarker[]): DecorationSet {
const decorations: {from: number, to: number, decoration: Decoration}[] = [];
if (markers.length === 0) return Decoration.none;
const text = doc.toString();
for (const marker of markers) {
// Find all occurrences of this variable in the text
// Match ${VAR_NAME} or ${VAR_NAME:-...} or $VAR_NAME patterns
const patterns = [
{ regex: new RegExp(`\\$\\{${marker.name}\\}`, 'g'), hasDefault: false },
{ regex: new RegExp(`\\$\\{${marker.name}:-([^}]*)\\}`, 'g'), hasDefault: true },
{ regex: new RegExp(`\\$\\{${marker.name}-([^}]*)\\}`, 'g'), hasDefault: true },
{ regex: new RegExp(`\\$\\{${marker.name}:\\?[^}]*\\}`, 'g'), hasDefault: false },
{ regex: new RegExp(`\\$\\{${marker.name}\\?[^}]*\\}`, 'g'), hasDefault: false },
{ regex: new RegExp(`\\$\\{${marker.name}:\\+[^}]*\\}`, 'g'), hasDefault: false },
{ regex: new RegExp(`\\$\\{${marker.name}\\+[^}]*\\}`, 'g'), hasDefault: false },
];
for (const { regex, hasDefault } of patterns) {
let match;
while ((match = regex.exec(text)) !== null) {
const from = match.index;
const to = from + match[0].length;
// Determine what to show:
// 1. If value is provided in env vars editor -> green with that value
// 2. If no value but has default in syntax -> blue with default value
// 3. If no value and no default (missing) -> red MISSING
let widget: VariableValueWidget;
if (marker.value) {
// Value provided in env vars editor -> GREEN
widget = new VariableValueWidget(marker.value, marker.isSecret ?? false, 'provided');
} else if (hasDefault && match[1]) {
// Has default value from compose syntax -> BLUE
widget = new VariableValueWidget(match[1], false, 'default');
} else if (marker.defaultValue) {
// Has default value from marker -> BLUE
widget = new VariableValueWidget(marker.defaultValue, false, 'default');
} else if (marker.type === 'missing') {
// Missing required variable -> RED
widget = new VariableValueWidget('', false, 'missing');
} else {
// Skip if nothing to show
continue;
}
// Add widget decoration at the end of the variable
decorations.push({
from: to,
to: to,
decoration: Decoration.widget({
widget,
side: 1
})
});
}
}
}
// Sort by position
decorations.sort((a, b) => a.from - b.from);
return Decoration.set(decorations.map(d => d.decoration.range(d.from, d.to)));
}
// Create decorations for variable markers
function createVariableDecorations(doc: any, markers: VariableMarker[]): RangeSet<GutterMarker> {
const gutterMarkers: {from: number, marker: GutterMarker}[] = [];
if (markers.length === 0) return RangeSet.empty;
const text = doc.toString();
const lines = text.split('\n');
let pos = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check if this line contains any of our marked variables
for (const marker of markers) {
// Match ${VAR_NAME} or ${VAR_NAME:-...} patterns
const patterns = [
`\${${marker.name}}`,
`\${${marker.name}:-`,
`\${${marker.name}-`,
`\${${marker.name}:?`,
`\${${marker.name}?`,
`\${${marker.name}:+`,
`\${${marker.name}+`,
`$${marker.name}`
];
const hasVariable = patterns.some(p => line.includes(p));
if (hasVariable) {
gutterMarkers.push({
from: pos,
marker: new VariableGutterMarker(marker.type, !!marker.value)
});
break; // Only one marker per line
}
}
pos += line.length + 1; // +1 for newline
}
// Sort by position and create RangeSet
gutterMarkers.sort((a, b) => a.from - b.from);
return RangeSet.of(gutterMarkers.map(m => m.marker.range(m.from)));
}
// Effect to update variable markers
const updateMarkersEffect = StateEffect.define<VariableMarker[]>();
// State field to track variable markers (gutter)
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
const variableMarkersField = StateField.define<RangeSet<GutterMarker>>({
create() {
// Start empty - markers will be pushed via effect
return RangeSet.empty;
},
update(markers, tr) {
for (const effect of tr.effects) {
if (effect.is(updateMarkersEffect)) {
return createVariableDecorations(tr.state.doc, effect.value);
}
}
// Don't recalculate on docChanged - wait for explicit effect from parent
return markers;
}
});
// State field to track value decorations (inline widgets)
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
const valueDecorationsField = StateField.define<DecorationSet>({
create() {
// Start empty - decorations will be pushed via effect
return Decoration.none;
},
update(decorations, tr) {
for (const effect of tr.effects) {
if (effect.is(updateMarkersEffect)) {
return createValueDecorations(tr.state.doc, effect.value);
}
}
// Don't recalculate on docChanged - wait for explicit effect from parent
return decorations;
},
provide: f => EditorView.decorations.from(f)
});
// Variable markers gutter
const variableGutter = gutter({
class: 'cm-variable-gutter',
markers: view => view.state.field(variableMarkersField),
initialSpacer: () => new VariableGutterMarker('required')
});
// Get language extension based on language name
function getLanguageExtension(lang: string) {
switch (lang) {
case 'yaml':
return yaml();
case 'json':
return json();
case 'javascript':
case 'js':
return javascript();
case 'typescript':
case 'ts':
return javascript({ typescript: true });
case 'jsx':
return javascript({ jsx: true });
case 'tsx':
return javascript({ jsx: true, typescript: true });
case 'python':
case 'py':
return python();
case 'html':
return html();
case 'css':
return css();
case 'markdown':
case 'md':
return markdown();
case 'xml':
return xml();
case 'sql':
return sql();
case 'dockerfile':
case 'shell':
case 'bash':
case 'sh':
// No dedicated shell/dockerfile support, use basic highlighting
return [];
default:
return [];
}
}
// Create custom dark theme that matches our UI
const dockhandDark = EditorView.theme({
'&': {
backgroundColor: '#1a1a1a',
color: '#d4d4d4',
height: '100%',
fontSize: '13px'
},
'.cm-content': {
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
padding: '8px 0'
},
'.cm-gutters': {
backgroundColor: '#1a1a1a',
color: '#858585',
border: 'none',
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: '13px'
},
'.cm-activeLineGutter': {
backgroundColor: '#2a2a2a'
},
'.cm-activeLine': {
backgroundColor: '#2a2a2a'
},
'.cm-selectionBackground': {
backgroundColor: 'yellow !important'
},
'&.cm-focused .cm-selectionBackground': {
backgroundColor: 'yellow !important'
},
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'yellow !important'
},
'.cm-cursor': {
borderLeftColor: '#d4d4d4'
},
'.cm-line': {
padding: '0 8px'
}
}, { dark: true });
// Create custom light theme
const dockhandLight = EditorView.theme({
'&': {
backgroundColor: '#fafafa',
color: '#3f3f46',
height: '100%',
fontSize: '13px'
},
'.cm-content': {
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
padding: '8px 0'
},
'.cm-gutters': {
backgroundColor: '#fafafa',
color: '#a1a1aa',
border: 'none',
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: '13px'
},
'.cm-activeLineGutter': {
backgroundColor: '#f4f4f5'
},
'.cm-activeLine': {
backgroundColor: '#f4f4f5'
},
'.cm-selectionBackground': {
backgroundColor: '#e4e4e7 !important'
},
'&.cm-focused .cm-selectionBackground': {
backgroundColor: '#e4e4e7 !important'
},
'.cm-cursor': {
borderLeftColor: '#3f3f46'
},
'.cm-line': {
padding: '0 8px'
}
}, { dark: false });
// Track if we're initialized (prevents multiple createEditor calls)
let initialized = false;
function createEditor() {
if (!container || view || initialized) return;
initialized = true;
const themeExtensions = theme === 'dark'
? [dockhandDark, syntaxHighlighting(oneDarkHighlightStyle)]
: [dockhandLight, syntaxHighlighting(defaultHighlightStyle)];
// Build autocompletion config - add Docker Compose completions for YAML
const autocompletionConfig = language === 'yaml'
? autocompletion({
override: [composeCompletions, composeValueCompletions],
activateOnTyping: true
})
: autocompletion();
const extensions = [
lineNumbers(),
highlightActiveLineGutter(),
highlightActiveLine(),
history(),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletionConfig,
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...completionKeymap,
...closeBracketsKeymap,
indentWithTab
]),
...themeExtensions,
EditorView.lineWrapping,
getLanguageExtension(language)
].flat();
if (readonly) {
extensions.push(EditorState.readOnly.of(true));
}
// Always add variable markers gutter and value decorations (can be updated dynamically)
extensions.push(variableMarkersField, variableGutter, valueDecorationsField);
const state = EditorState.create({
doc: value,
extensions
});
// Custom transaction handler - this is SYNCHRONOUS and more reliable than updateListener
// Based on the Svelte Playground pattern: https://svelte.dev/playground/91649ba3e0ce4122b3b34f3a95a00104
const dispatchTransactions = (trs: readonly import('@codemirror/state').Transaction[]) => {
if (!view) return;
// Apply all transactions
view.update(trs);
// Check if any transaction changed the document
const lastChangingTr = trs.findLast(tr => tr.docChanged);
if (lastChangingTr && onchangeRef) {
onchangeRef(lastChangingTr.newDoc.toString());
}
};
view = new EditorView({
state,
parent: container,
dispatchTransactions
});
// Push initial markers if provided
if (variableMarkers.length > 0) {
view.dispatch({
effects: updateMarkersEffect.of(variableMarkers)
});
}
}
function destroyEditor() {
if (view) {
view.destroy();
view = null;
}
initialized = false;
}
// Get current editor content
export function getValue(): string {
return view?.state.doc.toString() ?? value;
}
// Set editor content
export function setValue(newValue: string) {
if (view) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: newValue
}
});
}
}
// Focus the editor
export function focus() {
view?.focus();
}
// Update variable markers - this is the key method for parent to call
export function updateVariableMarkers(markers: VariableMarker[]) {
if (view) {
view.dispatch({
effects: updateMarkersEffect.of(markers)
});
}
}
onMount(() => {
createEditor();
});
onDestroy(() => {
destroyEditor();
});
// Track previous values for comparison
let prevLanguage = $state(language);
let prevTheme = $state(theme);
// Recreate editor if language or theme changes
$effect(() => {
const currentLanguage = language;
const currentTheme = theme;
// Only recreate if language or theme actually changed
if (view && (currentLanguage !== prevLanguage || currentTheme !== prevTheme)) {
prevLanguage = currentLanguage;
prevTheme = currentTheme;
const currentContent = view.state.doc.toString();
destroyEditor();
value = currentContent; // Preserve content
createEditor();
}
});
// Update markers when prop changes (backup mechanism, parent should also call updateVariableMarkers)
$effect(() => {
const markers = variableMarkers;
if (view && markers) {
view.dispatch({
effects: updateMarkersEffect.of(markers)
});
}
});
</script>
<div
bind:this={container}
class="h-full w-full overflow-hidden {className}"
onkeydown={(e) => e.stopPropagation()}
></div>
<style>
div :global(.cm-editor) {
height: 100%;
}
div :global(.cm-scroller) {
overflow: auto;
}
/* Variable marker gutter */
div :global(.cm-variable-gutter) {
width: 28px;
min-width: 28px;
}
div :global(.var-marker-wrapper) {
display: inline-flex;
align-items: center;
gap: 2px;
padding-left: 2px;
}
div :global(.var-marker-check) {
color: #22c55e;
font-size: 14px;
font-weight: bold;
line-height: 1;
margin-top: -1px;
}
div :global(.var-marker) {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin: 4px 3px;
cursor: help;
}
div :global(.var-marker-required) {
background-color: #22c55e; /* green-500 */
box-shadow: 0 0 4px #22c55e;
}
div :global(.var-marker-optional) {
background-color: #60a5fa; /* blue-400 */
box-shadow: 0 0 4px #60a5fa;
}
div :global(.var-marker-missing) {
background-color: #ef4444; /* red-500 */
box-shadow: 0 0 4px #ef4444;
}
/* Variable value overlay widget - base styles */
div :global(.var-value-overlay) {
display: inline-block;
margin-left: 4px;
padding: 0 6px;
font-size: 11px;
font-family: inherit;
border-radius: 4px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
cursor: help;
}
/* Provided value - GREEN */
div :global(.var-value-provided) {
background-color: rgba(34, 197, 94, 0.15);
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.3);
}
/* Default value - BLUE */
div :global(.var-value-default) {
background-color: rgba(96, 165, 250, 0.15);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.3);
}
/* Missing value - RED */
div :global(.var-value-missing) {
background-color: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
font-weight: 600;
}
/* Light theme adjustments */
:global(.cm-editor:not(.cm-dark)) div :global(.var-value-provided) {
background-color: rgba(34, 197, 94, 0.1);
color: #16a34a;
border-color: rgba(34, 197, 94, 0.4);
}
:global(.cm-editor:not(.cm-dark)) div :global(.var-value-default) {
background-color: rgba(59, 130, 246, 0.1);
color: #2563eb;
border-color: rgba(59, 130, 246, 0.4);
}
:global(.cm-editor:not(.cm-dark)) div :global(.var-value-missing) {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
border-color: rgba(239, 68, 68, 0.4);
}
</style>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { Settings2, RotateCcw, ChevronUp, ChevronDown } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import * as Popover from '$lib/components/ui/popover';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { gridPreferencesStore } from '$lib/stores/grid-preferences';
import { getConfigurableColumns } from '$lib/config/grid-columns';
import type { GridId, ColumnPreference } from '$lib/types';
interface Props {
gridId: GridId;
}
let { gridId }: Props = $props();
let open = $state(false);
let columns = $state<ColumnPreference[]>([]);
// Load columns when popover opens
$effect(() => {
if (open) {
columns = gridPreferencesStore.getAllColumns(gridId);
}
});
// Get column labels from config
const columnConfigs = $derived(getConfigurableColumns(gridId));
function getColumnLabel(id: string): string {
const config = columnConfigs.find((c) => c.id === id);
return config?.label || id;
}
// Save columns and update grid immediately
async function saveColumns(newColumns: ColumnPreference[]) {
columns = newColumns;
await gridPreferencesStore.setColumns(gridId, columns);
}
// Toggle column visibility
function toggleColumn(index: number) {
const newColumns = columns.map((col, i) =>
i === index ? { ...col, visible: !col.visible } : col
);
saveColumns(newColumns);
}
// Move column up/down
function moveUp(index: number) {
if (index <= 0) return;
const newColumns = [...columns];
[newColumns[index - 1], newColumns[index]] = [newColumns[index], newColumns[index - 1]];
saveColumns(newColumns);
}
function moveDown(index: number) {
if (index >= columns.length - 1) return;
const newColumns = [...columns];
[newColumns[index], newColumns[index + 1]] = [newColumns[index + 1], newColumns[index]];
saveColumns(newColumns);
}
// Reset to defaults
async function resetToDefaults() {
await gridPreferencesStore.resetGrid(gridId);
columns = gridPreferencesStore.getAllColumns(gridId);
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild>
{#snippet child({ props })}
<button
type="button"
title="Column settings"
{...props}
class="inline-flex items-center justify-center p-1 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
>
<Settings2 class="w-4 h-4" />
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-64 p-0" side="bottom" align="end" sideOffset={8}>
<div class="p-3 border-b">
<div class="flex items-center justify-between">
<span class="font-medium text-sm">Columns</span>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
onclick={resetToDefaults}
title="Reset to defaults"
>
<RotateCcw class="w-3 h-3 mr-1" />
Reset
</Button>
</div>
</div>
<div class="max-h-64 overflow-y-auto p-2">
{#each columns as column, index (column.id)}
<div class="flex items-center gap-1 p-1 rounded hover:bg-muted/50">
<div class="flex flex-col">
<button
type="button"
class="p-0.5 hover:bg-muted rounded disabled:opacity-30"
disabled={index === 0}
onclick={() => moveUp(index)}
>
<ChevronUp class="w-3 h-3" />
</button>
<button
type="button"
class="p-0.5 hover:bg-muted rounded disabled:opacity-30"
disabled={index === columns.length - 1}
onclick={() => moveDown(index)}
>
<ChevronDown class="w-3 h-3" />
</button>
</div>
<Checkbox
id="col-{column.id}"
checked={column.visible}
onCheckedChange={() => toggleColumn(index)}
/>
<Label
for="col-{column.id}"
class="text-sm cursor-pointer flex-1 truncate"
>
{getColumnLabel(column.id)}
</Label>
</div>
{/each}
</div>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,355 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import * as Command from '$lib/components/ui/command';
import {
LayoutDashboard,
Box,
Layers,
Images,
ScrollText,
HardDrive,
Network,
Download,
Settings,
Terminal,
Eye,
Timer,
ClipboardList,
Search,
Server,
Play,
Square,
RotateCcw,
FileText,
CircleDot,
Sun,
Moon,
Type,
Check
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, canAccess } from '$lib/stores/auth';
import { currentEnvironment } from '$lib/stores/environment';
import { themeStore, onDarkModeChange } from '$lib/stores/theme';
import { lightThemes, darkThemes, fonts } from '$lib/themes';
interface Props {
open?: boolean;
}
let { open = $bindable(false) }: Props = $props();
interface CommandItem {
name: string;
href: string;
icon: typeof LayoutDashboard;
keywords?: string[];
}
interface Environment {
id: number;
name: string;
icon?: string;
}
interface Container {
id: string;
name: string;
state: string;
image: string;
envId: number;
envName: string;
}
let environments = $state<Environment[]>([]);
let containers = $state<Container[]>([]);
let loading = $state(false);
const navigationItems: CommandItem[] = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard, keywords: ['home', 'overview'] },
{ name: 'Containers', href: '/containers', icon: Box, keywords: ['docker', 'running'] },
{ name: 'Logs', href: '/logs', icon: ScrollText, keywords: ['output', 'debug'] },
{ name: 'Shell', href: '/terminal', icon: Terminal, keywords: ['exec', 'bash', 'sh'] },
{ name: 'Stacks', href: '/stacks', icon: Layers, keywords: ['compose', 'docker-compose'] },
{ name: 'Images', href: '/images', icon: Images, keywords: ['pull', 'build'] },
{ name: 'Volumes', href: '/volumes', icon: HardDrive, keywords: ['storage', 'data'] },
{ name: 'Networks', href: '/networks', icon: Network, keywords: ['bridge', 'host'] },
{ name: 'Registry', href: '/registry', icon: Download, keywords: ['hub', 'pull'] },
{ name: 'Activity', href: '/activity', icon: Eye, keywords: ['events', 'history'] },
{ name: 'Schedules', href: '/schedules', icon: Timer, keywords: ['cron', 'auto'] },
{ name: 'Settings', href: '/settings', icon: Settings, keywords: ['config', 'preferences'] }
];
// Filter items based on permissions
const filteredItems = $derived(
navigationItems.filter(item => {
if (item.href === '/terminal' && !$canAccess('containers', 'exec')) return false;
if (item.href === '/audit' && (!$licenseStore.isEnterprise || !$authStore.authEnabled)) return false;
return true;
})
);
// Load environments and containers when palette opens
async function loadData() {
if (loading) return;
loading = true;
try {
const [envsRes, containersRes] = await Promise.all([
fetch('/api/environments'),
fetch('/api/containers?all=true')
]);
if (envsRes.ok) {
environments = await envsRes.json();
}
if (containersRes.ok) {
const data = await containersRes.json();
containers = data.map((c: any) => ({
id: c.Id,
name: c.Names?.[0]?.replace(/^\//, '') || c.Id.substring(0, 12),
state: c.State,
image: c.Image,
envId: c.environmentId || 0,
envName: c.environmentName || 'Local'
}));
}
} catch (e) {
console.error('Failed to load command palette data:', e);
} finally {
loading = false;
}
}
function handleSelect(href: string) {
open = false;
goto(href);
}
function handleEnvSelect(env: Environment) {
open = false;
currentEnvironment.set({ id: env.id, name: env.name });
}
function handleLightThemeSelect(themeId: string) {
const userId = $authStore.authEnabled && $authStore.user ? $authStore.user.id : undefined;
themeStore.setPreference('lightTheme', themeId, userId);
// Switch to light mode
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
onDarkModeChange();
}
function handleDarkThemeSelect(themeId: string) {
const userId = $authStore.authEnabled && $authStore.user ? $authStore.user.id : undefined;
themeStore.setPreference('darkTheme', themeId, userId);
// Switch to dark mode
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
onDarkModeChange();
}
function handleFontSelect(fontId: string) {
const userId = $authStore.authEnabled && $authStore.user ? $authStore.user.id : undefined;
themeStore.setPreference('font', fontId, userId);
}
async function handleContainerAction(containerId: string, action: 'logs' | 'terminal' | 'start' | 'stop' | 'restart') {
open = false;
const container = containers.find(c => c.id === containerId);
const envParam = container?.envId ? `?env=${container.envId}` : '';
if (action === 'logs') {
goto(`/logs?container=${containerId}${envParam ? '&env=' + container?.envId : ''}`);
} else if (action === 'terminal') {
goto(`/terminal?container=${containerId}${envParam ? '&env=' + container?.envId : ''}`);
} else {
try {
await fetch(`/api/containers/${containerId}/${action}${envParam}`, { method: 'POST' });
} catch (e) {
console.error(`Failed to ${action} container:`, e);
}
}
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
open = !open;
}
}
// Load data when dialog opens
$effect(() => {
if (open) {
loadData();
}
});
onMount(() => {
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
});
</script>
<Command.Dialog bind:open title="Command Palette" description="Search for pages and actions">
<Command.Input placeholder="Search..." />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
<Command.Group heading="Navigation">
{#each filteredItems as item (item.href)}
<Command.Item
value={item.name + ' ' + (item.keywords?.join(' ') || '')}
onSelect={() => handleSelect(item.href)}
>
<item.icon class="mr-2 h-4 w-4" />
<span>{item.name}</span>
</Command.Item>
{/each}
</Command.Group>
{#if $licenseStore.isEnterprise && $authStore.authEnabled}
<Command.Separator />
<Command.Group heading="Enterprise">
<Command.Item
value="Audit log compliance"
onSelect={() => handleSelect('/audit')}
>
<ClipboardList class="mr-2 h-4 w-4" />
<span>Audit log</span>
</Command.Item>
</Command.Group>
{/if}
<Command.Separator />
<Command.Group heading="Light theme">
{#each lightThemes as theme (theme.id)}
<Command.Item
value={`light theme ${theme.name}`}
onSelect={() => handleLightThemeSelect(theme.id)}
>
<Sun class="mr-2 h-4 w-4" />
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full border"
style="background-color: {theme.preview}"
></div>
<span>{theme.name}</span>
</div>
{#if $themeStore.lightTheme === theme.id}
<Check class="ml-auto h-4 w-4 text-green-500" />
{/if}
</Command.Item>
{/each}
</Command.Group>
<Command.Separator />
<Command.Group heading="Dark theme">
{#each darkThemes as theme (theme.id)}
<Command.Item
value={`dark theme ${theme.name}`}
onSelect={() => handleDarkThemeSelect(theme.id)}
>
<Moon class="mr-2 h-4 w-4" />
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full border"
style="background-color: {theme.preview}"
></div>
<span>{theme.name}</span>
</div>
{#if $themeStore.darkTheme === theme.id}
<Check class="ml-auto h-4 w-4 text-green-500" />
{/if}
</Command.Item>
{/each}
</Command.Group>
<Command.Separator />
<Command.Group heading="Font">
{#each fonts as font (font.id)}
<Command.Item
value={`font ${font.name}`}
onSelect={() => handleFontSelect(font.id)}
>
<Type class="mr-2 h-4 w-4" />
<span>{font.name}</span>
{#if $themeStore.font === font.id}
<Check class="ml-auto h-4 w-4 text-green-500" />
{/if}
</Command.Item>
{/each}
</Command.Group>
{#if environments.length > 0}
<Command.Separator />
<Command.Group heading="Switch environment">
{#each environments as env (env.id)}
<Command.Item
value={`environment ${env.name}`}
onSelect={() => handleEnvSelect(env)}
>
<Server class="mr-2 h-4 w-4" />
<span>{env.name}</span>
{#if $currentEnvironment?.id === env.id}
<CircleDot class="ml-auto h-4 w-4 text-green-500" />
{/if}
</Command.Item>
{/each}
</Command.Group>
{/if}
{#if containers.length > 0}
<Command.Separator />
<Command.Group heading="Containers">
{#each containers as container (container.id)}
<Command.Item
value={`container ${container.name} ${container.image} ${container.envName}`}
onSelect={() => handleContainerAction(container.id, 'logs')}
>
<Box class="mr-2 h-4 w-4" />
<div class="flex flex-col">
<span>{container.name}</span>
<span class="text-xs text-muted-foreground">{container.envName}{container.image}</span>
</div>
<div class="ml-auto flex items-center gap-1">
{#if container.state === 'running'}
<button
class="p-1 hover:bg-muted rounded"
onclick={(e) => { e.stopPropagation(); handleContainerAction(container.id, 'logs'); }}
title="View logs"
>
<FileText class="h-3 w-3" />
</button>
<button
class="p-1 hover:bg-muted rounded"
onclick={(e) => { e.stopPropagation(); handleContainerAction(container.id, 'terminal'); }}
title="Open terminal"
>
<Terminal class="h-3 w-3" />
</button>
<button
class="p-1 hover:bg-muted rounded"
onclick={(e) => { e.stopPropagation(); handleContainerAction(container.id, 'restart'); }}
title="Restart"
>
<RotateCcw class="h-3 w-3" />
</button>
<button
class="p-1 hover:bg-muted rounded text-destructive"
onclick={(e) => { e.stopPropagation(); handleContainerAction(container.id, 'stop'); }}
title="Stop"
>
<Square class="h-3 w-3" />
</button>
{:else}
<button
class="p-1 hover:bg-muted rounded text-green-500"
onclick={(e) => { e.stopPropagation(); handleContainerAction(container.id, 'start'); }}
title="Start"
>
<Play class="h-3 w-3" />
</button>
{/if}
</div>
</Command.Item>
{/each}
</Command.Group>
{/if}
</Command.List>
</Command.Dialog>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Popover from '$lib/components/ui/popover';
import type { Snippet } from 'svelte';
import { appSettings } from '$lib/stores/settings';
interface Props {
open: boolean;
action: string;
itemName?: string;
itemType: string;
confirmText?: string;
variant?: 'destructive' | 'secondary' | 'default';
autoHideMs?: number;
title?: string;
position?: 'left' | 'right';
unstyled?: boolean;
disabled?: boolean;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
children: Snippet<[{ open: boolean }]>;
}
let {
open = $bindable(false),
action,
itemName = '',
itemType,
confirmText = 'Confirm',
variant = 'destructive',
autoHideMs = 3000,
title = '',
position = 'right',
unstyled = false,
disabled = false,
onConfirm,
onOpenChange,
children
}: Props = $props();
const triggerClass = $derived(unstyled
? 'inline-flex items-center cursor-pointer'
: 'p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center'
);
// Get the confirmDestructive setting from the store
const confirmDestructive = $derived($appSettings.confirmDestructive);
// Truncate long names
const displayName = $derived(itemName && itemName.length > 20 ? itemName.slice(0, 20) + '...' : itemName);
// Auto-hide after specified time
$effect(() => {
if (open && autoHideMs > 0) {
const timeout = setTimeout(() => {
open = false;
onOpenChange(false);
}, autoHideMs);
return () => clearTimeout(timeout);
}
});
function handleConfirm() {
console.log('[ConfirmPopover] handleConfirm called, onConfirm:', typeof onConfirm);
onConfirm();
open = false;
onOpenChange(false);
}
function handleTriggerClick(e: MouseEvent) {
e.stopPropagation();
// If confirmDestructive is disabled, execute action immediately
if (!confirmDestructive) {
onConfirm();
return;
}
open = !open;
onOpenChange(open);
}
function handleOpenChange(newOpen: boolean) {
open = newOpen;
onOpenChange(newOpen);
}
</script>
<Popover.Root bind:open onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
{#snippet child({ props })}
<button
type="button"
{title}
{...props}
onclick={handleTriggerClick}
class={triggerClass}
>
{@render children({ open })}
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content
class="w-auto p-2 z-[200]"
side="top"
align={position === 'left' ? 'start' : 'end'}
sideOffset={8}
>
<div class="flex items-center gap-2">
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
{confirmText}
</Button>
</div>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { Sun, Moon } from 'lucide-svelte';
interface Props {
logs: string | null;
darkMode?: boolean;
onToggleTheme?: () => void;
}
let { logs, darkMode = true, onToggleTheme }: Props = $props();
// Parse log lines with timestamp and content
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
const content = line.replace(/^\[[\d\-T:.Z]+\]\s*/, '');
const timestamp = line.match(/^\[([\d\-T:.Z]+)\]/)?.[1] || '';
let type: 'trivy' | 'grype' | 'error' | 'default' = 'default';
if (content.startsWith('[trivy]')) {
type = 'trivy';
} else if (content.startsWith('[grype]')) {
type = 'grype';
} else if (content.toLowerCase().includes('error')) {
type = 'error';
}
return { timestamp, content, type };
}
function getTypeBadge(type: 'trivy' | 'grype' | 'error' | 'default'): { label: string; class: string } {
switch (type) {
case 'trivy':
return { label: 'trivy', class: 'bg-teal-500 text-white' };
case 'grype':
return { label: 'grype', class: 'bg-violet-500 text-white' };
case 'error':
return { label: 'error', class: 'bg-red-500 text-white' };
default:
return { label: 'dockhand', class: 'bg-slate-500 text-white' };
}
}
function cleanContent(content: string, type: 'trivy' | 'grype' | 'error' | 'default'): string {
return content.replace(/^\[(trivy|grype|scan)\]\s*/i, '');
}
function formatTimestamp(timestamp: string): string {
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
}
</script>
<div class="flex-1 flex flex-col min-h-0">
<div class="flex items-center justify-between text-xs text-muted-foreground mb-1 shrink-0">
<span>Logs</span>
{#if onToggleTheme}
<button
type="button"
onclick={onToggleTheme}
class="p-1 rounded hover:bg-muted transition-colors"
title="Toggle log theme"
>
{#if darkMode}
<Sun class="w-3.5 h-3.5" />
{:else}
<Moon class="w-3.5 h-3.5" />
{/if}
</button>
{/if}
</div>
<div
class="{darkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded p-3 font-mono text-xs flex-1 overflow-auto"
>
{#if logs}
{#each logs.split('\n') as line}
{@const parsed = parseLogLine(line)}
{@const badge = getTypeBadge(parsed.type)}
<div class="flex items-start gap-1.5 leading-relaxed">
<span
class="inline-flex items-center justify-center w-12 px-1 rounded text-[8px] font-medium {badge.class} shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]"
>
{badge.label}
</span>
{#if parsed.timestamp}
<span class="{darkMode ? 'text-zinc-500' : 'text-zinc-400'} shrink-0">
{formatTimestamp(parsed.timestamp)}
</span>
{/if}
<span class="break-all">{cleanContent(parsed.content, parsed.type)}</span>
</div>
{/each}
{:else}
<span class="text-muted-foreground">No logs available</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import type { Component } from 'svelte';
interface FilterOption {
value: string;
label: string;
icon?: Component;
color?: string;
}
interface Props {
value: string[];
options: FilterOption[];
placeholder: string;
pluralLabel?: string;
width?: string;
defaultIcon?: Component;
}
let {
value = $bindable([]),
options,
placeholder,
pluralLabel,
width = 'w-36',
defaultIcon
}: Props = $props();
// Control dropdown open state
let open = $state(false);
// Check if any options have icons
const hasIcons = $derived(options.some(o => o.icon));
// Get the icon for single selection
const singleOption = $derived(() => {
if (value.length === 1) {
return options.find(o => o.value === value[0]);
}
return null;
});
const displayLabel = $derived(() => {
if (value.length === 0) {
return placeholder;
} else if (value.length === 1) {
const opt = options.find(o => o.value === value[0]);
return opt?.label || value[0];
} else {
return `${value.length} ${pluralLabel || placeholder.toLowerCase()}`;
}
});
function clearAndClose() {
value = [];
open = false;
}
</script>
<Select.Root type="multiple" bind:value bind:open>
<Select.Trigger size="sm" class="{width} text-sm">
{#if hasIcons || defaultIcon}
{@const opt = singleOption()}
{@const IconComponent = opt?.icon || defaultIcon}
{#if IconComponent}
<svelte:component this={IconComponent} class="w-3.5 h-3.5 mr-1.5 {opt?.color || 'text-muted-foreground'} shrink-0" />
{/if}
{/if}
<span class="{value.length === 0 ? 'text-muted-foreground' : ''}">
{displayLabel()}
</span>
</Select.Trigger>
<Select.Content>
{#if value.length > 0}
<button
type="button"
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
onclick={clearAndClose}
>
Clear
</button>
{/if}
{#each options as option}
<Select.Item value={option.value}>
{#if option.icon}
<svelte:component this={option.icon} class="w-4 h-4 mr-2 {option.color || ''}" />
{/if}
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { themeStore, type FontSize } from '$lib/stores/theme';
import { sseConnected } from '$lib/stores/events';
import { Badge } from '$lib/components/ui/badge';
import { Wifi } from 'lucide-svelte';
import type { Component } from 'svelte';
interface Props {
icon: Component;
title: string;
count?: number | string;
total?: number;
showConnection?: boolean;
class?: string;
iconClass?: string;
countClass?: string;
}
let {
icon: Icon,
title,
count,
total,
showConnection = true,
class: className = '',
iconClass = '',
countClass = 'min-w-12'
}: Props = $props();
// Font size scaling for page header
let fontSize = $state<FontSize>('normal');
themeStore.subscribe(prefs => fontSize = prefs.fontSize);
// Page header text size - shifted smaller (normal = what was small)
const headerTextClass = $derived(() => {
switch (fontSize) {
case 'small': return 'text-lg';
case 'normal': return 'text-xl';
case 'medium': return 'text-2xl';
case 'large': return 'text-2xl';
case 'xlarge': return 'text-3xl';
default: return 'text-xl';
}
});
// Page header icon size - shifted smaller (normal = what was small)
const headerIconClass = $derived(() => {
switch (fontSize) {
case 'small': return 'w-4 h-4';
case 'normal': return 'w-5 h-5';
case 'medium': return 'w-6 h-6';
case 'large': return 'w-6 h-6';
case 'xlarge': return 'w-7 h-7';
default: return 'w-5 h-5';
}
});
// Format count display
const countDisplay = $derived(() => {
if (count === undefined) return null;
const countStr = typeof count === 'number' ? count.toLocaleString() : count;
if (total !== undefined) {
return `${countStr} of ${total.toLocaleString()}`;
}
return countStr;
});
</script>
<div class="flex items-center gap-3 {className}">
<Icon class="{headerIconClass()} {iconClass}" />
<h1 class="{headerTextClass()} font-bold">{title}</h1>
{#if countDisplay()}
<Badge variant="secondary" class="text-xs tabular-nums {countClass} justify-center">
{countDisplay()}
</Badge>
{/if}
{#if showConnection}
<span title={$sseConnected ? 'Live updates active - grid will auto-refresh' : 'Connecting to live updates...'}>
<Wifi class="w-3.5 h-3.5 {$sseConnected ? 'text-emerald-500' : 'text-muted-foreground'}" />
</span>
{/if}
<slot />
</div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
interface Props {
password: string;
}
let { password }: Props = $props();
// Calculate password strength (0-4)
const strength = $derived.by(() => {
if (!password) return 0;
let score = 0;
// Length checks
if (password.length >= 8) score++;
if (password.length >= 12) score++;
// Character variety checks
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
return Math.min(score, 4);
});
const strengthLabel = $derived(
strength === 0 ? 'Too short' :
strength === 1 ? 'Weak' :
strength === 2 ? 'Fair' :
strength === 3 ? 'Good' :
'Strong'
);
const strengthColor = $derived(
strength === 0 ? 'bg-muted' :
strength === 1 ? 'bg-red-500' :
strength === 2 ? 'bg-orange-500' :
strength === 3 ? 'bg-yellow-500' :
'bg-green-500'
);
const strengthTextColor = $derived(
strength === 0 ? 'text-muted-foreground' :
strength === 1 ? 'text-red-500' :
strength === 2 ? 'text-orange-500' :
strength === 3 ? 'text-yellow-500' :
'text-green-500'
);
</script>
{#if password}
<div class="space-y-1">
<div class="flex gap-1 h-1">
{#each [1, 2, 3, 4] as level}
<div
class="flex-1 rounded-full transition-colors {strength >= level ? strengthColor : 'bg-muted'}"
></div>
{/each}
</div>
<p class="text-xs {strengthTextColor}">{strengthLabel}</p>
</div>
{/if}

View File

@@ -0,0 +1,506 @@
<script lang="ts">
import { tick } from 'svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Progress } from '$lib/components/ui/progress';
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Download } from 'lucide-svelte';
import { onMount } from 'svelte';
import { appendEnvParam } from '$lib/stores/environment';
interface LayerProgress {
id: string;
status: string;
progress?: string;
current?: number;
total?: number;
order: number;
isComplete: boolean;
}
type PullStatus = 'idle' | 'pulling' | 'complete' | 'error';
interface Props {
imageName?: string;
envId?: number | null;
autoStart?: boolean;
showImageInput?: boolean;
onComplete?: () => void;
onError?: (error: string) => void;
onStatusChange?: (status: PullStatus) => void;
onImageChange?: (image: string) => void;
}
let {
imageName: initialImageName = '',
envId = null,
autoStart = false,
showImageInput = true,
onComplete,
onError,
onStatusChange,
onImageChange
}: Props = $props();
let status = $state<PullStatus>('idle');
let image = $state(initialImageName);
let duration = $state(0);
// Notify parent of status changes
$effect(() => {
onStatusChange?.(status);
});
let layersMap = $state<Record<string, LayerProgress>>({});
let hasLayers = $state(false);
let errorMessage = $state('');
let statusMessage = $state('');
let completedLayers = $state(0);
let totalLayers = $state(0);
let layerOrder = $state(0);
let outputLines = $state<string[]>([]);
let outputContainer: HTMLDivElement | undefined;
let logDarkMode = $state(true);
let startTime = $state(0);
onMount(() => {
const saved = localStorage.getItem('logTheme');
if (saved !== null) {
logDarkMode = saved === 'dark';
}
});
$effect(() => {
if (initialImageName) {
image = initialImageName;
}
});
// Notify parent when image changes
$effect(() => {
onImageChange?.(image);
});
$effect(() => {
if (autoStart && image && status === 'idle') {
startPull();
}
});
function toggleLogTheme() {
logDarkMode = !logDarkMode;
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function getProgressPercentage(layer: LayerProgress): number {
if (!layer.current || !layer.total) return 0;
return Math.round((layer.current / layer.total) * 100);
}
async function scrollOutputToBottom() {
await tick();
if (outputContainer) {
outputContainer.scrollTop = outputContainer.scrollHeight;
}
}
function addOutputLine(line: string) {
outputLines = [...outputLines, line];
scrollOutputToBottom();
}
export function reset() {
status = 'idle';
image = initialImageName;
layersMap = {};
hasLayers = false;
errorMessage = '';
statusMessage = '';
completedLayers = 0;
totalLayers = 0;
layerOrder = 0;
outputLines = [];
duration = 0;
}
export function getImage() {
return image;
}
export function getStatus() {
return status;
}
export async function startPull() {
if (!image.trim()) return;
reset();
status = 'pulling';
startTime = Date.now();
addOutputLine(`[pull] Starting pull for ${image}`);
try {
const response = await fetch(appendEnvParam('/api/images/pull', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: image.trim(), scanAfterPull: false })
});
if (!response.ok) {
throw new Error('Failed to start pull');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
handlePullProgress(data);
} catch (e) {
// Ignore parse errors
}
}
}
if (status === 'pulling') {
duration = Date.now() - startTime;
status = 'complete';
addOutputLine(`[pull] Pull completed in ${formatDuration(duration)}`);
onComplete?.();
}
} catch (error: any) {
duration = Date.now() - startTime;
status = 'error';
errorMessage = error.message || 'Failed to pull image';
addOutputLine(`[error] ${errorMessage}`);
onError?.(errorMessage);
}
}
function handlePullProgress(data: any) {
// Filter out scan-related events (handled by ScanTab)
if (data.status === 'scanning' || data.status === 'scan-progress' || data.status === 'scan-complete' || data.status === 'scan-error') {
return;
}
if (data.status === 'complete') {
duration = Date.now() - startTime;
status = 'complete';
addOutputLine(`[pull] Pull completed in ${formatDuration(duration)}`);
onComplete?.();
} else if (data.status === 'error') {
duration = Date.now() - startTime;
status = 'error';
errorMessage = data.error || 'Unknown error occurred';
addOutputLine(`[error] ${errorMessage}`);
onError?.(errorMessage);
} else if (data.id) {
// Layer progress update
const isLayerId = /^[a-f0-9]{12}$/i.test(data.id);
if (!isLayerId) {
if (data.status) {
statusMessage = `${data.id}: ${data.status}`;
addOutputLine(`[pull] ${data.id}: ${data.status}`);
}
return;
}
const existing = layersMap[data.id];
const statusLower = (data.status || '').toLowerCase();
const isFullyComplete = statusLower === 'pull complete' || statusLower === 'already exists';
if (!existing) {
totalLayers++;
layerOrder++;
hasLayers = true;
if (isFullyComplete) {
completedLayers++;
}
// Use spread to ensure reactivity in Svelte 5
layersMap = {
...layersMap,
[data.id]: {
id: data.id,
status: data.status || 'Processing',
progress: data.progress,
current: data.progressDetail?.current,
total: data.progressDetail?.total,
order: layerOrder,
isComplete: isFullyComplete
}
};
if (isFullyComplete) {
addOutputLine(`[layer] ${data.id.slice(0, 12)}: ${data.status}`);
}
} else {
if (isFullyComplete && !existing.isComplete) {
completedLayers++;
addOutputLine(`[layer] ${data.id.slice(0, 12)}: ${data.status}`);
}
// Use spread to ensure reactivity in Svelte 5
layersMap = {
...layersMap,
[data.id]: {
id: data.id,
status: data.status || 'Processing',
progress: data.progress,
current: data.progressDetail?.current,
total: data.progressDetail?.total,
order: existing.order,
isComplete: existing.isComplete || isFullyComplete
}
};
}
} else if (data.status) {
statusMessage = data.status;
addOutputLine(`[pull] ${data.status}`);
}
}
const sortedLayers = $derived(
Object.values(layersMap).sort((a, b) => a.order - b.order)
);
const overallProgress = $derived(
totalLayers > 0 ? (completedLayers / totalLayers) * 100 : 0
);
const downloadStats = $derived.by(() => {
let totalBytes = 0;
let downloadedBytes = 0;
for (const layer of Object.values(layersMap)) {
if (layer.total) {
totalBytes += layer.total;
downloadedBytes += layer.current || 0;
}
}
return { totalBytes, downloadedBytes };
});
const isPulling = $derived(status === 'pulling');
</script>
<div class="flex flex-col gap-4 flex-1 min-h-0">
<!-- Image Input -->
{#if showImageInput}
<div class="space-y-2 shrink-0">
<Label for="pull-image" class="text-sm font-medium">Image name</Label>
<div class="flex gap-2">
<Input
id="pull-image"
bind:value={image}
placeholder="nginx:latest, ubuntu:22.04, postgres:16"
class="flex-1 h-10"
disabled={isPulling}
/>
<Button
onclick={startPull}
disabled={isPulling || !image.trim()}
class="h-10"
>
{#if isPulling}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Pulling...
{:else}
<Download class="w-4 h-4 mr-2" />
Pull
{/if}
</Button>
</div>
</div>
{/if}
<!-- Progress Section -->
{#if status !== 'idle'}
<div class="space-y-2 shrink-0">
<!-- Status -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if status === 'pulling'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Pulling layers...</span>
{:else if status === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
<span class="text-sm text-green-600">Pull completed!</span>
{:else if status === 'error'}
<XCircle class="w-4 h-4 text-red-600" />
<span class="text-sm text-red-600">Failed</span>
{/if}
</div>
<div class="flex items-center gap-3">
{#if status === 'pulling' || status === 'complete'}
<Badge variant="secondary" class="text-xs min-w-20 text-center">
{#if totalLayers > 0}
{completedLayers} / {totalLayers} layers
{:else}
...
{/if}
</Badge>
{/if}
<span class="text-xs text-muted-foreground min-w-12">
{#if duration > 0}{formatDuration(duration)}{/if}
</span>
</div>
</div>
<!-- Progress Bar and Download Stats -->
{#if status === 'pulling'}
<div class="space-y-2">
<Progress value={overallProgress} class="h-2" />
<div class="text-xs text-muted-foreground h-4">
{#if downloadStats.totalBytes > 0}
Downloaded: {formatBytes(downloadStats.downloadedBytes)} / {formatBytes(downloadStats.totalBytes)}
{/if}
</div>
</div>
{/if}
<!-- Error Message -->
{#if errorMessage}
<div class="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div class="flex items-start gap-2">
<AlertCircle class="w-4 h-4 text-destructive mt-0.5 shrink-0" />
<span class="text-sm text-destructive break-all">{errorMessage}</span>
</div>
</div>
{/if}
</div>
<!-- Layer Progress Grid -->
{#if status === 'pulling' || status === 'complete' || hasLayers}
<div class="shrink-0 border rounded-lg h-36 overflow-auto">
<table class="w-full text-xs">
<thead class="bg-muted sticky top-0">
<tr>
<th class="text-left py-1.5 px-3 font-medium w-28">Layer ID</th>
<th class="text-left py-1.5 px-3 font-medium">Status</th>
<th class="text-right py-1.5 px-3 font-medium w-24">Progress</th>
</tr>
</thead>
<tbody>
{#each sortedLayers as layer (layer.id)}
{@const percentage = getProgressPercentage(layer)}
{@const statusLower = layer.status.toLowerCase()}
{@const isComplete = statusLower.includes('complete') || statusLower.includes('already exists')}
{@const isDownloading = statusLower.includes('downloading')}
{@const isExtracting = statusLower.includes('extracting')}
<tr class="border-t border-muted hover:bg-muted/30 transition-colors">
<td class="py-1.5 px-3">
<code class="font-mono text-2xs">{layer.id.slice(0, 12)}</code>
</td>
<td class="py-1.5 px-3">
<div class="flex items-center gap-2">
{#if isComplete}
<CheckCircle2 class="w-3 h-3 text-green-500 shrink-0" />
{:else if isDownloading || isExtracting}
<Loader2 class="w-3 h-3 text-blue-500 animate-spin shrink-0" />
{:else}
<Loader2 class="w-3 h-3 text-muted-foreground animate-spin shrink-0" />
{/if}
<span class={isComplete ? 'text-green-600' : isDownloading ? 'text-blue-600' : isExtracting ? 'text-amber-600' : 'text-muted-foreground'}>
{layer.status}
</span>
</div>
</td>
<td class="py-1.5 px-3 text-right">
{#if (isDownloading || isExtracting) && layer.current && layer.total}
<div class="flex items-center gap-2 justify-end">
<div class="w-16 bg-muted rounded-full h-1.5">
<div
class="{isExtracting ? 'bg-amber-500' : 'bg-blue-500'} h-1.5 rounded-full transition-all duration-200"
style="width: {percentage}%"
></div>
</div>
<span class="text-muted-foreground w-8">{percentage}%</span>
</div>
{:else if isComplete}
<span class="text-green-600">Done</span>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Output Log -->
<div class="flex-1 min-h-0 flex flex-col">
<div class="flex items-center justify-between text-xs text-muted-foreground mb-2 shrink-0">
<div class="flex items-center gap-2">
<Terminal class="w-3.5 h-3.5" />
<span>Output ({outputLines.length} lines)</span>
</div>
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" title="Toggle log theme">
{#if logDarkMode}
<Sun class="w-3.5 h-3.5" />
{:else}
<Moon class="w-3.5 h-3.5" />
{/if}
</button>
</div>
<div
bind:this={outputContainer}
class="{logDarkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded-lg p-3 font-mono text-xs flex-1 min-h-0 overflow-auto"
>
{#each outputLines as line}
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
{#if line.startsWith('[pull]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-blue-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">pull</span>
<span>{line.slice(7)}</span>
{:else if line.startsWith('[layer]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-green-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">layer</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[error]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-red-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">error</span>
<span class="text-red-400">{line.slice(8)}</span>
{:else}
<span>{line}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Idle state -->
{#if status === 'idle' && !showImageInput}
<div class="flex-1 flex items-center justify-center text-muted-foreground">
<p class="text-sm">Enter an image name to start pulling</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,308 @@
<script lang="ts">
import { tick, onMount } from 'svelte';
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Upload } from 'lucide-svelte';
import { appendEnvParam } from '$lib/stores/environment';
type PushStatus = 'idle' | 'pushing' | 'complete' | 'error';
interface Props {
sourceImageName: string;
registryId: number;
newTag?: string;
registryName?: string;
envId?: number | null;
autoStart?: boolean;
onComplete?: (targetTag: string) => void;
onError?: (error: string) => void;
onStatusChange?: (status: PushStatus) => void;
}
let {
sourceImageName,
registryId,
newTag = '',
registryName = 'registry',
envId = null,
autoStart = false,
onComplete,
onError,
onStatusChange
}: Props = $props();
let status = $state<PushStatus>('idle');
let errorMessage = $state('');
let statusMessage = $state('');
let targetTag = $state('');
let outputLines = $state<string[]>([]);
let outputContainer: HTMLDivElement | undefined;
let logDarkMode = $state(true);
// Notify parent of status changes
$effect(() => {
onStatusChange?.(status);
});
onMount(() => {
const saved = localStorage.getItem('logTheme');
if (saved !== null) {
logDarkMode = saved === 'dark';
}
});
$effect(() => {
if (autoStart && sourceImageName && registryId && status === 'idle') {
startPush();
}
});
function toggleLogTheme() {
logDarkMode = !logDarkMode;
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
}
async function scrollOutputToBottom() {
await tick();
if (outputContainer) {
outputContainer.scrollTop = outputContainer.scrollHeight;
}
}
function addOutputLine(line: string) {
outputLines = [...outputLines, line];
scrollOutputToBottom();
}
export function reset() {
status = 'idle';
errorMessage = '';
statusMessage = '';
targetTag = '';
outputLines = [];
}
export function getStatus() {
return status;
}
export async function startPush() {
if (!sourceImageName || !registryId) return;
reset();
status = 'pushing';
statusMessage = 'Finding image...';
try {
// Small delay to ensure image is indexed
await new Promise(resolve => setTimeout(resolve, 500));
// Get the image ID from the pulled image
const imagesResponse = await fetch(appendEnvParam('/api/images', envId));
const images = await imagesResponse.json();
const searchName = sourceImageName.includes(':') ? sourceImageName : `${sourceImageName}:latest`;
const searchNameNoTag = sourceImageName.split(':')[0];
const pulledImage = images.find((img: any) => {
if (!img.tags || img.tags.length === 0) return false;
return img.tags.some((t: string) => {
if (t === searchName || t === sourceImageName) return true;
if (t === `${searchNameNoTag}:latest`) return true;
if (t === `library/${searchName}` || t === `library/${searchNameNoTag}:latest`) return true;
if (t.startsWith(searchNameNoTag + ':')) return true;
return false;
});
});
if (!pulledImage) {
console.log('Looking for:', sourceImageName, 'Available tags:', images.map((i: any) => i.tags).flat());
errorMessage = 'Could not find image to push';
status = 'error';
onError?.(errorMessage);
return;
}
addOutputLine(`[push] Starting push to ${registryName}...`);
// Push to target registry with streaming
const pushResponse = await fetch(appendEnvParam('/api/images/push', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageId: pulledImage.id,
imageName: sourceImageName,
registryId: registryId,
newTag: newTag || null
})
});
if (!pushResponse.ok) {
const data = await pushResponse.json();
errorMessage = data.error || 'Failed to push image';
status = 'error';
addOutputLine(`[error] ${errorMessage}`);
onError?.(errorMessage);
return;
}
// Handle SSE stream
const reader = pushResponse.body?.getReader();
if (!reader) {
errorMessage = 'No response body';
status = 'error';
onError?.(errorMessage);
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handlePushProgress(data);
} catch (e) {
// Ignore parse errors
}
}
}
}
// If stream ended without complete/error status
if (status === 'pushing') {
status = 'complete';
statusMessage = 'Image pushed successfully!';
addOutputLine(`[push] Push complete!`);
onComplete?.(targetTag);
}
} catch (error: any) {
console.error('Failed to push image:', error);
errorMessage = error.message || 'Failed to push image';
status = 'error';
addOutputLine(`[error] ${errorMessage}`);
onError?.(errorMessage);
}
}
function handlePushProgress(data: any) {
if (data.targetTag) {
targetTag = data.targetTag;
}
if (data.status === 'tagging') {
addOutputLine(`[push] Tagging image for target registry...`);
} else if (data.status === 'pushing') {
addOutputLine(`[push] Pushing layers...`);
} else if (data.status === 'complete') {
statusMessage = data.message || 'Image pushed successfully!';
status = 'complete';
addOutputLine(`[push] ${data.message || 'Push complete!'}`);
onComplete?.(targetTag || data.targetTag || '');
} else if (data.status === 'error' || data.error) {
errorMessage = data.error || 'Push failed';
status = 'error';
addOutputLine(`[error] ${data.error}`);
onError?.(errorMessage);
} else if (data.id && data.status) {
// Layer progress
const progress = data.progress ? ` ${data.progress}` : '';
addOutputLine(`[layer ${data.id.substring(0, 12)}] ${data.status}${progress}`);
} else if (data.message) {
// Generic message (not part of above statuses)
statusMessage = data.message;
addOutputLine(`[push] ${data.message}`);
}
}
const isPushing = $derived(status === 'pushing');
</script>
<div class="flex flex-col gap-4 flex-1 min-h-0">
<!-- Status Section -->
{#if status !== 'idle'}
<div class="space-y-2 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if status === 'pushing'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">{statusMessage}</span>
{:else if status === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
<span class="text-sm text-green-600">Push complete!</span>
{:else if status === 'error'}
<XCircle class="w-4 h-4 text-red-600" />
<span class="text-sm text-red-600">Push failed</span>
{/if}
</div>
{#if status === 'complete' && targetTag}
<code class="text-xs bg-muted px-2 py-1 rounded">{targetTag}</code>
{/if}
</div>
{#if errorMessage}
<div class="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div class="flex items-start gap-2">
<AlertCircle class="w-4 h-4 text-destructive mt-0.5 shrink-0" />
<span class="text-sm text-destructive break-all">{errorMessage}</span>
</div>
</div>
{/if}
</div>
<!-- Output Log -->
{#if outputLines.length > 0 || status === 'pushing'}
<div class="flex-1 min-h-0 flex flex-col">
<div class="flex items-center justify-between text-xs text-muted-foreground mb-2 shrink-0">
<div class="flex items-center gap-2">
<Terminal class="w-3.5 h-3.5" />
<span>Output ({outputLines.length} lines)</span>
</div>
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" title="Toggle log theme">
{#if logDarkMode}
<Sun class="w-3.5 h-3.5" />
{:else}
<Moon class="w-3.5 h-3.5" />
{/if}
</button>
</div>
<div
bind:this={outputContainer}
class="{logDarkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded-lg p-3 font-mono text-xs flex-1 min-h-0 overflow-auto"
>
{#each outputLines as line}
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
{#if line.startsWith('[push]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-blue-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">push</span>
<span>{line.slice(7)}</span>
{:else if line.startsWith('[layer')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">layer</span>
<span>{line.slice(line.indexOf(']') + 2)}</span>
{:else if line.startsWith('[error]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-red-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">error</span>
<span class="text-red-400">{line.slice(8)}</span>
{:else}
<span>{line}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{/if}
<!-- Idle state -->
{#if status === 'idle'}
<div class="flex-1 flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Upload class="w-12 h-12 opacity-50" />
<p class="text-sm">Ready to push to <code class="bg-muted px-1.5 py-0.5 rounded">{registryName}</code></p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,390 @@
<script lang="ts">
import { tick } from 'svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Loader2, AlertCircle, Terminal, Sun, Moon, ShieldCheck, ShieldAlert, ShieldX, Shield } from 'lucide-svelte';
import { onMount } from 'svelte';
import { appendEnvParam } from '$lib/stores/environment';
import ScanResultsView from '../../routes/images/ScanResultsView.svelte';
export interface ScanResult {
scanner: 'grype' | 'trivy';
imageId?: string;
imageName?: string;
scanDuration?: number;
summary: {
critical: number;
high: number;
medium: number;
low: number;
negligible: number;
unknown: number;
};
vulnerabilities: Array<{
id: string;
severity: string;
package: string;
version: string;
fixedVersion?: string;
description?: string;
link?: string;
}>;
}
type ScanStatus = 'idle' | 'scanning' | 'complete' | 'error';
interface Props {
imageName: string;
envId?: number | null;
autoStart?: boolean;
onComplete?: (results: ScanResult[]) => void;
onError?: (error: string) => void;
onStatusChange?: (status: ScanStatus) => void;
}
let {
imageName,
envId = null,
autoStart = false,
onComplete,
onError,
onStatusChange
}: Props = $props();
let status = $state<ScanStatus>('idle');
let results = $state<ScanResult[]>([]);
let duration = $state(0);
// Notify parent of status changes
$effect(() => {
onStatusChange?.(status);
});
let errorMessage = $state('');
let scanMessage = $state('');
let outputLines = $state<string[]>([]);
let outputContainer: HTMLDivElement | undefined;
let logDarkMode = $state(true);
let startTime = $state(0);
let activeTab = $state<'output' | 'results'>('output');
let hasStarted = $state(false);
onMount(() => {
const saved = localStorage.getItem('logTheme');
if (saved !== null) {
logDarkMode = saved === 'dark';
}
});
$effect(() => {
if (autoStart && imageName && !hasStarted && status === 'idle') {
hasStarted = true;
startScan();
}
});
function toggleLogTheme() {
logDarkMode = !logDarkMode;
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
async function scrollOutputToBottom() {
await tick();
if (outputContainer) {
outputContainer.scrollTop = outputContainer.scrollHeight;
}
}
function addOutputLine(line: string) {
outputLines = [...outputLines, line];
scrollOutputToBottom();
}
export function reset() {
status = 'idle';
results = [];
errorMessage = '';
scanMessage = '';
outputLines = [];
duration = 0;
activeTab = 'output';
hasStarted = false;
}
export function getResults(): ScanResult[] {
return results;
}
export function getStatus(): ScanStatus {
return status;
}
export async function startScan() {
if (!imageName) return;
status = 'scanning';
errorMessage = '';
scanMessage = 'Starting vulnerability scan...';
outputLines = [];
startTime = Date.now();
results = [];
addOutputLine(`[dockhand] Starting vulnerability scan for ${imageName}`);
try {
const url = appendEnvParam('/api/images/scan', envId);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageName })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handleScanProgress(data);
} catch (e) {
// Ignore parse errors
}
}
}
}
// If stream ended without complete status
if (status === 'scanning') {
duration = Date.now() - startTime;
status = results.length > 0 ? 'complete' : 'error';
if (status === 'complete') {
activeTab = 'results';
onComplete?.(results);
}
}
} catch (err) {
errorMessage = err instanceof Error ? err.message : String(err);
status = 'error';
addOutputLine(`[error] ${errorMessage}`);
onError?.(errorMessage);
}
}
function handleScanProgress(data: any) {
if (data.message) {
scanMessage = data.message;
const scanner = data.scanner || 'dockhand';
addOutputLine(`[${scanner}] ${data.message}`);
}
if (data.output) {
const scanner = data.scanner || 'dockhand';
addOutputLine(`[${scanner}] ${data.output}`);
}
if (data.stage === 'complete' || data.status === 'complete') {
duration = Date.now() - startTime;
status = 'complete';
if (data.results) {
results = data.results;
} else if (data.result) {
results = [data.result];
}
activeTab = 'results';
onComplete?.(results);
}
if (data.stage === 'error' || data.status === 'error') {
if (!data.scanner) {
// Global error
duration = Date.now() - startTime;
status = 'error';
errorMessage = data.error || data.message || 'Scan failed';
onError?.(errorMessage);
}
}
}
const totalVulnerabilities = $derived(
results.reduce((total, r) => total + r.vulnerabilities.length, 0)
);
const hasCriticalOrHigh = $derived(
results.some(r => r.summary.critical > 0 || r.summary.high > 0)
);
const isScanning = $derived(status === 'scanning');
</script>
<div class="flex flex-col gap-3 flex-1 min-h-0">
<!-- Status Section -->
<div class="space-y-2 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if status === 'idle'}
<Shield class="w-4 h-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">Ready to scan</span>
{:else if status === 'scanning'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Scanning for vulnerabilities...</span>
{:else if status === 'complete'}
{#if hasCriticalOrHigh}
<ShieldX class="w-4 h-4 text-red-500" />
<span class="text-sm text-red-500">Vulnerabilities found</span>
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-4 h-4 text-yellow-500" />
<span class="text-sm text-yellow-500">Some vulnerabilities found</span>
{:else}
<ShieldCheck class="w-4 h-4 text-green-600" />
<span class="text-sm text-green-600">No vulnerabilities!</span>
{/if}
{:else if status === 'error'}
<ShieldX class="w-4 h-4 text-red-600" />
<span class="text-sm text-red-600">Scan failed</span>
{/if}
</div>
<div class="flex items-center gap-3">
{#if status === 'complete' && results.length > 0}
<Badge variant={hasCriticalOrHigh ? 'destructive' : totalVulnerabilities > 0 ? 'secondary' : 'outline'} class="text-xs">
{totalVulnerabilities} vulnerabilities
</Badge>
{/if}
<span class="text-xs text-muted-foreground min-w-12">
{#if duration > 0}{formatDuration(duration)}{/if}
</span>
</div>
</div>
<!-- Scan Message -->
{#if scanMessage && status === 'scanning'}
<p class="text-xs text-muted-foreground">{scanMessage}</p>
{/if}
<!-- Error Message -->
{#if errorMessage}
<div class="p-3 rounded-lg bg-destructive/10 border border-destructive/30">
<div class="flex items-start gap-2">
<AlertCircle class="w-4 h-4 text-destructive mt-0.5 shrink-0" />
<span class="text-sm text-destructive break-all">{errorMessage}</span>
</div>
</div>
{/if}
</div>
<!-- Idle state with scan button -->
{#if status === 'idle'}
<div class="flex-1 flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Shield class="w-12 h-12 opacity-50" />
<p class="text-sm">Scan <code class="bg-muted px-1.5 py-0.5 rounded">{imageName}</code> for vulnerabilities</p>
<Button onclick={startScan}>
<Shield class="w-4 h-4 mr-2" />
Start scan
</Button>
</div>
{/if}
<!-- Scanning/Complete state -->
{#if status !== 'idle'}
<!-- Tabs for Output/Results -->
{#if results.length > 0}
<div class="flex gap-1 border-b shrink-0">
<button
class="px-3 py-1.5 text-xs font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'output' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'output'}
>
<Terminal class="w-3 h-3 inline mr-1" />
Output
</button>
<button
class="px-3 py-1.5 text-xs font-medium border-b-2 transition-colors cursor-pointer {activeTab === 'results' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'results'}
>
{#if hasCriticalOrHigh}
<ShieldX class="w-3 h-3 inline mr-1 text-red-500" />
{:else if totalVulnerabilities > 0}
<ShieldAlert class="w-3 h-3 inline mr-1 text-yellow-500" />
{:else}
<ShieldCheck class="w-3 h-3 inline mr-1 text-green-500" />
{/if}
Scan results
<Badge variant={hasCriticalOrHigh ? 'destructive' : 'secondary'} class="ml-1 text-2xs py-0">
{totalVulnerabilities}
</Badge>
</button>
</div>
{/if}
<!-- Tab Content -->
<div class="flex-1 min-h-0 flex flex-col">
{#if activeTab === 'output' || results.length === 0}
<!-- Output Log -->
<div class="flex items-center justify-between text-xs text-muted-foreground mb-2 shrink-0">
<div class="flex items-center gap-2">
<Terminal class="w-3.5 h-3.5" />
<span>Output ({outputLines.length} lines)</span>
</div>
<button type="button" onclick={toggleLogTheme} class="p-1 rounded hover:bg-muted transition-colors cursor-pointer" title="Toggle log theme">
{#if logDarkMode}
<Sun class="w-3.5 h-3.5" />
{:else}
<Moon class="w-3.5 h-3.5" />
{/if}
</button>
</div>
<div
bind:this={outputContainer}
class="{logDarkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded-lg p-3 font-mono text-xs flex-1 min-h-0 overflow-auto"
>
{#each outputLines as line}
<div class="whitespace-pre-wrap break-all leading-relaxed flex items-start gap-1.5">
{#if line.startsWith('[grype]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">grype</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[trivy]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-teal-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">trivy</span>
<span>{line.slice(8)}</span>
{:else if line.startsWith('[dockhand]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-slate-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">dockhand</span>
<span>{line.slice(11)}</span>
{:else if line.startsWith('[scan]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-violet-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">scan</span>
<span>{line.slice(7)}</span>
{:else if line.startsWith('[error]')}
<span class="inline-flex items-center px-1 rounded text-[8px] font-medium bg-red-500 text-white shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]">error</span>
<span class="text-red-400">{line.slice(8)}</span>
{:else}
<span>{line}</span>
{/if}
</div>
{/each}
</div>
{:else}
<!-- Scan Results -->
<div class="flex-1 min-h-0 overflow-auto">
<ScanResultsView {results} />
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
interface ScannerResult {
scanner: 'grype' | 'trivy';
critical: number;
high: number;
medium: number;
low: number;
negligible?: number;
unknown?: number;
}
interface Props {
results: ScannerResult[];
}
let { results }: Props = $props();
</script>
<div class="flex items-center gap-3 shrink-0">
{#each results as result}
<div class="flex items-center gap-1">
<span class="text-2xs text-muted-foreground">{result.scanner === 'grype' ? 'Grype' : 'Trivy'}:</span>
{#if (result.critical || 0) > 0}
<Badge variant="outline" class="px-1 py-0 text-2xs bg-red-500/10 text-red-600 border-red-500/30" title="Critical">{result.critical}</Badge>
{/if}
{#if (result.high || 0) > 0}
<Badge variant="outline" class="px-1 py-0 text-2xs bg-orange-500/10 text-orange-600 border-orange-500/30" title="High">{result.high}</Badge>
{/if}
{#if (result.medium || 0) > 0}
<Badge variant="outline" class="px-1 py-0 text-2xs bg-yellow-500/10 text-yellow-600 border-yellow-500/30" title="Medium">{result.medium}</Badge>
{/if}
{#if (result.low || 0) > 0}
<Badge variant="outline" class="px-1 py-0 text-2xs bg-blue-500/10 text-blue-600 border-blue-500/30" title="Low">{result.low}</Badge>
{/if}
</div>
{/each}
</div>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { page } from '$app/stores';
let currentPath = $derived($page.url.pathname);
function isActive(path: string): boolean {
return currentPath === path;
}
</script>
<nav class="flex-1 overflow-y-auto py-2">
<ul class="space-y-0.5 px-2">
<li>
<a
href="/"
class="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-all duration-150 {isActive('/')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/50 dark:hover:text-gray-200'}"
>
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"
fill="currentColor"
/>
</svg>
<span>Dashboard</span>
</a>
</li>
<li>
<a
href="/containers"
class="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-all duration-150 {isActive(
'/containers'
)
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/50 dark:hover:text-gray-200'}"
>
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="7" rx="1" fill="currentColor" />
<rect x="3" y="13" width="7" height="7" rx="1" fill="currentColor" />
<rect x="13" y="3" width="7" height="7" rx="1" fill="currentColor" />
<rect x="13" y="13" width="7" height="7" rx="1" fill="currentColor" />
</svg>
<span>Containers</span>
</a>
</li>
<li>
<a
href="/stacks"
class="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-all duration-150 {isActive('/stacks')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/50 dark:hover:text-gray-200'}"
>
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z"
fill="currentColor"
opacity="0.3"
/>
<path
d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 2.18l7.5 3.75v8.32c0 4.35-3 8.44-7.5 9.57-4.5-1.13-7.5-5.22-7.5-9.57V7.93L12 4.18z"
fill="currentColor"
/>
<path d="M7 12l3 3 6-6" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
<span>Compose stacks</span>
</a>
</li>
<li>
<a
href="/images"
class="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-all duration-150 {isActive('/images')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/50 dark:hover:text-gray-200'}"
>
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" fill="none" />
<circle cx="12" cy="12" r="5" fill="currentColor" opacity="0.3" />
<path
d="M12 3v2m0 14v2M3 12h2m14 0h2m-3.05-7.05l1.42-1.42M5.63 18.37l1.42-1.42m11.9 0l1.42 1.42M5.63 5.63l1.42 1.42"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Images</span>
</a>
</li>
<li>
<a
href="/logs"
class="flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-all duration-150 {isActive('/logs')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/50 dark:hover:text-gray-200'}"
>
<svg class="w-4 h-4 flex-shrink-0" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="4" width="18" height="2" rx="1" fill="currentColor" opacity="0.5" />
<rect x="3" y="8" width="18" height="2" rx="1" fill="currentColor" />
<rect x="3" y="12" width="14" height="2" rx="1" fill="currentColor" opacity="0.5" />
<rect x="3" y="16" width="16" height="2" rx="1" fill="currentColor" />
</svg>
<span>Logs</span>
</a>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,234 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot } from 'lucide-svelte';
export interface EnvVar {
key: string;
value: string;
isSecret: boolean;
}
export interface ValidationResult {
valid: boolean;
required: string[];
optional: string[];
defined: string[];
missing: string[];
unused: string[];
}
interface Props {
variables: EnvVar[];
validation?: ValidationResult | null;
readonly?: boolean;
showSource?: boolean; // For git stacks - show where variable comes from
sources?: Record<string, 'file' | 'override'>; // Key -> source mapping
placeholder?: { key: string; value: string };
existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility)
}
let {
variables = $bindable(),
validation = null,
readonly = false,
showSource = false,
sources = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
existingSecretKeys = new Set<string>()
}: Props = $props();
// Check if a variable is an existing secret that was loaded from DB
function isExistingSecret(key: string, isSecret: boolean): boolean {
return isSecret && existingSecretKeys.has(key);
}
function addVariable() {
variables = [...variables, { key: '', value: '', isSecret: false }];
}
function removeVariable(index: number) {
variables = variables.filter((_, i) => i !== index);
}
function toggleSecret(index: number) {
variables[index].isSecret = !variables[index].isSecret;
}
// Check if a variable key is missing (required but not defined)
function isMissing(key: string): boolean {
return validation?.missing?.includes(key) ?? false;
}
// Check if a variable key is unused (defined but not in compose)
function isUnused(key: string): boolean {
return validation?.unused?.includes(key) ?? false;
}
// Check if a variable key is required
function isRequired(key: string): boolean {
return validation?.required?.includes(key) ?? false;
}
// Check if a variable key is optional
function isOptional(key: string): boolean {
return validation?.optional?.includes(key) ?? false;
}
// Get validation status class for key input
function getKeyValidationClass(key: string): string {
if (!key || !validation) return '';
if (isMissing(key)) return 'border-red-500 dark:border-red-400';
if (isUnused(key)) return 'border-amber-500 dark:border-amber-400';
if (isRequired(key) || isOptional(key)) return 'border-green-500 dark:border-green-400';
return '';
}
// Get source icon for a variable
function getSource(key: string): 'file' | 'override' | null {
if (!showSource || !sources) return null;
return sources[key] || null;
}
// Count non-empty variables
const variableCount = $derived(variables.filter(v => v.key).length);
const secretCount = $derived(variables.filter(v => v.key && v.isSecret).length);
</script>
<div class="space-y-3">
<!-- Variables List -->
<div class="space-y-3">
{#each variables as variable, index}
{@const source = getSource(variable.key)}
{@const isVarRequired = isRequired(variable.key)}
{@const isVarOptional = isOptional(variable.key)}
{@const isVarMissing = isMissing(variable.key)}
{@const isVarUnused = isUnused(variable.key)}
<div class="flex gap-2 items-center">
<!-- Source indicator (for git stacks) - always reserve space if showSource -->
{#if showSource}
<div class="flex items-center h-9 w-5 justify-center shrink-0">
{#if source === 'file'}
<Tooltip.Root>
<Tooltip.Trigger>
<FileText class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content><p>From .env file</p></Tooltip.Content>
</Tooltip.Root>
{:else if source === 'override'}
<Tooltip.Root>
<Tooltip.Trigger>
<Pencil class="w-3.5 h-3.5 text-blue-500" />
</Tooltip.Trigger>
<Tooltip.Content><p>Manual override</p></Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
<!-- Validation status indicator - always reserve space if validation exists -->
{#if validation}
<div class="flex items-center h-9 w-5 justify-center shrink-0">
{#if variable.key}
{#if isVarRequired && !isVarMissing}
<Tooltip.Root>
<Tooltip.Trigger>
<CheckCircle2 class="w-4 h-4 text-green-500" />
</Tooltip.Trigger>
<Tooltip.Content><p>Required variable defined</p></Tooltip.Content>
</Tooltip.Root>
{:else if isVarOptional}
<Tooltip.Root>
<Tooltip.Trigger>
<CircleDot class="w-4 h-4 text-blue-400" />
</Tooltip.Trigger>
<Tooltip.Content><p>Optional variable (has default)</p></Tooltip.Content>
</Tooltip.Root>
{:else if isVarUnused}
<Tooltip.Root>
<Tooltip.Trigger>
<AlertCircle class="w-4 h-4 text-amber-500" />
</Tooltip.Trigger>
<Tooltip.Content><p>Unused variable</p></Tooltip.Content>
</Tooltip.Root>
{/if}
{/if}
</div>
{/if}
<!-- Key Input with floating label -->
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Name</span>
<Input
bind:value={variable.key}
disabled={readonly}
class="h-9 font-mono text-xs"
/>
</div>
<!-- Value Input with floating label -->
<div class="flex-1 relative">
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Value</span>
<Input
bind:value={variable.value}
type={variable.isSecret ? 'password' : 'text'}
disabled={readonly}
class="h-9 font-mono text-xs"
/>
</div>
<!-- Secret Toggle Button -->
{#if !readonly}
{@const existingSecret = isExistingSecret(variable.key, variable.isSecret)}
{#if existingSecret}
<!-- Existing secret from DB - show locked icon, no toggle (value can still be modified) -->
<div class="flex items-center h-9 w-9 justify-center shrink-0" title="Secret value (cannot unhide)">
<Key class="w-3.5 h-3.5 text-amber-500" />
</div>
{:else}
<!-- New or non-secret variable - show toggle button -->
<button
type="button"
onclick={() => toggleSecret(index)}
title={variable.isSecret ? 'Marked as secret' : 'Mark as secret'}
class="h-9 w-9 flex items-center justify-center rounded-md shrink-0 transition-colors {variable.isSecret ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}"
>
<Key class="w-3.5 h-3.5" />
</button>
{/if}
{:else if variable.isSecret}
<div class="flex items-center h-9 w-9 justify-center shrink-0">
<Key class="w-3.5 h-3.5 text-amber-500" />
</div>
{/if}
<!-- Remove Button -->
{#if !readonly}
<Button
type="button"
variant="ghost"
size="icon"
onclick={() => removeVariable(index)}
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
{/if}
</div>
{/each}
<!-- Empty state -->
{#if variables.length === 0}
<div class="text-center py-6 text-muted-foreground">
<p class="text-sm">No environment variables defined.</p>
{#if !readonly}
<Button type="button" variant="link" onclick={addVariable} class="mt-1 text-xs">
<Plus class="w-3 h-3 mr-1" />
Add your first variable
</Button>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,236 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { Plus, Info, Upload, Trash2 } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
variables: EnvVar[];
validation?: ValidationResult | null;
readonly?: boolean;
showSource?: boolean;
sources?: Record<string, 'file' | 'override'>;
placeholder?: { key: string; value: string };
infoText?: string;
existingSecretKeys?: Set<string>;
class?: string;
onchange?: () => void;
}
let {
variables = $bindable(),
validation = null,
readonly = false,
showSource = false,
sources = {},
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
infoText,
existingSecretKeys = new Set<string>(),
class: className = '',
onchange
}: Props = $props();
let fileInputRef: HTMLInputElement;
function addEnvVariable() {
variables = [...variables, { key: '', value: '', isSecret: false }];
}
function handleLoadFromFile() {
fileInputRef?.click();
}
function parseEnvFile(content: string): EnvVar[] {
const lines = content.split('\n');
const envVars: EnvVar[] = [];
for (const line of lines) {
// Skip empty lines and comments
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Parse KEY=VALUE format
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1).trim();
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) {
envVars.push({ key, value, isSecret: false });
}
}
return envVars;
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const parsedVars = parseEnvFile(content);
if (parsedVars.length > 0) {
// Get existing keys to avoid duplicates
const existingKeys = new Set(variables.filter(v => v.key.trim()).map(v => v.key.trim()));
// Filter empty entries from current variables
const nonEmptyVars = variables.filter(v => v.key.trim());
// Add new variables, updating existing ones or appending new
for (const newVar of parsedVars) {
if (existingKeys.has(newVar.key)) {
// Update existing variable
const idx = nonEmptyVars.findIndex(v => v.key.trim() === newVar.key);
if (idx !== -1) {
nonEmptyVars[idx] = { ...nonEmptyVars[idx], value: newVar.value };
}
} else {
// Add new variable
nonEmptyVars.push(newVar);
existingKeys.add(newVar.key);
}
}
variables = nonEmptyVars;
// Notify parent of change (important for async file load)
onchange?.();
}
};
reader.readAsText(file);
// Reset input so the same file can be selected again
input.value = '';
}
function clearAllVariables() {
variables = [];
}
// Count of non-empty variables
const hasVariables = $derived(variables.some(v => v.key.trim()));
</script>
<div class="flex flex-col h-full {className}">
<!-- Header -->
<div class="px-4 py-2.5 border-b border-zinc-200 dark:border-zinc-700 flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-zinc-500 dark:text-zinc-400">Environment variables</span>
{#if infoText}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="w-3.5 h-3.5 text-blue-400" />
</Tooltip.Trigger>
<Tooltip.Content class="max-w-md">
<p class="text-xs">{infoText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{#if !readonly}
<div class="flex items-center gap-1">
<Button type="button" size="sm" variant="ghost" onclick={handleLoadFromFile} class="h-6 text-xs px-2">
<Upload class="w-3.5 h-3.5 mr-1" />
Load .env
</Button>
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
<Plus class="w-3.5 h-3.5 mr-1" />
Add
</Button>
{#if hasVariables}
<ConfirmPopover
title="Clear all variables"
description="This will remove all environment variables. This cannot be undone."
confirmText="Clear all"
onConfirm={clearAllVariables}
>
<Button type="button" size="sm" variant="ghost" class="h-6 text-xs px-2 text-destructive hover:text-destructive">
<Trash2 class="w-3.5 h-3.5 mr-1" />
Clear
</Button>
</ConfirmPopover>
{/if}
</div>
<input
bind:this={fileInputRef}
type="file"
accept=".env,.env.*,text/plain"
class="hidden"
onchange={handleFileSelect}
/>
{/if}
</div>
<!-- Variable syntax help -->
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-zinc-400 dark:text-zinc-500 font-mono">
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR}`}</span> required</span>
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
</div>
<!-- Validation status pills -->
{#if validation}
<div class="flex flex-wrap gap-1">
{#if validation.missing.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
{validation.missing.length} missing
</span>
{/if}
{#if validation.required.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
{validation.required.length - validation.missing.length} required
</span>
{/if}
{#if validation.optional.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
{validation.optional.length} optional
</span>
{/if}
{#if validation.unused.length > 0}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{validation.unused.length} unused
</span>
{/if}
</div>
{/if}
<!-- Add missing variables -->
{#if validation && validation.missing.length > 0 && !readonly}
<div class="flex flex-wrap gap-1 items-center">
<span class="text-xs text-muted-foreground mr-1">Add missing:</span>
{#each validation.missing as missing}
<button
type="button"
onclick={() => {
variables = [...variables, { key: missing, value: '', isSecret: false }];
}}
class="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 transition-colors"
>
{missing}
</button>
{/each}
</div>
{/if}
</div>
<!-- Variables list -->
<div class="flex-1 overflow-auto px-4 py-3">
<StackEnvVarsEditor
bind:variables
{validation}
{readonly}
{showSource}
{sources}
{placeholder}
{existingSecretKeys}
/>
</div>
</div>

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import { Sun, Moon, Type, AArrowUp, Table, Terminal } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes';
import { themeStore, applyTheme, type FontSize } from '$lib/stores/theme';
// Font size options
const fontSizes: { id: FontSize; name: string }[] = [
{ id: 'xsmall', name: 'Extra Small' },
{ id: 'small', name: 'Small' },
{ id: 'normal', name: 'Normal' },
{ id: 'medium', name: 'Medium' },
{ id: 'large', name: 'Large' },
{ id: 'xlarge', name: 'Extra Large' }
];
interface Props {
userId?: number; // Pass userId for per-user settings, undefined for global
}
let { userId }: Props = $props();
// Local state bound to selects
let selectedLightTheme = $state($themeStore.lightTheme);
let selectedDarkTheme = $state($themeStore.darkTheme);
let selectedFont = $state($themeStore.font);
let selectedFontSize = $state($themeStore.fontSize);
let selectedGridFontSize = $state($themeStore.gridFontSize);
let selectedTerminalFont = $state($themeStore.terminalFont);
// Sync local state with store changes
$effect(() => {
selectedLightTheme = $themeStore.lightTheme;
selectedDarkTheme = $themeStore.darkTheme;
selectedFont = $themeStore.font;
selectedFontSize = $themeStore.fontSize;
selectedGridFontSize = $themeStore.gridFontSize;
selectedTerminalFont = $themeStore.terminalFont;
});
async function handleLightThemeChange(value: string | undefined) {
if (!value) return;
selectedLightTheme = value;
await themeStore.setPreference('lightTheme', value, userId);
}
async function handleDarkThemeChange(value: string | undefined) {
if (!value) return;
selectedDarkTheme = value;
await themeStore.setPreference('darkTheme', value, userId);
}
async function handleFontChange(value: string | undefined) {
if (!value) return;
selectedFont = value;
await themeStore.setPreference('font', value, userId);
}
async function handleFontSizeChange(value: string | undefined) {
if (!value) return;
selectedFontSize = value as FontSize;
await themeStore.setPreference('fontSize', value as FontSize, userId);
}
async function handleGridFontSizeChange(value: string | undefined) {
if (!value) return;
selectedGridFontSize = value as FontSize;
await themeStore.setPreference('gridFontSize', value as FontSize, userId);
}
async function handleTerminalFontChange(value: string | undefined) {
if (!value) return;
selectedTerminalFont = value;
await themeStore.setPreference('terminalFont', value, userId);
}
</script>
<div class="space-y-4">
<!-- Light Theme -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Sun class="w-4 h-4 text-muted-foreground" />
<Label>Light theme</Label>
</div>
<Select.Root type="single" value={selectedLightTheme} onValueChange={handleLightThemeChange}>
<Select.Trigger class="w-56">
<div class="flex items-center gap-2">
{#each lightThemes as theme}
{#if theme.id === selectedLightTheme}
<span
class="w-3 h-3 rounded-full border border-border"
style="background-color: {theme.preview}"
></span>
<span>{theme.name}</span>
{/if}
{/each}
</div>
</Select.Trigger>
<Select.Content>
{#each lightThemes as theme}
<Select.Item value={theme.id}>
<div class="flex items-center gap-2">
<span
class="w-3 h-3 rounded-full border border-border"
style="background-color: {theme.preview}"
></span>
<span>{theme.name}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Dark Theme -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Moon class="w-4 h-4 text-muted-foreground" />
<Label>Dark theme</Label>
</div>
<Select.Root type="single" value={selectedDarkTheme} onValueChange={handleDarkThemeChange}>
<Select.Trigger class="w-56">
<div class="flex items-center gap-2">
{#each darkThemes as theme}
{#if theme.id === selectedDarkTheme}
<span
class="w-3 h-3 rounded-full border border-border"
style="background-color: {theme.preview}"
></span>
<span>{theme.name}</span>
{/if}
{/each}
</div>
</Select.Trigger>
<Select.Content>
{#each darkThemes as theme}
<Select.Item value={theme.id}>
<div class="flex items-center gap-2">
<span
class="w-3 h-3 rounded-full border border-border"
style="background-color: {theme.preview}"
></span>
<span>{theme.name}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Font -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Type class="w-4 h-4 text-muted-foreground" />
<Label>Font</Label>
</div>
<Select.Root type="single" value={selectedFont} onValueChange={handleFontChange}>
<Select.Trigger class="w-56">
{#each fonts as font}
{#if font.id === selectedFont}
<span>{font.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each fonts as font}
<Select.Item value={font.id}>
<span style="font-family: {font.family}">{font.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Font Size -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<AArrowUp class="w-4 h-4 text-muted-foreground" />
<Label>Font size</Label>
</div>
<Select.Root type="single" value={selectedFontSize} onValueChange={handleFontSizeChange}>
<Select.Trigger class="w-56">
{#each fontSizes as size}
{#if size.id === selectedFontSize}
<span>{size.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each fontSizes as size}
<Select.Item value={size.id}>
<span>{size.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Grid Font Size -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Table class="w-4 h-4 text-muted-foreground" />
<Label>Grid font size</Label>
</div>
<Select.Root type="single" value={selectedGridFontSize} onValueChange={handleGridFontSizeChange}>
<Select.Trigger class="w-56">
{#each fontSizes as size}
{#if size.id === selectedGridFontSize}
<span>{size.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each fontSizes as size}
<Select.Item value={size.id}>
<span>{size.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Terminal Font -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Terminal class="w-4 h-4 text-muted-foreground" />
<Label>Terminal font</Label>
</div>
<Select.Root type="single" value={selectedTerminalFont} onValueChange={handleTerminalFontChange}>
<Select.Trigger class="w-56">
{#each monospaceFonts as font}
{#if font.id === selectedTerminalFont}
<span style="font-family: {font.family}">{font.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each monospaceFonts as font}
<Select.Item value={font.id}>
<span style="font-family: {font.family}">{font.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { ChevronsUpDown, Check, Globe } from 'lucide-svelte';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
interface Props {
value: string;
onchange?: (value: string) => void;
id?: string;
class?: string;
placeholder?: string;
}
let {
value = $bindable('UTC'),
onchange,
id,
class: className,
placeholder = 'Select timezone...'
}: Props = $props();
let open = $state(false);
let searchQuery = $state('');
// Common timezones to show at the top
const commonTimezones = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Warsaw',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Singapore',
'Australia/Sydney'
];
// Get all timezones
const allTimezones = Intl.supportedValuesOf('timeZone');
// Other timezones (excluding common ones)
const otherTimezones = allTimezones.filter((tz) => !commonTimezones.includes(tz));
// Filter based on search query
const filteredCommon = $derived(
searchQuery
? commonTimezones.filter((tz) => tz.toLowerCase().includes(searchQuery.toLowerCase()))
: commonTimezones
);
const filteredOther = $derived(
searchQuery
? otherTimezones.filter((tz) => tz.toLowerCase().includes(searchQuery.toLowerCase()))
: otherTimezones
);
function selectTimezone(tz: string) {
value = tz;
open = false;
searchQuery = '';
onchange?.(tz);
}
// Format timezone for display (show offset if available)
function formatTimezone(tz: string): string {
try {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
timeZoneName: 'shortOffset'
});
const parts = formatter.formatToParts(now);
const offsetPart = parts.find((p) => p.type === 'timeZoneName');
if (offsetPart) {
return `${tz} (${offsetPart.value})`;
}
} catch {
// If formatting fails, just return the timezone name
}
return tz;
}
// Shorter display for trigger button
function formatTimezoneShort(tz: string): string {
return tz;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={open}
class={cn('w-full justify-between', className)}
{...props}
{id}
>
<span class="flex items-center gap-2 truncate">
<Globe class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{value ? formatTimezoneShort(value) : placeholder}</span>
</span>
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[350px] p-0" align="start">
<Command.Root shouldFilter={false}>
<Command.Input bind:value={searchQuery} placeholder="Search timezone..." />
<Command.List class="max-h-[300px]">
<Command.Empty>No timezone found.</Command.Empty>
{#if filteredCommon.length > 0}
<Command.Group heading="Common">
{#each filteredCommon as tz}
<Command.Item value={tz} onSelect={() => selectTimezone(tz)}>
<Check class={cn('mr-2 h-4 w-4', value === tz ? 'opacity-100' : 'opacity-0')} />
<span class="truncate">{formatTimezone(tz)}</span>
</Command.Item>
{/each}
</Command.Group>
{/if}
{#if filteredOther.length > 0}
<Command.Group heading="All timezones">
{#each filteredOther as tz}
<Command.Item value={tz} onSelect={() => selectTimezone(tz)}>
<Check class={cn('mr-2 h-4 w-4', value === tz ? 'opacity-100' : 'opacity-0')} />
<span class="truncate">{formatTimezone(tz)}</span>
</Command.Item>
{/each}
</Command.Group>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ChevronDown, ChevronRight, CheckCircle2, XCircle, Loader2 } from 'lucide-svelte';
import type { StepType } from '$lib/utils/update-steps';
import { getStepIcon, getStepLabel, getStepColor } from '$lib/utils/update-steps';
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
interface ScannerResult {
scanner: 'grype' | 'trivy';
critical: number;
high: number;
medium: number;
low: number;
negligible?: number;
unknown?: number;
}
interface Props {
name: string;
status: StepType;
error?: string;
blockReason?: string;
scannerResults?: ScannerResult[];
isActive?: boolean;
showLogs?: boolean;
isForceUpdating?: boolean;
onToggleLogs?: () => void;
onForceUpdate?: () => void;
}
let {
name,
status,
error,
blockReason,
scannerResults,
isActive = false,
showLogs = false,
isForceUpdating = false,
onToggleLogs,
onForceUpdate
}: Props = $props();
const StepIcon = $derived(getStepIcon(status));
const stepLabel = $derived(getStepLabel(status));
const colorClass = $derived(getStepColor(status));
const hasToggle = $derived(onToggleLogs !== undefined);
</script>
<div class="flex items-center gap-3 p-3">
<svelte:component
this={StepIcon}
class="w-4 h-4 shrink-0 {colorClass} {isActive ? 'animate-spin' : ''}"
/>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{name}</div>
{#if error}
<div class="text-xs text-red-600 dark:text-red-400 truncate">{error}</div>
{:else if blockReason}
<div class="text-xs text-amber-600 dark:text-amber-400 truncate">{blockReason}</div>
{:else}
<div class="text-xs text-muted-foreground">{stepLabel}</div>
{/if}
</div>
<!-- Scan result badges -->
{#if scannerResults && scannerResults.length > 0}
<ScannerSeverityPills results={scannerResults} />
{/if}
<!-- Status/action icons -->
{#if status === 'done' || status === 'updated'}
<CheckCircle2 class="w-4 h-4 text-green-600 shrink-0" />
{:else if status === 'failed'}
<XCircle class="w-4 h-4 text-red-600 shrink-0" />
{:else if status === 'blocked' && onForceUpdate}
{#if isForceUpdating}
<Loader2 class="w-4 h-4 text-blue-500 shrink-0 animate-spin" />
{:else}
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/50"
onclick={onForceUpdate}
>
Update anyway
</Button>
{/if}
{/if}
<!-- Toggle logs button -->
{#if hasToggle}
<button
type="button"
onclick={onToggleLogs}
class="p-1 hover:bg-muted rounded cursor-pointer"
title={showLogs ? 'Hide logs' : 'Show logs'}
>
{#if showLogs}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{/if}
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { StepType } from '$lib/utils/update-steps';
import { getStepIcon, getStepLabel, getStepColor } from '$lib/utils/update-steps';
interface Props {
step: StepType;
isActive?: boolean;
showLabel?: boolean;
}
let { step, isActive = false, showLabel = true }: Props = $props();
const Icon = $derived(getStepIcon(step));
const label = $derived(getStepLabel(step));
const colorClass = $derived(getStepColor(step));
</script>
<div class="flex items-center gap-1.5">
<svelte:component
this={Icon}
class="w-4 h-4 shrink-0 {colorClass} {isActive ? 'animate-spin' : ''}"
/>
{#if showLabel}
<span class="text-xs {colorClass}">{label}</span>
{/if}
</div>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { CheckCircle2, ShieldAlert, XCircle, Search } from 'lucide-svelte';
interface Props {
checked?: number;
updated: number;
blocked: number;
failed: number;
compact?: boolean;
}
let { checked, updated, blocked, failed, compact = false }: Props = $props();
</script>
{#if compact}
<!-- Inline compact layout -->
<div class="flex items-center gap-4 text-sm">
{#if checked !== undefined}
<div class="flex items-center gap-1.5">
<span class="font-bold">{checked}</span>
<span class="text-muted-foreground">Checked</span>
</div>
{/if}
{#if updated > 0}
<div class="flex items-center gap-1.5 text-green-600">
<CheckCircle2 class="w-4 h-4" />
<span>{updated} updated</span>
</div>
{/if}
{#if blocked > 0}
<div class="flex items-center gap-1.5 text-amber-600">
<ShieldAlert class="w-4 h-4" />
<span>{blocked} blocked</span>
</div>
{/if}
{#if failed > 0}
<div class="flex items-center gap-1.5 text-red-600">
<XCircle class="w-4 h-4" />
<span>{failed} failed</span>
</div>
{/if}
</div>
{:else}
<!-- Grid layout -->
<div class="flex items-center gap-4 text-sm pb-3 border-b">
{#if checked !== undefined}
<div class="flex items-center gap-1.5">
<Search class="w-4 h-4 text-muted-foreground" />
<span class="font-bold">{checked}</span>
<span class="text-muted-foreground">Checked</span>
</div>
{/if}
<div class="flex items-center gap-1.5">
<CheckCircle2 class="w-4 h-4 text-green-500" />
<span class="font-bold text-green-500">{updated}</span>
<span class="text-muted-foreground">Updated</span>
</div>
<div class="flex items-center gap-1.5">
<ShieldAlert class="w-4 h-4 text-amber-500" />
<span class="font-bold text-amber-500">{blocked}</span>
<span class="text-muted-foreground">Blocked</span>
</div>
<div class="flex items-center gap-1.5">
<XCircle class="w-4 h-4 text-red-500" />
<span class="font-bold text-red-500">{failed}</span>
<span class="text-muted-foreground">Failed</span>
</div>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import type { VulnerabilityCriteria } from '$lib/server/db';
import {
vulnerabilityCriteriaLabels,
vulnerabilityCriteriaIcons,
getCriteriaBadgeClass
} from '$lib/utils/update-steps';
interface Props {
criteria: VulnerabilityCriteria;
showLabel?: boolean;
}
let { criteria, showLabel = true }: Props = $props();
const iconConfig = $derived(vulnerabilityCriteriaIcons[criteria] || vulnerabilityCriteriaIcons.never);
const label = $derived(vulnerabilityCriteriaLabels[criteria] || criteria);
const badgeClass = $derived(getCriteriaBadgeClass(criteria));
</script>
<Badge variant="outline" class="text-xs {badgeClass}">
<svelte:component this={iconConfig.component} class={iconConfig.class} />
{#if showLabel}
<span class="ml-1">{label}</span>
{/if}
</Badge>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { Shield, ShieldOff, ShieldAlert, ShieldX } from 'lucide-svelte';
export type VulnerabilityCriteria = 'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current';
interface Props {
value: VulnerabilityCriteria;
onchange?: (value: VulnerabilityCriteria) => void;
class?: string;
}
let {
value = $bindable(),
onchange,
class: className = ''
}: Props = $props();
</script>
<Select.Root
type="single"
bind:value={value}
onValueChange={(v) => {
if (v) {
value = v as VulnerabilityCriteria;
onchange?.(value);
}
}}
>
<Select.Trigger class="w-full h-9 {className}">
<span class="flex items-center gap-2">
{#if value === 'never'}
<ShieldOff class="w-3.5 h-3.5 text-muted-foreground" />
<span>Never block</span>
{:else if value === 'any'}
<ShieldAlert class="w-3.5 h-3.5 text-amber-500" />
<span>Any vulnerabilities</span>
{:else if value === 'critical_high'}
<ShieldX class="w-3.5 h-3.5 text-orange-500" />
<span>Critical or high</span>
{:else if value === 'critical'}
<ShieldX class="w-3.5 h-3.5 text-red-500" />
<span>Critical only</span>
{:else if value === 'more_than_current'}
<Shield class="w-3.5 h-3.5 text-blue-500" />
<span>More than current image</span>
{:else}
<ShieldOff class="w-3.5 h-3.5 text-muted-foreground" />
<span>Never block</span>
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="never">
<div class="flex items-center gap-2">
<ShieldOff class="w-3.5 h-3.5 text-muted-foreground" />
<span>Never block</span>
</div>
</Select.Item>
<Select.Item value="any">
<div class="flex items-center gap-2">
<ShieldAlert class="w-3.5 h-3.5 text-amber-500" />
<span>Any vulnerabilities</span>
</div>
</Select.Item>
<Select.Item value="critical_high">
<div class="flex items-center gap-2">
<ShieldX class="w-3.5 h-3.5 text-orange-500" />
<span>Critical or high</span>
</div>
</Select.Item>
<Select.Item value="critical">
<div class="flex items-center gap-2">
<ShieldX class="w-3.5 h-3.5 text-red-500" />
<span>Critical only</span>
</div>
</Select.Item>
<Select.Item value="more_than_current">
<div class="flex items-center gap-2">
<Shield class="w-3.5 h-3.5 text-blue-500" />
<span>More than current image</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
import { compareVersions } from '$lib/utils/version';
interface ChangelogEntry {
version: string;
date: string;
changes: Array<{ type: string; text: string }>;
imageTag?: string;
}
interface Props {
open: boolean;
version: string;
lastSeenVersion: string | null;
changelog: ChangelogEntry[];
onDismiss: () => void;
}
let { open = $bindable(), version, changelog, lastSeenVersion, onDismiss }: Props = $props();
// Filter to show versions newer than lastSeenVersion, limited to 3 most recent
const missedReleases = $derived(
changelog
.filter((r) => {
if (!lastSeenVersion) return true; // Show all if first time
return compareVersions(r.version, lastSeenVersion.replace(/^v/, '')) > 0;
})
.slice(0, 3)
);
function getChangeIcon(type: string) {
switch (type) {
case 'feature':
return { icon: Sparkles, class: 'text-green-500' };
case 'fix':
return { icon: Bug, class: 'text-amber-500' };
case 'improvement':
return { icon: Zap, class: 'text-green-500' };
default:
return { icon: CheckCircle, class: 'text-muted-foreground' };
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-3xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<ScrollText class="w-5 h-5 text-muted-foreground" />
Dockhand has been updated to {version}
</Dialog.Title>
</Dialog.Header>
<div class="py-4 max-h-[60vh] overflow-y-auto space-y-6">
{#each missedReleases as release}
<div>
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
<span>v{release.version}</span>
<span class="text-muted-foreground font-normal">({release.date})</span>
</h3>
<div class="space-y-1.5 ml-1">
{#each release.changes as change}
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
<span class="text-sm">{change.text}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
<Dialog.Footer>
<Button onclick={onDismiss}>Got it</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Sidebar from '$lib/components/ui/sidebar';
import { useSidebar } from '$lib/components/ui/sidebar';
import {
LayoutDashboard,
Box,
Layers,
Images,
ScrollText,
HardDrive,
Network,
PanelLeftClose,
PanelLeft,
Download,
Settings,
Terminal,
Info,
Crown,
LogOut,
User,
ClipboardList,
Activity,
Timer
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import * as Avatar from '$lib/components/ui/avatar';
import type { Permissions } from '$lib/stores/auth';
// TypeScript interface for menu items
interface MenuItem {
href: string;
Icon: typeof LayoutDashboard;
label: string;
// Permission resource required to see this menu item (enterprise only)
// Show menu if user has ANY permission for this resource, or 'always' (no check)
permission?: keyof Permissions | 'always';
// If true, item is only visible with enterprise license
enterpriseOnly?: boolean;
}
const currentPath = $derived($page.url.pathname);
const sidebar = useSidebar();
function isActive(path: string): boolean {
if (path === '/') return currentPath === '/';
return currentPath === path || currentPath.startsWith(`${path}/`);
}
async function handleLogout() {
sidebar.setOpenMobile(false);
await authStore.logout();
goto('/login');
}
/**
* Check if a menu item should be visible based on permissions
* - Enterprise-only items require enterprise license
* - FREE edition: all non-enterprise items visible (no permission checks)
* - ENTERPRISE edition: check if user has ANY permission for the resource
*/
function canSeeMenuItem(item: MenuItem): boolean {
// Enterprise-only items are hidden without enterprise license
if (item.enterpriseOnly && !$licenseStore.isEnterprise) {
return false;
}
// FREE edition or auth disabled = all items visible (except enterprise-only)
if (!$licenseStore.isEnterprise || !$authStore.authEnabled) {
return true;
}
// ENTERPRISE edition: check permissions
// Admins see everything
if ($authStore.user?.isAdmin) {
return true;
}
// No permission specified = always visible
if (!item.permission || item.permission === 'always') {
return true;
}
// Check if user has ANY permission for this resource
return $hasAnyAccess(item.permission);
}
const menuItems: readonly MenuItem[] = [
{ href: '/', Icon: LayoutDashboard, label: 'Dashboard', permission: 'always' },
{ href: '/containers', Icon: Box, label: 'Containers', permission: 'containers' },
{ href: '/logs', Icon: ScrollText, label: 'Logs', permission: 'containers' },
{ href: '/terminal', Icon: Terminal, label: 'Shell', permission: 'containers' },
{ href: '/stacks', Icon: Layers, label: 'Stacks', permission: 'stacks' },
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
{ href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true },
{ href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' }
] as const;
</script>
<Sidebar.Root collapsible="icon">
<Sidebar.Header class="overflow-visible flex items-center justify-center p-0">
<!-- Expanded state: logo + collapse button -->
<div class="relative flex items-center justify-center w-full group-data-[state=collapsed]:hidden">
<a href="/" class="flex justify-center relative">
<img src="/logo-light.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 dark:hidden" style="filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.9));" />
<img src="/logo-dark.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 hidden dark:block" style="filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.2));" />
{#if $licenseStore.isEnterprise}
<Crown class="w-4 h-4 absolute top-0 -right-[6px] text-amber-500 fill-amber-400 drop-shadow-sm rotate-[20deg]" />
{/if}
</a>
<button
type="button"
onclick={() => sidebar.toggle()}
class="absolute right-1 p-1.5 rounded-md hover:bg-sidebar-accent text-gray-300 hover:text-gray-400 transition-colors"
title="Collapse sidebar"
aria-label="Collapse sidebar"
>
<PanelLeftClose class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<!-- Collapsed state: expand button only -->
<button
type="button"
onclick={() => sidebar.toggle()}
class="hidden group-data-[state=collapsed]:flex p-1.5 rounded-md hover:bg-sidebar-accent text-muted-foreground hover:text-foreground transition-colors"
title="Expand sidebar"
aria-label="Expand sidebar"
>
<PanelLeft class="w-4 h-4" aria-hidden="true" />
</button>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
{#each menuItems as item}
{#if canSeeMenuItem(item)}
<Sidebar.MenuItem>
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
<item.Icon aria-hidden="true" />
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{/each}
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<!-- User info footer (only when auth is enabled) -->
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
<Sidebar.Footer class="border-t">
<Sidebar.Menu>
<Sidebar.MenuItem>
<a
href="/profile"
onclick={() => sidebar.setOpenMobile(false)}
class="flex items-center gap-2 px-2 py-1.5 group-data-[state=collapsed]:px-1 group-data-[state=collapsed]:py-1 rounded-md hover:bg-sidebar-accent transition-colors group-data-[state=collapsed]:justify-center"
title="View profile"
>
<Avatar.Root class="w-8 h-8 group-data-[state=collapsed]:w-6 group-data-[state=collapsed]:h-6 shrink-0 transition-all">
<Avatar.Image src={$authStore.user.avatar} alt={$authStore.user.username} />
<Avatar.Fallback class="bg-primary/10 text-primary text-xs">
{($authStore.user.displayName || $authStore.user.username)?.slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col min-w-0 group-data-[state=collapsed]:hidden">
<span class="text-sm font-medium truncate">{$authStore.user.displayName || $authStore.user.username}</span>
<span class="text-xs text-muted-foreground truncate">{$authStore.user.isAdmin ? 'Admin' : 'User'}</span>
</div>
</a>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<button
type="button"
onclick={handleLogout}
class="flex items-center gap-2 w-full px-2 py-1.5 group-data-[state=collapsed]:px-1 group-data-[state=collapsed]:py-1 text-sm text-muted-foreground hover:text-foreground hover:bg-sidebar-accent rounded-md transition-colors group-data-[state=collapsed]:justify-center"
title="Sign out"
>
<LogOut class="w-4 h-4 shrink-0 group-data-[state=collapsed]:w-3.5 group-data-[state=collapsed]:h-3.5" />
<span class="group-data-[state=collapsed]:hidden">Sign out</span>
</button>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Footer>
{/if}
</Sidebar.Root>

View File

@@ -0,0 +1,308 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { Input } from '$lib/components/ui/input';
import { Calendar, CalendarDays, Clock } from 'lucide-svelte';
import { appSettings } from '$lib/stores/settings';
import cronstrue from 'cronstrue';
// Reactive time format from settings
let is12Hour = $derived($appSettings.timeFormat === '12h');
interface Props {
value: string;
onchange: (cron: string) => void;
disabled?: boolean;
}
let { value, onchange, disabled = false }: Props = $props();
// Detect schedule type from cron expression
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
const parts = cron.split(' ');
if (parts.length < 5) return 'custom';
const [, , day, month, dow] = parts;
// Weekly: specific day of week (0-6), day and month are wildcards
if (dow !== '*' && day === '*' && month === '*') {
return 'weekly';
}
// Daily: all wildcards except minute and hour
if (day === '*' && month === '*' && dow === '*') {
return 'daily';
}
return 'custom';
}
// Parse cron into components for UI
let minute = $state('0');
let hour = $state('3');
let dayOfWeek = $state('1'); // Monday
let scheduleType = $state<'daily' | 'weekly' | 'custom'>('daily');
// Track if component has been initialized
let initialized = $state(false);
let previousScheduleType = $state<'daily' | 'weekly' | 'custom'>('daily');
let isTypingCustom = $state(false); // Track if user is actively typing in custom mode
// Update UI when value (cron expression) changes externally
$effect(() => {
if (value) {
const parts = value.split(' ');
if (parts.length >= 5) {
minute = parts[0] || '0';
hour = parts[1] || '3';
dayOfWeek = parts[4] !== '*' ? parts[4] : '1'; // Default to Monday
// Only update schedule type if not actively typing in custom mode
if (!isTypingCustom) {
scheduleType = detectScheduleType(value);
}
}
}
// Mark as initialized after first parse
if (!initialized) {
initialized = true;
previousScheduleType = scheduleType;
}
});
// Generate cron expression from UI inputs
function updateCronExpression() {
let newCron = '';
if (scheduleType === 'daily') {
newCron = `${minute} ${hour} * * *`;
} else if (scheduleType === 'weekly') {
newCron = `${minute} ${hour} * * ${dayOfWeek}`;
} else {
// For custom, keep the current value
return;
}
onchange(newCron);
}
// Handle schedule type change
function handleScheduleTypeChange(newType: string) {
const type = newType as 'daily' | 'weekly' | 'custom';
scheduleType = type;
// Set flag when switching to custom mode
if (type === 'custom') {
isTypingCustom = true;
} else {
isTypingCustom = false;
}
// Only reset to defaults if schedule type actually changed after initialization
if (initialized && type !== previousScheduleType) {
if (type === 'daily') {
minute = '0';
hour = '3';
onchange('0 3 * * *');
} else if (type === 'weekly') {
minute = '0';
hour = '3';
dayOfWeek = '1'; // Monday
onchange('0 3 * * 1');
}
previousScheduleType = type;
}
}
function handleMinuteChange(value: string) {
minute = value;
updateCronExpression();
}
function handleHourChange(value: string) {
hour = value;
updateCronExpression();
}
function handleDayOfWeekChange(value: string) {
dayOfWeek = value;
updateCronExpression();
}
function handleCustomCronInput(e: Event) {
const newValue = (e.currentTarget as HTMLInputElement).value;
onchange(newValue);
}
// Validate cron expression
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
}
// Human-readable description using cronstrue
let humanReadable = $derived(() => {
if (!value) return '';
if (!value.trim()) return '';
// Validate first
if (!isValidCron(value)) {
return 'Invalid';
}
try {
// Use cronstrue to parse the cron expression
// Configure it to use the user's time format preference
const description = cronstrue.toString(value, {
use24HourTimeFormat: !is12Hour,
throwExceptionOnParseError: true,
locale: 'en' // You can add user locale preference here if needed
});
return description;
} catch (error) {
return 'Invalid';
}
});
// Generate hours array based on time format preference
const hours = $derived(
Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: is12Hour
? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM`
: i.toString().padStart(2, '0') + ':00'
}))
);
const minutes = [
{ value: '0', label: ':00' },
{ value: '15', label: ':15' },
{ value: '30', label: ':30' },
{ value: '45', label: ':45' }
];
const daysOfWeek = [
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '3', label: 'Wednesday' },
{ value: '4', label: 'Thursday' },
{ value: '5', label: 'Friday' },
{ value: '6', label: 'Saturday' },
{ value: '0', label: 'Sunday' }
];
</script>
<div class="flex items-center gap-2 flex-wrap">
<!-- Schedule Type Selector -->
<Select.Root type="single" value={scheduleType} onValueChange={handleScheduleTypeChange} {disabled}>
<Select.Trigger class="w-[140px] h-9">
<div class="flex items-center gap-2">
{#if scheduleType === 'daily'}
<Calendar class="w-4 h-4" />
<span>Daily</span>
{:else if scheduleType === 'weekly'}
<CalendarDays class="w-4 h-4" />
<span>Weekly</span>
{:else}
<Clock class="w-4 h-4" />
<span>Custom</span>
{/if}
</div>
</Select.Trigger>
<Select.Content>
<Select.Item value="daily">
<div class="flex items-center gap-2">
<Calendar class="w-4 h-4" />
<span>Daily</span>
</div>
</Select.Item>
<Select.Item value="weekly">
<div class="flex items-center gap-2">
<CalendarDays class="w-4 h-4" />
<span>Weekly</span>
</div>
</Select.Item>
<Select.Item value="custom">
<div class="flex items-center gap-2">
<Clock class="w-4 h-4" />
<span>Custom</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
{#if scheduleType === 'daily' || scheduleType === 'weekly'}
<!-- Time Selectors -->
<span class="text-sm text-muted-foreground">at</span>
<Select.Root type="single" value={hour} onValueChange={handleHourChange} {disabled}>
<Select.Trigger class="w-[100px] h-9">
<span>{hours.find((h: { value: string; label: string }) => h.value === hour)?.label || hour}</span>
</Select.Trigger>
<Select.Content>
{#each hours as h}
<Select.Item value={h.value} label={h.label} />
{/each}
</Select.Content>
</Select.Root>
<Select.Root type="single" value={minute} onValueChange={handleMinuteChange} {disabled}>
<Select.Trigger class="w-[70px] h-9">
<span>{minutes.find(m => m.value === minute)?.label || `:${minute}`}</span>
</Select.Trigger>
<Select.Content>
{#each minutes as m}
<Select.Item value={m.value} label={m.label} />
{/each}
</Select.Content>
</Select.Root>
{#if scheduleType === 'weekly'}
<span class="text-sm text-muted-foreground">on</span>
<Select.Root type="single" value={dayOfWeek} onValueChange={handleDayOfWeekChange} {disabled}>
<Select.Trigger class="w-[110px] h-9">
<span>{daysOfWeek.find(d => d.value === dayOfWeek)?.label || dayOfWeek}</span>
</Select.Trigger>
<Select.Content>
{#each daysOfWeek as d}
<Select.Item value={d.value} label={d.label} />
{/each}
</Select.Content>
</Select.Root>
{/if}
{:else}
<!-- Custom cron input -->
{@const readable = humanReadable()}
{@const isInvalid = readable === 'Invalid'}
<Input
value={value}
oninput={handleCustomCronInput}
placeholder="0 3 * * *"
class="h-9 font-mono flex-1 min-w-[200px] {isInvalid ? 'border-destructive focus-visible:ring-destructive' : ''}"
{disabled}
/>
{/if}
</div>
<!-- Description area with fixed height -->
<div class="min-h-[20px] mt-1">
{#if value}
{@const readable = humanReadable()}
{@const isInvalid = readable === 'Invalid'}
<p class="text-xs {isInvalid ? 'text-destructive' : 'text-muted-foreground'}">
{readable}
</p>
{/if}
</div>

View File

@@ -0,0 +1,850 @@
<script lang="ts" generics="T">
import { onMount, onDestroy } from 'svelte';
import type { Snippet } from 'svelte';
import { CheckSquare, Square as SquareIcon, ArrowUp, ArrowDown, ArrowUpDown, ChevronDown, ChevronRight } from 'lucide-svelte';
import { columnResize } from '$lib/actions/column-resize';
import { gridPreferencesStore } from '$lib/stores/grid-preferences';
import { getAllColumnConfigs } from '$lib/config/grid-columns';
import ColumnSettingsPopover from '$lib/components/ColumnSettingsPopover.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import type { GridId, ColumnConfig, ColumnPreference } from '$lib/types';
import type { DataGridSortState, DataGridRowState } from './types';
import { setDataGridContext } from './context';
// Props
interface Props {
// Required
data: T[];
keyField: keyof T;
gridId: GridId;
// Virtual Scroll Mode (OFF by default)
virtualScroll?: boolean;
rowHeight?: number;
bufferRows?: number;
// Selection
selectable?: boolean;
selectedKeys?: Set<unknown>;
onSelectionChange?: (keys: Set<unknown>) => void;
// Sorting
sortState?: DataGridSortState;
onSortChange?: (state: DataGridSortState) => void;
// Infinite scroll (virtual mode)
hasMore?: boolean;
onLoadMore?: () => void;
loadMoreThreshold?: number;
// Visible range callback (for virtual scroll)
onVisibleRangeChange?: (start: number, end: number, total: number) => void;
// Row interaction
onRowClick?: (item: T, event: MouseEvent) => void;
highlightedKey?: unknown;
rowClass?: (item: T) => string;
// Selection filter - return false to make an item non-selectable
selectableFilter?: (item: T) => boolean;
// Expandable rows
expandable?: boolean;
expandedKeys?: Set<unknown>;
onExpandChange?: (key: unknown, expanded: boolean) => void;
expandedRow?: Snippet<[T, DataGridRowState]>;
// State
loading?: boolean;
skeletonRows?: number;
// CSS
class?: string;
wrapperClass?: string;
// Snippets for customization
headerCell?: Snippet<[ColumnConfig, DataGridSortState | undefined]>;
cell?: Snippet<[ColumnConfig, T, DataGridRowState]>;
emptyState?: Snippet;
loadingState?: Snippet;
}
let {
data,
keyField,
gridId,
virtualScroll = false,
rowHeight = 33,
bufferRows = 10,
selectable = false,
selectedKeys = $bindable(new Set<unknown>()),
onSelectionChange,
sortState,
onSortChange,
hasMore = false,
onLoadMore,
loadMoreThreshold = 200,
onVisibleRangeChange,
onRowClick,
highlightedKey,
rowClass,
selectableFilter,
expandable = false,
expandedKeys = $bindable(new Set<unknown>()),
onExpandChange,
expandedRow,
loading = false,
skeletonRows = 8,
class: className = '',
wrapperClass = '',
headerCell,
cell,
emptyState,
loadingState
}: Props = $props();
// Column configuration
const columnConfigs = getAllColumnConfigs(gridId);
const columnConfigMap = new Map(columnConfigs.map((c) => [c.id, c]));
const fixedStartCols = columnConfigs.filter((c) => c.fixed === 'start').map((c) => c.id);
const fixedEndCols = columnConfigs.filter((c) => c.fixed === 'end').map((c) => c.id);
// Grid preferences (reactive)
const gridPrefs = $derived($gridPreferencesStore);
// Get ordered visible columns from preferences
const orderedColumns = $derived.by(() => {
const prefs = gridPrefs[gridId];
if (!prefs?.columns?.length) {
// Default: all configurable columns visible
return columnConfigs.filter((c) => !c.fixed).map((c) => c.id);
}
return prefs.columns.filter((c) => c.visible).map((c) => c.id);
});
// Identify visible grow columns (columns with grow: true that are currently visible)
const visibleGrowCols = $derived(
orderedColumns.filter((id) => columnConfigMap.get(id)?.grow)
);
// Helper to check if column is a grow column
function isGrowColumn(colId: string): boolean {
return visibleGrowCols.includes(colId);
}
// Saved column widths from preferences
const savedWidths = $derived.by(() => {
const prefs = gridPrefs[gridId];
const widths = new Map<string, number>();
if (prefs?.columns) {
for (const col of prefs.columns) {
if (col.width !== undefined) {
widths.set(col.id, col.width);
}
}
}
return widths;
});
// Local widths for smooth resize feedback (not persisted until mouseup)
let localWidths = $state<Map<string, number>>(new Map());
// RAF throttling for performance
let resizeRAF: number | null = null;
let scrollRAF: number | null = null;
// Helper to get base width for a column (without grow calculation)
function getBaseWidth(colId: string): number {
if (localWidths.has(colId)) return localWidths.get(colId)!;
if (savedWidths.has(colId)) return savedWidths.get(colId)!;
return columnConfigMap.get(colId)?.width ?? 100;
}
// Calculate width for grow columns (distributes remaining space equally)
const growColumnWidth = $derived.by(() => {
if (!scrollContainerWidth || visibleGrowCols.length === 0) return null;
// Sum of all fixed-width columns (non-grow)
let fixedTotal = 0;
// Fixed start columns (select, expand)
for (const colId of fixedStartCols) {
fixedTotal += getBaseWidth(colId);
}
// Visible non-grow columns
for (const colId of orderedColumns) {
if (!visibleGrowCols.includes(colId)) {
fixedTotal += getBaseWidth(colId);
}
}
// Fixed end columns (actions)
for (const colId of fixedEndCols) {
fixedTotal += getBaseWidth(colId);
}
// Distribute remaining space equally among grow columns
// No buffer - grow columns absorb all remaining space
const remaining = Math.max(0, scrollContainerWidth - fixedTotal);
const perGrowCol = remaining / visibleGrowCols.length;
// Respect minimum widths
const minWidth = Math.max(
...visibleGrowCols.map((id) => columnConfigMap.get(id)?.minWidth ?? 60)
);
return Math.max(perGrowCol, minWidth);
});
// Calculate total table width (sum of all column widths)
const totalTableWidth = $derived.by(() => {
let total = 0;
for (const colId of fixedStartCols) {
total += getBaseWidth(colId);
}
for (const colId of orderedColumns) {
total += getDisplayWidth(colId);
}
for (const colId of fixedEndCols) {
total += getBaseWidth(colId);
}
return total;
});
// Get display width for a column (priority: local > saved > grow-calculated > default)
function getDisplayWidth(colId: string): number {
// For non-grow columns, use base width
if (!isGrowColumn(colId)) {
return getBaseWidth(colId);
}
// For grow columns: if user has resized, use their width
if (localWidths.has(colId)) return localWidths.get(colId)!;
if (savedWidths.has(colId)) return savedWidths.get(colId)!;
// Otherwise use calculated grow width
if (growColumnWidth) {
return growColumnWidth;
}
return columnConfigMap.get(colId)?.width ?? 100;
}
// Get column config by ID
function getColumnConfig(colId: string): ColumnConfig | undefined {
return columnConfigMap.get(colId);
}
// Handle resize during drag (RAF throttled for performance)
function handleResize(colId: string, width: number) {
if (resizeRAF) return; // Skip if already pending
resizeRAF = requestAnimationFrame(() => {
resizeRAF = null;
localWidths.set(colId, width);
localWidths = new Map(localWidths); // Trigger reactivity
});
}
// Handle resize end - persist to store
async function handleResizeEnd(colId: string, width: number) {
await gridPreferencesStore.setColumnWidth(gridId, colId, width);
localWidths.delete(colId);
localWidths = new Map(localWidths);
}
// Selection helpers
function isItemSelectable(item: T): boolean {
return selectableFilter ? selectableFilter(item) : true;
}
const selectableData = $derived(data.filter(isItemSelectable));
const allSelected = $derived(selectableData.length > 0 && selectableData.every((item) => selectedKeys.has(item[keyField])));
const someSelected = $derived(selectableData.some((item) => selectedKeys.has(item[keyField])) && !allSelected);
function isSelected(key: unknown): boolean {
return selectedKeys.has(key);
}
function toggleSelection(key: unknown) {
const newKeys = new Set(selectedKeys);
if (newKeys.has(key)) {
newKeys.delete(key);
} else {
newKeys.add(key);
}
selectedKeys = newKeys;
onSelectionChange?.(newKeys);
}
function selectAll() {
// Add all selectable items to existing selection (preserves filtered-out selections)
const newKeys = new Set(selectedKeys);
for (const item of selectableData) {
newKeys.add(item[keyField]);
}
selectedKeys = newKeys;
onSelectionChange?.(newKeys);
}
function selectNone() {
// Remove only selectable items from selection (preserves filtered-out selections)
const newKeys = new Set(selectedKeys);
for (const item of selectableData) {
newKeys.delete(item[keyField]);
}
selectedKeys = newKeys;
onSelectionChange?.(newKeys);
}
function toggleSelectAll() {
if (allSelected) {
selectNone();
} else {
selectAll();
}
}
// Expand helpers
function isExpanded(key: unknown): boolean {
return expandedKeys.has(key);
}
function toggleExpand(key: unknown) {
const newKeys = new Set(expandedKeys);
const nowExpanded = !newKeys.has(key);
if (nowExpanded) {
newKeys.add(key);
} else {
newKeys.delete(key);
}
expandedKeys = newKeys;
onExpandChange?.(key, nowExpanded);
}
// Sort helpers
function toggleSort(field: string) {
if (!onSortChange) return;
if (sortState?.field === field) {
onSortChange({
field,
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
});
} else {
onSortChange({ field, direction: 'asc' });
}
}
// Virtual scroll state
let scrollContainer = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
let containerHeight = $state(600);
// Container width for grow column calculation
let scrollContainerWidth = $state(0);
// Virtual scroll calculations
const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0);
const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0);
const endIndex = $derived(
virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length
);
const visibleData = $derived(virtualScroll ? data.slice(startIndex, endIndex) : data);
const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0);
// Notify parent of visible range changes
$effect(() => {
if (virtualScroll && onVisibleRangeChange && data.length > 0) {
// Calculate actual visible range (without buffer)
const visibleStart = Math.max(1, Math.floor(scrollTop / rowHeight) + 1);
const visibleEnd = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight));
onVisibleRangeChange(visibleStart, Math.max(visibleEnd, visibleStart), data.length);
}
});
// Handle scroll for virtual mode (RAF throttled for performance)
function handleScroll(event: Event) {
if (!virtualScroll) return;
if (scrollRAF) return; // Skip if already pending
scrollRAF = requestAnimationFrame(() => {
scrollRAF = null;
const target = event.target as HTMLDivElement;
scrollTop = target.scrollTop;
// Update container height on scroll (in case of resize)
containerHeight = target.clientHeight;
// Infinite scroll trigger
if (hasMore && onLoadMore) {
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < loadMoreThreshold) {
onLoadMore();
}
}
});
}
// Update container dimensions on mount and resize
onMount(() => {
if (scrollContainer) {
// Track width for grow column calculation (always needed)
scrollContainerWidth = scrollContainer.clientWidth;
// Track height for virtual scroll
if (virtualScroll) {
containerHeight = scrollContainer.clientHeight;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
scrollContainerWidth = entry.contentRect.width;
if (virtualScroll) {
containerHeight = entry.contentRect.height;
}
}
});
resizeObserver.observe(scrollContainer);
return () => {
resizeObserver.disconnect();
};
}
});
// Cleanup RAF handles on destroy
onDestroy(() => {
if (resizeRAF) cancelAnimationFrame(resizeRAF);
if (scrollRAF) cancelAnimationFrame(scrollRAF);
});
// Set context for child components
setDataGridContext({
gridId,
keyField: keyField as keyof unknown,
orderedColumns,
getDisplayWidth,
getColumnConfig,
selectable,
isSelected,
toggleSelection,
selectAll,
selectNone,
allSelected,
someSelected,
sortState,
toggleSort,
handleResize,
handleResizeEnd,
highlightedKey
});
// Helper to get row state
function getRowState(item: T, index: number): DataGridRowState {
return {
isSelected: isSelected(item[keyField]),
isHighlighted: highlightedKey === item[keyField],
isSelectable: isItemSelectable(item),
isExpanded: isExpanded(item[keyField]),
index: virtualScroll ? startIndex + index : index
};
}
// Helper to check if column is resizable
function isResizable(colId: string): boolean {
const config = columnConfigMap.get(colId);
// Fixed columns are not resizable by default, but can be made resizable explicitly
if (config?.fixed) {
return config.resizable === true;
}
return config?.resizable !== false;
}
// Helper to check if column is sortable
function isSortable(colId: string): boolean {
const config = columnConfigMap.get(colId);
return config?.sortable === true;
}
// Helper to get sort field
function getSortField(colId: string): string {
const config = columnConfigMap.get(colId);
return config?.sortField ?? colId;
}
// Generate skeleton row indices
const skeletonIndices = $derived(Array.from({ length: skeletonRows }, (_, i) => i));
</script>
{#snippet skeletonContent()}
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
<thead class="bg-muted sticky top-0 z-10">
<tr>
<!-- Fixed start columns -->
{#each fixedStartCols as colId (colId)}
<th class="py-2 px-1 font-medium {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px"></th>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<th class="{colConfig.align === 'right' ? 'text-right' : colConfig.align === 'center' ? 'text-center' : 'text-left'} py-2 px-2 font-medium" style="width: {getDisplayWidth(colId)}px">
{colConfig.label}
</th>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
<th class="text-right py-2 px-2 font-medium actions-col" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'actions'}
<div class="flex items-center justify-end gap-1">
<span>Actions</span>
<ColumnSettingsPopover {gridId} />
</div>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each skeletonIndices as i (i)}
<tr class="border-b border-muted">
<!-- Fixed start columns -->
{#each fixedStartCols as colId (colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
<Skeleton class="h-4 w-4" />
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
{@const width = getDisplayWidth(colId)}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {width}px">
<Skeleton class="h-4" style="width: {Math.max(30, Math.min(width - 16, width * 0.7))}px" />
</td>
{/if}
{/each}
<!-- Fixed end columns -->
{#each fixedEndCols as colId (colId)}
<td class="py-1.5 px-2 actions-col" style="width: {getDisplayWidth(colId)}px">
<Skeleton class="h-4 w-12" />
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/snippet}
{#snippet tableHeader()}
<thead class="bg-muted sticky top-0 z-10">
<tr>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<th class="py-2 px-1 font-medium {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
<button
type="button"
onclick={toggleSelectAll}
class="flex items-center justify-center transition-colors opacity-40 hover:opacity-100 cursor-pointer"
title={allSelected ? 'Deselect all' : 'Select all'}
>
{#if allSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else if someSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{:else if colId === 'expand' && expandable}
<!-- Expand column header is empty -->
{:else if headerCell}
{@render headerCell(colConfig!, sortState)}
{:else}
{colConfig?.label ?? ''}
{/if}
</th>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<th
class="{colConfig.align === 'right' ? 'text-right' : colConfig.align === 'center' ? 'text-center' : 'text-left'} py-2 px-2 font-medium"
style="width: {getDisplayWidth(colId)}px"
>
{#if headerCell}
{@render headerCell(colConfig, sortState)}
{:else if isSortable(colId)}
<button
type="button"
onclick={() => toggleSort(getSortField(colId))}
class="flex items-center gap-1 hover:text-foreground transition-colors w-full {colConfig.align === 'right' ? 'justify-end' : colConfig.align === 'center' ? 'justify-center' : ''}"
>
{colConfig.label}
{#if sortState?.field === getSortField(colId)}
{#if sortState.direction === 'asc'}
<ArrowUp class="w-3 h-3" />
{:else}
<ArrowDown class="w-3 h-3" />
{/if}
{:else}
<ArrowUpDown class="w-3 h-3 opacity-30" />
{/if}
</button>
{:else}
{colConfig.label}
{/if}
<!-- Resize handle -->
{#if isResizable(colId)}
<div
class="resize-handle"
use:columnResize={{
onResize: (w) => handleResize(colId, w),
onResizeEnd: (w) => handleResizeEnd(colId, w),
minWidth: colConfig.minWidth
}}
></div>
{/if}
</th>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<th class="text-right py-2 px-2 font-medium actions-col" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'actions'}
<div class="flex items-center justify-end gap-1">
<span>Actions</span>
<ColumnSettingsPopover {gridId} />
</div>
{:else if headerCell}
{@render headerCell(colConfig!, sortState)}
{:else}
{colConfig?.label ?? ''}
{/if}
<!-- Resize handle for fixed end columns -->
{#if isResizable(colId)}
<div
class="resize-handle resize-handle-left"
use:columnResize={{
onResize: (w) => handleResize(colId, w),
onResizeEnd: (w) => handleResizeEnd(colId, w),
minWidth: colConfig?.minWidth
}}
></div>
{/if}
</th>
{/each}
</tr>
</thead>
{/snippet}
{#snippet tableBody()}
<tbody>
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleSelection(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleExpand(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
<!-- Default: render as text -->
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
<!-- Expanded row content -->
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{/each}
</tbody>
{/snippet}
{#snippet tableContent()}
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
{@render tableHeader()}
{@render tableBody()}
</table>
{/snippet}
<div class="flex-1 min-h-0 overflow-auto rounded-lg data-grid-wrapper {wrapperClass}" bind:this={scrollContainer} onscroll={handleScroll}>
{#if loading && data.length === 0}
{#if loadingState}
{@render loadingState()}
{:else}
{@render skeletonContent()}
{/if}
{:else if data.length === 0 && emptyState}
{@render emptyState()}
{:else if virtualScroll}
<!-- Virtual scroll mode with spacer rows for sticky header support -->
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
{@render tableHeader()}
<tbody>
<!-- Top spacer -->
{#if offsetY > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {offsetY}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Visible rows -->
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleExpand(item[keyField]); }}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{/each}
<!-- Bottom spacer -->
{#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
{/if}
</tbody>
</table>
{:else}
<!-- Standard mode -->
{@render tableContent()}
{/if}
</div>

View File

@@ -0,0 +1,28 @@
/**
* DataGrid Context
*
* Provides shared state to child components via Svelte context.
*/
import { getContext, setContext } from 'svelte';
import type { DataGridContext } from './types';
const DATA_GRID_CONTEXT_KEY = Symbol('data-grid');
/**
* Set the DataGrid context (called by DataGrid.svelte)
*/
export function setDataGridContext<T>(ctx: DataGridContext<T>): void {
setContext(DATA_GRID_CONTEXT_KEY, ctx);
}
/**
* Get the DataGrid context (called by child components)
*/
export function getDataGridContext<T = unknown>(): DataGridContext<T> {
const ctx = getContext<DataGridContext<T>>(DATA_GRID_CONTEXT_KEY);
if (!ctx) {
throw new Error('DataGrid context not found. Ensure component is used within a DataGrid.');
}
return ctx;
}

View File

@@ -0,0 +1,16 @@
/**
* DataGrid Component
*
* A reusable, feature-rich data grid with:
* - Column resizing, hiding, reordering
* - Sticky first/last columns
* - Multi-row selection
* - Sortable headers
* - Virtual scrolling (optional)
* - Preference persistence
*/
import DataGrid from './DataGrid.svelte';
export { DataGrid };
export * from './types';
export * from './context';

View File

@@ -0,0 +1,112 @@
/**
* DataGrid Component Types
*
* Extends the base grid types with component-specific interfaces
* for the reusable DataGrid component.
*/
import type { Snippet } from 'svelte';
import type { GridId, ColumnConfig, ColumnPreference } from '$lib/types';
// Re-export base types for convenience
export type { GridId, ColumnConfig, ColumnPreference };
/**
* Sort state for the grid
*/
export interface DataGridSortState {
field: string;
direction: 'asc' | 'desc';
}
/**
* Row state passed to cell snippets
*/
export interface DataGridRowState {
isSelected: boolean;
isHighlighted: boolean;
isSelectable: boolean;
isExpanded: boolean;
index: number;
}
/**
* Main DataGrid component props
*/
export interface DataGridProps<T> {
// Required
data: T[];
keyField: keyof T;
gridId: GridId;
// Virtual Scroll Mode (OFF by default)
virtualScroll?: boolean;
rowHeight?: number;
bufferRows?: number;
// Selection
selectable?: boolean;
selectedKeys?: Set<unknown>;
onSelectionChange?: (keys: Set<unknown>) => void;
// Sorting
sortState?: DataGridSortState;
onSortChange?: (state: DataGridSortState) => void;
// Infinite scroll (virtual mode)
hasMore?: boolean;
onLoadMore?: () => void;
loadMoreThreshold?: number;
// Row interaction
onRowClick?: (item: T, event: MouseEvent) => void;
highlightedKey?: unknown;
rowClass?: (item: T) => string;
// State
loading?: boolean;
// CSS
class?: string;
wrapperClass?: string;
// Snippets for customization
headerCell?: Snippet<[ColumnConfig, DataGridSortState | undefined]>;
cell?: Snippet<[ColumnConfig, T, DataGridRowState]>;
emptyState?: Snippet;
loadingState?: Snippet;
}
/**
* Context provided to child components
*/
export interface DataGridContext<T = unknown> {
// Grid configuration
gridId: GridId;
keyField: keyof T;
// Column state
orderedColumns: string[];
getDisplayWidth: (colId: string) => number;
getColumnConfig: (colId: string) => ColumnConfig | undefined;
// Selection helpers
selectable: boolean;
isSelected: (key: unknown) => boolean;
toggleSelection: (key: unknown) => void;
selectAll: () => void;
selectNone: () => void;
allSelected: boolean;
someSelected: boolean;
// Sort helpers
sortState: DataGridSortState | undefined;
toggleSort: (field: string) => void;
// Resize helpers
handleResize: (colId: string, width: number) => void;
handleResizeEnd: (colId: string, width: number) => void;
// Row state
highlightedKey: unknown;
}

View File

@@ -0,0 +1,466 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2 } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { Button } from '$lib/components/ui/button';
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
import { sseConnected } from '$lib/stores/events';
import { getIconComponent } from '$lib/utils/icons';
import { toast } from 'svelte-sonner';
import { themeStore, type FontSize } from '$lib/stores/theme';
// Font size scaling for header
let fontSize = $state<FontSize>('normal');
themeStore.subscribe(prefs => fontSize = prefs.fontSize);
// Derive text and icon size classes based on font size
const textSizeClass = $derived(() => {
switch (fontSize) {
case 'small': return 'text-xs';
case 'normal': return 'text-xs';
case 'medium': return 'text-sm';
case 'large': return 'text-sm';
case 'xlarge': return 'text-base';
default: return 'text-xs';
}
});
const iconSizeClass = $derived(() => {
switch (fontSize) {
case 'small': return 'h-3 w-3';
case 'normal': return 'h-3 w-3';
case 'medium': return 'h-3.5 w-3.5';
case 'large': return 'h-4 w-4';
case 'xlarge': return 'h-4 w-4';
default: return 'h-3 w-3';
}
});
const iconSizeLargeClass = $derived(() => {
switch (fontSize) {
case 'small': return 'h-3.5 w-3.5';
case 'normal': return 'h-3.5 w-3.5';
case 'medium': return 'h-4 w-4';
case 'large': return 'h-5 w-5';
case 'xlarge': return 'h-5 w-5';
default: return 'h-3.5 w-3.5';
}
});
interface HostInfo {
hostname: string;
ipAddress: string;
platform: string;
arch: string;
cpus: number;
totalMemory: number;
freeMemory: number;
uptime: number;
dockerVersion: string;
dockerContainers: number;
dockerContainersRunning: number;
dockerImages: number;
environment: Environment & { icon?: string; connectionType?: string; hawserVersion?: string };
}
interface DiskUsageInfo {
LayersSize: number;
Images: any[];
Containers: any[];
Volumes: any[];
BuildCache: any[];
}
let hostInfo = $state<HostInfo | null>(null);
let diskUsage = $state<DiskUsageInfo | null>(null);
let diskUsageLoading = $state(false);
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
let showDropdown = $state(false);
let currentEnvId = $state<number | null>(null);
let lastUpdated = $state<Date>(new Date());
let isConnected = $state(false);
let initializedFromStore = false;
let switchingEnvId = $state<number | null>(null); // Track which env is being switched to
let offlineEnvIds = $state<Set<number>>(new Set()); // Track offline environments
// Abort all pending requests for current environment
function abortPendingRequests() {
if (envAbortController) {
envAbortController.abort();
envAbortController = null;
}
}
// Reactive environment list from store
let envList = $derived($environments);
sseConnected.subscribe(v => isConnected = v);
// Subscribe to the store and react to changes (including from command palette)
currentEnvironment.subscribe(env => {
if (env) {
// Only update if different to avoid loops and unnecessary fetches
// Use Number() for type-safe comparison
if (Number(env.id) !== Number(currentEnvId)) {
currentEnvId = env.id;
// Fetch new host info for the changed environment
if (initializedFromStore) {
fetchHostInfo();
fetchDiskUsage();
}
}
initializedFromStore = true;
} else if (!env && envList.length > 0 && currentEnvId === null) {
// Set current env to first if not restored from store
currentEnvId = envList[0].id;
}
});
// Watch for when current environment is deleted, all environments removed, or no env selected
// IMPORTANT: Don't clear state when envList is empty during initial load - wait for environments to load first
$effect(() => {
// Skip if environments haven't loaded yet - the store subscription will handle initial setup
if (envList.length === 0) return;
if (currentEnvId === null) {
// No environment selected - select first one
currentEnvId = envList[0].id;
fetchHostInfo();
fetchDiskUsage();
} else {
// Use Number() for type-safe comparison in case of string/number mismatch
const stillExists = envList.find((e: Environment) => Number(e.id) === Number(currentEnvId));
if (!stillExists) {
// Current environment was deleted - select first one
currentEnvId = envList[0].id;
fetchHostInfo();
fetchDiskUsage();
}
}
});
async function fetchHostInfo() {
// Skip if no environment selected or no abort controller
if (!currentEnvId || !envAbortController) return;
try {
const url = `/api/host?env=${currentEnvId}`;
const response = await fetch(url, { signal: envAbortController.signal });
if (response.ok) {
hostInfo = await response.json();
lastUpdated = new Date();
if (hostInfo?.environment) {
currentEnvId = hostInfo.environment.id;
// Update the store
currentEnvironment.set({
id: hostInfo.environment.id,
name: hostInfo.environment.name,
highlightChanges: hostInfo.environment.highlightChanges ?? true
});
}
}
} catch (error) {
// Ignore abort errors
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch host info:', error);
}
}
}
async function fetchDiskUsage() {
// Skip if no environment selected or no abort controller
if (!currentEnvId || !envAbortController) return;
diskUsage = null;
diskUsageLoading = true;
try {
const url = `/api/system/disk?env=${currentEnvId}`;
const response = await fetch(url, { signal: envAbortController.signal });
if (response.ok) {
const data = await response.json();
diskUsage = data.diskUsage;
}
} catch (error) {
// Ignore abort errors
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to fetch disk usage:', error);
}
diskUsage = null;
} finally {
diskUsageLoading = false;
}
}
// Calculate total disk usage
let totalDiskUsage = $derived(() => {
if (!diskUsage) return 0;
return (diskUsage.LayersSize || 0) +
(diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0);
});
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
async function switchEnvironment(envId: number) {
// Don't switch if already on this environment
if (Number(envId) === Number(currentEnvId)) {
showDropdown = false;
return;
}
// Don't switch if already switching
if (switchingEnvId !== null) {
return;
}
// IMMEDIATELY abort all pending requests for current environment
abortPendingRequests();
// Clear stale data immediately for instant UI feedback
diskUsage = null;
diskUsageLoading = false;
const targetEnv = envList.find((e: Environment) => Number(e.id) === Number(envId));
const envName = targetEnv?.name || `Environment ${envId}`;
// Mark as switching and create new abort controller
switchingEnvId = envId;
showDropdown = false;
envAbortController = new AbortController();
try {
// Try to connect to the new environment first
const url = `/api/host?env=${envId}`;
const response = await fetch(url, { signal: envAbortController.signal });
if (!response.ok) {
offlineEnvIds.add(envId);
offlineEnvIds = new Set(offlineEnvIds);
toast.error(`Cannot switch to "${envName}" - environment is offline`);
return;
}
const newHostInfo = await response.json();
if (newHostInfo.error) {
offlineEnvIds.add(envId);
offlineEnvIds = new Set(offlineEnvIds);
toast.error(`Cannot switch to "${envName}" - ${newHostInfo.error}`);
return;
}
// Environment is online, proceed with switch
offlineEnvIds.delete(envId);
offlineEnvIds = new Set(offlineEnvIds);
currentEnvId = envId;
hostInfo = newHostInfo;
lastUpdated = new Date();
// Fetch disk usage (non-blocking, uses shared abort controller)
fetchDiskUsage();
// Update the store
if (newHostInfo.environment) {
currentEnvironment.set({
id: newHostInfo.environment.id,
name: newHostInfo.environment.name,
highlightChanges: newHostInfo.environment.highlightChanges ?? true
});
}
} catch (error) {
// Ignore abort errors
if (error instanceof Error && error.name === 'AbortError') {
return;
}
offlineEnvIds.add(envId);
offlineEnvIds = new Set(offlineEnvIds);
toast.error(`Cannot switch to "${envName}" - connection failed`);
} finally {
switchingEnvId = null;
}
}
function formatMemory(bytes: number): string {
const gb = bytes / (1024 * 1024 * 1024);
return `${gb.toFixed(1)} GB`;
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days > 0) {
return `${days}d ${hours}h`;
}
return `${hours}h`;
}
let memoryPercent = $derived(
hostInfo ? ((hostInfo.totalMemory - hostInfo.freeMemory) / hostInfo.totalMemory) * 100 : 0
);
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.env-dropdown')) {
showDropdown = false;
}
}
onMount(() => {
// Create initial abort controller
envAbortController = new AbortController();
fetchHostInfo();
fetchDiskUsage();
const hostInterval = setInterval(fetchHostInfo, 30000);
const diskInterval = setInterval(fetchDiskUsage, 30000);
document.addEventListener('click', handleClickOutside);
return () => {
abortPendingRequests(); // Abort on destroy
clearInterval(hostInterval);
clearInterval(diskInterval);
document.removeEventListener('click', handleClickOutside);
};
});
</script>
<div class="flex items-center gap-3 min-w-0 {textSizeClass()} text-muted-foreground">
<!-- Environment Selector - always show -->
<div class="relative env-dropdown">
<button
onclick={() => (showDropdown = !showDropdown)}
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
>
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
{:else if currentEnvId && envList.length > 0}
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
{#if currentEnv}
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
<span class="font-medium text-foreground">{currentEnv.name}</span>
{:else}
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
<span class="font-medium text-foreground">Select environment</span>
{/if}
{:else}
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
<span class="font-medium text-foreground">No environments</span>
{/if}
<ChevronDown class="{iconSizeClass()}" />
</button>
{#if showDropdown && envList.length > 0}
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
<div class="py-1">
{#each envList as env (env.id)}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
{@const isOffline = offlineEnvIds.has(env.id)}
{@const isSwitching = switchingEnvId === env.id}
<button
onclick={() => switchEnvironment(env.id)}
disabled={isSwitching}
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left cursor-pointer disabled:cursor-wait disabled:opacity-70"
class:opacity-60={isOffline && !isSwitching}
>
{#if isSwitching}
<Loader2 class="{iconSizeLargeClass()} text-muted-foreground shrink-0 animate-spin" />
{:else if isOffline}
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
{:else}
<EnvIcon class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
{/if}
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
{#if isOffline && !isSwitching}
<span class="text-xs text-destructive">offline</span>
{:else if Number(env.id) === Number(currentEnvId)}
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{#if hostInfo}
<span class="text-border">|</span>
<!-- Platform/OS -->
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
<span class="hidden md:inline text-border">|</span>
<!-- Docker version -->
<span class="hidden md:inline">Docker {hostInfo.dockerVersion}</span>
<span class="hidden md:inline text-border">|</span>
<!-- Connection type -->
<div class="hidden md:flex items-center gap-1">
{#if hostInfo.environment?.connectionType === 'hawser-standard'}
<Route class="{iconSizeClass()}" />
<span>Hawser (standard){hostInfo.environment.hawserVersion ? ` ${hostInfo.environment.hawserVersion}` : ''}</span>
{:else if hostInfo.environment?.connectionType === 'hawser-edge'}
<UndoDot class="{iconSizeClass()}" />
<span>Hawser (edge){hostInfo.environment.hawserVersion ? ` ${hostInfo.environment.hawserVersion}` : ''}</span>
{:else}
<Icon iconNode={whale} class="{iconSizeClass()}" />
<span>Socket</span>
{/if}
</div>
<span class="hidden md:inline text-border">|</span>
<!-- CPU cores -->
{#if hostInfo.cpus > 0}
<span class="hidden lg:inline">{hostInfo.cpus} cores</span>
<span class="hidden lg:inline text-border">|</span>
{/if}
<!-- Memory -->
{#if hostInfo.totalMemory > 0}
<span class="hidden lg:inline">{formatBytes(hostInfo.totalMemory)} RAM</span>
<span class="hidden lg:inline text-border">|</span>
{/if}
<!-- Disk usage - only show when data is available (hide on timeout/error) -->
{#if diskUsage && !diskUsageLoading}
<div class="hidden xl:flex items-center gap-1">
<HardDrive class="{iconSizeClass()}" />
<span>{formatBytes(totalDiskUsage())}</span>
</div>
<span class="hidden xl:inline text-border">|</span>
{/if}
<!-- Uptime - hidden for direct remote connections without Hawser -->
{#if hostInfo.uptime > 0}
<div class="hidden xl:flex items-center gap-1">
<Clock class="{iconSizeClass()}" />
<span>{formatUptime(hostInfo.uptime)}</span>
</div>
<span class="hidden xl:inline text-border">|</span>
{/if}
<!-- Live indicator with timestamp -->
<div
class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}"
title={isConnected ? 'Live updates connected' : 'Live updates disconnected'}
>
<span class="text-muted-foreground">{lastUpdated.toLocaleTimeString()}</span>
{#if isConnected}
<Wifi class="{iconSizeLargeClass()}" />
<span class="font-medium">Live</span>
{:else}
<WifiOff class="{iconSizeLargeClass()}" />
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import * as Popover from '$lib/components/ui/popover';
import { iconMap, getIconComponent } from '$lib/utils/icons';
interface Props {
value: string;
onchange: (icon: string) => void;
}
let { value, onchange }: Props = $props();
let searchQuery = $state('');
let open = $state(false);
const allIcons = Object.keys(iconMap);
let filteredIcons = $derived(
searchQuery.trim()
? allIcons.filter(name => name.toLowerCase().includes(searchQuery.toLowerCase()))
: allIcons
);
function selectIcon(iconName: string) {
onchange(iconName);
open = false;
}
// Get the current icon component
let CurrentIcon = $derived(getIconComponent(value || 'globe'));
</script>
<Popover.Root bind:open>
<Popover.Trigger>
<Button variant="outline" size="sm" class="h-9 w-9 p-0" type="button">
<CurrentIcon class="h-4 w-4" />
</Button>
</Popover.Trigger>
<Popover.Content class="w-80 p-3" align="start">
<div class="space-y-3">
<Input
bind:value={searchQuery}
placeholder="Search icons..."
class="h-8"
/>
<div class="grid grid-cols-8 gap-1 max-h-48 overflow-y-auto">
{#each filteredIcons as iconName}
{@const IconComponent = iconMap[iconName]}
<button
type="button"
onclick={() => selectIcon(iconName)}
class="p-2 rounded hover:bg-muted transition-colors {value === iconName ? 'bg-primary/10 ring-1 ring-primary' : ''}"
title={iconName}
>
<IconComponent class="h-4 w-4" />
</button>
{/each}
</div>
{#if filteredIcons.length === 0}
<p class="text-sm text-muted-foreground text-center py-2">No icons found</p>
{/if}
</div>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children?: Snippet } = $props();
</script>
<main class="bg-background flex w-full flex-1 flex-col min-w-0">
{@render children?.()}
</main>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { authStore, canAccess } from '$lib/stores/auth';
import type { Snippet } from 'svelte';
interface Props {
resource: string;
action: string;
children: Snippet;
fallback?: Snippet;
}
let { resource, action, children, fallback }: Props = $props();
// Check if user can access the resource/action
const hasAccess = $derived($canAccess(resource, action));
</script>
{#if hasAccess}
{@render children()}
{:else if fallback}
{@render fallback()}
{/if}

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Sun, Moon } from 'lucide-svelte';
import { onMount } from 'svelte';
import { onDarkModeChange } from '$lib/stores/theme';
let isDark = $state(false);
onMount(() => {
// Check for saved preference or system preference
const saved = localStorage.getItem('theme');
if (saved) {
isDark = saved === 'dark';
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
updateTheme();
});
function updateTheme() {
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// Apply the correct theme colors for the new mode
onDarkModeChange();
}
function toggleTheme() {
isDark = !isDark;
updateTheme();
}
</script>
<Button variant="ghost" size="icon" onclick={toggleTheme} class="h-9 w-9">
{#if isDark}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
<span class="sr-only">Toggle theme</span>
</Button>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
</script>
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...restProps}
>
<div class={cn("pb-4 pt-0", className)}>
{@render children?.()}
</div>
</AccordionPrimitive.Content>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AccordionPrimitive.ItemProps = $props();
</script>
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn("border-b last:border-b-0", className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
level?: AccordionPrimitive.HeaderProps["level"];
} = $props();
</script>
<AccordionPrimitive.Header {level} class="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
bind:ref
class={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
<AccordionPrimitive.Root
bind:ref
bind:value={value as never}
data-slot="accordion"
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
import Root from "./accordion.svelte";
import Content from "./accordion-content.svelte";
import Item from "./accordion-item.svelte";
import Trigger from "./accordion-trigger.svelte";
export {
Root,
Content,
Item,
Trigger,
//
Root as Accordion,
Content as AccordionContent,
Item as AccordionItem,
Trigger as AccordionTrigger,
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-destructive/10 border-destructive/20 *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
warning:
"text-amber-700 dark:text-amber-400 bg-amber-500/10 border-amber-500/20 *:data-[slot=alert-description]:text-amber-600 dark:*:data-[slot=alert-description]:text-amber-400/90 [&>svg]:text-current",
success:
"text-green-700 dark:text-green-400 bg-green-500/10 border-green-500/20 *:data-[slot=alert-description]:text-green-600 dark:*:data-[slot=alert-description]:text-green-400/90 [&>svg]:text-current",
info:
"text-blue-700 dark:text-blue-400 bg-blue-500/10 border-blue-500/20 *:data-[slot=alert-description]:text-blue-600 dark:*:data-[slot=alert-description]:text-blue-400/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View File

@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import type Calendar from "./calendar.svelte";
import CalendarMonthSelect from "./calendar-month-select.svelte";
import CalendarYearSelect from "./calendar-year-select.svelte";
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
let {
captionLayout,
months,
monthFormat,
years,
yearFormat,
month,
locale,
placeholder = $bindable(),
monthIndex = 0,
}: {
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
months: ComponentProps<typeof CalendarMonthSelect>["months"];
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
years: ComponentProps<typeof CalendarYearSelect>["years"];
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
month: DateValue;
placeholder: DateValue | undefined;
locale: string;
monthIndex: number;
} = $props();
function formatYear(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
}
function formatMonth(date: DateValue) {
const dateObj = date.toDate(getLocalTimeZone());
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
}
</script>
{#snippet MonthSelect()}
<CalendarMonthSelect
{months}
{monthFormat}
value={month.month}
onchange={(e) => {
if (!placeholder) return;
const v = Number.parseInt(e.currentTarget.value);
const newPlaceholder = placeholder.set({ month: v });
placeholder = newPlaceholder.subtract({ months: monthIndex });
}}
/>
{/snippet}
{#snippet YearSelect()}
<CalendarYearSelect {years} {yearFormat} value={month.year} />
{/snippet}
{#if captionLayout === "dropdown"}
{@render MonthSelect()}
{@render YearSelect()}
{:else if captionLayout === "dropdown-months"}
{@render MonthSelect()}
{#if placeholder}
{formatYear(placeholder)}
{/if}
{:else if captionLayout === "dropdown-years"}
{#if placeholder}
{formatMonth(placeholder)}
{/if}
{@render YearSelect()}
{:else}
{formatMonth(month)} {formatYear(month)}
{/if}

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.CellProps = $props();
</script>
<CalendarPrimitive.Cell
bind:ref
class={cn(
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.DayProps = $props();
</script>
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
"[&>span]:text-xs [&>span]:opacity-70",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridBodyProps = $props();
</script>
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridHeadProps = $props();
</script>
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridRowProps = $props();
</script>
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.GridProps = $props();
</script>
<CalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeaderProps = $props();
</script>
<CalendarPrimitive.Header
bind:ref
class={cn(
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CalendarPrimitive.HeadingProps = $props();
</script>
<CalendarPrimitive.Heading
bind:ref
class={cn("px-(--cell-size) text-sm font-medium", className)}
{...restProps}
/>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
onchange,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
className
)}
>
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, monthItems, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
<option
value={monthItem.value}
selected={value !== undefined
? monthItem.value === value
: monthItem.value === selectedMonthItem.value}
>
{monthItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pe-1 ps-2 text-sm font-medium [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.MonthSelect>
</span>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { type WithElementRef, cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<nav
{...restProps}
bind:this={ref}
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
>
{@render children?.()}
</nav>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.NextButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronRightIcon class="size-4" />
{/snippet}
<CalendarPrimitive.NextButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
variant = "ghost",
...restProps
}: CalendarPrimitive.PrevButtonProps & {
variant?: ButtonVariant;
} = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
{/snippet}
<CalendarPrimitive.PrevButton
bind:ref
class={cn(
buttonVariants({ variant }),
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
let {
ref = $bindable(null),
class: className,
value,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
</script>
<span
class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
className
)}
>
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
{#snippet child({ props, yearItems, selectedYearItem })}
<select {...props} {value}>
{#each yearItems as yearItem (yearItem.value)}
<option
value={yearItem.value}
selected={value !== undefined
? yearItem.value === value
: yearItem.value === selectedYearItem.value}
>
{yearItem.label}
</option>
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pe-1 ps-2 text-sm font-medium [&>svg]:size-3.5"
aria-hidden="true"
>
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
<ChevronDownIcon class="size-4" />
</span>
{/snippet}
</CalendarPrimitive.YearSelect>
</span>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { Calendar as CalendarPrimitive } from "bits-ui";
import * as Calendar from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ButtonVariant } from "../button/button.svelte";
import { isEqualMonth, type DateValue } from "@internationalized/date";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
value = $bindable(),
placeholder = $bindable(),
class: className,
weekdayFormat = "short",
buttonVariant = "ghost",
captionLayout = "label",
locale = "en-US",
months: monthsProp,
years,
monthFormat: monthFormatProp,
yearFormat = "numeric",
day,
disableDaysOutsideMonth = false,
...restProps
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
buttonVariant?: ButtonVariant;
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
months?: CalendarPrimitive.MonthSelectProps["months"];
years?: CalendarPrimitive.YearSelectProps["years"];
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
} = $props();
const monthFormat = $derived.by(() => {
if (monthFormatProp) return monthFormatProp;
if (captionLayout.startsWith("dropdown")) return "short";
return "long";
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<CalendarPrimitive.Root
bind:value={value as never}
bind:ref
bind:placeholder
{weekdayFormat}
{disableDaysOutsideMonth}
class={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
className
)}
{locale}
{monthFormat}
{yearFormat}
{...restProps}
>
{#snippet children({ months, weekdays })}
<Calendar.Months>
<Calendar.Nav>
<Calendar.PrevButton variant={buttonVariant} />
<Calendar.NextButton variant={buttonVariant} />
</Calendar.Nav>
{#each months as month, monthIndex (month)}
<Calendar.Month>
<Calendar.Header>
<Calendar.Caption
{captionLayout}
months={monthsProp}
{monthFormat}
{years}
{yearFormat}
month={month.value}
bind:placeholder
{locale}
{monthIndex}
/>
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead>
<Calendar.GridRow class="select-none">
{#each weekdays as weekday (weekday)}
<Calendar.HeadCell>
{weekday.slice(0, 2)}
</Calendar.HeadCell>
{/each}
</Calendar.GridRow>
</Calendar.GridHead>
<Calendar.GridBody>
{#each month.weeks as weekDates (weekDates)}
<Calendar.GridRow class="mt-2 w-full">
{#each weekDates as date (date)}
<Calendar.Cell {date} month={month.value}>
{#if day}
{@render day({
day: date,
outsideMonth: !isEqualMonth(date, month.value),
})}
{:else}
<Calendar.Day />
{/if}
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Month>
{/each}
</Calendar.Months>
{/snippet}
</CalendarPrimitive.Root>

View File

@@ -0,0 +1,40 @@
import Root from "./calendar.svelte";
import Cell from "./calendar-cell.svelte";
import Day from "./calendar-day.svelte";
import Grid from "./calendar-grid.svelte";
import Header from "./calendar-header.svelte";
import Months from "./calendar-months.svelte";
import GridRow from "./calendar-grid-row.svelte";
import Heading from "./calendar-heading.svelte";
import GridBody from "./calendar-grid-body.svelte";
import GridHead from "./calendar-grid-head.svelte";
import HeadCell from "./calendar-head-cell.svelte";
import NextButton from "./calendar-next-button.svelte";
import PrevButton from "./calendar-prev-button.svelte";
import MonthSelect from "./calendar-month-select.svelte";
import YearSelect from "./calendar-year-select.svelte";
import Month from "./calendar-month.svelte";
import Nav from "./calendar-nav.svelte";
import Caption from "./calendar-caption.svelte";
export {
Day,
Cell,
Grid,
Header,
Months,
GridRow,
Heading,
GridBody,
GridHead,
HeadCell,
NextButton,
PrevButton,
Nav,
Month,
YearSelect,
MonthSelect,
Caption,
//
Root as Calendar,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import { Check as CheckIcon } from "lucide-svelte";
import { Minus as MinusIcon } from "lucide-svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
import type { Snippet } from "svelte";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
open = $bindable(false),
ref = $bindable(null),
value = $bindable(""),
title = "Command Palette",
description = "Search for a command to run",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
title?: string;
description?: string;
} = $props();
</script>
<Dialog.Root bind:open {...restProps}>
<Dialog.Header class="sr-only">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{description}</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
<Command
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-group]]:px-2 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
{...restProps}
bind:value
bind:ref
{children}
/>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.EmptyProps = $props();
</script>
<CommandPrimitive.Empty
bind:ref
data-slot="command-empty"
class={cn("py-6 text-center text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Command as CommandPrimitive, useId } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
heading,
value,
...restProps
}: CommandPrimitive.GroupProps & {
heading?: string;
} = $props();
</script>
<CommandPrimitive.Group
bind:ref
data-slot="command-group"
class={cn("text-foreground overflow-hidden p-1", className)}
value={value ?? heading ?? `----${useId()}`}
{...restProps}
>
{#if heading}
<CommandPrimitive.GroupHeading
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
>
{heading}
</CommandPrimitive.GroupHeading>
{/if}
<CommandPrimitive.GroupItems {children} />
</CommandPrimitive.Group>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import SearchIcon from "@lucide/svelte/icons/search";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: CommandPrimitive.InputProps = $props();
</script>
<div class="flex h-9 items-center gap-2 border-b pe-8 ps-3" data-slot="command-input-wrapper">
<SearchIcon class="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
class={cn(
"placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:ref
{...restProps}
bind:value
/>
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ItemProps = $props();
</script>
<CommandPrimitive.Item
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.LinkItemProps = $props();
</script>
<CommandPrimitive.LinkItem
bind:ref
data-slot="command-item"
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.ListProps = $props();
</script>
<CommandPrimitive.List
bind:ref
data-slot="command-list"
class={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CommandPrimitive.LoadingProps = $props();
</script>
<CommandPrimitive.Loading bind:ref {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Command as CommandPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: CommandPrimitive.SeparatorProps = $props();
</script>
<CommandPrimitive.Separator
bind:ref
data-slot="command-separator"
class={cn("bg-border -mx-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="command-shortcut"
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Command as CommandPrimitive } from "bits-ui";
export type CommandRootApi = CommandPrimitive.Root;
let {
api = $bindable(null),
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: CommandPrimitive.RootProps & {
api?: CommandRootApi | null;
} = $props();
</script>
<CommandPrimitive.Root
bind:this={api}
bind:value
bind:ref
data-slot="command"
class={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,37 @@
import Root from "./command.svelte";
import Loading from "./command-loading.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
import LinkItem from "./command-link-item.svelte";
export {
Root,
Dialog,
Empty,
Group,
Item,
LinkItem,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
LinkItem as CommandLinkItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { Calendar as CalendarIcon } from 'lucide-svelte';
import { type DateValue, getLocalTimeZone, parseDate, today } from '@internationalized/date';
import { cn } from '$lib/utils.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Calendar } from '$lib/components/ui/calendar/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import { formatDate } from '$lib/stores/settings';
interface Props {
/** Value in YYYY-MM-DD format (for compatibility with existing code) */
value: string;
placeholder?: string;
class?: string;
}
let { value = $bindable(''), placeholder = 'Pick a date', class: className = '' }: Props = $props();
let open = $state(false);
// Convert YYYY-MM-DD string to DateValue
const dateValue = $derived.by(() => {
if (!value) return undefined;
try {
return parseDate(value);
} catch {
return undefined;
}
});
// Format display using user's preferred format
const displayValue = $derived(value ? formatDate(value + 'T00:00:00') : '');
// Handle calendar selection
function onSelect(newValue: DateValue | undefined) {
if (newValue) {
// Convert DateValue back to YYYY-MM-DD string
const year = newValue.year;
const month = String(newValue.month).padStart(2, '0');
const day = String(newValue.day).padStart(2, '0');
value = `${year}-${month}-${day}`;
} else {
value = '';
}
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
class={cn(
buttonVariants({ variant: 'outline' }),
'w-[140px] justify-start text-left font-normal h-8',
!value && 'text-muted-foreground',
className
)}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{#if displayValue}
<span class="text-xs">{displayValue}</span>
{:else}
<span class="text-xs">{placeholder}</span>
{/if}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<Calendar
type="single"
value={dateValue}
onValueChange={onSelect}
initialFocus
/>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,3 @@
import DatePicker from './date-picker.svelte';
export { DatePicker };

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

Some files were not shown because too many files have changed in this diff Show More