v0.7: GraphView rewrite (@blinksgg/canvas), WhiteboardView, DatabaseView (table/gallery/list), GitPanel, backlink context, dataview queries v0.8: OutlinePanel, TimelineView, StatusBar (doc stats), TableEditor, RandomNote, link suggestions v0.9: ImportExport (ZIP/folder), ShortcutsEditor (rebind+persist), GraphAnalytics (orphans/most-connected), note pinning Backend: 15 new Rust commands, zip crate, rand crate Frontend: 18 new components, ~1400 lines CSS Dependencies: @blinksgg/canvas, jotai, graphology, d3-force
171 lines
7.3 KiB
TypeScript
171 lines
7.3 KiB
TypeScript
import { useEffect, useState, useMemo } from "react";
|
|
import { useVault } from "../App";
|
|
import { queryFrontmatter, type FrontmatterRow } from "../lib/commands";
|
|
|
|
type ViewMode = "table" | "gallery" | "list";
|
|
|
|
/**
|
|
* DatabaseView — Notion-style table/gallery/list views from frontmatter.
|
|
*/
|
|
export function DatabaseView() {
|
|
const { vaultPath, navigateToNote } = useVault();
|
|
const [rows, setRows] = useState<FrontmatterRow[]>([]);
|
|
const [viewMode, setViewMode] = useState<ViewMode>("table");
|
|
const [sortField, setSortField] = useState<string>("title");
|
|
const [sortAsc, setSortAsc] = useState(true);
|
|
const [filterField, setFilterField] = useState("");
|
|
const [filterValue, setFilterValue] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!vaultPath) return;
|
|
queryFrontmatter(vaultPath).then(setRows).catch(() => setRows([]));
|
|
}, [vaultPath]);
|
|
|
|
// All unique field keys
|
|
const allFields = useMemo(() => {
|
|
const keys = new Set<string>();
|
|
rows.forEach(r => Object.keys(r.fields).forEach(k => keys.add(k)));
|
|
return ["title", ...Array.from(keys)];
|
|
}, [rows]);
|
|
|
|
// Sort + Filter
|
|
const processed = useMemo(() => {
|
|
let data = [...rows];
|
|
if (filterField && filterValue) {
|
|
data = data.filter(r => {
|
|
const val = filterField === "title"
|
|
? r.title
|
|
: (r.fields[filterField] || "");
|
|
return val.toLowerCase().includes(filterValue.toLowerCase());
|
|
});
|
|
}
|
|
data.sort((a, b) => {
|
|
const va = sortField === "title" ? a.title : (a.fields[sortField] || "");
|
|
const vb = sortField === "title" ? b.title : (b.fields[sortField] || "");
|
|
return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va);
|
|
});
|
|
return data;
|
|
}, [rows, sortField, sortAsc, filterField, filterValue]);
|
|
|
|
const handleSort = (field: string) => {
|
|
if (sortField === field) setSortAsc(!sortAsc);
|
|
else { setSortField(field); setSortAsc(true); }
|
|
};
|
|
|
|
return (
|
|
<div className="database-view">
|
|
<div className="database-header">
|
|
<h2 className="database-title">📊 Database</h2>
|
|
<div className="database-controls">
|
|
<div className="database-filter">
|
|
<select
|
|
className="database-select"
|
|
value={filterField}
|
|
onChange={e => setFilterField(e.target.value)}
|
|
>
|
|
<option value="">Filter by…</option>
|
|
{allFields.map(f => <option key={f} value={f}>{f}</option>)}
|
|
</select>
|
|
{filterField && (
|
|
<input
|
|
className="database-filter-input"
|
|
value={filterValue}
|
|
onChange={e => setFilterValue(e.target.value)}
|
|
placeholder="Contains…"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="database-mode-group">
|
|
{(["table", "gallery", "list"] as ViewMode[]).map(m => (
|
|
<button
|
|
key={m}
|
|
className={`database-mode-btn ${viewMode === m ? "active" : ""}`}
|
|
onClick={() => setViewMode(m)}
|
|
>
|
|
{m === "table" ? "📋" : m === "gallery" ? "🖼️" : "📝"} {m}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === "table" && (
|
|
<div className="database-table-wrapper">
|
|
<table className="database-table">
|
|
<thead>
|
|
<tr>
|
|
{allFields.map(f => (
|
|
<th key={f} onClick={() => handleSort(f)} className="database-th">
|
|
{f}
|
|
{sortField === f && <span>{sortAsc ? " ↑" : " ↓"}</span>}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{processed.map(row => (
|
|
<tr
|
|
key={row.path}
|
|
className="database-row"
|
|
onClick={() => navigateToNote(row.title)}
|
|
>
|
|
{allFields.map(f => (
|
|
<td key={f} className="database-td">
|
|
{f === "title" ? row.title : (row.fields[f] || "—")}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === "gallery" && (
|
|
<div className="database-gallery">
|
|
{processed.map(row => (
|
|
<div
|
|
key={row.path}
|
|
className="database-gallery-card"
|
|
onClick={() => navigateToNote(row.title)}
|
|
>
|
|
<div className="gallery-card-title">{row.title}</div>
|
|
<div className="gallery-card-fields">
|
|
{Object.entries(row.fields).slice(0, 3).map(([k, v]) => (
|
|
<div key={k} className="gallery-card-field">
|
|
<span className="gallery-field-key">{k}:</span>
|
|
<span className="gallery-field-val">{v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === "list" && (
|
|
<div className="database-list">
|
|
{processed.map(row => (
|
|
<div
|
|
key={row.path}
|
|
className="database-list-item"
|
|
onClick={() => navigateToNote(row.title)}
|
|
>
|
|
<span className="database-list-name">{row.title}</span>
|
|
<span className="database-list-meta">
|
|
{Object.entries(row.fields).slice(0, 2).map(([k, v]) => `${k}: ${v}`).join(" • ")}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{processed.length === 0 && (
|
|
<div className="database-empty">
|
|
<p>No notes with frontmatter found.</p>
|
|
<p>Add YAML frontmatter between <code>---</code> markers to your notes.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|