diff --git a/apps/web/package.json b/apps/web/package.json index 680225a..1a423f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/entities/editor-slash-menu.tsx b/apps/web/src/components/entities/editor-slash-menu.tsx new file mode 100644 index 0000000..9a02907 --- /dev/null +++ b/apps/web/src/components/entities/editor-slash-menu.tsx @@ -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 ( +
+
+ No results +
+
+ ); + } + + return ( +
+ {items.map((item, i) => ( + + ))} +
+ ); +} + +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(); + }, + }; + }, + }), + ]; + }, +}); diff --git a/apps/web/src/components/entities/note-editor.tsx b/apps/web/src/components/entities/note-editor.tsx index 4ffddea..8c983d7 100644 --- a/apps/web/src/components/entities/note-editor.tsx +++ b/apps/web/src/components/entities/note-editor.tsx @@ -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([]); + 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 ( +
+ {results.length === 0 ? ( +
+ No notes found +
+ ) : ( + results.map((r, i) => ( + + )) + )} +
+ ); +} + // 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 | 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({ } `} + {wikilinkOpen && ( + + )} ); }); diff --git a/apps/web/src/lib/stores/use-saved-views-store.ts b/apps/web/src/lib/stores/use-saved-views-store.ts new file mode 100644 index 0000000..8152ebd --- /dev/null +++ b/apps/web/src/lib/stores/use-saved-views-store.ts @@ -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) => void; +} + +export const useSavedViewsStore = create()( + 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" } + ) +); diff --git a/apps/web/src/routes/_app/tasks.tsx b/apps/web/src/routes/_app/tasks.tsx index f9662c7..2bf098b 100644 --- a/apps/web/src/routes/_app/tasks.tsx +++ b/apps/web/src/routes/_app/tasks.tsx @@ -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() { )} + + {/* Saved views */} + {savedViews.length > 0 && ( + + )} + + + + + + + + Save Current Filters + +
+ setViewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && viewName.trim()) { + addSavedView(viewName.trim(), { search, projectId: filterProjectId, stateId: selectedStateId, priority: "" }); + setViewName(""); + setSaveViewOpen(false); + } + }} + /> +
+ + +
+
+
+
{isLoading ? ( diff --git a/bun.lock b/bun.lock index e31104b..346cab3 100644 --- a/bun.lock +++ b/bun.lock @@ -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",