@md-meta-view/web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components.json +25 -0
- package/index.html +12 -0
- package/package.json +54 -0
- package/src/components/column-filter.tsx +70 -0
- package/src/components/data-table.tsx +390 -0
- package/src/components/markdown-view.tsx +104 -0
- package/src/components/theme-toggle.tsx +21 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/popover.tsx +88 -0
- package/src/components/ui/resizable.tsx +48 -0
- package/src/components/ui/table.tsx +114 -0
- package/src/hooks/use-md-data.ts +71 -0
- package/src/hooks/use-theme.ts +38 -0
- package/src/index.css +131 -0
- package/src/lib/router.ts +32 -0
- package/src/lib/search-params.test.ts +163 -0
- package/src/lib/search-params.ts +72 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +11 -0
- package/src/routes/__root.tsx +27 -0
- package/src/routes/index.tsx +131 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +14 -0
package/components.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "base-nova",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/index.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"iconLibrary": "lucide",
|
|
14
|
+
"rtl": false,
|
|
15
|
+
"aliases": {
|
|
16
|
+
"components": "@/components",
|
|
17
|
+
"utils": "@/lib/utils",
|
|
18
|
+
"ui": "@/components/ui",
|
|
19
|
+
"lib": "@/lib",
|
|
20
|
+
"hooks": "@/hooks"
|
|
21
|
+
},
|
|
22
|
+
"menuColor": "default",
|
|
23
|
+
"menuAccent": "subtle",
|
|
24
|
+
"registries": {}
|
|
25
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>md-meta-view</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@md-meta-view/web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Web frontend for md-meta-view",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/ShuntaToda/md-meta-view.git",
|
|
10
|
+
"directory": "packages/web"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"index.html",
|
|
15
|
+
"vite.config.ts",
|
|
16
|
+
"components.json",
|
|
17
|
+
"tsconfig.json"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "vite",
|
|
24
|
+
"build": "tsc -b && vite build"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@base-ui/react": "^1.3.0",
|
|
28
|
+
"@fontsource-variable/geist": "^5.2.8",
|
|
29
|
+
"@md-meta-view/core": "workspace:*",
|
|
30
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
31
|
+
"@tanstack/react-router": "^1.168.8",
|
|
32
|
+
"@tanstack/react-table": "^8.21.3",
|
|
33
|
+
"class-variance-authority": "^0.7.1",
|
|
34
|
+
"clsx": "^2.1.1",
|
|
35
|
+
"dompurify": "^3.3.3",
|
|
36
|
+
"lucide-react": "^1.7.0",
|
|
37
|
+
"marked": "^17.0.5",
|
|
38
|
+
"react": "^19.2.4",
|
|
39
|
+
"react-dom": "^19.2.4",
|
|
40
|
+
"react-resizable-panels": "^4.8.0",
|
|
41
|
+
"shadcn": "^4.1.1",
|
|
42
|
+
"tailwind-merge": "^3.5.0",
|
|
43
|
+
"tailwindcss": "^4.2.2",
|
|
44
|
+
"tw-animate-css": "^1.4.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@tailwindcss/vite": "^4.2.2",
|
|
48
|
+
"@types/react": "^19.2.14",
|
|
49
|
+
"@types/react-dom": "^19.2.3",
|
|
50
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
51
|
+
"typescript": "~5.9.3",
|
|
52
|
+
"vite": "^8.0.1"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { MdEntry } from "@md-meta-view/core";
|
|
2
|
+
import type { Column } from "@tanstack/react-table";
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
|
|
7
|
+
interface ColumnFilterProps {
|
|
8
|
+
column: Column<MdEntry>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatCellValue(value: unknown): string {
|
|
12
|
+
if (value === null || value === undefined) return "";
|
|
13
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
14
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
15
|
+
return String(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ColumnFilter({ column }: ColumnFilterProps) {
|
|
19
|
+
const columnFilterValue = column.getFilterValue() as string | undefined;
|
|
20
|
+
|
|
21
|
+
const uniqueValues = useMemo(() => {
|
|
22
|
+
const values = new Set<string>();
|
|
23
|
+
for (const row of column.getFacetedRowModel().rows) {
|
|
24
|
+
const val = row.getValue(column.id);
|
|
25
|
+
if (val === null || val === undefined) continue;
|
|
26
|
+
if (Array.isArray(val)) {
|
|
27
|
+
for (const v of val) values.add(String(v));
|
|
28
|
+
} else {
|
|
29
|
+
values.add(formatCellValue(val));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return Array.from(values).sort();
|
|
33
|
+
}, [column]);
|
|
34
|
+
|
|
35
|
+
const isFewValues = uniqueValues.length <= 20;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<fieldset
|
|
39
|
+
className="space-y-2 border-0 p-0 m-0"
|
|
40
|
+
onClick={(e) => e.stopPropagation()}
|
|
41
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
42
|
+
>
|
|
43
|
+
<Input
|
|
44
|
+
placeholder={`Filter ${column.id.replace("fm_", "")}...`}
|
|
45
|
+
value={columnFilterValue ?? ""}
|
|
46
|
+
onChange={(e) => column.setFilterValue(e.target.value || undefined)}
|
|
47
|
+
className="h-7 text-xs"
|
|
48
|
+
/>
|
|
49
|
+
{isFewValues && uniqueValues.length > 0 && (
|
|
50
|
+
<div className="flex flex-wrap gap-1 max-h-32 overflow-auto">
|
|
51
|
+
{uniqueValues.map((value) => {
|
|
52
|
+
const isActive = columnFilterValue === value;
|
|
53
|
+
return (
|
|
54
|
+
<Badge
|
|
55
|
+
key={value}
|
|
56
|
+
variant={isActive ? "default" : "outline"}
|
|
57
|
+
className="text-xs cursor-pointer hover:bg-primary/80"
|
|
58
|
+
onClick={() => {
|
|
59
|
+
column.setFilterValue(isActive ? undefined : value);
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{value || "(empty)"}
|
|
63
|
+
</Badge>
|
|
64
|
+
);
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</fieldset>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import type { MdEntry } from "@md-meta-view/core";
|
|
2
|
+
import {
|
|
3
|
+
type ColumnDef,
|
|
4
|
+
type ColumnFiltersState,
|
|
5
|
+
flexRender,
|
|
6
|
+
getCoreRowModel,
|
|
7
|
+
getFacetedRowModel,
|
|
8
|
+
getFacetedUniqueValues,
|
|
9
|
+
getFilteredRowModel,
|
|
10
|
+
getPaginationRowModel,
|
|
11
|
+
getSortedRowModel,
|
|
12
|
+
type SortingState,
|
|
13
|
+
useReactTable,
|
|
14
|
+
type VisibilityState,
|
|
15
|
+
} from "@tanstack/react-table";
|
|
16
|
+
import { useMemo, useState } from "react";
|
|
17
|
+
import { ColumnFilter } from "@/components/column-filter";
|
|
18
|
+
import { Badge } from "@/components/ui/badge";
|
|
19
|
+
import { Button } from "@/components/ui/button";
|
|
20
|
+
import { Input } from "@/components/ui/input";
|
|
21
|
+
import {
|
|
22
|
+
Popover,
|
|
23
|
+
PopoverContent,
|
|
24
|
+
PopoverTrigger,
|
|
25
|
+
} from "@/components/ui/popover";
|
|
26
|
+
import {
|
|
27
|
+
Table,
|
|
28
|
+
TableBody,
|
|
29
|
+
TableCell,
|
|
30
|
+
TableHead,
|
|
31
|
+
TableHeader,
|
|
32
|
+
TableRow,
|
|
33
|
+
} from "@/components/ui/table";
|
|
34
|
+
|
|
35
|
+
export interface TableState {
|
|
36
|
+
sorting: SortingState;
|
|
37
|
+
columnFilters: ColumnFiltersState;
|
|
38
|
+
globalFilter: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DataTableProps {
|
|
42
|
+
entries: MdEntry[];
|
|
43
|
+
keys: string[];
|
|
44
|
+
onSelect: (entry: MdEntry) => void;
|
|
45
|
+
selectedId?: string;
|
|
46
|
+
tableState?: TableState;
|
|
47
|
+
onTableStateChange?: (state: TableState) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatCellValue(value: unknown): string {
|
|
51
|
+
if (value === null || value === undefined) return "";
|
|
52
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
53
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function DataTable({
|
|
58
|
+
entries,
|
|
59
|
+
keys,
|
|
60
|
+
onSelect,
|
|
61
|
+
selectedId,
|
|
62
|
+
tableState,
|
|
63
|
+
onTableStateChange,
|
|
64
|
+
}: DataTableProps) {
|
|
65
|
+
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
|
|
66
|
+
const [internalColumnFilters, setInternalColumnFilters] =
|
|
67
|
+
useState<ColumnFiltersState>([]);
|
|
68
|
+
const [internalGlobalFilter, setInternalGlobalFilter] = useState("");
|
|
69
|
+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
70
|
+
|
|
71
|
+
const sorting = tableState?.sorting ?? internalSorting;
|
|
72
|
+
const columnFilters = tableState?.columnFilters ?? internalColumnFilters;
|
|
73
|
+
const globalFilter = tableState?.globalFilter ?? internalGlobalFilter;
|
|
74
|
+
|
|
75
|
+
const updateState = (partial: Partial<TableState>) => {
|
|
76
|
+
const next = { sorting, columnFilters, globalFilter, ...partial };
|
|
77
|
+
if (onTableStateChange) {
|
|
78
|
+
onTableStateChange(next);
|
|
79
|
+
} else {
|
|
80
|
+
if (partial.sorting !== undefined) setInternalSorting(partial.sorting);
|
|
81
|
+
if (partial.columnFilters !== undefined)
|
|
82
|
+
setInternalColumnFilters(partial.columnFilters);
|
|
83
|
+
if (partial.globalFilter !== undefined)
|
|
84
|
+
setInternalGlobalFilter(partial.globalFilter);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const columns = useMemo<ColumnDef<MdEntry>[]>(() => {
|
|
89
|
+
const cols: ColumnDef<MdEntry>[] = [
|
|
90
|
+
{
|
|
91
|
+
accessorKey: "relativePath",
|
|
92
|
+
header: "File",
|
|
93
|
+
cell: ({ row }) => (
|
|
94
|
+
<span className="font-mono text-xs">{row.original.relativePath}</span>
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
cols.push({
|
|
101
|
+
id: `fm_${key}`,
|
|
102
|
+
accessorFn: (row) => row.frontmatter[key],
|
|
103
|
+
header: key,
|
|
104
|
+
cell: ({ getValue }) => {
|
|
105
|
+
const value = getValue();
|
|
106
|
+
const str = formatCellValue(value);
|
|
107
|
+
if (str.length > 60) {
|
|
108
|
+
return (
|
|
109
|
+
<span className="text-xs" title={str}>
|
|
110
|
+
{str.slice(0, 60)}...
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return <span className="text-xs">{str}</span>;
|
|
115
|
+
},
|
|
116
|
+
filterFn: (row, columnId, filterValue) => {
|
|
117
|
+
const cellValue = formatCellValue(row.getValue(columnId));
|
|
118
|
+
return cellValue.toLowerCase().includes(filterValue.toLowerCase());
|
|
119
|
+
},
|
|
120
|
+
sortingFn: (rowA, rowB, columnId) => {
|
|
121
|
+
const a = formatCellValue(rowA.getValue(columnId));
|
|
122
|
+
const b = formatCellValue(rowB.getValue(columnId));
|
|
123
|
+
return a.localeCompare(b, "ja");
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return cols;
|
|
129
|
+
}, [keys]);
|
|
130
|
+
|
|
131
|
+
const table = useReactTable({
|
|
132
|
+
data: entries,
|
|
133
|
+
columns,
|
|
134
|
+
state: {
|
|
135
|
+
sorting,
|
|
136
|
+
columnFilters,
|
|
137
|
+
columnVisibility,
|
|
138
|
+
globalFilter,
|
|
139
|
+
},
|
|
140
|
+
onSortingChange: (updater) => {
|
|
141
|
+
const next = typeof updater === "function" ? updater(sorting) : updater;
|
|
142
|
+
updateState({ sorting: next });
|
|
143
|
+
},
|
|
144
|
+
onColumnFiltersChange: (updater) => {
|
|
145
|
+
const next =
|
|
146
|
+
typeof updater === "function" ? updater(columnFilters) : updater;
|
|
147
|
+
updateState({ columnFilters: next });
|
|
148
|
+
},
|
|
149
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
150
|
+
onGlobalFilterChange: (value) => {
|
|
151
|
+
updateState({ globalFilter: value });
|
|
152
|
+
},
|
|
153
|
+
getCoreRowModel: getCoreRowModel(),
|
|
154
|
+
getSortedRowModel: getSortedRowModel(),
|
|
155
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
156
|
+
getFacetedRowModel: getFacetedRowModel(),
|
|
157
|
+
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
158
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
159
|
+
enableMultiSort: true,
|
|
160
|
+
initialState: {
|
|
161
|
+
pagination: {
|
|
162
|
+
pageSize: 50,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const activeFilterCount = columnFilters.length + (globalFilter ? 1 : 0);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="space-y-3">
|
|
171
|
+
{/* Toolbar */}
|
|
172
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
173
|
+
<Input
|
|
174
|
+
placeholder="Search all columns..."
|
|
175
|
+
value={globalFilter}
|
|
176
|
+
onChange={(e) => updateState({ globalFilter: e.target.value })}
|
|
177
|
+
className="max-w-xs h-8 text-sm"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
{/* Column visibility toggle */}
|
|
181
|
+
<Popover>
|
|
182
|
+
<PopoverTrigger>
|
|
183
|
+
<Button variant="outline" size="sm" className="h-8">
|
|
184
|
+
Columns
|
|
185
|
+
</Button>
|
|
186
|
+
</PopoverTrigger>
|
|
187
|
+
<PopoverContent align="start" className="w-48 p-2">
|
|
188
|
+
<p className="text-xs font-medium text-muted-foreground mb-2">
|
|
189
|
+
Toggle columns
|
|
190
|
+
</p>
|
|
191
|
+
<div className="space-y-1">
|
|
192
|
+
{table
|
|
193
|
+
.getAllColumns()
|
|
194
|
+
.filter((col) => col.getCanHide())
|
|
195
|
+
.map((col) => (
|
|
196
|
+
<label
|
|
197
|
+
key={col.id}
|
|
198
|
+
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5"
|
|
199
|
+
>
|
|
200
|
+
<input
|
|
201
|
+
type="checkbox"
|
|
202
|
+
checked={col.getIsVisible()}
|
|
203
|
+
onChange={(e) => col.toggleVisibility(e.target.checked)}
|
|
204
|
+
className="rounded"
|
|
205
|
+
/>
|
|
206
|
+
{col.id.replace("fm_", "")}
|
|
207
|
+
</label>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
</PopoverContent>
|
|
211
|
+
</Popover>
|
|
212
|
+
|
|
213
|
+
{/* Clear filters */}
|
|
214
|
+
{activeFilterCount > 0 && (
|
|
215
|
+
<Button
|
|
216
|
+
variant="ghost"
|
|
217
|
+
size="sm"
|
|
218
|
+
className="h-8"
|
|
219
|
+
onClick={() => {
|
|
220
|
+
updateState({ columnFilters: [], globalFilter: "" });
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
Clear filters
|
|
224
|
+
<Badge variant="secondary" className="ml-1 text-xs">
|
|
225
|
+
{activeFilterCount}
|
|
226
|
+
</Badge>
|
|
227
|
+
</Button>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* Sort indicator */}
|
|
231
|
+
{sorting.length > 0 && (
|
|
232
|
+
<div className="flex items-center gap-1">
|
|
233
|
+
<span className="text-xs text-muted-foreground">Sort:</span>
|
|
234
|
+
{sorting.map((sort) => (
|
|
235
|
+
<Badge
|
|
236
|
+
key={sort.id}
|
|
237
|
+
variant="outline"
|
|
238
|
+
className="text-xs cursor-pointer"
|
|
239
|
+
onClick={() => {
|
|
240
|
+
updateState({
|
|
241
|
+
sorting: sorting.filter((s) => s.id !== sort.id),
|
|
242
|
+
});
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
{sort.id.replace("fm_", "")} {sort.desc ? "↓" : "↑"} ✕
|
|
246
|
+
</Badge>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{/* Active column filters */}
|
|
253
|
+
{columnFilters.length > 0 && (
|
|
254
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
255
|
+
<span className="text-xs text-muted-foreground">Filters:</span>
|
|
256
|
+
{columnFilters.map((filter) => (
|
|
257
|
+
<Badge
|
|
258
|
+
key={filter.id}
|
|
259
|
+
variant="secondary"
|
|
260
|
+
className="text-xs cursor-pointer"
|
|
261
|
+
onClick={() => {
|
|
262
|
+
updateState({
|
|
263
|
+
columnFilters: columnFilters.filter(
|
|
264
|
+
(f) => f.id !== filter.id,
|
|
265
|
+
),
|
|
266
|
+
});
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
{filter.id.replace("fm_", "")}: {String(filter.value)} ✕
|
|
270
|
+
</Badge>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
{/* Table */}
|
|
276
|
+
<div className="rounded-md border">
|
|
277
|
+
<Table>
|
|
278
|
+
<TableHeader>
|
|
279
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
280
|
+
<TableRow key={headerGroup.id}>
|
|
281
|
+
{headerGroup.headers.map((header) => (
|
|
282
|
+
<TableHead key={header.id} className="p-0">
|
|
283
|
+
<div className="flex items-center">
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
className="flex items-center gap-1 px-3 py-2 text-left hover:bg-muted/50 flex-1 cursor-pointer select-none"
|
|
287
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
288
|
+
>
|
|
289
|
+
{header.isPlaceholder
|
|
290
|
+
? null
|
|
291
|
+
: flexRender(
|
|
292
|
+
header.column.columnDef.header,
|
|
293
|
+
header.getContext(),
|
|
294
|
+
)}
|
|
295
|
+
{{
|
|
296
|
+
asc: " ↑",
|
|
297
|
+
desc: " ↓",
|
|
298
|
+
}[header.column.getIsSorted() as string] ?? null}
|
|
299
|
+
</button>
|
|
300
|
+
{header.column.getCanFilter() && (
|
|
301
|
+
<Popover>
|
|
302
|
+
<PopoverTrigger>
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
className={`px-1.5 py-2 hover:bg-muted/50 cursor-pointer ${
|
|
306
|
+
header.column.getFilterValue()
|
|
307
|
+
? "text-primary"
|
|
308
|
+
: "text-muted-foreground"
|
|
309
|
+
}`}
|
|
310
|
+
>
|
|
311
|
+
▼
|
|
312
|
+
</button>
|
|
313
|
+
</PopoverTrigger>
|
|
314
|
+
<PopoverContent className="w-60 p-3" align="start">
|
|
315
|
+
<ColumnFilter column={header.column} />
|
|
316
|
+
</PopoverContent>
|
|
317
|
+
</Popover>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</TableHead>
|
|
321
|
+
))}
|
|
322
|
+
</TableRow>
|
|
323
|
+
))}
|
|
324
|
+
</TableHeader>
|
|
325
|
+
<TableBody>
|
|
326
|
+
{table.getRowModel().rows.length ? (
|
|
327
|
+
table.getRowModel().rows.map((row) => (
|
|
328
|
+
<TableRow
|
|
329
|
+
key={row.id}
|
|
330
|
+
className={`cursor-pointer hover:bg-muted/50 ${
|
|
331
|
+
row.original.id === selectedId ? "bg-muted" : ""
|
|
332
|
+
}`}
|
|
333
|
+
onClick={() => onSelect(row.original)}
|
|
334
|
+
>
|
|
335
|
+
{row.getVisibleCells().map((cell) => (
|
|
336
|
+
<TableCell key={cell.id} className="py-2">
|
|
337
|
+
{flexRender(
|
|
338
|
+
cell.column.columnDef.cell,
|
|
339
|
+
cell.getContext(),
|
|
340
|
+
)}
|
|
341
|
+
</TableCell>
|
|
342
|
+
))}
|
|
343
|
+
</TableRow>
|
|
344
|
+
))
|
|
345
|
+
) : (
|
|
346
|
+
<TableRow>
|
|
347
|
+
<TableCell
|
|
348
|
+
colSpan={columns.length}
|
|
349
|
+
className="h-24 text-center"
|
|
350
|
+
>
|
|
351
|
+
No results.
|
|
352
|
+
</TableCell>
|
|
353
|
+
</TableRow>
|
|
354
|
+
)}
|
|
355
|
+
</TableBody>
|
|
356
|
+
</Table>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
{/* Footer */}
|
|
360
|
+
<div className="flex items-center justify-between">
|
|
361
|
+
<span className="text-xs text-muted-foreground">
|
|
362
|
+
{table.getFilteredRowModel().rows.length} of {entries.length} file(s)
|
|
363
|
+
</span>
|
|
364
|
+
<div className="flex items-center gap-2">
|
|
365
|
+
<Button
|
|
366
|
+
variant="outline"
|
|
367
|
+
size="sm"
|
|
368
|
+
className="h-7 text-xs"
|
|
369
|
+
onClick={() => table.previousPage()}
|
|
370
|
+
disabled={!table.getCanPreviousPage()}
|
|
371
|
+
>
|
|
372
|
+
Previous
|
|
373
|
+
</Button>
|
|
374
|
+
<span className="text-xs text-muted-foreground">
|
|
375
|
+
{table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
|
|
376
|
+
</span>
|
|
377
|
+
<Button
|
|
378
|
+
variant="outline"
|
|
379
|
+
size="sm"
|
|
380
|
+
className="h-7 text-xs"
|
|
381
|
+
onClick={() => table.nextPage()}
|
|
382
|
+
disabled={!table.getCanNextPage()}
|
|
383
|
+
>
|
|
384
|
+
Next
|
|
385
|
+
</Button>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { MdEntry } from "@md-meta-view/core";
|
|
2
|
+
import DOMPurify from "dompurify";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
|
7
|
+
|
|
8
|
+
interface MarkdownViewProps {
|
|
9
|
+
entry: MdEntry;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatValue(value: unknown): string {
|
|
14
|
+
if (value === null || value === undefined) return "";
|
|
15
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
16
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
17
|
+
return String(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderInlineMd(text: string): string {
|
|
21
|
+
const html = marked.parseInline(text) as string;
|
|
22
|
+
return DOMPurify.sanitize(html);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function CopyableCell({ value }: { value: string }) {
|
|
26
|
+
const [copied, setCopied] = useState(false);
|
|
27
|
+
const html = useMemo(() => renderInlineMd(value), [value]);
|
|
28
|
+
|
|
29
|
+
const handleCopy = async () => {
|
|
30
|
+
await navigator.clipboard.writeText(value);
|
|
31
|
+
setCopied(true);
|
|
32
|
+
setTimeout(() => setCopied(false), 1500);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<TableCell
|
|
37
|
+
className="text-xs cursor-pointer hover:bg-muted/50 relative group"
|
|
38
|
+
onClick={handleCopy}
|
|
39
|
+
title="Click to copy"
|
|
40
|
+
>
|
|
41
|
+
<div className="flex items-center gap-2">
|
|
42
|
+
<span
|
|
43
|
+
className="prose prose-xs dark:prose-invert [&_a]:text-primary [&_a]:underline"
|
|
44
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
45
|
+
/>
|
|
46
|
+
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
|
47
|
+
{copied ? "Copied!" : "Copy"}
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
</TableCell>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function MarkdownView({ entry, onClose }: MarkdownViewProps) {
|
|
55
|
+
const [copied, setCopied] = useState(false);
|
|
56
|
+
|
|
57
|
+
const handleCopyLink = async () => {
|
|
58
|
+
const url = new URL(window.location.origin + "/");
|
|
59
|
+
url.searchParams.set("file", entry.id);
|
|
60
|
+
await navigator.clipboard.writeText(url.toString());
|
|
61
|
+
setCopied(true);
|
|
62
|
+
setTimeout(() => setCopied(false), 2000);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="space-y-6">
|
|
67
|
+
<div className="flex items-center justify-between">
|
|
68
|
+
<span className="font-mono text-sm text-muted-foreground truncate mr-2">
|
|
69
|
+
{entry.relativePath}
|
|
70
|
+
</span>
|
|
71
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
72
|
+
<Button variant="outline" size="sm" onClick={handleCopyLink}>
|
|
73
|
+
{copied ? "Copied!" : "Copy Link"}
|
|
74
|
+
</Button>
|
|
75
|
+
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
76
|
+
✕
|
|
77
|
+
</Button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{Object.keys(entry.frontmatter).length > 0 && (
|
|
82
|
+
<div className="rounded-md border">
|
|
83
|
+
<Table>
|
|
84
|
+
<TableBody>
|
|
85
|
+
{Object.entries(entry.frontmatter).map(([key, value]) => (
|
|
86
|
+
<TableRow key={key}>
|
|
87
|
+
<TableCell className="font-medium text-muted-foreground w-32 text-xs">
|
|
88
|
+
{key}
|
|
89
|
+
</TableCell>
|
|
90
|
+
<CopyableCell value={formatValue(value)} />
|
|
91
|
+
</TableRow>
|
|
92
|
+
))}
|
|
93
|
+
</TableBody>
|
|
94
|
+
</Table>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<div
|
|
99
|
+
className="prose prose-sm dark:prose-invert max-w-none"
|
|
100
|
+
dangerouslySetInnerHTML={{ __html: entry.html }}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Button } from "@/components/ui/button";
|
|
2
|
+
import { useTheme } from "@/hooks/use-theme";
|
|
3
|
+
|
|
4
|
+
export function ThemeToggle() {
|
|
5
|
+
const { theme, setTheme } = useTheme();
|
|
6
|
+
|
|
7
|
+
const next =
|
|
8
|
+
theme === "light" ? "dark" : theme === "dark" ? "system" : "light";
|
|
9
|
+
const icon = theme === "light" ? "☀️" : theme === "dark" ? "🌙" : "💻";
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<Button
|
|
13
|
+
variant="ghost"
|
|
14
|
+
size="sm"
|
|
15
|
+
onClick={() => setTheme(next)}
|
|
16
|
+
title={`Theme: ${theme}`}
|
|
17
|
+
>
|
|
18
|
+
{icon}
|
|
19
|
+
</Button>
|
|
20
|
+
);
|
|
21
|
+
}
|