mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
Initial commit
This commit is contained in:
BIN
lib/.DS_Store
vendored
Normal file
BIN
lib/.DS_Store
vendored
Normal file
Binary file not shown.
77
lib/actions/column-resize.ts
Normal file
77
lib/actions/column-resize.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Svelte action for column resize handles
|
||||
*
|
||||
* Usage:
|
||||
* <div class="resize-handle" use:columnResize={{ onResize, onResizeEnd, minWidth }} />
|
||||
*/
|
||||
|
||||
export interface ColumnResizeParams {
|
||||
onResize: (width: number) => void;
|
||||
onResizeEnd: (width: number) => void;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
export function columnResize(node: HTMLElement, params: ColumnResizeParams) {
|
||||
let startX: number;
|
||||
let startWidth: number;
|
||||
let currentWidth: number;
|
||||
let currentParams = params;
|
||||
let isLeftHandle = false;
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get the parent th/td element's width
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
// Check if this is a left-side resize handle
|
||||
isLeftHandle = node.classList.contains('resize-handle-left');
|
||||
|
||||
startX = e.clientX;
|
||||
startWidth = parent.offsetWidth;
|
||||
currentWidth = startWidth;
|
||||
|
||||
// Set cursor for entire document during drag
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
let delta = e.clientX - startX;
|
||||
// For left-side handles, invert the delta (drag left = wider)
|
||||
if (isLeftHandle) {
|
||||
delta = -delta;
|
||||
}
|
||||
currentWidth = Math.max(currentParams.minWidth ?? 50, startWidth + delta);
|
||||
currentParams.onResize(currentWidth);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Use the calculated width, not the rendered width
|
||||
currentParams.onResizeEnd(currentWidth);
|
||||
}
|
||||
|
||||
node.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
return {
|
||||
update(newParams: ColumnResizeParams) {
|
||||
currentParams = newParams;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('mousedown', onMouseDown);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
};
|
||||
}
|
||||
1
lib/assets/favicon.svg
Normal file
1
lib/assets/favicon.svg
Normal 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
lib/components/AvatarCropper.svelte
Normal file
274
lib/components/AvatarCropper.svelte
Normal 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}
|
||||
332
lib/components/BatchOperationModal.svelte
Normal file
332
lib/components/BatchOperationModal.svelte
Normal file
@@ -0,0 +1,332 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
const progressText: Record<string, string> = {
|
||||
remove: 'removing',
|
||||
start: 'starting',
|
||||
stop: 'stopping',
|
||||
restart: 'restarting',
|
||||
down: 'stopping'
|
||||
};
|
||||
|
||||
// Local type definitions (matching server types)
|
||||
type ItemStatus = 'pending' | 'processing' | 'success' | 'error' | 'cancelled';
|
||||
|
||||
type BatchEvent =
|
||||
| { type: 'start'; total: number }
|
||||
| { type: 'progress'; id: string; name: string; status: ItemStatus; message?: string; error?: string; current: number; total: number }
|
||||
| { type: 'complete'; summary: { total: number; success: number; failed: number } }
|
||||
| { type: 'error'; error: string };
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
operation: string;
|
||||
entityType: 'containers' | 'images' | 'volumes' | 'networks' | 'stacks';
|
||||
items: Array<{ id: string; name: string }>;
|
||||
envId?: number;
|
||||
options?: Record<string, any>;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
title,
|
||||
operation,
|
||||
entityType,
|
||||
items,
|
||||
envId,
|
||||
options = {},
|
||||
onClose,
|
||||
onComplete
|
||||
}: Props = $props();
|
||||
|
||||
// State
|
||||
type ItemState = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ItemStatus;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
let itemStates = $state<ItemState[]>([]);
|
||||
let isRunning = $state(false);
|
||||
let isComplete = $state(false);
|
||||
let successCount = $state(0);
|
||||
let failCount = $state(0);
|
||||
let cancelledCount = $state(0);
|
||||
let abortController: AbortController | null = null;
|
||||
|
||||
// Progress calculation
|
||||
const progress = $derived(() => {
|
||||
if (itemStates.length === 0) return 0;
|
||||
const completed = itemStates.filter(i => i.status === 'success' || i.status === 'error' || i.status === 'cancelled').length;
|
||||
return Math.round((completed / itemStates.length) * 100);
|
||||
});
|
||||
|
||||
// Initialize when modal opens
|
||||
$effect(() => {
|
||||
if (open && items.length > 0 && !isRunning && !isComplete) {
|
||||
startOperation();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on destroy
|
||||
onDestroy(() => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
async function startOperation() {
|
||||
// Initialize item states
|
||||
itemStates = items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
status: 'pending' as ItemStatus
|
||||
}));
|
||||
|
||||
isRunning = true;
|
||||
isComplete = false;
|
||||
successCount = 0;
|
||||
failCount = 0;
|
||||
cancelledCount = 0;
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/batch${envId ? `?env=${envId}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
operation,
|
||||
entityType,
|
||||
items,
|
||||
options
|
||||
}),
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const event: BatchEvent = JSON.parse(line.slice(6));
|
||||
handleEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
// User cancelled - mark remaining as cancelled
|
||||
let cancelled = 0;
|
||||
itemStates = itemStates.map(item => {
|
||||
if (item.status === 'pending' || item.status === 'processing') {
|
||||
cancelled++;
|
||||
return { ...item, status: 'cancelled' as ItemStatus };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
cancelledCount = cancelled;
|
||||
} else {
|
||||
console.error('Batch operation error:', error);
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
isComplete = true;
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(event: BatchEvent) {
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
itemStates = itemStates.map(item =>
|
||||
item.id === event.id
|
||||
? { ...item, status: event.status, error: event.error }
|
||||
: item
|
||||
);
|
||||
if (event.status === 'success') successCount++;
|
||||
if (event.status === 'error') failCount++;
|
||||
break;
|
||||
case 'complete':
|
||||
successCount = event.summary.success;
|
||||
failCount = event.summary.failed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (isRunning) {
|
||||
// Confirm before closing during operation
|
||||
if (!confirm('Operation is still running. Cancel and close?')) {
|
||||
return;
|
||||
}
|
||||
handleCancel();
|
||||
}
|
||||
open = false;
|
||||
// Reset state for next use
|
||||
itemStates = [];
|
||||
isRunning = false;
|
||||
isComplete = false;
|
||||
successCount = 0;
|
||||
failCount = 0;
|
||||
cancelledCount = 0;
|
||||
onClose();
|
||||
if (isComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
open = false;
|
||||
itemStates = [];
|
||||
isRunning = false;
|
||||
isComplete = false;
|
||||
successCount = 0;
|
||||
failCount = 0;
|
||||
cancelledCount = 0;
|
||||
onClose();
|
||||
onComplete();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<Dialog.Content class="w-full max-w-lg" onInteractOutside={(e) => isRunning && e.preventDefault()}>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if isRunning}
|
||||
Processing {items.length} {entityType}...
|
||||
{:else if isComplete}
|
||||
Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
|
||||
{:else}
|
||||
Preparing to {operation} {items.length} {entityType}...
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="py-2">
|
||||
<Progress value={progress()} class="h-2" />
|
||||
<div class="text-xs text-muted-foreground mt-1 text-right">
|
||||
{progress()}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items list -->
|
||||
<div class="max-h-80 overflow-y-auto border rounded-md">
|
||||
{#each itemStates as item (item.id)}
|
||||
<div class="px-3 py-2 border-b last:border-b-0 text-sm {item.status === 'error' ? 'bg-red-50 dark:bg-red-950/20' : ''} {item.status === 'cancelled' ? 'bg-amber-50 dark:bg-amber-950/20' : ''}">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status icon -->
|
||||
<div class="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
{#if item.status === 'pending'}
|
||||
<Circle class="w-4 h-4 text-muted-foreground" />
|
||||
{:else if item.status === 'processing'}
|
||||
<Loader2 class="w-4 h-4 text-blue-500 animate-spin" />
|
||||
{:else if item.status === 'success'}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else if item.status === 'error'}
|
||||
<X class="w-4 h-4 text-red-500" />
|
||||
{:else if item.status === 'cancelled'}
|
||||
<Ban class="w-4 h-4 text-amber-500" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Item name -->
|
||||
<span class="flex-1 truncate font-mono text-xs" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
|
||||
<!-- Status text -->
|
||||
<span class="text-xs text-muted-foreground flex-shrink-0">
|
||||
{#if item.status === 'pending'}
|
||||
pending
|
||||
{:else if item.status === 'processing'}
|
||||
{progressText[operation] ?? operation}...
|
||||
{:else if item.status === 'success'}
|
||||
done
|
||||
{:else if item.status === 'error'}
|
||||
<span class="text-red-500">failed</span>
|
||||
{:else if item.status === 'cancelled'}
|
||||
<span class="text-amber-500">cancelled</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Error message on separate line -->
|
||||
{#if item.status === 'error' && item.error}
|
||||
<div class="mt-1 ml-7 text-xs text-red-600 dark:text-red-400 break-words">
|
||||
{item.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Footer: Summary + Button in one row -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div class="flex items-center gap-1" title="Succeeded">
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
<span class="tabular-nums">{successCount}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1" title="Failed">
|
||||
<X class="w-4 h-4 text-red-500" />
|
||||
<span class="tabular-nums">{failCount}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1" title="Cancelled">
|
||||
<Ban class="w-4 h-4 text-amber-500" />
|
||||
<span class="tabular-nums">{cancelledCount}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground" title="Pending">
|
||||
<Circle class="w-4 h-4" />
|
||||
<span class="tabular-nums">{items.length - successCount - failCount - cancelledCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if isRunning}
|
||||
<Button variant="outline" size="sm" onclick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
{:else}
|
||||
<Button size="sm" onclick={handleOk}>
|
||||
OK
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
821
lib/components/CodeEditor.svelte
Normal file
821
lib/components/CodeEditor.svelte
Normal 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>
|
||||
138
lib/components/ColumnSettingsPopover.svelte
Normal file
138
lib/components/ColumnSettingsPopover.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { Settings2, RotateCcw, ChevronUp, ChevronDown } from 'lucide-svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { gridPreferencesStore } from '$lib/stores/grid-preferences';
|
||||
import { getConfigurableColumns } from '$lib/config/grid-columns';
|
||||
import type { GridId, ColumnPreference } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
gridId: GridId;
|
||||
}
|
||||
|
||||
let { gridId }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let columns = $state<ColumnPreference[]>([]);
|
||||
|
||||
// Load columns when popover opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
columns = gridPreferencesStore.getAllColumns(gridId);
|
||||
}
|
||||
});
|
||||
|
||||
// Get column labels from config
|
||||
const columnConfigs = $derived(getConfigurableColumns(gridId));
|
||||
function getColumnLabel(id: string): string {
|
||||
const config = columnConfigs.find((c) => c.id === id);
|
||||
return config?.label || id;
|
||||
}
|
||||
|
||||
// Save columns and update grid immediately
|
||||
async function saveColumns(newColumns: ColumnPreference[]) {
|
||||
columns = newColumns;
|
||||
await gridPreferencesStore.setColumns(gridId, columns);
|
||||
}
|
||||
|
||||
// Toggle column visibility
|
||||
function toggleColumn(index: number) {
|
||||
const newColumns = columns.map((col, i) =>
|
||||
i === index ? { ...col, visible: !col.visible } : col
|
||||
);
|
||||
saveColumns(newColumns);
|
||||
}
|
||||
|
||||
// Move column up/down
|
||||
function moveUp(index: number) {
|
||||
if (index <= 0) return;
|
||||
const newColumns = [...columns];
|
||||
[newColumns[index - 1], newColumns[index]] = [newColumns[index], newColumns[index - 1]];
|
||||
saveColumns(newColumns);
|
||||
}
|
||||
|
||||
function moveDown(index: number) {
|
||||
if (index >= columns.length - 1) return;
|
||||
const newColumns = [...columns];
|
||||
[newColumns[index], newColumns[index + 1]] = [newColumns[index + 1], newColumns[index]];
|
||||
saveColumns(newColumns);
|
||||
}
|
||||
|
||||
// Reset to defaults
|
||||
async function resetToDefaults() {
|
||||
await gridPreferencesStore.resetGrid(gridId);
|
||||
columns = gridPreferencesStore.getAllColumns(gridId);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger asChild>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
type="button"
|
||||
title="Column settings"
|
||||
{...props}
|
||||
class="inline-flex items-center justify-center p-1 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Settings2 class="w-4 h-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-64 p-0" side="bottom" align="end" sideOffset={8}>
|
||||
<div class="p-3 border-b">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-sm">Columns</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
onclick={resetToDefaults}
|
||||
title="Reset to defaults"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto p-2">
|
||||
{#each columns as column, index (column.id)}
|
||||
<div class="flex items-center gap-1 p-1 rounded hover:bg-muted/50">
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 hover:bg-muted rounded disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
onclick={() => moveUp(index)}
|
||||
>
|
||||
<ChevronUp class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-0.5 hover:bg-muted rounded disabled:opacity-30"
|
||||
disabled={index === columns.length - 1}
|
||||
onclick={() => moveDown(index)}
|
||||
>
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="col-{column.id}"
|
||||
checked={column.visible}
|
||||
onCheckedChange={() => toggleColumn(index)}
|
||||
/>
|
||||
<Label
|
||||
for="col-{column.id}"
|
||||
class="text-sm cursor-pointer flex-1 truncate"
|
||||
>
|
||||
{getColumnLabel(column.id)}
|
||||
</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
355
lib/components/CommandPalette.svelte
Normal file
355
lib/components/CommandPalette.svelte
Normal 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
lib/components/ConfirmPopover.svelte
Normal file
114
lib/components/ConfirmPopover.svelte
Normal 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>
|
||||
94
lib/components/ExecutionLogViewer.svelte
Normal file
94
lib/components/ExecutionLogViewer.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { Sun, Moon } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
logs: string | null;
|
||||
darkMode?: boolean;
|
||||
onToggleTheme?: () => void;
|
||||
}
|
||||
|
||||
let { logs, darkMode = true, onToggleTheme }: Props = $props();
|
||||
|
||||
// Parse log lines with timestamp and content
|
||||
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
|
||||
const content = line.replace(/^\[[\d\-T:.Z]+\]\s*/, '');
|
||||
const timestamp = line.match(/^\[([\d\-T:.Z]+)\]/)?.[1] || '';
|
||||
|
||||
let type: 'trivy' | 'grype' | 'error' | 'default' = 'default';
|
||||
if (content.startsWith('[trivy]')) {
|
||||
type = 'trivy';
|
||||
} else if (content.startsWith('[grype]')) {
|
||||
type = 'grype';
|
||||
} else if (content.toLowerCase().includes('error')) {
|
||||
type = 'error';
|
||||
}
|
||||
|
||||
return { timestamp, content, type };
|
||||
}
|
||||
|
||||
function getTypeBadge(type: 'trivy' | 'grype' | 'error' | 'default'): { label: string; class: string } {
|
||||
switch (type) {
|
||||
case 'trivy':
|
||||
return { label: 'trivy', class: 'bg-teal-500 text-white' };
|
||||
case 'grype':
|
||||
return { label: 'grype', class: 'bg-violet-500 text-white' };
|
||||
case 'error':
|
||||
return { label: 'error', class: 'bg-red-500 text-white' };
|
||||
default:
|
||||
return { label: 'dockhand', class: 'bg-slate-500 text-white' };
|
||||
}
|
||||
}
|
||||
|
||||
function cleanContent(content: string, type: 'trivy' | 'grype' | 'error' | 'default'): string {
|
||||
return content.replace(/^\[(trivy|grype|scan)\]\s*/i, '');
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground mb-1 shrink-0">
|
||||
<span>Logs</span>
|
||||
{#if onToggleTheme}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggleTheme}
|
||||
class="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="Toggle log theme"
|
||||
>
|
||||
{#if darkMode}
|
||||
<Sun class="w-3.5 h-3.5" />
|
||||
{:else}
|
||||
<Moon class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="{darkMode ? 'bg-zinc-950 text-zinc-300' : 'bg-zinc-100 text-zinc-700'} rounded p-3 font-mono text-xs flex-1 overflow-auto"
|
||||
>
|
||||
{#if logs}
|
||||
{#each logs.split('\n') as line}
|
||||
{@const parsed = parseLogLine(line)}
|
||||
{@const badge = getTypeBadge(parsed.type)}
|
||||
<div class="flex items-start gap-1.5 leading-relaxed">
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-12 px-1 rounded text-[8px] font-medium {badge.class} shadow-[0_1px_1px_rgba(0,0,0,0.2)] shrink-0 mt-[3px]"
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
{#if parsed.timestamp}
|
||||
<span class="{darkMode ? 'text-zinc-500' : 'text-zinc-400'} shrink-0">
|
||||
{formatTimestamp(parsed.timestamp)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="break-all">{cleanContent(parsed.content, parsed.type)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">No logs available</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
93
lib/components/MultiSelectFilter.svelte
Normal file
93
lib/components/MultiSelectFilter.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: Component;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
options: FilterOption[];
|
||||
placeholder: string;
|
||||
pluralLabel?: string;
|
||||
width?: string;
|
||||
defaultIcon?: Component;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable([]),
|
||||
options,
|
||||
placeholder,
|
||||
pluralLabel,
|
||||
width = 'w-36',
|
||||
defaultIcon
|
||||
}: Props = $props();
|
||||
|
||||
// Control dropdown open state
|
||||
let open = $state(false);
|
||||
|
||||
// Check if any options have icons
|
||||
const hasIcons = $derived(options.some(o => o.icon));
|
||||
|
||||
// Get the icon for single selection
|
||||
const singleOption = $derived(() => {
|
||||
if (value.length === 1) {
|
||||
return options.find(o => o.value === value[0]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const displayLabel = $derived(() => {
|
||||
if (value.length === 0) {
|
||||
return placeholder;
|
||||
} else if (value.length === 1) {
|
||||
const opt = options.find(o => o.value === value[0]);
|
||||
return opt?.label || value[0];
|
||||
} else {
|
||||
return `${value.length} ${pluralLabel || placeholder.toLowerCase()}`;
|
||||
}
|
||||
});
|
||||
|
||||
function clearAndClose() {
|
||||
value = [];
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select.Root type="multiple" bind:value bind:open>
|
||||
<Select.Trigger size="sm" class="{width} text-sm">
|
||||
{#if hasIcons || defaultIcon}
|
||||
{@const opt = singleOption()}
|
||||
{@const IconComponent = opt?.icon || defaultIcon}
|
||||
{#if IconComponent}
|
||||
<svelte:component this={IconComponent} class="w-3.5 h-3.5 mr-1.5 {opt?.color || 'text-muted-foreground'} shrink-0" />
|
||||
{/if}
|
||||
{/if}
|
||||
<span class="{value.length === 0 ? 'text-muted-foreground' : ''}">
|
||||
{displayLabel()}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#if value.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
|
||||
onclick={clearAndClose}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
{#each options as option}
|
||||
<Select.Item value={option.value}>
|
||||
{#if option.icon}
|
||||
<svelte:component this={option.icon} class="w-4 h-4 mr-2 {option.color || ''}" />
|
||||
{/if}
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
83
lib/components/PageHeader.svelte
Normal file
83
lib/components/PageHeader.svelte
Normal 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>
|
||||
62
lib/components/PasswordStrengthIndicator.svelte
Normal file
62
lib/components/PasswordStrengthIndicator.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
password: string;
|
||||
}
|
||||
|
||||
let { password }: Props = $props();
|
||||
|
||||
// Calculate password strength (0-4)
|
||||
const strength = $derived.by(() => {
|
||||
if (!password) return 0;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Length checks
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
|
||||
// Character variety checks
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
|
||||
if (/\d/.test(password)) score++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++;
|
||||
|
||||
return Math.min(score, 4);
|
||||
});
|
||||
|
||||
const strengthLabel = $derived(
|
||||
strength === 0 ? 'Too short' :
|
||||
strength === 1 ? 'Weak' :
|
||||
strength === 2 ? 'Fair' :
|
||||
strength === 3 ? 'Good' :
|
||||
'Strong'
|
||||
);
|
||||
|
||||
const strengthColor = $derived(
|
||||
strength === 0 ? 'bg-muted' :
|
||||
strength === 1 ? 'bg-red-500' :
|
||||
strength === 2 ? 'bg-orange-500' :
|
||||
strength === 3 ? 'bg-yellow-500' :
|
||||
'bg-green-500'
|
||||
);
|
||||
|
||||
const strengthTextColor = $derived(
|
||||
strength === 0 ? 'text-muted-foreground' :
|
||||
strength === 1 ? 'text-red-500' :
|
||||
strength === 2 ? 'text-orange-500' :
|
||||
strength === 3 ? 'text-yellow-500' :
|
||||
'text-green-500'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if password}
|
||||
<div class="space-y-1">
|
||||
<div class="flex gap-1 h-1">
|
||||
{#each [1, 2, 3, 4] as level}
|
||||
<div
|
||||
class="flex-1 rounded-full transition-colors {strength >= level ? strengthColor : 'bg-muted'}"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs {strengthTextColor}">{strengthLabel}</p>
|
||||
</div>
|
||||
{/if}
|
||||
506
lib/components/PullTab.svelte
Normal file
506
lib/components/PullTab.svelte
Normal 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
lib/components/PushTab.svelte
Normal file
308
lib/components/PushTab.svelte
Normal 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
lib/components/ScanTab.svelte
Normal file
390
lib/components/ScanTab.svelte
Normal 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>
|
||||
39
lib/components/ScannerSeverityPills.svelte
Normal file
39
lib/components/ScannerSeverityPills.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface ScannerResult {
|
||||
scanner: 'grype' | 'trivy';
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
negligible?: number;
|
||||
unknown?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
results: ScannerResult[];
|
||||
}
|
||||
|
||||
let { results }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
{#each results as result}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-2xs text-muted-foreground">{result.scanner === 'grype' ? 'Grype' : 'Trivy'}:</span>
|
||||
{#if (result.critical || 0) > 0}
|
||||
<Badge variant="outline" class="px-1 py-0 text-2xs bg-red-500/10 text-red-600 border-red-500/30" title="Critical">{result.critical}</Badge>
|
||||
{/if}
|
||||
{#if (result.high || 0) > 0}
|
||||
<Badge variant="outline" class="px-1 py-0 text-2xs bg-orange-500/10 text-orange-600 border-orange-500/30" title="High">{result.high}</Badge>
|
||||
{/if}
|
||||
{#if (result.medium || 0) > 0}
|
||||
<Badge variant="outline" class="px-1 py-0 text-2xs bg-yellow-500/10 text-yellow-600 border-yellow-500/30" title="Medium">{result.medium}</Badge>
|
||||
{/if}
|
||||
{#if (result.low || 0) > 0}
|
||||
<Badge variant="outline" class="px-1 py-0 text-2xs bg-blue-500/10 text-blue-600 border-blue-500/30" title="Low">{result.low}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
106
lib/components/Sidebar.svelte
Normal file
106
lib/components/Sidebar.svelte
Normal 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>
|
||||
234
lib/components/StackEnvVarsEditor.svelte
Normal file
234
lib/components/StackEnvVarsEditor.svelte
Normal file
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot } from 'lucide-svelte';
|
||||
|
||||
export interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
isSecret: boolean;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
required: string[];
|
||||
optional: string[];
|
||||
defined: string[];
|
||||
missing: string[];
|
||||
unused: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
variables: EnvVar[];
|
||||
validation?: ValidationResult | null;
|
||||
readonly?: boolean;
|
||||
showSource?: boolean; // For git stacks - show where variable comes from
|
||||
sources?: Record<string, 'file' | 'override'>; // Key -> source mapping
|
||||
placeholder?: { key: string; value: string };
|
||||
existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility)
|
||||
}
|
||||
|
||||
let {
|
||||
variables = $bindable(),
|
||||
validation = null,
|
||||
readonly = false,
|
||||
showSource = false,
|
||||
sources = {},
|
||||
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
|
||||
existingSecretKeys = new Set<string>()
|
||||
}: Props = $props();
|
||||
|
||||
// Check if a variable is an existing secret that was loaded from DB
|
||||
function isExistingSecret(key: string, isSecret: boolean): boolean {
|
||||
return isSecret && existingSecretKeys.has(key);
|
||||
}
|
||||
|
||||
function addVariable() {
|
||||
variables = [...variables, { key: '', value: '', isSecret: false }];
|
||||
}
|
||||
|
||||
function removeVariable(index: number) {
|
||||
variables = variables.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function toggleSecret(index: number) {
|
||||
variables[index].isSecret = !variables[index].isSecret;
|
||||
}
|
||||
|
||||
// Check if a variable key is missing (required but not defined)
|
||||
function isMissing(key: string): boolean {
|
||||
return validation?.missing?.includes(key) ?? false;
|
||||
}
|
||||
|
||||
// Check if a variable key is unused (defined but not in compose)
|
||||
function isUnused(key: string): boolean {
|
||||
return validation?.unused?.includes(key) ?? false;
|
||||
}
|
||||
|
||||
// Check if a variable key is required
|
||||
function isRequired(key: string): boolean {
|
||||
return validation?.required?.includes(key) ?? false;
|
||||
}
|
||||
|
||||
// Check if a variable key is optional
|
||||
function isOptional(key: string): boolean {
|
||||
return validation?.optional?.includes(key) ?? false;
|
||||
}
|
||||
|
||||
// Get validation status class for key input
|
||||
function getKeyValidationClass(key: string): string {
|
||||
if (!key || !validation) return '';
|
||||
if (isMissing(key)) return 'border-red-500 dark:border-red-400';
|
||||
if (isUnused(key)) return 'border-amber-500 dark:border-amber-400';
|
||||
if (isRequired(key) || isOptional(key)) return 'border-green-500 dark:border-green-400';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get source icon for a variable
|
||||
function getSource(key: string): 'file' | 'override' | null {
|
||||
if (!showSource || !sources) return null;
|
||||
return sources[key] || null;
|
||||
}
|
||||
|
||||
// Count non-empty variables
|
||||
const variableCount = $derived(variables.filter(v => v.key).length);
|
||||
const secretCount = $derived(variables.filter(v => v.key && v.isSecret).length);
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Variables List -->
|
||||
<div class="space-y-3">
|
||||
{#each variables as variable, index}
|
||||
{@const source = getSource(variable.key)}
|
||||
{@const isVarRequired = isRequired(variable.key)}
|
||||
{@const isVarOptional = isOptional(variable.key)}
|
||||
{@const isVarMissing = isMissing(variable.key)}
|
||||
{@const isVarUnused = isUnused(variable.key)}
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- Source indicator (for git stacks) - always reserve space if showSource -->
|
||||
{#if showSource}
|
||||
<div class="flex items-center h-9 w-5 justify-center shrink-0">
|
||||
{#if source === 'file'}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<FileText class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content><p>From .env file</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if source === 'override'}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Pencil class="w-3.5 h-3.5 text-blue-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content><p>Manual override</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Validation status indicator - always reserve space if validation exists -->
|
||||
{#if validation}
|
||||
<div class="flex items-center h-9 w-5 justify-center shrink-0">
|
||||
{#if variable.key}
|
||||
{#if isVarRequired && !isVarMissing}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<CheckCircle2 class="w-4 h-4 text-green-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content><p>Required variable defined</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if isVarOptional}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<CircleDot class="w-4 h-4 text-blue-400" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content><p>Optional variable (has default)</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if isVarUnused}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<AlertCircle class="w-4 h-4 text-amber-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content><p>Unused variable</p></Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Key Input with floating label -->
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Name</span>
|
||||
<Input
|
||||
bind:value={variable.key}
|
||||
disabled={readonly}
|
||||
class="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Value Input with floating label -->
|
||||
<div class="flex-1 relative">
|
||||
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Value</span>
|
||||
<Input
|
||||
bind:value={variable.value}
|
||||
type={variable.isSecret ? 'password' : 'text'}
|
||||
disabled={readonly}
|
||||
class="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Secret Toggle Button -->
|
||||
{#if !readonly}
|
||||
{@const existingSecret = isExistingSecret(variable.key, variable.isSecret)}
|
||||
{#if existingSecret}
|
||||
<!-- Existing secret from DB - show locked icon, no toggle (value can still be modified) -->
|
||||
<div class="flex items-center h-9 w-9 justify-center shrink-0" title="Secret value (cannot unhide)">
|
||||
<Key class="w-3.5 h-3.5 text-amber-500" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- New or non-secret variable - show toggle button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleSecret(index)}
|
||||
title={variable.isSecret ? 'Marked as secret' : 'Mark as secret'}
|
||||
class="h-9 w-9 flex items-center justify-center rounded-md shrink-0 transition-colors {variable.isSecret ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}"
|
||||
>
|
||||
<Key class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if variable.isSecret}
|
||||
<div class="flex items-center h-9 w-9 justify-center shrink-0">
|
||||
<Key class="w-3.5 h-3.5 text-amber-500" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Remove Button -->
|
||||
{#if !readonly}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => removeVariable(index)}
|
||||
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if variables.length === 0}
|
||||
<div class="text-center py-6 text-muted-foreground">
|
||||
<p class="text-sm">No environment variables defined.</p>
|
||||
{#if !readonly}
|
||||
<Button type="button" variant="link" onclick={addVariable} class="mt-1 text-xs">
|
||||
<Plus class="w-3 h-3 mr-1" />
|
||||
Add your first variable
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
236
lib/components/StackEnvVarsPanel.svelte
Normal file
236
lib/components/StackEnvVarsPanel.svelte
Normal 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
lib/components/ThemeSelector.svelte
Normal file
247
lib/components/ThemeSelector.svelte
Normal 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
lib/components/TimezoneSelector.svelte
Normal file
142
lib/components/TimezoneSelector.svelte
Normal 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>
|
||||
106
lib/components/UpdateContainerRow.svelte
Normal file
106
lib/components/UpdateContainerRow.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, XCircle, Loader2 } from 'lucide-svelte';
|
||||
import type { StepType } from '$lib/utils/update-steps';
|
||||
import { getStepIcon, getStepLabel, getStepColor } from '$lib/utils/update-steps';
|
||||
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
|
||||
|
||||
interface ScannerResult {
|
||||
scanner: 'grype' | 'trivy';
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
negligible?: number;
|
||||
unknown?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
status: StepType;
|
||||
error?: string;
|
||||
blockReason?: string;
|
||||
scannerResults?: ScannerResult[];
|
||||
isActive?: boolean;
|
||||
showLogs?: boolean;
|
||||
isForceUpdating?: boolean;
|
||||
onToggleLogs?: () => void;
|
||||
onForceUpdate?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
blockReason,
|
||||
scannerResults,
|
||||
isActive = false,
|
||||
showLogs = false,
|
||||
isForceUpdating = false,
|
||||
onToggleLogs,
|
||||
onForceUpdate
|
||||
}: Props = $props();
|
||||
|
||||
const StepIcon = $derived(getStepIcon(status));
|
||||
const stepLabel = $derived(getStepLabel(status));
|
||||
const colorClass = $derived(getStepColor(status));
|
||||
const hasToggle = $derived(onToggleLogs !== undefined);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<svelte:component
|
||||
this={StepIcon}
|
||||
class="w-4 h-4 shrink-0 {colorClass} {isActive ? 'animate-spin' : ''}"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{name}</div>
|
||||
{#if error}
|
||||
<div class="text-xs text-red-600 dark:text-red-400 truncate">{error}</div>
|
||||
{:else if blockReason}
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400 truncate">{blockReason}</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground">{stepLabel}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scan result badges -->
|
||||
{#if scannerResults && scannerResults.length > 0}
|
||||
<ScannerSeverityPills results={scannerResults} />
|
||||
{/if}
|
||||
|
||||
<!-- Status/action icons -->
|
||||
{#if status === 'done' || status === 'updated'}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600 shrink-0" />
|
||||
{:else if status === 'failed'}
|
||||
<XCircle class="w-4 h-4 text-red-600 shrink-0" />
|
||||
{:else if status === 'blocked' && onForceUpdate}
|
||||
{#if isForceUpdating}
|
||||
<Loader2 class="w-4 h-4 text-blue-500 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/50"
|
||||
onclick={onForceUpdate}
|
||||
>
|
||||
Update anyway
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Toggle logs button -->
|
||||
{#if hasToggle}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggleLogs}
|
||||
class="p-1 hover:bg-muted rounded cursor-pointer"
|
||||
title={showLogs ? 'Hide logs' : 'Show logs'}
|
||||
>
|
||||
{#if showLogs}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
26
lib/components/UpdateStepIndicator.svelte
Normal file
26
lib/components/UpdateStepIndicator.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { StepType } from '$lib/utils/update-steps';
|
||||
import { getStepIcon, getStepLabel, getStepColor } from '$lib/utils/update-steps';
|
||||
|
||||
interface Props {
|
||||
step: StepType;
|
||||
isActive?: boolean;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let { step, isActive = false, showLabel = true }: Props = $props();
|
||||
|
||||
const Icon = $derived(getStepIcon(step));
|
||||
const label = $derived(getStepLabel(step));
|
||||
const colorClass = $derived(getStepColor(step));
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svelte:component
|
||||
this={Icon}
|
||||
class="w-4 h-4 shrink-0 {colorClass} {isActive ? 'animate-spin' : ''}"
|
||||
/>
|
||||
{#if showLabel}
|
||||
<span class="text-xs {colorClass}">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
69
lib/components/UpdateSummaryStats.svelte
Normal file
69
lib/components/UpdateSummaryStats.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, ShieldAlert, XCircle, Search } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
checked?: number;
|
||||
updated: number;
|
||||
blocked: number;
|
||||
failed: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { checked, updated, blocked, failed, compact = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if compact}
|
||||
<!-- Inline compact layout -->
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
{#if checked !== undefined}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-bold">{checked}</span>
|
||||
<span class="text-muted-foreground">Checked</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updated > 0}
|
||||
<div class="flex items-center gap-1.5 text-green-600">
|
||||
<CheckCircle2 class="w-4 h-4" />
|
||||
<span>{updated} updated</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if blocked > 0}
|
||||
<div class="flex items-center gap-1.5 text-amber-600">
|
||||
<ShieldAlert class="w-4 h-4" />
|
||||
<span>{blocked} blocked</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if failed > 0}
|
||||
<div class="flex items-center gap-1.5 text-red-600">
|
||||
<XCircle class="w-4 h-4" />
|
||||
<span>{failed} failed</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Grid layout -->
|
||||
<div class="flex items-center gap-4 text-sm pb-3 border-b">
|
||||
{#if checked !== undefined}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Search class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="font-bold">{checked}</span>
|
||||
<span class="text-muted-foreground">Checked</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<CheckCircle2 class="w-4 h-4 text-green-500" />
|
||||
<span class="font-bold text-green-500">{updated}</span>
|
||||
<span class="text-muted-foreground">Updated</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<ShieldAlert class="w-4 h-4 text-amber-500" />
|
||||
<span class="font-bold text-amber-500">{blocked}</span>
|
||||
<span class="text-muted-foreground">Blocked</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
<span class="font-bold text-red-500">{failed}</span>
|
||||
<span class="text-muted-foreground">Failed</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
27
lib/components/VulnerabilityCriteriaBadge.svelte
Normal file
27
lib/components/VulnerabilityCriteriaBadge.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import type { VulnerabilityCriteria } from '$lib/server/db';
|
||||
import {
|
||||
vulnerabilityCriteriaLabels,
|
||||
vulnerabilityCriteriaIcons,
|
||||
getCriteriaBadgeClass
|
||||
} from '$lib/utils/update-steps';
|
||||
|
||||
interface Props {
|
||||
criteria: VulnerabilityCriteria;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
let { criteria, showLabel = true }: Props = $props();
|
||||
|
||||
const iconConfig = $derived(vulnerabilityCriteriaIcons[criteria] || vulnerabilityCriteriaIcons.never);
|
||||
const label = $derived(vulnerabilityCriteriaLabels[criteria] || criteria);
|
||||
const badgeClass = $derived(getCriteriaBadgeClass(criteria));
|
||||
</script>
|
||||
|
||||
<Badge variant="outline" class="text-xs {badgeClass}">
|
||||
<svelte:component this={iconConfig.component} class={iconConfig.class} />
|
||||
{#if showLabel}
|
||||
<span class="ml-1">{label}</span>
|
||||
{/if}
|
||||
</Badge>
|
||||
85
lib/components/VulnerabilityCriteriaSelector.svelte
Normal file
85
lib/components/VulnerabilityCriteriaSelector.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Shield, ShieldOff, ShieldAlert, ShieldX } from 'lucide-svelte';
|
||||
|
||||
export type VulnerabilityCriteria = 'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current';
|
||||
|
||||
interface Props {
|
||||
value: VulnerabilityCriteria;
|
||||
onchange?: (value: VulnerabilityCriteria) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
onchange,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={value}
|
||||
onValueChange={(v) => {
|
||||
if (v) {
|
||||
value = v as VulnerabilityCriteria;
|
||||
onchange?.(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full h-9 {className}">
|
||||
<span class="flex items-center gap-2">
|
||||
{#if value === 'never'}
|
||||
<ShieldOff class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>Never block</span>
|
||||
{:else if value === 'any'}
|
||||
<ShieldAlert class="w-3.5 h-3.5 text-amber-500" />
|
||||
<span>Any vulnerabilities</span>
|
||||
{:else if value === 'critical_high'}
|
||||
<ShieldX class="w-3.5 h-3.5 text-orange-500" />
|
||||
<span>Critical or high</span>
|
||||
{:else if value === 'critical'}
|
||||
<ShieldX class="w-3.5 h-3.5 text-red-500" />
|
||||
<span>Critical only</span>
|
||||
{:else if value === 'more_than_current'}
|
||||
<Shield class="w-3.5 h-3.5 text-blue-500" />
|
||||
<span>More than current image</span>
|
||||
{:else}
|
||||
<ShieldOff class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>Never block</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="never">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldOff class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>Never block</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="any">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldAlert class="w-3.5 h-3.5 text-amber-500" />
|
||||
<span>Any vulnerabilities</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="critical_high">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldX class="w-3.5 h-3.5 text-orange-500" />
|
||||
<span>Critical or high</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="critical">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldX class="w-3.5 h-3.5 text-red-500" />
|
||||
<span>Critical only</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="more_than_current">
|
||||
<div class="flex items-center gap-2">
|
||||
<Shield class="w-3.5 h-3.5 text-blue-500" />
|
||||
<span>More than current image</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
81
lib/components/WhatsNewModal.svelte
Normal file
81
lib/components/WhatsNewModal.svelte
Normal 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
lib/components/app-sidebar.svelte
Normal file
195
lib/components/app-sidebar.svelte
Normal 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
lib/components/cron-editor.svelte
Normal file
308
lib/components/cron-editor.svelte
Normal 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>
|
||||
850
lib/components/data-grid/DataGrid.svelte
Normal file
850
lib/components/data-grid/DataGrid.svelte
Normal file
@@ -0,0 +1,850 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { CheckSquare, Square as SquareIcon, ArrowUp, ArrowDown, ArrowUpDown, ChevronDown, ChevronRight } from 'lucide-svelte';
|
||||
import { columnResize } from '$lib/actions/column-resize';
|
||||
import { gridPreferencesStore } from '$lib/stores/grid-preferences';
|
||||
import { getAllColumnConfigs } from '$lib/config/grid-columns';
|
||||
import ColumnSettingsPopover from '$lib/components/ColumnSettingsPopover.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import type { GridId, ColumnConfig, ColumnPreference } from '$lib/types';
|
||||
import type { DataGridSortState, DataGridRowState } from './types';
|
||||
import { setDataGridContext } from './context';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
// Required
|
||||
data: T[];
|
||||
keyField: keyof T;
|
||||
gridId: GridId;
|
||||
|
||||
// Virtual Scroll Mode (OFF by default)
|
||||
virtualScroll?: boolean;
|
||||
rowHeight?: number;
|
||||
bufferRows?: number;
|
||||
|
||||
// Selection
|
||||
selectable?: boolean;
|
||||
selectedKeys?: Set<unknown>;
|
||||
onSelectionChange?: (keys: Set<unknown>) => void;
|
||||
|
||||
// Sorting
|
||||
sortState?: DataGridSortState;
|
||||
onSortChange?: (state: DataGridSortState) => void;
|
||||
|
||||
// Infinite scroll (virtual mode)
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
loadMoreThreshold?: number;
|
||||
|
||||
// Visible range callback (for virtual scroll)
|
||||
onVisibleRangeChange?: (start: number, end: number, total: number) => void;
|
||||
|
||||
// Row interaction
|
||||
onRowClick?: (item: T, event: MouseEvent) => void;
|
||||
highlightedKey?: unknown;
|
||||
rowClass?: (item: T) => string;
|
||||
|
||||
// Selection filter - return false to make an item non-selectable
|
||||
selectableFilter?: (item: T) => boolean;
|
||||
|
||||
// Expandable rows
|
||||
expandable?: boolean;
|
||||
expandedKeys?: Set<unknown>;
|
||||
onExpandChange?: (key: unknown, expanded: boolean) => void;
|
||||
expandedRow?: Snippet<[T, DataGridRowState]>;
|
||||
|
||||
// State
|
||||
loading?: boolean;
|
||||
skeletonRows?: number;
|
||||
|
||||
// CSS
|
||||
class?: string;
|
||||
wrapperClass?: string;
|
||||
|
||||
// Snippets for customization
|
||||
headerCell?: Snippet<[ColumnConfig, DataGridSortState | undefined]>;
|
||||
cell?: Snippet<[ColumnConfig, T, DataGridRowState]>;
|
||||
emptyState?: Snippet;
|
||||
loadingState?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
data,
|
||||
keyField,
|
||||
gridId,
|
||||
virtualScroll = false,
|
||||
rowHeight = 33,
|
||||
bufferRows = 10,
|
||||
selectable = false,
|
||||
selectedKeys = $bindable(new Set<unknown>()),
|
||||
onSelectionChange,
|
||||
sortState,
|
||||
onSortChange,
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
loadMoreThreshold = 200,
|
||||
onVisibleRangeChange,
|
||||
onRowClick,
|
||||
highlightedKey,
|
||||
rowClass,
|
||||
selectableFilter,
|
||||
expandable = false,
|
||||
expandedKeys = $bindable(new Set<unknown>()),
|
||||
onExpandChange,
|
||||
expandedRow,
|
||||
loading = false,
|
||||
skeletonRows = 8,
|
||||
class: className = '',
|
||||
wrapperClass = '',
|
||||
headerCell,
|
||||
cell,
|
||||
emptyState,
|
||||
loadingState
|
||||
}: Props = $props();
|
||||
|
||||
// Column configuration
|
||||
const columnConfigs = getAllColumnConfigs(gridId);
|
||||
const columnConfigMap = new Map(columnConfigs.map((c) => [c.id, c]));
|
||||
const fixedStartCols = columnConfigs.filter((c) => c.fixed === 'start').map((c) => c.id);
|
||||
const fixedEndCols = columnConfigs.filter((c) => c.fixed === 'end').map((c) => c.id);
|
||||
|
||||
// Grid preferences (reactive)
|
||||
const gridPrefs = $derived($gridPreferencesStore);
|
||||
|
||||
// Get ordered visible columns from preferences
|
||||
const orderedColumns = $derived.by(() => {
|
||||
const prefs = gridPrefs[gridId];
|
||||
if (!prefs?.columns?.length) {
|
||||
// Default: all configurable columns visible
|
||||
return columnConfigs.filter((c) => !c.fixed).map((c) => c.id);
|
||||
}
|
||||
return prefs.columns.filter((c) => c.visible).map((c) => c.id);
|
||||
});
|
||||
|
||||
// Identify visible grow columns (columns with grow: true that are currently visible)
|
||||
const visibleGrowCols = $derived(
|
||||
orderedColumns.filter((id) => columnConfigMap.get(id)?.grow)
|
||||
);
|
||||
|
||||
// Helper to check if column is a grow column
|
||||
function isGrowColumn(colId: string): boolean {
|
||||
return visibleGrowCols.includes(colId);
|
||||
}
|
||||
|
||||
// Saved column widths from preferences
|
||||
const savedWidths = $derived.by(() => {
|
||||
const prefs = gridPrefs[gridId];
|
||||
const widths = new Map<string, number>();
|
||||
if (prefs?.columns) {
|
||||
for (const col of prefs.columns) {
|
||||
if (col.width !== undefined) {
|
||||
widths.set(col.id, col.width);
|
||||
}
|
||||
}
|
||||
}
|
||||
return widths;
|
||||
});
|
||||
|
||||
// Local widths for smooth resize feedback (not persisted until mouseup)
|
||||
let localWidths = $state<Map<string, number>>(new Map());
|
||||
|
||||
// RAF throttling for performance
|
||||
let resizeRAF: number | null = null;
|
||||
let scrollRAF: number | null = null;
|
||||
|
||||
// Helper to get base width for a column (without grow calculation)
|
||||
function getBaseWidth(colId: string): number {
|
||||
if (localWidths.has(colId)) return localWidths.get(colId)!;
|
||||
if (savedWidths.has(colId)) return savedWidths.get(colId)!;
|
||||
return columnConfigMap.get(colId)?.width ?? 100;
|
||||
}
|
||||
|
||||
// Calculate width for grow columns (distributes remaining space equally)
|
||||
const growColumnWidth = $derived.by(() => {
|
||||
if (!scrollContainerWidth || visibleGrowCols.length === 0) return null;
|
||||
|
||||
// Sum of all fixed-width columns (non-grow)
|
||||
let fixedTotal = 0;
|
||||
|
||||
// Fixed start columns (select, expand)
|
||||
for (const colId of fixedStartCols) {
|
||||
fixedTotal += getBaseWidth(colId);
|
||||
}
|
||||
|
||||
// Visible non-grow columns
|
||||
for (const colId of orderedColumns) {
|
||||
if (!visibleGrowCols.includes(colId)) {
|
||||
fixedTotal += getBaseWidth(colId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed end columns (actions)
|
||||
for (const colId of fixedEndCols) {
|
||||
fixedTotal += getBaseWidth(colId);
|
||||
}
|
||||
|
||||
// Distribute remaining space equally among grow columns
|
||||
// No buffer - grow columns absorb all remaining space
|
||||
const remaining = Math.max(0, scrollContainerWidth - fixedTotal);
|
||||
const perGrowCol = remaining / visibleGrowCols.length;
|
||||
|
||||
// Respect minimum widths
|
||||
const minWidth = Math.max(
|
||||
...visibleGrowCols.map((id) => columnConfigMap.get(id)?.minWidth ?? 60)
|
||||
);
|
||||
|
||||
return Math.max(perGrowCol, minWidth);
|
||||
});
|
||||
|
||||
// Calculate total table width (sum of all column widths)
|
||||
const totalTableWidth = $derived.by(() => {
|
||||
let total = 0;
|
||||
for (const colId of fixedStartCols) {
|
||||
total += getBaseWidth(colId);
|
||||
}
|
||||
for (const colId of orderedColumns) {
|
||||
total += getDisplayWidth(colId);
|
||||
}
|
||||
for (const colId of fixedEndCols) {
|
||||
total += getBaseWidth(colId);
|
||||
}
|
||||
return total;
|
||||
});
|
||||
|
||||
// Get display width for a column (priority: local > saved > grow-calculated > default)
|
||||
function getDisplayWidth(colId: string): number {
|
||||
// For non-grow columns, use base width
|
||||
if (!isGrowColumn(colId)) {
|
||||
return getBaseWidth(colId);
|
||||
}
|
||||
|
||||
// For grow columns: if user has resized, use their width
|
||||
if (localWidths.has(colId)) return localWidths.get(colId)!;
|
||||
if (savedWidths.has(colId)) return savedWidths.get(colId)!;
|
||||
|
||||
// Otherwise use calculated grow width
|
||||
if (growColumnWidth) {
|
||||
return growColumnWidth;
|
||||
}
|
||||
|
||||
return columnConfigMap.get(colId)?.width ?? 100;
|
||||
}
|
||||
|
||||
// Get column config by ID
|
||||
function getColumnConfig(colId: string): ColumnConfig | undefined {
|
||||
return columnConfigMap.get(colId);
|
||||
}
|
||||
|
||||
// Handle resize during drag (RAF throttled for performance)
|
||||
function handleResize(colId: string, width: number) {
|
||||
if (resizeRAF) return; // Skip if already pending
|
||||
resizeRAF = requestAnimationFrame(() => {
|
||||
resizeRAF = null;
|
||||
localWidths.set(colId, width);
|
||||
localWidths = new Map(localWidths); // Trigger reactivity
|
||||
});
|
||||
}
|
||||
|
||||
// Handle resize end - persist to store
|
||||
async function handleResizeEnd(colId: string, width: number) {
|
||||
await gridPreferencesStore.setColumnWidth(gridId, colId, width);
|
||||
localWidths.delete(colId);
|
||||
localWidths = new Map(localWidths);
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
function isItemSelectable(item: T): boolean {
|
||||
return selectableFilter ? selectableFilter(item) : true;
|
||||
}
|
||||
|
||||
const selectableData = $derived(data.filter(isItemSelectable));
|
||||
const allSelected = $derived(selectableData.length > 0 && selectableData.every((item) => selectedKeys.has(item[keyField])));
|
||||
const someSelected = $derived(selectableData.some((item) => selectedKeys.has(item[keyField])) && !allSelected);
|
||||
|
||||
function isSelected(key: unknown): boolean {
|
||||
return selectedKeys.has(key);
|
||||
}
|
||||
|
||||
function toggleSelection(key: unknown) {
|
||||
const newKeys = new Set(selectedKeys);
|
||||
if (newKeys.has(key)) {
|
||||
newKeys.delete(key);
|
||||
} else {
|
||||
newKeys.add(key);
|
||||
}
|
||||
selectedKeys = newKeys;
|
||||
onSelectionChange?.(newKeys);
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
// Add all selectable items to existing selection (preserves filtered-out selections)
|
||||
const newKeys = new Set(selectedKeys);
|
||||
for (const item of selectableData) {
|
||||
newKeys.add(item[keyField]);
|
||||
}
|
||||
selectedKeys = newKeys;
|
||||
onSelectionChange?.(newKeys);
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
// Remove only selectable items from selection (preserves filtered-out selections)
|
||||
const newKeys = new Set(selectedKeys);
|
||||
for (const item of selectableData) {
|
||||
newKeys.delete(item[keyField]);
|
||||
}
|
||||
selectedKeys = newKeys;
|
||||
onSelectionChange?.(newKeys);
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected) {
|
||||
selectNone();
|
||||
} else {
|
||||
selectAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Expand helpers
|
||||
function isExpanded(key: unknown): boolean {
|
||||
return expandedKeys.has(key);
|
||||
}
|
||||
|
||||
function toggleExpand(key: unknown) {
|
||||
const newKeys = new Set(expandedKeys);
|
||||
const nowExpanded = !newKeys.has(key);
|
||||
if (nowExpanded) {
|
||||
newKeys.add(key);
|
||||
} else {
|
||||
newKeys.delete(key);
|
||||
}
|
||||
expandedKeys = newKeys;
|
||||
onExpandChange?.(key, nowExpanded);
|
||||
}
|
||||
|
||||
// Sort helpers
|
||||
function toggleSort(field: string) {
|
||||
if (!onSortChange) return;
|
||||
|
||||
if (sortState?.field === field) {
|
||||
onSortChange({
|
||||
field,
|
||||
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
|
||||
});
|
||||
} else {
|
||||
onSortChange({ field, direction: 'asc' });
|
||||
}
|
||||
}
|
||||
|
||||
// Virtual scroll state
|
||||
let scrollContainer = $state<HTMLDivElement | null>(null);
|
||||
let scrollTop = $state(0);
|
||||
let containerHeight = $state(600);
|
||||
|
||||
// Container width for grow column calculation
|
||||
let scrollContainerWidth = $state(0);
|
||||
|
||||
// Virtual scroll calculations
|
||||
const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0);
|
||||
const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0);
|
||||
const endIndex = $derived(
|
||||
virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length
|
||||
);
|
||||
const visibleData = $derived(virtualScroll ? data.slice(startIndex, endIndex) : data);
|
||||
const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0);
|
||||
|
||||
// Notify parent of visible range changes
|
||||
$effect(() => {
|
||||
if (virtualScroll && onVisibleRangeChange && data.length > 0) {
|
||||
// Calculate actual visible range (without buffer)
|
||||
const visibleStart = Math.max(1, Math.floor(scrollTop / rowHeight) + 1);
|
||||
const visibleEnd = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight));
|
||||
onVisibleRangeChange(visibleStart, Math.max(visibleEnd, visibleStart), data.length);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle scroll for virtual mode (RAF throttled for performance)
|
||||
function handleScroll(event: Event) {
|
||||
if (!virtualScroll) return;
|
||||
if (scrollRAF) return; // Skip if already pending
|
||||
|
||||
scrollRAF = requestAnimationFrame(() => {
|
||||
scrollRAF = null;
|
||||
const target = event.target as HTMLDivElement;
|
||||
scrollTop = target.scrollTop;
|
||||
|
||||
// Update container height on scroll (in case of resize)
|
||||
containerHeight = target.clientHeight;
|
||||
|
||||
// Infinite scroll trigger
|
||||
if (hasMore && onLoadMore) {
|
||||
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
if (scrollBottom < loadMoreThreshold) {
|
||||
onLoadMore();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update container dimensions on mount and resize
|
||||
onMount(() => {
|
||||
if (scrollContainer) {
|
||||
// Track width for grow column calculation (always needed)
|
||||
scrollContainerWidth = scrollContainer.clientWidth;
|
||||
|
||||
// Track height for virtual scroll
|
||||
if (virtualScroll) {
|
||||
containerHeight = scrollContainer.clientHeight;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
scrollContainerWidth = entry.contentRect.width;
|
||||
if (virtualScroll) {
|
||||
containerHeight = entry.contentRect.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(scrollContainer);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup RAF handles on destroy
|
||||
onDestroy(() => {
|
||||
if (resizeRAF) cancelAnimationFrame(resizeRAF);
|
||||
if (scrollRAF) cancelAnimationFrame(scrollRAF);
|
||||
});
|
||||
|
||||
// Set context for child components
|
||||
setDataGridContext({
|
||||
gridId,
|
||||
keyField: keyField as keyof unknown,
|
||||
orderedColumns,
|
||||
getDisplayWidth,
|
||||
getColumnConfig,
|
||||
selectable,
|
||||
isSelected,
|
||||
toggleSelection,
|
||||
selectAll,
|
||||
selectNone,
|
||||
allSelected,
|
||||
someSelected,
|
||||
sortState,
|
||||
toggleSort,
|
||||
handleResize,
|
||||
handleResizeEnd,
|
||||
highlightedKey
|
||||
});
|
||||
|
||||
// Helper to get row state
|
||||
function getRowState(item: T, index: number): DataGridRowState {
|
||||
return {
|
||||
isSelected: isSelected(item[keyField]),
|
||||
isHighlighted: highlightedKey === item[keyField],
|
||||
isSelectable: isItemSelectable(item),
|
||||
isExpanded: isExpanded(item[keyField]),
|
||||
index: virtualScroll ? startIndex + index : index
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to check if column is resizable
|
||||
function isResizable(colId: string): boolean {
|
||||
const config = columnConfigMap.get(colId);
|
||||
// Fixed columns are not resizable by default, but can be made resizable explicitly
|
||||
if (config?.fixed) {
|
||||
return config.resizable === true;
|
||||
}
|
||||
return config?.resizable !== false;
|
||||
}
|
||||
|
||||
// Helper to check if column is sortable
|
||||
function isSortable(colId: string): boolean {
|
||||
const config = columnConfigMap.get(colId);
|
||||
return config?.sortable === true;
|
||||
}
|
||||
|
||||
// Helper to get sort field
|
||||
function getSortField(colId: string): string {
|
||||
const config = columnConfigMap.get(colId);
|
||||
return config?.sortField ?? colId;
|
||||
}
|
||||
|
||||
// Generate skeleton row indices
|
||||
const skeletonIndices = $derived(Array.from({ length: skeletonRows }, (_, i) => i));
|
||||
</script>
|
||||
|
||||
{#snippet skeletonContent()}
|
||||
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
|
||||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<!-- Fixed start columns -->
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
<th class="py-2 px-1 font-medium {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px"></th>
|
||||
{/each}
|
||||
|
||||
<!-- Configurable columns -->
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
<th class="{colConfig.align === 'right' ? 'text-right' : colConfig.align === 'center' ? 'text-center' : 'text-left'} py-2 px-2 font-medium" style="width: {getDisplayWidth(colId)}px">
|
||||
{colConfig.label}
|
||||
</th>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Fixed end columns (actions) -->
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
<th class="text-right py-2 px-2 font-medium actions-col" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'actions'}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<span>Actions</span>
|
||||
<ColumnSettingsPopover {gridId} />
|
||||
</div>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each skeletonIndices as i (i)}
|
||||
<tr class="border-b border-muted">
|
||||
<!-- Fixed start columns -->
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<!-- Configurable columns -->
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
{@const width = getDisplayWidth(colId)}
|
||||
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {width}px">
|
||||
<Skeleton class="h-4" style="width: {Math.max(30, Math.min(width - 16, width * 0.7))}px" />
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Fixed end columns -->
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
<td class="py-1.5 px-2 actions-col" style="width: {getDisplayWidth(colId)}px">
|
||||
<Skeleton class="h-4 w-12" />
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tableHeader()}
|
||||
<thead class="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<!-- Fixed start columns (select checkbox, expand chevron) -->
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<th class="py-2 px-1 font-medium {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'select' && selectable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleSelectAll}
|
||||
class="flex items-center justify-center transition-colors opacity-40 hover:opacity-100 cursor-pointer"
|
||||
title={allSelected ? 'Deselect all' : 'Select all'}
|
||||
>
|
||||
{#if allSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{:else if someSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if colId === 'expand' && expandable}
|
||||
<!-- Expand column header is empty -->
|
||||
{:else if headerCell}
|
||||
{@render headerCell(colConfig!, sortState)}
|
||||
{:else}
|
||||
{colConfig?.label ?? ''}
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
|
||||
<!-- Configurable columns -->
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
<th
|
||||
class="{colConfig.align === 'right' ? 'text-right' : colConfig.align === 'center' ? 'text-center' : 'text-left'} py-2 px-2 font-medium"
|
||||
style="width: {getDisplayWidth(colId)}px"
|
||||
>
|
||||
{#if headerCell}
|
||||
{@render headerCell(colConfig, sortState)}
|
||||
{:else if isSortable(colId)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleSort(getSortField(colId))}
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors w-full {colConfig.align === 'right' ? 'justify-end' : colConfig.align === 'center' ? 'justify-center' : ''}"
|
||||
>
|
||||
{colConfig.label}
|
||||
{#if sortState?.field === getSortField(colId)}
|
||||
{#if sortState.direction === 'asc'}
|
||||
<ArrowUp class="w-3 h-3" />
|
||||
{:else}
|
||||
<ArrowDown class="w-3 h-3" />
|
||||
{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="w-3 h-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
{colConfig.label}
|
||||
{/if}
|
||||
|
||||
<!-- Resize handle -->
|
||||
{#if isResizable(colId)}
|
||||
<div
|
||||
class="resize-handle"
|
||||
use:columnResize={{
|
||||
onResize: (w) => handleResize(colId, w),
|
||||
onResizeEnd: (w) => handleResizeEnd(colId, w),
|
||||
minWidth: colConfig.minWidth
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
</th>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Fixed end columns (actions) -->
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<th class="text-right py-2 px-2 font-medium actions-col" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'actions'}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<span>Actions</span>
|
||||
<ColumnSettingsPopover {gridId} />
|
||||
</div>
|
||||
{:else if headerCell}
|
||||
{@render headerCell(colConfig!, sortState)}
|
||||
{:else}
|
||||
{colConfig?.label ?? ''}
|
||||
{/if}
|
||||
|
||||
<!-- Resize handle for fixed end columns -->
|
||||
{#if isResizable(colId)}
|
||||
<div
|
||||
class="resize-handle resize-handle-left"
|
||||
use:columnResize={{
|
||||
onResize: (w) => handleResize(colId, w),
|
||||
onResizeEnd: (w) => handleResizeEnd(colId, w),
|
||||
minWidth: colConfig?.minWidth
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tableBody()}
|
||||
<tbody>
|
||||
{#each visibleData as item, index (item[keyField])}
|
||||
{@const rowState = getRowState(item, index)}
|
||||
<tr
|
||||
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
|
||||
onclick={(e) => onRowClick?.(item, e)}
|
||||
>
|
||||
<!-- Fixed start columns (select checkbox, expand chevron) -->
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'select' && selectable}
|
||||
{#if rowState.isSelectable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection(item[keyField]);
|
||||
}}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
>
|
||||
{#if rowState.isSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if colId === 'expand' && expandable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(item[keyField]);
|
||||
}}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
|
||||
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{#if rowState.isExpanded}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
|
||||
<!-- Configurable columns -->
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if cell}
|
||||
{@render cell(colConfig, item, rowState)}
|
||||
{:else}
|
||||
<!-- Default: render as text -->
|
||||
{String(item[colId as keyof T] ?? '')}
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Fixed end columns (actions) -->
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
|
||||
{#if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
|
||||
<!-- Expanded row content -->
|
||||
{#if rowState.isExpanded && expandedRow}
|
||||
<tr class="expanded-row">
|
||||
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
|
||||
{@render expandedRow(item, rowState)}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
{/snippet}
|
||||
|
||||
{#snippet tableContent()}
|
||||
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
|
||||
{@render tableHeader()}
|
||||
{@render tableBody()}
|
||||
</table>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto rounded-lg data-grid-wrapper {wrapperClass}" bind:this={scrollContainer} onscroll={handleScroll}>
|
||||
{#if loading && data.length === 0}
|
||||
{#if loadingState}
|
||||
{@render loadingState()}
|
||||
{:else}
|
||||
{@render skeletonContent()}
|
||||
{/if}
|
||||
{:else if data.length === 0 && emptyState}
|
||||
{@render emptyState()}
|
||||
{:else if virtualScroll}
|
||||
<!-- Virtual scroll mode with spacer rows for sticky header support -->
|
||||
<table class="text-sm table-fixed data-grid {className}" style="width: {totalTableWidth}px">
|
||||
{@render tableHeader()}
|
||||
<tbody>
|
||||
<!-- Top spacer -->
|
||||
{#if offsetY > 0}
|
||||
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {offsetY}px; padding: 0; border: none;"></td></tr>
|
||||
{/if}
|
||||
<!-- Visible rows -->
|
||||
{#each visibleData as item, index (item[keyField])}
|
||||
{@const rowState = getRowState(item, index)}
|
||||
<tr
|
||||
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
|
||||
onclick={(e) => onRowClick?.(item, e)}
|
||||
>
|
||||
{#each fixedStartCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if colId === 'select' && selectable}
|
||||
{#if rowState.isSelectable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
|
||||
>
|
||||
{#if rowState.isSelected}
|
||||
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{:else}
|
||||
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if colId === 'expand' && expandable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleExpand(item[keyField]); }}
|
||||
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
|
||||
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{#if rowState.isExpanded}
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#each orderedColumns as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
{#if colConfig}
|
||||
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
|
||||
{#if cell}
|
||||
{@render cell(colConfig, item, rowState)}
|
||||
{:else}
|
||||
{String(item[colId as keyof T] ?? '')}
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each fixedEndCols as colId (colId)}
|
||||
{@const colConfig = columnConfigMap.get(colId)}
|
||||
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
|
||||
{#if cell}
|
||||
{@render cell(colConfig!, item, rowState)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{#if rowState.isExpanded && expandedRow}
|
||||
<tr class="expanded-row">
|
||||
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
|
||||
{@render expandedRow(item, rowState)}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- Bottom spacer -->
|
||||
{#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0}
|
||||
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<!-- Standard mode -->
|
||||
{@render tableContent()}
|
||||
{/if}
|
||||
</div>
|
||||
28
lib/components/data-grid/context.ts
Normal file
28
lib/components/data-grid/context.ts
Normal 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
lib/components/data-grid/index.ts
Normal file
16
lib/components/data-grid/index.ts
Normal 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
lib/components/data-grid/types.ts
Normal file
112
lib/components/data-grid/types.ts
Normal 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
lib/components/host-info.svelte
Normal file
466
lib/components/host-info.svelte
Normal 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
lib/components/icon-picker.svelte
Normal file
65
lib/components/icon-picker.svelte
Normal 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
lib/components/main-content.svelte
Normal file
9
lib/components/main-content.svelte
Normal 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>
|
||||
22
lib/components/permission-guard.svelte
Normal file
22
lib/components/permission-guard.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { authStore, canAccess } from '$lib/stores/auth';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
resource: string;
|
||||
action: string;
|
||||
children: Snippet;
|
||||
fallback?: Snippet;
|
||||
}
|
||||
|
||||
let { resource, action, children, fallback }: Props = $props();
|
||||
|
||||
// Check if user can access the resource/action
|
||||
const hasAccess = $derived($canAccess(resource, action));
|
||||
</script>
|
||||
|
||||
{#if hasAccess}
|
||||
{@render children()}
|
||||
{:else if fallback}
|
||||
{@render fallback()}
|
||||
{/if}
|
||||
44
lib/components/theme-toggle.svelte
Normal file
44
lib/components/theme-toggle.svelte
Normal 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>
|
||||
22
lib/components/ui/accordion/accordion-content.svelte
Normal file
22
lib/components/ui/accordion/accordion-content.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="accordion-content"
|
||||
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...restProps}
|
||||
>
|
||||
<div class={cn("pb-4 pt-0", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
17
lib/components/ui/accordion/accordion-item.svelte
Normal file
17
lib/components/ui/accordion/accordion-item.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="accordion-item"
|
||||
class={cn("border-b last:border-b-0", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
32
lib/components/ui/accordion/accordion-trigger.svelte
Normal file
32
lib/components/ui/accordion/accordion-trigger.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
|
||||
level?: AccordionPrimitive.HeaderProps["level"];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header {level} class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
bind:ref
|
||||
class={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon
|
||||
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
16
lib/components/ui/accordion/accordion.svelte
Normal file
16
lib/components/ui/accordion/accordion.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: AccordionPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="accordion"
|
||||
{...restProps}
|
||||
/>
|
||||
16
lib/components/ui/accordion/index.ts
Normal file
16
lib/components/ui/accordion/index.ts
Normal 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,
|
||||
};
|
||||
23
lib/components/ui/alert/alert-description.svelte
Normal file
23
lib/components/ui/alert/alert-description.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-description"
|
||||
class={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
lib/components/ui/alert/alert-title.svelte
Normal file
20
lib/components/ui/alert/alert-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-title"
|
||||
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
50
lib/components/ui/alert/alert.svelte
Normal file
50
lib/components/ui/alert/alert.svelte
Normal 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
lib/components/ui/alert/index.ts
Normal file
14
lib/components/ui/alert/index.ts
Normal 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,
|
||||
};
|
||||
17
lib/components/ui/avatar/avatar-fallback.svelte
Normal file
17
lib/components/ui/avatar/avatar-fallback.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
lib/components/ui/avatar/avatar-image.svelte
Normal file
17
lib/components/ui/avatar/avatar-image.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("aspect-square size-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
lib/components/ui/avatar/avatar.svelte
Normal file
19
lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
13
lib/components/ui/avatar/index.ts
Normal file
13
lib/components/ui/avatar/index.ts
Normal 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
lib/components/ui/badge/badge.svelte
Normal file
50
lib/components/ui/badge/badge.svelte
Normal 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
lib/components/ui/badge/index.ts
Normal file
2
lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
lib/components/ui/button/button.svelte
Normal file
82
lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "cursor-pointer focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
17
lib/components/ui/button/index.ts
Normal file
17
lib/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
76
lib/components/ui/calendar/calendar-caption.svelte
Normal file
76
lib/components/ui/calendar/calendar-caption.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type Calendar from "./calendar.svelte";
|
||||
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||
|
||||
let {
|
||||
captionLayout,
|
||||
months,
|
||||
monthFormat,
|
||||
years,
|
||||
yearFormat,
|
||||
month,
|
||||
locale,
|
||||
placeholder = $bindable(),
|
||||
monthIndex = 0,
|
||||
}: {
|
||||
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||
month: DateValue;
|
||||
placeholder: DateValue | undefined;
|
||||
locale: string;
|
||||
monthIndex: number;
|
||||
} = $props();
|
||||
|
||||
function formatYear(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||
}
|
||||
|
||||
function formatMonth(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet MonthSelect()}
|
||||
<CalendarMonthSelect
|
||||
{months}
|
||||
{monthFormat}
|
||||
value={month.month}
|
||||
onchange={(e) => {
|
||||
if (!placeholder) return;
|
||||
const v = Number.parseInt(e.currentTarget.value);
|
||||
const newPlaceholder = placeholder.set({ month: v });
|
||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet YearSelect()}
|
||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||
{/snippet}
|
||||
|
||||
{#if captionLayout === "dropdown"}
|
||||
{@render MonthSelect()}
|
||||
{@render YearSelect()}
|
||||
{:else if captionLayout === "dropdown-months"}
|
||||
{@render MonthSelect()}
|
||||
{#if placeholder}
|
||||
{formatYear(placeholder)}
|
||||
{/if}
|
||||
{:else if captionLayout === "dropdown-years"}
|
||||
{#if placeholder}
|
||||
{formatMonth(placeholder)}
|
||||
{/if}
|
||||
{@render YearSelect()}
|
||||
{:else}
|
||||
{formatMonth(month)} {formatYear(month)}
|
||||
{/if}
|
||||
19
lib/components/ui/calendar/calendar-cell.svelte
Normal file
19
lib/components/ui/calendar/calendar-cell.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.CellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Cell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-md [&:last-child[data-selected]_[data-bits-day]]:rounded-e-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
35
lib/components/ui/calendar/calendar-day.svelte
Normal file
35
lib/components/ui/calendar/calendar-day.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.DayProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Day
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
|
||||
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||
// Outside months
|
||||
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||
// Disabled
|
||||
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
// Unavailable
|
||||
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||
// hover
|
||||
"dark:hover:text-accent-foreground",
|
||||
// focus
|
||||
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||
// inner spans
|
||||
"[&>span]:text-xs [&>span]:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
12
lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
12
lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridBodyProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||
12
lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
12
lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridHeadProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||
12
lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
12
lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridRowProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||
16
lib/components/ui/calendar/calendar-grid.svelte
Normal file
16
lib/components/ui/calendar/calendar-grid.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Grid
|
||||
bind:ref
|
||||
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
19
lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadCellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.HeadCell
|
||||
bind:ref
|
||||
class={cn(
|
||||
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
lib/components/ui/calendar/calendar-header.svelte
Normal file
19
lib/components/ui/calendar/calendar-header.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Header
|
||||
bind:ref
|
||||
class={cn(
|
||||
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
16
lib/components/ui/calendar/calendar-heading.svelte
Normal file
16
lib/components/ui/calendar/calendar-heading.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadingProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Heading
|
||||
bind:ref
|
||||
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
44
lib/components/ui/calendar/calendar-month-select.svelte
Normal file
44
lib/components/ui/calendar/calendar-month-select.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
onchange,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||
<select {...props} {value} {onchange}>
|
||||
{#each monthItems as monthItem (monthItem.value)}
|
||||
<option
|
||||
value={monthItem.value}
|
||||
selected={value !== undefined
|
||||
? monthItem.value === value
|
||||
: monthItem.value === selectedMonthItem.value}
|
||||
>
|
||||
{monthItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pe-1 ps-2 text-sm font-medium [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.MonthSelect>
|
||||
</span>
|
||||
15
lib/components/ui/calendar/calendar-month.svelte
Normal file
15
lib/components/ui/calendar/calendar-month.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
19
lib/components/ui/calendar/calendar-months.svelte
Normal file
19
lib/components/ui/calendar/calendar-months.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
19
lib/components/ui/calendar/calendar-nav.svelte
Normal file
19
lib/components/ui/calendar/calendar-nav.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
31
lib/components/ui/calendar/calendar-next-button.svelte
Normal file
31
lib/components/ui/calendar/calendar-next-button.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.NextButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.NextButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
31
lib/components/ui/calendar/calendar-prev-button.svelte
Normal file
31
lib/components/ui/calendar/calendar-prev-button.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = "ghost",
|
||||
...restProps
|
||||
}: CalendarPrimitive.PrevButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.PrevButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
43
lib/components/ui/calendar/calendar-year-select.svelte
Normal file
43
lib/components/ui/calendar/calendar-year-select.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||
<select {...props} {value}>
|
||||
{#each yearItems as yearItem (yearItem.value)}
|
||||
<option
|
||||
value={yearItem.value}
|
||||
selected={value !== undefined
|
||||
? yearItem.value === value
|
||||
: yearItem.value === selectedYearItem.value}
|
||||
>
|
||||
{yearItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pe-1 ps-2 text-sm font-medium [&>svg]:size-3.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.YearSelect>
|
||||
</span>
|
||||
115
lib/components/ui/calendar/calendar.svelte
Normal file
115
lib/components/ui/calendar/calendar.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||
import * as Calendar from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ButtonVariant } from "../button/button.svelte";
|
||||
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
class: className,
|
||||
weekdayFormat = "short",
|
||||
buttonVariant = "ghost",
|
||||
captionLayout = "label",
|
||||
locale = "en-US",
|
||||
months: monthsProp,
|
||||
years,
|
||||
monthFormat: monthFormatProp,
|
||||
yearFormat = "numeric",
|
||||
day,
|
||||
disableDaysOutsideMonth = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||
buttonVariant?: ButtonVariant;
|
||||
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||
} = $props();
|
||||
|
||||
const monthFormat = $derived.by(() => {
|
||||
if (monthFormatProp) return monthFormatProp;
|
||||
if (captionLayout.startsWith("dropdown")) return "short";
|
||||
return "long";
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<CalendarPrimitive.Root
|
||||
bind:value={value as never}
|
||||
bind:ref
|
||||
bind:placeholder
|
||||
{weekdayFormat}
|
||||
{disableDaysOutsideMonth}
|
||||
class={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{locale}
|
||||
{monthFormat}
|
||||
{yearFormat}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<Calendar.Months>
|
||||
<Calendar.Nav>
|
||||
<Calendar.PrevButton variant={buttonVariant} />
|
||||
<Calendar.NextButton variant={buttonVariant} />
|
||||
</Calendar.Nav>
|
||||
{#each months as month, monthIndex (month)}
|
||||
<Calendar.Month>
|
||||
<Calendar.Header>
|
||||
<Calendar.Caption
|
||||
{captionLayout}
|
||||
months={monthsProp}
|
||||
{monthFormat}
|
||||
{years}
|
||||
{yearFormat}
|
||||
month={month.value}
|
||||
bind:placeholder
|
||||
{locale}
|
||||
{monthIndex}
|
||||
/>
|
||||
</Calendar.Header>
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow class="select-none">
|
||||
{#each weekdays as weekday (weekday)}
|
||||
<Calendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</Calendar.HeadCell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
<Calendar.GridBody>
|
||||
{#each month.weeks as weekDates (weekDates)}
|
||||
<Calendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date (date)}
|
||||
<Calendar.Cell {date} month={month.value}>
|
||||
{#if day}
|
||||
{@render day({
|
||||
day: date,
|
||||
outsideMonth: !isEqualMonth(date, month.value),
|
||||
})}
|
||||
{:else}
|
||||
<Calendar.Day />
|
||||
{/if}
|
||||
</Calendar.Cell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
{/each}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
</Calendar.Month>
|
||||
{/each}
|
||||
</Calendar.Months>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.Root>
|
||||
40
lib/components/ui/calendar/index.ts
Normal file
40
lib/components/ui/calendar/index.ts
Normal 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,
|
||||
};
|
||||
20
lib/components/ui/card/card-action.svelte
Normal file
20
lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
lib/components/ui/card/card-content.svelte
Normal file
15
lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
lib/components/ui/card/card-description.svelte
Normal file
20
lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
20
lib/components/ui/card/card-footer.svelte
Normal file
20
lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
lib/components/ui/card/card-header.svelte
Normal file
23
lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
lib/components/ui/card/card-title.svelte
Normal file
20
lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
lib/components/ui/card/card.svelte
Normal file
23
lib/components/ui/card/card.svelte
Normal 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
lib/components/ui/card/index.ts
Normal file
25
lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
36
lib/components/ui/checkbox/checkbox.svelte
Normal file
36
lib/components/ui/checkbox/checkbox.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import { Check as CheckIcon } from "lucide-svelte";
|
||||
import { Minus as MinusIcon } from "lucide-svelte";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<CheckboxPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
||||
{#if checked}
|
||||
<CheckIcon class="size-3.5" />
|
||||
{:else if indeterminate}
|
||||
<MinusIcon class="size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</CheckboxPrimitive.Root>
|
||||
6
lib/components/ui/checkbox/index.ts
Normal file
6
lib/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
40
lib/components/ui/command/command-dialog.svelte
Normal file
40
lib/components/ui/command/command-dialog.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { Command as CommandPrimitive, Dialog as DialogPrimitive } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
import Command from "./command.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import type { WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run",
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
|
||||
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
title?: string;
|
||||
description?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...restProps}>
|
||||
<Dialog.Header class="sr-only">
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>{description}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
|
||||
<Command
|
||||
class="**:data-[slot=command-input-wrapper]:h-12 [&_[data-command-group]:not([hidden])_~[data-command-group]]:pt-0 [&_[data-command-group]]:px-2 [&_[data-command-input-wrapper]_svg]:h-5 [&_[data-command-input-wrapper]_svg]:w-5 [&_[data-command-input]]:h-12 [&_[data-command-item]]:px-2 [&_[data-command-item]]:py-3 [&_[data-command-item]_svg]:h-5 [&_[data-command-item]_svg]:w-5"
|
||||
{...restProps}
|
||||
bind:value
|
||||
bind:ref
|
||||
{children}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
17
lib/components/ui/command/command-empty.svelte
Normal file
17
lib/components/ui/command/command-empty.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.EmptyProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty
|
||||
bind:ref
|
||||
data-slot="command-empty"
|
||||
class={cn("py-6 text-center text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
32
lib/components/ui/command/command-group.svelte
Normal file
32
lib/components/ui/command/command-group.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive, useId } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
heading,
|
||||
value,
|
||||
...restProps
|
||||
}: CommandPrimitive.GroupProps & {
|
||||
heading?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Group
|
||||
bind:ref
|
||||
data-slot="command-group"
|
||||
class={cn("text-foreground overflow-hidden p-1", className)}
|
||||
value={value ?? heading ?? `----${useId()}`}
|
||||
{...restProps}
|
||||
>
|
||||
{#if heading}
|
||||
<CommandPrimitive.GroupHeading
|
||||
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
|
||||
>
|
||||
{heading}
|
||||
</CommandPrimitive.GroupHeading>
|
||||
{/if}
|
||||
<CommandPrimitive.GroupItems {children} />
|
||||
</CommandPrimitive.Group>
|
||||
26
lib/components/ui/command/command-input.svelte
Normal file
26
lib/components/ui/command/command-input.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import SearchIcon from "@lucide/svelte/icons/search";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(""),
|
||||
...restProps
|
||||
}: CommandPrimitive.InputProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-9 items-center gap-2 border-b pe-8 ps-3" data-slot="command-input-wrapper">
|
||||
<SearchIcon class="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
class={cn(
|
||||
"placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
bind:value
|
||||
/>
|
||||
</div>
|
||||
20
lib/components/ui/command/command-item.svelte
Normal file
20
lib/components/ui/command/command-item.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="command-item"
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
lib/components/ui/command/command-link-item.svelte
Normal file
20
lib/components/ui/command/command-link-item.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.LinkItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.LinkItem
|
||||
bind:ref
|
||||
data-slot="command-item"
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
lib/components/ui/command/command-list.svelte
Normal file
17
lib/components/ui/command/command-list.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
bind:ref
|
||||
data-slot="command-list"
|
||||
class={cn("max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
lib/components/ui/command/command-loading.svelte
Normal file
7
lib/components/ui/command/command-loading.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CommandPrimitive.LoadingProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Loading bind:ref {...restProps} />
|
||||
17
lib/components/ui/command/command-separator.svelte
Normal file
17
lib/components/ui/command/command-separator.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="command-separator"
|
||||
class={cn("bg-border -mx-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
lib/components/ui/command/command-shortcut.svelte
Normal file
20
lib/components/ui/command/command-shortcut.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="command-shortcut"
|
||||
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
28
lib/components/ui/command/command.svelte
Normal file
28
lib/components/ui/command/command.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
|
||||
export type CommandRootApi = CommandPrimitive.Root;
|
||||
|
||||
let {
|
||||
api = $bindable(null),
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.RootProps & {
|
||||
api?: CommandRootApi | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Root
|
||||
bind:this={api}
|
||||
bind:value
|
||||
bind:ref
|
||||
data-slot="command"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
37
lib/components/ui/command/index.ts
Normal file
37
lib/components/ui/command/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Root from "./command.svelte";
|
||||
import Loading from "./command-loading.svelte";
|
||||
import Dialog from "./command-dialog.svelte";
|
||||
import Empty from "./command-empty.svelte";
|
||||
import Group from "./command-group.svelte";
|
||||
import Item from "./command-item.svelte";
|
||||
import Input from "./command-input.svelte";
|
||||
import List from "./command-list.svelte";
|
||||
import Separator from "./command-separator.svelte";
|
||||
import Shortcut from "./command-shortcut.svelte";
|
||||
import LinkItem from "./command-link-item.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
LinkItem,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
LinkItem as CommandLinkItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading,
|
||||
};
|
||||
73
lib/components/ui/date-picker/date-picker.svelte
Normal file
73
lib/components/ui/date-picker/date-picker.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { Calendar as CalendarIcon } from 'lucide-svelte';
|
||||
import { type DateValue, getLocalTimeZone, parseDate, today } from '@internationalized/date';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { Calendar } from '$lib/components/ui/calendar/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||
import { formatDate } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
/** Value in YYYY-MM-DD format (for compatibility with existing code) */
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { value = $bindable(''), placeholder = 'Pick a date', class: className = '' }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
// Convert YYYY-MM-DD string to DateValue
|
||||
const dateValue = $derived.by(() => {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return parseDate(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Format display using user's preferred format
|
||||
const displayValue = $derived(value ? formatDate(value + 'T00:00:00') : '');
|
||||
|
||||
// Handle calendar selection
|
||||
function onSelect(newValue: DateValue | undefined) {
|
||||
if (newValue) {
|
||||
// Convert DateValue back to YYYY-MM-DD string
|
||||
const year = newValue.year;
|
||||
const month = String(newValue.month).padStart(2, '0');
|
||||
const day = String(newValue.day).padStart(2, '0');
|
||||
value = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
value = '';
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'w-[140px] justify-start text-left font-normal h-8',
|
||||
!value && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{#if displayValue}
|
||||
<span class="text-xs">{displayValue}</span>
|
||||
{:else}
|
||||
<span class="text-xs">{placeholder}</span>
|
||||
{/if}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
type="single"
|
||||
value={dateValue}
|
||||
onValueChange={onSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
3
lib/components/ui/date-picker/index.ts
Normal file
3
lib/components/ui/date-picker/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import DatePicker from './date-picker.svelte';
|
||||
|
||||
export { DatePicker };
|
||||
7
lib/components/ui/dialog/dialog-close.svelte
Normal file
7
lib/components/ui/dialog/dialog-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user