command mode
This commit is contained in:
parent
27fd13e40d
commit
52a235f8ac
2 changed files with 146 additions and 0 deletions
111
src/lib/components/ui/CommandPalette.svelte
Normal file
111
src/lib/components/ui/CommandPalette.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue