@aprovan/patchwork-editor 0.1.0 → 0.1.2-dev.03aaf5b
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.log +4 -7
- package/dist/components/CodeBlockExtension.d.ts +2 -0
- package/dist/components/CodePreview.d.ts +13 -0
- package/dist/components/MarkdownEditor.d.ts +10 -0
- package/dist/components/ServicesInspector.d.ts +49 -0
- package/dist/components/edit/CodeBlockView.d.ts +7 -0
- package/dist/components/edit/EditHistory.d.ts +10 -0
- package/dist/components/edit/EditModal.d.ts +20 -0
- package/dist/components/edit/FileTree.d.ts +8 -0
- package/dist/components/edit/MediaPreview.d.ts +6 -0
- package/dist/components/edit/SaveConfirmDialog.d.ts +9 -0
- package/dist/components/edit/api.d.ts +8 -0
- package/dist/components/edit/fileTypes.d.ts +14 -0
- package/dist/components/edit/index.d.ts +10 -0
- package/dist/components/edit/types.d.ts +41 -0
- package/dist/components/edit/useEditSession.d.ts +9 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/index.d.ts +3 -331
- package/dist/index.js +812 -142
- package/dist/lib/code-extractor.d.ts +44 -0
- package/dist/lib/diff.d.ts +90 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/vfs.d.ts +36 -0
- package/package.json +5 -4
- package/src/components/CodeBlockExtension.tsx +1 -1
- package/src/components/CodePreview.tsx +64 -4
- package/src/components/edit/CodeBlockView.tsx +188 -0
- package/src/components/edit/EditModal.tsx +172 -30
- package/src/components/edit/FileTree.tsx +67 -13
- package/src/components/edit/MediaPreview.tsx +124 -0
- package/src/components/edit/SaveConfirmDialog.tsx +60 -0
- package/src/components/edit/fileTypes.ts +125 -0
- package/src/components/edit/index.ts +4 -0
- package/src/components/edit/types.ts +3 -0
- package/src/components/edit/useEditSession.ts +56 -11
- package/src/index.ts +17 -0
- package/src/lib/diff.ts +2 -1
- package/src/lib/vfs.ts +28 -10
- package/tsup.config.ts +10 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useMemo, type ReactNode } from 'react';
|
|
1
|
+
import { useState, useCallback, useMemo, useRef, type ReactNode } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Code,
|
|
4
4
|
Eye,
|
|
@@ -10,13 +10,19 @@ import {
|
|
|
10
10
|
Send,
|
|
11
11
|
FolderTree,
|
|
12
12
|
FileCode,
|
|
13
|
+
Save,
|
|
13
14
|
} from 'lucide-react';
|
|
14
15
|
import { MarkdownEditor } from '../MarkdownEditor';
|
|
15
16
|
import { EditHistory } from './EditHistory';
|
|
16
17
|
import { FileTree } from './FileTree';
|
|
18
|
+
import { SaveConfirmDialog } from './SaveConfirmDialog';
|
|
19
|
+
import { CodeBlockView } from './CodeBlockView';
|
|
20
|
+
import { MediaPreview } from './MediaPreview';
|
|
17
21
|
import { useEditSession, type UseEditSessionOptions } from './useEditSession';
|
|
18
22
|
import { getActiveContent, getFiles } from './types';
|
|
23
|
+
import { getFileType, isCompilable, getMimeType } from './fileTypes';
|
|
19
24
|
import { Bobbin, serializeChangesToYAML, type Change } from '@aprovan/bobbin';
|
|
25
|
+
import type { VirtualProject } from '@aprovan/patchwork-compiler';
|
|
20
26
|
|
|
21
27
|
// Simple hash for React key to force re-render on code changes
|
|
22
28
|
function hashCode(str: string): number {
|
|
@@ -27,9 +33,17 @@ function hashCode(str: string): number {
|
|
|
27
33
|
return hash;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
|
|
30
37
|
export interface EditModalProps extends UseEditSessionOptions {
|
|
31
38
|
isOpen: boolean;
|
|
39
|
+
initialState?: Partial<{
|
|
40
|
+
showTree: boolean;
|
|
41
|
+
showPreview: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
hideFileTree?: boolean;
|
|
32
44
|
onClose: (finalCode: string, editCount: number) => void;
|
|
45
|
+
onSave?: (code: string) => Promise<void>;
|
|
46
|
+
onSaveProject?: (project: VirtualProject) => Promise<void>;
|
|
33
47
|
renderPreview: (code: string) => ReactNode;
|
|
34
48
|
renderLoading?: () => ReactNode;
|
|
35
49
|
renderError?: (error: string) => ReactNode;
|
|
@@ -40,24 +54,41 @@ export interface EditModalProps extends UseEditSessionOptions {
|
|
|
40
54
|
export function EditModal({
|
|
41
55
|
isOpen,
|
|
42
56
|
onClose,
|
|
57
|
+
onSave,
|
|
58
|
+
onSaveProject,
|
|
43
59
|
renderPreview,
|
|
44
60
|
renderLoading,
|
|
45
61
|
renderError,
|
|
46
62
|
previewError,
|
|
47
63
|
previewLoading,
|
|
64
|
+
initialState = {},
|
|
65
|
+
hideFileTree = false,
|
|
48
66
|
...sessionOptions
|
|
49
67
|
}: EditModalProps) {
|
|
50
|
-
const [showPreview, setShowPreview] = useState(true);
|
|
51
|
-
const [showTree, setShowTree] = useState(
|
|
68
|
+
const [showPreview, setShowPreview] = useState(initialState?.showPreview ?? true);
|
|
69
|
+
const [showTree, setShowTree] = useState(
|
|
70
|
+
hideFileTree ? false : (initialState?.showTree ?? false)
|
|
71
|
+
);
|
|
52
72
|
const [editInput, setEditInput] = useState('');
|
|
53
73
|
const [bobbinChanges, setBobbinChanges] = useState<Change[]>([]);
|
|
54
74
|
const [previewContainer, setPreviewContainer] = useState<HTMLDivElement | null>(null);
|
|
75
|
+
const [pillContainer, setPillContainer] = useState<HTMLDivElement | null>(null);
|
|
76
|
+
const [showConfirm, setShowConfirm] = useState(false);
|
|
77
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
78
|
+
const [saveError, setSaveError] = useState<string | null>(null);
|
|
79
|
+
const [pendingClose, setPendingClose] = useState<{ code: string; count: number } | null>(null);
|
|
80
|
+
const currentCodeRef = useRef<string>('');
|
|
55
81
|
|
|
56
82
|
const session = useEditSession(sessionOptions);
|
|
57
83
|
const code = getActiveContent(session);
|
|
84
|
+
currentCodeRef.current = code;
|
|
58
85
|
const files = useMemo(() => getFiles(session.project), [session.project]);
|
|
59
86
|
const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? '');
|
|
60
87
|
|
|
88
|
+
const fileType = useMemo(() => getFileType(session.activeFile), [session.activeFile]);
|
|
89
|
+
const isCompilableFile = isCompilable(session.activeFile);
|
|
90
|
+
const showPreviewToggle = isCompilableFile;
|
|
91
|
+
|
|
61
92
|
const handleBobbinChanges = useCallback((changes: Change[]) => {
|
|
62
93
|
setBobbinChanges(changes);
|
|
63
94
|
}, []);
|
|
@@ -78,17 +109,81 @@ export function EditModal({
|
|
|
78
109
|
setBobbinChanges([]);
|
|
79
110
|
};
|
|
80
111
|
|
|
81
|
-
const
|
|
112
|
+
const hasSaveHandler = onSave || onSaveProject;
|
|
113
|
+
|
|
114
|
+
const handleClose = useCallback(() => {
|
|
82
115
|
const editCount = session.history.length;
|
|
83
116
|
const finalCode = code;
|
|
117
|
+
const hasUnsavedChanges = editCount > 0 && finalCode !== (session.originalProject.files.get(session.activeFile)?.content ?? '');
|
|
118
|
+
|
|
119
|
+
if (hasUnsavedChanges && hasSaveHandler) {
|
|
120
|
+
setPendingClose({ code: finalCode, count: editCount });
|
|
121
|
+
setShowConfirm(true);
|
|
122
|
+
} else {
|
|
123
|
+
setEditInput('');
|
|
124
|
+
session.clearError();
|
|
125
|
+
onClose(finalCode, editCount);
|
|
126
|
+
}
|
|
127
|
+
}, [code, session, hasSaveHandler, onClose]);
|
|
128
|
+
|
|
129
|
+
const handleSaveAndClose = useCallback(async () => {
|
|
130
|
+
if (!pendingClose || !hasSaveHandler) return;
|
|
131
|
+
setIsSaving(true);
|
|
132
|
+
setSaveError(null);
|
|
133
|
+
try {
|
|
134
|
+
if (onSaveProject) {
|
|
135
|
+
await onSaveProject(session.project);
|
|
136
|
+
} else if (onSave) {
|
|
137
|
+
await onSave(pendingClose.code);
|
|
138
|
+
}
|
|
139
|
+
setShowConfirm(false);
|
|
140
|
+
setEditInput('');
|
|
141
|
+
session.clearError();
|
|
142
|
+
onClose(pendingClose.code, pendingClose.count);
|
|
143
|
+
setPendingClose(null);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
setSaveError(e instanceof Error ? e.message : 'Save failed');
|
|
146
|
+
} finally {
|
|
147
|
+
setIsSaving(false);
|
|
148
|
+
}
|
|
149
|
+
}, [pendingClose, onSave, onSaveProject, session, onClose]);
|
|
150
|
+
|
|
151
|
+
const handleDiscard = useCallback(() => {
|
|
152
|
+
if (!pendingClose) return;
|
|
153
|
+
setShowConfirm(false);
|
|
84
154
|
setEditInput('');
|
|
85
155
|
session.clearError();
|
|
86
|
-
onClose(
|
|
87
|
-
|
|
156
|
+
onClose(pendingClose.code, pendingClose.count);
|
|
157
|
+
setPendingClose(null);
|
|
158
|
+
}, [pendingClose, session, onClose]);
|
|
159
|
+
|
|
160
|
+
const handleCancelClose = useCallback(() => {
|
|
161
|
+
setShowConfirm(false);
|
|
162
|
+
setPendingClose(null);
|
|
163
|
+
setSaveError(null);
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const handleDirectSave = useCallback(async () => {
|
|
167
|
+
if (!hasSaveHandler) return;
|
|
168
|
+
setIsSaving(true);
|
|
169
|
+
setSaveError(null);
|
|
170
|
+
try {
|
|
171
|
+
if (onSaveProject) {
|
|
172
|
+
await onSaveProject(session.project);
|
|
173
|
+
} else if (onSave && currentCodeRef.current) {
|
|
174
|
+
await onSave(currentCodeRef.current);
|
|
175
|
+
}
|
|
176
|
+
} catch (e) {
|
|
177
|
+
setSaveError(e instanceof Error ? e.message : 'Save failed');
|
|
178
|
+
} finally {
|
|
179
|
+
setIsSaving(false);
|
|
180
|
+
}
|
|
181
|
+
}, [onSave, onSaveProject, session.project]);
|
|
88
182
|
|
|
89
183
|
if (!isOpen) return null;
|
|
90
184
|
|
|
91
185
|
return (
|
|
186
|
+
<>
|
|
92
187
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8">
|
|
93
188
|
<div className="flex flex-col bg-background rounded-lg shadow-xl w-full h-full max-w-6xl max-h-[90vh] overflow-hidden">
|
|
94
189
|
<div className="flex items-center gap-2 px-4 py-3 bg-background border-b-2">
|
|
@@ -109,20 +204,39 @@ export function EditModal({
|
|
|
109
204
|
<RotateCcw className="h-3 w-3" />
|
|
110
205
|
</button>
|
|
111
206
|
)}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
207
|
+
{!hideFileTree && (
|
|
208
|
+
<button
|
|
209
|
+
onClick={() => setShowTree(!showTree)}
|
|
210
|
+
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${showTree ? 'bg-primary text-primary-foreground' : 'hover:bg-primary/20 text-primary'}`}
|
|
211
|
+
title={showTree ? 'Single file' : 'File tree'}
|
|
212
|
+
>
|
|
213
|
+
{showTree ? <FileCode className="h-3 w-3" /> : <FolderTree className="h-3 w-3" />}
|
|
214
|
+
</button>
|
|
215
|
+
)}
|
|
216
|
+
{showPreviewToggle && (
|
|
217
|
+
<button
|
|
218
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
219
|
+
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${showPreview ? 'bg-primary text-primary-foreground' : 'hover:bg-primary/20 text-primary'}`}
|
|
220
|
+
>
|
|
221
|
+
{showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
|
|
222
|
+
{showPreview ? 'Preview' : 'Code'}
|
|
223
|
+
</button>
|
|
224
|
+
)}
|
|
225
|
+
{hasSaveHandler && (
|
|
226
|
+
<button
|
|
227
|
+
onClick={handleDirectSave}
|
|
228
|
+
disabled={isSaving}
|
|
229
|
+
className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary disabled:opacity-50"
|
|
230
|
+
title="Save changes"
|
|
231
|
+
>
|
|
232
|
+
{isSaving ? (
|
|
233
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
234
|
+
) : (
|
|
235
|
+
<Save className="h-3 w-3" />
|
|
236
|
+
)}
|
|
237
|
+
Save
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
126
240
|
<button
|
|
127
241
|
onClick={handleClose}
|
|
128
242
|
className="px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
@@ -135,15 +249,16 @@ export function EditModal({
|
|
|
135
249
|
</div>
|
|
136
250
|
|
|
137
251
|
<div className="flex-1 min-h-0 border-b-2 overflow-hidden flex">
|
|
138
|
-
{showTree && (
|
|
252
|
+
{!hideFileTree && showTree && (
|
|
139
253
|
<FileTree
|
|
140
254
|
files={files}
|
|
141
255
|
activeFile={session.activeFile}
|
|
142
256
|
onSelectFile={session.setActiveFile}
|
|
257
|
+
onReplaceFile={session.replaceFile}
|
|
143
258
|
/>
|
|
144
259
|
)}
|
|
145
|
-
<div className="flex-1 overflow-auto">
|
|
146
|
-
{showPreview ? (
|
|
260
|
+
<div className="flex-1 overflow-auto" ref={setPillContainer}>
|
|
261
|
+
{fileType.category === 'compilable' && showPreview ? (
|
|
147
262
|
<div className="bg-white h-full relative" ref={setPreviewContainer}>
|
|
148
263
|
{previewError && renderError ? (
|
|
149
264
|
renderError(previewError)
|
|
@@ -164,18 +279,36 @@ export function EditModal({
|
|
|
164
279
|
)}
|
|
165
280
|
{!renderLoading && !renderError && !previewLoading && <Bobbin
|
|
166
281
|
container={previewContainer}
|
|
167
|
-
pillContainer={
|
|
282
|
+
pillContainer={pillContainer}
|
|
168
283
|
defaultActive={false}
|
|
169
284
|
showInspector
|
|
170
285
|
onChanges={handleBobbinChanges}
|
|
171
286
|
exclude={['.bobbin-pill', '[data-bobbin]']}
|
|
172
287
|
/>}
|
|
173
288
|
</div>
|
|
289
|
+
) : fileType.category === 'compilable' && !showPreview ? (
|
|
290
|
+
<CodeBlockView
|
|
291
|
+
content={code}
|
|
292
|
+
language={fileType.language}
|
|
293
|
+
editable
|
|
294
|
+
onChange={session.updateActiveFile}
|
|
295
|
+
/>
|
|
296
|
+
) : fileType.category === 'text' ? (
|
|
297
|
+
<CodeBlockView
|
|
298
|
+
content={code}
|
|
299
|
+
language={fileType.language}
|
|
300
|
+
editable
|
|
301
|
+
onChange={session.updateActiveFile}
|
|
302
|
+
/>
|
|
303
|
+
) : fileType.category === 'media' ? (
|
|
304
|
+
<MediaPreview
|
|
305
|
+
content={code}
|
|
306
|
+
mimeType={getMimeType(session.activeFile)}
|
|
307
|
+
fileName={session.activeFile.split('/').pop() ?? session.activeFile}
|
|
308
|
+
/>
|
|
174
309
|
) : (
|
|
175
|
-
<div className="
|
|
176
|
-
<
|
|
177
|
-
<code>{code}</code>
|
|
178
|
-
</pre>
|
|
310
|
+
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
311
|
+
<p className="text-sm">Preview not available for this file type</p>
|
|
179
312
|
</div>
|
|
180
313
|
)}
|
|
181
314
|
</div>
|
|
@@ -189,10 +322,10 @@ export function EditModal({
|
|
|
189
322
|
className="h-48"
|
|
190
323
|
/>
|
|
191
324
|
|
|
192
|
-
{session.error && (
|
|
325
|
+
{(session.error || saveError) && (
|
|
193
326
|
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm flex items-center gap-2 border-t-2 border-destructive">
|
|
194
327
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
195
|
-
{session.error}
|
|
328
|
+
{session.error || saveError}
|
|
196
329
|
</div>
|
|
197
330
|
)}
|
|
198
331
|
|
|
@@ -232,5 +365,14 @@ export function EditModal({
|
|
|
232
365
|
</div>
|
|
233
366
|
</div>
|
|
234
367
|
</div>
|
|
368
|
+
<SaveConfirmDialog
|
|
369
|
+
isOpen={showConfirm}
|
|
370
|
+
isSaving={isSaving}
|
|
371
|
+
error={saveError}
|
|
372
|
+
onSave={handleSaveAndClose}
|
|
373
|
+
onDiscard={handleDiscard}
|
|
374
|
+
onCancel={handleCancelClose}
|
|
375
|
+
/>
|
|
376
|
+
</>
|
|
235
377
|
);
|
|
236
378
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
2
|
-
import { ChevronRight, ChevronDown, File, Folder } from 'lucide-react';
|
|
1
|
+
import { useMemo, useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { ChevronRight, ChevronDown, File, Folder, Upload } from 'lucide-react';
|
|
3
3
|
import type { VirtualFile } from '@aprovan/patchwork-compiler';
|
|
4
|
+
import { isMediaFile } from './fileTypes';
|
|
4
5
|
|
|
5
6
|
interface TreeNode {
|
|
6
7
|
name: string;
|
|
@@ -47,11 +48,34 @@ interface TreeNodeComponentProps {
|
|
|
47
48
|
node: TreeNode;
|
|
48
49
|
activeFile: string;
|
|
49
50
|
onSelect: (path: string) => void;
|
|
51
|
+
onReplaceFile?: (path: string, content: string, encoding: 'utf8' | 'base64') => void;
|
|
50
52
|
depth?: number;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }: TreeNodeComponentProps) {
|
|
55
|
+
function TreeNodeComponent({ node, activeFile, onSelect, onReplaceFile, depth = 0 }: TreeNodeComponentProps) {
|
|
54
56
|
const [expanded, setExpanded] = useState(true);
|
|
57
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
58
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
59
|
+
|
|
60
|
+
const handleUploadClick = useCallback((e: React.MouseEvent) => {
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
fileInputRef.current?.click();
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
66
|
+
const file = e.target.files?.[0];
|
|
67
|
+
if (!file || !onReplaceFile) return;
|
|
68
|
+
|
|
69
|
+
const reader = new FileReader();
|
|
70
|
+
reader.onload = () => {
|
|
71
|
+
const result = reader.result as string;
|
|
72
|
+
const base64 = result.split(',')[1] ?? '';
|
|
73
|
+
onReplaceFile(node.path, base64, 'base64');
|
|
74
|
+
};
|
|
75
|
+
reader.readAsDataURL(file);
|
|
76
|
+
|
|
77
|
+
e.target.value = '';
|
|
78
|
+
}, [node.path, onReplaceFile]);
|
|
55
79
|
|
|
56
80
|
if (!node.name) {
|
|
57
81
|
return (
|
|
@@ -62,6 +86,7 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }: TreeNodeCo
|
|
|
62
86
|
node={child}
|
|
63
87
|
activeFile={activeFile}
|
|
64
88
|
onSelect={onSelect}
|
|
89
|
+
onReplaceFile={onReplaceFile}
|
|
65
90
|
depth={depth}
|
|
66
91
|
/>
|
|
67
92
|
))}
|
|
@@ -70,6 +95,8 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }: TreeNodeCo
|
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
const isActive = node.path === activeFile;
|
|
98
|
+
const isMedia = !node.isDir && isMediaFile(node.path);
|
|
99
|
+
const showUpload = isMedia && isHovered && onReplaceFile;
|
|
73
100
|
|
|
74
101
|
if (node.isDir) {
|
|
75
102
|
return (
|
|
@@ -95,6 +122,7 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }: TreeNodeCo
|
|
|
95
122
|
node={child}
|
|
96
123
|
activeFile={activeFile}
|
|
97
124
|
onSelect={onSelect}
|
|
125
|
+
onReplaceFile={onReplaceFile}
|
|
98
126
|
depth={depth + 1}
|
|
99
127
|
/>
|
|
100
128
|
))}
|
|
@@ -105,16 +133,40 @@ function TreeNodeComponent({ node, activeFile, onSelect, depth = 0 }: TreeNodeCo
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
return (
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}`}
|
|
113
|
-
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
|
136
|
+
<div
|
|
137
|
+
className="relative"
|
|
138
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
139
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
114
140
|
>
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
<button
|
|
142
|
+
onClick={() => onSelect(node.path)}
|
|
143
|
+
className={`flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${
|
|
144
|
+
isActive ? 'bg-primary/10 text-primary' : ''
|
|
145
|
+
}`}
|
|
146
|
+
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
|
147
|
+
>
|
|
148
|
+
<File className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
149
|
+
<span className="truncate flex-1">{node.name}</span>
|
|
150
|
+
{showUpload && (
|
|
151
|
+
<span
|
|
152
|
+
onClick={handleUploadClick}
|
|
153
|
+
className="p-1 hover:bg-primary/20 rounded cursor-pointer"
|
|
154
|
+
title="Replace file"
|
|
155
|
+
>
|
|
156
|
+
<Upload className="h-3 w-3 text-primary" />
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
</button>
|
|
160
|
+
{isMedia && (
|
|
161
|
+
<input
|
|
162
|
+
ref={fileInputRef}
|
|
163
|
+
type="file"
|
|
164
|
+
className="hidden"
|
|
165
|
+
accept="image/*,video/*"
|
|
166
|
+
onChange={handleFileChange}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
118
170
|
);
|
|
119
171
|
}
|
|
120
172
|
|
|
@@ -122,9 +174,10 @@ export interface FileTreeProps {
|
|
|
122
174
|
files: VirtualFile[];
|
|
123
175
|
activeFile: string;
|
|
124
176
|
onSelectFile: (path: string) => void;
|
|
177
|
+
onReplaceFile?: (path: string, content: string, encoding: 'utf8' | 'base64') => void;
|
|
125
178
|
}
|
|
126
179
|
|
|
127
|
-
export function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
|
180
|
+
export function FileTree({ files, activeFile, onSelectFile, onReplaceFile }: FileTreeProps) {
|
|
128
181
|
const tree = useMemo(() => buildTree(files), [files]);
|
|
129
182
|
|
|
130
183
|
return (
|
|
@@ -137,6 +190,7 @@ export function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
|
|
|
137
190
|
node={tree}
|
|
138
191
|
activeFile={activeFile}
|
|
139
192
|
onSelect={onSelectFile}
|
|
193
|
+
onReplaceFile={onReplaceFile}
|
|
140
194
|
/>
|
|
141
195
|
</div>
|
|
142
196
|
</div>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { FileImage, FileVideo, AlertCircle } from 'lucide-react';
|
|
3
|
+
import { isImageFile, isVideoFile } from './fileTypes';
|
|
4
|
+
|
|
5
|
+
export interface MediaPreviewProps {
|
|
6
|
+
content: string;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
fileName: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatFileSize(bytes: number): string {
|
|
12
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
13
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
14
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isUrl(content: string): boolean {
|
|
18
|
+
// Check if content is a URL (absolute or relative path)
|
|
19
|
+
return content.startsWith('/') ||
|
|
20
|
+
content.startsWith('http://') ||
|
|
21
|
+
content.startsWith('https://') ||
|
|
22
|
+
content.startsWith('./') ||
|
|
23
|
+
content.startsWith('../');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getDataUrl(content: string, mimeType: string): string {
|
|
27
|
+
// If already a data URL, return as-is
|
|
28
|
+
if (content.startsWith('data:')) {
|
|
29
|
+
return content;
|
|
30
|
+
}
|
|
31
|
+
// If content is a URL path, return it directly (browser can fetch it)
|
|
32
|
+
if (isUrl(content)) {
|
|
33
|
+
return content;
|
|
34
|
+
}
|
|
35
|
+
// Otherwise, treat as raw base64 data and construct a data URL
|
|
36
|
+
return `data:${mimeType};base64,${content}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function MediaPreview({ content, mimeType, fileName }: MediaPreviewProps) {
|
|
40
|
+
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
|
|
43
|
+
const dataUrl = getDataUrl(content, mimeType);
|
|
44
|
+
const isImage = isImageFile(fileName);
|
|
45
|
+
const isVideo = isVideoFile(fileName);
|
|
46
|
+
// For URLs, we can't estimate file size from the string
|
|
47
|
+
const isUrlContent = isUrl(content);
|
|
48
|
+
const estimatedBytes = isUrlContent
|
|
49
|
+
? null
|
|
50
|
+
: content.startsWith('data:')
|
|
51
|
+
? Math.floor((content.split(',')[1]?.length ?? 0) * 0.75)
|
|
52
|
+
: Math.floor(content.length * 0.75);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setDimensions(null);
|
|
56
|
+
setError(null);
|
|
57
|
+
|
|
58
|
+
if (isImage) {
|
|
59
|
+
const img = new Image();
|
|
60
|
+
img.onload = () => {
|
|
61
|
+
setDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
|
62
|
+
};
|
|
63
|
+
img.onerror = () => {
|
|
64
|
+
setError('Failed to load image');
|
|
65
|
+
};
|
|
66
|
+
img.src = dataUrl;
|
|
67
|
+
}
|
|
68
|
+
}, [dataUrl, isImage]);
|
|
69
|
+
|
|
70
|
+
if (error) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
|
|
73
|
+
<AlertCircle className="h-12 w-12 mb-4 text-destructive" />
|
|
74
|
+
<p className="text-sm">{error}</p>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="flex flex-col items-center justify-center h-full p-8 bg-muted/20">
|
|
81
|
+
<div className="flex-1 flex items-center justify-center w-full max-h-[60vh] overflow-hidden">
|
|
82
|
+
{isImage && (
|
|
83
|
+
<img
|
|
84
|
+
src={dataUrl}
|
|
85
|
+
alt={fileName}
|
|
86
|
+
className="max-w-full max-h-full object-contain rounded shadow-sm"
|
|
87
|
+
style={{ maxHeight: 'calc(60vh - 2rem)' }}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
{isVideo && (
|
|
91
|
+
<video
|
|
92
|
+
src={dataUrl}
|
|
93
|
+
controls
|
|
94
|
+
className="max-w-full max-h-full rounded shadow-sm"
|
|
95
|
+
style={{ maxHeight: 'calc(60vh - 2rem)' }}
|
|
96
|
+
>
|
|
97
|
+
Your browser does not support video playback.
|
|
98
|
+
</video>
|
|
99
|
+
)}
|
|
100
|
+
{!isImage && !isVideo && (
|
|
101
|
+
<div className="flex flex-col items-center text-muted-foreground">
|
|
102
|
+
<FileImage className="h-16 w-16 mb-4" />
|
|
103
|
+
<p className="text-sm">Preview not available for this file type</p>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div className="mt-6 text-center text-sm text-muted-foreground space-y-1">
|
|
109
|
+
<div className="flex items-center justify-center gap-2">
|
|
110
|
+
{isImage && <FileImage className="h-4 w-4" />}
|
|
111
|
+
{isVideo && <FileVideo className="h-4 w-4" />}
|
|
112
|
+
<span className="font-medium">{fileName}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="text-xs space-x-3">
|
|
115
|
+
{dimensions && (
|
|
116
|
+
<span>{dimensions.width} × {dimensions.height} px</span>
|
|
117
|
+
)}
|
|
118
|
+
{estimatedBytes !== null && <span>{formatFileSize(estimatedBytes)}</span>}
|
|
119
|
+
<span className="text-muted-foreground/60">{mimeType}</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface SaveConfirmDialogProps {
|
|
2
|
+
isOpen: boolean;
|
|
3
|
+
isSaving: boolean;
|
|
4
|
+
error: string | null;
|
|
5
|
+
onSave: () => void;
|
|
6
|
+
onDiscard: () => void;
|
|
7
|
+
onCancel: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SaveConfirmDialog({
|
|
11
|
+
isOpen,
|
|
12
|
+
isSaving,
|
|
13
|
+
error,
|
|
14
|
+
onSave,
|
|
15
|
+
onDiscard,
|
|
16
|
+
onCancel,
|
|
17
|
+
}: SaveConfirmDialogProps) {
|
|
18
|
+
if (!isOpen) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80">
|
|
22
|
+
<div className="bg-background border rounded-lg shadow-lg w-full max-w-md">
|
|
23
|
+
<div className="p-6">
|
|
24
|
+
<h2 className="text-lg font-semibold leading-none tracking-tight">
|
|
25
|
+
Unsaved Changes
|
|
26
|
+
</h2>
|
|
27
|
+
<p className="text-sm text-muted-foreground mt-2">
|
|
28
|
+
You have unsaved changes. Would you like to save them before closing?
|
|
29
|
+
</p>
|
|
30
|
+
{error && (
|
|
31
|
+
<p className="text-sm text-destructive mt-3">Save failed: {error}</p>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
<div className="flex justify-end gap-2 p-6 pt-0">
|
|
35
|
+
<button
|
|
36
|
+
onClick={onCancel}
|
|
37
|
+
disabled={isSaving}
|
|
38
|
+
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 border border-input bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
|
39
|
+
>
|
|
40
|
+
Cancel
|
|
41
|
+
</button>
|
|
42
|
+
<button
|
|
43
|
+
onClick={onDiscard}
|
|
44
|
+
disabled={isSaving}
|
|
45
|
+
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 border border-input bg-background text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
|
46
|
+
>
|
|
47
|
+
Discard
|
|
48
|
+
</button>
|
|
49
|
+
<button
|
|
50
|
+
onClick={onSave}
|
|
51
|
+
disabled={isSaving}
|
|
52
|
+
className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
53
|
+
>
|
|
54
|
+
{isSaving ? 'Saving...' : 'Save'}
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|