feat: migrate to shadcn/ui

Install Tailwind CSS v4 + shadcn/ui with Radix UI primitives. Migrate all
UI components (Sidebar, NoteList, Editor tabs, Inspector, dialogs, palette)
from BEM CSS to Tailwind utility classes and shadcn components (Button,
Input, Dialog, Badge, etc.). Map theme system to shadcn CSS variables while
preserving BlockNote editor styles untouched. Remove 8 old CSS files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-16 16:56:44 +01:00
parent 4d33435bfd
commit 6989f44031
43 changed files with 3786 additions and 2173 deletions

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -18,11 +18,26 @@
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@mantine/core": "^8.3.14",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/api": "^2.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"lucide-react": "^0.564.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

2060
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
/* App layout - minimal CSS, most styling via Tailwind */
.app {
display: flex;
height: 100vh;

View File

@@ -379,6 +379,7 @@ function App() {
owner: null,
cadence: null,
modifiedAt: now,
createdAt: now,
fileSize: 0,
}

View File

@@ -1,119 +0,0 @@
.commit-dialog__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.commit-dialog {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 12px;
width: 420px;
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.commit-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.commit-dialog__header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-heading);
}
.commit-dialog__count {
font-size: 12px;
color: var(--text-tertiary);
background: var(--bg-button);
padding: 2px 8px;
border-radius: 10px;
}
.commit-dialog__input {
width: 100%;
padding: 10px 12px;
background: var(--bg-input);
border: 1px solid var(--border-input);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
resize: vertical;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.commit-dialog__input::placeholder {
color: var(--text-faint);
}
.commit-dialog__input:focus {
border-color: var(--accent-blue);
}
.commit-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 14px;
}
.commit-dialog__hint {
font-size: 11px;
color: var(--text-faint);
}
.commit-dialog__actions {
display: flex;
gap: 8px;
}
.commit-dialog__cancel {
padding: 6px 14px;
font-size: 13px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.commit-dialog__cancel:hover {
background: var(--bg-button);
}
.commit-dialog__submit {
padding: 6px 14px;
font-size: 13px;
border: none;
border-radius: 6px;
background: var(--accent-blue, #2196f3);
color: #fff;
cursor: pointer;
font-weight: 500;
transition: all 0.15s;
}
.commit-dialog__submit:hover {
opacity: 0.9;
}
.commit-dialog__submit:disabled {
opacity: 0.4;
cursor: default;
}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import './CommitDialog.css'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
interface CommitDialogProps {
open: boolean
@@ -15,13 +17,10 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD
useEffect(() => {
if (open) {
setMessage('')
// Focus with a small delay to ensure the dialog is rendered
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
if (!open) return null
const handleSubmit = () => {
const trimmed = message.trim()
if (!trimmed) return
@@ -38,37 +37,37 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD
}
return (
<div className="commit-dialog__overlay" onClick={onClose}>
<div className="commit-dialog" onClick={(e) => e.stopPropagation()}>
<div className="commit-dialog__header">
<h3>Commit & Push</h3>
<span className="commit-dialog__count">{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed</span>
</div>
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Commit & Push</DialogTitle>
<Badge variant="secondary" className="text-xs">
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
</Badge>
</div>
</DialogHeader>
<textarea
ref={inputRef}
className="commit-dialog__input"
className="w-full resize-y rounded-lg border border-input bg-[var(--bg-input)] px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-ring"
placeholder="Commit message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
rows={3}
/>
<div className="commit-dialog__footer">
<span className="commit-dialog__hint">Cmd+Enter to commit</span>
<div className="commit-dialog__actions">
<button className="commit-dialog__cancel" onClick={onClose}>
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">Cmd+Enter to commit</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</button>
<button
className="commit-dialog__submit"
onClick={handleSubmit}
disabled={!message.trim()}
>
</Button>
<Button onClick={handleSubmit} disabled={!message.trim()}>
Commit & Push
</button>
</Button>
</div>
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,131 +0,0 @@
.create-dialog__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--shadow-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.create-dialog {
background: var(--bg-dialog);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 24px;
width: 420px;
max-width: 90vw;
box-shadow: 0 8px 32px var(--shadow-dialog);
}
.create-dialog__title {
margin: 0 0 20px 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.create-dialog__field {
margin-bottom: 16px;
}
.create-dialog__label {
display: block;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.create-dialog__input {
width: 100%;
padding: 8px 12px;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.create-dialog__input:focus {
border-color: var(--accent-blue);
}
.create-dialog__input::placeholder {
color: var(--text-faint);
}
.create-dialog__types {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.create-dialog__type-btn {
padding: 4px 12px;
font-size: 12px;
border: 1px solid var(--border-primary);
border-radius: 14px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.15s;
}
.create-dialog__type-btn:hover {
background: var(--bg-button);
color: var(--text-primary);
}
.create-dialog__type-btn--active {
background: var(--accent-blue);
color: #fff;
border-color: var(--accent-blue);
}
.create-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.create-dialog__btn {
padding: 6px 16px;
font-size: 13px;
border-radius: 6px;
border: none;
cursor: pointer;
font-weight: 500;
}
.create-dialog__btn--cancel {
background: transparent;
color: var(--text-tertiary);
border: 1px solid var(--border-primary);
}
.create-dialog__btn--cancel:hover {
background: var(--bg-button);
color: var(--text-primary);
}
.create-dialog__btn--create {
background: var(--accent-blue);
color: #fff;
}
.create-dialog__btn--create:hover {
background: var(--accent-blue-hover);
}
.create-dialog__btn--create:disabled {
opacity: 0.4;
cursor: not-allowed;
}

View File

@@ -1,5 +1,8 @@
import { useState, useRef, useEffect } from 'react'
import './CreateNoteDialog.css'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
const NOTE_TYPES = [
'Note',
@@ -29,23 +32,10 @@ export function CreateNoteDialog({ open, onClose, onCreate }: CreateNoteDialogPr
if (open) {
setTitle('')
setType('Note')
// Focus input after render
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
// Close on Escape
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [open, onClose])
if (!open) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const trimmed = title.trim()
@@ -55,46 +45,55 @@ export function CreateNoteDialog({ open, onClose, onCreate }: CreateNoteDialogPr
}
return (
<div className="create-dialog__overlay" onClick={onClose}>
<div className="create-dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="create-dialog__title">Create New Note</h3>
<form onSubmit={handleSubmit}>
<div className="create-dialog__field">
<label className="create-dialog__label">Title</label>
<input
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Create New Note</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Title
</label>
<Input
ref={inputRef}
className="create-dialog__input"
type="text"
placeholder="Enter note title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="create-dialog__field">
<label className="create-dialog__label">Type</label>
<div className="create-dialog__types">
<div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Type
</label>
<div className="flex flex-wrap gap-1.5">
{NOTE_TYPES.map((t) => (
<button
<Button
key={t}
type="button"
className={`create-dialog__type-btn${type === t ? ' create-dialog__type-btn--active' : ''}`}
variant={type === t ? 'default' : 'outline'}
size="sm"
className={cn(
"rounded-full text-xs",
type === t && "bg-primary text-primary-foreground"
)}
onClick={() => setType(t)}
>
{t}
</button>
</Button>
))}
</div>
</div>
<div className="create-dialog__actions">
<button type="button" className="create-dialog__btn create-dialog__btn--cancel" onClick={onClose}>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</button>
<button type="submit" className="create-dialog__btn create-dialog__btn--create" disabled={!title.trim()}>
</Button>
<Button type="submit" disabled={!title.trim()}>
Create
</button>
</div>
</Button>
</DialogFooter>
</form>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,112 +1,3 @@
.editor {
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
.editor__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: var(--text-muted);
gap: 8px;
}
.editor__placeholder p {
margin: 0;
font-size: 15px;
}
.editor__placeholder-hint {
font-size: 12px;
color: var(--text-faint);
}
.editor__drag-strip {
height: 48px;
flex-shrink: 0;
-webkit-app-region: drag;
app-region: drag;
}
/* Tab bar */
.editor__tab-bar {
display: flex;
background: var(--bg-card);
border-bottom: 1px solid var(--border-primary);
overflow-x: auto;
flex-shrink: 0;
-webkit-app-region: drag;
app-region: drag;
}
.editor__tab-bar::-webkit-scrollbar {
height: 0;
}
.editor__tab {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-size: 12px;
color: var(--text-tertiary);
cursor: pointer;
border-right: 1px solid var(--border-primary);
white-space: nowrap;
max-width: 180px;
flex-shrink: 0;
transition: all 0.1s;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.editor__tab:hover {
background: var(--bg-hover-subtle);
color: var(--text-secondary);
}
.editor__tab--active {
background: var(--bg-primary);
color: var(--text-primary);
border-bottom: 2px solid var(--accent-blue);
}
.editor__tab-title {
overflow: hidden;
text-overflow: ellipsis;
}
.editor__tab-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 0 2px;
line-height: 1;
border-radius: 3px;
opacity: 0;
transition: opacity 0.1s;
}
.editor__tab:hover .editor__tab-close,
.editor__tab--active .editor__tab-close {
opacity: 1;
}
.editor__tab-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Wikilink inline content */
.wikilink {
color: var(--accent-blue, #2196f3);
@@ -143,126 +34,6 @@
max-width: 760px;
}
/* Tab bar actions (diff toggle) */
.editor__tab-bar-actions {
display: flex;
align-items: center;
margin-left: auto;
padding: 0 8px;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.editor__diff-toggle {
padding: 3px 10px;
font-size: 11px;
border: 1px solid var(--border-primary);
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.15s;
font-weight: 500;
}
.editor__diff-toggle:hover {
background: var(--bg-button);
color: var(--text-secondary);
}
.editor__diff-toggle--active {
background: var(--accent-blue-bg);
color: var(--accent-blue);
border-color: var(--accent-blue-bg);
}
.editor__diff-toggle:disabled {
opacity: 0.5;
cursor: default;
}
/* Diff view */
.editor__diff-container {
flex: 1;
overflow: auto;
}
.diff-view {
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 13px;
line-height: 1.6;
padding: 12px 0;
}
.diff-view__empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 14px;
}
.diff-view__line {
display: flex;
padding: 0 16px;
min-height: 22px;
}
.diff-view__line-number {
width: 40px;
flex-shrink: 0;
text-align: right;
padding-right: 12px;
color: var(--text-faint);
user-select: none;
}
.diff-view__line-content {
flex: 1;
white-space: pre-wrap;
word-break: break-all;
padding: 0 8px;
}
.diff-view__line--added {
background: rgba(76, 175, 80, 0.12);
}
.diff-view__line--added .diff-view__line-content {
color: #4caf50;
}
.diff-view__line--removed {
background: rgba(244, 67, 54, 0.12);
}
.diff-view__line--removed .diff-view__line-content {
color: #f44336;
}
.diff-view__line--hunk {
background: rgba(33, 150, 243, 0.08);
}
.diff-view__line--hunk .diff-view__line-content {
color: var(--accent-blue, #2196f3);
font-style: italic;
}
.diff-view__line--header {
background: var(--bg-hover-subtle);
}
.diff-view__line--header .diff-view__line-content {
color: var(--text-muted);
font-weight: 600;
}
.diff-view__line--context .diff-view__line-content {
color: var(--text-secondary);
}
/* =============================================
Bear-style live preview: zero horizontal shift
============================================= */

View File

@@ -6,6 +6,9 @@ import { BlockNoteView } from '@blocknote/mantine'
import '@blocknote/mantine/style.css'
import type { VaultEntry } from '../types'
import { useEditorTheme } from '../hooks/useTheme'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import './Editor.css'
import './EditorTheme.css'
@@ -56,8 +59,6 @@ const schema = BlockNoteSchema.create({
},
})
type EditorType = typeof schema.BlockNoteEditorType
/** Strip YAML frontmatter from markdown, returning [frontmatter, body] */
function splitFrontmatter(content: string): [string, string] {
if (!content.startsWith('---')) return ['', content]
@@ -95,17 +96,14 @@ function expandWikilinksInContent(content: any[]): any[] {
const result: any[] = []
for (const item of content) {
if (item.type === 'text' && typeof item.text === 'string' && item.text.includes(WL_START)) {
// Split this text node around wikilink placeholders
const text = item.text as string
let lastIndex = 0
WL_RE.lastIndex = 0
let match
while ((match = WL_RE.exec(text)) !== null) {
// Text before this match
if (match.index > lastIndex) {
result.push({ ...item, text: text.slice(lastIndex, match.index) })
}
// The wikilink
result.push({
type: 'wikilink',
props: { target: match[1] },
@@ -113,7 +111,6 @@ function expandWikilinksInContent(content: any[]): any[] {
})
lastIndex = match.index + match[0].length
}
// Text after last match
if (lastIndex < text.length) {
result.push({ ...item, text: text.slice(lastIndex) })
}
@@ -127,7 +124,7 @@ function expandWikilinksInContent(content: any[]): any[] {
function DiffView({ diff }: { diff: string }) {
if (!diff) {
return (
<div className="diff-view__empty">
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No changes to display
</div>
)
@@ -136,23 +133,27 @@ function DiffView({ diff }: { diff: string }) {
const lines = diff.split('\n')
return (
<div className="diff-view">
<div className="font-mono text-[13px] leading-relaxed py-3">
{lines.map((line, i) => {
let className = 'diff-view__line diff-view__line--context'
let lineClass = 'text-secondary-foreground'
if (line.startsWith('+') && !line.startsWith('+++')) {
className = 'diff-view__line diff-view__line--added'
lineClass = 'bg-[rgba(76,175,80,0.12)] text-[#4caf50]'
} else if (line.startsWith('-') && !line.startsWith('---')) {
className = 'diff-view__line diff-view__line--removed'
lineClass = 'bg-[rgba(244,67,54,0.12)] text-[#f44336]'
} else if (line.startsWith('@@')) {
className = 'diff-view__line diff-view__line--hunk'
lineClass = 'bg-[rgba(33,150,243,0.08)] text-primary italic'
} else if (line.startsWith('diff') || line.startsWith('index') || line.startsWith('---') || line.startsWith('+++') || line.startsWith('new file')) {
className = 'diff-view__line diff-view__line--header'
lineClass = 'bg-muted text-muted-foreground font-semibold'
}
return (
<div key={i} className={className}>
<span className="diff-view__line-number">{i + 1}</span>
<span className="diff-view__line-content">{line || '\u00A0'}</span>
<div key={i} className={cn("flex min-h-[22px] px-4", lineClass)}>
<span className="w-10 shrink-0 text-right pr-3 text-muted-foreground select-none">
{i + 1}
</span>
<span className="flex-1 whitespace-pre-wrap break-all px-2">
{line || '\u00A0'}
</span>
</div>
)
})}
@@ -169,7 +170,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
const editor = useCreateBlockNote({ schema })
// Load markdown content into editor, converting [[target]] to wikilink inline content
useEffect(() => {
async function load() {
const preprocessed = preProcessWikilinks(body)
@@ -180,7 +180,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
load()
}, [body, editor])
// Click handler for wikilinks
useEffect(() => {
const container = document.querySelector('.editor__blocknote-container')
if (!container) return
@@ -197,7 +196,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
return () => container.removeEventListener('click', handler as EventListener, true)
}, [editor])
// Suggestion menu items for [[ trigger
const getWikilinkItems = useCallback(async (query: string) => {
const items = entries.map(entry => ({
title: entry.title,
@@ -216,7 +214,7 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
return filterSuggestionItems(items, query)
}, [entries, editor])
const isDark = typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') !== 'light'
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
return (
<div className="editor__blocknote-container" style={cssVars as React.CSSProperties}>
@@ -224,7 +222,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
editor={editor}
theme={isDark ? 'dark' : 'light'}
>
{/* Wikilink suggestion menu triggered by [[ */}
<SuggestionMenuController
triggerCharacter="[["
getItems={getWikilinkItems}
@@ -268,52 +265,62 @@ export function Editor({ tabs, activeTabPath, entries, onSwitchTab, onCloseTab,
if (tabs.length === 0) {
return (
<div className="editor">
<div className="editor__drag-strip" data-tauri-drag-region />
<div className="editor__placeholder">
<p>Select a note to start editing</p>
<span className="editor__placeholder-hint">Cmd+P to search &middot; Cmd+N to create</span>
<div className="editor flex flex-col bg-background text-foreground">
<div className="editor__drag-strip h-12 shrink-0" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} />
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p className="m-0 text-[15px]">Select a note to start editing</p>
<span className="text-xs text-muted-foreground">Cmd+P to search &middot; Cmd+N to create</span>
</div>
</div>
)
}
return (
<div className="editor">
<div className="editor__tab-bar" data-tauri-drag-region>
<div className="editor flex flex-col bg-background text-foreground">
{/* Tab bar */}
<div className="flex shrink-0 overflow-x-auto border-b border-border bg-card" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
{tabs.map((tab) => (
<div
key={tab.entry.path}
className={`editor__tab${tab.entry.path === activeTabPath ? ' editor__tab--active' : ''}`}
className={cn(
"flex shrink-0 cursor-pointer items-center gap-1.5 border-r border-border px-3.5 py-[7px] text-xs whitespace-nowrap max-w-[180px] transition-all",
tab.entry.path === activeTabPath
? "bg-background text-foreground border-b-2 border-b-primary"
: "text-muted-foreground hover:bg-muted hover:text-secondary-foreground"
)}
onClick={() => onSwitchTab(tab.entry.path)}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<span className="editor__tab-title">{tab.entry.title}</span>
<span className="truncate">{tab.entry.title}</span>
<button
className="editor__tab-close"
className={cn(
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
tab.entry.path === activeTabPath ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.entry.path)
}}
>
×
<X className="size-3.5" />
</button>
</div>
))}
{showDiffToggle && (
<div className="editor__tab-bar-actions">
<button
className={`editor__diff-toggle${diffMode ? ' editor__diff-toggle--active' : ''}`}
<div className="ml-auto flex items-center px-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<Button
variant={diffMode ? 'default' : 'outline'}
size="xs"
onClick={handleToggleDiff}
disabled={diffLoading}
title={diffMode ? 'Switch to Edit view' : 'Show diff'}
>
{diffLoading ? '...' : diffMode ? 'Edit' : 'Diff'}
</button>
</Button>
</div>
)}
</div>
{diffMode ? (
<div className="editor__diff-container">
<div className="flex-1 overflow-auto">
<DiffView diff={diffContent ?? ''} />
</div>
) : (

View File

@@ -1,447 +0,0 @@
.inspector {
background: var(--bg-sidebar);
color: var(--text-primary);
display: flex;
flex-direction: column;
overflow-y: auto;
border-left: 1px solid var(--border-primary);
transition: width 0.2s ease;
}
.inspector--collapsed {
width: 40px !important;
min-width: 40px !important;
}
.inspector__header {
padding: 14px 12px;
border-bottom: 1px solid var(--border-primary);
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: drag;
app-region: drag;
}
.inspector__header button {
-webkit-app-region: no-drag;
app-region: no-drag;
}
.inspector__header h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
}
.inspector__toggle {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
font-size: 12px;
}
.inspector__toggle:hover {
color: var(--text-heading);
}
.inspector__content {
padding: 12px;
}
.inspector__section {
margin-bottom: 16px;
}
.inspector__section h4 {
margin: 0 0 8px;
font-size: 12px;
text-transform: uppercase;
color: var(--text-tertiary);
letter-spacing: 0.5px;
}
.inspector__empty {
font-size: 13px;
color: var(--text-faint);
margin: 0;
}
/* Properties */
.inspector__props {
display: flex;
flex-direction: column;
gap: 8px;
}
.inspector__prop {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.inspector__prop-label {
color: var(--text-tertiary);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
}
.inspector__prop-value {
color: var(--text-secondary);
text-align: right;
}
.inspector__prop-value--editable {
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.15s;
}
.inspector__prop-value--editable:hover {
background-color: var(--bg-hover);
}
.inspector__edit-input {
background: var(--bg-button);
border: 1px solid var(--border-dialog);
color: var(--text-heading);
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
}
.inspector__edit-input:focus {
outline: none;
border-color: var(--link-color);
}
.inspector__prop-delete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.inspector__prop:hover .inspector__prop-delete {
opacity: 1;
}
.inspector__prop-delete:hover {
color: var(--accent-red);
}
.inspector__status-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
color: #fff;
}
.inspector__status-pill--editable {
cursor: pointer;
transition: opacity 0.15s;
}
.inspector__status-pill--editable:hover {
opacity: 0.8;
}
.inspector__bool-toggle {
background: none;
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.inspector__bool-toggle:hover {
background: var(--bg-hover);
border-color: var(--text-muted);
}
/* List editor */
.inspector__list-editor {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.inspector__list-item {
display: flex;
align-items: center;
gap: 4px;
}
.inspector__list-item-text {
flex: 1;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.15s;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspector__list-item-text:hover {
background-color: var(--bg-hover);
}
.inspector__list-item-delete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.inspector__list-item:hover .inspector__list-item-delete {
opacity: 1;
}
.inspector__list-item-delete:hover {
color: var(--accent-red);
}
.inspector__list-add {
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
padding: 4px 0;
cursor: pointer;
text-align: left;
}
.inspector__list-add:hover {
color: var(--text-tertiary);
}
.inspector__add-prop {
margin-top: 12px;
background: none;
border: 1px dashed var(--border-primary);
color: var(--text-muted);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
width: 100%;
transition: all 0.15s;
}
.inspector__add-prop:hover:not(:disabled) {
border-color: var(--link-color);
color: var(--link-color);
}
.inspector__add-prop:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Add property dialog */
.inspector__add-dialog {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--bg-button);
border-radius: 6px;
border: 1px solid var(--border-dialog);
}
.inspector__add-dialog-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.inspector__add-dialog-buttons button {
background: var(--bg-hover);
border: none;
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.inspector__add-dialog-buttons button:hover:not(:disabled) {
background: var(--border-dialog);
color: var(--text-heading);
}
.inspector__add-dialog-buttons button:first-child {
background: var(--accent-blue);
color: #fff;
}
.inspector__add-dialog-buttons button:first-child:hover:not(:disabled) {
background: var(--accent-blue-hover);
}
.inspector__add-dialog-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Relationships */
.inspector__rel-group {
margin-bottom: 10px;
}
.inspector__rel-label {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
}
.inspector__rel-links {
display: flex;
flex-direction: column;
gap: 4px;
}
.inspector__rel-link {
background: none;
border: none;
color: var(--link-color);
font-size: 13px;
padding: 2px 0;
cursor: pointer;
text-align: left;
}
.inspector__rel-link:hover {
color: var(--link-hover);
text-decoration: underline;
}
/* Backlinks */
.inspector__count {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
margin-left: 4px;
}
.inspector__backlinks {
display: flex;
flex-direction: column;
gap: 2px;
}
.inspector__backlink {
display: flex;
justify-content: space-between;
align-items: center;
background: none;
border: none;
color: var(--link-color);
font-size: 13px;
padding: 4px 0;
cursor: pointer;
text-align: left;
gap: 8px;
}
.inspector__backlink:hover {
color: var(--link-hover);
}
.inspector__backlink-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspector__backlink-type {
font-size: 11px;
color: var(--text-faint);
flex-shrink: 0;
}
/* Git History */
.inspector__commits {
display: flex;
flex-direction: column;
gap: 10px;
}
.inspector__commit {
border-left: 2px solid var(--border-primary);
padding-left: 10px;
}
.inspector__commit-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2px;
}
.inspector__commit-hash {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 11px;
color: var(--link-color);
}
.inspector__commit-date {
font-size: 11px;
color: var(--text-muted);
}
.inspector__commit-msg {
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspector__view-all {
margin-top: 10px;
background: none;
border: none;
color: var(--text-muted);
font-size: 12px;
padding: 4px 0;
cursor: not-allowed;
}
.inspector__view-all:hover {
color: var(--text-tertiary);
}

View File

@@ -15,6 +15,7 @@ const mockEntry: VaultEntry = {
owner: 'Luca Rossi',
cadence: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 1024,
}
@@ -41,6 +42,7 @@ const referrerEntry: VaultEntry = {
owner: null,
cadence: null,
modifiedAt: 1707900000,
createdAt: null,
fileSize: 200,
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useState, useCallback } from 'react'
import type { VaultEntry, GitCommit } from '../types'
import './Inspector.css'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface InspectorProps {
collapsed: boolean
@@ -57,20 +58,11 @@ const SKIP_KEYS = new Set([
])
function formatDate(timestamp: number | null): string {
if (!timestamp) return ''
if (!timestamp) return '\u2014'
const d = new Date(timestamp * 1000)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatISODate(dateStr: string): string {
try {
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return dateStr
}
}
function countWords(content: string | null): number {
if (!content) return 0
// Strip YAML frontmatter
@@ -82,19 +74,19 @@ function countWords(content: string | null): number {
/** Parse YAML frontmatter from content */
function parseFrontmatter(content: string | null): ParsedFrontmatter {
if (!content) return {}
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (!match) return {}
const yaml = match[1]
const result: ParsedFrontmatter = {}
let currentKey: string | null = null
let currentList: string[] = []
let inList = false
const lines = yaml.split('\n')
for (const line of lines) {
// Check for list item
const listMatch = line.match(/^ - (.*)$/)
@@ -103,35 +95,35 @@ function parseFrontmatter(content: string | null): ParsedFrontmatter {
currentList.push(listMatch[1].replace(/^["']|["']$/g, ''))
continue
}
// If we were in a list and hit a non-list line, save it
if (inList && currentKey) {
result[currentKey] = currentList.length === 1 ? currentList[0] : currentList
currentList = []
inList = false
}
// Check for key: value
const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
if (kvMatch) {
currentKey = kvMatch[1].trim()
const value = kvMatch[2].trim()
if (value === '' || value === '|' || value === '>') {
// Empty value or multiline - wait for list items or next key
continue
}
// Handle inline list like [item1, item2]
if (value.startsWith('[') && value.endsWith(']')) {
const items = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''))
result[currentKey] = items.length === 1 ? items[0] : items
continue
}
// Handle quoted string
const unquoted = value.replace(/^["']|["']$/g, '')
// Handle boolean
if (unquoted.toLowerCase() === 'true') {
result[currentKey] = true
@@ -141,16 +133,16 @@ function parseFrontmatter(content: string | null): ParsedFrontmatter {
result[currentKey] = false
continue
}
result[currentKey] = unquoted
}
}
// Don't forget last list if any
if (inList && currentKey) {
result[currentKey] = currentList.length === 1 ? currentList[0] : currentList
}
return result
}
@@ -190,13 +182,13 @@ function wikilinkTarget(ref: string): string {
}
// Editable value component for inline editing
function EditableValue({
value,
onSave,
function EditableValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit
}: {
onStartEdit
}: {
value: string
onSave: (newValue: string) => void
onCancel: () => void
@@ -204,7 +196,7 @@ function EditableValue({
onStartEdit: () => void
}) {
const [editValue, setEditValue] = useState(value)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onSave(editValue)
@@ -213,11 +205,11 @@ function EditableValue({
onCancel()
}
}
if (isEditing) {
return (
<input
className="inspector__edit-input"
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
@@ -227,14 +219,14 @@ function EditableValue({
/>
)
}
return (
<span
className="inspector__prop-value inspector__prop-value--editable"
<span
className="cursor-pointer rounded px-1 py-0.5 text-right text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title="Click to edit"
>
{value || ''}
{value || '\u2014'}
</span>
)
}
@@ -243,12 +235,10 @@ function EditableValue({
function EditableList({
items,
onSave,
onDelete,
label,
}: {
items: string[]
onSave: (newItems: string[]) => void
onDelete?: () => void
label: string
}) {
const [editingIndex, setEditingIndex] = useState<number | null>(null)
@@ -304,12 +294,12 @@ function EditableList({
}
return (
<div className="inspector__list-editor">
<div className="flex w-full flex-col gap-1">
{items.map((item, idx) => (
<div key={idx} className="inspector__list-item">
<div key={idx} className="group/item flex items-center gap-1">
{editingIndex === idx ? (
<input
className="inspector__edit-input"
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
@@ -319,19 +309,19 @@ function EditableList({
/>
) : (
<>
<span
className="inspector__list-item-text"
<span
className="flex-1 cursor-pointer truncate rounded px-1 py-0.5 text-[13px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={() => handleStartEdit(idx)}
title="Click to edit"
>
{item}
</span>
<button
className="inspector__list-item-delete"
className="border-none bg-transparent p-0 px-1 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/item:opacity-100"
onClick={() => handleDeleteItem(idx)}
title="Remove item"
>
×
&times;
</button>
</>
)}
@@ -339,7 +329,7 @@ function EditableList({
))}
{isAddingNew ? (
<input
className="inspector__edit-input"
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
@@ -350,7 +340,7 @@ function EditableList({
/>
) : (
<button
className="inspector__list-add"
className="border-none bg-transparent p-0 py-1 text-left text-xs text-muted-foreground hover:text-secondary-foreground"
onClick={() => setIsAddingNew(true)}
>
+ Add item
@@ -363,13 +353,13 @@ function EditableList({
function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: string[]; onNavigate: (target: string) => void }) {
if (refs.length === 0) return null
return (
<div className="inspector__rel-group">
<span className="inspector__rel-label">{label}</span>
<div className="inspector__rel-links">
<div className="mb-2.5">
<span className="mb-1 block text-[11px] text-muted-foreground">{label}</span>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => (
<button
key={`${ref}-${idx}`}
className="inspector__rel-link"
className="border-none bg-transparent p-0 py-0.5 text-left text-[13px] text-primary cursor-pointer hover:underline"
onClick={() => onNavigate(wikilinkTarget(ref))}
>
{wikilinkDisplay(ref)}
@@ -380,12 +370,12 @@ function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: s
)
}
function DynamicRelationshipsPanel({
frontmatter,
onNavigate
}: {
function DynamicRelationshipsPanel({
frontmatter,
onNavigate
}: {
frontmatter: ParsedFrontmatter
onNavigate: (target: string) => void
onNavigate: (target: string) => void
}) {
// Find all keys that contain wikilinks
const relationshipEntries = useMemo(() => {
@@ -413,16 +403,16 @@ function DynamicRelationshipsPanel({
if (relationshipEntries.length === 0) {
return (
<div className="inspector__section">
<h4>Relationships</h4>
<p className="inspector__empty">No relationships</p>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
</div>
)
}
return (
<div className="inspector__section">
<h4>Relationships</h4>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
{relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup key={key} label={key} refs={refs} onNavigate={onNavigate} />
))}
@@ -430,14 +420,14 @@ function DynamicRelationshipsPanel({
)
}
function DynamicPropertiesPanel({
entry,
function DynamicPropertiesPanel({
entry,
content,
frontmatter,
onUpdateProperty,
onDeleteProperty,
onAddProperty,
}: {
}: {
entry: VaultEntry
content: string | null
frontmatter: ParsedFrontmatter
@@ -449,9 +439,9 @@ function DynamicPropertiesPanel({
const [showAddDialog, setShowAddDialog] = useState(false)
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const wordCount = countWords(content)
// Filter out relationship keys and skipped keys
const propertyEntries = useMemo(() => {
return Object.entries(frontmatter)
@@ -528,7 +518,7 @@ function DynamicPropertiesPanel({
/>
)
}
// Status gets special rendering but is still editable
if (key === 'Status' || key.includes('Status')) {
const statusStr = String(value)
@@ -536,7 +526,7 @@ function DynamicPropertiesPanel({
if (editingKey === key) {
return (
<input
className="inspector__edit-input"
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text"
defaultValue={statusStr}
onKeyDown={(e) => {
@@ -549,8 +539,8 @@ function DynamicPropertiesPanel({
)
}
return (
<span
className="inspector__status-pill inspector__status-pill--editable"
<span
className="inline-block cursor-pointer rounded-xl px-2.5 py-0.5 text-xs font-medium text-white transition-opacity hover:opacity-80"
style={{ backgroundColor: color }}
onClick={() => setEditingKey(key)}
title="Click to edit"
@@ -559,7 +549,7 @@ function DynamicPropertiesPanel({
</span>
)
}
// Arrays get list editor
if (Array.isArray(value)) {
return (
@@ -570,12 +560,9 @@ function DynamicPropertiesPanel({
/>
)
}
// Date fields - still editable but formatted
if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) {
const displayValue = typeof value === 'string' && value.includes('T')
? formatISODate(value)
: String(value)
return (
<EditableValue
value={String(value)}
@@ -586,19 +573,19 @@ function DynamicPropertiesPanel({
/>
)
}
// Boolean
if (typeof value === 'boolean') {
return (
<button
className="inspector__bool-toggle"
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
onClick={() => onUpdateProperty?.(key, !value)}
>
{value ? ' Yes' : ' No'}
{value ? '\u2713 Yes' : '\u2717 No'}
</button>
)
}
// Default: editable string
return (
<EditableValue
@@ -612,52 +599,52 @@ function DynamicPropertiesPanel({
}
return (
<div className="inspector__section">
<h4>Properties</h4>
<div className="inspector__props">
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
<div className="flex flex-col gap-2">
{/* Always show Type from entry */}
{entry.isA && (
<div className="inspector__prop">
<span className="inspector__prop-label">Type</span>
<span className="inspector__prop-value">{entry.isA}</span>
<div className="flex items-center justify-between text-[13px]">
<span className="shrink-0 text-muted-foreground">Type</span>
<span className="text-right text-secondary-foreground">{entry.isA}</span>
</div>
)}
{/* Dynamic properties from frontmatter */}
{propertyEntries.map(([key, value]) => (
<div key={key} className="inspector__prop">
<span className="inspector__prop-label">
<div key={key} className="group/prop flex items-center justify-between text-[13px]">
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
{key}
{onDeleteProperty && (
<button
className="inspector__prop-delete"
className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100"
onClick={() => onDeleteProperty(key)}
title="Delete property"
>
×
&times;
</button>
)}
</span>
{renderEditableValue(key, value)}
</div>
))}
{/* Always show Modified and Words (read-only) */}
<div className="inspector__prop">
<span className="inspector__prop-label">Modified</span>
<span className="inspector__prop-value">{formatDate(entry.modifiedAt)}</span>
<div className="flex items-center justify-between text-[13px]">
<span className="shrink-0 text-muted-foreground">Modified</span>
<span className="text-right text-secondary-foreground">{formatDate(entry.modifiedAt)}</span>
</div>
<div className="inspector__prop">
<span className="inspector__prop-label">Words</span>
<span className="inspector__prop-value">{wordCount}</span>
<div className="flex items-center justify-between text-[13px]">
<span className="shrink-0 text-muted-foreground">Words</span>
<span className="text-right text-secondary-foreground">{wordCount}</span>
</div>
</div>
{/* Add property UI */}
{showAddDialog ? (
<div className="inspector__add-dialog">
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
<input
className="inspector__edit-input"
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text"
placeholder="Property name"
value={newKey}
@@ -666,21 +653,21 @@ function DynamicPropertiesPanel({
autoFocus
/>
<input
className="inspector__edit-input"
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
type="text"
placeholder="Value"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
onKeyDown={handleAddKeyDown}
/>
<div className="inspector__add-dialog-buttons">
<button onClick={handleAddProperty} disabled={!newKey.trim()}>Add</button>
<button onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</button>
<div className="flex justify-end gap-2">
<Button size="xs" onClick={handleAddProperty} disabled={!newKey.trim()}>Add</Button>
<Button size="xs" variant="outline" onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</Button>
</div>
</div>
) : (
<button
className="inspector__add-prop"
<button
className="mt-3 w-full cursor-pointer rounded-md border border-dashed border-border bg-transparent px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => setShowAddDialog(true)}
disabled={!onAddProperty}
>
@@ -726,20 +713,22 @@ function useBacklinks(
function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; onNavigate: (target: string) => void }) {
return (
<div className="inspector__section">
<h4>Backlinks {backlinks.length > 0 && <span className="inspector__count">{backlinks.length}</span>}</h4>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Backlinks {backlinks.length > 0 && <span className="ml-1 text-[11px] font-normal text-muted-foreground">{backlinks.length}</span>}
</h4>
{backlinks.length === 0 ? (
<p className="inspector__empty">No backlinks</p>
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
) : (
<div className="inspector__backlinks">
<div className="flex flex-col gap-0.5">
{backlinks.map((e) => (
<button
key={e.path}
className="inspector__backlink"
className="flex items-center justify-between gap-2 border-none bg-transparent p-0 py-1 text-left text-[13px] text-primary cursor-pointer hover:opacity-80"
onClick={() => onNavigate(e.title)}
>
<span className="inspector__backlink-title">{e.title}</span>
{e.isA && <span className="inspector__backlink-type">{e.isA}</span>}
<span className="flex-1 truncate">{e.title}</span>
{e.isA && <span className="shrink-0 text-[11px] text-muted-foreground">{e.isA}</span>}
</button>
))}
</div>
@@ -762,24 +751,24 @@ function formatRelativeDate(timestamp: number): string {
function GitHistoryPanel({ commits }: { commits: GitCommit[] }) {
return (
<div className="inspector__section">
<h4>History</h4>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">History</h4>
{commits.length === 0 ? (
<p className="inspector__empty">No revision history</p>
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
) : (
<>
<div className="inspector__commits">
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} className="inspector__commit">
<div className="inspector__commit-top">
<span className="inspector__commit-hash">{c.shortHash}</span>
<span className="inspector__commit-date">{formatRelativeDate(c.date)}</span>
<div key={c.hash} className="border-l-2 border-border pl-2.5">
<div className="mb-0.5 flex items-center justify-between">
<span className="font-mono text-[11px] text-primary">{c.shortHash}</span>
<span className="text-[11px] text-muted-foreground">{formatRelativeDate(c.date)}</span>
</div>
<div className="inspector__commit-msg">{c.message}</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
</div>
))}
</div>
<button className="inspector__view-all" disabled>
<button className="mt-2.5 cursor-not-allowed border-none bg-transparent p-0 py-1 text-xs text-muted-foreground" disabled>
View all revisions
</button>
</>
@@ -788,14 +777,14 @@ function GitHistoryPanel({ commits }: { commits: GitCommit[] }) {
)
}
export function Inspector({
collapsed,
onToggle,
entry,
content,
entries,
allContent,
gitHistory,
export function Inspector({
collapsed,
onToggle,
entry,
content,
entries,
allContent,
gitHistory,
onNavigate,
onUpdateFrontmatter,
onDeleteProperty,
@@ -823,20 +812,27 @@ export function Inspector({
}, [entry, onAddProperty])
return (
<aside className={`inspector ${collapsed ? 'inspector--collapsed' : ''}`}>
<div className="inspector__header" data-tauri-drag-region>
<button className="inspector__toggle" onClick={onToggle}>
<aside className={cn(
"flex flex-col overflow-y-auto border-l border-border bg-sidebar text-sidebar-foreground transition-[width] duration-200",
collapsed && "!w-10 !min-w-10"
)}>
<div className="flex items-center gap-2 border-b border-border px-3 py-3.5" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<button
className="shrink-0 border-none bg-transparent p-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{collapsed ? '\u25C0' : '\u25B6'}
</button>
{!collapsed && <h3>Inspector</h3>}
{!collapsed && <h3 className="m-0 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">Inspector</h3>}
</div>
{!collapsed && (
<div className="inspector__content">
<div className="p-3">
{entry ? (
<>
<DynamicPropertiesPanel
entry={entry}
content={content}
<DynamicPropertiesPanel
entry={entry}
content={content}
frontmatter={frontmatter}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
@@ -848,21 +844,21 @@ export function Inspector({
</>
) : (
<>
<div className="inspector__section">
<h4>Properties</h4>
<p className="inspector__empty">No note selected</p>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
<p className="m-0 text-[13px] text-muted-foreground">No note selected</p>
</div>
<div className="inspector__section">
<h4>Relationships</h4>
<p className="inspector__empty">No relationships</p>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
</div>
<div className="inspector__section">
<h4>Backlinks</h4>
<p className="inspector__empty">No backlinks</p>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backlinks</h4>
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
</div>
<div className="inspector__section">
<h4>History</h4>
<p className="inspector__empty">No revision history</p>
<div className="mb-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">History</h4>
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
</div>
</>
)}

View File

@@ -1,345 +0,0 @@
.note-list {
background: var(--bg-card);
color: var(--text-primary);
display: flex;
flex-direction: column;
overflow-y: auto;
border-right: 1px solid var(--border-primary);
}
.note-list__header {
padding: 14px 16px;
border-bottom: 1px solid var(--border-primary);
display: flex;
align-items: center;
justify-content: space-between;
-webkit-app-region: drag;
app-region: drag;
}
.note-list__header button {
-webkit-app-region: no-drag;
app-region: no-drag;
}
.note-list__header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.note-list__header-right {
display: flex;
align-items: center;
gap: 8px;
}
.note-list__count {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-button);
padding: 2px 8px;
border-radius: 10px;
}
.note-list__add-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: var(--bg-button);
color: var(--text-tertiary);
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.15s;
}
.note-list__add-btn:hover {
background: var(--accent-blue);
color: #fff;
}
/* Search */
.note-list__search {
padding: 8px 12px;
border-bottom: 1px solid var(--border-primary);
}
.note-list__search-input {
width: 100%;
padding: 7px 10px;
background: var(--bg-input);
border: 1px solid var(--border-input);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.note-list__search-input::placeholder {
color: var(--text-faint);
}
.note-list__search-input:focus {
border-color: var(--accent-blue);
}
/* Type filter pills */
.note-list__pills {
display: flex;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-primary);
flex-wrap: wrap;
}
.note-list__pill {
padding: 3px 10px;
font-size: 11px;
border: 1px solid var(--border-primary);
border-radius: 12px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.note-list__pill:hover {
background: var(--bg-button);
color: var(--text-secondary);
}
.note-list__pill-count {
font-size: 10px;
opacity: 0.6;
margin-left: 2px;
}
.note-list__pill--active {
background: var(--accent-blue-bg);
color: var(--accent-blue);
border-color: var(--accent-blue-bg);
}
.note-list__pill--active .note-list__pill-count {
opacity: 0.8;
}
.note-list__items {
flex: 1;
overflow-y: auto;
}
.note-list__empty {
padding: 32px 16px;
color: var(--text-faint);
font-size: 13px;
text-align: center;
}
/* Note items */
.note-list__item {
padding: 10px 16px;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background 0.1s;
}
.note-list__item:hover {
background: var(--bg-hover-subtle);
}
.note-list__item-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.note-list__title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.note-list__date {
font-size: 11px;
color: var(--text-faint);
flex-shrink: 0;
white-space: nowrap;
}
.note-list__snippet {
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.note-list__meta {
display: flex;
gap: 6px;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 5px;
}
/* Type badges with distinct colors */
.note-list__type {
padding: 1px 7px;
border-radius: 3px;
font-weight: 500;
font-size: 10px;
letter-spacing: 0.02em;
}
.note-list__type--project {
background: rgba(74, 158, 255, 0.15);
color: #4a9eff;
}
.note-list__type--responsibility {
background: rgba(156, 114, 255, 0.15);
color: #9c72ff;
}
.note-list__type--procedure {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.note-list__type--experiment {
background: rgba(0, 200, 150, 0.15);
color: #00c896;
}
.note-list__type--note {
background: rgba(200, 200, 200, 0.1);
color: #999;
}
.note-list__type--person {
background: rgba(255, 100, 130, 0.15);
color: #ff6482;
}
.note-list__type--event {
background: rgba(255, 200, 50, 0.15);
color: #e6b800;
}
.note-list__type--topic {
background: rgba(100, 220, 200, 0.15);
color: #64dcc8;
}
.note-list__status {
color: var(--accent-green);
font-size: 10px;
}
/* Git status indicators */
.note-list__git-status {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
margin-right: 6px;
flex-shrink: 0;
font-family: 'SF Mono', 'Menlo', monospace;
}
.note-list__git-status--modified {
background: rgba(255, 152, 0, 0.15);
color: #ff9800;
}
.note-list__git-status--added {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.note-list__git-status--deleted {
background: rgba(244, 67, 54, 0.15);
color: #f44336;
}
.note-list__git-status--untracked {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.note-list__git-status--renamed {
background: rgba(33, 150, 243, 0.15);
color: #2196f3;
}
/* Selected & pinned states */
.note-list__item--selected {
background: var(--bg-selected);
border-left: 3px solid var(--accent-blue);
padding-left: 13px;
}
.note-list__item--selected:hover {
background: var(--bg-selected);
}
.note-list__item--pinned {
background: var(--bg-hover-subtle);
border-left: 3px solid var(--accent-green);
padding-left: 13px;
}
/* Relationship groups */
.note-list__group {
border-top: 1px solid var(--border-subtle);
}
.note-list__group-header {
padding: 10px 16px 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.note-list__group-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
}
.note-list__group-count {
font-size: 10px;
color: var(--text-faint);
background: var(--bg-button);
padding: 1px 6px;
border-radius: 8px;
}

View File

@@ -19,6 +19,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
},
{
@@ -33,6 +34,7 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 847,
},
{
@@ -47,6 +49,7 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 320,
},
{
@@ -61,6 +64,7 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 512,
},
{
@@ -75,6 +79,7 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
},
]

View File

@@ -1,6 +1,10 @@
import { useState } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
import './NoteList.css'
import { cn } from '@/lib/utils'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Plus } from 'lucide-react'
interface NoteListProps {
entries: VaultEntry[]
@@ -20,11 +24,8 @@ interface RelationshipGroup {
/** Extract first ~80 chars of content after the title heading */
function getSnippet(content: string | undefined): string {
if (!content) return ''
// Remove frontmatter
const withoutFm = content.replace(/^---[\s\S]*?---\s*/, '')
// Remove the first heading
const withoutH1 = withoutFm.replace(/^#\s+.*\n+/, '')
// Clean markdown syntax and collapse whitespace
const clean = withoutH1
.replace(/[#*_`\[\]]/g, '')
.replace(/\n+/g, ' ')
@@ -32,13 +33,11 @@ function getSnippet(content: string | undefined): string {
return clean.slice(0, 80) + (clean.length > 80 ? '...' : '')
}
/** Format a relative date string */
function relativeDate(ts: number | null): string {
if (!ts) return ''
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 0) {
// Future date - just show the date
const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
@@ -50,15 +49,11 @@ function relativeDate(ts: number | null): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
/** Get the best date to display for an entry (prefer modifiedAt, fallback to createdAt) */
function getDisplayDate(entry: VaultEntry): number | null {
// Prefer modifiedAt (most recent activity), but fall back to createdAt
return entry.modifiedAt ?? entry.createdAt
}
/** Check if a wikilink array (e.g. belongsTo) references a given entry by path stem */
function refsMatch(refs: string[], entry: VaultEntry): boolean {
// Extract the path stem: /Users/luca/Laputa/project/26q1-laputa-app.md → project/26q1-laputa-app
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
return refs.some((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
@@ -66,7 +61,6 @@ function refsMatch(refs: string[], entry: VaultEntry): boolean {
})
}
/** Resolve wikilink references to actual VaultEntry objects */
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
return refs
.map((ref) => {
@@ -86,12 +80,10 @@ function sortByModified(a: VaultEntry, b: VaultEntry): number {
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
/** Build relationship groups for an entity view */
function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]): RelationshipGroup[] {
const groups: RelationshipGroup[] = []
const seen = new Set<string>([entity.path])
// 1. Children: items whose belongsTo references this entity (non-events)
const children = allEntries
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
.sort(sortByModified)
@@ -100,7 +92,6 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
children.forEach((e) => seen.add(e.path))
}
// 2. Events that reference this entity (via belongsTo or relatedTo)
const events = allEntries
.filter(
(e) =>
@@ -114,7 +105,6 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
events.forEach((e) => seen.add(e.path))
}
// 3. Referenced By: non-event items whose relatedTo references this entity
const referencedBy = allEntries
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
.sort(sortByModified)
@@ -123,14 +113,12 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
referencedBy.forEach((e) => seen.add(e.path))
}
// 4. Belongs To: resolve this entity's own belongsTo references
const belongsTo = resolveRefs(entity.belongsTo, allEntries).filter((e) => !seen.has(e.path))
if (belongsTo.length > 0) {
groups.push({ label: 'Belongs To', entries: belongsTo })
belongsTo.forEach((e) => seen.add(e.path))
}
// 5. Related To: resolve this entity's own relatedTo references
const relatedTo = resolveRefs(entity.relatedTo, allEntries).filter((e) => !seen.has(e.path))
if (relatedTo.length > 0) {
groups.push({ label: 'Related To', entries: relatedTo })
@@ -156,17 +144,14 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection, modif
return entries.filter((e) => modifiedPaths.has(e.path))
}
case 'favorites':
// TODO: Implement favorites (needs a "favorite" field in frontmatter)
return []
case 'trash':
// TODO: Implement trash (needs deleted/archived status)
return []
}
break
case 'sectionGroup':
return entries.filter((e) => e.isA === selection.type)
case 'entity':
// Handled separately via buildRelationshipGroups
return []
case 'topic': {
const topic = selection.entry
@@ -186,20 +171,33 @@ const TYPE_PILLS = [
{ label: 'Responsibilities', type: 'Responsibility' },
] as const
const TYPE_COLORS: Record<string, string> = {
project: 'bg-[rgba(74,158,255,0.15)] text-[#4a9eff]',
responsibility: 'bg-[rgba(156,114,255,0.15)] text-[#9c72ff]',
procedure: 'bg-[rgba(255,152,0,0.15)] text-[#ff9800]',
experiment: 'bg-[rgba(0,200,150,0.15)] text-[#00c896]',
note: 'bg-[rgba(200,200,200,0.1)] text-[#999]',
person: 'bg-[rgba(255,100,130,0.15)] text-[#ff6482]',
event: 'bg-[rgba(255,200,50,0.15)] text-[#e6b800]',
topic: 'bg-[rgba(100,220,200,0.15)] text-[#64dcc8]',
}
const GIT_STATUS_COLORS: Record<string, string> = {
modified: 'bg-[rgba(255,152,0,0.15)] text-[#ff9800]',
added: 'bg-[rgba(76,175,80,0.15)] text-[#4caf50]',
deleted: 'bg-[rgba(244,67,54,0.15)] text-[#f44336]',
untracked: 'bg-[rgba(76,175,80,0.15)] text-[#4caf50]',
renamed: 'bg-[rgba(33,150,243,0.15)] text-[#2196f3]',
}
export function NoteList({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<string | null>(null)
const isEntityView = selection.kind === 'entity'
// Entity view: build relationship groups
const entityGroups = isEntityView
? buildRelationshipGroups(selection.entry, entries)
: []
const entityGroups = isEntityView ? buildRelationshipGroups(selection.entry, entries) : []
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
// Build a lookup for modified file status
const modifiedStatusMap = new Map<string, string>()
if (modifiedFiles) {
for (const f of modifiedFiles) {
@@ -207,14 +205,11 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
}
}
// Non-entity view: flat filtered list
const filtered = isEntityView ? [] : filterEntries(entries, selection, modifiedFiles)
const sorted = isEntityView ? [] : [...filtered].sort(sortByModified)
// Search filter
const query = search.trim().toLowerCase()
// For entity view: filter within groups
const searchedGroups = query
? entityGroups
.map((g) => ({
@@ -224,26 +219,22 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
.filter((g) => g.entries.length > 0)
: entityGroups
// For flat view
const searched = query
? sorted.filter((e) => e.title.toLowerCase().includes(query))
: sorted
// Compute per-type counts from the searched results (before type filter)
const typeCounts = new Map<string | null, number>()
typeCounts.set(null, searched.length) // "All" count
typeCounts.set(null, searched.length)
for (const entry of searched) {
if (entry.isA) {
typeCounts.set(entry.isA, (typeCounts.get(entry.isA) ?? 0) + 1)
}
}
// Type filter pills (flat view only)
const displayed = typeFilter
? searched.filter((e) => e.isA === typeFilter)
: searched
// Total count for header
const totalCount = isEntityView
? searchedGroups.reduce((sum, g) => sum + g.entries.length, 0)
: displayed.length
@@ -253,53 +244,80 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
return (
<div
key={entry.path}
className={`note-list__item${isPinned ? ' note-list__item--pinned' : ''}${
selectedNote?.path === entry.path ? ' note-list__item--selected' : ''
}`}
className={cn(
"cursor-pointer border-b border-[var(--border-subtle)] px-4 py-2.5 transition-colors",
isPinned && "border-l-[3px] border-l-[var(--accent-green)] bg-muted pl-[13px]",
selectedNote?.path === entry.path && !isPinned && "border-l-[3px] border-l-primary bg-[var(--bg-selected)] pl-[13px]",
!isPinned && selectedNote?.path !== entry.path && "hover:bg-muted"
)}
onClick={() => onSelectNote(entry)}
>
<div className="note-list__item-top">
<div className="note-list__title">
<div className="flex items-baseline justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center truncate text-[13px] font-semibold text-foreground">
{isChangesView && gitStatus && (
<span className={`note-list__git-status note-list__git-status--${gitStatus}`}>
<span className={cn(
"mr-1.5 inline-flex size-[18px] shrink-0 items-center justify-center rounded-sm font-mono text-[10px] font-bold",
GIT_STATUS_COLORS[gitStatus]
)}>
{gitStatus === 'modified' ? 'M' : gitStatus === 'added' ? 'A' : gitStatus === 'deleted' ? 'D' : gitStatus === 'untracked' ? '?' : 'R'}
</span>
)}
{entry.title}
<span className="truncate">{entry.title}</span>
</div>
<span className="note-list__date">{relativeDate(getDisplayDate(entry))}</span>
<span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
{relativeDate(getDisplayDate(entry))}
</span>
</div>
<div className="note-list__snippet">{getSnippet(allContent[entry.path])}</div>
<div className="note-list__meta">
{entry.isA && <span className={`note-list__type note-list__type--${entry.isA.toLowerCase()}`}>{entry.isA}</span>}
{entry.status && <span className="note-list__status">{entry.status}</span>}
<div className="mt-0.5 truncate text-xs leading-relaxed text-muted-foreground">
{getSnippet(allContent[entry.path])}
</div>
<div className="mt-1 flex gap-1.5 text-[11px]">
{entry.isA && (
<span className={cn(
"rounded-sm px-1.5 py-px text-[10px] font-medium tracking-wide",
TYPE_COLORS[entry.isA.toLowerCase()] ?? "bg-secondary text-secondary-foreground"
)}>
{entry.isA}
</span>
)}
{entry.status && (
<span className="text-[10px] text-[var(--accent-green)]">
{entry.status}
</span>
)}
</div>
</div>
)
}
return (
<div className="note-list">
<div className="note-list__header" data-tauri-drag-region>
<h3>{isEntityView ? selection.entry.title : 'Notes'}</h3>
<div className="note-list__header-right">
<span className="note-list__count">{totalCount}</span>
<button className="note-list__add-btn" onClick={onCreateNote} title="Create new note">
+
</button>
<div className="flex flex-col overflow-y-auto border-r border-border bg-card text-foreground">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3.5" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<h3 className="m-0 min-w-0 flex-1 truncate text-sm font-semibold">
{isEntityView ? selection.entry.title : 'Notes'}
</h3>
<div className="flex items-center gap-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<Badge variant="secondary" className="text-[11px]">{totalCount}</Badge>
<Button variant="secondary" size="icon-xs" onClick={onCreateNote} title="Create new note">
<Plus className="size-4" />
</Button>
</div>
</div>
<div className="note-list__search">
<input
type="text"
className="note-list__search-input"
{/* Search */}
<div className="border-b border-border px-3 py-2">
<Input
placeholder="Search notes..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-[13px]"
/>
</div>
{/* Type filter pills */}
{!isEntityView && (
<div className="note-list__pills">
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
{TYPE_PILLS.filter(({ type }) => {
const count = typeCounts.get(type) ?? 0
return type === null || count > 0
@@ -308,29 +326,38 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
return (
<button
key={label}
className={`note-list__pill${typeFilter === type ? ' note-list__pill--active' : ''}`}
className={cn(
"whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] transition-colors",
typeFilter === type
? "border-primary/20 bg-primary/10 text-primary"
: "border-border bg-transparent text-muted-foreground hover:bg-secondary hover:text-secondary-foreground"
)}
onClick={() => setTypeFilter(type)}
>
{label} <span className="note-list__pill-count">{count}</span>
{label} <span className="ml-0.5 opacity-60 text-[10px]">{count}</span>
</button>
)
})}
</div>
)}
<div className="note-list__items">
{/* Items */}
<div className="flex-1 overflow-y-auto">
{isEntityView ? (
<>
{renderItem(selection.entry, true)}
{searchedGroups.length === 0 ? (
<div className="note-list__empty">
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
{query ? 'No matching items' : 'No related items'}
</div>
) : (
searchedGroups.map((group) => (
<div key={group.label} className="note-list__group">
<div className="note-list__group-header">
<span className="note-list__group-label">{group.label}</span>
<span className="note-list__group-count">{group.entries.length}</span>
<div key={group.label} className="border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between px-4 py-2.5 pt-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</span>
<Badge variant="secondary" className="text-[10px]">{group.entries.length}</Badge>
</div>
{group.entries.map((entry) => renderItem(entry))}
</div>
@@ -339,7 +366,7 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
</>
) : (
displayed.length === 0 ? (
<div className="note-list__empty">No notes found</div>
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
) : (
displayed.map((entry) => renderItem(entry))
)

View File

@@ -1,88 +0,0 @@
.palette__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--shadow-dialog);
display: flex;
justify-content: center;
padding-top: 15vh;
z-index: 1000;
}
.palette {
width: 500px;
max-width: 90vw;
max-height: 400px;
background: var(--bg-dialog);
border: 1px solid var(--border-dialog);
border-radius: 10px;
box-shadow: 0 8px 32px var(--shadow-dialog);
display: flex;
flex-direction: column;
overflow: hidden;
align-self: flex-start;
}
.palette__input {
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-primary);
color: var(--text-primary);
font-size: 15px;
outline: none;
}
.palette__input::placeholder {
color: var(--text-faint);
}
.palette__results {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.palette__empty {
padding: 16px;
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
.palette__item {
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: background 0.1s;
}
.palette__item:hover,
.palette__item--selected {
background: var(--bg-button);
}
.palette__item--selected {
background: var(--bg-hover);
}
.palette__item-title {
font-size: 14px;
color: var(--text-primary);
}
.palette__item-type {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-button);
padding: 2px 8px;
border-radius: 4px;
}
.palette__item--selected .palette__item-type {
background: var(--border-dialog);
}

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import type { VaultEntry } from '../types'
import './QuickOpenPalette.css'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
interface QuickOpenPaletteProps {
open: boolean
@@ -19,9 +20,7 @@ function fuzzyMatch(query: string, target: string): { match: boolean; score: num
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
// Bonus for consecutive matches
if (ti === lastMatchIndex + 1) score += 2
// Bonus for matching at start or after separator
if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-') score += 3
score += 1
lastMatchIndex = ti
@@ -48,7 +47,6 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
const results = useMemo(() => {
if (!query.trim()) {
// Show all entries sorted by most recently modified
return [...entries].sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)).slice(0, 20)
}
return entries
@@ -59,19 +57,16 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
.map((r) => r.entry)
}, [entries, query])
// Keep selectedIndex in bounds
useEffect(() => {
setSelectedIndex(0)
}, [query])
// Scroll selected item into view
useEffect(() => {
if (!listRef.current) return
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
selected?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
// Close on Escape, navigate with arrows, select with Enter
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
@@ -99,32 +94,47 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
if (!open) return null
return (
<div className="palette__overlay" onClick={onClose}>
<div className="palette" onClick={(e) => e.stopPropagation()}>
<div
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>
<div
className="flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
onClick={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
className="palette__input"
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
type="text"
placeholder="Search notes..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="palette__results" ref={listRef}>
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
{results.length === 0 ? (
<div className="palette__empty">No matching notes</div>
<div className="px-4 py-4 text-center text-[13px] text-muted-foreground">
No matching notes
</div>
) : (
results.map((entry, i) => (
<div
key={entry.path}
className={`palette__item${i === selectedIndex ? ' palette__item--selected' : ''}`}
className={cn(
"flex cursor-pointer items-center justify-between px-4 py-2 transition-colors",
i === selectedIndex ? "bg-accent" : "hover:bg-secondary"
)}
onClick={() => {
onSelect(entry)
onClose()
}}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="palette__item-title">{entry.title}</span>
{entry.isA && <span className="palette__item-type">{entry.isA}</span>}
<span className="text-sm text-foreground">{entry.title}</span>
{entry.isA && (
<Badge variant="secondary" className="text-[11px]">
{entry.isA}
</Badge>
)}
</div>
))
)}

View File

@@ -1,11 +0,0 @@
.resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.15s;
flex-shrink: 0;
}
.resize-handle:hover {
background: var(--border-dialog);
}

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'
import './ResizeHandle.css'
interface ResizeHandleProps {
onResize: (delta: number) => void
@@ -44,5 +43,10 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
}
}, [onResize])
return <div className="resize-handle" onMouseDown={handleMouseDown} />
return (
<div
className="w-1 shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border-dialog)]"
onMouseDown={handleMouseDown}
/>
)
}

View File

@@ -1,278 +0,0 @@
.sidebar {
background: var(--bg-sidebar);
color: var(--text-primary);
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
}
.sidebar__header {
padding: 12px 16px;
padding-left: 78px; /* space for macOS traffic light buttons */
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
-webkit-app-region: drag;
app-region: drag;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar__header h2 {
margin: 0;
font-size: 17px;
font-weight: 700;
color: var(--text-heading);
letter-spacing: -0.01em;
}
.sidebar__header button {
-webkit-app-region: no-drag;
app-region: no-drag;
}
.sidebar__theme-toggle {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 16px;
padding: 4px 6px;
border-radius: 6px;
line-height: 1;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar__theme-toggle:hover {
background: var(--bg-hover);
color: var(--text-heading);
}
.sidebar__nav {
padding: 8px 0;
flex: 1;
overflow-y: auto;
}
/* Filters */
.sidebar__filters {
padding: 4px 0;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-primary);
}
.sidebar__filter-item {
padding: 6px 16px;
cursor: pointer;
font-size: 13px;
border-radius: 6px;
margin: 1px 6px;
transition: all 0.12s;
color: var(--text-secondary);
}
.sidebar__filter-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar__filter-item--active {
background: var(--accent-blue-bg);
color: var(--accent-blue);
font-weight: 500;
}
.sidebar__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 6px;
border-radius: 9px;
background: var(--accent-orange, #ff9800);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 1;
}
/* Section Groups */
.sidebar__section {
margin-bottom: 4px;
}
.sidebar__section-header {
display: flex;
align-items: center;
padding: 6px 8px;
margin: 0 4px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--text-tertiary);
user-select: none;
}
.sidebar__section-header:hover {
background: var(--bg-button);
color: var(--text-secondary);
}
.sidebar__section-header--active {
background: var(--bg-button);
color: var(--text-heading);
}
.sidebar__collapse-btn {
background: none;
border: none;
color: inherit;
font-size: 10px;
cursor: pointer;
padding: 0 4px 0 0;
line-height: 1;
width: 16px;
flex-shrink: 0;
}
.sidebar__section-label {
flex: 1;
}
.sidebar__section-count {
font-size: 10px;
color: var(--text-muted);
margin-right: 4px;
}
.sidebar__add-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
border-radius: 3px;
opacity: 0;
transition: opacity 0.15s;
}
.sidebar__section-header:hover .sidebar__add-btn {
opacity: 1;
}
.sidebar__add-btn:hover {
color: var(--text-heading);
background: var(--bg-hover);
}
/* Section items (entity list) */
.sidebar__section-items {
padding: 2px 0;
}
.sidebar__item {
padding: 5px 16px 5px 28px;
cursor: pointer;
font-size: 13px;
border-radius: 6px;
margin: 1px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
transition: all 0.12s;
}
.sidebar__item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar__item--active {
background: var(--accent-blue-bg);
color: var(--accent-blue);
font-weight: 500;
}
/* Topics */
.sidebar__topics {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-primary);
}
.sidebar__topics-header {
padding: 6px 16px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--text-tertiary);
user-select: none;
}
.sidebar__topic-item {
padding: 5px 16px 5px 28px;
cursor: pointer;
font-size: 13px;
border-radius: 6px;
margin: 1px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
transition: all 0.12s;
}
.sidebar__topic-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar__topic-item--active {
background: var(--accent-blue-bg);
color: var(--accent-blue);
font-weight: 500;
}
/* Commit button */
.sidebar__commit {
padding: 10px 12px;
border-top: 1px solid var(--border-primary);
flex-shrink: 0;
}
.sidebar__commit-btn {
width: 100%;
padding: 7px 12px;
font-size: 12px;
font-weight: 500;
border: none;
border-radius: 6px;
background: var(--accent-blue, #2196f3);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: opacity 0.15s;
}
.sidebar__commit-btn:hover {
opacity: 0.9;
}
.sidebar__commit-btn .sidebar__badge {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}

View File

@@ -16,6 +16,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
},
{
@@ -30,6 +31,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 512,
},
{
@@ -44,6 +46,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
},
{
@@ -58,6 +61,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca',
cadence: 'Weekly',
modifiedAt: 1700000000,
createdAt: null,
fileSize: 128,
},
{
@@ -72,6 +76,7 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
},
{
@@ -86,6 +91,7 @@ const mockEntries: VaultEntry[] = [
owner: null,
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 180,
},
]

View File

@@ -1,6 +1,9 @@
import { useState, useEffect } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import './Sidebar.css'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Sun, Moon, Plus, ChevronRight, ChevronDown, GitCommitHorizontal } from 'lucide-react'
interface SidebarProps {
entries: VaultEntry[]
@@ -38,11 +41,15 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
})
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
try {
localStorage.setItem('laputa-theme', theme)
} catch {
// localStorage unavailable (e.g. in tests)
// localStorage unavailable
}
}, [theme])
@@ -64,79 +71,98 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
}
return (
<aside className="sidebar">
<div className="sidebar__header" data-tauri-drag-region>
<h2>Laputa</h2>
<button
className="sidebar__theme-toggle"
<aside className="flex h-full flex-col overflow-y-auto bg-sidebar text-sidebar-foreground">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 pl-[78px]" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<h2 className="m-0 text-[17px] font-bold tracking-tight text-foreground">Laputa</h2>
<Button
variant="ghost"
size="icon-xs"
onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
className="text-muted-foreground hover:text-foreground"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{theme === 'dark' ? '\u2600' : '\u263D'}
</button>
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
</div>
<nav className="sidebar__nav">
<div className="sidebar__filters">
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-2">
{/* Filters */}
<div className="mb-2 border-b border-border py-1">
{FILTERS.map(({ label, filter }) => (
<div
key={filter}
className={`sidebar__filter-item${
isActive({ kind: 'filter', filter }) ? ' sidebar__filter-item--active' : ''
}`}
className={cn(
"mx-1.5 my-px cursor-pointer rounded-md px-4 py-1.5 text-[13px] transition-colors",
isActive({ kind: 'filter', filter })
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
onClick={() => onSelect({ kind: 'filter', filter })}
>
{label}
{filter === 'changes' && modifiedCount > 0 && (
<span className="sidebar__badge">{modifiedCount}</span>
)}
<span className="flex items-center gap-1.5">
{label}
{filter === 'changes' && modifiedCount > 0 && (
<Badge className="h-[18px] min-w-[18px] bg-[var(--accent-orange)] px-1 text-[10px] text-white">
{modifiedCount}
</Badge>
)}
</span>
</div>
))}
</div>
{/* Section Groups */}
{SECTION_GROUPS.map(({ label, type }) => {
const items = entries.filter((e) => e.isA === type)
const isCollapsed = collapsed[type] ?? false
return (
<div key={type} className="sidebar__section">
<div key={type} className="mb-1">
<div
className={`sidebar__section-header${
isActive({ kind: 'sectionGroup', type }) ? ' sidebar__section-header--active' : ''
}`}
className={cn(
"mx-1 flex cursor-pointer select-none items-center rounded px-2 py-1.5 text-[11px] font-semibold tracking-wide",
isActive({ kind: 'sectionGroup', type })
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary hover:text-secondary-foreground"
)}
onClick={() => onSelect({ kind: 'sectionGroup', type })}
>
<button
className="sidebar__collapse-btn"
className="mr-1 flex shrink-0 items-center bg-transparent border-none text-inherit cursor-pointer p-0"
onClick={(e) => {
e.stopPropagation()
toggleSection(type)
}}
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
>
{isCollapsed ? '\u25B8' : '\u25BE'}
{isCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
</button>
<span className="sidebar__section-label">{label}</span>
<span className="sidebar__section-count">{items.length}</span>
<span className="flex-1">{label}</span>
<span className="text-[10px] text-muted-foreground mr-1">{items.length}</span>
<button
className="sidebar__add-btn"
className="flex items-center bg-transparent border-none text-muted-foreground cursor-pointer p-0 opacity-0 transition-opacity group-hover/section:opacity-100 hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
// TODO: Wire up create new entity
}}
aria-label={`Add ${type}`}
>
+
<Plus className="size-3.5" />
</button>
</div>
{!isCollapsed && (
<div className="sidebar__section-items">
<div className="py-0.5">
{items.map((entry) => (
<div
key={entry.path}
className={`sidebar__item${
isActive({ kind: 'entity', entry }) ? ' sidebar__item--active' : ''
}`}
className={cn(
"mx-1.5 my-px cursor-pointer truncate rounded-md py-1.5 pl-7 pr-4 text-[13px] transition-colors",
isActive({ kind: 'entity', entry })
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
onClick={() => {
onSelect({ kind: 'entity', entry })
onSelectNote?.(entry)
@@ -151,18 +177,24 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
)
})}
{/* Topics */}
{(() => {
const topics = entries.filter((e) => e.isA === 'Topic')
if (topics.length === 0) return null
return (
<div className="sidebar__topics">
<div className="sidebar__topics-header">TOPICS</div>
<div className="mt-2 border-t border-border pt-2">
<div className="px-4 py-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground select-none">
TOPICS
</div>
{topics.map((entry) => (
<div
key={entry.path}
className={`sidebar__topic-item${
isActive({ kind: 'topic', entry }) ? ' sidebar__topic-item--active' : ''
}`}
className={cn(
"mx-1.5 my-px cursor-pointer truncate rounded-md py-1.5 pl-7 pr-4 text-[13px] transition-colors",
isActive({ kind: 'topic', entry })
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
onClick={() => {
onSelect({ kind: 'topic', entry })
onSelectNote?.(entry)
@@ -175,12 +207,17 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
)
})()}
</nav>
{/* Commit button */}
{modifiedCount > 0 && onCommitPush && (
<div className="sidebar__commit">
<button className="sidebar__commit-btn" onClick={onCommitPush}>
<div className="shrink-0 border-t border-border p-3">
<Button className="w-full gap-1.5" size="sm" onClick={onCommitPush}>
<GitCommitHorizontal className="size-3.5" />
Commit & Push
<span className="sidebar__badge">{modifiedCount}</span>
</button>
<Badge className="ml-1 bg-white/25 text-white text-[10px] h-[18px] min-w-[18px] px-1">
{modifiedCount}
</Badge>
</Button>
</div>
)}
</aside>

View File

@@ -1,25 +0,0 @@
.toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-button);
color: var(--text-primary);
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
box-shadow: 0 4px 16px var(--shadow-dialog);
z-index: 1100;
animation: toast-in 0.2s ease-out;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import './Toast.css'
import { cn } from '@/lib/utils'
interface ToastProps {
message: string | null
@@ -16,7 +16,12 @@ export function Toast({ message, onDismiss }: ToastProps) {
if (!message) return null
return (
<div className="toast">
<div className={cn(
"fixed bottom-8 left-1/2 -translate-x-1/2 z-[1100]",
"bg-secondary text-foreground px-5 py-2 rounded-lg text-[13px]",
"shadow-[0_4px_16px_var(--shadow-dialog)]",
"animate-in slide-in-from-bottom-2 fade-in duration-200"
)}>
{message}
</div>
)

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,188 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,89 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,72 +1,58 @@
* {
box-sizing: border-box;
}
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* ============================================================
Theme Variables
shadcn CSS variables + app-specific variables for both modes.
Dark mode is default (html.dark), light mode = no .dark class.
============================================================ */
:root {
/* --- Font defaults --- */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
font-weight: 400;
font-size: 14px;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Dark theme (default) */
color-scheme: dark;
/* --- shadcn theme variables (light mode) --- */
--radius: 0.5rem;
--background: #FFFFFF;
--foreground: #37352F;
--card: #FFFFFF;
--card-foreground: #37352F;
--popover: #FFFFFF;
--popover-foreground: #37352F;
--primary: #2383E2;
--primary-foreground: #FFFFFF;
--secondary: #EBEBEA;
--secondary-foreground: #37352F;
--muted: #F0F0EF;
--muted-foreground: #787774;
--accent: #EBEBEA;
--accent-foreground: #37352F;
--destructive: #E03E3E;
--destructive-foreground: #FFFFFF;
--border: #E9E9E7;
--input: #E9E9E7;
--ring: #2383E2;
--sidebar: #F7F6F3;
--sidebar-foreground: #37352F;
--sidebar-primary: #2383E2;
--sidebar-primary-foreground: #FFFFFF;
--sidebar-accent: #EBEBEA;
--sidebar-accent-foreground: #37352F;
--sidebar-border: #E9E9E7;
--sidebar-ring: #2383E2;
/* Backgrounds */
--bg-primary: #0f0f1a;
--bg-sidebar: #1a1a2e;
--bg-card: #16162a;
--bg-hover: #252545;
--bg-hover-subtle: #1e1e3a;
--bg-selected: #24244a;
--bg-input: #1e1e3a;
--bg-button: #2a2a4a;
--bg-dialog: #1e1e3a;
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #aaa;
--text-tertiary: #888;
--text-muted: #666;
--text-faint: #555;
--text-heading: #ffffff;
/* Accents */
--accent-blue: #4a9eff;
--accent-blue-bg: #4a9eff18;
--accent-blue-hover: #3a8eef;
--accent-green: #4caf50;
--accent-orange: #ff9800;
--accent-red: #f44336;
--accent-purple: #9c72ff;
/* Borders */
--border-primary: #2a2a4a;
--border-subtle: rgba(42, 42, 74, 0.5);
--border-input: #2a2a4a;
--border-dialog: #4a4a6a;
/* Links */
--link-color: #7eb8da;
--link-hover: #a8d8f0;
/* Shadows */
--shadow-overlay: rgba(0, 0, 0, 0.6);
--shadow-dialog: rgba(0, 0, 0, 0.5);
color: var(--text-primary);
background-color: var(--bg-primary);
}
[data-theme="light"] {
/* --- App-specific variables (light mode) --- */
color-scheme: light;
/* Backgrounds */
--bg-primary: #FFFFFF;
--bg-sidebar: #F7F6F3;
--bg-card: #FFFFFF;
@@ -76,16 +62,12 @@
--bg-input: #FFFFFF;
--bg-button: #EBEBEA;
--bg-dialog: #FFFFFF;
/* Text */
--text-primary: #37352F;
--text-secondary: #787774;
--text-tertiary: #787774;
--text-muted: #B4B4B4;
--text-faint: #B4B4B4;
--text-heading: #37352F;
/* Accents */
--accent-blue: #2383E2;
--accent-blue-bg: #2383E218;
--accent-blue-hover: #1a6bb5;
@@ -93,29 +75,129 @@
--accent-orange: #D9730D;
--accent-red: #E03E3E;
--accent-purple: #9065B0;
/* Borders */
--border-primary: #E9E9E7;
--border-subtle: #E9E9E7;
--border-input: #E9E9E7;
--border-dialog: #E9E9E7;
/* Links */
--link-color: #2383E2;
--link-hover: #1a6bb5;
/* Shadows */
--shadow-overlay: rgba(0, 0, 0, 0.3);
--shadow-dialog: rgba(0, 0, 0, 0.15);
}
body {
margin: 0;
padding: 0;
overflow: hidden;
.dark {
/* --- shadcn theme variables (dark mode) --- */
--background: #0f0f1a;
--foreground: #e0e0e0;
--card: #16162a;
--card-foreground: #e0e0e0;
--popover: #1e1e3a;
--popover-foreground: #e0e0e0;
--primary: #4a9eff;
--primary-foreground: #ffffff;
--secondary: #2a2a4a;
--secondary-foreground: #e0e0e0;
--muted: #1e1e3a;
--muted-foreground: #888;
--accent: #252545;
--accent-foreground: #ffffff;
--destructive: #f44336;
--destructive-foreground: #ffffff;
--border: #2a2a4a;
--input: #2a2a4a;
--ring: #4a9eff;
--sidebar: #1a1a2e;
--sidebar-foreground: #e0e0e0;
--sidebar-primary: #4a9eff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #252545;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #2a2a4a;
--sidebar-ring: #4a9eff;
/* --- App-specific variables (dark mode) --- */
color-scheme: dark;
--bg-primary: #0f0f1a;
--bg-sidebar: #1a1a2e;
--bg-card: #16162a;
--bg-hover: #252545;
--bg-hover-subtle: #1e1e3a;
--bg-selected: #24244a;
--bg-input: #1e1e3a;
--bg-button: #2a2a4a;
--bg-dialog: #1e1e3a;
--text-primary: #e0e0e0;
--text-secondary: #aaa;
--text-tertiary: #888;
--text-muted: #666;
--text-faint: #555;
--text-heading: #ffffff;
--accent-blue: #4a9eff;
--accent-blue-bg: #4a9eff18;
--accent-blue-hover: #3a8eef;
--accent-green: #4caf50;
--accent-orange: #ff9800;
--accent-red: #f44336;
--accent-purple: #9c72ff;
--border-primary: #2a2a4a;
--border-subtle: rgba(42, 42, 74, 0.5);
--border-input: #2a2a4a;
--border-dialog: #4a4a6a;
--link-color: #7eb8da;
--link-hover: #a8d8f0;
--shadow-overlay: rgba(0, 0, 0, 0.6);
--shadow-dialog: rgba(0, 0, 0, 0.5);
}
#root {
width: 100vw;
height: 100vh;
/* --- Tailwind v4 theme inline: register colors + radii --- */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* --- Base layer --- */
@layer base {
* {
@apply border-border;
box-sizing: border-box;
}
body {
@apply bg-background text-foreground;
margin: 0;
padding: 0;
overflow: hidden;
}
#root {
width: 100vw;
height: 100vh;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,10 +1,21 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
import App from './App.tsx'
// Set initial theme class on <html> before render
const savedTheme = (() => {
try { return localStorage.getItem('laputa-theme') } catch { return null }
})()
if (savedTheme !== 'light') {
document.documentElement.classList.add('dark')
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<TooltipProvider>
<App />
</TooltipProvider>
</StrictMode>,
)

View File

@@ -7,6 +7,10 @@
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",

View File

@@ -3,5 +3,11 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -1,10 +1,18 @@
/// <reference types="vitest/config" />
import path from 'path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
// Prevent vite from obscuring Rust errors
clearScreen: false,