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:
@@ -43,6 +43,7 @@
|
|||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.98.0",
|
"@tanstack/react-router": "^1.98.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/core": "^3.29.2",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.29.2",
|
"@tiptap/extension-code-block-lowlight": "^3.29.2",
|
||||||
"@tiptap/extension-link": "^3.29.2",
|
"@tiptap/extension-link": "^3.29.2",
|
||||||
"@tiptap/extension-mention": "^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 { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { SlashCommand } from "./editor-slash-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 800;
|
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
|
// 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
|
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
|
||||||
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
|
// 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 latestHtmlRef = useRef(initialContent || "");
|
||||||
const dirtyRef = useRef(false);
|
const dirtyRef = useRef(false);
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -28,6 +113,7 @@ export const NoteEditor = memo(function NoteEditor({
|
|||||||
StarterKit.configure({ link: false }),
|
StarterKit.configure({ link: false }),
|
||||||
Link.configure({ openOnClick: false }),
|
Link.configure({ openOnClick: false }),
|
||||||
Placeholder.configure({ placeholder }),
|
Placeholder.configure({ placeholder }),
|
||||||
|
SlashCommand,
|
||||||
],
|
],
|
||||||
content: initialContent || "",
|
content: initialContent || "",
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -62,9 +148,44 @@ export const NoteEditor = memo(function NoteEditor({
|
|||||||
editor.on("update", handleUpdate);
|
editor.on("update", handleUpdate);
|
||||||
editor.on("blur", flushSave);
|
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 () => {
|
return () => {
|
||||||
editor.off("update", handleUpdate);
|
editor.off("update", handleUpdate);
|
||||||
editor.off("blur", flushSave);
|
editor.off("blur", flushSave);
|
||||||
|
editor.off("update", handleWikilinkInput);
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
saveTimerRef.current = null;
|
saveTimerRef.current = null;
|
||||||
@@ -75,7 +196,30 @@ export const NoteEditor = memo(function NoteEditor({
|
|||||||
onSave(latestHtmlRef.current);
|
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;
|
if (!editor) return null;
|
||||||
|
|
||||||
@@ -93,6 +237,13 @@ export const NoteEditor = memo(function NoteEditor({
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
{wikilinkOpen && (
|
||||||
|
<WikilinkPopover
|
||||||
|
query={wikilinkQuery}
|
||||||
|
onSelect={handleWikilinkSelect}
|
||||||
|
onClose={handleWikilinkClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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" }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -5,14 +5,13 @@ import { Sidebar } from "@/components/shell/sidebar";
|
|||||||
import { Topbar } from "@/components/shell/topbar";
|
import { Topbar } from "@/components/shell/topbar";
|
||||||
import { CommandPalette } from "@/components/shell/command-palette";
|
import { CommandPalette } from "@/components/shell/command-palette";
|
||||||
import { ShortcutsHelp } from "@/components/shell/shortcuts-help";
|
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";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
|
||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
useKeyboardShortcuts();
|
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(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("density-compact", "density-spacious", "reduce-motion");
|
root.classList.remove("density-compact", "density-spacious", "reduce-motion");
|
||||||
@@ -38,9 +37,11 @@ function AppLayout() {
|
|||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<ShortcutsHelp />
|
<ShortcutsHelp />
|
||||||
|
<QuickCapture />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseTaskInput } from "@/lib/nlp";
|
import { parseTaskInput } from "@/lib/nlp";
|
||||||
|
import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
|
||||||
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
|
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
|
||||||
|
|
||||||
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
|
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
|
||||||
@@ -261,6 +262,11 @@ function TasksPage() {
|
|||||||
const projects = projectsData?.items || [];
|
const projects = projectsData?.items || [];
|
||||||
|
|
||||||
const [filterProjectId, setFilterProjectId] = useState("");
|
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[] }>(
|
const { data: statesData } = useApiQuery<{ items: State[] }>(
|
||||||
["states", filterProjectId],
|
["states", filterProjectId],
|
||||||
@@ -505,6 +511,82 @@ function TasksPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.98.0",
|
"@tanstack/react-router": "^1.98.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/core": "^3.29.2",
|
||||||
"@tiptap/extension-code-block-lowlight": "^3.29.2",
|
"@tiptap/extension-code-block-lowlight": "^3.29.2",
|
||||||
"@tiptap/extension-link": "^3.29.2",
|
"@tiptap/extension-link": "^3.29.2",
|
||||||
"@tiptap/extension-mention": "^3.29.2",
|
"@tiptap/extension-mention": "^3.29.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user