command mode

This commit is contained in:
Piotr Domański 2026-04-02 12:43:37 +02:00
parent 27fd13e40d
commit 52a235f8ac
2 changed files with 146 additions and 0 deletions

View file

@ -0,0 +1,111 @@
<script lang="ts">
export interface Command {
id: string;
label: string;
description?: string;
keywords?: string[];
action: () => void;
}
interface Props {
commands: Command[];
open: boolean;
onclose: () => void;
}
let { commands, open, onclose }: Props = $props();
let query = $state('');
let selectedIdx = $state(0);
let inputEl = $state<HTMLInputElement | null>(null);
const filtered = $derived(
query.trim()
? commands.filter(c =>
c.label.toLowerCase().includes(query.toLowerCase()) ||
c.keywords?.some(k => k.toLowerCase().includes(query.toLowerCase()))
)
: commands
);
$effect(() => {
if (open) {
query = '';
selectedIdx = 0;
setTimeout(() => inputEl?.focus(), 50);
}
});
// Reset selection when filter changes
$effect(() => { filtered; selectedIdx = 0; });
function execute(cmd: Command) {
onclose();
cmd.action();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { onclose(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, filtered.length - 1); }
if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); }
if (e.key === 'Enter' && filtered[selectedIdx]) { execute(filtered[selectedIdx]); }
}
</script>
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
onclick={onclose}
></div>
<!-- Palette -->
<div class="fixed top-[20%] left-1/2 -translate-x-1/2 z-50 w-full max-w-md px-4 pointer-events-none">
<div class="bg-zinc-900 border border-zinc-700 rounded-2xl shadow-2xl overflow-hidden pointer-events-auto">
<!-- Input -->
<div class="flex items-center gap-2.5 px-4 py-3 border-b border-zinc-800">
<svg class="w-4 h-4 text-zinc-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
bind:this={inputEl}
bind:value={query}
onkeydown={handleKeydown}
placeholder="Type a command…"
class="flex-1 bg-transparent text-zinc-100 placeholder-zinc-500 focus:outline-none text-sm"
/>
<kbd class="text-xs text-zinc-600 border border-zinc-700 rounded px-1.5 py-0.5">esc</kbd>
</div>
<!-- Commands -->
<ul class="py-1 max-h-72 overflow-y-auto">
{#each filtered as cmd, i (cmd.id)}
<li>
<button
onclick={() => execute(cmd)}
onmouseenter={() => selectedIdx = i}
class="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors {i === selectedIdx ? 'bg-zinc-800' : 'hover:bg-zinc-800/50'}"
>
<div class="flex-1 min-w-0">
<p class="text-sm text-zinc-100">{cmd.label}</p>
{#if cmd.description}
<p class="text-xs text-zinc-500 mt-0.5 truncate">{cmd.description}</p>
{/if}
</div>
</button>
</li>
{/each}
{#if filtered.length === 0}
<li class="px-4 py-4 text-sm text-zinc-500 text-center">No commands found</li>
{/if}
</ul>
<!-- Footer hint -->
<div class="px-4 py-2 border-t border-zinc-800 flex gap-4 text-xs text-zinc-600">
<span><kbd class="border border-zinc-700 rounded px-1">↑↓</kbd> navigate</span>
<span><kbd class="border border-zinc-700 rounded px-1"></kbd> select</span>
</div>
</div>
</div>
{/if}

View file

@ -5,16 +5,49 @@
import { getCachedDiary, cacheDiary } from '$lib/offline/db';
import { addDays, formatDisplay, today } from '$lib/utils/date';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import MacroSummary from '$lib/components/diary/MacroSummary.svelte';
import MealCard from '$lib/components/diary/MealCard.svelte';
import DateNav from '$lib/components/diary/DateNav.svelte';
import CalendarPicker from '$lib/components/diary/CalendarPicker.svelte';
import Sheet from '$lib/components/ui/Sheet.svelte';
import CommandPalette from '$lib/components/ui/CommandPalette.svelte';
import type { Command } from '$lib/components/ui/CommandPalette.svelte';
const date = $derived(page.params.date);
const queryClient = useQueryClient();
let calendarOpen = $state(false);
let commandOpen = $state(false);
const commands = $derived<Command[]>([
...(diaryQuery.data?.meals ?? []).map(meal => ({
id: `entry-${meal.id}`,
label: `Add entry → ${meal.name}`,
keywords: ['e', 'entry', 'food', 'add'],
action: () => goto(`/diary/${date}/add-entry?meal_id=${meal.id}`)
})),
{
id: 'add-meal',
label: 'Add meal',
keywords: ['m', 'meal'],
action: () => goto(`/diary/${date}/add-meal?diary_id=${diaryQuery.data?.id}`)
}
]);
onMount(() => {
function handleKeydown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement).tagName;
const editable = (e.target as HTMLElement).isContentEditable;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
if (e.key === '/') {
e.preventDefault();
commandOpen = true;
}
}
document.addEventListener('keydown', handleKeydown);
return () => document.removeEventListener('keydown', handleKeydown);
});
const diaryQuery = createQuery(() => ({
queryKey: ['diary', date],
@ -127,4 +160,6 @@
<Sheet open={calendarOpen} onclose={() => calendarOpen = false}>
<CalendarPicker selected={date} onSelect={handleDateSelect} />
</Sheet>
<CommandPalette {commands} open={commandOpen} onclose={() => commandOpen = false} />
</div>