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:
21
components.json
Normal file
21
components.json
Normal 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"
|
||||
}
|
||||
17
package.json
17
package.json
@@ -18,11 +18,26 @@
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@mantine/core": "^8.3.14",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.564.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
2060
pnpm-lock.yaml
generated
2060
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/* App layout - minimal CSS, most styling via Tailwind */
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
|
||||
@@ -379,6 +379,7 @@ function App() {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: now,
|
||||
createdAt: now,
|
||||
fileSize: 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import './CommitDialog.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface CommitDialogProps {
|
||||
open: boolean
|
||||
@@ -15,13 +17,10 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMessage('')
|
||||
// Focus with a small delay to ensure the dialog is rendered
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = message.trim()
|
||||
if (!trimmed) return
|
||||
@@ -38,37 +37,37 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="commit-dialog__overlay" onClick={onClose}>
|
||||
<div className="commit-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="commit-dialog__header">
|
||||
<h3>Commit & Push</h3>
|
||||
<span className="commit-dialog__count">{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed</span>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Commit & Push</DialogTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="commit-dialog__input"
|
||||
className="w-full resize-y rounded-lg border border-input bg-[var(--bg-input)] px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-ring"
|
||||
placeholder="Commit message..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="commit-dialog__footer">
|
||||
<span className="commit-dialog__hint">Cmd+Enter to commit</span>
|
||||
<div className="commit-dialog__actions">
|
||||
<button className="commit-dialog__cancel" onClick={onClose}>
|
||||
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
|
||||
<span className="text-[11px] text-muted-foreground">Cmd+Enter to commit</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="commit-dialog__submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={!message.trim()}
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!message.trim()}>
|
||||
Commit & Push
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import './CreateNoteDialog.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NOTE_TYPES = [
|
||||
'Note',
|
||||
@@ -29,23 +32,10 @@ export function CreateNoteDialog({ open, onClose, onCreate }: CreateNoteDialogPr
|
||||
if (open) {
|
||||
setTitle('')
|
||||
setType('Note')
|
||||
// Focus input after render
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKey)
|
||||
return () => window.removeEventListener('keydown', handleKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = title.trim()
|
||||
@@ -55,46 +45,55 @@ export function CreateNoteDialog({ open, onClose, onCreate }: CreateNoteDialogPr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="create-dialog__overlay" onClick={onClose}>
|
||||
<div className="create-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="create-dialog__title">Create New Note</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="create-dialog__field">
|
||||
<label className="create-dialog__label">Title</label>
|
||||
<input
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Note</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="create-dialog__input"
|
||||
type="text"
|
||||
placeholder="Enter note title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="create-dialog__field">
|
||||
<label className="create-dialog__label">Type</label>
|
||||
<div className="create-dialog__types">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Type
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{NOTE_TYPES.map((t) => (
|
||||
<button
|
||||
<Button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`create-dialog__type-btn${type === t ? ' create-dialog__type-btn--active' : ''}`}
|
||||
variant={type === t ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-full text-xs",
|
||||
type === t && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
onClick={() => setType(t)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="create-dialog__actions">
|
||||
<button type="button" className="create-dialog__btn create-dialog__btn--cancel" onClick={onClose}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="create-dialog__btn create-dialog__btn--create" disabled={!title.trim()}>
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim()}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,112 +1,3 @@
|
||||
.editor {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor__placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor__placeholder p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.editor__placeholder-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.editor__drag-strip {
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.editor__tab-bar {
|
||||
display: flex;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.editor__tab-bar::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.editor__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--border-primary);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.1s;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.editor__tab:hover {
|
||||
background: var(--bg-hover-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.editor__tab--active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.editor__tab-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.editor__tab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
|
||||
.editor__tab:hover .editor__tab-close,
|
||||
.editor__tab--active .editor__tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editor__tab-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Wikilink inline content */
|
||||
.wikilink {
|
||||
color: var(--accent-blue, #2196f3);
|
||||
@@ -143,126 +34,6 @@
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
/* Tab bar actions (diff toggle) */
|
||||
.editor__tab-bar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
padding: 0 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.editor__diff-toggle {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor__diff-toggle:hover {
|
||||
background: var(--bg-button);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.editor__diff-toggle--active {
|
||||
background: var(--accent-blue-bg);
|
||||
color: var(--accent-blue);
|
||||
border-color: var(--accent-blue-bg);
|
||||
}
|
||||
|
||||
.editor__diff-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Diff view */
|
||||
.editor__diff-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.diff-view {
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.diff-view__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.diff-view__line {
|
||||
display: flex;
|
||||
padding: 0 16px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.diff-view__line-number {
|
||||
width: 40px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
padding-right: 12px;
|
||||
color: var(--text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-view__line-content {
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.diff-view__line--added {
|
||||
background: rgba(76, 175, 80, 0.12);
|
||||
}
|
||||
|
||||
.diff-view__line--added .diff-view__line-content {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.diff-view__line--removed {
|
||||
background: rgba(244, 67, 54, 0.12);
|
||||
}
|
||||
|
||||
.diff-view__line--removed .diff-view__line-content {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.diff-view__line--hunk {
|
||||
background: rgba(33, 150, 243, 0.08);
|
||||
}
|
||||
|
||||
.diff-view__line--hunk .diff-view__line-content {
|
||||
color: var(--accent-blue, #2196f3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-view__line--header {
|
||||
background: var(--bg-hover-subtle);
|
||||
}
|
||||
|
||||
.diff-view__line--header .diff-view__line-content {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-view__line--context .diff-view__line-content {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Bear-style live preview: zero horizontal shift
|
||||
============================================= */
|
||||
|
||||
@@ -6,6 +6,9 @@ import { BlockNoteView } from '@blocknote/mantine'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-react'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -56,8 +59,6 @@ const schema = BlockNoteSchema.create({
|
||||
},
|
||||
})
|
||||
|
||||
type EditorType = typeof schema.BlockNoteEditorType
|
||||
|
||||
/** Strip YAML frontmatter from markdown, returning [frontmatter, body] */
|
||||
function splitFrontmatter(content: string): [string, string] {
|
||||
if (!content.startsWith('---')) return ['', content]
|
||||
@@ -95,17 +96,14 @@ function expandWikilinksInContent(content: any[]): any[] {
|
||||
const result: any[] = []
|
||||
for (const item of content) {
|
||||
if (item.type === 'text' && typeof item.text === 'string' && item.text.includes(WL_START)) {
|
||||
// Split this text node around wikilink placeholders
|
||||
const text = item.text as string
|
||||
let lastIndex = 0
|
||||
WL_RE.lastIndex = 0
|
||||
let match
|
||||
while ((match = WL_RE.exec(text)) !== null) {
|
||||
// Text before this match
|
||||
if (match.index > lastIndex) {
|
||||
result.push({ ...item, text: text.slice(lastIndex, match.index) })
|
||||
}
|
||||
// The wikilink
|
||||
result.push({
|
||||
type: 'wikilink',
|
||||
props: { target: match[1] },
|
||||
@@ -113,7 +111,6 @@ function expandWikilinksInContent(content: any[]): any[] {
|
||||
})
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
// Text after last match
|
||||
if (lastIndex < text.length) {
|
||||
result.push({ ...item, text: text.slice(lastIndex) })
|
||||
}
|
||||
@@ -127,7 +124,7 @@ function expandWikilinksInContent(content: any[]): any[] {
|
||||
function DiffView({ diff }: { diff: string }) {
|
||||
if (!diff) {
|
||||
return (
|
||||
<div className="diff-view__empty">
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No changes to display
|
||||
</div>
|
||||
)
|
||||
@@ -136,23 +133,27 @@ function DiffView({ diff }: { diff: string }) {
|
||||
const lines = diff.split('\n')
|
||||
|
||||
return (
|
||||
<div className="diff-view">
|
||||
<div className="font-mono text-[13px] leading-relaxed py-3">
|
||||
{lines.map((line, i) => {
|
||||
let className = 'diff-view__line diff-view__line--context'
|
||||
let lineClass = 'text-secondary-foreground'
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
className = 'diff-view__line diff-view__line--added'
|
||||
lineClass = 'bg-[rgba(76,175,80,0.12)] text-[#4caf50]'
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
className = 'diff-view__line diff-view__line--removed'
|
||||
lineClass = 'bg-[rgba(244,67,54,0.12)] text-[#f44336]'
|
||||
} else if (line.startsWith('@@')) {
|
||||
className = 'diff-view__line diff-view__line--hunk'
|
||||
lineClass = 'bg-[rgba(33,150,243,0.08)] text-primary italic'
|
||||
} else if (line.startsWith('diff') || line.startsWith('index') || line.startsWith('---') || line.startsWith('+++') || line.startsWith('new file')) {
|
||||
className = 'diff-view__line diff-view__line--header'
|
||||
lineClass = 'bg-muted text-muted-foreground font-semibold'
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={i} className={className}>
|
||||
<span className="diff-view__line-number">{i + 1}</span>
|
||||
<span className="diff-view__line-content">{line || '\u00A0'}</span>
|
||||
<div key={i} className={cn("flex min-h-[22px] px-4", lineClass)}>
|
||||
<span className="w-10 shrink-0 text-right pr-3 text-muted-foreground select-none">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap break-all px-2">
|
||||
{line || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -169,7 +170,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
|
||||
|
||||
const editor = useCreateBlockNote({ schema })
|
||||
|
||||
// Load markdown content into editor, converting [[target]] to wikilink inline content
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const preprocessed = preProcessWikilinks(body)
|
||||
@@ -180,7 +180,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
|
||||
load()
|
||||
}, [body, editor])
|
||||
|
||||
// Click handler for wikilinks
|
||||
useEffect(() => {
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
if (!container) return
|
||||
@@ -197,7 +196,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
|
||||
return () => container.removeEventListener('click', handler as EventListener, true)
|
||||
}, [editor])
|
||||
|
||||
// Suggestion menu items for [[ trigger
|
||||
const getWikilinkItems = useCallback(async (query: string) => {
|
||||
const items = entries.map(entry => ({
|
||||
title: entry.title,
|
||||
@@ -216,7 +214,7 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
|
||||
return filterSuggestionItems(items, query)
|
||||
}, [entries, editor])
|
||||
|
||||
const isDark = typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') !== 'light'
|
||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
|
||||
return (
|
||||
<div className="editor__blocknote-container" style={cssVars as React.CSSProperties}>
|
||||
@@ -224,7 +222,6 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
|
||||
editor={editor}
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
>
|
||||
{/* Wikilink suggestion menu triggered by [[ */}
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="[["
|
||||
getItems={getWikilinkItems}
|
||||
@@ -268,52 +265,62 @@ export function Editor({ tabs, activeTabPath, entries, onSwitchTab, onCloseTab,
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="editor">
|
||||
<div className="editor__drag-strip" data-tauri-drag-region />
|
||||
<div className="editor__placeholder">
|
||||
<p>Select a note to start editing</p>
|
||||
<span className="editor__placeholder-hint">Cmd+P to search · Cmd+N to create</span>
|
||||
<div className="editor flex flex-col bg-background text-foreground">
|
||||
<div className="editor__drag-strip h-12 shrink-0" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<p className="m-0 text-[15px]">Select a note to start editing</p>
|
||||
<span className="text-xs text-muted-foreground">Cmd+P to search · Cmd+N to create</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<div className="editor__tab-bar" data-tauri-drag-region>
|
||||
<div className="editor flex flex-col bg-background text-foreground">
|
||||
{/* Tab bar */}
|
||||
<div className="flex shrink-0 overflow-x-auto border-b border-border bg-card" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.entry.path}
|
||||
className={`editor__tab${tab.entry.path === activeTabPath ? ' editor__tab--active' : ''}`}
|
||||
className={cn(
|
||||
"flex shrink-0 cursor-pointer items-center gap-1.5 border-r border-border px-3.5 py-[7px] text-xs whitespace-nowrap max-w-[180px] transition-all",
|
||||
tab.entry.path === activeTabPath
|
||||
? "bg-background text-foreground border-b-2 border-b-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-secondary-foreground"
|
||||
)}
|
||||
onClick={() => onSwitchTab(tab.entry.path)}
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<span className="editor__tab-title">{tab.entry.title}</span>
|
||||
<span className="truncate">{tab.entry.title}</span>
|
||||
<button
|
||||
className="editor__tab-close"
|
||||
className={cn(
|
||||
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
|
||||
tab.entry.path === activeTabPath ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCloseTab(tab.entry.path)
|
||||
}}
|
||||
>
|
||||
×
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{showDiffToggle && (
|
||||
<div className="editor__tab-bar-actions">
|
||||
<button
|
||||
className={`editor__diff-toggle${diffMode ? ' editor__diff-toggle--active' : ''}`}
|
||||
<div className="ml-auto flex items-center px-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<Button
|
||||
variant={diffMode ? 'default' : 'outline'}
|
||||
size="xs"
|
||||
onClick={handleToggleDiff}
|
||||
disabled={diffLoading}
|
||||
title={diffMode ? 'Switch to Edit view' : 'Show diff'}
|
||||
>
|
||||
{diffLoading ? '...' : diffMode ? 'Edit' : 'Diff'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{diffMode ? (
|
||||
<div className="editor__diff-container">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DiffView diff={diffContent ?? ''} />
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const mockEntry: VaultEntry = {
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ const referrerEntry: VaultEntry = {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import './Inspector.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface InspectorProps {
|
||||
collapsed: boolean
|
||||
@@ -57,20 +58,11 @@ const SKIP_KEYS = new Set([
|
||||
])
|
||||
|
||||
function formatDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '—'
|
||||
if (!timestamp) return '\u2014'
|
||||
const d = new Date(timestamp * 1000)
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function formatISODate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function countWords(content: string | null): number {
|
||||
if (!content) return 0
|
||||
// Strip YAML frontmatter
|
||||
@@ -82,19 +74,19 @@ function countWords(content: string | null): number {
|
||||
/** Parse YAML frontmatter from content */
|
||||
function parseFrontmatter(content: string | null): ParsedFrontmatter {
|
||||
if (!content) return {}
|
||||
|
||||
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
||||
if (!match) return {}
|
||||
|
||||
|
||||
const yaml = match[1]
|
||||
const result: ParsedFrontmatter = {}
|
||||
|
||||
|
||||
let currentKey: string | null = null
|
||||
let currentList: string[] = []
|
||||
let inList = false
|
||||
|
||||
|
||||
const lines = yaml.split('\n')
|
||||
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for list item
|
||||
const listMatch = line.match(/^ - (.*)$/)
|
||||
@@ -103,35 +95,35 @@ function parseFrontmatter(content: string | null): ParsedFrontmatter {
|
||||
currentList.push(listMatch[1].replace(/^["']|["']$/g, ''))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// If we were in a list and hit a non-list line, save it
|
||||
if (inList && currentKey) {
|
||||
result[currentKey] = currentList.length === 1 ? currentList[0] : currentList
|
||||
currentList = []
|
||||
inList = false
|
||||
}
|
||||
|
||||
|
||||
// Check for key: value
|
||||
const kvMatch = line.match(/^["']?([^"':]+)["']?\s*:\s*(.*)$/)
|
||||
if (kvMatch) {
|
||||
currentKey = kvMatch[1].trim()
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
|
||||
if (value === '' || value === '|' || value === '>') {
|
||||
// Empty value or multiline - wait for list items or next key
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Handle inline list like [item1, item2]
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
const items = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''))
|
||||
result[currentKey] = items.length === 1 ? items[0] : items
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// Handle quoted string
|
||||
const unquoted = value.replace(/^["']|["']$/g, '')
|
||||
|
||||
|
||||
// Handle boolean
|
||||
if (unquoted.toLowerCase() === 'true') {
|
||||
result[currentKey] = true
|
||||
@@ -141,16 +133,16 @@ function parseFrontmatter(content: string | null): ParsedFrontmatter {
|
||||
result[currentKey] = false
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
result[currentKey] = unquoted
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Don't forget last list if any
|
||||
if (inList && currentKey) {
|
||||
result[currentKey] = currentList.length === 1 ? currentList[0] : currentList
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -190,13 +182,13 @@ function wikilinkTarget(ref: string): string {
|
||||
}
|
||||
|
||||
// Editable value component for inline editing
|
||||
function EditableValue({
|
||||
value,
|
||||
onSave,
|
||||
function EditableValue({
|
||||
value,
|
||||
onSave,
|
||||
onCancel,
|
||||
isEditing,
|
||||
onStartEdit
|
||||
}: {
|
||||
onStartEdit
|
||||
}: {
|
||||
value: string
|
||||
onSave: (newValue: string) => void
|
||||
onCancel: () => void
|
||||
@@ -204,7 +196,7 @@ function EditableValue({
|
||||
onStartEdit: () => void
|
||||
}) {
|
||||
const [editValue, setEditValue] = useState(value)
|
||||
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(editValue)
|
||||
@@ -213,11 +205,11 @@ function EditableValue({
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
@@ -227,14 +219,14 @@ function EditableValue({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inspector__prop-value inspector__prop-value--editable"
|
||||
<span
|
||||
className="cursor-pointer rounded px-1 py-0.5 text-right text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title="Click to edit"
|
||||
>
|
||||
{value || '—'}
|
||||
{value || '\u2014'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -243,12 +235,10 @@ function EditableValue({
|
||||
function EditableList({
|
||||
items,
|
||||
onSave,
|
||||
onDelete,
|
||||
label,
|
||||
}: {
|
||||
items: string[]
|
||||
onSave: (newItems: string[]) => void
|
||||
onDelete?: () => void
|
||||
label: string
|
||||
}) {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
@@ -304,12 +294,12 @@ function EditableList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inspector__list-editor">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className="inspector__list-item">
|
||||
<div key={idx} className="group/item flex items-center gap-1">
|
||||
{editingIndex === idx ? (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
@@ -319,19 +309,19 @@ function EditableList({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="inspector__list-item-text"
|
||||
<span
|
||||
className="flex-1 cursor-pointer truncate rounded px-1 py-0.5 text-[13px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => handleStartEdit(idx)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
<button
|
||||
className="inspector__list-item-delete"
|
||||
className="border-none bg-transparent p-0 px-1 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/item:opacity-100"
|
||||
onClick={() => handleDeleteItem(idx)}
|
||||
title="Remove item"
|
||||
>
|
||||
×
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -339,7 +329,7 @@ function EditableList({
|
||||
))}
|
||||
{isAddingNew ? (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
@@ -350,7 +340,7 @@ function EditableList({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="inspector__list-add"
|
||||
className="border-none bg-transparent p-0 py-1 text-left text-xs text-muted-foreground hover:text-secondary-foreground"
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
>
|
||||
+ Add item
|
||||
@@ -363,13 +353,13 @@ function EditableList({
|
||||
function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: string[]; onNavigate: (target: string) => void }) {
|
||||
if (refs.length === 0) return null
|
||||
return (
|
||||
<div className="inspector__rel-group">
|
||||
<span className="inspector__rel-label">{label}</span>
|
||||
<div className="inspector__rel-links">
|
||||
<div className="mb-2.5">
|
||||
<span className="mb-1 block text-[11px] text-muted-foreground">{label}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{refs.map((ref, idx) => (
|
||||
<button
|
||||
key={`${ref}-${idx}`}
|
||||
className="inspector__rel-link"
|
||||
className="border-none bg-transparent p-0 py-0.5 text-left text-[13px] text-primary cursor-pointer hover:underline"
|
||||
onClick={() => onNavigate(wikilinkTarget(ref))}
|
||||
>
|
||||
{wikilinkDisplay(ref)}
|
||||
@@ -380,12 +370,12 @@ function RelationshipGroup({ label, refs, onNavigate }: { label: string; refs: s
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicRelationshipsPanel({
|
||||
frontmatter,
|
||||
onNavigate
|
||||
}: {
|
||||
function DynamicRelationshipsPanel({
|
||||
frontmatter,
|
||||
onNavigate
|
||||
}: {
|
||||
frontmatter: ParsedFrontmatter
|
||||
onNavigate: (target: string) => void
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
// Find all keys that contain wikilinks
|
||||
const relationshipEntries = useMemo(() => {
|
||||
@@ -413,16 +403,16 @@ function DynamicRelationshipsPanel({
|
||||
|
||||
if (relationshipEntries.length === 0) {
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>Relationships</h4>
|
||||
<p className="inspector__empty">No relationships</p>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>Relationships</h4>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
|
||||
{relationshipEntries.map(({ key, refs }) => (
|
||||
<RelationshipGroup key={key} label={key} refs={refs} onNavigate={onNavigate} />
|
||||
))}
|
||||
@@ -430,14 +420,14 @@ function DynamicRelationshipsPanel({
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicPropertiesPanel({
|
||||
entry,
|
||||
function DynamicPropertiesPanel({
|
||||
entry,
|
||||
content,
|
||||
frontmatter,
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
onAddProperty,
|
||||
}: {
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
@@ -449,9 +439,9 @@ function DynamicPropertiesPanel({
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
|
||||
|
||||
const wordCount = countWords(content)
|
||||
|
||||
|
||||
// Filter out relationship keys and skipped keys
|
||||
const propertyEntries = useMemo(() => {
|
||||
return Object.entries(frontmatter)
|
||||
@@ -528,7 +518,7 @@ function DynamicPropertiesPanel({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Status gets special rendering but is still editable
|
||||
if (key === 'Status' || key.includes('Status')) {
|
||||
const statusStr = String(value)
|
||||
@@ -536,7 +526,7 @@ function DynamicPropertiesPanel({
|
||||
if (editingKey === key) {
|
||||
return (
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
defaultValue={statusStr}
|
||||
onKeyDown={(e) => {
|
||||
@@ -549,8 +539,8 @@ function DynamicPropertiesPanel({
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inspector__status-pill inspector__status-pill--editable"
|
||||
<span
|
||||
className="inline-block cursor-pointer rounded-xl px-2.5 py-0.5 text-xs font-medium text-white transition-opacity hover:opacity-80"
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => setEditingKey(key)}
|
||||
title="Click to edit"
|
||||
@@ -559,7 +549,7 @@ function DynamicPropertiesPanel({
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Arrays get list editor
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
@@ -570,12 +560,9 @@ function DynamicPropertiesPanel({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Date fields - still editable but formatted
|
||||
if (key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')) {
|
||||
const displayValue = typeof value === 'string' && value.includes('T')
|
||||
? formatISODate(value)
|
||||
: String(value)
|
||||
return (
|
||||
<EditableValue
|
||||
value={String(value)}
|
||||
@@ -586,19 +573,19 @@ function DynamicPropertiesPanel({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Boolean
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<button
|
||||
className="inspector__bool-toggle"
|
||||
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => onUpdateProperty?.(key, !value)}
|
||||
>
|
||||
{value ? '✓ Yes' : '✗ No'}
|
||||
{value ? '\u2713 Yes' : '\u2717 No'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// Default: editable string
|
||||
return (
|
||||
<EditableValue
|
||||
@@ -612,52 +599,52 @@ function DynamicPropertiesPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>Properties</h4>
|
||||
<div className="inspector__props">
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Always show Type from entry */}
|
||||
{entry.isA && (
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Type</span>
|
||||
<span className="inspector__prop-value">{entry.isA}</span>
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Type</span>
|
||||
<span className="text-right text-secondary-foreground">{entry.isA}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Dynamic properties from frontmatter */}
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<div key={key} className="inspector__prop">
|
||||
<span className="inspector__prop-label">
|
||||
<div key={key} className="group/prop flex items-center justify-between text-[13px]">
|
||||
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
{key}
|
||||
{onDeleteProperty && (
|
||||
<button
|
||||
className="inspector__prop-delete"
|
||||
className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100"
|
||||
onClick={() => onDeleteProperty(key)}
|
||||
title="Delete property"
|
||||
>
|
||||
×
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{renderEditableValue(key, value)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{/* Always show Modified and Words (read-only) */}
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Modified</span>
|
||||
<span className="inspector__prop-value">{formatDate(entry.modifiedAt)}</span>
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Modified</span>
|
||||
<span className="text-right text-secondary-foreground">{formatDate(entry.modifiedAt)}</span>
|
||||
</div>
|
||||
<div className="inspector__prop">
|
||||
<span className="inspector__prop-label">Words</span>
|
||||
<span className="inspector__prop-value">{wordCount}</span>
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span className="shrink-0 text-muted-foreground">Words</span>
|
||||
<span className="text-right text-secondary-foreground">{wordCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Add property UI */}
|
||||
{showAddDialog ? (
|
||||
<div className="inspector__add-dialog">
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
placeholder="Property name"
|
||||
value={newKey}
|
||||
@@ -666,21 +653,21 @@ function DynamicPropertiesPanel({
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="inspector__edit-input"
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
onKeyDown={handleAddKeyDown}
|
||||
/>
|
||||
<div className="inspector__add-dialog-buttons">
|
||||
<button onClick={handleAddProperty} disabled={!newKey.trim()}>Add</button>
|
||||
<button onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</button>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="xs" onClick={handleAddProperty} disabled={!newKey.trim()}>Add</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => { setShowAddDialog(false); setNewKey(''); setNewValue('') }}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="inspector__add-prop"
|
||||
<button
|
||||
className="mt-3 w-full cursor-pointer rounded-md border border-dashed border-border bg-transparent px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
disabled={!onAddProperty}
|
||||
>
|
||||
@@ -726,20 +713,22 @@ function useBacklinks(
|
||||
|
||||
function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; onNavigate: (target: string) => void }) {
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>Backlinks {backlinks.length > 0 && <span className="inspector__count">{backlinks.length}</span>}</h4>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Backlinks {backlinks.length > 0 && <span className="ml-1 text-[11px] font-normal text-muted-foreground">{backlinks.length}</span>}
|
||||
</h4>
|
||||
{backlinks.length === 0 ? (
|
||||
<p className="inspector__empty">No backlinks</p>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
|
||||
) : (
|
||||
<div className="inspector__backlinks">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{backlinks.map((e) => (
|
||||
<button
|
||||
key={e.path}
|
||||
className="inspector__backlink"
|
||||
className="flex items-center justify-between gap-2 border-none bg-transparent p-0 py-1 text-left text-[13px] text-primary cursor-pointer hover:opacity-80"
|
||||
onClick={() => onNavigate(e.title)}
|
||||
>
|
||||
<span className="inspector__backlink-title">{e.title}</span>
|
||||
{e.isA && <span className="inspector__backlink-type">{e.isA}</span>}
|
||||
<span className="flex-1 truncate">{e.title}</span>
|
||||
{e.isA && <span className="shrink-0 text-[11px] text-muted-foreground">{e.isA}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -762,24 +751,24 @@ function formatRelativeDate(timestamp: number): string {
|
||||
|
||||
function GitHistoryPanel({ commits }: { commits: GitCommit[] }) {
|
||||
return (
|
||||
<div className="inspector__section">
|
||||
<h4>History</h4>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">History</h4>
|
||||
{commits.length === 0 ? (
|
||||
<p className="inspector__empty">No revision history</p>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="inspector__commits">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{commits.map((c) => (
|
||||
<div key={c.hash} className="inspector__commit">
|
||||
<div className="inspector__commit-top">
|
||||
<span className="inspector__commit-hash">{c.shortHash}</span>
|
||||
<span className="inspector__commit-date">{formatRelativeDate(c.date)}</span>
|
||||
<div key={c.hash} className="border-l-2 border-border pl-2.5">
|
||||
<div className="mb-0.5 flex items-center justify-between">
|
||||
<span className="font-mono text-[11px] text-primary">{c.shortHash}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{formatRelativeDate(c.date)}</span>
|
||||
</div>
|
||||
<div className="inspector__commit-msg">{c.message}</div>
|
||||
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="inspector__view-all" disabled>
|
||||
<button className="mt-2.5 cursor-not-allowed border-none bg-transparent p-0 py-1 text-xs text-muted-foreground" disabled>
|
||||
View all revisions
|
||||
</button>
|
||||
</>
|
||||
@@ -788,14 +777,14 @@ function GitHistoryPanel({ commits }: { commits: GitCommit[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
collapsed,
|
||||
onToggle,
|
||||
entry,
|
||||
content,
|
||||
entries,
|
||||
allContent,
|
||||
gitHistory,
|
||||
export function Inspector({
|
||||
collapsed,
|
||||
onToggle,
|
||||
entry,
|
||||
content,
|
||||
entries,
|
||||
allContent,
|
||||
gitHistory,
|
||||
onNavigate,
|
||||
onUpdateFrontmatter,
|
||||
onDeleteProperty,
|
||||
@@ -823,20 +812,27 @@ export function Inspector({
|
||||
}, [entry, onAddProperty])
|
||||
|
||||
return (
|
||||
<aside className={`inspector ${collapsed ? 'inspector--collapsed' : ''}`}>
|
||||
<div className="inspector__header" data-tauri-drag-region>
|
||||
<button className="inspector__toggle" onClick={onToggle}>
|
||||
<aside className={cn(
|
||||
"flex flex-col overflow-y-auto border-l border-border bg-sidebar text-sidebar-foreground transition-[width] duration-200",
|
||||
collapsed && "!w-10 !min-w-10"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-3.5" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{collapsed ? '\u25C0' : '\u25B6'}
|
||||
</button>
|
||||
{!collapsed && <h3>Inspector</h3>}
|
||||
{!collapsed && <h3 className="m-0 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">Inspector</h3>}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="inspector__content">
|
||||
<div className="p-3">
|
||||
{entry ? (
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
content={content}
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry}
|
||||
content={content}
|
||||
frontmatter={frontmatter}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
@@ -848,21 +844,21 @@ export function Inspector({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="inspector__section">
|
||||
<h4>Properties</h4>
|
||||
<p className="inspector__empty">No note selected</p>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Properties</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No note selected</p>
|
||||
</div>
|
||||
<div className="inspector__section">
|
||||
<h4>Relationships</h4>
|
||||
<p className="inspector__empty">No relationships</p>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No relationships</p>
|
||||
</div>
|
||||
<div className="inspector__section">
|
||||
<h4>Backlinks</h4>
|
||||
<p className="inspector__empty">No backlinks</p>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backlinks</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No backlinks</p>
|
||||
</div>
|
||||
<div className="inspector__section">
|
||||
<h4>History</h4>
|
||||
<p className="inspector__empty">No revision history</p>
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">History</h4>
|
||||
<p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
},
|
||||
{
|
||||
@@ -33,6 +34,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
},
|
||||
{
|
||||
@@ -47,6 +49,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
},
|
||||
{
|
||||
@@ -61,6 +64,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
},
|
||||
{
|
||||
@@ -75,6 +79,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
|
||||
import './NoteList.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
interface NoteListProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -20,11 +24,8 @@ interface RelationshipGroup {
|
||||
/** Extract first ~80 chars of content after the title heading */
|
||||
function getSnippet(content: string | undefined): string {
|
||||
if (!content) return ''
|
||||
// Remove frontmatter
|
||||
const withoutFm = content.replace(/^---[\s\S]*?---\s*/, '')
|
||||
// Remove the first heading
|
||||
const withoutH1 = withoutFm.replace(/^#\s+.*\n+/, '')
|
||||
// Clean markdown syntax and collapse whitespace
|
||||
const clean = withoutH1
|
||||
.replace(/[#*_`\[\]]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
@@ -32,13 +33,11 @@ function getSnippet(content: string | undefined): string {
|
||||
return clean.slice(0, 80) + (clean.length > 80 ? '...' : '')
|
||||
}
|
||||
|
||||
/** Format a relative date string */
|
||||
function relativeDate(ts: number | null): string {
|
||||
if (!ts) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - ts
|
||||
if (diff < 0) {
|
||||
// Future date - just show the date
|
||||
const date = new Date(ts * 1000)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
@@ -50,15 +49,11 @@ function relativeDate(ts: number | null): string {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/** Get the best date to display for an entry (prefer modifiedAt, fallback to createdAt) */
|
||||
function getDisplayDate(entry: VaultEntry): number | null {
|
||||
// Prefer modifiedAt (most recent activity), but fall back to createdAt
|
||||
return entry.modifiedAt ?? entry.createdAt
|
||||
}
|
||||
|
||||
/** Check if a wikilink array (e.g. belongsTo) references a given entry by path stem */
|
||||
function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
// Extract the path stem: /Users/luca/Laputa/project/26q1-laputa-app.md → project/26q1-laputa-app
|
||||
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
return refs.some((ref) => {
|
||||
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '')
|
||||
@@ -66,7 +61,6 @@ function refsMatch(refs: string[], entry: VaultEntry): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve wikilink references to actual VaultEntry objects */
|
||||
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
|
||||
return refs
|
||||
.map((ref) => {
|
||||
@@ -86,12 +80,10 @@ function sortByModified(a: VaultEntry, b: VaultEntry): number {
|
||||
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
|
||||
}
|
||||
|
||||
/** Build relationship groups for an entity view */
|
||||
function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]): RelationshipGroup[] {
|
||||
const groups: RelationshipGroup[] = []
|
||||
const seen = new Set<string>([entity.path])
|
||||
|
||||
// 1. Children: items whose belongsTo references this entity (non-events)
|
||||
const children = allEntries
|
||||
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
|
||||
.sort(sortByModified)
|
||||
@@ -100,7 +92,6 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
|
||||
children.forEach((e) => seen.add(e.path))
|
||||
}
|
||||
|
||||
// 2. Events that reference this entity (via belongsTo or relatedTo)
|
||||
const events = allEntries
|
||||
.filter(
|
||||
(e) =>
|
||||
@@ -114,7 +105,6 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
|
||||
events.forEach((e) => seen.add(e.path))
|
||||
}
|
||||
|
||||
// 3. Referenced By: non-event items whose relatedTo references this entity
|
||||
const referencedBy = allEntries
|
||||
.filter((e) => !seen.has(e.path) && e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
|
||||
.sort(sortByModified)
|
||||
@@ -123,14 +113,12 @@ function buildRelationshipGroups(entity: VaultEntry, allEntries: VaultEntry[]):
|
||||
referencedBy.forEach((e) => seen.add(e.path))
|
||||
}
|
||||
|
||||
// 4. Belongs To: resolve this entity's own belongsTo references
|
||||
const belongsTo = resolveRefs(entity.belongsTo, allEntries).filter((e) => !seen.has(e.path))
|
||||
if (belongsTo.length > 0) {
|
||||
groups.push({ label: 'Belongs To', entries: belongsTo })
|
||||
belongsTo.forEach((e) => seen.add(e.path))
|
||||
}
|
||||
|
||||
// 5. Related To: resolve this entity's own relatedTo references
|
||||
const relatedTo = resolveRefs(entity.relatedTo, allEntries).filter((e) => !seen.has(e.path))
|
||||
if (relatedTo.length > 0) {
|
||||
groups.push({ label: 'Related To', entries: relatedTo })
|
||||
@@ -156,17 +144,14 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection, modif
|
||||
return entries.filter((e) => modifiedPaths.has(e.path))
|
||||
}
|
||||
case 'favorites':
|
||||
// TODO: Implement favorites (needs a "favorite" field in frontmatter)
|
||||
return []
|
||||
case 'trash':
|
||||
// TODO: Implement trash (needs deleted/archived status)
|
||||
return []
|
||||
}
|
||||
break
|
||||
case 'sectionGroup':
|
||||
return entries.filter((e) => e.isA === selection.type)
|
||||
case 'entity':
|
||||
// Handled separately via buildRelationshipGroups
|
||||
return []
|
||||
case 'topic': {
|
||||
const topic = selection.entry
|
||||
@@ -186,20 +171,33 @@ const TYPE_PILLS = [
|
||||
{ label: 'Responsibilities', type: 'Responsibility' },
|
||||
] as const
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
project: 'bg-[rgba(74,158,255,0.15)] text-[#4a9eff]',
|
||||
responsibility: 'bg-[rgba(156,114,255,0.15)] text-[#9c72ff]',
|
||||
procedure: 'bg-[rgba(255,152,0,0.15)] text-[#ff9800]',
|
||||
experiment: 'bg-[rgba(0,200,150,0.15)] text-[#00c896]',
|
||||
note: 'bg-[rgba(200,200,200,0.1)] text-[#999]',
|
||||
person: 'bg-[rgba(255,100,130,0.15)] text-[#ff6482]',
|
||||
event: 'bg-[rgba(255,200,50,0.15)] text-[#e6b800]',
|
||||
topic: 'bg-[rgba(100,220,200,0.15)] text-[#64dcc8]',
|
||||
}
|
||||
|
||||
const GIT_STATUS_COLORS: Record<string, string> = {
|
||||
modified: 'bg-[rgba(255,152,0,0.15)] text-[#ff9800]',
|
||||
added: 'bg-[rgba(76,175,80,0.15)] text-[#4caf50]',
|
||||
deleted: 'bg-[rgba(244,67,54,0.15)] text-[#f44336]',
|
||||
untracked: 'bg-[rgba(76,175,80,0.15)] text-[#4caf50]',
|
||||
renamed: 'bg-[rgba(33,150,243,0.15)] text-[#2196f3]',
|
||||
}
|
||||
|
||||
export function NoteList({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null)
|
||||
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
|
||||
// Entity view: build relationship groups
|
||||
const entityGroups = isEntityView
|
||||
? buildRelationshipGroups(selection.entry, entries)
|
||||
: []
|
||||
|
||||
const entityGroups = isEntityView ? buildRelationshipGroups(selection.entry, entries) : []
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
|
||||
// Build a lookup for modified file status
|
||||
const modifiedStatusMap = new Map<string, string>()
|
||||
if (modifiedFiles) {
|
||||
for (const f of modifiedFiles) {
|
||||
@@ -207,14 +205,11 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
|
||||
}
|
||||
}
|
||||
|
||||
// Non-entity view: flat filtered list
|
||||
const filtered = isEntityView ? [] : filterEntries(entries, selection, modifiedFiles)
|
||||
const sorted = isEntityView ? [] : [...filtered].sort(sortByModified)
|
||||
|
||||
// Search filter
|
||||
const query = search.trim().toLowerCase()
|
||||
|
||||
// For entity view: filter within groups
|
||||
const searchedGroups = query
|
||||
? entityGroups
|
||||
.map((g) => ({
|
||||
@@ -224,26 +219,22 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
|
||||
.filter((g) => g.entries.length > 0)
|
||||
: entityGroups
|
||||
|
||||
// For flat view
|
||||
const searched = query
|
||||
? sorted.filter((e) => e.title.toLowerCase().includes(query))
|
||||
: sorted
|
||||
|
||||
// Compute per-type counts from the searched results (before type filter)
|
||||
const typeCounts = new Map<string | null, number>()
|
||||
typeCounts.set(null, searched.length) // "All" count
|
||||
typeCounts.set(null, searched.length)
|
||||
for (const entry of searched) {
|
||||
if (entry.isA) {
|
||||
typeCounts.set(entry.isA, (typeCounts.get(entry.isA) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Type filter pills (flat view only)
|
||||
const displayed = typeFilter
|
||||
? searched.filter((e) => e.isA === typeFilter)
|
||||
: searched
|
||||
|
||||
// Total count for header
|
||||
const totalCount = isEntityView
|
||||
? searchedGroups.reduce((sum, g) => sum + g.entries.length, 0)
|
||||
: displayed.length
|
||||
@@ -253,53 +244,80 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={`note-list__item${isPinned ? ' note-list__item--pinned' : ''}${
|
||||
selectedNote?.path === entry.path ? ' note-list__item--selected' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-[var(--border-subtle)] px-4 py-2.5 transition-colors",
|
||||
isPinned && "border-l-[3px] border-l-[var(--accent-green)] bg-muted pl-[13px]",
|
||||
selectedNote?.path === entry.path && !isPinned && "border-l-[3px] border-l-primary bg-[var(--bg-selected)] pl-[13px]",
|
||||
!isPinned && selectedNote?.path !== entry.path && "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => onSelectNote(entry)}
|
||||
>
|
||||
<div className="note-list__item-top">
|
||||
<div className="note-list__title">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center truncate text-[13px] font-semibold text-foreground">
|
||||
{isChangesView && gitStatus && (
|
||||
<span className={`note-list__git-status note-list__git-status--${gitStatus}`}>
|
||||
<span className={cn(
|
||||
"mr-1.5 inline-flex size-[18px] shrink-0 items-center justify-center rounded-sm font-mono text-[10px] font-bold",
|
||||
GIT_STATUS_COLORS[gitStatus]
|
||||
)}>
|
||||
{gitStatus === 'modified' ? 'M' : gitStatus === 'added' ? 'A' : gitStatus === 'deleted' ? 'D' : gitStatus === 'untracked' ? '?' : 'R'}
|
||||
</span>
|
||||
)}
|
||||
{entry.title}
|
||||
<span className="truncate">{entry.title}</span>
|
||||
</div>
|
||||
<span className="note-list__date">{relativeDate(getDisplayDate(entry))}</span>
|
||||
<span className="shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{relativeDate(getDisplayDate(entry))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="note-list__snippet">{getSnippet(allContent[entry.path])}</div>
|
||||
<div className="note-list__meta">
|
||||
{entry.isA && <span className={`note-list__type note-list__type--${entry.isA.toLowerCase()}`}>{entry.isA}</span>}
|
||||
{entry.status && <span className="note-list__status">{entry.status}</span>}
|
||||
<div className="mt-0.5 truncate text-xs leading-relaxed text-muted-foreground">
|
||||
{getSnippet(allContent[entry.path])}
|
||||
</div>
|
||||
<div className="mt-1 flex gap-1.5 text-[11px]">
|
||||
{entry.isA && (
|
||||
<span className={cn(
|
||||
"rounded-sm px-1.5 py-px text-[10px] font-medium tracking-wide",
|
||||
TYPE_COLORS[entry.isA.toLowerCase()] ?? "bg-secondary text-secondary-foreground"
|
||||
)}>
|
||||
{entry.isA}
|
||||
</span>
|
||||
)}
|
||||
{entry.status && (
|
||||
<span className="text-[10px] text-[var(--accent-green)]">
|
||||
{entry.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="note-list">
|
||||
<div className="note-list__header" data-tauri-drag-region>
|
||||
<h3>{isEntityView ? selection.entry.title : 'Notes'}</h3>
|
||||
<div className="note-list__header-right">
|
||||
<span className="note-list__count">{totalCount}</span>
|
||||
<button className="note-list__add-btn" onClick={onCreateNote} title="Create new note">
|
||||
+
|
||||
</button>
|
||||
<div className="flex flex-col overflow-y-auto border-r border-border bg-card text-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3.5" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
|
||||
<h3 className="m-0 min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{isEntityView ? selection.entry.title : 'Notes'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<Badge variant="secondary" className="text-[11px]">{totalCount}</Badge>
|
||||
<Button variant="secondary" size="icon-xs" onClick={onCreateNote} title="Create new note">
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="note-list__search">
|
||||
<input
|
||||
type="text"
|
||||
className="note-list__search-input"
|
||||
|
||||
{/* Search */}
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<Input
|
||||
placeholder="Search notes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 text-[13px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type filter pills */}
|
||||
{!isEntityView && (
|
||||
<div className="note-list__pills">
|
||||
<div className="flex flex-wrap gap-1 border-b border-border px-3 py-2">
|
||||
{TYPE_PILLS.filter(({ type }) => {
|
||||
const count = typeCounts.get(type) ?? 0
|
||||
return type === null || count > 0
|
||||
@@ -308,29 +326,38 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
className={`note-list__pill${typeFilter === type ? ' note-list__pill--active' : ''}`}
|
||||
className={cn(
|
||||
"whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] transition-colors",
|
||||
typeFilter === type
|
||||
? "border-primary/20 bg-primary/10 text-primary"
|
||||
: "border-border bg-transparent text-muted-foreground hover:bg-secondary hover:text-secondary-foreground"
|
||||
)}
|
||||
onClick={() => setTypeFilter(type)}
|
||||
>
|
||||
{label} <span className="note-list__pill-count">{count}</span>
|
||||
{label} <span className="ml-0.5 opacity-60 text-[10px]">{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="note-list__items">
|
||||
|
||||
{/* Items */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isEntityView ? (
|
||||
<>
|
||||
{renderItem(selection.entry, true)}
|
||||
{searchedGroups.length === 0 ? (
|
||||
<div className="note-list__empty">
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
{query ? 'No matching items' : 'No related items'}
|
||||
</div>
|
||||
) : (
|
||||
searchedGroups.map((group) => (
|
||||
<div key={group.label} className="note-list__group">
|
||||
<div className="note-list__group-header">
|
||||
<span className="note-list__group-label">{group.label}</span>
|
||||
<span className="note-list__group-count">{group.entries.length}</span>
|
||||
<div key={group.label} className="border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 pt-3">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">{group.entries.length}</Badge>
|
||||
</div>
|
||||
{group.entries.map((entry) => renderItem(entry))}
|
||||
</div>
|
||||
@@ -339,7 +366,7 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
|
||||
</>
|
||||
) : (
|
||||
displayed.length === 0 ? (
|
||||
<div className="note-list__empty">No notes found</div>
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
|
||||
) : (
|
||||
displayed.map((entry) => renderItem(entry))
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import './QuickOpenPalette.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface QuickOpenPaletteProps {
|
||||
open: boolean
|
||||
@@ -19,9 +20,7 @@ function fuzzyMatch(query: string, target: string): { match: boolean; score: num
|
||||
|
||||
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
||||
if (t[ti] === q[qi]) {
|
||||
// Bonus for consecutive matches
|
||||
if (ti === lastMatchIndex + 1) score += 2
|
||||
// Bonus for matching at start or after separator
|
||||
if (ti === 0 || t[ti - 1] === ' ' || t[ti - 1] === '-') score += 3
|
||||
score += 1
|
||||
lastMatchIndex = ti
|
||||
@@ -48,7 +47,6 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim()) {
|
||||
// Show all entries sorted by most recently modified
|
||||
return [...entries].sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)).slice(0, 20)
|
||||
}
|
||||
return entries
|
||||
@@ -59,19 +57,16 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
.map((r) => r.entry)
|
||||
}, [entries, query])
|
||||
|
||||
// Keep selectedIndex in bounds
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [query])
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return
|
||||
const selected = listRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
selected?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
|
||||
// Close on Escape, navigate with arrows, select with Enter
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -99,32 +94,47 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="palette__overlay" onClick={onClose}>
|
||||
<div className="palette" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="palette__input"
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="palette__results" ref={listRef}>
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
{results.length === 0 ? (
|
||||
<div className="palette__empty">No matching notes</div>
|
||||
<div className="px-4 py-4 text-center text-[13px] text-muted-foreground">
|
||||
No matching notes
|
||||
</div>
|
||||
) : (
|
||||
results.map((entry, i) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={`palette__item${i === selectedIndex ? ' palette__item--selected' : ''}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between px-4 py-2 transition-colors",
|
||||
i === selectedIndex ? "bg-accent" : "hover:bg-secondary"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect(entry)
|
||||
onClose()
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
<span className="palette__item-title">{entry.title}</span>
|
||||
{entry.isA && <span className="palette__item-type">{entry.isA}</span>}
|
||||
<span className="text-sm text-foreground">{entry.title}</span>
|
||||
{entry.isA && (
|
||||
<Badge variant="secondary" className="text-[11px]">
|
||||
{entry.isA}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import './ResizeHandle.css'
|
||||
|
||||
interface ResizeHandleProps {
|
||||
onResize: (delta: number) => void
|
||||
@@ -44,5 +43,10 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
|
||||
}
|
||||
}, [onResize])
|
||||
|
||||
return <div className="resize-handle" onMouseDown={handleMouseDown} />
|
||||
return (
|
||||
<div
|
||||
className="w-1 shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border-dialog)]"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
},
|
||||
{
|
||||
@@ -30,6 +31,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
},
|
||||
{
|
||||
@@ -44,6 +46,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
},
|
||||
{
|
||||
@@ -58,6 +61,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: 'Weekly',
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 128,
|
||||
},
|
||||
{
|
||||
@@ -72,6 +76,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
},
|
||||
{
|
||||
@@ -86,6 +91,7 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 180,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import './Sidebar.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Sun, Moon, Plus, ChevronRight, ChevronDown, GitCommitHorizontal } from 'lucide-react'
|
||||
|
||||
interface SidebarProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -38,11 +41,15 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
try {
|
||||
localStorage.setItem('laputa-theme', theme)
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. in tests)
|
||||
// localStorage unavailable
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
@@ -64,79 +71,98 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__header" data-tauri-drag-region>
|
||||
<h2>Laputa</h2>
|
||||
<button
|
||||
className="sidebar__theme-toggle"
|
||||
<aside className="flex h-full flex-col overflow-y-auto bg-sidebar text-sidebar-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 pl-[78px]" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
|
||||
<h2 className="m-0 text-[17px] font-bold tracking-tight text-foreground">Laputa</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{theme === 'dark' ? '\u2600' : '\u263D'}
|
||||
</button>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar__nav">
|
||||
<div className="sidebar__filters">
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{/* Filters */}
|
||||
<div className="mb-2 border-b border-border py-1">
|
||||
{FILTERS.map(({ label, filter }) => (
|
||||
<div
|
||||
key={filter}
|
||||
className={`sidebar__filter-item${
|
||||
isActive({ kind: 'filter', filter }) ? ' sidebar__filter-item--active' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
"mx-1.5 my-px cursor-pointer rounded-md px-4 py-1.5 text-[13px] transition-colors",
|
||||
isActive({ kind: 'filter', filter })
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
onClick={() => onSelect({ kind: 'filter', filter })}
|
||||
>
|
||||
{label}
|
||||
{filter === 'changes' && modifiedCount > 0 && (
|
||||
<span className="sidebar__badge">{modifiedCount}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
{label}
|
||||
{filter === 'changes' && modifiedCount > 0 && (
|
||||
<Badge className="h-[18px] min-w-[18px] bg-[var(--accent-orange)] px-1 text-[10px] text-white">
|
||||
{modifiedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Section Groups */}
|
||||
{SECTION_GROUPS.map(({ label, type }) => {
|
||||
const items = entries.filter((e) => e.isA === type)
|
||||
const isCollapsed = collapsed[type] ?? false
|
||||
|
||||
return (
|
||||
<div key={type} className="sidebar__section">
|
||||
<div key={type} className="mb-1">
|
||||
<div
|
||||
className={`sidebar__section-header${
|
||||
isActive({ kind: 'sectionGroup', type }) ? ' sidebar__section-header--active' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
"mx-1 flex cursor-pointer select-none items-center rounded px-2 py-1.5 text-[11px] font-semibold tracking-wide",
|
||||
isActive({ kind: 'sectionGroup', type })
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-secondary-foreground"
|
||||
)}
|
||||
onClick={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
>
|
||||
<button
|
||||
className="sidebar__collapse-btn"
|
||||
className="mr-1 flex shrink-0 items-center bg-transparent border-none text-inherit cursor-pointer p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSection(type)
|
||||
}}
|
||||
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
|
||||
>
|
||||
{isCollapsed ? '\u25B8' : '\u25BE'}
|
||||
{isCollapsed ? <ChevronRight className="size-3" /> : <ChevronDown className="size-3" />}
|
||||
</button>
|
||||
<span className="sidebar__section-label">{label}</span>
|
||||
<span className="sidebar__section-count">{items.length}</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="text-[10px] text-muted-foreground mr-1">{items.length}</span>
|
||||
<button
|
||||
className="sidebar__add-btn"
|
||||
className="flex items-center bg-transparent border-none text-muted-foreground cursor-pointer p-0 opacity-0 transition-opacity group-hover/section:opacity-100 hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: Wire up create new entity
|
||||
}}
|
||||
aria-label={`Add ${type}`}
|
||||
>
|
||||
+
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar__section-items">
|
||||
<div className="py-0.5">
|
||||
{items.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={`sidebar__item${
|
||||
isActive({ kind: 'entity', entry }) ? ' sidebar__item--active' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
"mx-1.5 my-px cursor-pointer truncate rounded-md py-1.5 pl-7 pr-4 text-[13px] transition-colors",
|
||||
isActive({ kind: 'entity', entry })
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect({ kind: 'entity', entry })
|
||||
onSelectNote?.(entry)
|
||||
@@ -151,18 +177,24 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Topics */}
|
||||
{(() => {
|
||||
const topics = entries.filter((e) => e.isA === 'Topic')
|
||||
if (topics.length === 0) return null
|
||||
return (
|
||||
<div className="sidebar__topics">
|
||||
<div className="sidebar__topics-header">TOPICS</div>
|
||||
<div className="mt-2 border-t border-border pt-2">
|
||||
<div className="px-4 py-1.5 text-[11px] font-semibold tracking-wide text-muted-foreground select-none">
|
||||
TOPICS
|
||||
</div>
|
||||
{topics.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={`sidebar__topic-item${
|
||||
isActive({ kind: 'topic', entry }) ? ' sidebar__topic-item--active' : ''
|
||||
}`}
|
||||
className={cn(
|
||||
"mx-1.5 my-px cursor-pointer truncate rounded-md py-1.5 pl-7 pr-4 text-[13px] transition-colors",
|
||||
isActive({ kind: 'topic', entry })
|
||||
? "bg-primary/10 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect({ kind: 'topic', entry })
|
||||
onSelectNote?.(entry)
|
||||
@@ -175,12 +207,17 @@ export function Sidebar({ entries, selection, onSelect, onSelectNote, modifiedCo
|
||||
)
|
||||
})()}
|
||||
</nav>
|
||||
|
||||
{/* Commit button */}
|
||||
{modifiedCount > 0 && onCommitPush && (
|
||||
<div className="sidebar__commit">
|
||||
<button className="sidebar__commit-btn" onClick={onCommitPush}>
|
||||
<div className="shrink-0 border-t border-border p-3">
|
||||
<Button className="w-full gap-1.5" size="sm" onClick={onCommitPush}>
|
||||
<GitCommitHorizontal className="size-3.5" />
|
||||
Commit & Push
|
||||
<span className="sidebar__badge">{modifiedCount}</span>
|
||||
</button>
|
||||
<Badge className="ml-1 bg-white/25 text-white text-[10px] h-[18px] min-w-[18px] px-1">
|
||||
{modifiedCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import './Toast.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ToastProps {
|
||||
message: string | null
|
||||
@@ -16,7 +16,12 @@ export function Toast({ message, onDismiss }: ToastProps) {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div className="toast">
|
||||
<div className={cn(
|
||||
"fixed bottom-8 left-1/2 -translate-x-1/2 z-[1100]",
|
||||
"bg-secondary text-foreground px-5 py-2 rounded-lg text-[13px]",
|
||||
"shadow-[0_4px_16px_var(--shadow-dialog)]",
|
||||
"animate-in slide-in-from-bottom-2 fade-in duration-200"
|
||||
)}>
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
|
||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal 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 }
|
||||
64
src/components/ui/button.tsx
Normal file
64
src/components/ui/button.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
156
src/components/ui/dialog.tsx
Normal file
156
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
56
src/components/ui/scroll-area.tsx
Normal file
56
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
188
src/components/ui/select.tsx
Normal file
188
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal 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 }
|
||||
89
src/components/ui/tabs.tsx
Normal file
89
src/components/ui/tabs.tsx
Normal 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 }
|
||||
57
src/components/ui/tooltip.tsx
Normal file
57
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
226
src/index.css
226
src/index.css
@@ -1,72 +1,58 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ============================================================
|
||||
Theme Variables
|
||||
shadcn CSS variables + app-specific variables for both modes.
|
||||
Dark mode is default (html.dark), light mode = no .dark class.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* --- Font defaults --- */
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Dark theme (default) */
|
||||
color-scheme: dark;
|
||||
/* --- shadcn theme variables (light mode) --- */
|
||||
--radius: 0.5rem;
|
||||
--background: #FFFFFF;
|
||||
--foreground: #37352F;
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #37352F;
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #37352F;
|
||||
--primary: #2383E2;
|
||||
--primary-foreground: #FFFFFF;
|
||||
--secondary: #EBEBEA;
|
||||
--secondary-foreground: #37352F;
|
||||
--muted: #F0F0EF;
|
||||
--muted-foreground: #787774;
|
||||
--accent: #EBEBEA;
|
||||
--accent-foreground: #37352F;
|
||||
--destructive: #E03E3E;
|
||||
--destructive-foreground: #FFFFFF;
|
||||
--border: #E9E9E7;
|
||||
--input: #E9E9E7;
|
||||
--ring: #2383E2;
|
||||
--sidebar: #F7F6F3;
|
||||
--sidebar-foreground: #37352F;
|
||||
--sidebar-primary: #2383E2;
|
||||
--sidebar-primary-foreground: #FFFFFF;
|
||||
--sidebar-accent: #EBEBEA;
|
||||
--sidebar-accent-foreground: #37352F;
|
||||
--sidebar-border: #E9E9E7;
|
||||
--sidebar-ring: #2383E2;
|
||||
|
||||
/* Backgrounds */
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-sidebar: #1a1a2e;
|
||||
--bg-card: #16162a;
|
||||
--bg-hover: #252545;
|
||||
--bg-hover-subtle: #1e1e3a;
|
||||
--bg-selected: #24244a;
|
||||
--bg-input: #1e1e3a;
|
||||
--bg-button: #2a2a4a;
|
||||
--bg-dialog: #1e1e3a;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #aaa;
|
||||
--text-tertiary: #888;
|
||||
--text-muted: #666;
|
||||
--text-faint: #555;
|
||||
--text-heading: #ffffff;
|
||||
|
||||
/* Accents */
|
||||
--accent-blue: #4a9eff;
|
||||
--accent-blue-bg: #4a9eff18;
|
||||
--accent-blue-hover: #3a8eef;
|
||||
--accent-green: #4caf50;
|
||||
--accent-orange: #ff9800;
|
||||
--accent-red: #f44336;
|
||||
--accent-purple: #9c72ff;
|
||||
|
||||
/* Borders */
|
||||
--border-primary: #2a2a4a;
|
||||
--border-subtle: rgba(42, 42, 74, 0.5);
|
||||
--border-input: #2a2a4a;
|
||||
--border-dialog: #4a4a6a;
|
||||
|
||||
/* Links */
|
||||
--link-color: #7eb8da;
|
||||
--link-hover: #a8d8f0;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-overlay: rgba(0, 0, 0, 0.6);
|
||||
--shadow-dialog: rgba(0, 0, 0, 0.5);
|
||||
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
/* --- App-specific variables (light mode) --- */
|
||||
color-scheme: light;
|
||||
|
||||
/* Backgrounds */
|
||||
--bg-primary: #FFFFFF;
|
||||
--bg-sidebar: #F7F6F3;
|
||||
--bg-card: #FFFFFF;
|
||||
@@ -76,16 +62,12 @@
|
||||
--bg-input: #FFFFFF;
|
||||
--bg-button: #EBEBEA;
|
||||
--bg-dialog: #FFFFFF;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #37352F;
|
||||
--text-secondary: #787774;
|
||||
--text-tertiary: #787774;
|
||||
--text-muted: #B4B4B4;
|
||||
--text-faint: #B4B4B4;
|
||||
--text-heading: #37352F;
|
||||
|
||||
/* Accents */
|
||||
--accent-blue: #2383E2;
|
||||
--accent-blue-bg: #2383E218;
|
||||
--accent-blue-hover: #1a6bb5;
|
||||
@@ -93,29 +75,129 @@
|
||||
--accent-orange: #D9730D;
|
||||
--accent-red: #E03E3E;
|
||||
--accent-purple: #9065B0;
|
||||
|
||||
/* Borders */
|
||||
--border-primary: #E9E9E7;
|
||||
--border-subtle: #E9E9E7;
|
||||
--border-input: #E9E9E7;
|
||||
--border-dialog: #E9E9E7;
|
||||
|
||||
/* Links */
|
||||
--link-color: #2383E2;
|
||||
--link-hover: #1a6bb5;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-overlay: rgba(0, 0, 0, 0.3);
|
||||
--shadow-dialog: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
.dark {
|
||||
/* --- shadcn theme variables (dark mode) --- */
|
||||
--background: #0f0f1a;
|
||||
--foreground: #e0e0e0;
|
||||
--card: #16162a;
|
||||
--card-foreground: #e0e0e0;
|
||||
--popover: #1e1e3a;
|
||||
--popover-foreground: #e0e0e0;
|
||||
--primary: #4a9eff;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #2a2a4a;
|
||||
--secondary-foreground: #e0e0e0;
|
||||
--muted: #1e1e3a;
|
||||
--muted-foreground: #888;
|
||||
--accent: #252545;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #f44336;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #2a2a4a;
|
||||
--input: #2a2a4a;
|
||||
--ring: #4a9eff;
|
||||
--sidebar: #1a1a2e;
|
||||
--sidebar-foreground: #e0e0e0;
|
||||
--sidebar-primary: #4a9eff;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
--sidebar-accent: #252545;
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #2a2a4a;
|
||||
--sidebar-ring: #4a9eff;
|
||||
|
||||
/* --- App-specific variables (dark mode) --- */
|
||||
color-scheme: dark;
|
||||
--bg-primary: #0f0f1a;
|
||||
--bg-sidebar: #1a1a2e;
|
||||
--bg-card: #16162a;
|
||||
--bg-hover: #252545;
|
||||
--bg-hover-subtle: #1e1e3a;
|
||||
--bg-selected: #24244a;
|
||||
--bg-input: #1e1e3a;
|
||||
--bg-button: #2a2a4a;
|
||||
--bg-dialog: #1e1e3a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #aaa;
|
||||
--text-tertiary: #888;
|
||||
--text-muted: #666;
|
||||
--text-faint: #555;
|
||||
--text-heading: #ffffff;
|
||||
--accent-blue: #4a9eff;
|
||||
--accent-blue-bg: #4a9eff18;
|
||||
--accent-blue-hover: #3a8eef;
|
||||
--accent-green: #4caf50;
|
||||
--accent-orange: #ff9800;
|
||||
--accent-red: #f44336;
|
||||
--accent-purple: #9c72ff;
|
||||
--border-primary: #2a2a4a;
|
||||
--border-subtle: rgba(42, 42, 74, 0.5);
|
||||
--border-input: #2a2a4a;
|
||||
--border-dialog: #4a4a6a;
|
||||
--link-color: #7eb8da;
|
||||
--link-hover: #a8d8f0;
|
||||
--shadow-overlay: rgba(0, 0, 0, 0.6);
|
||||
--shadow-dialog: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
/* --- Tailwind v4 theme inline: register colors + radii --- */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
/* --- Base layer --- */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
13
src/main.tsx
13
src/main.tsx
@@ -1,10 +1,21 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Set initial theme class on <html> before render
|
||||
const savedTheme = (() => {
|
||||
try { return localStorage.getItem('laputa-theme') } catch { return null }
|
||||
})()
|
||||
if (savedTheme !== 'light') {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<TooltipProvider>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -3,5 +3,11 @@
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
|
||||
// Prevent vite from obscuring Rust errors
|
||||
clearScreen: false,
|
||||
|
||||
Reference in New Issue
Block a user