@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.
@@ -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
+ }