added Kano-Bar

This commit is contained in:
beo3000 2026-02-24 21:04:59 +01:00
parent a7f60960f6
commit fae8385e99
6 changed files with 592 additions and 11 deletions

141
docs/feature-commandbar.md Normal file
View File

@ -0,0 +1,141 @@
# Feature: Command Bar (Kano-Bar)
## Motivation
Die Sidebar in Ka-Note ist der primäre Navigationspfad. Das funktioniert gut, wird aber zum Engpass sobald man schnell zwischen Kontexten wechselt, eine Notiz erfassen will ohne den aktuellen Blick zu verlassen, oder auf dem Desktop tastaturgesteuert arbeitet.
Ein **Cmd+K Command Bar** (nach Slack-Vorbild) löst das:
- Navigation ohne Maus in < 3 Tastendrücken
- Schnelle Erfassung (Gedanke → Notiz in 2 Sekunden)
- Sidebar kann geschlossen bleiben → mehr Platz für Inhalte
---
## Konzept: Die "Kano-Bar"
Ein dezentes, zentriertes Modal das sich über den aktuellen Content legt.
**Trigger:** `Ctrl+K` (Windows/Linux) / `Cmd+K` (Mac)
**Drei Modi** — abhängig vom Input:
| Input | Modus | Beschreibung |
|-------|-------|--------------|
| *(leer)* | Recent | Zuletzt besuchte Kontexte |
| Text | Navigate | Kontexte + Wiki-Seiten durchsuchen |
| `/befehl` | Action | Slash-Commands ausführen |
---
## Navigation
Beim Tippen werden **Contexts** (Jour Fixes, Projekte, Firmen, Personen) und **Wiki-Seiten** gefiltert.
```
┌────────────────────────────────────────┐
│ 🔍 tis... │
├────────────────────────────────────────┤
│ 📋 TISAX PROJEKT │
│ 📋 IT-Sicherheit / TISAX WIKI ↵ │
│ 👤 Tischler, Martin PERSON │
└────────────────────────────────────────┘
```
- Links: Icon nach Typ (📅 Meeting, 📋 Projekt, 👤 Person, 🏢 Firma, 📄 Wiki)
- Rechts: Typ-Badge + Scope-Punkt (blau = Business, grau = Privat)
- Markierter Eintrag: `↵` Hint rechts
- `Enter` → Navigation zu `/context/{id}` oder `/wiki/{id}`
---
## Slash-Commands
| Command | Funktion |
|---------|----------|
| `/note [Text]` | Neues Topic im Daily Log erstellen |
| `/todo [Text]` | Topic mit Wiedervorlage (heute) im Daily Log |
| `/jf [Name]` | Zu passendem Jour-Fix-Kontext springen |
Beispiel: `/note Anruf bei Steffen wegen Angebot` → sofort als Topic in `daily-log` gespeichert, ohne den aktuellen Screen zu verlassen.
---
## Visuelles Design
Dark-Mode, passend zum bestehenden Ka-Note-Design:
```
Overlay: bg-black/50 backdrop-blur-sm (z-200)
Modal: bg-zinc-800, border border-amber-500/30, rounded-xl, shadow-2xl
Breite: max-w-lg, mx-4 (responsiv)
Input: text-lg, placeholder "Suchen oder Befehl eingeben..."
Liste: max-h-80, overflow-y-auto
Auswahl: bg-zinc-700 rounded
```
Goldener Rand (`amber-500/30`) greift das Ka-Note-Logo-Motiv auf.
---
## Tastatursteuerung
| Taste | Funktion |
|-------|----------|
| `Ctrl/Cmd+K` | Bar öffnen |
| `Escape` | Bar schließen |
| `↑ / ↓` | Auswahl navigieren |
| `Enter` | Ausgewählte Aktion ausführen |
---
## Recent Items
Wenn die Bar ohne Eingabe geöffnet wird, zeigt sie die **5 zuletzt besuchten Kontexte** (sessionStorage `recentContexts`). Aktualisierung in `+layout.svelte` nach jeder Navigation via `afterNavigate()`.
---
## Technische Umsetzung
### Neue Datei
`ka-note/client/src/lib/components/CommandBar.svelte`
### Änderungen
`ka-note/client/src/routes/+layout.svelte`:
- `CommandBar` mounten
- Globaler `keydown`-Listener: `Ctrl/Cmd+K` → öffnen
- `afterNavigate` → recentContexts aktualisieren
### Datenquellen (bestehende Stores/Queries)
- `allActiveContexts()` aus `lib/stores/agenda.ts` — Contexts
- `allPages()` aus `lib/stores/wiki.ts` — Wiki-Seiten
- Kein neuer Backend-Endpunkt nötig (alles Dexie/IndexedDB)
### Filterung
In-Memory: `label.toLowerCase().includes(query)` — reicht für typische Datenmengen (< 200 Contexts).
---
## Scope
**In Scope:**
- Navigation zu Contexts und Wiki-Seiten
- Slash-Commands: `/note`, `/todo`, `/jf`
- Recent-Items (sessionStorage)
- Tastatursteuerung
- Responsive (Modal auf Mobile funktioniert)
**Out of Scope (v1):**
- Fuzzy-Suche / Treffergewichtung
- @-Mention-Suche (Personen-Aktivität)
- Server-seitige Volltextsuche
- Persistente "Favourites" in der Bar
- Konfigurierbarer Shortcut
---
## Offene Fragen
1. **Mobile:** Lupe-Icon in `BottomTabBar` öffnet die Bar. ✓
2. **`/todo`:** Wiedervorlage auf **heute** setzen. ✓
3. **Ergebnis-Limit:** 3 Contexts + 3 Wiki-Seiten + alle Slash-Commands. ✓
4. **Archivierte Contexts:** Ausblenden. ✓

