diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 867f30f..ca2da4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: if: matrix.platform == 'mac' working-directory: apps/desktop env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} # Code signing CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} @@ -78,14 +78,14 @@ jobs: if: matrix.platform == 'win' working-directory: apps/desktop env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} run: pnpm dist:win --publish always - name: Build distributables (Linux) if: matrix.platform == 'linux' working-directory: apps/desktop env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GH_TOKEN }} run: pnpm dist:linux --publish always - name: Upload artifacts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ee0c0e8..aa3929a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,10 +49,12 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^4.2.0", "remark-gfm": "^4.0.1", + "unist-util-visit": "^5.0.0", "zustand": "^5.0.9" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", + "@types/mdast": "^4.0.4", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", "@vitejs/plugin-react": "^4.2.1", @@ -139,7 +141,8 @@ "publish": { "provider": "github", "owner": "tomymaritano", - "repo": "readide" + "repo": "readide", + "private": true } } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 96d0f4a..4292bc1 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -217,9 +217,12 @@ function registerIpcHandlers(): void { const repo = noteRepository; // Create note - ipcMain.handle('notes:create', async (_event, input: { content: string; id?: string }) => { - return createNoteOperation(input, repo); - }); + ipcMain.handle( + 'notes:create', + async (_event, input: { content: string; id?: string; notebookId?: string }) => { + return createNoteOperation(input, repo); + } + ); // Get note ipcMain.handle('notes:get', async (_event, id: string) => { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6457fc6..8e453e7 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -151,6 +151,7 @@ export interface ReadiedAPI { content: string; id?: string; title?: string; + notebookId?: string; }) => Promise>; /** Get a note by ID */ get: (id: string) => Promise>; diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 151e27d..9a711d4 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -109,14 +109,16 @@ function NotesApp() { }, 300); }, []); - // Create new note + // Create new note (respects current navigation context) const handleNewNote = useCallback(async () => { - const newNote = await createNote.mutateAsync({ content: '# Untitled\n\n' }); + const newNote = await createNote.mutateAsync({ + content: '# Untitled\n\n', + notebookId: selectedNotebookId ?? undefined, + }); setSelectedNote(newNote); - goToAllNotes(); setSearchQuery(''); setDebouncedSearch(''); - }, [createNote, goToAllNotes]); + }, [createNote, selectedNotebookId]); // Select note const handleSelectNote = useCallback(async (id: string) => { @@ -126,6 +128,22 @@ function NotesApp() { } }, []); + // Handle wikilink click - best-effort navigation by title + const handleWikilinkClick = useCallback( + async (title: string) => { + const notes = await window.readied.notes.search(title); + if (notes.length > 0) { + // Find exact match (case-insensitive) + const match = notes.find(n => n.title.toLowerCase() === title.toLowerCase()); + if (match) { + handleSelectNote(match.id); + } + } + // No-op if note doesn't exist (future: could show toast or create note) + }, + [handleSelectNote] + ); + // Update note content const handleUpdateNote = useCallback( async (content: string) => { @@ -311,6 +329,7 @@ function NotesApp() { onStatusChange={handleStatusChange} onDuplicate={selectedNote ? () => handleDuplicateNote(selectedNote.id) : undefined} onDelete={selectedNote ? () => handleDeleteNote(selectedNote.id) : undefined} + onWikilinkClick={handleWikilinkClick} /> diff --git a/apps/desktop/src/renderer/components/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor.tsx index c03b93b..eb2dca6 100644 --- a/apps/desktop/src/renderer/components/MarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor.tsx @@ -38,6 +38,7 @@ import { undoChange, redoChange, } from './editor/toolbar-commands'; +import { wikilinkExtension } from './editor/wikilink-extension'; /** Dark theme matching Readied's design */ const darkTheme = EditorView.theme( @@ -300,6 +301,9 @@ export const MarkdownEditor = forwardRef void; onDuplicate?: () => void; onDelete?: () => void; + onWikilinkClick?: (target: string) => void; } export function NoteEditor({ @@ -48,6 +49,7 @@ export function NoteEditor({ onStatusChange, onDuplicate, onDelete, + onWikilinkClick, }: NoteEditorProps) { const queryClient = useQueryClient(); const debounceRef = useRef(null); @@ -273,6 +275,7 @@ export function NoteEditor({ createdAt={note.createdAt} updatedAt={note.updatedAt} onReady={handlePreviewReady} + onWikilinkClick={onWikilinkClick} /> )} diff --git a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx index 2cf45b4..de6379c 100644 --- a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx +++ b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx @@ -1,12 +1,14 @@ -import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect } from 'react'; +import { useMemo, useRef, useImperativeHandle, forwardRef, useEffect, useCallback } from 'react'; import Markdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { remarkWikilink } from './remark-wikilink'; interface MarkdownPreviewProps { readonly content: string; readonly createdAt?: string; readonly updatedAt?: string; readonly onReady?: () => void; + readonly onWikilinkClick?: (target: string) => void; } /** Imperative handle for scroll sync */ @@ -24,9 +26,24 @@ export interface MarkdownPreviewHandle { * Exposes scroll methods via ref for sync with editor. */ export const MarkdownPreview = forwardRef( - function MarkdownPreview({ content, createdAt, updatedAt, onReady }, ref) { + function MarkdownPreview({ content, createdAt, updatedAt, onReady, onWikilinkClick }, ref) { const containerRef = useRef(null); + // Delegated click handler for wikilinks + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = (e.target as HTMLElement).closest('.wikilink'); + if (target) { + const noteTitle = target.getAttribute('data-target'); + if (noteTitle && onWikilinkClick) { + e.preventDefault(); + onWikilinkClick(noteTitle); + } + } + }, + [onWikilinkClick] + ); + // Notify parent when mounted useEffect(() => { onReady?.(); @@ -86,9 +103,9 @@ export const MarkdownPreview = forwardRef +
; +} + +export function remarkWikilink() { + return (tree: Root) => { + visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { + if (index === undefined || !parent) return; + + const value = node.value; + if (!value.includes('[[')) return; + + const children: Array<{ type: string; value?: string } | WikilinkNode> = []; + let lastIndex = 0; + + // IMPORTANTE: resetear lastIndex para regex global + WIKILINK_PATTERN.lastIndex = 0; + + let match; + while ((match = WIKILINK_PATTERN.exec(value)) !== null) { + // Text before match + if (match.index > lastIndex) { + children.push({ type: 'text', value: value.slice(lastIndex, match.index) }); + } + + // Wikilink element + const target = match[1]!.trim(); + const display = match[2]?.trim() || target; + children.push({ + type: 'element', + tagName: 'span', + properties: { + className: ['wikilink'], + 'data-target': target, + }, + children: [{ type: 'text', value: display }], + } as WikilinkNode); + + lastIndex = match.index + match[0].length; + } + + // Remaining text + if (lastIndex < value.length) { + children.push({ type: 'text', value: value.slice(lastIndex) }); + } + + if (children.length > 0) { + // Cast needed: we're inserting hast-like nodes into mdast parent + // This works because react-markdown handles the hybrid tree + (parent.children as unknown[]).splice(index, 1, ...children); + // IMPORTANTE: retornar nuevo index para no romper traversal + return index + children.length; + } + }); + }; +} diff --git a/apps/desktop/src/renderer/components/editor/wikilink-extension.ts b/apps/desktop/src/renderer/components/editor/wikilink-extension.ts new file mode 100644 index 0000000..ffbe7e6 --- /dev/null +++ b/apps/desktop/src/renderer/components/editor/wikilink-extension.ts @@ -0,0 +1,61 @@ +/** + * CodeMirror extension for wikilink [[note]] syntax highlighting + * + * Highlights [[target]] and [[target|display]] patterns in the editor. + * Does NOT handle click navigation - that's the preview's job. + */ + +import { ViewPlugin, Decoration, DecorationSet, EditorView } from '@codemirror/view'; +import type { ViewUpdate } from '@codemirror/view'; +import { RangeSetBuilder } from '@codemirror/state'; + +// Pattern: [[target]] or [[target|display]] +const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +const wikilinkMark = Decoration.mark({ class: 'cm-wikilink' }); + +const wikilinkTheme = EditorView.baseTheme({ + '.cm-wikilink': { + color: '#5eead4', + borderBottom: '1px solid rgba(94, 234, 212, 0.3)', + borderRadius: '2px', + }, +}); + +class WikilinkHighlighter { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.build(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.build(update.view); + } + } + + build(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const { from, to } = view.viewport; + const text = view.state.sliceDoc(from, to); + + // IMPORTANTE: resetear lastIndex para regex global + WIKILINK_PATTERN.lastIndex = 0; + + let match; + while ((match = WIKILINK_PATTERN.exec(text)) !== null) { + const start = from + match.index; + const end = start + match[0].length; + builder.add(start, end, wikilinkMark); + } + + return builder.finish(); + } +} + +const wikilinkHighlighter = ViewPlugin.fromClass(WikilinkHighlighter, { + decorations: v => v.decorations, +}); + +export const wikilinkExtension = [wikilinkTheme, wikilinkHighlighter]; diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.module.css b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.module.css new file mode 100644 index 0000000..7c13634 --- /dev/null +++ b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.module.css @@ -0,0 +1,134 @@ +/** + * NotebookCreateModal CSS Module + * + * Inkdrop-style modal for creating notebooks. + */ + +/* Overlay backdrop */ +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 120px; + background: rgba(0, 0, 0, 0.5); + animation: fadeIn 0.1s ease-out; +} + +.modal { + width: 100%; + max-width: 420px; + background: var(--bg-inset, #1a1d23); + border-radius: 12px; + border: none; + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5); + overflow: hidden; + animation: slideIn 0.15s ease-out; + padding: 20px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header */ +.header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 20px; +} + +.headerIcon { + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); +} + +.headerTitle { + font-size: 16px; + font-weight: 500; + color: var(--text-primary, rgba(255, 255, 255, 0.9)); +} + +/* Input wrapper */ +.inputWrapper { + margin-bottom: 20px; +} + +.input { + width: 100%; + padding: 14px 16px; + background: transparent; + border: 1px solid var(--accent-muted, rgba(107, 159, 255, 0.4)); + border-radius: 8px; + color: var(--text-primary, rgba(255, 255, 255, 0.9)); + font-size: 14px; + font-weight: 400; + outline: none; + transition: border-color 0.15s ease; +} + +.input:focus { + border-color: var(--accent, #6b9fff); + outline: none; + box-shadow: none; +} + +.input::placeholder { + color: var(--text-muted, rgba(255, 255, 255, 0.4)); +} + +/* Actions */ +.actions { + display: flex; + gap: 12px; +} + +.cancelBtn, +.createBtn { + flex: 1; + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.cancelBtn { + background: var(--bg-surface, #2a2d35); + border: none; + color: var(--text-primary, rgba(255, 255, 255, 0.9)); +} + +.cancelBtn:hover { + background: var(--bg-hover, #35383f); +} + +.createBtn { + background: var(--accent, #6b9fff); + border: none; + color: #fff; +} + +.createBtn:hover:not(:disabled) { + background: var(--accent-hover, #5a8fee); +} + +.createBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx new file mode 100644 index 0000000..54660e8 --- /dev/null +++ b/apps/desktop/src/renderer/components/sidebar/NotebookCreateModal.tsx @@ -0,0 +1,90 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { BookOpen } from 'lucide-react'; +import styles from './NotebookCreateModal.module.css'; + +interface NotebookCreateModalProps { + readonly parentId?: string | null; + readonly onSubmit: (name: string, parentId: string | null) => void; + readonly onCancel: () => void; +} + +export function NotebookCreateModal({ parentId, onSubmit, onCancel }: NotebookCreateModalProps) { + const [name, setName] = useState(''); + const inputRef = useRef(null); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Close on escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onCancel]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const trimmedName = name.trim(); + if (trimmedName) { + onSubmit(trimmedName, parentId ?? null); + } + }, + [name, parentId, onSubmit] + ); + + // Close when clicking overlay (outside modal) + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onCancel(); + } + }, + [onCancel] + ); + + return createPortal( +
+
+
+ + Add New Notebook +
+ +
+
+ setName(e.target.value)} + placeholder="Enter notebook name" + className={styles.input} + aria-label="New notebook name" + /> +
+ +
+ + +
+
+
+
, + document.body + ); +} diff --git a/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx b/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx index 8e9ee01..aa35782 100644 --- a/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx +++ b/apps/desktop/src/renderer/components/sidebar/NotebookList.tsx @@ -1,13 +1,14 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useNotebookTree, useNotebookMutations, getAncestorIds } from '../../hooks/useNotebooks'; import type { NotebookTreeNode } from '../../../preload/index'; import { NotebookItem } from './NotebookItem'; -import { NotebookCreateForm } from './NotebookCreateForm'; interface NotebookListProps { readonly selectedNotebookId: string | null; readonly onSelectNotebook: (id: string) => void; readonly filterParentId?: string | null; + /** Request to create a child notebook */ + readonly onRequestCreateChild: (parentId: string) => void; } function NotebookListSkeleton() { @@ -36,15 +37,20 @@ function NotebookListError({ message }: { message: string }) { ); } +/** + * NotebookList - Pure list component + * + * Only renders notebooks and emits events. + * Does NOT manage modals or overlays. + */ export function NotebookList({ selectedNotebookId, onSelectNotebook, filterParentId, + onRequestCreateChild, }: NotebookListProps) { const { data: tree, isLoading, error } = useNotebookTree(); - const { createNotebook, renameNotebook, deleteNotebook } = useNotebookMutations(); - const [isCreating, setIsCreating] = useState(false); - const [createParentId, setCreateParentId] = useState(null); + const { renameNotebook, deleteNotebook } = useNotebookMutations(); // Calculate ancestor IDs for breadcrumb-style highlighting const ancestorIds = useMemo( @@ -56,7 +62,6 @@ export function NotebookList({ const displayedTree = useMemo(() => { if (!filterParentId || !tree) return tree ?? []; - // Find children of the selected notebook function findChildren(nodes: NotebookTreeNode[]): NotebookTreeNode[] { for (const node of nodes) { if (node.notebook.id === filterParentId) { @@ -70,28 +75,6 @@ export function NotebookList({ return findChildren(tree); }, [tree, filterParentId]); - const handleStartCreate = useCallback((parentId?: string) => { - setCreateParentId(parentId ?? null); - setIsCreating(true); - }, []); - - const handleCreate = useCallback( - async (name: string, parentId: string | null) => { - await createNotebook.mutateAsync({ - name, - parentId: parentId ?? undefined, - }); - setIsCreating(false); - setCreateParentId(null); - }, - [createNotebook] - ); - - const handleCancelCreate = useCallback(() => { - setIsCreating(false); - setCreateParentId(null); - }, []); - const handleRename = useCallback( async (id: string, name: string) => { await renameNotebook.mutateAsync({ id, name }); @@ -126,13 +109,6 @@ export function NotebookList({ return (
- {isCreating && ( - - )}
    {displayedTree.map(node => ( ))}
@@ -154,5 +130,4 @@ export function NotebookList({ ); } -// Export for external use to trigger creation export type { NotebookListProps }; diff --git a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx index 37d64be..d59e4d4 100644 --- a/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx +++ b/apps/desktop/src/renderer/components/sidebar/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useState, useCallback } from 'react'; import { useIsNotebookContext, useSelectedNotebookId, @@ -8,6 +9,7 @@ import { useStatusFilter, useTagFilter, } from '../../hooks/useNavigation'; +import { useNotebookMutations } from '../../hooks/useNotebooks'; import { SidebarHeader } from './SidebarHeader'; import { SidebarBreadcrumb } from './SidebarBreadcrumb'; import { SidebarQuickFilters } from './SidebarQuickFilters'; @@ -16,14 +18,19 @@ import { NotebookList } from './NotebookList'; import { TagsList } from './TagsList'; import { StatusFilters } from './StatusFilters'; import { SidebarFooter } from './SidebarFooter'; +import { NotebookCreateModal } from './NotebookCreateModal'; /** * Sidebar component - Pure render of NavigationState from Zustand * * Uses granular selectors to minimize re-renders. - * Does NOT manage state - only renders UI and emits actions. + * Owns modal state for notebook creation. */ export function Sidebar() { + // Modal state - lives HERE, not in NotebookList + const [isCreateNotebookOpen, setIsCreateNotebookOpen] = useState(false); + const [createParentId, setCreateParentId] = useState(null); + // Granular selectors const isNotebookContext = useIsNotebookContext(); const selectedNotebookId = useSelectedNotebookId(); @@ -33,9 +40,12 @@ export function Sidebar() { const statusFilter = useStatusFilter(); const tagFilter = useTagFilter(); - // Actions + // Mutations + const { createNotebook } = useNotebookMutations(); + + // Navigation actions const { - goToAllNotes, + goToAllInCurrentContext, goToPinned, goToTrash, goToNotebook, @@ -44,6 +54,35 @@ export function Sidebar() { setTagFilter, } = useNavigationActions(); + // Modal handlers + // When in notebook context, create child of current notebook + // When at root level, create at root + const openCreateInContext = useCallback(() => { + setCreateParentId(selectedNotebookId); + setIsCreateNotebookOpen(true); + }, [selectedNotebookId]); + + const openCreateChild = useCallback((parentId: string) => { + setCreateParentId(parentId); + setIsCreateNotebookOpen(true); + }, []); + + const closeCreate = useCallback(() => { + setIsCreateNotebookOpen(false); + setCreateParentId(null); + }, []); + + const handleCreateNotebook = useCallback( + async (name: string, parentId: string | null) => { + await createNotebook.mutateAsync({ + name, + parentId: parentId ?? undefined, + }); + closeCreate(); + }, + [createNotebook, closeCreate] + ); + return ( ); } diff --git a/apps/desktop/src/renderer/hooks/useNavigation.ts b/apps/desktop/src/renderer/hooks/useNavigation.ts index fd388ed..9a5efd5 100644 --- a/apps/desktop/src/renderer/hooks/useNavigation.ts +++ b/apps/desktop/src/renderer/hooks/useNavigation.ts @@ -73,6 +73,7 @@ export const useSortOrder = () => useNavigationStore(selectSortOrder); /** Get all navigation actions (stable references) */ export function useNavigationActions() { const goToAllNotes = useNavigationStore(s => s.goToAllNotes); + const goToAllInCurrentContext = useNavigationStore(s => s.goToAllInCurrentContext); const goToPinned = useNavigationStore(s => s.goToPinned); const goToTrash = useNavigationStore(s => s.goToTrash); const goToNotebook = useNavigationStore(s => s.goToNotebook); @@ -86,6 +87,7 @@ export function useNavigationActions() { return { goToAllNotes, + goToAllInCurrentContext, goToPinned, goToTrash, goToNotebook, diff --git a/apps/desktop/src/renderer/hooks/useNotes.ts b/apps/desktop/src/renderer/hooks/useNotes.ts index acf8a62..2c9076a 100644 --- a/apps/desktop/src/renderer/hooks/useNotes.ts +++ b/apps/desktop/src/renderer/hooks/useNotes.ts @@ -112,7 +112,7 @@ export function useNoteMutations() { }; const createNote = useMutation({ - mutationFn: async (input: { content: string; id?: string }) => { + mutationFn: async (input: { content: string; id?: string; notebookId?: string }) => { const result = await window.readied.notes.create(input); if (!result.ok) throw new Error(result.error.type); return result.data; diff --git a/apps/desktop/src/renderer/stores/navigationStore.ts b/apps/desktop/src/renderer/stores/navigationStore.ts index 48957dc..4e5b015 100644 --- a/apps/desktop/src/renderer/stores/navigationStore.ts +++ b/apps/desktop/src/renderer/stores/navigationStore.ts @@ -44,6 +44,7 @@ interface NavigationStore { // Actions - Navigation goToAllNotes: () => void; + goToAllInCurrentContext: () => void; goToPinned: () => void; goToTrash: () => void; goToNotebook: (id: string) => void; @@ -67,7 +68,7 @@ interface NavigationStore { const DEFAULT_NAVIGATION: NavigationState = { kind: 'global', filter: 'all' }; -export const useNavigationStore = create(set => ({ +export const useNavigationStore = create((set, get) => ({ // Initial state navigation: DEFAULT_NAVIGATION, statusFilter: null, @@ -78,6 +79,16 @@ export const useNavigationStore = create(set => ({ // Navigation actions (clear filters when changing context) goToAllNotes: () => set({ navigation: { kind: 'global', filter: 'all' }, statusFilter: null, tagFilter: null }), + goToAllInCurrentContext: () => { + const { navigation } = get(); + if (navigation.kind === 'notebook') { + // Stay in notebook, just clear filters + set({ statusFilter: null, tagFilter: null }); + } else { + // Go to global "all notes" + set({ navigation: { kind: 'global', filter: 'all' }, statusFilter: null, tagFilter: null }); + } + }, goToPinned: () => set({ navigation: { kind: 'global', filter: 'pinned' }, statusFilter: null, tagFilter: null }), goToTrash: () => diff --git a/apps/desktop/src/renderer/styles/preview.css b/apps/desktop/src/renderer/styles/preview.css index 5976a6d..56dc33e 100644 --- a/apps/desktop/src/renderer/styles/preview.css +++ b/apps/desktop/src/renderer/styles/preview.css @@ -72,6 +72,18 @@ color: var(--accent); } +/* Wikilinks [[note]] */ +.markdown-preview .wikilink { + color: var(--accent); + border-bottom: 1px solid rgba(94, 234, 212, 0.3); + cursor: pointer; + border-radius: 2px; +} + +.markdown-preview .wikilink:hover { + background: rgba(94, 234, 212, 0.1); +} + /* Strong and emphasis */ .markdown-preview strong { font-weight: 600; diff --git a/packages/core/src/contracts/NoteInput.ts b/packages/core/src/contracts/NoteInput.ts index 0725d96..9dfab9f 100644 --- a/packages/core/src/contracts/NoteInput.ts +++ b/packages/core/src/contracts/NoteInput.ts @@ -13,6 +13,9 @@ export interface CreateNoteInput { /** Optional ID (auto-generated if not provided) */ id?: string; + + /** Optional notebook ID (defaults to Inbox) */ + notebookId?: string; } /** Input for updating an existing note's content */ diff --git a/packages/core/src/operations/createNote.ts b/packages/core/src/operations/createNote.ts index fd902c2..67c72ce 100644 --- a/packages/core/src/operations/createNote.ts +++ b/packages/core/src/operations/createNote.ts @@ -5,7 +5,7 @@ */ import { createNote } from '../domain/note.js'; -import { createNoteId } from '../domain/types.js'; +import { createNoteId, createNotebookId } from '../domain/types.js'; import { validateContent, validateNoteId } from '../domain/invariants.js'; import { success, validationError, alreadyExists, type Result } from '../contracts/CoreResult.js'; import { toSnapshot, type NoteSnapshot } from '../contracts/NoteSnapshot.js'; @@ -44,6 +44,7 @@ export async function createNoteOperation( const note = createNote({ id: noteId, content: input.content, + notebookId: input.notebookId ? createNotebookId(input.notebookId) : undefined, }); // Save to repository diff --git a/plan.md b/plan.md index 16f8edd..d0ea449 100644 --- a/plan.md +++ b/plan.md @@ -401,6 +401,42 @@ import { autoUpdater } from 'electron-updater'; autoUpdater.checkForUpdatesAndNotify(); ``` +### 9.3.1 Private Repo Updates (Current Setup) + +El repo es privado, por lo que los releases requieren autenticación. + +**Configuración actual:** + +- `publish.private: true` en package.json +- `GH_TOKEN` secret con PAT de solo lectura +- electron-updater maneja auth automáticamente + +**Riesgos aceptados:** + +- Token recuperable del binario (inevitable para repos privados) +- Si token expira → auto-update roto +- Dependencia de GitHub infra + +### 9.3.2 Evolución Futura (Post-Monetización) + +Cuando el producto tenga usuarios pagos, churn tracking, y soporte activo, la arquitectura evoluciona a: + +| Actual (Pre-revenue) | Futuro (Post-revenue) | +| -------------------- | --------------------------- | +| Repo privado | Releases públicos (o CDN) | +| Updater con auth | Updater sin auth | +| N/A | Licencia validada aparte | +| N/A | Features gated por licencia | + +**Principio:** Updates accesibles para todos. Monetización via licencia, no via acceso a updates. + +**Implementación futura:** + +1. Mover releases a GitHub público o CDN propio +2. Eliminar `private: true` y token +3. Validar licencia al iniciar app +4. Gating de features Pro via capabilities system + ### 9.4 Versioning Strategy ```typescript diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d3e2b8..0278c11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 zustand: specifier: ^5.0.9 version: 5.0.9(@types/react@18.3.27)(react@18.3.1) @@ -102,6 +105,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.12 version: 7.6.13 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 '@types/react': specifier: ^18.2.79 version: 18.3.27