Merge branch 'feat/poweruser-views'
# Conflicts: # apps/web/src/components/shell/quick-capture.tsx # apps/web/src/components/shell/status-bar.tsx # apps/web/src/routes/_app/tasks.tsx
This commit is contained in:
@@ -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,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" }
|
||||
)
|
||||
);
|
||||
@@ -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 { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +31,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";
|
||||
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
|
||||
import { showUndoToast } from "@/lib/undo/use-undo-toast";
|
||||
@@ -270,6 +271,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],
|
||||
@@ -696,6 +702,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 ? (
|
||||
|
||||
Reference in New Issue
Block a user