View File

@ -1 +1 @@
1.1.57
1.1.58

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { commandBarOpen } from "$lib/stores/commandBar";
interface Props {
onsidebaropen: () => void;
@ -46,6 +47,13 @@
action: () => goto("/wiki"),
active: currentPath.startsWith("/wiki"),
},
{
id: "search",
label: "Suche",
icon: "🔍",
action: () => ($commandBarOpen = true),
active: false,
},
{
id: "more",
label: "Mehr",

View File

@ -0,0 +1,375 @@
<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 {
getOrCreateJournalTopic,
createHistoryEntry,
} from "$lib/db/repositories";
import { today } from "$lib/db/helpers";
const contextsQuery = allActiveContexts();
const pagesQuery = allPages();
let query = $state("");
let inputEl: HTMLInputElement;
let selectedIndex = $state(0);
let recentContextIds = $state<string[]>([]);
let isMac = $state(false);
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";
}
}
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 = [];
if ("/note".startsWith(cmd)) {
actions.push({
id: "cmd-note",
type: "action",
icon: "📝",
label: text
? `Notiz im Daily Log: "${text}"`
: "Neu: Notiz (Daily Log)",
badge: "BEFEHL",
action: async () => {
if (!text) return;
await executeNoteCommand(text);
},
});
}
if ("/todo".startsWith(cmd)) {
actions.push({
id: "cmd-todo",
type: "action",
icon: "⏰",
label: text
? `Todo (heute) im Daily Log: "${text}"`
: "Neu: Todo (Daily Log)",
badge: "BEFEHL",
action: async () => {
if (!text) return;
await executeTodoCommand(text);
},
});
}
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}`);
},
});
});
}
}
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;
});
// 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) {
const topic = await getOrCreateJournalTopic();
await createHistoryEntry(topic.id, today(), text, null, false, false);
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) {
const topic = await getOrCreateJournalTopic();
await createHistoryEntry(topic.id, today(), text, null, true, false);
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}
</div>
</div>
{/if}

View File

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const commandBarOpen = writable(false);

View File

@ -3,6 +3,8 @@
import Sidebar from "$lib/components/Sidebar.svelte";
import BottomTabBar from "$lib/components/BottomTabBar.svelte";
import AiLockBanner from "$lib/components/AiLockBanner.svelte";
import CommandBar from "$lib/components/CommandBar.svelte";
import { commandBarOpen } from "$lib/stores/commandBar";
import { seedIfEmpty } from "$lib/db/seed";
import { sync } from "$lib/sync/syncService";
import { refreshLockStatus } from "$lib/stores/aiLock";
@ -74,13 +76,47 @@
sidebarOpen = false;
}
afterNavigate(() => closeSidebar());
function handleGlobalKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
$commandBarOpen = !$commandBarOpen;
}
}
afterNavigate(({ to }) => {
closeSidebar();
if (to?.url.pathname.startsWith("/context/")) {
const id = to.url.pathname.split("/").pop();
if (id && id !== "daily-log") {
// Exclude daily-log from recents
try {
const stored = sessionStorage.getItem("recentContexts");
let recent = stored ? JSON.parse(stored) : [];
recent = [
id,
...recent.filter((x: string) => x !== id),
].slice(0, 5);
sessionStorage.setItem(
"recentContexts",
JSON.stringify(recent),
);
} catch (e) {
console.error("Failed to save recent context", e);
}
}
}
});
$effect(() => {
document.documentElement.style.setProperty('--scope-color', $scopeColor);
document.documentElement.style.setProperty(
"--scope-color",
$scopeColor,
);
});
</script>
<svelte:window onkeydown={handleGlobalKeydown} />
{#if !authReady}
<div class="flex h-screen items-center justify-center bg-bg">
<span class="text-muted">Loading...</span>
@ -103,27 +139,43 @@
<!-- Sidebar: fullscreen on mobile, static on desktop -->
{#if sidebarOpen}
<!-- Backdrop -->
<div class="fixed inset-0 z-[59] bg-black/50 backdrop-blur-sm md:hidden" onclick={closeSidebar}></div>
<div
class="fixed inset-0 z-[59] bg-black/50 backdrop-blur-sm md:hidden"
onclick={closeSidebar}
></div>
<!-- Mobile: full-screen overlay -->
<div class="fixed inset-0 z-[60] flex flex-col bg-sidebar md:hidden">
<div
class="fixed inset-0 z-[60] flex flex-col bg-sidebar md:hidden"
>
<!-- Header with close button -->
<div class="flex items-center justify-between border-b border-border px-5 py-4 shrink-0" style="padding-top: max(1rem, env(safe-area-inset-top));">
<span class="text-lg font-bold text-accent uppercase tracking-wider">KaNote</span>
<div
class="flex items-center justify-between border-b border-border px-5 py-4 shrink-0"
style="padding-top: max(1rem, env(safe-area-inset-top));"
>
<span
class="text-lg font-bold text-accent uppercase tracking-wider"
>KaNote</span
>
<button
class="text-2xl text-muted hover:text-white leading-none"
onclick={closeSidebar}
aria-label="Close"
>&times;</button>
aria-label="Close">&times;</button
>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto px-5 pb-8" style="-webkit-overflow-scrolling: touch;">
<div
class="flex-1 overflow-y-auto px-5 pb-8"
style="-webkit-overflow-scrolling: touch;"
>
<Sidebar onnavigate={closeSidebar} hideLogo />
</div>
</div>
{/if}
<!-- Desktop sidebar -->
<aside class="hidden md:flex md:w-[250px] md:flex-col md:border-r md:border-border md:bg-sidebar md:p-5 md:overflow-y-auto">
<aside
class="hidden md:flex md:w-[250px] md:flex-col md:border-r md:border-border md:bg-sidebar md:p-5 md:overflow-y-auto"
>
<Sidebar onnavigate={closeSidebar} />
</aside>
@ -145,3 +197,5 @@
<span class="text-muted">Loading...</span>
</div>
{/if}
<CommandBar />