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 { getCachedDiary, cacheDiary } from '$lib/offline/db';
|
||||||
import { addDays, formatDisplay, today } from '$lib/utils/date';
|
import { addDays, formatDisplay, today } from '$lib/utils/date';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import MacroSummary from '$lib/components/diary/MacroSummary.svelte';
|
import MacroSummary from '$lib/components/diary/MacroSummary.svelte';
|
||||||
import MealCard from '$lib/components/diary/MealCard.svelte';
|
import MealCard from '$lib/components/diary/MealCard.svelte';
|
||||||
import DateNav from '$lib/components/diary/DateNav.svelte';
|
import DateNav from '$lib/components/diary/DateNav.svelte';
|
||||||
import CalendarPicker from '$lib/components/diary/CalendarPicker.svelte';
|
import CalendarPicker from '$lib/components/diary/CalendarPicker.svelte';
|
||||||
import Sheet from '$lib/components/ui/Sheet.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 date = $derived(page.params.date);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
let calendarOpen = $state(false);
|
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(() => ({
|
const diaryQuery = createQuery(() => ({
|
||||||
queryKey: ['diary', date],
|
queryKey: ['diary', date],
|
||||||
|
|
@ -127,4 +160,6 @@
|
||||||
<Sheet open={calendarOpen} onclose={() => calendarOpen = false}>
|
<Sheet open={calendarOpen} onclose={() => calendarOpen = false}>
|
||||||
<CalendarPicker selected={date} onSelect={handleDateSelect} />
|
<CalendarPicker selected={date} onSelect={handleDateSelect} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<CommandPalette {commands} open={commandOpen} onclose={() => commandOpen = false} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue