Ka-Note/ka-note/client/src/lib/components/CommandBar.svelte

733 lines
18 KiB
Svelte

<script lang="ts">
import { onMount, tick, onDestroy } from "svelte";
import { goto } from "$app/navigation";
import { commandBarOpen } from "$lib/stores/commandBar";
import { allActiveContexts } from "$lib/stores/agenda";
import { allPages } from "$lib/stores/wiki";
import { currentScope, scopeSettings } from "$lib/stores/scopeContext";
import { sidebarCollapsed } from "$lib/stores/sidebarCollapsed";
import {
getOrCreateJournalTopic,
createHistoryEntry,
createPage,
upsertContext,
contextNameExists,
pageNameExists,
createEvent,
} from "$lib/db/repositories";
import { newId, today } from "$lib/db/helpers";
import { authFetch } from "$lib/auth/apiClient";
import { searchResultsLimit } from "$lib/stores/settings";
const contextsQuery = allActiveContexts();
const pagesQuery = allPages();
let query = $state("");
let inputEl = $state<HTMLInputElement>();
let selectedIndex = $state(0);
let recentContextIds = $state<string[]>([]);
let isMac = $state(false);
// Server FTS results
interface ServerResult {
id: string;
type: "nav-history" | "nav-wiki";
icon: string;
label: string;
badge: string;
action: () => void;
}
let serverResults = $state<ServerResult[]>([]);
let isOffline = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
const q = query.trim();
if (q.length < 2 || q.startsWith("/")) {
serverResults = [];
isOffline = false;
if (searchTimer) clearTimeout(searchTimer);
return;
}
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
try {
const limit = $searchResultsLimit;
const res = await authFetch(`/api/search?q=${encodeURIComponent(q)}&limit=${limit}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as {
history: { id: string; topicId: string; contextId: string; date: string; snippet: string }[];
pages: { id: string; title: string; snippet: string }[];
};
const localPageIds = new Set(
($pagesQuery ?? [])
.filter((p) => p.title.toLowerCase().includes(q.toLowerCase()))
.map((p) => p.id),
);
const combined: ServerResult[] = [
...data.history.map((h) => {
const isJournal = h.topicId === 'daily-log-journal';
const targetContextId = isJournal ? 'daily-log' : h.contextId;
return {
id: `hist-${h.id}`,
type: "nav-history" as const,
icon: isJournal ? "📓" : "📋",
label: h.snippet.replace(/<[^>]+>/g, ""),
badge: isJournal ? `JOURNAL ${h.date}` : `MEETING ${h.date}`,
action: () => {
closeBar();
goto(`/context/${targetContextId}?date=${h.date}`);
},
};
}),
...data.pages
.filter((p) => !localPageIds.has(p.id))
.map((p) => ({
id: `page-fts-${p.id}`,
type: "nav-wiki" as const,
icon: "📄",
label: p.title,
badge: "WIKI",
action: () => {
closeBar();
goto(`/wiki/${p.id}`);
},
})),
];
serverResults = combined;
isOffline = false;
} catch {
isOffline = true;
serverResults = [];
}
}, 250);
});
onMount(() => {
isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
});
// Subscribe to the store to handle focus
$effect(() => {
if ($commandBarOpen) {
query = "";
selectedIndex = 0;
loadRecentContexts();
tick().then(() => inputEl?.focus());
// Prevent body scroll when open
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
});
onDestroy(() => {
if (typeof document !== "undefined") {
document.body.style.overflow = "";
}
});
function loadRecentContexts() {
try {
const stored = sessionStorage.getItem("recentContexts");
if (stored) {
recentContextIds = JSON.parse(stored);
} else {
recentContextIds = [];
}
} catch {
recentContextIds = [];
}
}
function getContextIcon(type: string) {
switch (type) {
case "meeting":
return "📅";
case "project":
return "📋";
case "person":
return "👤";
case "company":
return "🏢";
default:
return "📁";
}
}
function getSubtype(type: string): string {
switch (type) {
case "meeting":
return "MEETING";
case "project":
return "PROJEKT";
case "person":
return "PERSON";
case "company":
return "FIRMA";
default:
return "KONTEXT";
}
}
function promptIfEmpty(current: string, label: string): string {
if (current) return current;
return window.prompt(`${label}:`) ?? "";
}
const results = $derived.by(() => {
const q = query.trim().toLowerCase();
if (q.startsWith("/")) {
// Action mode
const parts = q.split(" ");
const cmd = parts[0];
const text = parts.slice(1).join(" ").trim();
const actions = [];
// Show scope-dynamic /note only when not narrowed to /pnote or /bnote
if ("/note".startsWith(cmd) && !"/pnote".startsWith(cmd) && !"/bnote".startsWith(cmd)) {
actions.push({
id: "cmd-note",
type: "action",
icon: "📝",
label: text
? `Notiz (${$currentScope === "private" ? "Privat" : "Firma"}): "${text}"`
: `Neu: Notiz (${$currentScope === "private" ? "Privat" : "Firma"})`,
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel der Notiz");
if (!t) return;
const isPrivateScope = $currentScope === "private";
await executeNoteCommand(t, isPrivateScope);
},
});
}
if ("/pnote".startsWith(cmd) && cmd.length > 1) {
actions.push({
id: "cmd-pnote",
type: "action",
icon: "📝",
label: text
? `Notiz (Privat): "${text}"`
: "Neu: Notiz (Privat)",
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel der Notiz");
if (!t) return;
await executeNoteCommand(t, true);
},
});
}
if ("/bnote".startsWith(cmd) && cmd.length > 1) {
actions.push({
id: "cmd-bnote",
type: "action",
icon: "📝",
label: text
? `Notiz (Firma): "${text}"`
: "Neu: Notiz (Firma)",
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel der Notiz");
if (!t) return;
await executeNoteCommand(t, false);
},
});
}
// Show scope-dynamic /todo only when not narrowed to /ptodo or /btodo
if ("/todo".startsWith(cmd) && !"/ptodo".startsWith(cmd) && !"/btodo".startsWith(cmd)) {
actions.push({
id: "cmd-todo",
type: "action",
icon: "⏰",
label: text
? `Todo (${$currentScope === "private" ? "Privat" : "Firma"}): "${text}"`
: `Neu: Todo (${$currentScope === "private" ? "Privat" : "Firma"})`,
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel des Todos");
if (!t) return;
const isPrivateScope = $currentScope === "private";
await executeTodoCommand(t, isPrivateScope);
},
});
}
if ("/ptodo".startsWith(cmd) && cmd.length > 1) {
actions.push({
id: "cmd-ptodo",
type: "action",
icon: "⏰",
label: text
? `Todo (Privat): "${text}"`
: "Neu: Todo (Privat)",
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel des Todos");
if (!t) return;
await executeTodoCommand(t, true);
},
});
}
if ("/btodo".startsWith(cmd) && cmd.length > 1) {
actions.push({
id: "cmd-btodo",
type: "action",
icon: "⏰",
label: text
? `Todo (Firma): "${text}"`
: "Neu: Todo (Firma)",
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel des Todos");
if (!t) return;
await executeTodoCommand(t, false);
},
});
}
if ("/page".startsWith(cmd) && !"/ppage".startsWith(cmd) && !"/bpage".startsWith(cmd)) {
actions.push({
id: "cmd-page",
type: "action",
icon: "📄",
label: text
? `Wiki-Seite (${$currentScope === "private" ? "Privat" : "Firma"}): "${text}"`
: `Neu: Wiki-Seite (${$currentScope === "private" ? "Privat" : "Firma"})`,
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel der Wiki-Seite");
if (!t) return;
if (await pageNameExists(t)) {
alert(`Wiki-Seite "${t}" existiert bereits.`);
return;
}
const isPrivateScope = $currentScope === "private";
const page = await createPage(t, isPrivateScope);
closeBar();
goto(`/wiki/${page.id}`);
},
});
}
if ("/ppage".startsWith(cmd) && cmd.length > 1) {
actions.push({
id: "cmd-ppage",
type: "action",
icon: "📄",
label: text
? `Wiki-Seite (Privat): "${text}"`
: `Neu: Wiki-Seite (Privat)`,
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel der Wiki-Seite");
if (!t) return;
if (await pageNameExists(t)) {
alert(`Wiki-Seite "${t}" existiert bereits.`);
return;
}
const page = await createPage(t, true);
closeBar();
goto(`/wiki/${page.id}`);
},
});
}
if ("/bpage".startsWith(cmd) && cmd.length > 1) {
actions.push({
id: "cmd-bpage",
type: "action",
icon: "📄",
label: text
? `Wiki-Seite (Firma): "${text}"`
: `Neu: Wiki-Seite (Firma)`,
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Titel der Wiki-Seite");
if (!t) return;
if (await pageNameExists(t)) {
alert(`Wiki-Seite "${t}" existiert bereits.`);
return;
}
const page = await createPage(t, false);
closeBar();
goto(`/wiki/${page.id}`);
},
});
}
if ("/person".startsWith(cmd)) {
actions.push({
id: "cmd-person",
type: "action",
icon: "👤",
label: text ? `Person: "${text}"` : `Neu: Person`,
badge: "BEFEHL",
action: async () => {
const raw = promptIfEmpty(text, "Name der Person");
if (!raw) return;
const displayName = raw.replace(/\b\w/g, (c) => c.toUpperCase());
const fullName = `Person ${displayName}`;
if (await contextNameExists(fullName, "person")) {
alert(`Person "${displayName}" existiert bereits.`);
return;
}
const id = newId();
await upsertContext({ id, name: fullName, type: "person", meta: { fullName: displayName, email: "", phone: "", duSince: "", personSubType: "colleague" } });
closeBar();
goto(`/context/${id}`);
},
});
}
if ("/firma".startsWith(cmd)) {
actions.push({
id: "cmd-firma",
type: "action",
icon: "🏢",
label: text ? `Firma: "${text}"` : `Neu: Firma`,
badge: "BEFEHL",
action: async () => {
const t = promptIfEmpty(text, "Name der Firma");
if (!t) return;
const fullName = `Firma ${t}`;
if (await contextNameExists(fullName, "company")) {
alert(`Firma "${t}" existiert bereits.`);
return;
}
const id = newId();
await upsertContext({ id, name: fullName, type: "company" });
closeBar();
goto(`/context/${id}`);
},
});
}
if ("/sidebar".startsWith(cmd)) {
actions.push({
id: "cmd-sidebar",
type: "action",
icon: $sidebarCollapsed ? "▶" : "◀",
label: $sidebarCollapsed ? "Sidebar einblenden" : "Sidebar ausblenden",
badge: "BEFEHL",
action: () => {
sidebarCollapsed.toggle();
closeBar();
},
});
}
if ("/home".startsWith(cmd)) {
actions.push({
id: "cmd-home",
type: "action",
icon: "🏠",
label: "Journal (Startseite)",
badge: "BEFEHL",
action: () => {
closeBar();
goto("/");
},
});
}
if ("/help".startsWith(cmd)) {
actions.push({
id: "cmd-help",
type: "action",
icon: "❓",
label: "Hilfe & Befehle anzeigen",
badge: "BEFEHL",
action: () => {
closeBar();
goto("/help");
},
});
}
if ("/jf".startsWith(cmd)) {
actions.push({
id: "cmd-jf",
type: "action",
icon: "📅",
label: "Zu Jour-Fix springen...",
badge: "BEFEHL",
action: () => {}, // Managed by the next items
});
// Add matching JFs
if (text && $contextsQuery) {
const jfs = $contextsQuery.filter(
(c) =>
c.type === "meeting" &&
c.name.toLowerCase().includes(text),
);
jfs.forEach((jf) => {
actions.push({
id: `jf-${jf.id}`,
type: "nav-context",
icon: "📅",
label: jf.name,
badge: "MEETING",
action: () => {
closeBar();
goto(`/context/${jf.id}`);
},
});
});
}
}
if ("/meeting".startsWith(cmd)) {
actions.push({
id: "cmd-meeting",
type: "action",
icon: "🤝",
label: text ? `Meeting: "${text}"` : `Neu: Meeting (Titel eingeben)`,
badge: "BEFEHL",
action: async () => {
const title = promptIfEmpty(text, "Meeting-Titel");
if (!title) return;
await createEvent(today(), "00:00", title, []);
closeBar();
goto("/");
},
});
}
return actions;
}
if (!q) {
// Recent mode
const recents = [];
if ($contextsQuery) {
for (const id of recentContextIds) {
const ctx = $contextsQuery.find((c) => c.id === id);
if (ctx) {
recents.push({
id: `ctx-${ctx.id}`,
type: "nav-context",
icon: getContextIcon(ctx.type),
label: ctx.name,
badge: getSubtype(ctx.type),
action: () => {
closeBar();
goto(`/context/${ctx.id}`);
},
});
}
}
}
return recents.slice(0, 5);
}
// Navigate mode: Search contexts and wiki pages
const searchResults = [];
let ctxCount = 0;
if ($contextsQuery) {
for (const ctx of $contextsQuery) {
if (ctx.name.toLowerCase().includes(q) && ctxCount < 3) {
searchResults.push({
id: `ctx-${ctx.id}`,
type: "nav-context",
icon: getContextIcon(ctx.type),
label: ctx.name,
badge: getSubtype(ctx.type),
action: () => {
closeBar();
goto(`/context/${ctx.id}`);
},
});
ctxCount++;
}
}
}
let pageCount = 0;
if ($pagesQuery) {
for (const p of $pagesQuery) {
if (p.title.toLowerCase().includes(q) && pageCount < 3) {
searchResults.push({
id: `page-${p.id}`,
type: "nav-wiki",
icon: "📄",
label: p.title,
badge: "WIKI",
action: () => {
closeBar();
goto(`/wiki/${p.id}`);
},
});
pageCount++;
}
}
}
return [...searchResults, ...serverResults].slice(0, 10);
});
// Reset selection when query changes
$effect(() => {
query; // reference to trigger
selectedIndex = 0;
});
function closeBar() {
$commandBarOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
closeBar();
return;
}
if (!results || results.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % results.length;
ensureVisible();
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex =
(selectedIndex - 1 + results.length) % results.length;
ensureVisible();
} else if (e.key === "Enter") {
e.preventDefault();
const selected = results[selectedIndex];
if (selected && selected.action) {
selected.action();
}
}
}
function ensureVisible() {
tick().then(() => {
const el = document.getElementById(`kano-item-${selectedIndex}`);
if (el) el.scrollIntoView({ block: "nearest" });
});
}
async function executeNoteCommand(text: string, isPrivate: boolean) {
const topic = await getOrCreateJournalTopic();
await createHistoryEntry(
topic.id,
today(),
text,
null,
false,
isPrivate,
);
closeBar();
if (window.location.pathname === "/context/daily-log") {
// Trigger a reload or just let dexie liveQuery handle it
} else {
goto("/context/daily-log");
}
}
async function executeTodoCommand(text: string, isPrivate: boolean) {
const topic = await getOrCreateJournalTopic();
await createHistoryEntry(
topic.id,
today(),
text,
null,
true,
isPrivate,
);
closeBar();
if (window.location.pathname !== "/context/daily-log") {
goto("/context/daily-log");
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
closeBar();
}
}
</script>
{#if $commandBarOpen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-[200] flex items-start justify-center bg-black/50 backdrop-blur-sm p-4 pt-[20vh]"
onclick={handleBackdropClick}
role="dialog"
aria-label="Command Bar"
>
<!-- Modal -->
<div
class="w-full max-w-lg rounded-xl border border-amber-500/30 bg-zinc-800 shadow-2xl overflow-hidden flex flex-col"
>
<div class="flex items-center border-b border-[#444] px-4 py-3">
<span class="mr-3 text-lg text-muted">🔍</span>
<input
bind:this={inputEl}
bind:value={query}
onkeydown={handleKeydown}
type="text"
class="w-full bg-transparent text-lg text-white placeholder-muted outline-none"
placeholder="Suchen oder Befehl eingeben..."
/>
<span
class="ml-2 text-xs text-muted font-mono rounded bg-zinc-700 px-1.5 py-0.5"
>ESC</span
>
</div>
{#if results && results.length > 0}
<div class="max-h-80 overflow-y-auto py-2">
{#each results as item, i (item.id)}
<button
id={`kano-item-${i}`}
class="w-full flex items-center justify-between px-4 py-3 text-left transition-colors {i ===
selectedIndex
? 'bg-zinc-700'
: 'hover:bg-zinc-700/50'}"
onmouseover={() => (selectedIndex = i)}
onclick={() => item.action()}
>
<div class="flex items-center truncate">
<span class="mr-3 text-lg">{item.icon}</span>
<span class="truncate text-white"
>{item.label}</span
>
</div>
<div class="ml-4 flex items-center shrink-0">
<span
class="text-[10px] font-bold tracking-wider text-muted opacity-80"
>{item.badge}</span
>
{#if i === selectedIndex}
<span class="ml-3 text-sm text-accent"
></span
>
{/if}
</div>
</button>
{/each}
</div>
{:else if query}
<div class="px-4 py-8 text-center text-muted">
Keine Ergebnisse für "{query}"
</div>
{/if}
{#if isOffline}
<div class="px-4 py-1.5 text-[11px] text-zinc-500 border-t border-zinc-700">
Server nicht erreichbar — nur lokale Ergebnisse
</div>
{/if}
</div>
</div>
{/if}