claude-plan-viewer 1.3.0 → 1.4.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/README.md +84 -4
- package/index.ts +238 -65
- package/package.json +25 -15
- package/src/api-docs.html +17 -0
- package/src/client/App.tsx +54 -9
- package/src/client/components/DetailOverlay.tsx +66 -9
- package/src/client/components/DetailPanel.tsx +63 -11
- package/src/client/components/Header.tsx +24 -5
- package/src/client/components/HelpModal.tsx +30 -7
- package/src/client/components/Markdown.tsx +37 -7
- package/src/client/components/PlanRow.tsx +15 -4
- package/src/client/components/ProjectFilter.tsx +6 -11
- package/src/client/components/SearchInput.tsx +7 -1
- package/src/client/components/index.ts +0 -1
- package/src/client/hooks/useFilters.ts +7 -7
- package/src/client/hooks/useFocusTrap.ts +70 -0
- package/src/client/hooks/useKeyboard.ts +2 -2
- package/src/client/hooks/usePlans.ts +64 -73
- package/src/client/hooks/useProjects.ts +24 -11
- package/src/client/index.tsx +1 -1
- package/src/client/types.ts +7 -1
- package/src/client/utils/api.ts +13 -4
- package/src/client/utils/formatters.ts +38 -9
- package/src/client/utils/index.ts +0 -12
- package/src/index.html +13 -0
- package/{styles.css → src/styles/styles.css} +154 -72
- package/index.html +0 -14
- package/prism.bundle.js +0 -35
- package/src/client/utils/markdown.ts +0 -123
|
@@ -3,12 +3,12 @@ import type { SortKey, SortDir } from "../types.ts";
|
|
|
3
3
|
|
|
4
4
|
// Natural default sort direction per column type
|
|
5
5
|
const SORT_DEFAULTS: Record<SortKey, SortDir> = {
|
|
6
|
-
title: "asc",
|
|
7
|
-
project: "asc",
|
|
8
|
-
size: "desc",
|
|
9
|
-
lines: "desc",
|
|
10
|
-
modified: "desc",
|
|
11
|
-
created: "desc",
|
|
6
|
+
title: "asc", // A → Z
|
|
7
|
+
project: "asc", // A → Z
|
|
8
|
+
size: "desc", // Biggest first
|
|
9
|
+
lines: "desc", // Biggest first
|
|
10
|
+
modified: "desc", // Newest first
|
|
11
|
+
created: "desc", // Newest first
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
interface UseFiltersReturn {
|
|
@@ -27,7 +27,7 @@ export function useFilters(): UseFiltersReturn {
|
|
|
27
27
|
const [sortKey, setSortKey] = useState<SortKey>("modified");
|
|
28
28
|
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
|
29
29
|
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
|
|
30
|
-
new Set()
|
|
30
|
+
new Set(),
|
|
31
31
|
);
|
|
32
32
|
|
|
33
33
|
const setSort = useCallback((key: SortKey, dir?: SortDir) => {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export function useFocusTrap(
|
|
4
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
5
|
+
isOpen: boolean,
|
|
6
|
+
) {
|
|
7
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!isOpen || !containerRef.current) return;
|
|
11
|
+
|
|
12
|
+
// Store the previously focused element
|
|
13
|
+
previousFocusRef.current = document.activeElement as HTMLElement;
|
|
14
|
+
|
|
15
|
+
// Get all focusable elements within the container
|
|
16
|
+
const getFocusableElements = () => {
|
|
17
|
+
const container = containerRef.current;
|
|
18
|
+
if (!container) return [];
|
|
19
|
+
return Array.from(
|
|
20
|
+
container.querySelectorAll<HTMLElement>(
|
|
21
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
22
|
+
),
|
|
23
|
+
).filter(
|
|
24
|
+
(el) => !el.hasAttribute("disabled") && el.offsetParent !== null,
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Focus first element
|
|
29
|
+
const focusableElements = getFocusableElements();
|
|
30
|
+
const firstFocusable = focusableElements[0];
|
|
31
|
+
if (firstFocusable) {
|
|
32
|
+
firstFocusable.focus();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle Tab key to trap focus
|
|
36
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
37
|
+
if (e.key !== "Tab") return;
|
|
38
|
+
|
|
39
|
+
const elements = getFocusableElements();
|
|
40
|
+
if (elements.length === 0) return;
|
|
41
|
+
|
|
42
|
+
const firstElement = elements[0];
|
|
43
|
+
const lastElement = elements[elements.length - 1];
|
|
44
|
+
|
|
45
|
+
if (!firstElement || !lastElement) return;
|
|
46
|
+
|
|
47
|
+
if (e.shiftKey) {
|
|
48
|
+
// Shift+Tab: if on first element, go to last
|
|
49
|
+
if (document.activeElement === firstElement) {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
lastElement.focus();
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
// Tab: if on last element, go to first
|
|
55
|
+
if (document.activeElement === lastElement) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
firstElement.focus();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
66
|
+
// Restore focus when modal closes
|
|
67
|
+
previousFocusRef.current?.focus();
|
|
68
|
+
};
|
|
69
|
+
}, [isOpen, containerRef]);
|
|
70
|
+
}
|
|
@@ -35,7 +35,7 @@ export function useKeyboard({
|
|
|
35
35
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
36
36
|
e.preventDefault();
|
|
37
37
|
const idx = plans.findIndex(
|
|
38
|
-
(p) => p.filename === selectedPlan?.filename
|
|
38
|
+
(p) => p.filename === selectedPlan?.filename,
|
|
39
39
|
);
|
|
40
40
|
let newIdx = e.key === "ArrowDown" ? idx + 1 : idx - 1;
|
|
41
41
|
if (newIdx < 0) newIdx = 0;
|
|
@@ -104,7 +104,7 @@ export function useKeyboard({
|
|
|
104
104
|
onToggleHelp,
|
|
105
105
|
onToggleOverlay,
|
|
106
106
|
onClearSearch,
|
|
107
|
-
]
|
|
107
|
+
],
|
|
108
108
|
);
|
|
109
109
|
|
|
110
110
|
useEffect(() => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { useCallback, useRef, useMemo, useState } from "react";
|
|
2
|
+
import useSWR from "swr";
|
|
3
|
+
import type { Plan, SortKey, SortDir, PlanMetadata } from "../types.ts";
|
|
4
|
+
import { fetchPlanContent, refreshCache } from "../utils/api.ts";
|
|
4
5
|
|
|
5
6
|
export interface UsePlansParams {
|
|
6
7
|
q?: string;
|
|
@@ -13,89 +14,76 @@ interface UsePlansReturn {
|
|
|
13
14
|
plans: Plan[];
|
|
14
15
|
total: number;
|
|
15
16
|
loading: boolean;
|
|
17
|
+
refreshing: boolean;
|
|
16
18
|
refresh: () => Promise<void>;
|
|
17
19
|
ensureContent: (plan: Plan) => Promise<Plan>;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
22
|
+
const plansFetcher = async (url: string): Promise<PlanMetadata[]> => {
|
|
23
|
+
const res = await fetch(url);
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`Failed to fetch plans: ${res.statusText}`);
|
|
26
|
+
}
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
return data.plans;
|
|
29
|
+
};
|
|
26
30
|
|
|
31
|
+
export function usePlans(params: UsePlansParams = {}): UsePlansReturn {
|
|
27
32
|
// Cache content by filename to persist across filter changes
|
|
28
33
|
const contentCache = useRef<Map<string, string>>(new Map());
|
|
34
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
data: fetchedPlans,
|
|
38
|
+
isLoading: loading,
|
|
39
|
+
mutate,
|
|
40
|
+
} = useSWR<PlanMetadata[]>("/api/plans", plansFetcher, {
|
|
41
|
+
onErrorRetry: (error, _key, _config, revalidate, { retryCount }) => {
|
|
42
|
+
// Don't retry on 4xx errors
|
|
43
|
+
if (error.status >= 400 && error.status < 500) return;
|
|
44
|
+
// Retry up to 5 times on network errors with exponential backoff
|
|
45
|
+
if (retryCount >= 5) return;
|
|
46
|
+
setTimeout(() => revalidate({ retryCount }), 500 * (retryCount + 1));
|
|
47
|
+
},
|
|
48
|
+
revalidateOnFocus: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Merge fetched plans with cached content
|
|
52
|
+
const allPlans = useMemo(() => {
|
|
53
|
+
if (!fetchedPlans) return [];
|
|
54
|
+
return fetchedPlans.map((p) => ({
|
|
55
|
+
...p,
|
|
56
|
+
content: contentCache.current.get(p.filename),
|
|
57
|
+
}));
|
|
58
|
+
}, [fetchedPlans]);
|
|
29
59
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (abortControllerRef.current) {
|
|
33
|
-
abortControllerRef.current.abort();
|
|
34
|
-
}
|
|
35
|
-
abortControllerRef.current = new AbortController();
|
|
36
|
-
|
|
60
|
+
const refresh = useCallback(async () => {
|
|
61
|
+
setRefreshing(true);
|
|
37
62
|
try {
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
...p,
|
|
44
|
-
content: contentCache.current.get(p.filename),
|
|
45
|
-
}));
|
|
46
|
-
|
|
47
|
-
setAllPlans(plansWithContent);
|
|
48
|
-
} catch (err) {
|
|
49
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
50
|
-
return;
|
|
63
|
+
contentCache.current.clear();
|
|
64
|
+
const { before, after } = await refreshCache();
|
|
65
|
+
// Only reload if count changed
|
|
66
|
+
if (before !== after) {
|
|
67
|
+
await mutate();
|
|
51
68
|
}
|
|
52
|
-
console.error("Failed to load plans:", err);
|
|
53
69
|
} finally {
|
|
54
|
-
|
|
70
|
+
setRefreshing(false);
|
|
55
71
|
}
|
|
56
|
-
}, []);
|
|
72
|
+
}, [mutate]);
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
loadAllPlans();
|
|
61
|
-
return () => {
|
|
62
|
-
abortControllerRef.current?.abort();
|
|
63
|
-
};
|
|
64
|
-
}, [loadAllPlans]);
|
|
74
|
+
const ensureContent = useCallback(async (plan: Plan): Promise<Plan> => {
|
|
75
|
+
if (plan.content) return plan;
|
|
65
76
|
|
|
66
|
-
|
|
67
|
-
contentCache.current.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const ensureContent = useCallback(
|
|
73
|
-
async (plan: Plan): Promise<Plan> => {
|
|
74
|
-
if (plan.content) return plan;
|
|
75
|
-
|
|
76
|
-
// Check cache
|
|
77
|
-
const cached = contentCache.current.get(plan.filename);
|
|
78
|
-
if (cached) {
|
|
79
|
-
const updatedPlan = { ...plan, content: cached };
|
|
80
|
-
setAllPlans((prev) =>
|
|
81
|
-
prev.map((p) => (p.filename === plan.filename ? updatedPlan : p))
|
|
82
|
-
);
|
|
83
|
-
return updatedPlan;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const content = await fetchPlanContent(plan.filename);
|
|
87
|
-
contentCache.current.set(plan.filename, content);
|
|
88
|
-
const updatedPlan = { ...plan, content };
|
|
89
|
-
|
|
90
|
-
// Update in state
|
|
91
|
-
setAllPlans((prev) =>
|
|
92
|
-
prev.map((p) => (p.filename === plan.filename ? updatedPlan : p))
|
|
93
|
-
);
|
|
77
|
+
// Check cache
|
|
78
|
+
const cached = contentCache.current.get(plan.filename);
|
|
79
|
+
if (cached) {
|
|
80
|
+
return { ...plan, content: cached };
|
|
81
|
+
}
|
|
94
82
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
);
|
|
83
|
+
const content = await fetchPlanContent(plan.filename);
|
|
84
|
+
contentCache.current.set(plan.filename, content);
|
|
85
|
+
return { ...plan, content };
|
|
86
|
+
}, []);
|
|
99
87
|
|
|
100
88
|
// Client-side filtering
|
|
101
89
|
const filteredPlans = useMemo(() => {
|
|
@@ -103,7 +91,7 @@ export function usePlans(
|
|
|
103
91
|
|
|
104
92
|
if (params.q) {
|
|
105
93
|
const lowerQ = params.q.toLowerCase();
|
|
106
|
-
filtered = filtered.filter(p => {
|
|
94
|
+
filtered = filtered.filter((p) => {
|
|
107
95
|
const content = contentCache.current.get(p.filename) || "";
|
|
108
96
|
return (
|
|
109
97
|
p.title.toLowerCase().includes(lowerQ) ||
|
|
@@ -116,7 +104,9 @@ export function usePlans(
|
|
|
116
104
|
|
|
117
105
|
if (params.projects && params.projects.length > 0) {
|
|
118
106
|
const projectFilterSet = new Set(params.projects);
|
|
119
|
-
filtered = filtered.filter(
|
|
107
|
+
filtered = filtered.filter(
|
|
108
|
+
(p) => p.project && projectFilterSet.has(p.project),
|
|
109
|
+
);
|
|
120
110
|
}
|
|
121
111
|
|
|
122
112
|
return filtered;
|
|
@@ -163,6 +153,7 @@ export function usePlans(
|
|
|
163
153
|
plans: sortedPlans,
|
|
164
154
|
total: sortedPlans.length,
|
|
165
155
|
loading,
|
|
156
|
+
refreshing,
|
|
166
157
|
refresh,
|
|
167
158
|
ensureContent,
|
|
168
159
|
};
|
|
@@ -1,21 +1,34 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { fetchProjects } from "../utils/api.ts";
|
|
1
|
+
import useSWR from "swr";
|
|
3
2
|
|
|
4
3
|
interface UseProjectsReturn {
|
|
5
4
|
projects: string[];
|
|
6
5
|
loading: boolean;
|
|
7
6
|
}
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
8
|
+
const projectsFetcher = async (url: string): Promise<string[]> => {
|
|
9
|
+
const res = await fetch(url);
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
throw new Error(`Failed to fetch projects: ${res.statusText}`);
|
|
12
|
+
}
|
|
13
|
+
const data = await res.json();
|
|
14
|
+
return data.projects;
|
|
15
|
+
};
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
export function useProjects(): UseProjectsReturn {
|
|
18
|
+
const { data: projects = [], isLoading: loading } = useSWR<string[]>(
|
|
19
|
+
"/api/projects",
|
|
20
|
+
projectsFetcher,
|
|
21
|
+
{
|
|
22
|
+
onErrorRetry: (error, _key, _config, revalidate, { retryCount }) => {
|
|
23
|
+
// Don't retry on 4xx errors
|
|
24
|
+
if (error.status >= 400 && error.status < 500) return;
|
|
25
|
+
// Retry up to 5 times on network errors with exponential backoff
|
|
26
|
+
if (retryCount >= 5) return;
|
|
27
|
+
setTimeout(() => revalidate({ retryCount }), 500 * (retryCount + 1));
|
|
28
|
+
},
|
|
29
|
+
revalidateOnFocus: false,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
19
32
|
|
|
20
33
|
return { projects, loading };
|
|
21
34
|
}
|
package/src/client/index.tsx
CHANGED
package/src/client/types.ts
CHANGED
|
@@ -15,5 +15,11 @@ export interface Plan extends PlanMetadata {
|
|
|
15
15
|
content?: string; // Lazy-loaded on demand
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export type SortKey =
|
|
18
|
+
export type SortKey =
|
|
19
|
+
| "title"
|
|
20
|
+
| "project"
|
|
21
|
+
| "modified"
|
|
22
|
+
| "size"
|
|
23
|
+
| "lines"
|
|
24
|
+
| "created";
|
|
19
25
|
export type SortDir = "asc" | "desc";
|
package/src/client/utils/api.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Plan, PlanMetadata } from "../types.ts";
|
|
2
2
|
|
|
3
|
-
export async function fetchPlans(
|
|
3
|
+
export async function fetchPlans(
|
|
4
|
+
signal?: AbortSignal,
|
|
5
|
+
): Promise<PlanMetadata[]> {
|
|
4
6
|
const url = new URL("/api/plans", window.location.origin);
|
|
5
7
|
const res = await fetch(url.toString(), { signal });
|
|
6
8
|
if (!res.ok) {
|
|
@@ -33,6 +35,13 @@ export async function openInEditor(filepath: string): Promise<void> {
|
|
|
33
35
|
});
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
export
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
export interface RefreshResponse {
|
|
39
|
+
success: boolean;
|
|
40
|
+
before: number;
|
|
41
|
+
after: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function refreshCache(): Promise<RefreshResponse> {
|
|
45
|
+
const res = await fetch("/api/refresh", { method: "POST" });
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
@@ -10,7 +10,11 @@ export function formatDate(iso: string): string {
|
|
|
10
10
|
|
|
11
11
|
// Show year if not current year
|
|
12
12
|
if (d.getFullYear() !== now.getFullYear()) {
|
|
13
|
-
return d.toLocaleDateString("en-US", {
|
|
13
|
+
return d.toLocaleDateString("en-US", {
|
|
14
|
+
month: "short",
|
|
15
|
+
day: "numeric",
|
|
16
|
+
year: "numeric",
|
|
17
|
+
});
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
@@ -26,15 +30,40 @@ export function formatFullDate(iso: string): string {
|
|
|
26
30
|
});
|
|
27
31
|
}
|
|
28
32
|
|
|
29
|
-
export function formatDateISO(iso: string): string {
|
|
30
|
-
const d = new Date(iso);
|
|
31
|
-
const year = d.getFullYear();
|
|
32
|
-
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
33
|
-
const day = String(d.getDate()).padStart(2, "0");
|
|
34
|
-
return `${year}-${month}-${day}`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
33
|
export function formatSize(bytes: number): string {
|
|
38
34
|
if (bytes < 1024) return bytes + " B";
|
|
39
35
|
return (bytes / 1024).toFixed(1) + " KB";
|
|
40
36
|
}
|
|
37
|
+
|
|
38
|
+
export function formatCompactDateTime(iso: string): string {
|
|
39
|
+
const d = new Date(iso);
|
|
40
|
+
const now = new Date();
|
|
41
|
+
|
|
42
|
+
const time = d.toLocaleTimeString("en-US", {
|
|
43
|
+
hour: "2-digit",
|
|
44
|
+
minute: "2-digit",
|
|
45
|
+
hour12: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const dateOpts: Intl.DateTimeFormatOptions = {
|
|
49
|
+
month: "short",
|
|
50
|
+
day: "numeric",
|
|
51
|
+
};
|
|
52
|
+
if (d.getFullYear() !== now.getFullYear()) {
|
|
53
|
+
dateOpts.year = "numeric";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const date = d.toLocaleDateString("en-US", dateOpts);
|
|
57
|
+
return `${date} · ${time}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isSameDay(iso1: string, iso2: string): boolean {
|
|
61
|
+
const d1 = new Date(iso1);
|
|
62
|
+
const d2 = new Date(iso2);
|
|
63
|
+
return d1.toDateString() === d2.toDateString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatCreatedShort(iso: string): string {
|
|
67
|
+
const d = new Date(iso);
|
|
68
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
69
|
+
}
|
|
@@ -1,15 +1,3 @@
|
|
|
1
1
|
export * from "./formatters.ts";
|
|
2
2
|
export * from "./strings.ts";
|
|
3
|
-
export * from "./markdown.ts";
|
|
4
3
|
export * from "./api.ts";
|
|
5
|
-
|
|
6
|
-
export function debounce<Args extends unknown[]>(
|
|
7
|
-
fn: (...args: Args) => void,
|
|
8
|
-
ms: number
|
|
9
|
-
): (...args: Args) => void {
|
|
10
|
-
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
11
|
-
return (...args: Args) => {
|
|
12
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
13
|
-
timeoutId = setTimeout(() => fn(...args), ms);
|
|
14
|
-
};
|
|
15
|
-
}
|
package/src/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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>Claude Plans Viewer</title>
|
|
7
|
+
<link rel="stylesheet" href="./styles/styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="./client/index.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|