claude-plan-viewer 1.1.1 → 1.3.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/index.html +2 -2
- package/index.ts +220 -43
- package/package.json +13 -3
- package/src/client/App.tsx +173 -0
- package/src/client/components/DetailOverlay.tsx +88 -0
- package/src/client/components/DetailPanel.tsx +84 -0
- package/src/client/components/Header.tsx +55 -0
- package/src/client/components/HelpModal.tsx +58 -0
- package/src/client/components/Markdown.tsx +16 -0
- package/src/client/components/PlanRow.tsx +53 -0
- package/src/client/components/PlansTable.tsx +94 -0
- package/src/client/components/ProjectFilter.tsx +180 -0
- package/src/client/components/SearchInput.tsx +42 -0
- package/src/client/components/index.ts +10 -0
- package/src/client/hooks/index.ts +3 -0
- package/src/client/hooks/useDebounce.ts +17 -0
- package/src/client/hooks/useFilters.ts +78 -0
- package/src/client/hooks/useKeyboard.ts +114 -0
- package/src/client/hooks/usePlans.ts +169 -0
- package/src/client/hooks/useProjects.ts +21 -0
- package/src/client/index.tsx +11 -0
- package/src/client/types.ts +19 -0
- package/src/client/utils/api.ts +38 -0
- package/src/client/utils/formatters.ts +40 -0
- package/src/client/utils/index.ts +15 -0
- package/src/client/utils/markdown.ts +123 -0
- package/src/client/utils/strings.ts +18 -0
- package/styles.css +297 -118
- package/frontend.ts +0 -843
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export function useDebounce<T>(value: T, delay: number): T {
|
|
4
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timer = setTimeout(() => {
|
|
8
|
+
setDebouncedValue(value);
|
|
9
|
+
}, delay);
|
|
10
|
+
|
|
11
|
+
return () => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
};
|
|
14
|
+
}, [value, delay]);
|
|
15
|
+
|
|
16
|
+
return debouncedValue;
|
|
17
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import type { SortKey, SortDir } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
// Natural default sort direction per column type
|
|
5
|
+
const SORT_DEFAULTS: Record<SortKey, SortDir> = {
|
|
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
|
+
};
|
|
13
|
+
|
|
14
|
+
interface UseFiltersReturn {
|
|
15
|
+
searchQuery: string;
|
|
16
|
+
setSearchQuery: (query: string) => void;
|
|
17
|
+
sortKey: SortKey;
|
|
18
|
+
sortDir: SortDir;
|
|
19
|
+
setSort: (key: SortKey, dir?: SortDir) => void;
|
|
20
|
+
selectedProjects: Set<string>;
|
|
21
|
+
toggleProject: (project: string) => void;
|
|
22
|
+
clearProjects: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useFilters(): UseFiltersReturn {
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
27
|
+
const [sortKey, setSortKey] = useState<SortKey>("modified");
|
|
28
|
+
const [sortDir, setSortDir] = useState<SortDir>("desc");
|
|
29
|
+
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
|
|
30
|
+
new Set()
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const setSort = useCallback((key: SortKey, dir?: SortDir) => {
|
|
34
|
+
if (dir) {
|
|
35
|
+
// Explicit direction provided
|
|
36
|
+
setSortKey(key);
|
|
37
|
+
setSortDir(dir);
|
|
38
|
+
} else {
|
|
39
|
+
setSortKey((prevKey) => {
|
|
40
|
+
if (prevKey === key) {
|
|
41
|
+
// Same column: toggle direction
|
|
42
|
+
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
|
|
43
|
+
} else {
|
|
44
|
+
// New column: use its natural default
|
|
45
|
+
setSortDir(SORT_DEFAULTS[key]);
|
|
46
|
+
}
|
|
47
|
+
return key;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const toggleProject = useCallback((project: string) => {
|
|
53
|
+
setSelectedProjects((prev) => {
|
|
54
|
+
const next = new Set(prev);
|
|
55
|
+
if (next.has(project)) {
|
|
56
|
+
next.delete(project);
|
|
57
|
+
} else {
|
|
58
|
+
next.add(project);
|
|
59
|
+
}
|
|
60
|
+
return next;
|
|
61
|
+
});
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const clearProjects = useCallback(() => {
|
|
65
|
+
setSelectedProjects(new Set());
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
searchQuery,
|
|
70
|
+
setSearchQuery,
|
|
71
|
+
sortKey,
|
|
72
|
+
sortDir,
|
|
73
|
+
setSort,
|
|
74
|
+
selectedProjects,
|
|
75
|
+
toggleProject,
|
|
76
|
+
clearProjects,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useCallback } from "react";
|
|
2
|
+
import type { Plan } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
interface UseKeyboardOptions {
|
|
5
|
+
plans: Plan[];
|
|
6
|
+
selectedPlan: Plan | null;
|
|
7
|
+
onSelectPlan: (plan: Plan | null) => void;
|
|
8
|
+
onOpenEditor: () => void;
|
|
9
|
+
onToggleHelp: () => void;
|
|
10
|
+
onToggleOverlay: () => void;
|
|
11
|
+
onClearSearch: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useKeyboard({
|
|
15
|
+
plans,
|
|
16
|
+
selectedPlan,
|
|
17
|
+
onSelectPlan,
|
|
18
|
+
onOpenEditor,
|
|
19
|
+
onToggleHelp,
|
|
20
|
+
onToggleOverlay,
|
|
21
|
+
onClearSearch,
|
|
22
|
+
}: UseKeyboardOptions): void {
|
|
23
|
+
const handleKeyDown = useCallback(
|
|
24
|
+
(e: KeyboardEvent) => {
|
|
25
|
+
// Cmd/Ctrl + K: Focus search
|
|
26
|
+
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
const search = document.getElementById("search") as HTMLInputElement;
|
|
29
|
+
search?.focus();
|
|
30
|
+
search?.select();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Arrow navigation
|
|
35
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
const idx = plans.findIndex(
|
|
38
|
+
(p) => p.filename === selectedPlan?.filename
|
|
39
|
+
);
|
|
40
|
+
let newIdx = e.key === "ArrowDown" ? idx + 1 : idx - 1;
|
|
41
|
+
if (newIdx < 0) newIdx = 0;
|
|
42
|
+
if (newIdx >= plans.length) newIdx = plans.length - 1;
|
|
43
|
+
const plan = plans[newIdx];
|
|
44
|
+
if (plan) {
|
|
45
|
+
onSelectPlan(plan);
|
|
46
|
+
document
|
|
47
|
+
.querySelector(`tr[data-filename="${plan.filename}"]`)
|
|
48
|
+
?.scrollIntoView({ block: "nearest" });
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Enter: Open in editor
|
|
54
|
+
if (e.key === "Enter" && selectedPlan) {
|
|
55
|
+
const activeEl = document.activeElement;
|
|
56
|
+
if (
|
|
57
|
+
activeEl?.tagName !== "INPUT" &&
|
|
58
|
+
activeEl?.tagName !== "TEXTAREA" &&
|
|
59
|
+
activeEl?.tagName !== "BUTTON"
|
|
60
|
+
) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
onOpenEditor();
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Escape: Clear search or close modal
|
|
68
|
+
if (e.key === "Escape") {
|
|
69
|
+
onClearSearch();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ?: Toggle help
|
|
74
|
+
if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
|
|
75
|
+
const activeEl = document.activeElement;
|
|
76
|
+
if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA") {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
onToggleHelp();
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// F: Toggle fullscreen overlay
|
|
84
|
+
if (
|
|
85
|
+
e.key === "f" &&
|
|
86
|
+
selectedPlan &&
|
|
87
|
+
!e.metaKey &&
|
|
88
|
+
!e.ctrlKey &&
|
|
89
|
+
!e.altKey
|
|
90
|
+
) {
|
|
91
|
+
const activeEl = document.activeElement;
|
|
92
|
+
if (activeEl?.tagName !== "INPUT" && activeEl?.tagName !== "TEXTAREA") {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
onToggleOverlay();
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
[
|
|
100
|
+
plans,
|
|
101
|
+
selectedPlan,
|
|
102
|
+
onSelectPlan,
|
|
103
|
+
onOpenEditor,
|
|
104
|
+
onToggleHelp,
|
|
105
|
+
onToggleOverlay,
|
|
106
|
+
onClearSearch,
|
|
107
|
+
]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
112
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
113
|
+
}, [handleKeyDown]);
|
|
114
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
2
|
+
import type { Plan, SortKey, SortDir } from "../types.ts";
|
|
3
|
+
import { fetchPlans, fetchPlanContent, refreshCache } from "../utils/api.ts";
|
|
4
|
+
|
|
5
|
+
export interface UsePlansParams {
|
|
6
|
+
q?: string;
|
|
7
|
+
sort?: SortKey;
|
|
8
|
+
dir?: SortDir;
|
|
9
|
+
projects?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UsePlansReturn {
|
|
13
|
+
plans: Plan[];
|
|
14
|
+
total: number;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
refresh: () => Promise<void>;
|
|
17
|
+
ensureContent: (plan: Plan) => Promise<Plan>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usePlans(
|
|
21
|
+
params: UsePlansParams = {},
|
|
22
|
+
): UsePlansReturn {
|
|
23
|
+
const [allPlans, setAllPlans] = useState<Plan[]>([]);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
26
|
+
|
|
27
|
+
// Cache content by filename to persist across filter changes
|
|
28
|
+
const contentCache = useRef<Map<string, string>>(new Map());
|
|
29
|
+
|
|
30
|
+
const loadAllPlans = useCallback(async () => {
|
|
31
|
+
// Cancel previous request
|
|
32
|
+
if (abortControllerRef.current) {
|
|
33
|
+
abortControllerRef.current.abort();
|
|
34
|
+
}
|
|
35
|
+
abortControllerRef.current = new AbortController();
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
setLoading(true);
|
|
39
|
+
const fetchedPlans = await fetchPlans(abortControllerRef.current.signal);
|
|
40
|
+
|
|
41
|
+
// Restore cached content
|
|
42
|
+
const plansWithContent = fetchedPlans.map((p) => ({
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
console.error("Failed to load plans:", err);
|
|
53
|
+
} finally {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
// Load all plans on mount and when parameters change
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
loadAllPlans();
|
|
61
|
+
return () => {
|
|
62
|
+
abortControllerRef.current?.abort();
|
|
63
|
+
};
|
|
64
|
+
}, [loadAllPlans]);
|
|
65
|
+
|
|
66
|
+
const refresh = useCallback(async () => {
|
|
67
|
+
contentCache.current.clear();
|
|
68
|
+
await refreshCache(); // Invalidate backend cache too
|
|
69
|
+
await loadAllPlans();
|
|
70
|
+
}, [loadAllPlans]);
|
|
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
|
+
);
|
|
94
|
+
|
|
95
|
+
return updatedPlan;
|
|
96
|
+
},
|
|
97
|
+
[]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Client-side filtering
|
|
101
|
+
const filteredPlans = useMemo(() => {
|
|
102
|
+
let filtered = allPlans;
|
|
103
|
+
|
|
104
|
+
if (params.q) {
|
|
105
|
+
const lowerQ = params.q.toLowerCase();
|
|
106
|
+
filtered = filtered.filter(p => {
|
|
107
|
+
const content = contentCache.current.get(p.filename) || "";
|
|
108
|
+
return (
|
|
109
|
+
p.title.toLowerCase().includes(lowerQ) ||
|
|
110
|
+
content.toLowerCase().includes(lowerQ) ||
|
|
111
|
+
p.filename.toLowerCase().includes(lowerQ) ||
|
|
112
|
+
(p.project?.toLowerCase().includes(lowerQ) ?? false)
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (params.projects && params.projects.length > 0) {
|
|
118
|
+
const projectFilterSet = new Set(params.projects);
|
|
119
|
+
filtered = filtered.filter(p => p.project && projectFilterSet.has(p.project));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return filtered;
|
|
123
|
+
}, [allPlans, params.q, params.projects]);
|
|
124
|
+
|
|
125
|
+
// Client-side sorting
|
|
126
|
+
const sortedPlans = useMemo(() => {
|
|
127
|
+
const { sort, dir } = params;
|
|
128
|
+
if (!sort) return filteredPlans;
|
|
129
|
+
|
|
130
|
+
const sorted = [...filteredPlans].sort((a, b) => {
|
|
131
|
+
let cmp = 0;
|
|
132
|
+
switch (sort) {
|
|
133
|
+
case "title":
|
|
134
|
+
cmp = a.title.localeCompare(b.title);
|
|
135
|
+
break;
|
|
136
|
+
case "project":
|
|
137
|
+
if (!a.project && !b.project) cmp = 0;
|
|
138
|
+
else if (!a.project) return 1;
|
|
139
|
+
else if (!b.project) return -1;
|
|
140
|
+
else cmp = a.project.localeCompare(b.project);
|
|
141
|
+
break;
|
|
142
|
+
case "size":
|
|
143
|
+
cmp = a.size - b.size;
|
|
144
|
+
break;
|
|
145
|
+
case "lines":
|
|
146
|
+
cmp = a.lineCount - b.lineCount;
|
|
147
|
+
break;
|
|
148
|
+
case "created":
|
|
149
|
+
cmp = new Date(a.created).getTime() - new Date(b.created).getTime();
|
|
150
|
+
break;
|
|
151
|
+
case "modified":
|
|
152
|
+
default:
|
|
153
|
+
cmp = new Date(a.modified).getTime() - new Date(b.modified).getTime();
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
return dir === "asc" ? cmp : -cmp;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return sorted;
|
|
160
|
+
}, [filteredPlans, params.sort, params.dir]);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
plans: sortedPlans,
|
|
164
|
+
total: sortedPlans.length,
|
|
165
|
+
loading,
|
|
166
|
+
refresh,
|
|
167
|
+
ensureContent,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { fetchProjects } from "../utils/api.ts";
|
|
3
|
+
|
|
4
|
+
interface UseProjectsReturn {
|
|
5
|
+
projects: string[];
|
|
6
|
+
loading: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useProjects(): UseProjectsReturn {
|
|
10
|
+
const [projects, setProjects] = useState<string[]>([]);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
fetchProjects()
|
|
15
|
+
.then(setProjects)
|
|
16
|
+
.catch((err) => console.error("Failed to load projects:", err))
|
|
17
|
+
.finally(() => setLoading(false));
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
return { projects, loading };
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { App } from "./App.tsx";
|
|
3
|
+
import "../../styles.css";
|
|
4
|
+
|
|
5
|
+
const container = document.getElementById("root");
|
|
6
|
+
if (!container) {
|
|
7
|
+
throw new Error("Root element not found");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const root = createRoot(container);
|
|
11
|
+
root.render(<App />);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface PlanMetadata {
|
|
2
|
+
filename: string;
|
|
3
|
+
filepath: string;
|
|
4
|
+
title: string;
|
|
5
|
+
size: number;
|
|
6
|
+
modified: string;
|
|
7
|
+
created: string;
|
|
8
|
+
lineCount: number;
|
|
9
|
+
wordCount: number;
|
|
10
|
+
project: string | null;
|
|
11
|
+
sessionId: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Plan extends PlanMetadata {
|
|
15
|
+
content?: string; // Lazy-loaded on demand
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SortKey = "title" | "project" | "modified" | "size" | "lines" | "created";
|
|
19
|
+
export type SortDir = "asc" | "desc";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Plan, PlanMetadata } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
export async function fetchPlans(signal?: AbortSignal): Promise<PlanMetadata[]> {
|
|
4
|
+
const url = new URL("/api/plans", window.location.origin);
|
|
5
|
+
const res = await fetch(url.toString(), { signal });
|
|
6
|
+
if (!res.ok) {
|
|
7
|
+
throw new Error(`Failed to fetch plans: ${res.statusText}`);
|
|
8
|
+
}
|
|
9
|
+
const data = await res.json();
|
|
10
|
+
return data.plans;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function fetchProjects(): Promise<string[]> {
|
|
14
|
+
const res = await fetch("/api/projects");
|
|
15
|
+
const data = await res.json();
|
|
16
|
+
return data.projects;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function fetchPlanContent(filename: string): Promise<string> {
|
|
20
|
+
const res = await fetch(`/api/plans/${encodeURIComponent(filename)}/content`);
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
throw new Error(`Failed to fetch plan content: ${res.statusText}`);
|
|
23
|
+
}
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
return data.content;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function openInEditor(filepath: string): Promise<void> {
|
|
29
|
+
await fetch("/api/open", {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ filepath }),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function refreshCache(): Promise<void> {
|
|
37
|
+
await fetch("/api/refresh", { method: "POST" });
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function formatDate(iso: string): string {
|
|
2
|
+
const d = new Date(iso);
|
|
3
|
+
const now = new Date();
|
|
4
|
+
const diff = now.getTime() - d.getTime();
|
|
5
|
+
|
|
6
|
+
if (diff < 60000) return "just now";
|
|
7
|
+
if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
|
|
8
|
+
if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
|
|
9
|
+
if (diff < 604800000) return Math.floor(diff / 86400000) + "d ago";
|
|
10
|
+
|
|
11
|
+
// Show year if not current year
|
|
12
|
+
if (d.getFullYear() !== now.getFullYear()) {
|
|
13
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatFullDate(iso: string): string {
|
|
20
|
+
return new Date(iso).toLocaleDateString("en-US", {
|
|
21
|
+
year: "numeric",
|
|
22
|
+
month: "short",
|
|
23
|
+
day: "numeric",
|
|
24
|
+
hour: "2-digit",
|
|
25
|
+
minute: "2-digit",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
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
|
+
export function formatSize(bytes: number): string {
|
|
38
|
+
if (bytes < 1024) return bytes + " B";
|
|
39
|
+
return (bytes / 1024).toFixed(1) + " KB";
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from "./formatters.ts";
|
|
2
|
+
export * from "./strings.ts";
|
|
3
|
+
export * from "./markdown.ts";
|
|
4
|
+
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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { escapeHtml } from "./strings.ts";
|
|
2
|
+
|
|
3
|
+
// Prism.js is loaded from CDN
|
|
4
|
+
declare const Prism: {
|
|
5
|
+
highlight: (code: string, grammar: unknown, language: string) => string;
|
|
6
|
+
languages: Record<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function renderMarkdown(content: string): string {
|
|
10
|
+
// First, extract and process code blocks before escaping
|
|
11
|
+
const codeBlocks: string[] = [];
|
|
12
|
+
let processed = content.replace(
|
|
13
|
+
/```(\w*)\n([\s\S]*?)```/g,
|
|
14
|
+
(_, lang, code) => {
|
|
15
|
+
const language = lang || "plaintext";
|
|
16
|
+
const grammar = Prism.languages[language] || Prism.languages.plaintext;
|
|
17
|
+
let highlighted: string;
|
|
18
|
+
try {
|
|
19
|
+
highlighted = grammar
|
|
20
|
+
? Prism.highlight(code, grammar, language)
|
|
21
|
+
: escapeHtml(code);
|
|
22
|
+
} catch {
|
|
23
|
+
highlighted = escapeHtml(code);
|
|
24
|
+
}
|
|
25
|
+
const block = `<pre class="language-${language}"><code class="language-${language}">${highlighted}</code></pre>`;
|
|
26
|
+
codeBlocks.push(block);
|
|
27
|
+
return `\x00CODE_BLOCK_${codeBlocks.length - 1}\x00`;
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Now escape the rest
|
|
32
|
+
let html = escapeHtml(processed);
|
|
33
|
+
|
|
34
|
+
// Restore code blocks
|
|
35
|
+
html = html.replace(/\x00CODE_BLOCK_(\d+)\x00/g, (_, idx) => codeBlocks[parseInt(idx)]);
|
|
36
|
+
|
|
37
|
+
// Inline code
|
|
38
|
+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
39
|
+
|
|
40
|
+
// Headers
|
|
41
|
+
html = html.replace(/^#### (.+)$/gm, "<h4>$1</h4>");
|
|
42
|
+
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
|
43
|
+
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
|
44
|
+
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
|
45
|
+
|
|
46
|
+
// Bold and italic
|
|
47
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
48
|
+
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
49
|
+
|
|
50
|
+
// Blockquotes
|
|
51
|
+
html = html.replace(/^> (.+)$/gm, "<blockquote><p>$1</p></blockquote>");
|
|
52
|
+
|
|
53
|
+
// Horizontal rules
|
|
54
|
+
html = html.replace(/^---$/gm, "<hr>");
|
|
55
|
+
|
|
56
|
+
// Links
|
|
57
|
+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
58
|
+
|
|
59
|
+
// Tables
|
|
60
|
+
html = html.replace(
|
|
61
|
+
/\|(.+)\|\n\|[-| ]+\|\n((?:\|.+\|\n?)+)/g,
|
|
62
|
+
(_, header, body) => {
|
|
63
|
+
const headers = header
|
|
64
|
+
.split("|")
|
|
65
|
+
.filter((c: string) => c.trim())
|
|
66
|
+
.map((c: string) => `<th>${c.trim()}</th>`)
|
|
67
|
+
.join("");
|
|
68
|
+
const rows = body
|
|
69
|
+
.trim()
|
|
70
|
+
.split("\n")
|
|
71
|
+
.map((row: string) => {
|
|
72
|
+
const cells = row
|
|
73
|
+
.split("|")
|
|
74
|
+
.filter((c: string) => c.trim())
|
|
75
|
+
.map((c: string) => `<td>${c.trim()}</td>`)
|
|
76
|
+
.join("");
|
|
77
|
+
return `<tr>${cells}</tr>`;
|
|
78
|
+
})
|
|
79
|
+
.join("");
|
|
80
|
+
return `<table><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Checkbox lists
|
|
85
|
+
html = html.replace(
|
|
86
|
+
/^- \[x\] (.+)$/gm,
|
|
87
|
+
'<li><input type="checkbox" checked disabled> $1</li>'
|
|
88
|
+
);
|
|
89
|
+
html = html.replace(
|
|
90
|
+
/^- \[ \] (.+)$/gm,
|
|
91
|
+
'<li><input type="checkbox" disabled> $1</li>'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Regular lists
|
|
95
|
+
html = html.replace(/^- (.+)$/gm, "<li>$1</li>");
|
|
96
|
+
html = html.replace(/(<li>.*<\/li>\n?)+/g, "<ul>$&</ul>");
|
|
97
|
+
|
|
98
|
+
// Numbered lists
|
|
99
|
+
html = html.replace(/^\d+\. (.+)$/gm, "<li>$1</li>");
|
|
100
|
+
|
|
101
|
+
// Paragraphs - wrap remaining text blocks
|
|
102
|
+
const blocks = html.split(/\n\n+/);
|
|
103
|
+
html = blocks
|
|
104
|
+
.map((block) => {
|
|
105
|
+
const trimmed = block.trim();
|
|
106
|
+
if (!trimmed) return "";
|
|
107
|
+
if (
|
|
108
|
+
trimmed.startsWith("<h") ||
|
|
109
|
+
trimmed.startsWith("<ul") ||
|
|
110
|
+
trimmed.startsWith("<ol") ||
|
|
111
|
+
trimmed.startsWith("<pre") ||
|
|
112
|
+
trimmed.startsWith("<hr") ||
|
|
113
|
+
trimmed.startsWith("<blockquote") ||
|
|
114
|
+
trimmed.startsWith("<table")
|
|
115
|
+
) {
|
|
116
|
+
return trimmed;
|
|
117
|
+
}
|
|
118
|
+
return "<p>" + trimmed.replace(/\n/g, "<br>") + "</p>";
|
|
119
|
+
})
|
|
120
|
+
.join("\n");
|
|
121
|
+
|
|
122
|
+
return html;
|
|
123
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function escapeHtml(str: string): string {
|
|
2
|
+
return str
|
|
3
|
+
.replace(/&/g, "&")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/>/g, ">")
|
|
6
|
+
.replace(/"/g, """);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function escapeRegex(str: string): string {
|
|
10
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function highlightText(text: string, query: string): string {
|
|
14
|
+
if (!query) return escapeHtml(text);
|
|
15
|
+
const escaped = escapeHtml(text);
|
|
16
|
+
const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
|
|
17
|
+
return escaped.replace(regex, "<mark>$1</mark>");
|
|
18
|
+
}
|