Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import type {
export type {
Result,
NoteStatus,
BoardStage,
NoteSnapshot,
NotebookSnapshot,
NotebookWithMetadata,
Expand Down
13 changes: 11 additions & 2 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ 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';
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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -581,7 +583,14 @@ function NotesApp() {
/>

<main className="app__editor">
{isGraphOpen ? (
{navigation.kind === 'planning' ? (
<PlanningBoard
onOpenNote={noteId => {
goToNotebook(PLANNING_NOTEBOOK_ID);
void handleSelectNote(noteId);
}}
/>
) : isGraphOpen ? (
<GraphView
selectedNoteId={selectedNote?.id}
onNodeClick={noteId => {
Expand Down
25 changes: 21 additions & 4 deletions apps/desktop/src/renderer/components/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<any>(null);
Expand All @@ -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(
Expand Down
186 changes: 186 additions & 0 deletions apps/desktop/src/renderer/components/planning/PlanningBoard.css
Original file line number Diff line number Diff line change
@@ -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;
}
105 changes: 105 additions & 0 deletions apps/desktop/src/renderer/components/planning/PlanningBoard.tsx
Original file line number Diff line number Diff line change
@@ -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<ViewMode>('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<BoardStage, NoteSnapshot[]> = {
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 (
<div className="planning-board">
<header className="planning-board__header">
<h2 className="planning-board__title">Planning</h2>
<div className="planning-board__toggle" role="tablist" aria-label="Planning view mode">
<button
type="button"
role="tab"
aria-selected={mode === 'board'}
className={`planning-board__toggle-btn ${mode === 'board' ? 'is-active' : ''}`}
onClick={() => setMode('board')}
>
<KanbanSquare size={14} /> Board
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'graph'}
className={`planning-board__toggle-btn ${mode === 'graph' ? 'is-active' : ''}`}
onClick={() => setMode('graph')}
>
<Network size={14} /> Graph
</button>
</div>
</header>

{mode === 'board' ? (
<div className="planning-board__columns">
{BOARD_STAGES.map(stage => (
<PlanningColumn
key={stage}
stage={stage}
notes={notesByStage[stage]}
onDropNote={handleDropNote}
onOpenNote={onOpenNote}
/>
))}
</div>
) : (
<div className="planning-board__graph">
<GraphView filterNotebookId={PLANNING_NOTEBOOK_ID} onNodeClick={onOpenNote} />
</div>
)}
</div>
);
}
Loading