@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,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
+ }
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
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
+ });