@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildShareUrl,
|
|
4
|
+
searchParamsToTableState,
|
|
5
|
+
tableStateToSearchParams,
|
|
6
|
+
} from "./search-params";
|
|
7
|
+
|
|
8
|
+
describe("searchParamsToTableState", () => {
|
|
9
|
+
it("parses sort parameter", () => {
|
|
10
|
+
const state = searchParamsToTableState({ sort: "fm_date:desc" });
|
|
11
|
+
|
|
12
|
+
expect(state.sorting).toEqual([{ id: "fm_date", desc: true }]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("parses multiple sort parameters", () => {
|
|
16
|
+
const state = searchParamsToTableState({ sort: "fm_date:desc,fm_title:asc" });
|
|
17
|
+
|
|
18
|
+
expect(state.sorting).toEqual([
|
|
19
|
+
{ id: "fm_date", desc: true },
|
|
20
|
+
{ id: "fm_title", desc: false },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("parses filter parameter", () => {
|
|
25
|
+
const state = searchParamsToTableState({ filter: "fm_category:testing" });
|
|
26
|
+
|
|
27
|
+
expect(state.columnFilters).toEqual([{ id: "fm_category", value: "testing" }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("parses multiple filter parameters", () => {
|
|
31
|
+
const state = searchParamsToTableState({
|
|
32
|
+
filter: "fm_category:testing,fm_status:done",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(state.columnFilters).toEqual([
|
|
36
|
+
{ id: "fm_category", value: "testing" },
|
|
37
|
+
{ id: "fm_status", value: "done" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("handles filter values containing colons", () => {
|
|
42
|
+
const state = searchParamsToTableState({ filter: "fm_url:https://example.com" });
|
|
43
|
+
|
|
44
|
+
expect(state.columnFilters).toEqual([
|
|
45
|
+
{ id: "fm_url", value: "https://example.com" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("parses global search parameter", () => {
|
|
50
|
+
const state = searchParamsToTableState({ q: "Next.js" });
|
|
51
|
+
|
|
52
|
+
expect(state.globalFilter).toBe("Next.js");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns empty state for empty params", () => {
|
|
56
|
+
const state = searchParamsToTableState({});
|
|
57
|
+
|
|
58
|
+
expect(state.sorting).toEqual([]);
|
|
59
|
+
expect(state.columnFilters).toEqual([]);
|
|
60
|
+
expect(state.globalFilter).toBe("");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("tableStateToSearchParams", () => {
|
|
65
|
+
it("serializes sorting", () => {
|
|
66
|
+
const params = tableStateToSearchParams({
|
|
67
|
+
sorting: [{ id: "fm_date", desc: true }],
|
|
68
|
+
columnFilters: [],
|
|
69
|
+
globalFilter: "",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(params.sort).toBe("fm_date:desc");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("serializes multiple sorts", () => {
|
|
76
|
+
const params = tableStateToSearchParams({
|
|
77
|
+
sorting: [
|
|
78
|
+
{ id: "fm_date", desc: true },
|
|
79
|
+
{ id: "fm_title", desc: false },
|
|
80
|
+
],
|
|
81
|
+
columnFilters: [],
|
|
82
|
+
globalFilter: "",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(params.sort).toBe("fm_date:desc,fm_title:asc");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("serializes column filters", () => {
|
|
89
|
+
const params = tableStateToSearchParams({
|
|
90
|
+
sorting: [],
|
|
91
|
+
columnFilters: [{ id: "fm_category", value: "testing" }],
|
|
92
|
+
globalFilter: "",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(params.filter).toBe("fm_category:testing");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("serializes global filter", () => {
|
|
99
|
+
const params = tableStateToSearchParams({
|
|
100
|
+
sorting: [],
|
|
101
|
+
columnFilters: [],
|
|
102
|
+
globalFilter: "Next.js",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(params.q).toBe("Next.js");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("omits empty values", () => {
|
|
109
|
+
const params = tableStateToSearchParams({
|
|
110
|
+
sorting: [],
|
|
111
|
+
columnFilters: [],
|
|
112
|
+
globalFilter: "",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(params.sort).toBeUndefined();
|
|
116
|
+
expect(params.filter).toBeUndefined();
|
|
117
|
+
expect(params.q).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("round-trip", () => {
|
|
122
|
+
it("preserves state through serialize/deserialize", () => {
|
|
123
|
+
const original = {
|
|
124
|
+
sorting: [
|
|
125
|
+
{ id: "fm_date", desc: true },
|
|
126
|
+
{ id: "fm_title", desc: false },
|
|
127
|
+
],
|
|
128
|
+
columnFilters: [
|
|
129
|
+
{ id: "fm_category", value: "testing" },
|
|
130
|
+
{ id: "fm_status", value: "done" },
|
|
131
|
+
],
|
|
132
|
+
globalFilter: "search term",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const params = tableStateToSearchParams(original);
|
|
136
|
+
const restored = searchParamsToTableState(params);
|
|
137
|
+
|
|
138
|
+
expect(restored).toEqual(original);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("buildShareUrl", () => {
|
|
143
|
+
const origin = "http://localhost:3000";
|
|
144
|
+
|
|
145
|
+
it("builds URL with all params", () => {
|
|
146
|
+
const url = buildShareUrl(
|
|
147
|
+
{ sort: "fm_date:desc", filter: "fm_category:testing", q: "Next" },
|
|
148
|
+
origin,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(url).toContain("sort=fm_date%3Adesc");
|
|
152
|
+
expect(url).toContain("filter=fm_category%3Atesting");
|
|
153
|
+
expect(url).toContain("q=Next");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("omits undefined params", () => {
|
|
157
|
+
const url = buildShareUrl({ sort: "fm_date:desc" }, origin);
|
|
158
|
+
|
|
159
|
+
expect(url).toContain("sort=");
|
|
160
|
+
expect(url).not.toContain("filter=");
|
|
161
|
+
expect(url).not.toContain("q=");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ColumnFiltersState, SortingState } from "@tanstack/react-table";
|
|
2
|
+
import type { TableState } from "@/components/data-table";
|
|
3
|
+
|
|
4
|
+
export interface SearchParams {
|
|
5
|
+
sort?: string;
|
|
6
|
+
filter?: string;
|
|
7
|
+
q?: string;
|
|
8
|
+
file?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function searchParamsToTableState(search: SearchParams): TableState {
|
|
12
|
+
const sorting: SortingState = [];
|
|
13
|
+
if (search.sort) {
|
|
14
|
+
for (const part of search.sort.split(",")) {
|
|
15
|
+
const [id, dir] = part.split(":");
|
|
16
|
+
if (id) {
|
|
17
|
+
sorting.push({ id, desc: dir === "desc" });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const columnFilters: ColumnFiltersState = [];
|
|
23
|
+
if (search.filter) {
|
|
24
|
+
for (const part of search.filter.split(",")) {
|
|
25
|
+
const colonIdx = part.indexOf(":");
|
|
26
|
+
if (colonIdx > 0) {
|
|
27
|
+
const id = part.slice(0, colonIdx);
|
|
28
|
+
const value = part.slice(colonIdx + 1);
|
|
29
|
+
columnFilters.push({ id, value });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
sorting,
|
|
36
|
+
columnFilters,
|
|
37
|
+
globalFilter: search.q ?? "",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function tableStateToSearchParams(state: TableState): SearchParams {
|
|
42
|
+
const params: SearchParams = {};
|
|
43
|
+
|
|
44
|
+
if (state.sorting.length > 0) {
|
|
45
|
+
params.sort = state.sorting
|
|
46
|
+
.map((s) => `${s.id}:${s.desc ? "desc" : "asc"}`)
|
|
47
|
+
.join(",");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (state.columnFilters.length > 0) {
|
|
51
|
+
params.filter = state.columnFilters
|
|
52
|
+
.map((f) => `${f.id}:${f.value}`)
|
|
53
|
+
.join(",");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (state.globalFilter) {
|
|
57
|
+
params.q = state.globalFilter;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return params;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildShareUrl(
|
|
64
|
+
search: SearchParams,
|
|
65
|
+
origin = window.location.origin,
|
|
66
|
+
): string {
|
|
67
|
+
const url = new URL(origin + "/");
|
|
68
|
+
if (search.sort) url.searchParams.set("sort", search.sort);
|
|
69
|
+
if (search.filter) url.searchParams.set("filter", search.filter);
|
|
70
|
+
if (search.q) url.searchParams.set("q", search.q);
|
|
71
|
+
return url.toString();
|
|
72
|
+
}
|
package/src/lib/utils.ts
ADDED
package/src/main.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RouterProvider } from "@tanstack/react-router";
|
|
2
|
+
import { StrictMode } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { router } from "@/lib/router";
|
|
5
|
+
import "./index.css";
|
|
6
|
+
|
|
7
|
+
createRoot(document.getElementById("root")!).render(
|
|
8
|
+
<StrictMode>
|
|
9
|
+
<RouterProvider router={router} />
|
|
10
|
+
</StrictMode>,
|
|
11
|
+
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Outlet } from "@tanstack/react-router";
|
|
2
|
+
import { ThemeToggle } from "@/components/theme-toggle";
|
|
3
|
+
import { useMdData } from "@/hooks/use-md-data";
|
|
4
|
+
|
|
5
|
+
export function RootLayout() {
|
|
6
|
+
const { loading } = useMdData();
|
|
7
|
+
|
|
8
|
+
if (loading) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex min-h-screen items-center justify-center">
|
|
11
|
+
<p className="text-muted-foreground">Loading...</p>
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="h-screen bg-background flex flex-col">
|
|
18
|
+
<header className="border-b px-4 py-3 shrink-0 flex items-center justify-between">
|
|
19
|
+
<h1 className="text-lg font-bold">md-meta-view</h1>
|
|
20
|
+
<ThemeToggle />
|
|
21
|
+
</header>
|
|
22
|
+
<div className="flex-1 overflow-hidden">
|
|
23
|
+
<Outlet />
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { MdEntry } from "@md-meta-view/core";
|
|
2
|
+
import { useNavigate, useSearch } from "@tanstack/react-router";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { DataTable, type TableState } from "@/components/data-table";
|
|
5
|
+
import { MarkdownView } from "@/components/markdown-view";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
ResizableHandle,
|
|
9
|
+
ResizablePanel,
|
|
10
|
+
ResizablePanelGroup,
|
|
11
|
+
} from "@/components/ui/resizable";
|
|
12
|
+
import { useMdData } from "@/hooks/use-md-data";
|
|
13
|
+
import {
|
|
14
|
+
buildShareUrl,
|
|
15
|
+
searchParamsToTableState,
|
|
16
|
+
tableStateToSearchParams,
|
|
17
|
+
} from "@/lib/search-params";
|
|
18
|
+
|
|
19
|
+
export function IndexPage() {
|
|
20
|
+
const { data } = useMdData();
|
|
21
|
+
const navigate = useNavigate();
|
|
22
|
+
const search = useSearch({ from: "/" });
|
|
23
|
+
const [copied, setCopied] = useState(false);
|
|
24
|
+
|
|
25
|
+
const tableState = useMemo(() => searchParamsToTableState(search), [search]);
|
|
26
|
+
const [selectedId, setSelectedId] = useState<string | null>(
|
|
27
|
+
search.file ?? null,
|
|
28
|
+
);
|
|
29
|
+
const selected = useMemo(() => {
|
|
30
|
+
if (!selectedId || !data) return null;
|
|
31
|
+
return (
|
|
32
|
+
data.entries.find(
|
|
33
|
+
(e) => e.id === selectedId || e.relativePath === selectedId,
|
|
34
|
+
) ?? null
|
|
35
|
+
);
|
|
36
|
+
}, [selectedId, data]);
|
|
37
|
+
|
|
38
|
+
const handleTableStateChange = useCallback(
|
|
39
|
+
(state: TableState) => {
|
|
40
|
+
const params = tableStateToSearchParams(state);
|
|
41
|
+
navigate({
|
|
42
|
+
to: "/",
|
|
43
|
+
search: (prev) => ({
|
|
44
|
+
file: prev.file,
|
|
45
|
+
sort: params.sort,
|
|
46
|
+
filter: params.filter,
|
|
47
|
+
q: params.q,
|
|
48
|
+
}),
|
|
49
|
+
replace: true,
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
[navigate],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const handleCopyShareLink = useCallback(async () => {
|
|
56
|
+
const params = tableStateToSearchParams(tableState);
|
|
57
|
+
const url = buildShareUrl(params);
|
|
58
|
+
await navigator.clipboard.writeText(url);
|
|
59
|
+
setCopied(true);
|
|
60
|
+
setTimeout(() => setCopied(false), 2000);
|
|
61
|
+
}, [tableState]);
|
|
62
|
+
|
|
63
|
+
if (!data || data.entries.length === 0) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex h-full items-center justify-center">
|
|
66
|
+
<p className="text-muted-foreground">No markdown files found.</p>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handleSelect = (entry: MdEntry) => {
|
|
72
|
+
setSelectedId(entry.id);
|
|
73
|
+
navigate({
|
|
74
|
+
to: "/",
|
|
75
|
+
search: (prev) => ({ ...prev, file: entry.id }),
|
|
76
|
+
replace: true,
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handleClose = () => {
|
|
81
|
+
setSelectedId(null);
|
|
82
|
+
navigate({
|
|
83
|
+
to: "/",
|
|
84
|
+
search: (prev) => ({ ...prev, file: undefined }),
|
|
85
|
+
replace: true,
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const hasFilters =
|
|
90
|
+
tableState.sorting.length > 0 ||
|
|
91
|
+
tableState.columnFilters.length > 0 ||
|
|
92
|
+
tableState.globalFilter !== "";
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<ResizablePanelGroup orientation="horizontal" className="h-full">
|
|
96
|
+
<ResizablePanel defaultSize={selected ? 50 : 100} minSize={30}>
|
|
97
|
+
<div className="h-full overflow-auto p-4">
|
|
98
|
+
{hasFilters && (
|
|
99
|
+
<div className="mb-3 flex justify-end">
|
|
100
|
+
<Button
|
|
101
|
+
variant="outline"
|
|
102
|
+
size="sm"
|
|
103
|
+
onClick={handleCopyShareLink}
|
|
104
|
+
>
|
|
105
|
+
{copied ? "Copied!" : "Share View"}
|
|
106
|
+
</Button>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
<DataTable
|
|
110
|
+
entries={data.entries}
|
|
111
|
+
keys={data.keys}
|
|
112
|
+
onSelect={handleSelect}
|
|
113
|
+
selectedId={selected?.id}
|
|
114
|
+
tableState={tableState}
|
|
115
|
+
onTableStateChange={handleTableStateChange}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
</ResizablePanel>
|
|
119
|
+
{selected && (
|
|
120
|
+
<>
|
|
121
|
+
<ResizableHandle withHandle />
|
|
122
|
+
<ResizablePanel defaultSize={50} minSize={20}>
|
|
123
|
+
<div className="h-full overflow-auto p-4">
|
|
124
|
+
<MarkdownView entry={selected} onClose={handleClose} />
|
|
125
|
+
</div>
|
|
126
|
+
</ResizablePanel>
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
</ResizablePanelGroup>
|
|
130
|
+
);
|
|
131
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"moduleDetection": "force",
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"baseUrl": ".",
|
|
19
|
+
"paths": {
|
|
20
|
+
"@/*": ["./src/*"]
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"]
|
|
24
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
3
|
+
import react from "@vitejs/plugin-react";
|
|
4
|
+
import { defineConfig } from "vite";
|
|
5
|
+
|
|
6
|
+
// https://vite.dev/config/
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [react(), tailwindcss()],
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
"@": path.resolve(__dirname, "./src"),
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|