mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-11 05:39:04 +00:00
Initial commit
This commit is contained in:
128
LICENSE.txt
Normal file
128
LICENSE.txt
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
Business Source License 1.1
|
||||||
|
|
||||||
|
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
|
||||||
|
"Business Source License" is a trademark of MariaDB Corporation Ab.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
|
||||||
|
Licensor: Finsys / Jarek Krochmalski
|
||||||
|
|
||||||
|
Licensed Work: Dockhand
|
||||||
|
The Licensed Work is (c) 2025-2026 Finsys / Jarek Krochmalski.
|
||||||
|
|
||||||
|
Additional Use Grant: You may use the Licensed Work for any purpose, including
|
||||||
|
production use, provided that you do not offer the Licensed
|
||||||
|
Work, or any derivative work of the Licensed Work, to third
|
||||||
|
parties as a commercial hosted service, managed service, or
|
||||||
|
software-as-a-service (SaaS) offering where the primary value
|
||||||
|
proposition to users is Docker container management
|
||||||
|
functionality substantially similar to the Licensed Work.
|
||||||
|
|
||||||
|
For clarity, the following uses are explicitly permitted
|
||||||
|
without any restriction:
|
||||||
|
|
||||||
|
(a) Personal use, including home labs and hobby projects
|
||||||
|
(b) Internal business use within your organization, regardless
|
||||||
|
of the number of Docker environments managed
|
||||||
|
(c) Use by non-profit organizations and charitable entities
|
||||||
|
(d) Educational, academic, and research purposes
|
||||||
|
(e) Evaluation, testing, development, and demonstration purposes
|
||||||
|
(f) Embedding or integrating the Licensed Work into internal
|
||||||
|
tools or platforms that are not offered commercially to
|
||||||
|
third parties
|
||||||
|
(g) Use by managed service providers (MSPs) to manage Docker
|
||||||
|
infrastructure on behalf of their clients, provided the
|
||||||
|
MSP does not offer Dockhand itself as the service
|
||||||
|
|
||||||
|
Change Date: January 1, 2029
|
||||||
|
|
||||||
|
Change License: Apache License, Version 2.0
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Terms
|
||||||
|
|
||||||
|
The Licensor hereby grants you the right to copy, modify, create derivative
|
||||||
|
works, redistribute, and make non-production use of the Licensed Work. The
|
||||||
|
Licensor may make an Additional Use Grant, above, permitting limited
|
||||||
|
production use.
|
||||||
|
|
||||||
|
Effective on the Change Date, or the fourth anniversary of the first publicly
|
||||||
|
available distribution of a specific version of the Licensed Work under this
|
||||||
|
License, whichever comes first, the Licensor hereby grants you rights under
|
||||||
|
the terms of the Change License, and the rights granted in the paragraph
|
||||||
|
above terminate.
|
||||||
|
|
||||||
|
If your use of the Licensed Work does not comply with the requirements
|
||||||
|
currently in effect as described in this License, you must purchase a
|
||||||
|
commercial license from the Licensor, its affiliated entities, or authorized
|
||||||
|
resellers, or you must refrain from using the Licensed Work.
|
||||||
|
|
||||||
|
All copies of the original and modified Licensed Work, and derivative works
|
||||||
|
of the Licensed Work, are subject to this License. This License applies
|
||||||
|
separately for each version of the Licensed Work and the Change Date may vary
|
||||||
|
for each version of the Licensed Work released by Licensor.
|
||||||
|
|
||||||
|
You must conspicuously display this License on each original or modified copy
|
||||||
|
of the Licensed Work. If you receive the Licensed Work in original or
|
||||||
|
modified form from a third party, the terms and conditions set forth in this
|
||||||
|
License apply to your use of that work.
|
||||||
|
|
||||||
|
Any use of the Licensed Work in violation of this License will automatically
|
||||||
|
terminate your rights under this License for the current and all other
|
||||||
|
versions of the Licensed Work.
|
||||||
|
|
||||||
|
This License does not grant you any right in any trademark or logo of
|
||||||
|
Licensor or its affiliates (provided that you may use a trademark or logo of
|
||||||
|
Licensor as expressly required by this License).
|
||||||
|
|
||||||
|
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
||||||
|
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
||||||
|
TITLE.
|
||||||
|
|
||||||
|
MariaDB hereby grants you permission to use this License's text to license
|
||||||
|
your works, and to refer to it using the trademark "Business Source License",
|
||||||
|
as long as you comply with the Covenants of Licensor below.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Covenants of Licensor
|
||||||
|
|
||||||
|
In consideration of the right to use this License's text and the "Business
|
||||||
|
Source License" name and trademark, Licensor covenants to MariaDB, and to all
|
||||||
|
other recipients of the licensed work to be provided by Licensor:
|
||||||
|
|
||||||
|
1. To specify as the Change License the GPL Version 2.0 or any later version,
|
||||||
|
or a license that is compatible with GPL Version 2.0 or a later version,
|
||||||
|
where "compatible" means that software provided under the Change License can
|
||||||
|
be included in a program with software provided under GPL Version 2.0 or a
|
||||||
|
later version. Licensor may specify additional Change Licenses without
|
||||||
|
limitation.
|
||||||
|
|
||||||
|
2. To either: (a) specify an additional grant of rights to use that does not
|
||||||
|
impose any additional restriction on the right granted in this License, as
|
||||||
|
the Additional Use Grant; or (b) insert the text "None".
|
||||||
|
|
||||||
|
3. To specify a Change Date.
|
||||||
|
|
||||||
|
4. Not to modify this License in any other way.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Notice
|
||||||
|
|
||||||
|
The Business Source License (this document, or the "License") is not an Open
|
||||||
|
Source license. However, the Licensed Work will eventually be made available
|
||||||
|
under an Open Source License, as stated in this License.
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
For licensing inquiries, commercial licensing, or enterprise features:
|
||||||
|
|
||||||
|
Website: https://dockhand.io
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
23
app.d.ts
vendored
Normal file
23
app.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
|
||||||
|
import type { AuthenticatedUser } from '$lib/server/auth';
|
||||||
|
|
||||||
|
// Build-time constants injected by Vite
|
||||||
|
declare const __BUILD_DATE__: string | null;
|
||||||
|
declare const __BUILD_COMMIT__: string | null;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
interface Locals {
|
||||||
|
user: AuthenticatedUser | null;
|
||||||
|
authEnabled: boolean;
|
||||||
|
}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
17
app.html
Normal file
17
app.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
|
||||||
168
hooks.server.ts
Normal file
168
hooks.server.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { initDatabase, hasAdminUser } from '$lib/server/db';
|
||||||
|
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
|
||||||
|
import { startScheduler } from '$lib/server/scheduler';
|
||||||
|
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
||||||
|
import { setServerStartTime } from '$lib/server/uptime';
|
||||||
|
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
|
||||||
|
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// License expiry check interval (24 hours)
|
||||||
|
const LICENSE_CHECK_INTERVAL = 86400000;
|
||||||
|
|
||||||
|
// HMR guard for license check interval
|
||||||
|
declare global {
|
||||||
|
var __licenseCheckInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database on server start (synchronous with SQLite)
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
if (!initialized) {
|
||||||
|
try {
|
||||||
|
setServerStartTime(); // Track when server started
|
||||||
|
initDatabase();
|
||||||
|
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
|
||||||
|
console.log('Hostname for license validation:', getHostname());
|
||||||
|
// Start background subprocesses for metrics and event collection (isolated processes)
|
||||||
|
startSubprocesses().catch(err => {
|
||||||
|
console.error('Failed to start background subprocesses:', err);
|
||||||
|
});
|
||||||
|
startScheduler(); // Start unified scheduler for auto-updates and git syncs (async)
|
||||||
|
|
||||||
|
// Check license expiry on startup and then daily (with HMR guard)
|
||||||
|
checkLicenseExpiry().catch(err => {
|
||||||
|
console.error('Failed to check license expiry:', err);
|
||||||
|
});
|
||||||
|
if (!globalThis.__licenseCheckInterval) {
|
||||||
|
globalThis.__licenseCheckInterval = setInterval(() => {
|
||||||
|
checkLicenseExpiry().catch(err => {
|
||||||
|
console.error('Failed to check license expiry:', err);
|
||||||
|
});
|
||||||
|
}, LICENSE_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown handling
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log('[Server] Shutting down...');
|
||||||
|
await stopSubprocesses();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize database:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes that don't require authentication
|
||||||
|
const PUBLIC_PATHS = [
|
||||||
|
'/login',
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/logout',
|
||||||
|
'/api/auth/session',
|
||||||
|
'/api/auth/settings',
|
||||||
|
'/api/auth/providers',
|
||||||
|
'/api/auth/oidc',
|
||||||
|
'/api/license',
|
||||||
|
'/api/changelog',
|
||||||
|
'/api/dependencies'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if path is public
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
return PUBLIC_PATHS.some(path => pathname === path || pathname.startsWith(path + '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is a static asset
|
||||||
|
function isStaticAsset(pathname: string): boolean {
|
||||||
|
return pathname.startsWith('/_app/') ||
|
||||||
|
pathname.startsWith('/favicon') ||
|
||||||
|
pathname.endsWith('.webp') ||
|
||||||
|
pathname.endsWith('.png') ||
|
||||||
|
pathname.endsWith('.jpg') ||
|
||||||
|
pathname.endsWith('.svg') ||
|
||||||
|
pathname.endsWith('.ico') ||
|
||||||
|
pathname.endsWith('.css') ||
|
||||||
|
pathname.endsWith('.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
// Skip auth for static assets
|
||||||
|
if (isStaticAsset(event.url.pathname)) {
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket upgrade for terminal connections is handled by the build patch (scripts/patch-build.ts)
|
||||||
|
// This is necessary because svelte-adapter-bun expects server.websocket() which doesn't exist in SvelteKit
|
||||||
|
|
||||||
|
// Check if auth is enabled
|
||||||
|
const authEnabled = await isAuthEnabled();
|
||||||
|
|
||||||
|
// If auth is disabled, allow everything (app works as before)
|
||||||
|
if (!authEnabled) {
|
||||||
|
event.locals.user = null;
|
||||||
|
event.locals.authEnabled = false;
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth is enabled - check session
|
||||||
|
const user = await validateSession(event.cookies);
|
||||||
|
event.locals.user = user;
|
||||||
|
event.locals.authEnabled = true;
|
||||||
|
|
||||||
|
// Public paths don't require authentication
|
||||||
|
if (isPublicPath(event.url.pathname)) {
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not authenticated
|
||||||
|
if (!user) {
|
||||||
|
// Special case: allow user creation when auth is enabled but no admin exists yet
|
||||||
|
// This enables the first admin user to be created during initial setup
|
||||||
|
const noAdminSetupMode = !(await hasAdminUser());
|
||||||
|
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
|
||||||
|
return resolve(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API routes return 401
|
||||||
|
if (event.url.pathname.startsWith('/api/')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI routes redirect to login
|
||||||
|
const redirectUrl = encodeURIComponent(event.url.pathname + event.url.search);
|
||||||
|
redirect(307, `/login?redirect=${redirectUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleError: HandleServerError = ({ error, event }) => {
|
||||||
|
// Skip logging 404 errors - they're expected for missing routes
|
||||||
|
const status = (error as { status?: number })?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
return {
|
||||||
|
message: 'Not found',
|
||||||
|
code: 'NOT_FOUND'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log only essential error info without code snippets
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error(`[Error] ${event.url.pathname}: ${message}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
code: 'INTERNAL_ERROR'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// CI trigger 1766327149
|
||||||
BIN
images/logo.webp
Normal file
BIN
images/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
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} />
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user