@contractspec/lib.example-shared-ui 0.0.0-canary-20260113170453
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/.turbo/turbo-build$colon$bundle.log +9 -0
- package/.turbo/turbo-build.log +11 -0
- package/CHANGELOG.md +34 -0
- package/dist/index.mjs +3121 -0
- package/package.json +43 -0
- package/src/EvolutionDashboard.tsx +480 -0
- package/src/EvolutionSidebar.tsx +282 -0
- package/src/LocalDataIndicator.tsx +39 -0
- package/src/MarkdownView.tsx +389 -0
- package/src/OverlayContextProvider.tsx +341 -0
- package/src/PersonalizationInsights.tsx +293 -0
- package/src/SaveToStudioButton.tsx +64 -0
- package/src/SpecEditorPanel.tsx +165 -0
- package/src/TemplateShell.tsx +63 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useBehaviorTracking.ts +327 -0
- package/src/hooks/useEvolution.ts +501 -0
- package/src/hooks/useRegistryTemplates.ts +49 -0
- package/src/hooks/useSpecContent.ts +243 -0
- package/src/hooks/useWorkflowComposer.ts +670 -0
- package/src/index.ts +15 -0
- package/src/lib/component-registry.tsx +64 -0
- package/src/lib/runtime-context.tsx +54 -0
- package/src/lib/types.ts +84 -0
- package/src/overlay-types.ts +25 -0
- package/src/utils/fetchPresentationData.ts +48 -0
- package/src/utils/generateSpecFromTemplate.ts +458 -0
- package/src/utils/index.ts +2 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useMemo } from 'react';
|
|
4
|
+
import { Button } from '@contractspec/lib.design-system';
|
|
5
|
+
import { Card } from '@contractspec/lib.ui-kit-web/ui/card';
|
|
6
|
+
import { Badge } from '@contractspec/lib.ui-kit-web/ui/badge';
|
|
7
|
+
import type { TemplateId } from './lib/types';
|
|
8
|
+
import { type SpecSuggestion, useEvolution } from './hooks/useEvolution';
|
|
9
|
+
|
|
10
|
+
export interface EvolutionSidebarProps {
|
|
11
|
+
templateId: TemplateId;
|
|
12
|
+
/** Whether sidebar is expanded */
|
|
13
|
+
expanded?: boolean;
|
|
14
|
+
/** Toggle expanded state */
|
|
15
|
+
onToggle?: () => void;
|
|
16
|
+
/** Callback for logging actions */
|
|
17
|
+
onLog?: (message: string) => void;
|
|
18
|
+
/** Navigate to full evolution mode */
|
|
19
|
+
onOpenEvolution?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compact sidebar for Evolution Engine.
|
|
24
|
+
* Shows top anomalies, pending suggestions, and quick actions.
|
|
25
|
+
* Collapsible by default.
|
|
26
|
+
*/
|
|
27
|
+
export function EvolutionSidebar({
|
|
28
|
+
templateId,
|
|
29
|
+
expanded = false,
|
|
30
|
+
onToggle,
|
|
31
|
+
onLog,
|
|
32
|
+
onOpenEvolution,
|
|
33
|
+
}: EvolutionSidebarProps) {
|
|
34
|
+
const {
|
|
35
|
+
anomalies,
|
|
36
|
+
suggestions,
|
|
37
|
+
loading,
|
|
38
|
+
approveSuggestion,
|
|
39
|
+
rejectSuggestion,
|
|
40
|
+
operationCount,
|
|
41
|
+
} = useEvolution(templateId);
|
|
42
|
+
|
|
43
|
+
const pendingSuggestions = useMemo(
|
|
44
|
+
() => suggestions.filter((s) => s.status === 'pending'),
|
|
45
|
+
[suggestions]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const topAnomalies = useMemo(
|
|
49
|
+
() =>
|
|
50
|
+
anomalies
|
|
51
|
+
.sort((a, b) => {
|
|
52
|
+
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
53
|
+
return severityOrder[a.severity] - severityOrder[b.severity];
|
|
54
|
+
})
|
|
55
|
+
.slice(0, 3),
|
|
56
|
+
[anomalies]
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const handleApprove = useCallback(
|
|
60
|
+
(id: string) => {
|
|
61
|
+
approveSuggestion(id);
|
|
62
|
+
onLog?.(`Approved suggestion ${id.slice(0, 8)}`);
|
|
63
|
+
},
|
|
64
|
+
[approveSuggestion, onLog]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const handleReject = useCallback(
|
|
68
|
+
(id: string) => {
|
|
69
|
+
rejectSuggestion(id);
|
|
70
|
+
onLog?.(`Rejected suggestion ${id.slice(0, 8)}`);
|
|
71
|
+
},
|
|
72
|
+
[rejectSuggestion, onLog]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Collapsed view - just show badge
|
|
76
|
+
if (!expanded) {
|
|
77
|
+
return (
|
|
78
|
+
<button
|
|
79
|
+
onClick={onToggle}
|
|
80
|
+
className="flex items-center gap-2 rounded-lg border border-violet-500/30 bg-violet-500/10 px-3 py-2 text-sm transition hover:bg-violet-500/20"
|
|
81
|
+
type="button"
|
|
82
|
+
>
|
|
83
|
+
<span>🤖</span>
|
|
84
|
+
<span>Evolution</span>
|
|
85
|
+
{pendingSuggestions.length > 0 && (
|
|
86
|
+
<Badge
|
|
87
|
+
variant="secondary"
|
|
88
|
+
className="border-amber-500/30 bg-amber-500/20 text-amber-400"
|
|
89
|
+
>
|
|
90
|
+
{pendingSuggestions.length}
|
|
91
|
+
</Badge>
|
|
92
|
+
)}
|
|
93
|
+
{anomalies.length > 0 && pendingSuggestions.length === 0 && (
|
|
94
|
+
<Badge variant="destructive">{anomalies.length}</Badge>
|
|
95
|
+
)}
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Expanded view
|
|
101
|
+
return (
|
|
102
|
+
<Card className="w-80 overflow-hidden">
|
|
103
|
+
{/* Header */}
|
|
104
|
+
<div className="flex items-center justify-between border-b border-violet-500/20 bg-violet-500/5 px-3 py-2">
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<span>🤖</span>
|
|
107
|
+
<span className="text-sm font-semibold">Evolution</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex items-center gap-1">
|
|
110
|
+
{onOpenEvolution && (
|
|
111
|
+
<Button variant="ghost" size="sm" onPress={onOpenEvolution}>
|
|
112
|
+
Expand
|
|
113
|
+
</Button>
|
|
114
|
+
)}
|
|
115
|
+
<button
|
|
116
|
+
onClick={onToggle}
|
|
117
|
+
className="text-muted-foreground hover:text-foreground p-1"
|
|
118
|
+
type="button"
|
|
119
|
+
title="Collapse"
|
|
120
|
+
>
|
|
121
|
+
✕
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="max-h-96 overflow-y-auto p-3">
|
|
127
|
+
{/* Stats */}
|
|
128
|
+
<div className="mb-3 flex items-center justify-between text-xs">
|
|
129
|
+
<span className="text-muted-foreground">
|
|
130
|
+
{operationCount} ops tracked
|
|
131
|
+
</span>
|
|
132
|
+
<div className="flex items-center gap-2">
|
|
133
|
+
{anomalies.length > 0 && (
|
|
134
|
+
<Badge variant="destructive">{anomalies.length} anomalies</Badge>
|
|
135
|
+
)}
|
|
136
|
+
{pendingSuggestions.length > 0 && (
|
|
137
|
+
<Badge
|
|
138
|
+
variant="secondary"
|
|
139
|
+
className="border-amber-500/30 bg-amber-500/20 text-amber-400"
|
|
140
|
+
>
|
|
141
|
+
{pendingSuggestions.length} pending
|
|
142
|
+
</Badge>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{loading && (
|
|
148
|
+
<div className="text-muted-foreground py-4 text-center text-sm">
|
|
149
|
+
Generating suggestions...
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Top Anomalies */}
|
|
154
|
+
{topAnomalies.length > 0 && (
|
|
155
|
+
<div className="mb-4">
|
|
156
|
+
<p className="mb-2 text-xs font-semibold text-violet-400 uppercase">
|
|
157
|
+
Top Issues
|
|
158
|
+
</p>
|
|
159
|
+
<div className="space-y-2">
|
|
160
|
+
{topAnomalies.map((anomaly, index) => (
|
|
161
|
+
<div
|
|
162
|
+
key={`${anomaly.operation.name}-${index}`}
|
|
163
|
+
className="rounded border border-amber-500/20 bg-amber-500/5 p-2 text-xs"
|
|
164
|
+
>
|
|
165
|
+
<div className="flex items-center gap-2">
|
|
166
|
+
<span>
|
|
167
|
+
{anomaly.severity === 'high'
|
|
168
|
+
? '🔴'
|
|
169
|
+
: anomaly.severity === 'medium'
|
|
170
|
+
? '🟠'
|
|
171
|
+
: '🟡'}
|
|
172
|
+
</span>
|
|
173
|
+
<span className="truncate font-medium">
|
|
174
|
+
{anomaly.operation.name}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
<p className="text-muted-foreground mt-1 truncate">
|
|
178
|
+
{anomaly.description}
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{/* Pending Suggestions */}
|
|
187
|
+
{pendingSuggestions.length > 0 && (
|
|
188
|
+
<div>
|
|
189
|
+
<p className="mb-2 text-xs font-semibold text-violet-400 uppercase">
|
|
190
|
+
Pending Suggestions
|
|
191
|
+
</p>
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
{pendingSuggestions.slice(0, 3).map((suggestion) => (
|
|
194
|
+
<CompactSuggestionCard
|
|
195
|
+
key={suggestion.id}
|
|
196
|
+
suggestion={suggestion}
|
|
197
|
+
onApprove={handleApprove}
|
|
198
|
+
onReject={handleReject}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
{pendingSuggestions.length > 3 && (
|
|
202
|
+
<p className="text-muted-foreground text-center text-xs">
|
|
203
|
+
+{pendingSuggestions.length - 3} more suggestions
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{/* Empty State */}
|
|
211
|
+
{anomalies.length === 0 &&
|
|
212
|
+
pendingSuggestions.length === 0 &&
|
|
213
|
+
!loading && (
|
|
214
|
+
<div className="text-muted-foreground py-4 text-center text-xs">
|
|
215
|
+
No issues detected. Keep coding!
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Footer */}
|
|
221
|
+
{onOpenEvolution && (
|
|
222
|
+
<div className="border-t border-violet-500/20 p-2">
|
|
223
|
+
<Button
|
|
224
|
+
variant="ghost"
|
|
225
|
+
size="sm"
|
|
226
|
+
className="w-full"
|
|
227
|
+
onPress={onOpenEvolution}
|
|
228
|
+
>
|
|
229
|
+
Open Evolution Dashboard →
|
|
230
|
+
</Button>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</Card>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Compact suggestion card for sidebar
|
|
239
|
+
*/
|
|
240
|
+
function CompactSuggestionCard({
|
|
241
|
+
suggestion,
|
|
242
|
+
onApprove,
|
|
243
|
+
onReject,
|
|
244
|
+
}: {
|
|
245
|
+
suggestion: SpecSuggestion;
|
|
246
|
+
onApprove: (id: string) => void;
|
|
247
|
+
onReject: (id: string) => void;
|
|
248
|
+
}) {
|
|
249
|
+
return (
|
|
250
|
+
<div className="rounded border border-violet-500/20 bg-violet-500/5 p-2">
|
|
251
|
+
<div className="flex items-start justify-between gap-2">
|
|
252
|
+
<div className="min-w-0 flex-1">
|
|
253
|
+
<p className="truncate text-xs font-medium">
|
|
254
|
+
{suggestion.proposal.summary}
|
|
255
|
+
</p>
|
|
256
|
+
<div className="mt-1 flex items-center gap-2 text-xs">
|
|
257
|
+
<Badge variant="secondary">{suggestion.priority}</Badge>
|
|
258
|
+
<span className="text-muted-foreground">
|
|
259
|
+
{(suggestion.confidence * 100).toFixed(0)}%
|
|
260
|
+
</span>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
<div className="mt-2 flex justify-end gap-1">
|
|
265
|
+
<button
|
|
266
|
+
onClick={() => onReject(suggestion.id)}
|
|
267
|
+
className="rounded px-2 py-0.5 text-xs text-red-400 hover:bg-red-400/10"
|
|
268
|
+
type="button"
|
|
269
|
+
>
|
|
270
|
+
Reject
|
|
271
|
+
</button>
|
|
272
|
+
<button
|
|
273
|
+
onClick={() => onApprove(suggestion.id)}
|
|
274
|
+
className="rounded bg-violet-500/20 px-2 py-0.5 text-xs text-violet-400 hover:bg-violet-500/30"
|
|
275
|
+
type="button"
|
|
276
|
+
>
|
|
277
|
+
Approve
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { RefreshCw, Shield } from 'lucide-react';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useTemplateRuntime } from './lib/runtime-context';
|
|
7
|
+
|
|
8
|
+
export function LocalDataIndicator() {
|
|
9
|
+
const { projectId, templateId, template, installer } = useTemplateRuntime();
|
|
10
|
+
const [isResetting, setIsResetting] = useState(false);
|
|
11
|
+
|
|
12
|
+
const handleReset = async () => {
|
|
13
|
+
setIsResetting(true);
|
|
14
|
+
try {
|
|
15
|
+
await installer.install(templateId, { projectId });
|
|
16
|
+
} finally {
|
|
17
|
+
setIsResetting(false);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="border-border bg-muted/40 text-muted-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs">
|
|
23
|
+
<Shield className="h-3.5 w-3.5 text-violet-400" />
|
|
24
|
+
<span>
|
|
25
|
+
Local runtime ·{' '}
|
|
26
|
+
<span className="text-foreground font-semibold">{template.name}</span>
|
|
27
|
+
</span>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
className="border-border text-muted-foreground hover:text-foreground inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold"
|
|
31
|
+
onClick={handleReset}
|
|
32
|
+
disabled={isResetting}
|
|
33
|
+
>
|
|
34
|
+
<RefreshCw className="h-3 w-3" />
|
|
35
|
+
{isResetting ? 'Resetting…' : 'Reset data'}
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Button,
|
|
6
|
+
ErrorState,
|
|
7
|
+
LoaderBlock,
|
|
8
|
+
} from '@contractspec/lib.design-system';
|
|
9
|
+
import { Card } from '@contractspec/lib.ui-kit-web/ui/card';
|
|
10
|
+
import { Badge } from '@contractspec/lib.ui-kit-web/ui/badge';
|
|
11
|
+
import type { PresentationTarget } from '@contractspec/lib.contracts';
|
|
12
|
+
import type { TransformEngine } from '@contractspec/lib.contracts';
|
|
13
|
+
import type { TemplateId } from './lib/types';
|
|
14
|
+
|
|
15
|
+
import { useTemplateRuntime } from './lib/runtime-context';
|
|
16
|
+
|
|
17
|
+
export interface MarkdownViewProps {
|
|
18
|
+
/** Optional override, otherwise comes from context */
|
|
19
|
+
templateId?: TemplateId;
|
|
20
|
+
presentationId?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface MarkdownOutput {
|
|
25
|
+
mimeType: string;
|
|
26
|
+
body: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* MarkdownView renders template presentations as markdown using TransformEngine.
|
|
31
|
+
* It allows switching between available presentations for the template.
|
|
32
|
+
*/
|
|
33
|
+
export function MarkdownView({
|
|
34
|
+
templateId: propTemplateId,
|
|
35
|
+
presentationId,
|
|
36
|
+
className,
|
|
37
|
+
}: MarkdownViewProps) {
|
|
38
|
+
const {
|
|
39
|
+
engine,
|
|
40
|
+
template,
|
|
41
|
+
templateId: contextTemplateId,
|
|
42
|
+
resolvePresentation,
|
|
43
|
+
fetchData,
|
|
44
|
+
} = useTemplateRuntime();
|
|
45
|
+
|
|
46
|
+
// Prefer prop if given, else context
|
|
47
|
+
const templateId = propTemplateId ?? contextTemplateId;
|
|
48
|
+
const presentations = (template?.presentations as string[]) ?? [];
|
|
49
|
+
|
|
50
|
+
const [selectedPresentation, setSelectedPresentation] = useState<string>('');
|
|
51
|
+
const [markdownContent, setMarkdownContent] = useState<string>('');
|
|
52
|
+
const [loading, setLoading] = useState(false);
|
|
53
|
+
const [error, setError] = useState<Error | null>(null);
|
|
54
|
+
|
|
55
|
+
// Initialize selected presentation
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (presentationId && presentations.includes(presentationId)) {
|
|
58
|
+
setSelectedPresentation(presentationId);
|
|
59
|
+
} else if (presentations.length > 0 && !selectedPresentation) {
|
|
60
|
+
setSelectedPresentation(presentations[0] ?? '');
|
|
61
|
+
}
|
|
62
|
+
}, [presentationId, presentations, selectedPresentation]);
|
|
63
|
+
|
|
64
|
+
// Render markdown when presentation changes
|
|
65
|
+
const renderMarkdown = useCallback(async () => {
|
|
66
|
+
if (!selectedPresentation || !engine) return;
|
|
67
|
+
|
|
68
|
+
setLoading(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
if (!resolvePresentation) {
|
|
73
|
+
throw new Error('resolvePresentation not available in runtime context');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const descriptor = resolvePresentation(selectedPresentation);
|
|
77
|
+
|
|
78
|
+
if (!descriptor) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Presentation descriptor not found: ${selectedPresentation}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fetch data for this presentation using the data fetcher from context
|
|
85
|
+
const dataResult = await fetchData(selectedPresentation);
|
|
86
|
+
|
|
87
|
+
// Render to markdown using the engine with data context
|
|
88
|
+
const result = await engine.render<MarkdownOutput>(
|
|
89
|
+
'markdown' as PresentationTarget,
|
|
90
|
+
descriptor as Parameters<TransformEngine['render']>[1],
|
|
91
|
+
{ data: dataResult.data } // Pass data in context for schema-driven rendering
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
setMarkdownContent(result.body);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
setError(
|
|
97
|
+
err instanceof Error ? err : new Error('Failed to render markdown')
|
|
98
|
+
);
|
|
99
|
+
} finally {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}
|
|
102
|
+
}, [
|
|
103
|
+
selectedPresentation,
|
|
104
|
+
templateId,
|
|
105
|
+
engine,
|
|
106
|
+
resolvePresentation,
|
|
107
|
+
fetchData,
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
renderMarkdown();
|
|
112
|
+
}, [renderMarkdown]);
|
|
113
|
+
|
|
114
|
+
if (!presentations.length) {
|
|
115
|
+
return (
|
|
116
|
+
<Card className={className}>
|
|
117
|
+
<div className="p-6 text-center">
|
|
118
|
+
<p className="text-muted-foreground">
|
|
119
|
+
No presentations available for this template.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
</Card>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Copy markdown to clipboard
|
|
127
|
+
const handleCopy = useCallback(() => {
|
|
128
|
+
if (markdownContent) {
|
|
129
|
+
navigator.clipboard.writeText(markdownContent);
|
|
130
|
+
}
|
|
131
|
+
}, [markdownContent]);
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className={className}>
|
|
135
|
+
{/* Presentation Selector */}
|
|
136
|
+
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
137
|
+
<span className="text-muted-foreground text-sm font-medium">
|
|
138
|
+
Presentation:
|
|
139
|
+
</span>
|
|
140
|
+
{presentations.map((name) => (
|
|
141
|
+
<Button
|
|
142
|
+
key={name}
|
|
143
|
+
variant={selectedPresentation === name ? 'default' : 'outline'}
|
|
144
|
+
size="sm"
|
|
145
|
+
onPress={() => setSelectedPresentation(name)}
|
|
146
|
+
>
|
|
147
|
+
{formatPresentationName(name)}
|
|
148
|
+
</Button>
|
|
149
|
+
))}
|
|
150
|
+
<div className="ml-auto flex items-center gap-2">
|
|
151
|
+
<Badge variant="secondary">LLM-friendly</Badge>
|
|
152
|
+
<Button
|
|
153
|
+
variant="outline"
|
|
154
|
+
size="sm"
|
|
155
|
+
onPress={handleCopy}
|
|
156
|
+
disabled={!markdownContent || loading}
|
|
157
|
+
>
|
|
158
|
+
Copy
|
|
159
|
+
</Button>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* Content Area */}
|
|
164
|
+
<Card className="overflow-hidden">
|
|
165
|
+
{loading && <LoaderBlock label="Rendering markdown..." />}
|
|
166
|
+
|
|
167
|
+
{error && (
|
|
168
|
+
<ErrorState
|
|
169
|
+
title="Render failed"
|
|
170
|
+
description={error.message}
|
|
171
|
+
onRetry={renderMarkdown}
|
|
172
|
+
retryLabel="Retry"
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{!loading && !error && markdownContent && (
|
|
177
|
+
<div className="p-6">
|
|
178
|
+
<MarkdownRenderer content={markdownContent} />
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{!loading && !error && !markdownContent && (
|
|
183
|
+
<div className="p-6 text-center">
|
|
184
|
+
<p className="text-muted-foreground">
|
|
185
|
+
Select a presentation to view its markdown output.
|
|
186
|
+
</p>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</Card>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Simple markdown renderer using pre-formatted display
|
|
196
|
+
* For production, consider using react-markdown or similar
|
|
197
|
+
*/
|
|
198
|
+
function MarkdownRenderer({ content }: { content: string }) {
|
|
199
|
+
const lines = content.split('\n');
|
|
200
|
+
const rendered: React.ReactNode[] = [];
|
|
201
|
+
let i = 0;
|
|
202
|
+
|
|
203
|
+
while (i < lines.length) {
|
|
204
|
+
const line = lines[i] ?? '';
|
|
205
|
+
|
|
206
|
+
// Check for table (starts with | and next line is separator)
|
|
207
|
+
if (line.startsWith('|') && lines[i + 1]?.match(/^\|[\s-|]+\|$/)) {
|
|
208
|
+
const tableLines: string[] = [line];
|
|
209
|
+
i++;
|
|
210
|
+
// Collect all table lines
|
|
211
|
+
while (i < lines.length && (lines[i]?.startsWith('|') ?? false)) {
|
|
212
|
+
tableLines.push(lines[i] ?? '');
|
|
213
|
+
i++;
|
|
214
|
+
}
|
|
215
|
+
rendered.push(renderTable(tableLines, rendered.length));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Headers
|
|
220
|
+
if (line.startsWith('# ')) {
|
|
221
|
+
rendered.push(
|
|
222
|
+
<h1 key={i} className="mb-4 text-2xl font-bold">
|
|
223
|
+
{line.slice(2)}
|
|
224
|
+
</h1>
|
|
225
|
+
);
|
|
226
|
+
} else if (line.startsWith('## ')) {
|
|
227
|
+
rendered.push(
|
|
228
|
+
<h2 key={i} className="mt-6 mb-3 text-xl font-semibold">
|
|
229
|
+
{line.slice(3)}
|
|
230
|
+
</h2>
|
|
231
|
+
);
|
|
232
|
+
} else if (line.startsWith('### ')) {
|
|
233
|
+
rendered.push(
|
|
234
|
+
<h3 key={i} className="mt-4 mb-2 text-lg font-medium">
|
|
235
|
+
{line.slice(4)}
|
|
236
|
+
</h3>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
// Blockquotes
|
|
240
|
+
else if (line.startsWith('> ')) {
|
|
241
|
+
rendered.push(
|
|
242
|
+
<blockquote
|
|
243
|
+
key={i}
|
|
244
|
+
className="text-muted-foreground my-2 border-l-4 border-violet-500/50 pl-4 italic"
|
|
245
|
+
>
|
|
246
|
+
{line.slice(2)}
|
|
247
|
+
</blockquote>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
// List items
|
|
251
|
+
else if (line.startsWith('- ')) {
|
|
252
|
+
rendered.push(
|
|
253
|
+
<li key={i} className="ml-4 list-disc">
|
|
254
|
+
{formatInlineMarkdown(line.slice(2))}
|
|
255
|
+
</li>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
// Bold text (lines starting with **)
|
|
259
|
+
else if (line.startsWith('**') && line.includes(':**')) {
|
|
260
|
+
const [label, ...rest] = line.split(':**');
|
|
261
|
+
rendered.push(
|
|
262
|
+
<p key={i} className="my-1">
|
|
263
|
+
<strong>{label?.slice(2)}:</strong>
|
|
264
|
+
{rest.join(':**')}
|
|
265
|
+
</p>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
// Italic text (lines starting with _)
|
|
269
|
+
else if (line.startsWith('_') && line.endsWith('_')) {
|
|
270
|
+
rendered.push(
|
|
271
|
+
<p key={i} className="text-muted-foreground my-1 italic">
|
|
272
|
+
{line.slice(1, -1)}
|
|
273
|
+
</p>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
// Empty lines
|
|
277
|
+
else if (!line.trim()) {
|
|
278
|
+
rendered.push(<div key={i} className="h-2" />);
|
|
279
|
+
}
|
|
280
|
+
// Regular text
|
|
281
|
+
else {
|
|
282
|
+
rendered.push(
|
|
283
|
+
<p key={i} className="my-1">
|
|
284
|
+
{formatInlineMarkdown(line)}
|
|
285
|
+
</p>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
i++;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div className="prose prose-sm dark:prose-invert max-w-none">
|
|
294
|
+
{rendered}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Render a markdown table
|
|
301
|
+
*/
|
|
302
|
+
function renderTable(lines: string[], keyPrefix: number): React.ReactNode {
|
|
303
|
+
if (lines.length < 2) return null;
|
|
304
|
+
|
|
305
|
+
const parseRow = (row: string) =>
|
|
306
|
+
row
|
|
307
|
+
.split('|')
|
|
308
|
+
.slice(1, -1)
|
|
309
|
+
.map((cell) => cell.trim());
|
|
310
|
+
|
|
311
|
+
const headers = parseRow(lines[0] ?? '');
|
|
312
|
+
// Skip separator line (index 1)
|
|
313
|
+
const dataRows = lines.slice(2).map(parseRow);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div key={`table-${keyPrefix}`} className="my-4 overflow-x-auto">
|
|
317
|
+
<table className="border-border min-w-full border-collapse border text-sm">
|
|
318
|
+
<thead>
|
|
319
|
+
<tr className="bg-muted/50">
|
|
320
|
+
{headers.map((header, idx) => (
|
|
321
|
+
<th
|
|
322
|
+
key={idx}
|
|
323
|
+
className="border-border border px-3 py-2 text-left font-semibold"
|
|
324
|
+
>
|
|
325
|
+
{header}
|
|
326
|
+
</th>
|
|
327
|
+
))}
|
|
328
|
+
</tr>
|
|
329
|
+
</thead>
|
|
330
|
+
<tbody>
|
|
331
|
+
{dataRows.map((row, rowIdx) => (
|
|
332
|
+
<tr key={rowIdx} className="hover:bg-muted/30">
|
|
333
|
+
{row.map((cell, cellIdx) => (
|
|
334
|
+
<td key={cellIdx} className="border-border border px-3 py-2">
|
|
335
|
+
{formatInlineMarkdown(cell)}
|
|
336
|
+
</td>
|
|
337
|
+
))}
|
|
338
|
+
</tr>
|
|
339
|
+
))}
|
|
340
|
+
</tbody>
|
|
341
|
+
</table>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Format inline markdown (bold, code)
|
|
348
|
+
*/
|
|
349
|
+
function formatInlineMarkdown(text: string): React.ReactNode {
|
|
350
|
+
// Handle **bold** text
|
|
351
|
+
const parts = text.split(/(\*\*[^*]+\*\*)/g);
|
|
352
|
+
return parts.map((part, i) => {
|
|
353
|
+
if (part.startsWith('**') && part.endsWith('**')) {
|
|
354
|
+
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
|
355
|
+
}
|
|
356
|
+
// Handle `code` text
|
|
357
|
+
if (part.includes('`')) {
|
|
358
|
+
const codeParts = part.split(/(`[^`]+`)/g);
|
|
359
|
+
return codeParts.map((cp, j) => {
|
|
360
|
+
if (cp.startsWith('`') && cp.endsWith('`')) {
|
|
361
|
+
return (
|
|
362
|
+
<code
|
|
363
|
+
key={`${i}-${j}`}
|
|
364
|
+
className="rounded bg-violet-500/10 px-1.5 py-0.5 font-mono text-sm"
|
|
365
|
+
>
|
|
366
|
+
{cp.slice(1, -1)}
|
|
367
|
+
</code>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return cp;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return part;
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Format presentation name for display
|
|
379
|
+
*/
|
|
380
|
+
function formatPresentationName(name: string): string {
|
|
381
|
+
// Extract the last part after the last dot
|
|
382
|
+
const parts = name.split('.');
|
|
383
|
+
const lastPart = parts[parts.length - 1] ?? name;
|
|
384
|
+
// Convert kebab-case to Title Case
|
|
385
|
+
return lastPart
|
|
386
|
+
.split('-')
|
|
387
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
388
|
+
.join(' ');
|
|
389
|
+
}
|