@aprovan/patchwork-editor 0.1.0 → 0.1.1-dev.6bd527d

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 +707 -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 +4 -4
  26. package/src/components/CodeBlockExtension.tsx +1 -1
  27. package/src/components/CodePreview.tsx +64 -4
  28. package/src/components/edit/CodeBlockView.tsx +72 -0
  29. package/src/components/edit/EditModal.tsx +169 -28
  30. package/src/components/edit/FileTree.tsx +67 -13
  31. package/src/components/edit/MediaPreview.tsx +106 -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,40 @@ 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 [showConfirm, setShowConfirm] = useState(false);
76
+ const [isSaving, setIsSaving] = useState(false);
77
+ const [saveError, setSaveError] = useState<string | null>(null);
78
+ const [pendingClose, setPendingClose] = useState<{ code: string; count: number } | null>(null);
79
+ const currentCodeRef = useRef<string>('');
55
80
 
56
81
  const session = useEditSession(sessionOptions);
57
82
  const code = getActiveContent(session);
83
+ currentCodeRef.current = code;
58
84
  const files = useMemo(() => getFiles(session.project), [session.project]);
59
85
  const hasChanges = code !== (session.originalProject.files.get(session.activeFile)?.content ?? '');
60
86
 
87
+ const fileType = useMemo(() => getFileType(session.activeFile), [session.activeFile]);
88
+ const isCompilableFile = isCompilable(session.activeFile);
89
+ const showPreviewToggle = isCompilableFile;
90
+
61
91
  const handleBobbinChanges = useCallback((changes: Change[]) => {
62
92
  setBobbinChanges(changes);
63
93
  }, []);
