proper src structure, dockerfile, entrypoint

This commit is contained in:
jarek committed 2025-12-29 08:40:11 +01:00
1 parent e5828c7d31
commit 73c9f580a1
556 files changed
+1390

No files matched your search

+1717
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { AuthenticatedUser } from '$lib/server/auth';
// Build-time constants injected by Vite
declare const __BUILD_DATE__: string | null;
declare const __BUILD_COMMIT__: string | null;
declare global {
namespace App {
// interface Error {}
interface Locals {
user: AuthenticatedUser | null;
authEnabled: boolean;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+168
View File
@@ -0,0 +1,168 @@
import { initDatabase, hasAdminUser } from '$lib/server/db';
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
import { startScheduler } from '$lib/server/scheduler';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { setServerStartTime } from '$lib/server/uptime';
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
// License expiry check interval (24 hours)
const LICENSE_CHECK_INTERVAL = 86400000;
// HMR guard for license check interval
declare global {
var __licenseCheckInterval: ReturnType<typeof setInterval> | undefined;
}
// Initialize database on server start (synchronous with SQLite)
let initialized = false;
if (!initialized) {
try {
setServerStartTime(); // Track when server started
initDatabase();
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
console.log('Hostname for license validation:', getHostname());
// Start background subprocesses for metrics and event collection (isolated processes)
startSubprocesses().catch(err => {
console.error('Failed to start background subprocesses:', err);
});
startScheduler(); // Start unified scheduler for auto-updates and git syncs (async)
// Check license expiry on startup and then daily (with HMR guard)
checkLicenseExpiry().catch(err => {
console.error('Failed to check license expiry:', err);
});
if (!globalThis.__licenseCheckInterval) {
globalThis.__licenseCheckInterval = setInterval(() => {
checkLicenseExpiry().catch(err => {
console.error('Failed to check license expiry:', err);
});
}, LICENSE_CHECK_INTERVAL);
}
// Graceful shutdown handling
const shutdown = async () => {
console.log('[Server] Shutting down...');
await stopSubprocesses();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
initialized = true;
} catch (error) {
console.error('Failed to initialize database:', error);
}
}
// Routes that don't require authentication
const PUBLIC_PATHS = [
'/login',
'/api/auth/login',
'/api/auth/logout',
'/api/auth/session',
'/api/auth/settings',
'/api/auth/providers',
'/api/auth/oidc',
'/api/license',
'/api/changelog',
'/api/dependencies'
];
// Check if path is public
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/'));
}
// Check if path is a static asset
function isStaticAsset(pathname: string): boolean {
return pathname.startsWith('/_app/') ||
pathname.startsWith('/favicon') ||
pathname.endsWith('.webp') ||
pathname.endsWith('.png') ||
pathname.endsWith('.jpg') ||
pathname.endsWith('.svg') ||
pathname.endsWith('.ico') ||
pathname.endsWith('.css') ||
pathname.endsWith('.js');
}
export const handle: Handle = async ({ event, resolve }) => {
// Skip auth for static assets
if (isStaticAsset(event.url.pathname)) {
return resolve(event);
}
// WebSocket upgrade for terminal connections is handled by the build patch (scripts/patch-build.ts)
// This is necessary because svelte-adapter-bun expects server.websocket() which doesn't exist in SvelteKit
// Check if auth is enabled
const authEnabled = await isAuthEnabled();
// If auth is disabled, allow everything (app works as before)
if (!authEnabled) {
event.locals.user = null;
event.locals.authEnabled = false;
return resolve(event);
}
// Auth is enabled - check session
const user = await validateSession(event.cookies);
event.locals.user = user;
event.locals.authEnabled = true;
// Public paths don't require authentication
if (isPublicPath(event.url.pathname)) {
return resolve(event);
}
// If not authenticated
if (!user) {
// Special case: allow user creation when auth is enabled but no admin exists yet
// This enables the first admin user to be created during initial setup
const noAdminSetupMode = !(await hasAdminUser());
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
return resolve(event);
}
// API routes return 401
if (event.url.pathname.startsWith('/api/')) {
return new Response(
JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
}
);
}
// UI routes redirect to login
const redirectUrl = encodeURIComponent(event.url.pathname + event.url.search);
redirect(307, `/login?redirect=${redirectUrl}`);
}
return resolve(event);
};
export const handleError: HandleServerError = ({ error, event }) => {
// Skip logging 404 errors - they're expected for missing routes
const status = (error as { status?: number })?.status;
if (status === 404) {
return {
message: 'Not found',
code: 'NOT_FOUND'
};
}
// Log only essential error info without code snippets
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[Error] ${event.url.pathname}: ${message}`);
return {
message,
code: 'INTERNAL_ERROR'
};
};
// CI trigger 1766327149
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+77
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
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

+274
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}
@@ -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>
+821
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>
@@ -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>
+355
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>
+114
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>
@@ -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>
@@ -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>
+83
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>
@@ -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}
+506
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>
+308
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>
+390
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>
@@ -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>
+106
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>
@@ -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>
+236
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>
+247
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>
+142
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>
@@ -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>
@@ -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>
@@ -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}
@@ -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>
@@ -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>
+81
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>
+195
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>
+308
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>
@@ -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>
+28
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;
}
+16
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';
+112
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;
}
+466
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>
+65
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>
+9
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>
@@ -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}
+44
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>
@@ -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>
@@ -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}
/>
@@ -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>
@@ -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}
/>
+16
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,
};
@@ -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>
@@ -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>
+50
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>
+14
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,
};
@@ -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}
/>
@@ -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}
/>
@@ -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}
/>
+13
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,
};
+50
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>
+2
View File
@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
@@ -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}
+17
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,
};
@@ -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}
@@ -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}
/>
@@ -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}
/>
@@ -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} />
@@ -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} />
@@ -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} />
@@ -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}
/>
@@ -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}
/>
@@ -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}
/>
@@ -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}
/>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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}
/>
@@ -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}
/>
@@ -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>
@@ -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>
+40
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,
};
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+23
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>
+25
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,
};
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -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>
@@ -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}
/>
@@ -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>
@@ -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>
@@ -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}
/>
@@ -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}
/>
@@ -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}
/>
@@ -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} />
@@ -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}
/>
@@ -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>
@@ -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}
/>
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff. Show more