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