@@ -78,17 +108,81 @@ export function EditModal({
78
108
  setBobbinChanges([]);
79
109
  };
80
110
 
81
- const handleClose = () => {
111
+ const hasSaveHandler = onSave || onSaveProject;
112
+
113
+ const handleClose = useCallback(() => {
82
114
  const editCount = session.history.length;
83
115
  const finalCode = code;
116
+ const hasUnsavedChanges = editCount > 0 && finalCode !== (session.originalProject.files.get(session.activeFile)?.content ?? '');
117
+
118
+ if (hasUnsavedChanges && hasSaveHandler) {
119
+ setPendingClose({ code: finalCode, count: editCount });
120
+ setShowConfirm(true);
121
+ } else {
122
+ setEditInput('');
123
+ session.clearError();
124
+ onClose(finalCode, editCount);
125
+ }
126
+ }, [code, session, hasSaveHandler, onClose]);
127
+
128
+ const handleSaveAndClose = useCallback(async () => {
129
+ if (!pendingClose || !hasSaveHandler) return;
130
+ setIsSaving(true);
131
+ setSaveError(null);
132
+ try {
133
+ if (onSaveProject) {
134
+ await onSaveProject(session.project);
135
+ } else if (onSave) {
136
+ await onSave(pendingClose.code);
137
+ }
138
+ setShowConfirm(false);
139
+ setEditInput('');
140
+ session.clearError();
141
+ onClose(pendingClose.code, pendingClose.count);
142
+ setPendingClose(null);
143
+ } catch (e) {
144
+ setSaveError(e instanceof Error ? e.message : 'Save failed');
145
+ } finally {
146
+ setIsSaving(false);
147
+ }
148
+ }, [pendingClose, onSave, onSaveProject, session, onClose]);
149
+
150
+ const handleDiscard = useCallback(() => {
151
+ if (!pendingClose) return;
152
+ setShowConfirm(false);
84
153
  setEditInput('');
85
154
  session.clearError();
86
- onClose(finalCode, editCount);
87
- };
155
+ onClose(pendingClose.code, pendingClose.count);
156
+ setPendingClose(null);
157
+ }, [pendingClose, session, onClose]);
158
+
159
+ const handleCancelClose = useCallback(() => {
160
+ setShowConfirm(false);
161
+ setPendingClose(null);
162
+ setSaveError(null);
163
+ }, []);
164
+
165
+ const handleDirectSave = useCallback(async () => {
166
+ if (!hasSaveHandler) return;
167
+ setIsSaving(true);
168
+ setSaveError(null);
169
+ try {
170
+ if (onSaveProject) {
171
+ await onSaveProject(session.project);
172
+ } else if (onSave && currentCodeRef.current) {
173
+ await onSave(currentCodeRef.current);
174
+ }
175
+ } catch (e) {
176
+ setSaveError(e instanceof Error ? e.message : 'Save failed');
177
+ } finally {
178
+ setIsSaving(false);
179
+ }
180
+ }, [onSave, onSaveProject, session.project]);
88
181
 
89
182
  if (!isOpen) return null;
90
183
 
91
184
  return (
185
+ <>
92
186
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8">
93
187
  <div className="flex flex-col bg-background rounded-lg shadow-xl w-full h-full max-w-6xl max-h-[90vh] overflow-hidden">
94
188
  <div className="flex items-center gap-2 px-4 py-3 bg-background border-b-2">
@@ -109,20 +203,39 @@ export function EditModal({
109
203
  <RotateCcw className="h-3 w-3" />
110
204
  </button>
111
205
  )}
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>
206
+ {!hideFileTree && (
207
+ <button
208
+ onClick={() => setShowTree(!showTree)}
209
+ 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'}`}
210
+ title={showTree ? 'Single file' : 'File tree'}
211
+ >
212
+ {showTree ? <FileCode className="h-3 w-3" /> : <FolderTree className="h-3 w-3" />}
213
+ </button>
214
+ )}
215
+ {showPreviewToggle && (
216
+ <button
217
+ onClick={() => setShowPreview(!showPreview)}
218
+ 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'}`}
219
+ >
220
+ {showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
221
+ {showPreview ? 'Preview' : 'Code'}
222
+ </button>
223
+ )}
224
+ {hasSaveHandler && (
225
+ <button
226
+ onClick={handleDirectSave}
227
+ disabled={isSaving}
228
+ className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-primary/20 text-primary disabled:opacity-50"
229
+ title="Save changes"
230
+ >
231
+ {isSaving ? (
232
+ <Loader2 className="h-3 w-3 animate-spin" />
233
+ ) : (
234
+ <Save className="h-3 w-3" />
235
+ )}
236
+ Save
237
+ </button>
238
+ )}
126
239
  <button
127
240
  onClick={handleClose}
128
241
  className="px-2 py-1 text-xs rounded flex items-center gap-1 bg-primary text-primary-foreground hover:bg-primary/90"
@@ -135,15 +248,16 @@ export function EditModal({
135
248
  </div>
136
249
 
137
250
  <div className="flex-1 min-h-0 border-b-2 overflow-hidden flex">
138
- {showTree && (
251
+ {!hideFileTree && showTree && (
139
252
  <FileTree
140
253
  files={files}
141
254
  activeFile={session.activeFile}
142
255
  onSelectFile={session.setActiveFile}
256
+ onReplaceFile={session.replaceFile}
143
257
  />
144
258
  )}
145
259
  <div className="flex-1 overflow-auto">
146
- {showPreview ? (
260
+ {fileType.category === 'compilable' && showPreview ? (
147
261
  <div className="bg-white h-full relative" ref={setPreviewContainer}>
148
262
  {previewError && renderError ? (
149
263
  renderError(previewError)
@@ -171,11 +285,29 @@ export function EditModal({
171
285
  exclude={['.bobbin-pill', '[data-bobbin]']}
172
286
  />}
173
287
  </div>
288
+ ) : fileType.category === 'compilable' && !showPreview ? (
289
+ <CodeBlockView
290
+ content={code}
291
+ language={fileType.language}
292
+ editable
293
+ onChange={session.updateActiveFile}
294
+ />
295
+ ) : fileType.category === 'text' ? (
296
+ <CodeBlockView
297
+ content={code}
298
+ language={fileType.language}
299
+ editable
300
+ onChange={session.updateActiveFile}
301
+ />
302
+ ) : fileType.category === 'media' ? (
303
+ <MediaPreview
304
+ content={code}
305
+ mimeType={getMimeType(session.activeFile)}
306
+ fileName={session.activeFile.split('/').pop() ?? session.activeFile}
307
+ />
174
308
  ) : (
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>
309
+ <div className="flex items-center justify-center h-full text-muted-foreground">
310
+ <p className="text-sm">Preview not available for this file type</p>
179
311
  </div>
180
312
  )}
181
313
  </div>
@@ -189,10 +321,10 @@ export function EditModal({
189
321
  className="h-48"
190
322
  />
191
323
 
192
- {session.error && (
324
+ {(session.error || saveError) && (
193
325
  <div className="px-4 py-2 bg-destructive/10 text-destructive text-sm flex items-center gap-2 border-t-2 border-destructive">
194
326
  <AlertCircle className="h-4 w-4 shrink-0" />
195
- {session.error}
327
+ {session.error || saveError}
196
328
  </div>
197
329
  )}
198
330
 
@@ -232,5 +364,14 @@ export function EditModal({
232
364
  </div>
233
365
  </div>
234
366
  </div>
367
+ <SaveConfirmDialog
368
+ isOpen={showConfirm}
369
+ isSaving={isSaving}
370
+ error={saveError}
371
+ onSave={handleSaveAndClose}
372
+ onDiscard={handleDiscard}
373
+ onCancel={handleCancelClose}
374
+ />
375
+ </>
235
376
  );
236
377
  }
@@ -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-0.5 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,106 @@
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 getDataUrl(content: string, mimeType: string): string {
18
+ if (content.startsWith('data:')) {
19
+ return content;
20
+ }
21
+ return `data:${mimeType};base64,${content}`;
22
+ }
23
+
24
+ export function MediaPreview({ content, mimeType, fileName }: MediaPreviewProps) {
25
+ const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
26
+ const [error, setError] = useState<string | null>(null);
27
+
28
+ const dataUrl = getDataUrl(content, mimeType);
29
+ const isImage = isImageFile(fileName);
30
+ const isVideo = isVideoFile(fileName);
31
+ const contentSize = content.length;
32
+ const estimatedBytes = content.startsWith('data:')
33
+ ? Math.floor((content.split(',')[1]?.length ?? 0) * 0.75)
34
+ : Math.floor(content.length * 0.75);
35
+
36
+ useEffect(() => {
37
+ setDimensions(null);
38
+ setError(null);
39
+
40
+ if (isImage) {
41
+ const img = new Image();
42
+ img.onload = () => {
43
+ setDimensions({ width: img.naturalWidth, height: img.naturalHeight });
44
+ };
45
+ img.onerror = () => {
46
+ setError('Failed to load image');
47
+ };
48
+ img.src = dataUrl;
49
+ }
50
+ }, [dataUrl, isImage]);
51
+
52
+ if (error) {
53
+ return (
54
+ <div className="flex flex-col items-center justify-center h-full p-8 text-muted-foreground">
55
+ <AlertCircle className="h-12 w-12 mb-4 text-destructive" />
56
+ <p className="text-sm">{error}</p>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div className="flex flex-col items-center justify-center h-full p-8 bg-muted/20">
63
+ <div className="flex-1 flex items-center justify-center w-full max-h-[60vh] overflow-hidden">
64
+ {isImage && (
65
+ <img
66
+ src={dataUrl}
67
+ alt={fileName}
68
+ className="max-w-full max-h-full object-contain rounded shadow-sm"
69
+ style={{ maxHeight: 'calc(60vh - 2rem)' }}
70
+ />
71
+ )}
72
+ {isVideo && (
73
+ <video
74
+ src={dataUrl}
75
+ controls
76
+ className="max-w-full max-h-full rounded shadow-sm"
77
+ style={{ maxHeight: 'calc(60vh - 2rem)' }}
78
+ >
79
+ Your browser does not support video playback.
80
+ </video>
81
+ )}
82
+ {!isImage && !isVideo && (
83
+ <div className="flex flex-col items-center text-muted-foreground">
84
+ <FileImage className="h-16 w-16 mb-4" />
85
+ <p className="text-sm">Preview not available for this file type</p>
86
+ </div>
87
+ )}
88
+ </div>
89
+
90
+ <div className="mt-6 text-center text-sm text-muted-foreground space-y-1">
91
+ <div className="flex items-center justify-center gap-2">
92
+ {isImage && <FileImage className="h-4 w-4" />}
93
+ {isVideo && <FileVideo className="h-4 w-4" />}
94
+ <span className="font-medium">{fileName}</span>
95
+ </div>
96
+ <div className="text-xs space-x-3">
97
+ {dimensions && (
98
+ <span>{dimensions.width} × {dimensions.height} px</span>
99
+ )}
100
+ <span>{formatFileSize(estimatedBytes)}</span>
101
+ <span className="text-muted-foreground/60">{mimeType}</span>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -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
+ }