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
package/src/client/App.tsx
CHANGED
|
@@ -16,6 +16,7 @@ export function App() {
|
|
|
16
16
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
|
17
17
|
const [showOverlay, setShowOverlay] = useState(false);
|
|
18
18
|
const [showHelp, setShowHelp] = useState(false);
|
|
19
|
+
const [copied, setCopied] = useState(false);
|
|
19
20
|
|
|
20
21
|
// Filter state
|
|
21
22
|
const {
|
|
@@ -35,11 +36,11 @@ export function App() {
|
|
|
35
36
|
// Convert Set to array for API
|
|
36
37
|
const projectsArray = useMemo(
|
|
37
38
|
() => Array.from(selectedProjects),
|
|
38
|
-
[selectedProjects]
|
|
39
|
+
[selectedProjects],
|
|
39
40
|
);
|
|
40
41
|
|
|
41
42
|
// Fetch plans with server-side filtering
|
|
42
|
-
const { plans, loading, refresh, ensureContent } = usePlans({
|
|
43
|
+
const { plans, loading, refreshing, refresh, ensureContent } = usePlans({
|
|
43
44
|
q: debouncedSearch,
|
|
44
45
|
sort: sortKey,
|
|
45
46
|
dir: sortDir,
|
|
@@ -52,6 +53,15 @@ export function App() {
|
|
|
52
53
|
// Load content when plan is selected
|
|
53
54
|
const handleSelectPlan = useCallback(
|
|
54
55
|
async (plan: Plan | null) => {
|
|
56
|
+
// Update URL query parameter
|
|
57
|
+
const url = new URL(window.location.href);
|
|
58
|
+
if (plan) {
|
|
59
|
+
url.searchParams.set("plan", plan.filename);
|
|
60
|
+
} else {
|
|
61
|
+
url.searchParams.delete("plan");
|
|
62
|
+
}
|
|
63
|
+
window.history.replaceState({}, "", url.toString());
|
|
64
|
+
|
|
55
65
|
if (plan) {
|
|
56
66
|
const withContent = await ensureContent(plan);
|
|
57
67
|
setSelectedPlan(withContent);
|
|
@@ -59,7 +69,7 @@ export function App() {
|
|
|
59
69
|
setSelectedPlan(null);
|
|
60
70
|
}
|
|
61
71
|
},
|
|
62
|
-
[ensureContent]
|
|
72
|
+
[ensureContent],
|
|
63
73
|
);
|
|
64
74
|
|
|
65
75
|
// Open in editor
|
|
@@ -74,6 +84,20 @@ export function App() {
|
|
|
74
84
|
navigator.clipboard.writeText(`claude --resume ${sessionId}`);
|
|
75
85
|
}, []);
|
|
76
86
|
|
|
87
|
+
// Copy filepath
|
|
88
|
+
const handleCopyFilepath = useCallback((filepath: string) => {
|
|
89
|
+
navigator.clipboard.writeText(filepath);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
// Copy plan content
|
|
93
|
+
const handleCopyPlan = useCallback(() => {
|
|
94
|
+
if (selectedPlan?.content) {
|
|
95
|
+
navigator.clipboard.writeText(selectedPlan.content);
|
|
96
|
+
setCopied(true);
|
|
97
|
+
setTimeout(() => setCopied(false), 2000);
|
|
98
|
+
}
|
|
99
|
+
}, [selectedPlan]);
|
|
100
|
+
|
|
77
101
|
// Clear search
|
|
78
102
|
const handleClearSearch = useCallback(() => {
|
|
79
103
|
setSearchQuery("");
|
|
@@ -92,19 +116,34 @@ export function App() {
|
|
|
92
116
|
onClearSearch: handleClearSearch,
|
|
93
117
|
});
|
|
94
118
|
|
|
95
|
-
//
|
|
119
|
+
// Select plan from URL or auto-select first plan
|
|
96
120
|
useEffect(() => {
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
if (plans.length === 0) return;
|
|
122
|
+
|
|
123
|
+
// Check URL for plan parameter
|
|
124
|
+
const url = new URL(window.location.href);
|
|
125
|
+
const planFromUrl = url.searchParams.get("plan");
|
|
126
|
+
|
|
127
|
+
if (planFromUrl) {
|
|
128
|
+
const matchingPlan = plans.find((p) => p.filename === planFromUrl);
|
|
129
|
+
if (matchingPlan && selectedPlan?.filename !== planFromUrl) {
|
|
130
|
+
handleSelectPlan(matchingPlan);
|
|
131
|
+
return;
|
|
101
132
|
}
|
|
102
133
|
}
|
|
134
|
+
|
|
135
|
+
// Fall back to selecting first plan if nothing selected
|
|
136
|
+
if (!selectedPlan) {
|
|
137
|
+
handleSelectPlan(plans[0]);
|
|
138
|
+
}
|
|
103
139
|
}, [plans, selectedPlan, handleSelectPlan]);
|
|
104
140
|
|
|
105
141
|
// Clear selection if selected plan is no longer in results
|
|
106
142
|
useEffect(() => {
|
|
107
|
-
if (
|
|
143
|
+
if (
|
|
144
|
+
selectedPlan &&
|
|
145
|
+
!plans.find((p) => p.filename === selectedPlan.filename)
|
|
146
|
+
) {
|
|
108
147
|
setSelectedPlan(null);
|
|
109
148
|
}
|
|
110
149
|
}, [plans, selectedPlan]);
|
|
@@ -130,6 +169,7 @@ export function App() {
|
|
|
130
169
|
onToggleProject={toggleProject}
|
|
131
170
|
onClearProjects={clearProjects}
|
|
132
171
|
onRefresh={refresh}
|
|
172
|
+
refreshing={refreshing}
|
|
133
173
|
/>
|
|
134
174
|
|
|
135
175
|
{plans.length === 0 ? (
|
|
@@ -156,6 +196,9 @@ export function App() {
|
|
|
156
196
|
onOpenEditor={handleOpenEditor}
|
|
157
197
|
onToggleOverlay={() => setShowOverlay(true)}
|
|
158
198
|
onCopySession={handleCopySession}
|
|
199
|
+
onCopyFilepath={handleCopyFilepath}
|
|
200
|
+
onCopyPlan={handleCopyPlan}
|
|
201
|
+
copied={copied}
|
|
159
202
|
/>
|
|
160
203
|
|
|
161
204
|
{showOverlay && selectedPlan && (
|
|
@@ -164,6 +207,8 @@ export function App() {
|
|
|
164
207
|
onClose={() => setShowOverlay(false)}
|
|
165
208
|
onOpenEditor={handleOpenEditor}
|
|
166
209
|
onCopySession={handleCopySession}
|
|
210
|
+
onCopyPlan={handleCopyPlan}
|
|
211
|
+
copied={copied}
|
|
167
212
|
/>
|
|
168
213
|
)}
|
|
169
214
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useCallback } from "react";
|
|
1
|
+
import { useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { useFocusTrap } from "../hooks/useFocusTrap";
|
|
2
3
|
import type { Plan } from "../types.ts";
|
|
3
4
|
import { formatFullDate, formatSize } from "../utils/formatters.ts";
|
|
4
5
|
import { Markdown } from "./Markdown.tsx";
|
|
@@ -8,6 +9,8 @@ interface DetailOverlayProps {
|
|
|
8
9
|
onClose: () => void;
|
|
9
10
|
onOpenEditor: () => void;
|
|
10
11
|
onCopySession: (sessionId: string) => void;
|
|
12
|
+
onCopyPlan: () => void;
|
|
13
|
+
copied: boolean;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export function DetailOverlay({
|
|
@@ -15,7 +18,12 @@ export function DetailOverlay({
|
|
|
15
18
|
onClose,
|
|
16
19
|
onOpenEditor,
|
|
17
20
|
onCopySession,
|
|
21
|
+
onCopyPlan,
|
|
22
|
+
copied,
|
|
18
23
|
}: DetailOverlayProps) {
|
|
24
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
useFocusTrap(panelRef, true);
|
|
26
|
+
|
|
19
27
|
const handleKeyDown = useCallback(
|
|
20
28
|
(e: KeyboardEvent) => {
|
|
21
29
|
if (e.key === "Escape" || e.key === "f") {
|
|
@@ -23,7 +31,7 @@ export function DetailOverlay({
|
|
|
23
31
|
onClose();
|
|
24
32
|
}
|
|
25
33
|
},
|
|
26
|
-
[onClose]
|
|
34
|
+
[onClose],
|
|
27
35
|
);
|
|
28
36
|
|
|
29
37
|
useEffect(() => {
|
|
@@ -43,11 +51,20 @@ export function DetailOverlay({
|
|
|
43
51
|
aria-hidden="false"
|
|
44
52
|
onClick={onClose}
|
|
45
53
|
>
|
|
46
|
-
<div
|
|
54
|
+
<div
|
|
55
|
+
ref={panelRef}
|
|
56
|
+
className="detail-overlay-panel"
|
|
57
|
+
role="dialog"
|
|
58
|
+
aria-modal="true"
|
|
59
|
+
aria-labelledby="detail-overlay-title"
|
|
60
|
+
onClick={(e) => e.stopPropagation()}
|
|
61
|
+
>
|
|
47
62
|
<div className="detail-overlay-bar">
|
|
48
63
|
<div className="detail-meta detail-overlay-meta">
|
|
49
|
-
{plan.project &&
|
|
50
|
-
|
|
64
|
+
{plan.project && (
|
|
65
|
+
<span className="project-tag">{plan.project}</span>
|
|
66
|
+
)}
|
|
67
|
+
<span id="detail-overlay-title">{plan.filename}</span>
|
|
51
68
|
<span>{formatFullDate(plan.modified)}</span>
|
|
52
69
|
<span>{formatSize(plan.size)}</span>
|
|
53
70
|
<span>{plan.lineCount} lines</span>
|
|
@@ -61,14 +78,42 @@ export function DetailOverlay({
|
|
|
61
78
|
</button>
|
|
62
79
|
)}
|
|
63
80
|
</div>
|
|
81
|
+
<button
|
|
82
|
+
className={copied ? "action-btn copied" : "action-btn"}
|
|
83
|
+
onClick={onCopyPlan}
|
|
84
|
+
title="Copy plan to clipboard"
|
|
85
|
+
>
|
|
86
|
+
<svg
|
|
87
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
88
|
+
viewBox="0 0 24 24"
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
strokeWidth="2"
|
|
92
|
+
strokeLinecap="round"
|
|
93
|
+
strokeLinejoin="round"
|
|
94
|
+
className="icon"
|
|
95
|
+
>
|
|
96
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
97
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
98
|
+
</svg>
|
|
99
|
+
</button>
|
|
64
100
|
<button
|
|
65
101
|
className="action-btn"
|
|
66
102
|
onClick={onOpenEditor}
|
|
67
103
|
title="Open in editor (Enter)"
|
|
68
104
|
>
|
|
69
|
-
<svg
|
|
70
|
-
|
|
71
|
-
|
|
105
|
+
<svg
|
|
106
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
107
|
+
viewBox="0 0 24 24"
|
|
108
|
+
fill="none"
|
|
109
|
+
stroke="currentColor"
|
|
110
|
+
strokeWidth="2"
|
|
111
|
+
strokeLinecap="round"
|
|
112
|
+
strokeLinejoin="round"
|
|
113
|
+
className="icon"
|
|
114
|
+
>
|
|
115
|
+
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
116
|
+
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
72
117
|
</svg>
|
|
73
118
|
</button>
|
|
74
119
|
<button
|
|
@@ -76,7 +121,19 @@ export function DetailOverlay({
|
|
|
76
121
|
onClick={onClose}
|
|
77
122
|
title="Close fullscreen (Esc or F)"
|
|
78
123
|
>
|
|
79
|
-
|
|
124
|
+
<svg
|
|
125
|
+
width="14"
|
|
126
|
+
height="14"
|
|
127
|
+
viewBox="0 0 24 24"
|
|
128
|
+
fill="none"
|
|
129
|
+
stroke="currentColor"
|
|
130
|
+
strokeWidth="2.5"
|
|
131
|
+
strokeLinecap="round"
|
|
132
|
+
strokeLinejoin="round"
|
|
133
|
+
>
|
|
134
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
135
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
136
|
+
</svg>
|
|
80
137
|
</button>
|
|
81
138
|
</div>
|
|
82
139
|
<div className="detail-overlay-content">
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Plan } from "../types.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
formatDate,
|
|
4
|
+
formatCompactDateTime,
|
|
5
|
+
formatCreatedShort,
|
|
6
|
+
isSameDay,
|
|
7
|
+
} from "../utils/formatters.ts";
|
|
3
8
|
import { Markdown } from "./Markdown.tsx";
|
|
4
9
|
|
|
5
10
|
interface DetailPanelProps {
|
|
@@ -7,6 +12,9 @@ interface DetailPanelProps {
|
|
|
7
12
|
onOpenEditor: () => void;
|
|
8
13
|
onToggleOverlay: () => void;
|
|
9
14
|
onCopySession: (sessionId: string) => void;
|
|
15
|
+
onCopyFilepath: (filepath: string) => void;
|
|
16
|
+
onCopyPlan: () => void;
|
|
17
|
+
copied: boolean;
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
export function DetailPanel({
|
|
@@ -14,13 +22,18 @@ export function DetailPanel({
|
|
|
14
22
|
onOpenEditor,
|
|
15
23
|
onToggleOverlay,
|
|
16
24
|
onCopySession,
|
|
25
|
+
onCopyFilepath,
|
|
26
|
+
onCopyPlan,
|
|
27
|
+
copied,
|
|
17
28
|
}: DetailPanelProps) {
|
|
18
29
|
if (!plan) {
|
|
19
30
|
return (
|
|
20
31
|
<div id="detail-panel" className="detail-panel">
|
|
21
32
|
<div className="detail-empty">
|
|
22
33
|
<p>Select a plan to view details</p>
|
|
23
|
-
<p className="hint">
|
|
34
|
+
<p className="hint">
|
|
35
|
+
Use ↑↓ arrows to navigate, Enter to open in editor
|
|
36
|
+
</p>
|
|
24
37
|
</div>
|
|
25
38
|
</div>
|
|
26
39
|
);
|
|
@@ -32,14 +45,42 @@ export function DetailPanel({
|
|
|
32
45
|
<div className="detail-header-top">
|
|
33
46
|
<h2 className="detail-title">{plan.title}</h2>
|
|
34
47
|
<div className="detail-actions">
|
|
48
|
+
<button
|
|
49
|
+
className={copied ? "action-btn copied" : "action-btn"}
|
|
50
|
+
onClick={onCopyPlan}
|
|
51
|
+
title="Copy plan to clipboard"
|
|
52
|
+
>
|
|
53
|
+
<svg
|
|
54
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
55
|
+
viewBox="0 0 24 24"
|
|
56
|
+
fill="none"
|
|
57
|
+
stroke="currentColor"
|
|
58
|
+
strokeWidth="2"
|
|
59
|
+
strokeLinecap="round"
|
|
60
|
+
strokeLinejoin="round"
|
|
61
|
+
className="icon"
|
|
62
|
+
>
|
|
63
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
64
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
65
|
+
</svg>
|
|
66
|
+
</button>
|
|
35
67
|
<button
|
|
36
68
|
className="action-btn"
|
|
37
69
|
onClick={onOpenEditor}
|
|
38
70
|
title="Open in editor (Enter)"
|
|
39
71
|
>
|
|
40
|
-
<svg
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
<svg
|
|
73
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
74
|
+
viewBox="0 0 24 24"
|
|
75
|
+
fill="none"
|
|
76
|
+
stroke="currentColor"
|
|
77
|
+
strokeWidth="2"
|
|
78
|
+
strokeLinecap="round"
|
|
79
|
+
strokeLinejoin="round"
|
|
80
|
+
className="icon"
|
|
81
|
+
>
|
|
82
|
+
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
83
|
+
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
43
84
|
</svg>
|
|
44
85
|
</button>
|
|
45
86
|
<button
|
|
@@ -53,13 +94,24 @@ export function DetailPanel({
|
|
|
53
94
|
</div>
|
|
54
95
|
|
|
55
96
|
<div className="detail-meta">
|
|
56
|
-
<span title={plan.modified}>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
97
|
+
<span title={plan.modified}>{formatDate(plan.modified)}</span>
|
|
98
|
+
<span>{formatCompactDateTime(plan.modified)}</span>
|
|
99
|
+
{!isSameDay(plan.modified, plan.created) && (
|
|
100
|
+
<span className="created-date">
|
|
101
|
+
(created {formatCreatedShort(plan.created)})
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
61
104
|
<span>{plan.wordCount} words</span>
|
|
62
|
-
|
|
105
|
+
<button
|
|
106
|
+
className="filename-tag"
|
|
107
|
+
onClick={() => onCopyFilepath(plan.filepath)}
|
|
108
|
+
title={`Click to copy: ${plan.filepath}`}
|
|
109
|
+
>
|
|
110
|
+
{plan.filename}
|
|
111
|
+
</button>
|
|
112
|
+
{plan.project && (
|
|
113
|
+
<span className="project-badge">{plan.project}</span>
|
|
114
|
+
)}
|
|
63
115
|
{plan.sessionId && (
|
|
64
116
|
<button
|
|
65
117
|
className="session-tag"
|
|
@@ -9,6 +9,7 @@ interface HeaderProps {
|
|
|
9
9
|
onToggleProject: (project: string) => void;
|
|
10
10
|
onClearProjects: () => void;
|
|
11
11
|
onRefresh: () => void;
|
|
12
|
+
refreshing: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function Header({
|
|
@@ -19,13 +20,19 @@ export function Header({
|
|
|
19
20
|
onToggleProject,
|
|
20
21
|
onClearProjects,
|
|
21
22
|
onRefresh,
|
|
23
|
+
refreshing,
|
|
22
24
|
}: HeaderProps) {
|
|
23
25
|
return (
|
|
24
26
|
<div className="header">
|
|
25
27
|
<div className="header-row">
|
|
26
28
|
<h1>
|
|
27
|
-
<svg
|
|
28
|
-
|
|
29
|
+
<svg
|
|
30
|
+
className="icon"
|
|
31
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="currentColor"
|
|
34
|
+
>
|
|
35
|
+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
|
29
36
|
</svg>
|
|
30
37
|
Claude Plan Viewer
|
|
31
38
|
</h1>
|
|
@@ -40,13 +47,25 @@ export function Header({
|
|
|
40
47
|
/>
|
|
41
48
|
)}
|
|
42
49
|
<button
|
|
43
|
-
className="action-btn"
|
|
50
|
+
className={refreshing ? "action-btn loading" : "action-btn"}
|
|
44
51
|
id="refresh-btn"
|
|
45
52
|
onClick={onRefresh}
|
|
53
|
+
disabled={refreshing}
|
|
46
54
|
title="Refresh plans"
|
|
47
55
|
>
|
|
48
|
-
<svg
|
|
49
|
-
|
|
56
|
+
<svg
|
|
57
|
+
className="icon"
|
|
58
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
59
|
+
fill="none"
|
|
60
|
+
viewBox="0 0 24 24"
|
|
61
|
+
stroke="currentColor"
|
|
62
|
+
>
|
|
63
|
+
<path
|
|
64
|
+
strokeLinecap="round"
|
|
65
|
+
strokeLinejoin="round"
|
|
66
|
+
strokeWidth="2"
|
|
67
|
+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
68
|
+
/>
|
|
50
69
|
</svg>
|
|
51
70
|
</button>
|
|
52
71
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useCallback } from "react";
|
|
1
|
+
import { useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import { useFocusTrap } from "../hooks/useFocusTrap";
|
|
2
3
|
|
|
3
4
|
interface HelpModalProps {
|
|
4
5
|
onClose: () => void;
|
|
@@ -14,6 +15,9 @@ const SHORTCUTS = [
|
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
export function HelpModal({ onClose }: HelpModalProps) {
|
|
18
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
useFocusTrap(modalRef, true);
|
|
20
|
+
|
|
17
21
|
const handleKeyDown = useCallback(
|
|
18
22
|
(e: KeyboardEvent) => {
|
|
19
23
|
if (e.key === "Escape" || e.key === "?") {
|
|
@@ -21,7 +25,7 @@ export function HelpModal({ onClose }: HelpModalProps) {
|
|
|
21
25
|
onClose();
|
|
22
26
|
}
|
|
23
27
|
},
|
|
24
|
-
[onClose]
|
|
28
|
+
[onClose],
|
|
25
29
|
);
|
|
26
30
|
|
|
27
31
|
useEffect(() => {
|
|
@@ -30,12 +34,31 @@ export function HelpModal({ onClose }: HelpModalProps) {
|
|
|
30
34
|
}, [handleKeyDown]);
|
|
31
35
|
|
|
32
36
|
return (
|
|
33
|
-
<div className="modal-
|
|
34
|
-
<div
|
|
37
|
+
<div className="modal-backdrop" onClick={onClose}>
|
|
38
|
+
<div
|
|
39
|
+
ref={modalRef}
|
|
40
|
+
className="modal help-modal"
|
|
41
|
+
role="dialog"
|
|
42
|
+
aria-modal="true"
|
|
43
|
+
aria-labelledby="help-modal-title"
|
|
44
|
+
onClick={(e) => e.stopPropagation()}
|
|
45
|
+
>
|
|
35
46
|
<div className="modal-header">
|
|
36
|
-
<h3>Keyboard Shortcuts</h3>
|
|
37
|
-
<button className="
|
|
38
|
-
|
|
47
|
+
<h3 id="help-modal-title">Keyboard Shortcuts</h3>
|
|
48
|
+
<button className="modal-close" onClick={onClose} title="Close (Esc)">
|
|
49
|
+
<svg
|
|
50
|
+
width="14"
|
|
51
|
+
height="14"
|
|
52
|
+
viewBox="0 0 24 24"
|
|
53
|
+
fill="none"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
strokeWidth="2.5"
|
|
56
|
+
strokeLinecap="round"
|
|
57
|
+
strokeLinejoin="round"
|
|
58
|
+
>
|
|
59
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
60
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
61
|
+
</svg>
|
|
39
62
|
</button>
|
|
40
63
|
</div>
|
|
41
64
|
<div className="modal-body">
|
|
@@ -1,16 +1,46 @@
|
|
|
1
|
-
import
|
|
1
|
+
import ReactMarkdown from "react-markdown";
|
|
2
|
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
3
|
+
import { atomDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
4
|
+
import remarkGfm from "remark-gfm";
|
|
2
5
|
|
|
3
6
|
interface MarkdownProps {
|
|
4
7
|
content: string;
|
|
5
8
|
}
|
|
6
9
|
|
|
7
10
|
export function Markdown({ content }: MarkdownProps) {
|
|
8
|
-
const html = renderMarkdown(content);
|
|
9
|
-
|
|
10
11
|
return (
|
|
11
|
-
<div
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
<div className="markdown">
|
|
13
|
+
<ReactMarkdown
|
|
14
|
+
remarkPlugins={[remarkGfm]}
|
|
15
|
+
components={{
|
|
16
|
+
code({ inline, className, children, ...props }) {
|
|
17
|
+
const match = /language-(\w+)/.exec(className || "");
|
|
18
|
+
return !inline && match ? (
|
|
19
|
+
<SyntaxHighlighter
|
|
20
|
+
style={atomDark}
|
|
21
|
+
language={match[1]}
|
|
22
|
+
PreTag="div"
|
|
23
|
+
{...props}
|
|
24
|
+
>
|
|
25
|
+
{String(children).replace(/\n$/, "")}
|
|
26
|
+
</SyntaxHighlighter>
|
|
27
|
+
) : (
|
|
28
|
+
<code className={className} {...props}>
|
|
29
|
+
{children}
|
|
30
|
+
</code>
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
table({ children, ...props }) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="markdown-table-wrapper">
|
|
36
|
+
<table {...props}>{children}</table>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{content}
|
|
43
|
+
</ReactMarkdown>
|
|
44
|
+
</div>
|
|
15
45
|
);
|
|
16
46
|
}
|
|
@@ -9,7 +9,12 @@ interface PlanRowProps {
|
|
|
9
9
|
onSelect: (plan: Plan) => void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function PlanRow({
|
|
12
|
+
export function PlanRow({
|
|
13
|
+
plan,
|
|
14
|
+
selected,
|
|
15
|
+
searchQuery,
|
|
16
|
+
onSelect,
|
|
17
|
+
}: PlanRowProps) {
|
|
13
18
|
return (
|
|
14
19
|
<tr
|
|
15
20
|
className={selected ? "selected" : ""}
|
|
@@ -22,14 +27,18 @@ export function PlanRow({ plan, selected, searchQuery, onSelect }: PlanRowProps)
|
|
|
22
27
|
data-filename={plan.filename}
|
|
23
28
|
title={plan.title}
|
|
24
29
|
dangerouslySetInnerHTML={{
|
|
25
|
-
__html: searchQuery
|
|
30
|
+
__html: searchQuery
|
|
31
|
+
? highlightText(plan.title, searchQuery)
|
|
32
|
+
: plan.title,
|
|
26
33
|
}}
|
|
27
34
|
/>
|
|
28
35
|
</td>
|
|
29
36
|
<td className="filename-cell">
|
|
30
37
|
<span
|
|
31
38
|
dangerouslySetInnerHTML={{
|
|
32
|
-
__html: searchQuery
|
|
39
|
+
__html: searchQuery
|
|
40
|
+
? highlightText(plan.filename, searchQuery)
|
|
41
|
+
: plan.filename,
|
|
33
42
|
}}
|
|
34
43
|
/>
|
|
35
44
|
</td>
|
|
@@ -37,7 +46,9 @@ export function PlanRow({ plan, selected, searchQuery, onSelect }: PlanRowProps)
|
|
|
37
46
|
{plan.project ? (
|
|
38
47
|
<span
|
|
39
48
|
dangerouslySetInnerHTML={{
|
|
40
|
-
__html: searchQuery
|
|
49
|
+
__html: searchQuery
|
|
50
|
+
? highlightText(plan.project, searchQuery)
|
|
51
|
+
: plan.project,
|
|
41
52
|
}}
|
|
42
53
|
/>
|
|
43
54
|
) : (
|
|
@@ -20,11 +20,9 @@ export function ProjectFilter({
|
|
|
20
20
|
.map((p) => ({ value: p, label: p }));
|
|
21
21
|
|
|
22
22
|
const handleChange = (
|
|
23
|
-
newValue: MultiValue<{ value: string; label: string }> | null
|
|
23
|
+
newValue: MultiValue<{ value: string; label: string }> | null,
|
|
24
24
|
) => {
|
|
25
|
-
const selectedSet = new Set(
|
|
26
|
-
(newValue ?? []).map((option) => option.value)
|
|
27
|
-
);
|
|
25
|
+
const selectedSet = new Set((newValue ?? []).map((option) => option.value));
|
|
28
26
|
|
|
29
27
|
projects.forEach((project) => {
|
|
30
28
|
const isSelected = selectedSet.has(project);
|
|
@@ -36,10 +34,7 @@ export function ProjectFilter({
|
|
|
36
34
|
};
|
|
37
35
|
|
|
38
36
|
const customStyles = {
|
|
39
|
-
control: (
|
|
40
|
-
base: object,
|
|
41
|
-
state: { isFocused: boolean }
|
|
42
|
-
) => ({
|
|
37
|
+
control: (base: object, state: { isFocused: boolean }) => ({
|
|
43
38
|
...base,
|
|
44
39
|
background: "var(--bg-tertiary)",
|
|
45
40
|
border: "1px solid var(--border)",
|
|
@@ -100,14 +95,14 @@ export function ProjectFilter({
|
|
|
100
95
|
}),
|
|
101
96
|
option: (
|
|
102
97
|
base: object,
|
|
103
|
-
state: { isSelected: boolean; isFocused: boolean }
|
|
98
|
+
state: { isSelected: boolean; isFocused: boolean },
|
|
104
99
|
) => ({
|
|
105
100
|
...base,
|
|
106
101
|
background: state.isSelected
|
|
107
102
|
? "var(--accent-dim)"
|
|
108
103
|
: state.isFocused
|
|
109
|
-
|
|
110
|
-
|
|
104
|
+
? "var(--bg-hover)"
|
|
105
|
+
: "transparent",
|
|
111
106
|
color: "var(--text-primary)",
|
|
112
107
|
fontSize: "12px",
|
|
113
108
|
cursor: "pointer",
|
|
@@ -23,7 +23,13 @@ export function SearchInput({ value, onChange }: SearchInputProps) {
|
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
25
|
<div className="search-wrapper">
|
|
26
|
-
<svg
|
|
26
|
+
<svg
|
|
27
|
+
className="search-icon"
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
fill="none"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeWidth="2"
|
|
32
|
+
>
|
|
27
33
|
<circle cx="11" cy="11" r="8" />
|
|
28
34
|
<path d="m21 21-4.35-4.35" />
|
|
29
35
|
</svg>
|
|
@@ -4,7 +4,6 @@ export { PlansTable } from "./PlansTable.tsx";
|
|
|
4
4
|
export { DetailPanel } from "./DetailPanel.tsx";
|
|
5
5
|
export { DetailOverlay } from "./DetailOverlay.tsx";
|
|
6
6
|
export { SearchInput } from "./SearchInput.tsx";
|
|
7
|
-
export { SortDropdown } from "./SortDropdown.tsx";
|
|
8
7
|
export { ProjectFilter } from "./ProjectFilter.tsx";
|
|
9
8
|
export { HelpModal } from "./HelpModal.tsx";
|
|
10
9
|
export { Header } from "./Header.tsx";
|