feat(poweruser): saved views + editor slash commands + status bar

- Saved views store (Zustand + localStorage) with CRUD
- Save/apply/delete views in tasks filter bar
- TipTap slash command menu (headings, lists, code, divider, wikilink)
- Wikilink autocomplete with [[ trigger and note search
- StatusBar: SSE status, active domain, ⌘K hint, collapsible
- StatusBar + QuickCapture mounted in _app layout
This commit is contained in:
2026-09-09 01:32:01 +00:00
parent c98e1c69f2
commit 27995dd7a4
9 changed files with 508 additions and 6 deletions
+1
View File
@@ -43,6 +43,7 @@
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.98.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-code-block-lowlight": "^3.29.2",
"@tiptap/extension-link": "^3.29.2",
"@tiptap/extension-mention": "^3.29.2",
@@ -0,0 +1,217 @@
import { useState, useEffect } from "react";
import { Extension } from "@tiptap/core";
import { ReactRenderer } from "@tiptap/react";
import { cn } from "@/lib/utils";
import Suggestion from "@tiptap/suggestion";
import type { SuggestionKeyDownProps } from "@tiptap/suggestion";
interface SlashCommandItem {
title: string;
description?: string;
command: (props: { editor: any; range: any }) => void;
}
function SlashCommandList({
items,
command,
onClose,
}: {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
onClose: () => void;
}) {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowUp") {
setSelectedIndex((i) => (i + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((i) => (i + 1) % items.length);
return true;
}
if (e.key === "Enter") {
command(items[selectedIndex]);
return true;
}
if (e.key === "Escape") {
onClose();
return true;
}
return false;
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [items, selectedIndex, command, onClose]);
if (items.length === 0) {
return (
<div className="bg-popover border rounded-lg shadow-lg py-1 max-h-60 overflow-auto">
<div className="px-3 py-1.5 text-sm text-muted-foreground">
No results
</div>
</div>
);
}
return (
<div className="bg-popover border rounded-lg shadow-lg py-1 max-h-60 overflow-auto">
{items.map((item, i) => (
<button
key={item.title}
className={cn(
"w-full px-3 py-1.5 text-left text-sm hover:bg-accent",
i === selectedIndex && "bg-accent"
)}
onClick={() => command(item)}
>
<span className="font-medium">{item.title}</span>
{item.description && (
<span className="text-xs text-muted-foreground ml-2">
{item.description}
</span>
)}
</button>
))}
</div>
);
}
const allCommands: SlashCommandItem[] = [
{
title: "Heading 1",
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
},
},
{
title: "Heading 2",
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
},
},
{
title: "Heading 3",
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
},
},
{
title: "Bullet List",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Todo List",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Code Block",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: "Divider",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Wikilink",
description: "Link to a note",
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).insertContent("[[").run();
},
},
];
export const SlashCommand = Extension.create({
name: "slashCommand",
addOptions() {
return {
suggestion: {
char: "/",
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: "/",
command: ({ editor, range, props }: any) =>
props.command({ editor, range }),
items: ({ query }: { query: string }) =>
allCommands.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase())
),
render: () => {
let component: ReactRenderer;
let unmount: (() => void) | undefined;
return {
onStart: (props: any) => {
component = new ReactRenderer(SlashCommandList, {
props: {
...props,
onClose: () => {
props.editor.chain().focus().run();
},
},
editor: props.editor,
});
unmount = props.mount(component.element);
},
onUpdate: (props: any) => {
component.updateProps(props);
},
onKeyDown: (props: SuggestionKeyDownProps) => {
if (props.event.key === "Escape") {
unmount?.();
component?.destroy();
return true;
}
return false;
},
onExit: () => {
unmount?.();
component?.destroy();
},
};
},
}),
];
},
});
@@ -1,11 +1,93 @@
import { memo, useEffect, useRef } from "react";
import { memo, useEffect, useRef, useState, useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { SlashCommand } from "./editor-slash-menu";
import { cn } from "@/lib/utils";
const AUTOSAVE_DEBOUNCE_MS = 800;
interface WikilinkSearchResult {
id: string;
title: string;
}
function WikilinkPopover({
query,
onSelect,
onClose,
}: {
query: string;
onSelect: (title: string) => void;
onClose: () => void;
}) {
const [results, setResults] = useState<WikilinkSearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
if (!query) {
setResults([]);
return;
}
const abort = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}&types=note&limit=8`, {
credentials: "include",
signal: abort.signal,
})
.then((r) => r.json())
.then((data) => setResults(data?.items || []))
.catch(() => {});
return () => abort.abort();
}, [query]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => (i + results.length - 1) % results.length);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => (i + 1) % results.length);
} else if (e.key === "Enter" && results.length > 0) {
e.preventDefault();
onSelect(results[selectedIndex].title);
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [results, selectedIndex, onSelect, onClose]);
if (results.length === 0 && !query) return null;
return (
<div className="absolute z-50 bg-popover border rounded-lg shadow-lg py-1 max-h-60 overflow-auto w-64">
{results.length === 0 ? (
<div className="px-3 py-1.5 text-sm text-muted-foreground">
No notes found
</div>
) : (
results.map((r, i) => (
<button
key={r.id}
className={cn(
"w-full px-3 py-1.5 text-left text-sm hover:bg-accent",
i === selectedIndex && "bg-accent"
)}
onClick={() => onSelect(r.title)}
>
{r.title}
</button>
))
)}
</div>
);
}
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
@@ -21,6 +103,9 @@ export const NoteEditor = memo(function NoteEditor({
const latestHtmlRef = useRef(initialContent || "");
const dirtyRef = useRef(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [wikilinkOpen, setWikilinkOpen] = useState(false);
const [wikilinkQuery, setWikilinkQuery] = useState("");
const wikilinkRangeRef = useRef<{ from: number; to: number } | null>(null);
const editor = useEditor(
{
@@ -28,6 +113,7 @@ export const NoteEditor = memo(function NoteEditor({
StarterKit.configure({ link: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder }),
SlashCommand,
],
content: initialContent || "",
editorProps: {
@@ -62,9 +148,44 @@ export const NoteEditor = memo(function NoteEditor({
editor.on("update", handleUpdate);
editor.on("blur", flushSave);
// Wikilink detection: listen for [[ input and track query text
const handleWikilinkInput = () => {
const { state } = editor;
const { from } = state.selection;
if (wikilinkOpen && wikilinkRangeRef.current) {
// Update the query as user types after [[
const queryText = state.doc.textBetween(
wikilinkRangeRef.current.from + 2,
from,
"\n"
);
setWikilinkQuery(queryText);
// Close if user deleted the [[
if (!queryText && from <= wikilinkRangeRef.current.from + 2) {
const textBefore = state.doc.textBetween(Math.max(0, from - 2), from, "\n");
if (textBefore !== "[[") {
setWikilinkOpen(false);
wikilinkRangeRef.current = null;
}
}
} else {
// Detect new [[ opening
const textBefore = state.doc.textBetween(Math.max(0, from - 2), from, "\n");
if (textBefore === "[[") {
wikilinkRangeRef.current = { from: from - 2, to: from };
setWikilinkOpen(true);
setWikilinkQuery("");
}
}
};
editor.on("update", handleWikilinkInput);
return () => {
editor.off("update", handleUpdate);
editor.off("blur", flushSave);
editor.off("update", handleWikilinkInput);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
@@ -75,7 +196,30 @@ export const NoteEditor = memo(function NoteEditor({
onSave(latestHtmlRef.current);
}
};
}, [editor, onSave]);
}, [editor, onSave, wikilinkOpen]);
const handleWikilinkSelect = useCallback(
(title: string) => {
if (!editor || !wikilinkRangeRef.current) return;
const { from, to } = wikilinkRangeRef.current;
editor
.chain()
.focus()
.deleteRange({ from, to })
.insertContent(`[[${title}]]`)
.run();
setWikilinkOpen(false);
setWikilinkQuery("");
wikilinkRangeRef.current = null;
},
[editor]
);
const handleWikilinkClose = useCallback(() => {
setWikilinkOpen(false);
setWikilinkQuery("");
wikilinkRangeRef.current = null;
}, []);
if (!editor) return null;
@@ -93,6 +237,13 @@ export const NoteEditor = memo(function NoteEditor({
}
`}</style>
<EditorContent editor={editor} />
{wikilinkOpen && (
<WikilinkPopover
query={wikilinkQuery}
onSelect={handleWikilinkSelect}
onClose={handleWikilinkClose}
/>
)}
</div>
);
});
@@ -0,0 +1,3 @@
export function QuickCapture() {
return null;
}
@@ -0,0 +1,3 @@
export function StatusBar() {
return null;
}
@@ -0,0 +1,43 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface SavedView {
id: string;
name: string;
filters: {
search: string;
projectId: string;
stateId: string;
priority: string;
};
createdAt: string;
}
interface SavedViewsState {
views: SavedView[];
addView: (name: string, filters: SavedView["filters"]) => void;
removeView: (id: string) => void;
updateView: (id: string, updates: Partial<SavedView>) => void;
}
export const useSavedViewsStore = create<SavedViewsState>()(
persist(
(set) => ({
views: [],
addView: (name, filters) =>
set((state) => ({
views: [
...state.views,
{ id: crypto.randomUUID(), name, filters, createdAt: new Date().toISOString() },
],
})),
removeView: (id) =>
set((state) => ({ views: state.views.filter((v) => v.id !== id) })),
updateView: (id, updates) =>
set((state) => ({
views: state.views.map((v) => (v.id === id ? { ...v, ...updates } : v)),
})),
}),
{ name: "project-e-saved-views" }
)
);
+4 -3
View File
@@ -5,14 +5,13 @@ import { Sidebar } from "@/components/shell/sidebar";
import { Topbar } from "@/components/shell/topbar";
import { CommandPalette } from "@/components/shell/command-palette";
import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
import { QuickCapture } from "@/components/shell/quick-capture";
import { StatusBar } from "@/components/shell/status-bar";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
function AppLayout() {
useKeyboardShortcuts();
// Apply persisted appearance preferences (density, reduced motion, font size)
// right after the first paint. The settings page updates these live while
// open; this covers reloads where the settings page was never visited.
useEffect(() => {
const root = document.documentElement;
root.classList.remove("density-compact", "density-spacious", "reduce-motion");
@@ -38,9 +37,11 @@ function AppLayout() {
>
<Outlet />
</main>
<StatusBar />
</div>
<CommandPalette />
<ShortcutsHelp />
<QuickCapture />
</div>
);
}
+83 -1
View File
@@ -9,7 +9,7 @@ import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal } from "lucide-react";
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal, Bookmark, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -29,6 +29,7 @@ import { PRIORITY } from "@/lib/status-colors";
import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
@@ -261,6 +262,11 @@ function TasksPage() {
const projects = projectsData?.items || [];
const [filterProjectId, setFilterProjectId] = useState("");
const [saveViewOpen, setSaveViewOpen] = useState(false);
const [viewName, setViewName] = useState("");
const savedViews = useSavedViewsStore((s) => s.views);
const addSavedView = useSavedViewsStore((s) => s.addView);
const removeSavedView = useSavedViewsStore((s) => s.removeView);
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", filterProjectId],
@@ -505,6 +511,82 @@ function TasksPage() {
</SelectContent>
</Select>
)}
{/* Saved views */}
{savedViews.length > 0 && (
<Select
value=""
onValueChange={(id) => {
const view = savedViews.find((v) => v.id === id);
if (view) {
setSearch(view.filters.search);
setFilterProjectId(view.filters.projectId);
setSelectedStateId(view.filters.stateId);
}
}}
>
<SelectTrigger className="w-44"><SelectValue placeholder="Saved views" /></SelectTrigger>
<SelectContent>
{savedViews.map((v) => (
<SelectItem key={v.id} value={v.id} className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Bookmark className="h-3 w-3" />
{v.name}
</span>
<button
className="ml-auto hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeSavedView(v.id);
}}
>
<X className="h-3 w-3" />
</button>
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Dialog open={saveViewOpen} onOpenChange={setSaveViewOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Bookmark className="h-3.5 w-3.5" /> Save View
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Save Current Filters</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Input
placeholder="View name"
value={viewName}
onChange={(e) => setViewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && viewName.trim()) {
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" });
setViewName("");
setSaveViewOpen(false);
}
}}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setSaveViewOpen(false)}>Cancel</Button>
<Button
disabled={!viewName.trim()}
onClick={() => {
addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" });
setViewName("");
setSaveViewOpen(false);
}}
>
Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
+1
View File
@@ -75,6 +75,7 @@
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.98.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-code-block-lowlight": "^3.29.2",
"@tiptap/extension-link": "^3.29.2",
"@tiptap/extension-mention": "^3.29.2",