diff --git a/src/App.tsx b/src/App.tsx index d716a3b7..7d678dcf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Sidebar } from './components/Sidebar' import { NoteList } from './components/NoteList' import { Editor } from './components/Editor' import { ResizeHandle } from './components/ResizeHandle' -import { CreateNoteDialog, type NoteType } from './components/CreateNoteDialog' +import { CreateNoteDialog } from './components/CreateNoteDialog' +import { CreateTypeDialog } from './components/CreateTypeDialog' import { QuickOpenPalette } from './components/QuickOpenPalette' import { Toast } from './components/Toast' import { CommitDialog } from './components/CommitDialog' @@ -29,6 +30,12 @@ const VAULTS = [ { label: 'Demo', path: '/Users/luca/Workspace/laputa-app/demo-vault' }, ] +const BUILT_IN_TYPE_NAMES = new Set([ + 'Project', 'Experiment', 'Responsibility', 'Procedure', + 'Person', 'Event', 'Topic', 'Type', 'Note', 'Essay', + 'Quarter', 'Journal', 'Evergreen', +]) + function App() { const [selection, setSelection] = useState(DEFAULT_SELECTION) const [sidebarWidth, setSidebarWidth] = useState(250) @@ -37,7 +44,8 @@ function App() { const [inspectorCollapsed, setInspectorCollapsed] = useState(false) const [gitHistory, setGitHistory] = useState([]) const [showCreateDialog, setShowCreateDialog] = useState(false) - const [createNoteDefaultType, setCreateNoteDefaultType] = useState() + const [createNoteDefaultType, setCreateNoteDefaultType] = useState() + const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false) const [showQuickOpen, setShowQuickOpen] = useState(false) const [showCommitDialog, setShowCommitDialog] = useState(false) const [toastMessage, setToastMessage] = useState(null) @@ -47,6 +55,15 @@ function App() { const vault = useVaultLoader(vaultPath) const notes = useNoteActions(vault.addEntry, vault.updateContent, vault.entries, setToastMessage) + // Derive custom types from vault (Type entries not in built-in list) + const customTypes = useMemo( + () => vault.entries + .filter((e) => e.isA === 'Type' && !BUILT_IN_TYPE_NAMES.has(e.title)) + .map((e) => e.title) + .sort(), + [vault.entries], + ) + // Reset UI state when vault changes const handleSwitchVault = useCallback((path: string) => { setVaultPath(path) @@ -65,10 +82,19 @@ function App() { }, [notes.activeTabPath, vault.loadGitHistory]) const openCreateDialog = useCallback((type?: string) => { - setCreateNoteDefaultType(type as NoteType | undefined) + setCreateNoteDefaultType(type) setShowCreateDialog(true) }, []) + const openCreateTypeDialog = useCallback(() => { + setShowCreateTypeDialog(true) + }, []) + + const handleCreateType = useCallback((name: string) => { + notes.handleCreateType(name) + setToastMessage(`Type "${name}" created`) + }, [notes, setToastMessage]) + useAppKeyboard({ onQuickOpen: () => setShowQuickOpen(true), onCreateNote: openCreateDialog, @@ -107,7 +133,7 @@ function App() {
- setShowCommitDialog(true)} /> + setShowCommitDialog(true)} />
@@ -154,6 +180,12 @@ function App() { onClose={() => setShowCreateDialog(false)} onCreate={notes.handleCreateNote} defaultType={createNoteDefaultType} + customTypes={customTypes} + /> + setShowCreateTypeDialog(false)} + onCreate={handleCreateType} /> void - onCreate: (title: string, type: NoteType) => void - defaultType?: NoteType + onCreate: (title: string, type: string) => void + defaultType?: string + /** Custom types from the vault (Type documents not in built-in list) */ + customTypes?: string[] } -export function CreateNoteDialog({ open, onClose, onCreate, defaultType }: CreateNoteDialogProps) { +export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customTypes = [] }: CreateNoteDialogProps) { const [title, setTitle] = useState('') - const [type, setType] = useState('Note') + const [type, setType] = useState('Note') const inputRef = useRef(null) useEffect(() => { @@ -68,7 +70,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType }: Creat Type
- {NOTE_TYPES.map((t) => ( + {BUILT_IN_TYPES.map((t) => ( + ))}
diff --git a/src/components/CreateTypeDialog.tsx b/src/components/CreateTypeDialog.tsx new file mode 100644 index 00000000..0dfdf90a --- /dev/null +++ b/src/components/CreateTypeDialog.tsx @@ -0,0 +1,64 @@ +import { useState, useRef, useEffect } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +interface CreateTypeDialogProps { + open: boolean + onClose: () => void + onCreate: (name: string) => void +} + +export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogProps) { + const [name, setName] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + if (open) { + setName('') + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = name.trim() + if (!trimmed) return + onCreate(trimmed) + onClose() + } + + return ( + { if (!isOpen) onClose() }}> + + + Create New Type + +
+
+ + setName(e.target.value)} + /> +

+ Creates a type document. Its properties become defaults for new docs of this type. +

+
+ + + + +
+
+
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 426bda2a..ee078979 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, memo, type ComponentType } from 'react' +import { useState, useMemo, memo, type ComponentType } from 'react' import type { VaultEntry, SidebarSelection } from '../types' import { cn } from '@/lib/utils' import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus } from 'lucide-react' @@ -25,6 +25,7 @@ interface SidebarProps { onSelect: (selection: SidebarSelection) => void onSelectNote?: (entry: VaultEntry) => void onCreateType?: (type: string) => void + onCreateNewType?: () => void modifiedCount?: number onCommitPush?: () => void } @@ -34,7 +35,13 @@ const TOP_NAV = [ { label: 'Favorites', filter: 'favorites' as const, Icon: Star }, ] -const SECTION_GROUPS: { label: string; type: string; Icon: ComponentType }[] = [ +interface SectionGroup { + label: string + type: string + Icon: ComponentType +} + +const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [ { label: 'Projects', type: 'Project', Icon: Wrench }, { label: 'Experiments', type: 'Experiment', Icon: Flask }, { label: 'Responsibilities', type: 'Responsibility', Icon: Target }, @@ -45,7 +52,9 @@ const SECTION_GROUPS: { label: string; type: string; Icon: ComponentType s.type)) + +export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, modifiedCount = 0, onCommitPush }: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) const toggleSection = (type: string) => { setCollapsed((prev) => ({ ...prev, [type]: !prev[type] })) @@ -62,6 +71,113 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS return false } + // Derive custom type sections from Type entries not in the built-in list + const customSectionGroups: SectionGroup[] = useMemo(() => { + return entries + .filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title)) + .sort((a, b) => a.title.localeCompare(b.title)) + .map((e) => ({ + label: e.title + 's', + type: e.title, + Icon: FileText, + })) + }, [entries]) + + const allSectionGroups = useMemo( + () => [...BUILT_IN_SECTION_GROUPS, ...customSectionGroups], + [customSectionGroups], + ) + + const renderSection = ({ label, type, Icon }: SectionGroup) => { + const items = entries.filter((e) => e.isA === type) + const isCollapsed = collapsed[type] ?? false + const isTopic = type === 'Topic' + const isTypeSection = type === 'Type' + + const handlePlusClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (isTypeSection) { + onCreateNewType?.() + } else { + onCreateType?.(type) + } + } + + return ( +
+ {/* Section header row */} +
onSelect({ kind: 'sectionGroup', type })} + > +
+ + {label} +
+
+ {(onCreateType || (isTypeSection && onCreateNewType)) && ( + + )} + +
+
+ + {/* Children items */} + {!isCollapsed && items.length > 0 && ( +
+ {items.map((entry) => ( +
{ + onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) + onSelectNote?.(entry) + }} + > + {entry.title} +
+ ))} +
+ )} +
+ ) + } + return (
- {/* Section Groups */} - {SECTION_GROUPS.map(({ label, type, Icon }) => { - const items = type === 'Topic' - ? entries.filter((e) => e.isA === 'Topic') - : entries.filter((e) => e.isA === type) - const isCollapsed = collapsed[type] ?? false - const isTopic = type === 'Topic' - - return ( -
- {/* Section header row */} -
onSelect({ kind: 'sectionGroup', type })} - > -
- - {label} -
-
- {onCreateType && ( - - )} - -
-
- - {/* Children items */} - {!isCollapsed && items.length > 0 && ( -
- {items.map((entry) => ( -
{ - onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) - onSelectNote?.(entry) - }} - > - {entry.title} -
- ))} -
- )} -
- ) - })} + {/* Section Groups (built-in + custom) */} + {allSectionGroups.map(renderSection)} {/* Commit button — always visible */} diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 8cb1d67c..0d4bfa36 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -3,7 +3,6 @@ import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri' import type { VaultEntry } from '../types' import type { FrontmatterValue } from '../components/Inspector' -import type { NoteType } from '../components/CreateNoteDialog' interface Tab { entry: VaultEntry @@ -173,21 +172,23 @@ export function useNoteActions( } }, [entries, handleSelectNote]) - const handleCreateNote = useCallback(async (title: string, type: NoteType) => { + const handleCreateNote = useCallback(async (title: string, type: string) => { const typeToFolder: Record = { Note: 'note', Project: 'project', Experiment: 'experiment', Responsibility: 'responsibility', Procedure: 'procedure', Person: 'person', Event: 'event', Topic: 'topic', } - const folder = typeToFolder[type] || 'note' + // Custom types use lowercased type name as folder + const folder = typeToFolder[type] || type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') const path = `/Users/luca/Laputa/${folder}/${slug}.md` const now = Math.floor(Date.now() / 1000) + const noStatusTypes = new Set(['Topic', 'Person']) const newEntry: VaultEntry = { path, filename: `${slug}.md`, title, isA: type, aliases: [], belongsTo: [], relatedTo: [], - status: type === 'Topic' || type === 'Person' ? null : 'Active', + status: noStatusTypes.has(type) ? null : 'Active', owner: null, cadence: null, modifiedAt: now, createdAt: now, fileSize: 0, snippet: '', relationships: {}, } @@ -207,6 +208,29 @@ export function useNoteActions( handleSelectNote(newEntry) }, [handleSelectNote, addEntry]) + const handleCreateType = useCallback(async (typeName: string) => { + const slug = typeName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + const path = `/Users/luca/Laputa/type/${slug}.md` + const now = Math.floor(Date.now() / 1000) + + const newEntry: VaultEntry = { + path, filename: `${slug}.md`, title: typeName, isA: 'Type', + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + modifiedAt: now, createdAt: now, fileSize: 0, + snippet: '', relationships: {}, + } + + const content = `---\nIs A: Type\n---\n\n# ${typeName}\n\n` + + if (!isTauri()) { + addMockEntry(newEntry, content) + } + + addEntry(newEntry, content) + handleSelectNote(newEntry) + }, [handleSelectNote, addEntry]) + const handleUpdateFrontmatter = useCallback(async (path: string, key: string, value: FrontmatterValue) => { try { let newContent: string @@ -272,6 +296,7 @@ export function useNoteActions( handleSwitchTab, handleNavigateWikilink, handleCreateNote, + handleCreateType, handleUpdateFrontmatter, handleDeleteProperty, handleAddProperty,