diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 47ac84c4..97c923c7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -65,6 +65,7 @@ import type { export type { Result, NoteStatus, + BoardStage, NoteSnapshot, NotebookSnapshot, NotebookWithMetadata, diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index bac5a25b..08c4fcaa 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -14,6 +14,7 @@ import { import type { EditorAPIWithEvents, AppAPIWithEvents, DataAPIWithEvents } from '@dripnex/plugin-api'; import type { RegisteredCommand } from '@dripnex/command-registry'; import { useStore } from 'zustand'; +import { PLANNING_NOTEBOOK_ID } from '@dripnex/core'; import type { NoteSnapshot } from '../preload/index'; import { UpdateBanner } from './components/UpdateBanner'; import { NoteList } from './components/NoteList'; @@ -21,6 +22,7 @@ import { NoteEditor } from './components/NoteEditor'; import { NoteWindow } from './components/NoteWindow'; import { Sidebar } from './components/sidebar'; import { GraphView } from './components/GraphView'; +import { PlanningBoard } from './components/planning/PlanningBoard'; import { CommandPalette } from './components/CommandPalette'; import { AiPanel } from './components/ai/AiPanel'; import { LicenseProvider } from './contexts/LicenseContext'; @@ -120,7 +122,7 @@ function NotesApp() { const selectedTag = useSelectedTag(); const sortBy = useSortBy(); const sortOrder = useSortOrder(); - const { goToTag, setSort } = useNavigationActions(); + const { goToTag, goToNotebook, setSort } = useNavigationActions(); // Load tag colors on mount (once) useEffect(() => { @@ -581,7 +583,14 @@ function NotesApp() { />
- {isGraphOpen ? ( + {navigation.kind === 'planning' ? ( + { + goToNotebook(PLANNING_NOTEBOOK_ID); + void handleSelectNote(noteId); + }} + /> + ) : isGraphOpen ? ( { diff --git a/apps/desktop/src/renderer/components/GraphView.tsx b/apps/desktop/src/renderer/components/GraphView.tsx index d38a1a5b..70791f71 100644 --- a/apps/desktop/src/renderer/components/GraphView.tsx +++ b/apps/desktop/src/renderer/components/GraphView.tsx @@ -18,6 +18,8 @@ interface GraphViewProps { onNodeClick?: (noteId: string) => void; /** Callback to close the graph view */ onClose?: () => void; + /** When set, restrict the graph to notes in this notebook (and links between them) */ + filterNotebookId?: string; } interface GraphNode { @@ -31,7 +33,12 @@ interface GraphNode { vy?: number; } -export function GraphView({ selectedNoteId, onNodeClick, onClose }: GraphViewProps) { +export function GraphView({ + selectedNoteId, + onNodeClick, + onClose, + filterNotebookId, +}: GraphViewProps) { const { data, isLoading, error } = useGraphData(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null); @@ -40,18 +47,28 @@ export function GraphView({ selectedNoteId, onNodeClick, onClose }: GraphViewPro const graphData = useMemo(() => { if (!data) return { nodes: [], links: [] }; + // Optionally restrict to a single notebook (e.g. the Planning board), + // keeping only links whose endpoints both remain in the filtered set. + const nodes = filterNotebookId + ? data.nodes.filter(n => n.notebookId === filterNotebookId) + : data.nodes; + const includedIds = new Set(nodes.map(n => n.id)); + const edges = filterNotebookId + ? data.edges.filter(e => includedIds.has(e.source) && includedIds.has(e.target)) + : data.edges; + return { - nodes: data.nodes.map(n => ({ + nodes: nodes.map(n => ({ id: n.id, title: n.title, notebookId: n.notebookId, })), - links: data.edges.map(e => ({ + links: edges.map(e => ({ source: e.source, target: e.target, })), }; - }, [data]); + }, [data, filterNotebookId]); // Node color based on selection (using hex - CSS vars don't work in canvas) const getNodeColor = useCallback( diff --git a/apps/desktop/src/renderer/components/planning/PlanningBoard.css b/apps/desktop/src/renderer/components/planning/PlanningBoard.css new file mode 100644 index 00000000..0603a0ab --- /dev/null +++ b/apps/desktop/src/renderer/components/planning/PlanningBoard.css @@ -0,0 +1,186 @@ +.planning-board { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--bg-base); +} + +.planning-board__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} + +.planning-board__title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.planning-board__toggle { + display: inline-flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-surface); +} + +.planning-board__toggle-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; +} + +.planning-board__toggle-btn.is-active { + background: var(--bg-elevated); + color: var(--text-primary); +} + +.planning-board__columns { + display: flex; + gap: 12px; + flex: 1 1 auto; + min-height: 0; + padding: 16px; + overflow-x: auto; + overflow-y: hidden; +} + +.planning-board__graph { + flex: 1 1 auto; + min-height: 0; + position: relative; +} + +/* Columns */ +.planning-column { + display: flex; + flex-direction: column; + flex: 0 0 280px; + min-height: 0; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-surface); + transition: border-color 0.12s ease, background 0.12s ease; +} + +.planning-column--over { + border-color: var(--accent); + background: var(--accent-subtle); +} + +.planning-column__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.planning-column__title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); +} + +.planning-column__count { + font-size: 11px; + color: var(--text-muted); + background: var(--bg-elevated); + border-radius: 10px; + padding: 1px 8px; +} + +.planning-column__cards { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + overflow-y: auto; + flex: 1 1 auto; + min-height: 0; +} + +/* Cards */ +.planning-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-elevated); + cursor: pointer; + user-select: none; +} + +.planning-card:hover { + border-color: var(--accent-muted); +} + +.planning-card__title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.planning-card__excerpt { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.planning-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 2px; +} + +.planning-card__tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-width: 0; +} + +.planning-card__tag { + font-size: 11px; + color: var(--accent); + background: var(--accent-subtle); + border-radius: 4px; + padding: 0 5px; +} + +.planning-card__tasks { + flex: 0 0 auto; + font-size: 11px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} diff --git a/apps/desktop/src/renderer/components/planning/PlanningBoard.tsx b/apps/desktop/src/renderer/components/planning/PlanningBoard.tsx new file mode 100644 index 00000000..af53f9b1 --- /dev/null +++ b/apps/desktop/src/renderer/components/planning/PlanningBoard.tsx @@ -0,0 +1,105 @@ +import { useMemo, useState } from 'react'; +import { KanbanSquare, Network } from 'lucide-react'; +import { BOARD_STAGES, PLANNING_NOTEBOOK_ID } from '@dripnex/core'; +import type { NoteSnapshot, BoardStage } from '../../../preload/index'; +import { useNotes, useNoteMutations } from '../../hooks/useNotes'; +import { GraphView } from '../GraphView'; +import { PlanningColumn } from './PlanningColumn'; +import './PlanningBoard.css'; + +interface PlanningBoardProps { + /** Open a note in the editor (leaves the board). */ + readonly onOpenNote: (id: string) => void; +} + +type ViewMode = 'board' | 'graph'; + +/** + * The Planning view: a Linear-style Kanban board over the notes in the + * reserved Planning notebook, plus a graph mode that reuses GraphView filtered + * to those same notes. Dragging a card persists its stage via boardStage + * (DB metadata only — never rewrites the note's markdown). + */ +export function PlanningBoard({ onOpenNote }: PlanningBoardProps) { + const [mode, setMode] = useState('board'); + const { data: allNotes } = useNotes({ + sortBy: 'updatedAt', + sortOrder: 'desc', + archived: 'active', + }); + const { setBoardStage } = useNoteMutations(); + + const planningNotes = useMemo( + () => + (allNotes ?? []).filter( + n => n.notebookId === PLANNING_NOTEBOOK_ID && !n.isDeleted && !n.isArchived + ), + [allNotes] + ); + + const notesByStage = useMemo(() => { + const groups: Record = { + backlog: [], + todo: [], + in_progress: [], + in_review: [], + in_staging: [], + }; + for (const note of planningNotes) { + // Notes with no explicit stage (e.g. moved into the notebook) land in Backlog. + const stage = note.boardStage ?? 'backlog'; + groups[stage].push(note); + } + return groups; + }, [planningNotes]); + + const handleDropNote = (noteId: string, stage: BoardStage) => { + setBoardStage.mutate({ id: noteId, boardStage: stage }); + }; + + return ( +
+
+

Planning

+
+ + +
+
+ + {mode === 'board' ? ( +
+ {BOARD_STAGES.map(stage => ( + + ))} +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/planning/PlanningCard.tsx b/apps/desktop/src/renderer/components/planning/PlanningCard.tsx new file mode 100644 index 00000000..314b5788 --- /dev/null +++ b/apps/desktop/src/renderer/components/planning/PlanningCard.tsx @@ -0,0 +1,63 @@ +import { memo, useCallback } from 'react'; +import { countMarkdownTasks } from '@dripnex/tasks'; +import type { NoteSnapshot } from '../../../preload/index'; +import { extractExcerpt } from '../../hooks/useNotes'; +import { NOTE_MIME } from './constants'; + +interface PlanningCardProps { + readonly note: NoteSnapshot; + readonly onOpen: (id: string) => void; +} + +/** + * A draggable card on the Planning board. Dragging carries the note id via a + * custom MIME type; dropping on a column updates the note's boardStage. + */ +export const PlanningCard = memo(function PlanningCard({ note, onOpen }: PlanningCardProps) { + const handleDragStart = useCallback( + (e: React.DragEvent) => { + e.dataTransfer.setData(NOTE_MIME, note.id); + e.dataTransfer.setData('text/plain', note.id); + e.dataTransfer.effectAllowed = 'move'; + }, + [note.id] + ); + + const tasks = countMarkdownTasks(note.content); + const excerpt = extractExcerpt(note.content, 120); + + return ( +
onOpen(note.id)} + onKeyDown={e => { + if (e.key === 'Enter') onOpen(note.id); + }} + role="button" + tabIndex={0} + > +

{note.title || 'Untitled'}

+ {excerpt &&

{excerpt}

} + {(note.tags.length > 0 || tasks.total > 0) && ( +
+ {note.tags.length > 0 && ( +
+ {note.tags.slice(0, 3).map(tag => ( + + #{tag} + + ))} +
+ )} + {tasks.total > 0 && ( + + {tasks.completed}/{tasks.total} + + )} +
+ )} +
+ ); +}); diff --git a/apps/desktop/src/renderer/components/planning/PlanningColumn.tsx b/apps/desktop/src/renderer/components/planning/PlanningColumn.tsx new file mode 100644 index 00000000..5e8d8844 --- /dev/null +++ b/apps/desktop/src/renderer/components/planning/PlanningColumn.tsx @@ -0,0 +1,65 @@ +import { memo, useCallback, useState } from 'react'; +import type { NoteSnapshot, BoardStage } from '../../../preload/index'; +import { PlanningCard } from './PlanningCard'; +import { NOTE_MIME, BOARD_STAGE_LABELS } from './constants'; + +interface PlanningColumnProps { + readonly stage: BoardStage; + readonly notes: readonly NoteSnapshot[]; + readonly onDropNote: (noteId: string, stage: BoardStage) => void; + readonly onOpenNote: (id: string) => void; +} + +/** A droppable Kanban column for a single board stage. */ +export const PlanningColumn = memo(function PlanningColumn({ + stage, + notes, + onDropNote, + onOpenNote, +}: PlanningColumnProps) { + const [isOver, setIsOver] = useState(false); + + const handleDragOver = useCallback((e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(NOTE_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsOver(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + // Only clear when the pointer actually leaves the column (not a child). + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsOver(false); + } + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsOver(false); + const noteId = e.dataTransfer.getData(NOTE_MIME); + if (noteId) onDropNote(noteId, stage); + }, + [onDropNote, stage] + ); + + return ( +
+
+ {BOARD_STAGE_LABELS[stage]} + {notes.length} +
+
+ {notes.map(note => ( + + ))} +
+
+ ); +}); diff --git a/apps/desktop/src/renderer/components/planning/constants.ts b/apps/desktop/src/renderer/components/planning/constants.ts new file mode 100644 index 00000000..6d346612 --- /dev/null +++ b/apps/desktop/src/renderer/components/planning/constants.ts @@ -0,0 +1,13 @@ +import type { BoardStage } from '../../../preload/index'; + +/** Custom MIME type used to carry a note id during Kanban drag-and-drop. */ +export const NOTE_MIME = 'application/x-dripnex-note'; + +/** Human-readable column titles for each board stage. */ +export const BOARD_STAGE_LABELS: Record = { + backlog: 'Backlog', + todo: 'Todo', + in_progress: 'In Progress', + in_review: 'In Review', + in_staging: 'In Staging', +}; diff --git a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx index ae03a98d..e963aa8f 100644 --- a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import { LayoutZone } from '@dripnex/plugin-api'; import { useIsNotebookContext, + useIsPlanningContext, useSelectedNotebookId, useGlobalFilter, useNavigationActions, @@ -51,6 +52,7 @@ export function Sidebar({ onOpenGraph }: SidebarProps) { // Granular selectors const isNotebookContext = useIsNotebookContext(); + const isPlanningContext = useIsPlanningContext(); const selectedNotebookId = useSelectedNotebookId(); const globalFilter = useGlobalFilter(); const globalCounts = useGlobalCounts(); @@ -67,6 +69,7 @@ export function Sidebar({ onOpenGraph }: SidebarProps) { goToPinned, goToTrash, goToNotebook, + goToPlanning, clearNavigation, setStatusFilter, setTagFilter, @@ -124,6 +127,8 @@ export function Sidebar({ onOpenGraph }: SidebarProps) { }} isNotebookContext={isNotebookContext} onOpenGraph={onOpenGraph} + onOpenPlanning={goToPlanning} + isPlanningSelected={isPlanningContext} /> diff --git a/apps/desktop/src/renderer/components/sidebar/SidebarQuickFilters.tsx b/apps/desktop/src/renderer/components/sidebar/SidebarQuickFilters.tsx index 15bfd384..d2baaa6b 100644 --- a/apps/desktop/src/renderer/components/sidebar/SidebarQuickFilters.tsx +++ b/apps/desktop/src/renderer/components/sidebar/SidebarQuickFilters.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { FileText, Pin, Trash2, Network } from 'lucide-react'; +import { FileText, Pin, Trash2, Network, KanbanSquare } from 'lucide-react'; export type QuickFilterType = 'all' | 'pinned' | 'trash'; @@ -11,6 +11,8 @@ interface SidebarQuickFiltersProps { readonly onSelectFilter: (filter: QuickFilterType) => void; readonly isNotebookContext?: boolean; readonly onOpenGraph?: () => void; + readonly onOpenPlanning?: () => void; + readonly isPlanningSelected?: boolean; } interface QuickFilterItemProps { @@ -54,6 +56,8 @@ export const SidebarQuickFilters = memo(function SidebarQuickFilters({ onSelectFilter, isNotebookContext = false, onOpenGraph, + onOpenPlanning, + isPlanningSelected = false, }: SidebarQuickFiltersProps) { return (