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/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2", "@blocknote/react": "^0.46.2",
"@mantine/core": "^8.3.14", "@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", "@tauri-apps/api": "^2.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"katex": "^0.16.28", "katex": "^0.16.28",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.564.0",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "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": { "devDependencies": {
"@eslint/js": "^9.39.1", "@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 { .app {
display: flex; display: flex;
height: 100vh; height: 100vh;

View File

@@ -379,6 +379,7 @@ function App() {
owner: null, owner: null,
cadence: null, cadence: null,
modifiedAt: now, modifiedAt: now,
createdAt: now,
fileSize: 0, 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 { 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 { interface CommitDialogProps {
open: boolean open: boolean
@@ -15,13 +17,10 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMessage('') setMessage('')
// Focus with a small delay to ensure the dialog is rendered
setTimeout(() => inputRef.current?.focus(), 50) setTimeout(() => inputRef.current?.focus(), 50)
} }
}, [open]) }, [open])
if (!open) return null
const handleSubmit = () => { const handleSubmit = () => {
const trimmed = message.trim() const trimmed = message.trim()
if (!trimmed) return if (!trimmed) return
@@ -38,37 +37,37 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD
} }
return ( return (
<div className="commit-dialog__overlay" onClick={onClose}> <Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<div className="commit-dialog" onClick={(e) => e.stopPropagation()}> <DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<div className="commit-dialog__header"> <DialogHeader>
<h3>Commit & Push</h3> <div className="flex items-center justify-between">
<span className="commit-dialog__count">{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed</span> <DialogTitle>Commit & Push</DialogTitle>
</div> <Badge variant="secondary" className="text-xs">
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
</Badge>
</div>
</DialogHeader>
<textarea <textarea
ref={inputRef} 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..." placeholder="Commit message..."
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
rows={3} rows={3}
/> />
<div className="commit-dialog__footer"> <DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="commit-dialog__hint">Cmd+Enter to commit</span> <span className="text-[11px] text-muted-foreground">Cmd+Enter to commit</span>
<div className="commit-dialog__actions"> <div className="flex gap-2">
<button className="commit-dialog__cancel" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Cancel Cancel
</button> </Button>
<button <Button onClick={handleSubmit} disabled={!message.trim()}>
className="commit-dialog__submit"
onClick={handleSubmit}
disabled={!message.trim()}
>
Commit & Push Commit & Push
</button> </Button>
</div> </div>
</div> </DialogFooter>
</div> </DialogContent>
</div> </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 { 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 = [ const NOTE_TYPES = [
'Note', 'Note',
@@ -29,23 +32,10 @@ export function CreateNoteDialog({ open, onClose, onCreate }: CreateNoteDialogPr
if (open) { if (open) {
setTitle('') setTitle('')
setType('Note') setType('Note')
// Focus input after render
setTimeout(() => inputRef.current?.focus(), 50) setTimeout(() => inputRef.current?.focus(), 50)
} }
}, [open]) }, [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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
const trimmed = title.trim() const trimmed = title.trim()
@@ -55,46 +45,55 @@ export function CreateNoteDialog({ open, onClose, onCreate }: CreateNoteDialogPr
} }
return ( return (
<div className="create-dialog__overlay" onClick={onClose}> <Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<div className="create-dialog" onClick={(e) => e.stopPropagation()}> <DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<h3 className="create-dialog__title">Create New Note</h3> <DialogHeader>
<form onSubmit={handleSubmit}> <DialogTitle>Create New Note</DialogTitle>
<div className="create-dialog__field"> </DialogHeader>
<label className="create-dialog__label">Title</label> <form onSubmit={handleSubmit} className="space-y-4">
<input <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Title
</label>
<Input
ref={inputRef} ref={inputRef}
className="create-dialog__input"
type="text"
placeholder="Enter note title..." placeholder="Enter note title..."
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</div> </div>
<div className="create-dialog__field"> <div className="space-y-1.5">
<label className="create-dialog__label">Type</label> <label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div className="create-dialog__types"> Type
</label>
<div className="flex flex-wrap gap-1.5">
{NOTE_TYPES.map((t) => ( {NOTE_TYPES.map((t) => (
<button <Button
key={t} key={t}
type="button" 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)} onClick={() => setType(t)}
> >
{t} {t}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
<div className="create-dialog__actions"> <DialogFooter>
<button type="button" className="create-dialog__btn create-dialog__btn--cancel" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</button> </Button>
<button type="submit" className="create-dialog__btn create-dialog__btn--create" disabled={!title.trim()}> <Button type="submit" disabled={!title.trim()}>
Create Create
</button> </Button>
</div> </DialogFooter>
</form> </form>
</div> </DialogContent>
</div> </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 inline content */
.wikilink { .wikilink {
color: var(--accent-blue, #2196f3); color: var(--accent-blue, #2196f3);
@@ -143,126 +34,6 @@
max-width: 760px; 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 Bear-style live preview: zero horizontal shift
============================================= */ ============================================= */

View File

@@ -6,6 +6,9 @@ import { BlockNoteView } from '@blocknote/mantine'
import '@blocknote/mantine/style.css' import '@blocknote/mantine/style.css'
import type { VaultEntry } from '../types' import type { VaultEntry } from '../types'
import { useEditorTheme } from '../hooks/useTheme' 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 './Editor.css'
import './EditorTheme.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] */ /** Strip YAML frontmatter from markdown, returning [frontmatter, body] */
function splitFrontmatter(content: string): [string, string] { function splitFrontmatter(content: string): [string, string] {
if (!content.startsWith('---')) return ['', content] if (!content.startsWith('---')) return ['', content]
@@ -95,17 +96,14 @@ function expandWikilinksInContent(content: any[]): any[] {
const result: any[] = [] const result: any[] = []
for (const item of content) { for (const item of content) {
if (item.type === 'text' && typeof item.text === 'string' && item.text.includes(WL_START)) { 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 const text = item.text as string
let lastIndex = 0 let lastIndex = 0
WL_RE.lastIndex = 0 WL_RE.lastIndex = 0
let match let match
while ((match = WL_RE.exec(text)) !== null) { while ((match = WL_RE.exec(text)) !== null) {
// Text before this match
if (match.index > lastIndex) { if (match.index > lastIndex) {
result.push({ ...item, text: text.slice(lastIndex, match.index) }) result.push({ ...item, text: text.slice(lastIndex, match.index) })
} }
// The wikilink
result.push({ result.push({
type: 'wikilink', type: 'wikilink',
props: { target: match[1] }, props: { target: match[1] },
@@ -113,7 +111,6 @@ function expandWikilinksInContent(content: any[]): any[] {
}) })
lastIndex = match.index + match[0].length lastIndex = match.index + match[0].length
} }
// Text after last match
if (lastIndex < text.length) { if (lastIndex < text.length) {
result.push({ ...item, text: text.slice(lastIndex) }) result.push({ ...item, text: text.slice(lastIndex) })
} }
@@ -127,7 +124,7 @@ function expandWikilinksInContent(content: any[]): any[] {
function DiffView({ diff }: { diff: string }) { function DiffView({ diff }: { diff: string }) {
if (!diff) { if (!diff) {
return ( return (
<div className="diff-view__empty"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No changes to display No changes to display
</div> </div>
) )
@@ -136,23 +133,27 @@ function DiffView({ diff }: { diff: string }) {
const lines = diff.split('\n') const lines = diff.split('\n')
return ( return (
<div className="diff-view"> <div className="font-mono text-[13px] leading-relaxed py-3">
{lines.map((line, i) => { {lines.map((line, i) => {
let className = 'diff-view__line diff-view__line--context' let lineClass = 'text-secondary-foreground'
if (line.startsWith('+') && !line.startsWith('+++')) { 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('---')) { } 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('@@')) { } 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')) { } 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 ( return (
<div key={i} className={className}> <div key={i} className={cn("flex min-h-[22px] px-4", lineClass)}>
<span className="diff-view__line-number">{i + 1}</span> <span className="w-10 shrink-0 text-right pr-3 text-muted-foreground select-none">
<span className="diff-view__line-content">{line || '\u00A0'}</span> {i + 1}
</span>
<span className="flex-1 whitespace-pre-wrap break-all px-2">
{line || '\u00A0'}
</span>
</div> </div>
) )
})} })}
@@ -169,7 +170,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
const editor = useCreateBlockNote({ schema }) const editor = useCreateBlockNote({ schema })
// Load markdown content into editor, converting [[target]] to wikilink inline content
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const preprocessed = preProcessWikilinks(body) const preprocessed = preProcessWikilinks(body)
@@ -180,7 +180,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
load() load()
}, [body, editor]) }, [body, editor])
// Click handler for wikilinks
useEffect(() => { useEffect(() => {
const container = document.querySelector('.editor__blocknote-container') const container = document.querySelector('.editor__blocknote-container')
if (!container) return if (!container) return
@@ -197,7 +196,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
return () => container.removeEventListener('click', handler as EventListener, true) return () => container.removeEventListener('click', handler as EventListener, true)
}, [editor]) }, [editor])
// Suggestion menu items for [[ trigger
const getWikilinkItems = useCallback(async (query: string) => { const getWikilinkItems = useCallback(async (query: string) => {
const items = entries.map(entry => ({ const items = entries.map(entry => ({
title: entry.title, title: entry.title,
@@ -216,7 +214,7 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
return filterSuggestionItems(items, query) return filterSuggestionItems(items, query)
}, [entries, editor]) }, [entries, editor])
const isDark = typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') !== 'light' const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
return ( return (
<div className="editor__blocknote-container" style={cssVars as React.CSSProperties}> <div className="editor__blocknote-container" style={cssVars as React.CSSProperties}>
@@ -224,7 +222,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
editor={editor} editor={editor}
theme={isDark ? 'dark' : 'light'} theme={isDark ? 'dark' : 'light'}
> >
{/* Wikilink suggestion menu triggered by [[ */}
<SuggestionMenuController <SuggestionMenuController
triggerCharacter="[[" triggerCharacter="[["
getItems={getWikilinkItems} getItems={getWikilinkItems}
@@ -268,52 +265,62 @@ export function Editor({ tabs, activeTabPath, entries, onSwitchTab, onCloseTab,
if (tabs.length === 0) { if (tabs.length === 0) {
return ( return (
<div className="editor"> <div className="editor flex flex-col bg-background text-foreground">
<div className="editor__drag-strip" data-tauri-drag-region /> <div className="editor__drag-strip h-12 shrink-0" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} />
<div className="editor__placeholder"> <div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p>Select a note to start editing</p> <p className="m-0 text-[15px]">Select a note to start editing</p>
<span className="editor__placeholder-hint">Cmd+P to search &middot; Cmd+N to create</span> <span className="text-xs text-muted-foreground">Cmd+P to search &middot; Cmd+N to create</span>
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className="editor"> <div className="editor flex flex-col bg-background text-foreground">
<div className="editor__tab-bar" data-tauri-drag-region> {/* 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) => ( {tabs.map((tab) => (
<div <div
key={tab.entry.path} 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)} 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 <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) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onCloseTab(tab.entry.path) onCloseTab(tab.entry.path)
}} }}
> >
× <X className="size-3.5" />
</button> </button>
</div> </div>
))} ))}
{showDiffToggle && ( {showDiffToggle && (
<div className="editor__tab-bar-actions"> <div className="ml-auto flex items-center px-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<button <Button
className={`editor__diff-toggle${diffMode ? ' editor__diff-toggle--active' : ''}`} variant={diffMode ? 'default' : 'outline'}
size="xs"
onClick={handleToggleDiff} onClick={handleToggleDiff}
disabled={diffLoading} disabled={diffLoading}
title={diffMode ? 'Switch to Edit view' : 'Show diff'}
> >
{diffLoading ? '...' : diffMode ? 'Edit' : 'Diff'} {diffLoading ? '...' : diffMode ? 'Edit' : 'Diff'}
</button> </Button>
</div> </div>
)} )}
</div> </div>
{diffMode ? ( {diffMode ? (
<div className="editor__diff-container"> <div className="flex-1 overflow-auto">
<DiffView diff={diffContent ?? ''} /> <DiffView diff={diffContent ?? ''} />
</div> </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', owner: 'Luca Rossi',
cadence: null, cadence: null,
modifiedAt: 1707900000, modifiedAt: 1707900000,
createdAt: null,
fileSize: 1024, fileSize: 1024,
} }
@@ -41,6 +42,7 @@ const referrerEntry: VaultEntry = {
owner: null, owner: null,
cadence: null, cadence: null,
modifiedAt: 1707900000, modifiedAt: 1707900000,
createdAt: null,
fileSize: 200, fileSize: 200,
} }

View File

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

View File

@@ -1,6 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types' 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 { interface NoteListProps {
entries: VaultEntry[] entries: VaultEntry[]
@@ -20,11 +24,8 @@ interface RelationshipGroup {
/** Extract first ~80 chars of content after the title heading */ /** Extract first ~80 chars of content after the title heading */
function getSnippet(content: string | undefined): string { function getSnippet(content: string | undefined): string {
if (!content) return '' if (!content) return ''
// Remove frontmatter
const withoutFm = content.replace(/^---[\s\S]*?---\s*/, '') const withoutFm = content.replace(/^---[\s\S]*?---\s*/, '')
// Remove the first heading
const withoutH1 = withoutFm.replace(/^#\s+.*\n+/, '') const withoutH1 = withoutFm.replace(/^#\s+.*\n+/, '')
// Clean markdown syntax and collapse whitespace
const clean = withoutH1 const clean = withoutH1
.replace(/[#*_`\[\]]/g, '') .replace(/[#*_`\[\]]/g, '')
.replace(/\n+/g, ' ') .replace(/\n+/g, ' ')
@@ -32,13 +33,11 @@ function getSnippet(content: string | undefined): string {
return clean.slice(0, 80) + (clean.length > 80 ? '...' : '') return clean.slice(0, 80) + (clean.length > 80 ? '...' : '')
} }
/** Format a relative date string */
function relativeDate(ts: number | null): string { function relativeDate(ts: number | null): string {
if (!ts) return '' if (!ts) return ''
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const diff = now - ts const diff = now - ts
if (diff < 0) { if (diff < 0) {
// Future date - just show the date
const date = new Date(ts * 1000) const date = new Date(ts * 1000)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) 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' }) 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 { function getDisplayDate(entry: VaultEntry): number | null {
// Prefer modifiedAt (most recent activity), but fall back to createdAt
return entry.modifiedAt ?? entry.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 { 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$/, '') const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
return refs.some((ref) => { return refs.some((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '') 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[] { function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
return refs return refs
.map((ref) => { .map((ref) => {
@@ -86,12 +80,10 @@ function sortByModified(a: VaultEntry, b: VaultEntry): number {
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0) return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
} }
/** Build relationship groups for an entity view */
function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]): RelationshipGroup[] { function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]): RelationshipGroup[] {
const groups: RelationshipGroup[] = [] const groups: RelationshipGroup[] = []
const seen = new Set<string>([entity.path]) const seen = new Set<string>([entity.path])
// 1. Children: items whose belongsTo references this entity (non-events)
const children = allEntries const children = allEntries
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity)) .filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
.sort(sortByModified) .sort(sortByModified)
@@ -100,7 +92,6 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
children.forEach((e) => seen.add(e.path)) children.forEach((e) => seen.add(e.path))
} }
// 2. Events that reference this entity (via belongsTo or relatedTo)
const events = allEntries const events = allEntries
.filter( .filter(
(e) => (e) =>
@@ -114,7 +105,6 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
events.forEach((e) => seen.add(e.path)) events.forEach((e) => seen.add(e.path))
} }
// 3. Referenced By: non-event items whose relatedTo references this entity
const referencedBy = allEntries const referencedBy = allEntries
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity)) .filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
.sort(sortByModified) .sort(sortByModified)
@@ -123,14 +113,12 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
referencedBy.forEach((e) => seen.add(e.path)) 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)) const belongsTo = resolveRefs(entity.belongsTo, allEntries).filter((e) => !seen.has(e.path))
if (belongsTo.length > 0) { if (belongsTo.length > 0) {
groups.push({ label: 'Belongs To', entries: belongsTo }) groups.push({ label: 'Belongs To', entries: belongsTo })
belongsTo.forEach((e) => seen.add(e.path)) 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)) const relatedTo = resolveRefs(entity.relatedTo, allEntries).filter((e) => !seen.has(e.path))
if (relatedTo.length > 0) { if (relatedTo.length > 0) {
groups.push({ label: 'Related To', entries: relatedTo }) 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)) return entries.filter((e) => modifiedPaths.has(e.path))
} }
case 'favorites': case 'favorites':
// TODO: Implement favorites (needs a "favorite" field in frontmatter)
return [] return []
case 'trash': case 'trash':
// TODO: Implement trash (needs deleted/archived status)
return [] return []
} }
break break
case 'sectionGroup': case 'sectionGroup':
return entries.filter((e) => e.isA === selection.type) return entries.filter((e) => e.isA === selection.type)
case 'entity': case 'entity':
// Handled separately via buildRelationshipGroups
return [] return []
case 'topic': { case 'topic': {
const topic = selection.entry const topic = selection.entry
@@ -186,20 +171,33 @@ const TYPE_PILLS = [
{ label: 'Responsibilities', type: 'Responsibility' }, { label: 'Responsibilities', type: 'Responsibility' },
] as const ] 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) { export function NoteList({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<string | null>(null) const [typeFilter, setTypeFilter] = useState<string | null>(null)
const isEntityView = selection.kind === 'entity' const isEntityView = selection.kind === 'entity'
const entityGroups = isEntityView ? buildRelationshipGroups(selection.entry, entries) : []
// Entity view: build relationship groups
const entityGroups = isEntityView
? buildRelationshipGroups(selection.entry, entries)
: []
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes' const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
// Build a lookup for modified file status
const modifiedStatusMap = new Map<string, string>() const modifiedStatusMap = new Map<string, string>()
if (modifiedFiles) { if (modifiedFiles) {
for (const f of 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 filtered = isEntityView ? [] : filterEntries(entries, selection, modifiedFiles)
const sorted = isEntityView ? [] : [...filtered].sort(sortByModified) const sorted = isEntityView ? [] : [...filtered].sort(sortByModified)
// Search filter
const query = search.trim().toLowerCase() const query = search.trim().toLowerCase()
// For entity view: filter within groups
const searchedGroups = query const searchedGroups = query
? entityGroups ? entityGroups
.map((g) => ({ .map((g) => ({
@@ -224,26 +219,22 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
.filter((g) => g.entries.length > 0) .filter((g) => g.entries.length > 0)
: entityGroups : entityGroups
// For flat view
const searched = query const searched = query
? sorted.filter((e) => e.title.toLowerCase().includes(query)) ? sorted.filter((e) => e.title.toLowerCase().includes(query))
: sorted : sorted
// Compute per-type counts from the searched results (before type filter)
const typeCounts = new Map<string | null, number>() const typeCounts = new Map<string | null, number>()
typeCounts.set(null, searched.length) // "All" count typeCounts.set(null, searched.length)
for (const entry of searched) { for (const entry of searched) {
if (entry.isA) { if (entry.isA) {
typeCounts.set(entry.isA, (typeCounts.get(entry.isA) ?? 0) + 1) typeCounts.set(entry.isA, (typeCounts.get(entry.isA) ?? 0) + 1)
} }
} }
// Type filter pills (flat view only)
const displayed = typeFilter const displayed = typeFilter
? searched.filter((e) => e.isA === typeFilter) ? searched.filter((e) => e.isA === typeFilter)
: searched : searched
// Total count for header
const totalCount = isEntityView const totalCount = isEntityView
? searchedGroups.reduce((sum, g) => sum + g.entries.length, 0) ? searchedGroups.reduce((sum, g) => sum + g.entries.length, 0)
: displayed.length : displayed.length
@@ -253,53 +244,80 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
return ( return (
<div <div
key={entry.path} key={entry.path}
className={`note-list__item${isPinned ? ' note-list__item--pinned' : ''}${ className={cn(
selectedNote?.path === entry.path ? ' note-list__item--selected' : '' "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)} onClick={() => onSelectNote(entry)}
> >
<div className="note-list__item-top"> <div className="flex items-baseline justify-between gap-2">
<div className="note-list__title"> <div className="flex min-w-0 flex-1 items-center truncate text-[13px] font-semibold text-foreground">
{isChangesView && gitStatus && ( {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'} {gitStatus === 'modified' ? 'M' : gitStatus === 'added' ? 'A' : gitStatus === 'deleted' ? 'D' : gitStatus === 'untracked' ? '?' : 'R'}
</span> </span>
)} )}
{entry.title} <span className="truncate">{entry.title}</span>
</div> </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>
<div className="note-list__snippet">{getSnippet(allContent[entry.path])}</div> <div className="mt-0.5 truncate text-xs leading-relaxed text-muted-foreground">
<div className="note-list__meta"> {getSnippet(allContent[entry.path])}
{entry.isA && <span className={`note-list__type note-list__type--${entry.isA.toLowerCase()}`}>{entry.isA}</span>} </div>
{entry.status && <span className="note-list__status">{entry.status}</span>} <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>
</div> </div>
) )
} }
return ( return (
<div className="note-list"> <div className="flex flex-col overflow-y-auto border-r border-border bg-card text-foreground">
<div className="note-list__header" data-tauri-drag-region> {/* Header */}
<h3>{isEntityView ? selection.entry.title : 'Notes'}</h3> <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}>
<div className="note-list__header-right"> <h3 className="m-0 min-w-0 flex-1 truncate text-sm font-semibold">
<span className="note-list__count">{totalCount}</span> {isEntityView ? selection.entry.title : 'Notes'}
<button className="note-list__add-btn" onClick={onCreateNote} title="Create new note"> </h3>
+ <div className="flex items-center gap-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
</button> <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> </div>
<div className="note-list__search">
<input {/* Search */}
type="text" <div className="border-b border-border px-3 py-2">
className="note-list__search-input" <Input
placeholder="Search notes..." placeholder="Search notes..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="h-8 text-[13px]"
/> />
</div> </div>
{/* Type filter pills */}
{!isEntityView && ( {!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 }) => { {TYPE_PILLS.filter(({ type }) => {
const count = typeCounts.get(type) ?? 0 const count = typeCounts.get(type) ?? 0
return type === null || count > 0 return type === null || count > 0
@@ -308,29 +326,38 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
return ( return (
<button <button
key={label} 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)} 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> </button>
) )
})} })}
</div> </div>
)} )}
<div className="note-list__items">
{/* Items */}
<div className="flex-1 overflow-y-auto">
{isEntityView ? ( {isEntityView ? (
<> <>
{renderItem(selection.entry, true)} {renderItem(selection.entry, true)}
{searchedGroups.length === 0 ? ( {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'} {query ? 'No matching items' : 'No related items'}
</div> </div>
) : ( ) : (
searchedGroups.map((group) => ( searchedGroups.map((group) => (
<div key={group.label} className="note-list__group"> <div key={group.label} className="border-t border-[var(--border-subtle)]">
<div className="note-list__group-header"> <div className="flex items-center justify-between px-4 py-2.5 pt-3">
<span className="note-list__group-label">{group.label}</span> <span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="note-list__group-count">{group.entries.length}</span> {group.label}
</span>
<Badge variant="secondary" className="text-[10px]">{group.entries.length}</Badge>
</div> </div>
{group.entries.map((entry) => renderItem(entry))} {group.entries.map((entry) => renderItem(entry))}
</div> </div>
@@ -339,7 +366,7 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
</> </>
) : ( ) : (
displayed.length === 0 ? ( 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)) 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 { useState, useRef, useEffect, useMemo } from 'react'
import type { VaultEntry } from '../types' import type { VaultEntry } from '../types'
import './QuickOpenPalette.css' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
interface QuickOpenPaletteProps { interface QuickOpenPaletteProps {
open: boolean 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++) { for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) { if (t[ti] === q[qi]) {
// Bonus for consecutive matches
if (ti === lastMatchIndex + 1) score += 2 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 if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-') score += 3
score += 1 score += 1
lastMatchIndex = ti lastMatchIndex = ti
@@ -48,7 +47,6 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
const results = useMemo(() => { const results = useMemo(() => {
if (!query.trim()) { 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].sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)).slice(0, 20)
} }
return entries return entries
@@ -59,19 +57,16 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
.map((r) => r.entry) .map((r) => r.entry)
}, [entries, query]) }, [entries, query])
// Keep selectedIndex in bounds
useEffect(() => { useEffect(() => {
setSelectedIndex(0) setSelectedIndex(0)
}, [query]) }, [query])
// Scroll selected item into view
useEffect(() => { useEffect(() => {
if (!listRef.current) return if (!listRef.current) return
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
selected?.scrollIntoView({ block: 'nearest' }) selected?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex]) }, [selectedIndex])
// Close on Escape, navigate with arrows, select with Enter
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -99,32 +94,47 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
if (!open) return null if (!open) return null
return ( return (
<div className="palette__overlay" onClick={onClose}> <div
<div className="palette" onClick={(e) => e.stopPropagation()}> 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 <input
ref={inputRef} 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" type="text"
placeholder="Search notes..." placeholder="Search notes..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} 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 ? ( {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) => ( results.map((entry, i) => (
<div <div
key={entry.path} 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={() => { onClick={() => {
onSelect(entry) onSelect(entry)
onClose() onClose()
}} }}
onMouseEnter={() => setSelectedIndex(i)} onMouseEnter={() => setSelectedIndex(i)}
> >
<span className="palette__item-title">{entry.title}</span> <span className="text-sm text-foreground">{entry.title}</span>
{entry.isA && <span className="palette__item-type">{entry.isA}</span>} {entry.isA && (
<Badge variant="secondary" className="text-[11px]">
{entry.isA}
</Badge>
)}
</div> </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 { useCallback, useEffect, useRef } from 'react'
import './ResizeHandle.css'
interface ResizeHandleProps { interface ResizeHandleProps {
onResize: (delta: number) => void onResize: (delta: number) => void
@@ -44,5 +43,10 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
} }
}, [onResize]) }, [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', owner: 'Luca',
cadence: null, cadence: null,
modifiedAt: 1700000000, modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024, fileSize: 1024,
}, },
{ {
@@ -30,6 +31,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca', owner: 'Luca',
cadence: null, cadence: null,
modifiedAt: 1700000000, modifiedAt: 1700000000,
createdAt: null,
fileSize: 512, fileSize: 512,
}, },
{ {
@@ -44,6 +46,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca', owner: 'Luca',
cadence: null, cadence: null,
modifiedAt: 1700000000, modifiedAt: 1700000000,
createdAt: null,
fileSize: 256, fileSize: 256,
}, },
{ {
@@ -58,6 +61,7 @@ const mockEntries: VaultEntry[] = [
owner: 'Luca', owner: 'Luca',
cadence: 'Weekly', cadence: 'Weekly',
modifiedAt: 1700000000, modifiedAt: 1700000000,
createdAt: null,
fileSize: 128, fileSize: 128,
}, },
{ {
@@ -72,6 +76,7 @@ const mockEntries: VaultEntry[] = [
owner: null, owner: null,
cadence: null, cadence: null,
modifiedAt: 1700000000, modifiedAt: 1700000000,
createdAt: null,
fileSize: 256, fileSize: 256,
}, },
{ {
@@ -86,6 +91,7 @@ const mockEntries: VaultEntry[] = [
owner: null, owner: null,
cadence: null, cadence: null,
modifiedAt: 1700000000, modifiedAt: 1700000000,
createdAt: null,
fileSize: 180, fileSize: 180,
}, },
] ]

View File

@@ -1,6 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import type { VaultEntry, SidebarSelection } from '../types' 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 { interface SidebarProps {
entries: VaultEntry[] entries: VaultEntry[]
@@ -38,11 +41,15 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
}) })
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme) if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
try { try {
localStorage.setItem('laputa-theme', theme) localStorage.setItem('laputa-theme', theme)
} catch { } catch {
// localStorage unavailable (e.g. in tests) // localStorage unavailable
} }
}, [theme]) }, [theme])
@@ -64,79 +71,98 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
} }
return ( return (
<aside className="sidebar"> <aside className="flex h-full flex-col overflow-y-auto bg-sidebar text-sidebar-foreground">
<div className="sidebar__header" data-tauri-drag-region> {/* Header */}
<h2>Laputa</h2> <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}>
<button <h2 className="m-0 text-[17px] font-bold tracking-tight text-foreground">Laputa</h2>
className="sidebar__theme-toggle" <Button
variant="ghost"
size="icon-xs"
onClick={toggleTheme} onClick={toggleTheme}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} 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'} {theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button> </Button>
</div> </div>
<nav className="sidebar__nav"> {/* Navigation */}
<div className="sidebar__filters"> <nav className="flex-1 overflow-y-auto py-2">
{/* Filters */}
<div className="mb-2 border-b border-border py-1">
{FILTERS.map(({ label, filter }) => ( {FILTERS.map(({ label, filter }) => (
<div <div
key={filter} key={filter}
className={`sidebar__filter-item${ className={cn(
isActive({ kind: 'filter', filter }) ? ' sidebar__filter-item--active' : '' "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 })} onClick={() => onSelect({ kind: 'filter', filter })}
> >
{label} <span className="flex items-center gap-1.5">
{filter === 'changes' && modifiedCount > 0 && ( {label}
<span className="sidebar__badge">{modifiedCount}</span> {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>
))} ))}
</div> </div>
{/* Section Groups */}
{SECTION_GROUPS.map(({ label, type }) => { {SECTION_GROUPS.map(({ label, type }) => {
const items = entries.filter((e) => e.isA === type) const items = entries.filter((e) => e.isA === type)
const isCollapsed = collapsed[type] ?? false const isCollapsed = collapsed[type] ?? false
return ( return (
<div key={type} className="sidebar__section"> <div key={type} className="mb-1">
<div <div
className={`sidebar__section-header${ className={cn(
isActive({ kind: 'sectionGroup', type }) ? ' sidebar__section-header--active' : '' "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 })} onClick={() => onSelect({ kind: 'sectionGroup', type })}
> >
<button <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) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
toggleSection(type) toggleSection(type)
}} }}
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`} aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
> >
{isCollapsed ? '\u25B8' : '\u25BE'} {isCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
</button> </button>
<span className="sidebar__section-label">{label}</span> <span className="flex-1">{label}</span>
<span className="sidebar__section-count">{items.length}</span> <span className="text-[10px] text-muted-foreground mr-1">{items.length}</span>
<button <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) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
// TODO: Wire up create new entity
}} }}
aria-label={`Add ${type}`} aria-label={`Add ${type}`}
> >
+ <Plus className="size-3.5" />
</button> </button>
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="sidebar__section-items"> <div className="py-0.5">
{items.map((entry) => ( {items.map((entry) => (
<div <div
key={entry.path} key={entry.path}
className={`sidebar__item${ className={cn(
isActive({ kind: 'entity', entry }) ? ' sidebar__item--active' : '' "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={() => { onClick={() => {
onSelect({ kind: 'entity', entry }) onSelect({ kind: 'entity', entry })
onSelectNote?.(entry) onSelectNote?.(entry)
@@ -151,18 +177,24 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
) )
})} })}
{/* Topics */}
{(() => { {(() => {
const topics = entries.filter((e) => e.isA === 'Topic') const topics = entries.filter((e) => e.isA === 'Topic')
if (topics.length === 0) return null if (topics.length === 0) return null
return ( return (
<div className="sidebar__topics"> <div className="mt-2 border-t border-border pt-2">
<div className="sidebar__topics-header">TOPICS</div> <div className="px-4 py-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground select-none">
TOPICS
</div>
{topics.map((entry) => ( {topics.map((entry) => (
<div <div
key={entry.path} key={entry.path}
className={`sidebar__topic-item${ className={cn(
isActive({ kind: 'topic', entry }) ? ' sidebar__topic-item--active' : '' "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={() => { onClick={() => {
onSelect({ kind: 'topic', entry }) onSelect({ kind: 'topic', entry })
onSelectNote?.(entry) onSelectNote?.(entry)
@@ -175,12 +207,17 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
) )
})()} })()}
</nav> </nav>
{/* Commit button */}
{modifiedCount > 0 && onCommitPush && ( {modifiedCount > 0 && onCommitPush && (
<div className="sidebar__commit"> <div className="shrink-0 border-t border-border p-3">
<button className="sidebar__commit-btn" onClick={onCommitPush}> <Button className="w-full gap-1.5" size="sm" onClick={onCommitPush}>
<GitCommitHorizontal className="size-3.5" />
Commit & Push Commit & Push
<span className="sidebar__badge">{modifiedCount}</span> <Badge className="ml-1 bg-white/25 text-white text-[10px] h-[18px] min-w-[18px] px-1">
</button> {modifiedCount}
</Badge>
</Button>
</div> </div>
)} )}
</aside> </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 { useEffect } from 'react'
import './Toast.css' import { cn } from '@/lib/utils'
interface ToastProps { interface ToastProps {
message: string | null message: string | null
@@ -16,7 +16,12 @@ export function Toast({ message, onDismiss }: ToastProps) {
if (!message) return null if (!message) return null
return ( 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} {message}
</div> </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 @@
* { @import "tailwindcss";
box-sizing: border-box; @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 { :root {
/* --- Font defaults --- */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif; Ubuntu, Cantarell, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-size: 14px; font-size: 14px;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
/* Dark theme (default) */ /* --- shadcn theme variables (light mode) --- */
color-scheme: dark; --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 */ /* --- App-specific variables (light mode) --- */
--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"] {
color-scheme: light; color-scheme: light;
/* Backgrounds */
--bg-primary: #FFFFFF; --bg-primary: #FFFFFF;
--bg-sidebar: #F7F6F3; --bg-sidebar: #F7F6F3;
--bg-card: #FFFFFF; --bg-card: #FFFFFF;
@@ -76,16 +62,12 @@
--bg-input: #FFFFFF; --bg-input: #FFFFFF;
--bg-button: #EBEBEA; --bg-button: #EBEBEA;
--bg-dialog: #FFFFFF; --bg-dialog: #FFFFFF;
/* Text */
--text-primary: #37352F; --text-primary: #37352F;
--text-secondary: #787774; --text-secondary: #787774;
--text-tertiary: #787774; --text-tertiary: #787774;
--text-muted: #B4B4B4; --text-muted: #B4B4B4;
--text-faint: #B4B4B4; --text-faint: #B4B4B4;
--text-heading: #37352F; --text-heading: #37352F;
/* Accents */
--accent-blue: #2383E2; --accent-blue: #2383E2;
--accent-blue-bg: #2383E218; --accent-blue-bg: #2383E218;
--accent-blue-hover: #1a6bb5; --accent-blue-hover: #1a6bb5;
@@ -93,29 +75,129 @@
--accent-orange: #D9730D; --accent-orange: #D9730D;
--accent-red: #E03E3E; --accent-red: #E03E3E;
--accent-purple: #9065B0; --accent-purple: #9065B0;
/* Borders */
--border-primary: #E9E9E7; --border-primary: #E9E9E7;
--border-subtle: #E9E9E7; --border-subtle: #E9E9E7;
--border-input: #E9E9E7; --border-input: #E9E9E7;
--border-dialog: #E9E9E7; --border-dialog: #E9E9E7;
/* Links */
--link-color: #2383E2; --link-color: #2383E2;
--link-hover: #1a6bb5; --link-hover: #1a6bb5;
/* Shadows */
--shadow-overlay: rgba(0, 0, 0, 0.3); --shadow-overlay: rgba(0, 0, 0, 0.3);
--shadow-dialog: rgba(0, 0, 0, 0.15); --shadow-dialog: rgba(0, 0, 0, 0.15);
} }
body { .dark {
margin: 0; /* --- shadcn theme variables (dark mode) --- */
padding: 0; --background: #0f0f1a;
overflow: hidden; --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 { /* --- Tailwind v4 theme inline: register colors + radii --- */
width: 100vw; @theme inline {
height: 100vh; --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 { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css' import './index.css'
import App from './App.tsx' 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <TooltipProvider>
<App />
</TooltipProvider>
</StrictMode>, </StrictMode>,
) )

View File

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

View File

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

View File

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