Files
dockhand/lib/components/CommandPalette.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

356 lines
11 KiB
Svelte

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