@aprovan/patchwork-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +16 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +1597 -0
- package/package.json +45 -0
- package/src/components/CodeBlockExtension.tsx +190 -0
- package/src/components/CodePreview.tsx +344 -0
- package/src/components/MarkdownEditor.tsx +270 -0
- package/src/components/ServicesInspector.tsx +118 -0
- package/src/components/edit/EditHistory.tsx +89 -0
- package/src/components/edit/EditModal.tsx +236 -0
- package/src/components/edit/FileTree.tsx +144 -0
- package/src/components/edit/api.ts +100 -0
- package/src/components/edit/index.ts +6 -0
- package/src/components/edit/types.ts +53 -0
- package/src/components/edit/useEditSession.ts +164 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +72 -0
- package/src/lib/code-extractor.ts +210 -0
- package/src/lib/diff.ts +308 -0
- package/src/lib/index.ts +4 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/vfs.ts +106 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +10 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aprovan/patchwork-editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Components for facilitating widget generation and editing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@tiptap/core": "^3.19.0",
|
|
16
|
+
"@tiptap/extension-placeholder": "^3.19.0",
|
|
17
|
+
"@tiptap/extension-typography": "^3.19.0",
|
|
18
|
+
"@tiptap/pm": "^3.19.0",
|
|
19
|
+
"@tiptap/react": "^3.19.0",
|
|
20
|
+
"@tiptap/starter-kit": "^3.19.0",
|
|
21
|
+
"clsx": "^2.1.1",
|
|
22
|
+
"lucide-react": "^0.511.0",
|
|
23
|
+
"react-markdown": "^10.1.0",
|
|
24
|
+
"remark-gfm": "^4.0.1",
|
|
25
|
+
"tailwind-merge": "^3.4.0",
|
|
26
|
+
"tiptap-markdown": "^0.9.0",
|
|
27
|
+
"@aprovan/bobbin": "0.1.0",
|
|
28
|
+
"@aprovan/patchwork-compiler": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
32
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "^18.3.27",
|
|
36
|
+
"@types/react-dom": "^18.3.7",
|
|
37
|
+
"tsup": "^8.3.5",
|
|
38
|
+
"typescript": "^5.9.3"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"dev": "tsup --watch",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Node, textblockTypeInputRule } from '@tiptap/core';
|
|
2
|
+
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent, type NodeViewProps } from '@tiptap/react';
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
const BACKTICK_INPUT_REGEX = /^```([a-z]*)?$/;
|
|
6
|
+
|
|
7
|
+
function CodeBlockComponent({ node, updateAttributes, editor, getPos }: NodeViewProps) {
|
|
8
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
9
|
+
|
|
10
|
+
const focusInput = useCallback(() => {
|
|
11
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
const focusCodeContent = useCallback(() => {
|
|
15
|
+
const pos = typeof getPos === 'function' ? getPos() : undefined;
|
|
16
|
+
if (pos !== undefined) {
|
|
17
|
+
editor.chain().focus().setTextSelection(pos + 1).run();
|
|
18
|
+
}
|
|
19
|
+
}, [editor, getPos]);
|
|
20
|
+
|
|
21
|
+
const focusPreviousNode = useCallback(() => {
|
|
22
|
+
const pos = typeof getPos === 'function' ? getPos() : undefined;
|
|
23
|
+
if (pos !== undefined && pos > 1) {
|
|
24
|
+
const $pos = editor.state.doc.resolve(pos);
|
|
25
|
+
if ($pos.nodeBefore) {
|
|
26
|
+
editor.chain().focus().setTextSelection(pos - 1).run();
|
|
27
|
+
} else {
|
|
28
|
+
editor.chain().focus().setTextSelection(1).run();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}, [editor, getPos]);
|
|
32
|
+
|
|
33
|
+
const handleLanguageChange = useCallback(
|
|
34
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
35
|
+
updateAttributes({ language: e.target.value });
|
|
36
|
+
},
|
|
37
|
+
[updateAttributes]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
switch (e.key) {
|
|
43
|
+
case 'Enter':
|
|
44
|
+
case 'ArrowDown':
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
focusCodeContent();
|
|
47
|
+
break;
|
|
48
|
+
case 'ArrowUp':
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
focusPreviousNode();
|
|
51
|
+
break;
|
|
52
|
+
case 'Escape':
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
focusCodeContent();
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}, [focusCodeContent, focusPreviousNode]);
|
|
58
|
+
|
|
59
|
+
const handleInputMouseDown = useCallback((e: React.MouseEvent) => {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
focusInput();
|
|
63
|
+
}, [focusInput]);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<NodeViewWrapper className="code-block-wrapper my-2" data-type="codeBlock">
|
|
67
|
+
<div className="language-input-wrapper flex items-center gap-1 mb-1" contentEditable={false}>
|
|
68
|
+
<span className="text-xs text-muted-foreground select-none">```</span>
|
|
69
|
+
<input
|
|
70
|
+
ref={inputRef}
|
|
71
|
+
type="text"
|
|
72
|
+
value={(node.attrs.language as string) || ''}
|
|
73
|
+
onChange={handleLanguageChange}
|
|
74
|
+
onKeyDown={handleInputKeyDown}
|
|
75
|
+
onKeyUp={(e) => e.stopPropagation()}
|
|
76
|
+
onMouseDown={handleInputMouseDown}
|
|
77
|
+
onClick={(e) => { e.stopPropagation(); focusInput(); }}
|
|
78
|
+
onFocus={(e) => e.stopPropagation()}
|
|
79
|
+
onBlur={(e) => e.stopPropagation()}
|
|
80
|
+
placeholder="language"
|
|
81
|
+
className="language-input bg-transparent text-xs text-muted-foreground outline-none border-none min-w-[60px] max-w-[120px] focus:ring-1 focus:ring-ring rounded px-1"
|
|
82
|
+
style={{ width: `${Math.max(60, ((node.attrs.language as string)?.length || 8) * 7)}px` }}
|
|
83
|
+
spellCheck={false}
|
|
84
|
+
autoComplete="off"
|
|
85
|
+
autoCorrect="off"
|
|
86
|
+
autoCapitalize="off"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
<pre className="bg-muted rounded-md px-3 py-2 font-mono text-sm overflow-x-auto !mt-0">
|
|
90
|
+
<NodeViewContent as={'code' as any} />
|
|
91
|
+
</pre>
|
|
92
|
+
</NodeViewWrapper>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const CodeBlockExtension = Node.create({
|
|
97
|
+
name: 'codeBlock',
|
|
98
|
+
|
|
99
|
+
addOptions() {
|
|
100
|
+
return {
|
|
101
|
+
languageClassPrefix: 'language-',
|
|
102
|
+
HTMLAttributes: {},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
content: 'text*',
|
|
107
|
+
marks: '',
|
|
108
|
+
group: 'block',
|
|
109
|
+
code: true,
|
|
110
|
+
defining: true,
|
|
111
|
+
|
|
112
|
+
addAttributes() {
|
|
113
|
+
return {
|
|
114
|
+
language: {
|
|
115
|
+
default: null,
|
|
116
|
+
parseHTML: (element: HTMLElement) => {
|
|
117
|
+
const { languageClassPrefix } = this.options;
|
|
118
|
+
const classNames = [...(element.firstElementChild?.classList || [])];
|
|
119
|
+
const languages = classNames
|
|
120
|
+
.filter((className) => className.startsWith(languageClassPrefix))
|
|
121
|
+
.map((className) => className.replace(languageClassPrefix, ''));
|
|
122
|
+
return languages[0] || null;
|
|
123
|
+
},
|
|
124
|
+
rendered: false,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
parseHTML() {
|
|
130
|
+
return [{ tag: 'pre', preserveWhitespace: 'full' }];
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: Record<string, any> }) {
|
|
134
|
+
return [
|
|
135
|
+
'pre',
|
|
136
|
+
HTMLAttributes,
|
|
137
|
+
[
|
|
138
|
+
'code',
|
|
139
|
+
{
|
|
140
|
+
class: node.attrs.language
|
|
141
|
+
? this.options.languageClassPrefix + node.attrs.language
|
|
142
|
+
: null,
|
|
143
|
+
},
|
|
144
|
+
0,
|
|
145
|
+
],
|
|
146
|
+
];
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
addNodeView() {
|
|
150
|
+
return ReactNodeViewRenderer(CodeBlockComponent, {
|
|
151
|
+
stopEvent: ({ event }) => {
|
|
152
|
+
const target = event.target as HTMLElement;
|
|
153
|
+
return target.classList.contains('language-input') ||
|
|
154
|
+
!!target.closest('.language-input-wrapper');
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
addInputRules() {
|
|
160
|
+
return [
|
|
161
|
+
textblockTypeInputRule({
|
|
162
|
+
find: BACKTICK_INPUT_REGEX,
|
|
163
|
+
type: this.type,
|
|
164
|
+
getAttributes: (match: RegExpMatchArray) => ({
|
|
165
|
+
language: match[1] || '',
|
|
166
|
+
}),
|
|
167
|
+
}),
|
|
168
|
+
];
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
addKeyboardShortcuts() {
|
|
172
|
+
return {
|
|
173
|
+
'Mod-Alt-c': () => this.editor.commands.toggleCodeBlock(),
|
|
174
|
+
Backspace: () => {
|
|
175
|
+
const { empty, $anchor } = this.editor.state.selection;
|
|
176
|
+
const isAtStart = $anchor.pos === 1;
|
|
177
|
+
|
|
178
|
+
if (!empty || $anchor.parent.type.name !== this.name) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (isAtStart || !$anchor.parent.textContent.length) {
|
|
183
|
+
return this.editor.commands.clearNodes();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return false;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
});
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
2
|
+
import { Code, Eye, AlertCircle, Loader2, Pencil, RotateCcw, MessageSquare, Cloud, Check } from 'lucide-react';
|
|
3
|
+
import type { Compiler, MountedWidget, Manifest } from '@aprovan/patchwork-compiler';
|
|
4
|
+
import { createSingleFileProject } from '@aprovan/patchwork-compiler';
|
|
5
|
+
import { EditModal, type CompileFn } from './edit';
|
|
6
|
+
import { saveProject, getVFSConfig } from '../lib/vfs';
|
|
7
|
+
|
|
8
|
+
type SaveStatus = 'unsaved' | 'saving' | 'saved' | 'error';
|
|
9
|
+
|
|
10
|
+
interface CodePreviewProps {
|
|
11
|
+
code: string;
|
|
12
|
+
compiler: Compiler | null;
|
|
13
|
+
/** Available service namespaces for widget calls */
|
|
14
|
+
services?: string[];
|
|
15
|
+
/** Optional file path from code block attributes (e.g., "components/calculator.tsx") */
|
|
16
|
+
filePath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createManifest(services?: string[]): Manifest {
|
|
20
|
+
return {
|
|
21
|
+
name: 'preview',
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
platform: 'browser',
|
|
24
|
+
image: '@aprovan/patchwork-image-shadcn',
|
|
25
|
+
services,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function useCodeCompiler(compiler: Compiler | null, code: string, enabled: boolean, services?: string[]) {
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const mountedRef = useRef<MountedWidget | null>(null);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!enabled || !compiler || !containerRef.current) return;
|
|
37
|
+
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
|
|
40
|
+
async function compileAndMount() {
|
|
41
|
+
if (!containerRef.current || !compiler) return;
|
|
42
|
+
|
|
43
|
+
setLoading(true);
|
|
44
|
+
setError(null);
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
if (mountedRef.current) {
|
|
48
|
+
compiler.unmount(mountedRef.current);
|
|
49
|
+
mountedRef.current = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const widget = await compiler.compile(
|
|
53
|
+
code,
|
|
54
|
+
createManifest(services),
|
|
55
|
+
{ typescript: true }
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (cancelled) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mounted = await compiler.mount(widget, {
|
|
63
|
+
target: containerRef.current,
|
|
64
|
+
mode: 'embedded'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
mountedRef.current = mounted;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (!cancelled) {
|
|
70
|
+
setError(err instanceof Error ? err.message : 'Failed to render JSX');
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
if (!cancelled) {
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
compileAndMount();
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
if (mountedRef.current && compiler) {
|
|
84
|
+
compiler.unmount(mountedRef.current);
|
|
85
|
+
mountedRef.current = null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}, [code, compiler, enabled, services]);
|
|
89
|
+
|
|
90
|
+
return { containerRef, loading, error };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function CodePreview({ code: originalCode, compiler, services, filePath }: CodePreviewProps) {
|
|
94
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
95
|
+
const [showPreview, setShowPreview] = useState(true);
|
|
96
|
+
const [currentCode, setCurrentCode] = useState(originalCode);
|
|
97
|
+
const [editCount, setEditCount] = useState(0);
|
|
98
|
+
const [saveStatus, setSaveStatus] = useState<SaveStatus>('unsaved');
|
|
99
|
+
|
|
100
|
+
// Stable project ID for this widget instance (fallback when not using paths)
|
|
101
|
+
const fallbackId = useMemo(() => crypto.randomUUID(), []);
|
|
102
|
+
|
|
103
|
+
// Determine project ID based on server config and available path
|
|
104
|
+
const getProjectId = useCallback(async () => {
|
|
105
|
+
if (filePath) {
|
|
106
|
+
const config = await getVFSConfig();
|
|
107
|
+
if (config.usePaths) {
|
|
108
|
+
// Use the directory containing the file as project ID
|
|
109
|
+
const parts = filePath.split('/');
|
|
110
|
+
if (parts.length > 1) {
|
|
111
|
+
return parts.slice(0, -1).join('/');
|
|
112
|
+
}
|
|
113
|
+
// Single file, use filename without extension as ID
|
|
114
|
+
return filePath.replace(/\.[^.]+$/, '');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return fallbackId;
|
|
118
|
+
}, [filePath, fallbackId]);
|
|
119
|
+
|
|
120
|
+
// Get the entry filename
|
|
121
|
+
const getEntryFile = useCallback(() => {
|
|
122
|
+
if (filePath) {
|
|
123
|
+
const parts = filePath.split('/');
|
|
124
|
+
return parts[parts.length - 1] || 'main.tsx';
|
|
125
|
+
}
|
|
126
|
+
return 'main.tsx';
|
|
127
|
+
}, [filePath]);
|
|
128
|
+
|
|
129
|
+
// Manual save handler
|
|
130
|
+
const handleSave = useCallback(async () => {
|
|
131
|
+
setSaveStatus('saving');
|
|
132
|
+
try {
|
|
133
|
+
const projectId = await getProjectId();
|
|
134
|
+
const entryFile = getEntryFile();
|
|
135
|
+
const project = createSingleFileProject(currentCode, entryFile, projectId);
|
|
136
|
+
await saveProject(project);
|
|
137
|
+
setSaveStatus('saved');
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn('[VFS] Failed to save project:', err);
|
|
140
|
+
setSaveStatus('error');
|
|
141
|
+
}
|
|
142
|
+
}, [currentCode, getProjectId, getEntryFile]);
|
|
143
|
+
|
|
144
|
+
const { containerRef, loading, error } = useCodeCompiler(
|
|
145
|
+
compiler,
|
|
146
|
+
currentCode,
|
|
147
|
+
showPreview && !isEditing,
|
|
148
|
+
services
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const compile: CompileFn = useCallback(
|
|
152
|
+
async (code: string) => {
|
|
153
|
+
if (!compiler) return { success: true };
|
|
154
|
+
|
|
155
|
+
// Capture console.error outputs during compilation
|
|
156
|
+
const errors: string[] = [];
|
|
157
|
+
const originalError = console.error;
|
|
158
|
+
console.error = (...args) => {
|
|
159
|
+
errors.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '));
|
|
160
|
+
originalError.apply(console, args);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await compiler.compile(
|
|
165
|
+
code,
|
|
166
|
+
createManifest(services),
|
|
167
|
+
{ typescript: true }
|
|
168
|
+
);
|
|
169
|
+
return { success: true };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const errorMessage = err instanceof Error ? err.message : 'Compilation failed';
|
|
172
|
+
const consoleErrors = errors.length > 0 ? `\n\nConsole errors:\n${errors.join('\n')}` : '';
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
error: errorMessage + consoleErrors,
|
|
176
|
+
};
|
|
177
|
+
} finally {
|
|
178
|
+
console.error = originalError;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[compiler, services]
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const handleRevert = () => {
|
|
185
|
+
setCurrentCode(originalCode);
|
|
186
|
+
setEditCount(0);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const hasChanges = currentCode !== originalCode;
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<>
|
|
193
|
+
<div className="my-3 border rounded-lg">
|
|
194
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b rounded-t-lg">
|
|
195
|
+
<Code className="h-4 w-4 text-muted-foreground" />
|
|
196
|
+
{editCount > 0 && (
|
|
197
|
+
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
198
|
+
<MessageSquare className="h-3 w-3" />
|
|
199
|
+
{editCount} edit{editCount !== 1 ? 's' : ''}
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
{/* Save status indicator */}
|
|
203
|
+
<button
|
|
204
|
+
onClick={handleSave}
|
|
205
|
+
disabled={saveStatus === 'saving'}
|
|
206
|
+
className={`px-2 py-1 text-xs rounded flex items-center gap-1 ${
|
|
207
|
+
saveStatus === 'saved'
|
|
208
|
+
? 'text-green-600'
|
|
209
|
+
: saveStatus === 'error'
|
|
210
|
+
? 'text-destructive hover:bg-muted'
|
|
211
|
+
: 'text-muted-foreground hover:bg-muted'
|
|
212
|
+
}`}
|
|
213
|
+
title={saveStatus === 'saved' ? 'Saved to disk' : saveStatus === 'saving' ? 'Saving...' : saveStatus === 'error' ? 'Save failed - click to retry' : 'Click to save'}
|
|
214
|
+
>
|
|
215
|
+
{saveStatus === 'saving' ? (
|
|
216
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
217
|
+
) : (
|
|
218
|
+
<span className="relative">
|
|
219
|
+
<Cloud className="h-3 w-3" />
|
|
220
|
+
{saveStatus === 'saved' && (
|
|
221
|
+
<Check className="h-2 w-2 absolute -bottom-0.5 -right-0.5 stroke-[3]" />
|
|
222
|
+
)}
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
</button>
|
|
226
|
+
<div className="ml-auto flex gap-1">
|
|
227
|
+
{hasChanges && (
|
|
228
|
+
<button
|
|
229
|
+
onClick={handleRevert}
|
|
230
|
+
className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-muted text-muted-foreground"
|
|
231
|
+
title="Revert to original"
|
|
232
|
+
>
|
|
233
|
+
<RotateCcw className="h-3 w-3" />
|
|
234
|
+
</button>
|
|
235
|
+
)}
|
|
236
|
+
<button
|
|
237
|
+
onClick={() => setIsEditing(true)}
|
|
238
|
+
className="px-2 py-1 text-xs rounded flex items-center gap-1 hover:bg-muted"
|
|
239
|
+
title="Edit component"
|
|
240
|
+
>
|
|
241
|
+
<Pencil className="h-3 w-3" />
|
|
242
|
+
</button>
|
|
243
|
+
<button
|
|
244
|
+
onClick={() => setShowPreview(!showPreview)}
|
|
245
|
+
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'}`}
|
|
246
|
+
>
|
|
247
|
+
{showPreview ? <Eye className="h-3 w-3" /> : <Code className="h-3 w-3" />}
|
|
248
|
+
{showPreview ? 'Preview' : 'Code'}
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{showPreview ? (
|
|
254
|
+
<div className="bg-white">
|
|
255
|
+
{error ? (
|
|
256
|
+
<div className="p-3 text-sm text-destructive flex items-center gap-2">
|
|
257
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
258
|
+
<span>{error}</span>
|
|
259
|
+
</div>
|
|
260
|
+
) : loading ? (
|
|
261
|
+
<div className="p-3 flex items-center gap-2 text-muted-foreground">
|
|
262
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
263
|
+
<span className="text-sm">Rendering preview...</span>
|
|
264
|
+
</div>
|
|
265
|
+
) : !compiler ? (
|
|
266
|
+
<div className="p-3 text-sm text-muted-foreground">
|
|
267
|
+
Compiler not initialized
|
|
268
|
+
</div>
|
|
269
|
+
) : null}
|
|
270
|
+
<div ref={containerRef} />
|
|
271
|
+
</div>
|
|
272
|
+
) : (
|
|
273
|
+
<div className="p-3 bg-muted/30 overflow-auto max-h-96">
|
|
274
|
+
<pre className="text-xs whitespace-pre-wrap break-words m-0">
|
|
275
|
+
<code>{currentCode}</code>
|
|
276
|
+
</pre>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<EditModal
|
|
282
|
+
isOpen={isEditing}
|
|
283
|
+
onClose={(finalCode, edits) => {
|
|
284
|
+
setCurrentCode(finalCode);
|
|
285
|
+
setEditCount((prev) => prev + edits);
|
|
286
|
+
setIsEditing(false);
|
|
287
|
+
|
|
288
|
+
// Auto-save to VFS when edits complete
|
|
289
|
+
if (edits > 0) {
|
|
290
|
+
setSaveStatus('saving');
|
|
291
|
+
(async () => {
|
|
292
|
+
try {
|
|
293
|
+
const projectId = await getProjectId();
|
|
294
|
+
const entryFile = getEntryFile();
|
|
295
|
+
const project = createSingleFileProject(finalCode, entryFile, projectId);
|
|
296
|
+
await saveProject(project);
|
|
297
|
+
setSaveStatus('saved');
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.warn('[VFS] Failed to save project:', err);
|
|
300
|
+
setSaveStatus('error');
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
}
|
|
304
|
+
}}
|
|
305
|
+
originalCode={currentCode}
|
|
306
|
+
compile={compile}
|
|
307
|
+
renderPreview={(code) => <ModalPreview code={code} compiler={compiler} services={services} />}
|
|
308
|
+
/>
|
|
309
|
+
</>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function ModalPreview({
|
|
314
|
+
code,
|
|
315
|
+
compiler,
|
|
316
|
+
services,
|
|
317
|
+
}: {
|
|
318
|
+
code: string;
|
|
319
|
+
compiler: Compiler | null;
|
|
320
|
+
services?: string[];
|
|
321
|
+
}) {
|
|
322
|
+
const { containerRef, loading, error } = useCodeCompiler(compiler, code, true, services);
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<>
|
|
326
|
+
{error && (
|
|
327
|
+
<div className="text-sm text-destructive flex items-center gap-2">
|
|
328
|
+
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
329
|
+
<span>{error}</span>
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
{loading && (
|
|
333
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
334
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
335
|
+
<span className="text-sm">Rendering preview...</span>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
{!compiler && !loading && !error && (
|
|
339
|
+
<div className="text-sm text-muted-foreground">Compiler not initialized</div>
|
|
340
|
+
)}
|
|
341
|
+
<div ref={containerRef} />
|
|
342
|
+
</>
|
|
343
|
+
);
|
|
344
|
+
}
|