{savedViews.map((view) => (
-
-
{view.name}
-
{view.query}
-
{view.resultCount} results
-
+
+
+ {view.name}
+ {view.query}
+
+
+
+
))}
+ {savedViews.length === 0 ? (
+
diff --git a/web/src/components/KeyboardShortcuts.tsx b/web/src/components/KeyboardShortcuts.tsx
new file mode 100644
index 0000000..79043ce
--- /dev/null
+++ b/web/src/components/KeyboardShortcuts.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useMemo } from "react";
+import { useKeyboardShortcuts, type KeyboardShortcut } from "@/lib/use-keyboard-shortcuts";
+
+export function KeyboardShortcuts() {
+ const router = useRouter();
+
+ const shortcuts = useMemo
(
+ () => [
+ {
+ key: "k",
+ meta: true,
+ handler: () => router.push("/search"),
+ description: "Open search",
+ },
+ {
+ key: "n",
+ meta: true,
+ handler: () => router.push("/workspaces"),
+ description: "Open workspaces (new note)",
+ },
+ {
+ key: "d",
+ meta: true,
+ shift: true,
+ handler: () => router.push("/dashboard"),
+ description: "Go to dashboard",
+ },
+ {
+ key: "r",
+ meta: true,
+ shift: true,
+ handler: () => router.push("/reviews"),
+ description: "Go to reviews",
+ },
+ {
+ key: "Escape",
+ handler: () => {
+ const active = document.activeElement as HTMLElement | null;
+ active?.blur();
+ },
+ description: "Dismiss / blur",
+ },
+ ],
+ [router],
+ );
+
+ useKeyboardShortcuts(shortcuts);
+
+ return null;
+}
diff --git a/web/src/lib/saved-views-client.ts b/web/src/lib/saved-views-client.ts
new file mode 100644
index 0000000..8d5b2d5
--- /dev/null
+++ b/web/src/lib/saved-views-client.ts
@@ -0,0 +1,79 @@
+"use client";
+
+import { createApiClient } from "@bytelyst/api-client";
+import { NOTES_API_URL, PRODUCT_ID } from "@/lib/product-config";
+
+export interface SavedView {
+ id: string;
+ name: string;
+ scope: "workspace" | "search" | "review";
+ description?: string;
+ query: string;
+ filters?: Record;
+ sortOrder: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface SavedViewListResponse {
+ items: SavedView[];
+ total: number;
+}
+
+function getAccessToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem(`${PRODUCT_ID}_access_token`);
+}
+
+function createNotesApiClient() {
+ return createApiClient({
+ baseUrl: NOTES_API_URL,
+ getToken: getAccessToken,
+ defaultHeaders: { "x-product-id": PRODUCT_ID },
+ });
+}
+
+export async function listSavedViews(scope?: SavedView["scope"]): Promise {
+ const api = createNotesApiClient();
+ const params = new URLSearchParams();
+ if (scope) params.set("scope", scope);
+ const response = await api.fetch(`/saved-views?${params.toString()}`);
+ return response.items;
+}
+
+export async function getSavedView(id: string): Promise {
+ const api = createNotesApiClient();
+ return api.fetch(`/saved-views/${encodeURIComponent(id)}`);
+}
+
+export async function createSavedView(input: {
+ id: string;
+ name: string;
+ scope: SavedView["scope"];
+ description?: string;
+ query: string;
+ filters?: Record;
+ sortOrder?: number;
+}): Promise {
+ const api = createNotesApiClient();
+ return api.fetch("/saved-views", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export async function updateSavedView(
+ id: string,
+ updates: Partial>,
+): Promise {
+ const api = createNotesApiClient();
+ return api.fetch(`/saved-views/${encodeURIComponent(id)}`, {
+ method: "PATCH",
+ body: JSON.stringify(updates),
+ });
+}
+
+export async function deleteSavedView(id: string): Promise {
+ const api = createNotesApiClient();
+ await api.fetch(`/saved-views/${encodeURIComponent(id)}`, { method: "DELETE" });
+}
diff --git a/web/src/lib/use-debounce.ts b/web/src/lib/use-debounce.ts
new file mode 100644
index 0000000..4507b8d
--- /dev/null
+++ b/web/src/lib/use-debounce.ts
@@ -0,0 +1,14 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export function useDebounce(value: T, delayMs: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delayMs);
+ return () => clearTimeout(timer);
+ }, [value, delayMs]);
+
+ return debouncedValue;
+}
diff --git a/web/src/lib/use-keyboard-shortcuts.ts b/web/src/lib/use-keyboard-shortcuts.ts
new file mode 100644
index 0000000..2d8e12d
--- /dev/null
+++ b/web/src/lib/use-keyboard-shortcuts.ts
@@ -0,0 +1,43 @@
+"use client";
+
+import { useEffect } from "react";
+
+export interface KeyboardShortcut {
+ key: string;
+ meta?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+ handler: () => void;
+ description: string;
+}
+
+export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
+ useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ const target = event.target as HTMLElement;
+ const isInputFocused =
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.tagName === "SELECT" ||
+ target.isContentEditable;
+
+ for (const shortcut of shortcuts) {
+ const metaMatch = shortcut.meta ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
+ const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey;
+ const altMatch = shortcut.alt ? event.altKey : !event.altKey;
+ const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase();
+
+ if (keyMatch && metaMatch && shiftMatch && altMatch) {
+ if (shortcut.key === "Escape" || !isInputFocused) {
+ event.preventDefault();
+ shortcut.handler();
+ return;
+ }
+ }
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [shortcuts]);
+}