@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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +4 -7
  2. package/dist/components/CodeBlockExtension.d.ts +2 -0
  3. package/dist/components/CodePreview.d.ts +13 -0
  4. package/dist/components/MarkdownEditor.d.ts +10 -0
  5. package/dist/components/ServicesInspector.d.ts +49 -0
  6. package/dist/components/edit/CodeBlockView.d.ts +7 -0
  7. package/dist/components/edit/EditHistory.d.ts +10 -0
  8. package/dist/components/edit/EditModal.d.ts +20 -0
  9. package/dist/components/edit/FileTree.d.ts +8 -0
  10. package/dist/components/edit/MediaPreview.d.ts +6 -0
  11. package/dist/components/edit/SaveConfirmDialog.d.ts +9 -0
  12. package/dist/components/edit/api.d.ts +8 -0
  13. package/dist/components/edit/fileTypes.d.ts +14 -0
  14. package/dist/components/edit/index.d.ts +10 -0
  15. package/dist/components/edit/types.d.ts +41 -0
  16. package/dist/components/edit/useEditSession.d.ts +9 -0
  17. package/dist/components/index.d.ts +5 -0
  18. package/dist/index.d.ts +3 -331
  19. package/dist/index.js +812 -142
  20. package/dist/lib/code-extractor.d.ts +44 -0
  21. package/dist/lib/diff.d.ts +90 -0
  22. package/dist/lib/index.d.ts +4 -0
  23. package/dist/lib/utils.d.ts +2 -0
  24. package/dist/lib/vfs.d.ts +36 -0
  25. package/package.json +5 -4
  26. package/src/components/CodeBlockExtension.tsx +1 -1
  27. package/src/components/CodePreview.tsx +64 -4
  28. package/src/components/edit/CodeBlockView.tsx +188 -0
  29. package/src/components/edit/EditModal.tsx +172 -30
  30. package/src/components/edit/FileTree.tsx +67 -13
  31. package/src/components/edit/MediaPreview.tsx +124 -0
  32. package/src/components/edit/SaveConfirmDialog.tsx +60 -0
  33. package/src/components/edit/fileTypes.ts +125 -0
  34. package/src/components/edit/index.ts +4 -0
  35. package/src/components/edit/types.ts +3 -0
  36. package/src/components/edit/useEditSession.ts +56 -11
  37. package/src/index.ts +17 -0
  38. package/src/lib/diff.ts +2 -1
  39. package/src/lib/vfs.ts +28 -10
  40. 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(false);
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 handleClose = () => {
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(finalCode, editCount);
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
- <button
113
- onClick={() => setShowTree(!showTree)}
114
- 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'}`}
115
- title={showTree ? 'Single file' : 'File tree'}
116
- >
117
- {showTree ? <FileCode className="h-3 w-3" /> : <FolderTree className="h-3 w-3" />}
118
- </button>
119
- <button
120
- onClick={() => setShowPreview(!showPreview)}
121
- 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'}`}
122
- >
123
- {showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
124
- {showPreview ? 'Preview' : 'Code'}
125
- </button>
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={previewContainer}
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="p-4 bg-muted/10 h-full overflow-auto">
176
- <pre className="text-xs whitespace-pre-wrap break-words m-0">
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
- <button
109
- onClick={() => onSelect(node.path)}
110
- className={`flex items-center gap-1 w-full px-2 py-1 text-left text-sm hover:bg-muted/50 rounded ${
111
- isActive ? 'bg-primary/10 text-primary' : ''
112
- }`}
113
- style={{ paddingLeft: `${depth * 12 + 20}px` }}
136
+ <div
137
+ className="relative"
138
+ onMouseEnter={() => setIsHovered(true)}
139
+ onMouseLeave={() => setIsHovered(false)}
114
140
  >
115
- <File className="h-3 w-3 shrink-0 text-muted-foreground" />
116
- <span className="truncate">{node.name}</span>
117
- </button>
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
+ }