From 9aa9c1a1cec55358ab9d69db9004ebeb518d04f0 Mon Sep 17 00:00:00 2001 From: Moa Torres <44585769+moatorres@users.noreply.github.com> Date: Sat, 1 Nov 2025 20:09:27 -0300 Subject: [PATCH 01/40] feat(blog): editor v0 --- apps/blog/next.config.mjs | 17 +- apps/blog/package.json | 9 + .../src/app/(public)/editor/code-editor.tsx | 169 ++++ .../src/app/(public)/editor/editor-themes.ts | 121 +++ .../src/app/(public)/editor/export-utils.tsx | 107 ++ .../src/app/(public)/editor/file-tree.tsx | 324 ++++++ apps/blog/src/app/(public)/editor/page.tsx | 928 ++++++++++++++++++ apps/blog/src/app/(public)/editor/preview.tsx | 161 +++ .../app/(public)/editor/project-selector.tsx | 46 + .../blog/src/app/(public)/editor/projects.tsx | 522 ++++++++++ .../blog/src/app/(public)/editor/terminal.tsx | 107 ++ apps/blog/src/app/(public)/editor/toolbar.tsx | 70 ++ apps/blog/src/app/(public)/editor/types.ts | 14 + .../src/app/(public)/editor/use-debounce.ts | 44 + .../src/app/(public)/editor/webcontainer.ts | 213 ++++ pnpm-lock.yaml | 139 ++- 16 files changed, 2987 insertions(+), 4 deletions(-) create mode 100644 apps/blog/src/app/(public)/editor/code-editor.tsx create mode 100644 apps/blog/src/app/(public)/editor/editor-themes.ts create mode 100644 apps/blog/src/app/(public)/editor/export-utils.tsx create mode 100644 apps/blog/src/app/(public)/editor/file-tree.tsx create mode 100644 apps/blog/src/app/(public)/editor/page.tsx create mode 100644 apps/blog/src/app/(public)/editor/preview.tsx create mode 100644 apps/blog/src/app/(public)/editor/project-selector.tsx create mode 100644 apps/blog/src/app/(public)/editor/projects.tsx create mode 100644 apps/blog/src/app/(public)/editor/terminal.tsx create mode 100644 apps/blog/src/app/(public)/editor/toolbar.tsx create mode 100644 apps/blog/src/app/(public)/editor/types.ts create mode 100644 apps/blog/src/app/(public)/editor/use-debounce.ts create mode 100644 apps/blog/src/app/(public)/editor/webcontainer.ts diff --git a/apps/blog/next.config.mjs b/apps/blog/next.config.mjs index 3f43b62..754b90c 100644 --- a/apps/blog/next.config.mjs +++ b/apps/blog/next.config.mjs @@ -33,11 +33,22 @@ const nextConfig = { }, ], }, + { + // Apply to all routes + source: '/:path*', + headers: [ + { + key: 'Cross-Origin-Embedder-Policy', + value: 'require-corp', + }, + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin', + }, + ], + }, ] }, - nx: { - svgr: false, - }, pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], poweredByHeader: false, reactStrictMode: true, diff --git a/apps/blog/package.json b/apps/blog/package.json index f361448..a1d45ba 100644 --- a/apps/blog/package.json +++ b/apps/blog/package.json @@ -9,23 +9,31 @@ "@effect/printer-ansi": "^0.44.8", "@effect/rpc": "^0.62.4", "@effect/typeclass": "^0.35.8", + "@monaco-editor/react": "^4.7.0", "@vercel/analytics": "^1.5.0", "@vercel/edge-config": "^1.4.0", "@vercel/speed-insights": "^1.2.0", + "@webcontainer/api": "^1.6.1", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "codice": "^1.3.2", "date-fns": "^3.6.0", "effect": "^3.16.8", "esbuild": "^0.25.10", + "file-saver": "^2.0.5", "framer-motion": "^12.7.4", "jose": "^6.0.11", + "jszip": "^3.10.1", "lucide-react": "^0.503.0", + "monaco-editor": "^0.54.0", "next": "^15.5.4", "next-themes": "^0.4.6", "react": "^19.1.1", "react-day-picker": "^8.10.1", "react-dom": "^19.1.1", + "react-resizable-panels": "^3.0.6", "recharts": "^2.15.3", "sonner": "^2.0.3", "tailwindcss": "^4.1.4", @@ -41,6 +49,7 @@ "@shadcn/ui": "workspace:*", "@tailwindcss/postcss": "^4.1.4", "@tailwindcss/typography": "^0.5.16", + "@types/file-saver": "^2.0.7", "@types/mdx": "^2.0.13", "@zod/mini": "4.0.0-beta.20250430T185432", "clsx": "^2.1.1", diff --git a/apps/blog/src/app/(public)/editor/code-editor.tsx b/apps/blog/src/app/(public)/editor/code-editor.tsx new file mode 100644 index 0000000..4137089 --- /dev/null +++ b/apps/blog/src/app/(public)/editor/code-editor.tsx @@ -0,0 +1,169 @@ +'use client' + +import { Editor, Monaco, type OnMount } from '@monaco-editor/react' +import { useTheme } from 'next-themes' +import { useCallback, useEffect, useRef } from 'react' + +import { monacoThemeDark, monacoThemeLight } from './editor-themes' + +interface CodeEditorProps { + value: string + onChange: (value: string) => void + onSave?: (value: string) => void + language: string + filePath: string + onMonacoReady?: (monaco: Monaco) => void +} + +export function CodeEditor({ + value, + onChange, + onSave, + language, + filePath, + onMonacoReady, +}: CodeEditorProps) { + const editorRef = useRef(null) + const monacoRef = useRef(null) + const { resolvedTheme } = useTheme() + const debounceTimerRef = useRef(null) + + const handleEditorDidMount: OnMount = async (editor, monaco) => { + editorRef.current = editor + monacoRef.current = monaco + + if (onMonacoReady) { + onMonacoReady(monaco) + } + + monaco.languages.typescript.typescriptDefaults.setEagerModelSync(true) + + monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + }) + + monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ + target: monaco.languages.typescript.ScriptTarget.ES2015, + allowNonTsExtensions: true, + }) + + // monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + // allowNonTsExtensions: true, + // exactOptionalPropertyTypes: true, + // module: 199 as any, // ts.ModuleKind.NodeNext + // moduleResolution: 99 as any, // ts.ModuleResolutionKind.ESNext + // strict: true, + // target: 99, // ts.ScriptTarget.ESNext, + // }) + + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + module: monaco.languages.typescript.ModuleKind.ESNext, + target: monaco.languages.typescript.ScriptTarget.ESNext, + allowSyntheticDefaultImports: true, + esModuleInterop: true, + moduleDetection: 'force', + resolveJsonModule: true, + skipLibCheck: true, + allowJs: true, + types: ['node'], + }) + + monaco.editor.defineTheme('monaco-theme-light', monacoThemeLight) + monaco.editor.defineTheme('monaco-theme-dark', monacoThemeDark) + monaco.editor.setTheme( + resolvedTheme === 'dark' ? 'monaco-theme-dark' : 'monaco-theme-light' + ) + + editor.addAction({ + id: 'format-and-save', + label: 'Format and Save', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: async (ed) => { + // Format first + await ed.getAction('editor.action.formatDocument')?.run() + + // Get formatted content + const formattedContent = ed.getValue() + + // Save + if (onSave) { + onSave(formattedContent) + } + }, + }) + } + + useEffect(() => { + if (editorRef.current && monacoRef.current) { + monacoRef.current.editor.setTheme( + resolvedTheme === 'dark' ? 'monaco-theme-dark' : 'monaco-theme-light' + ) + } + }, [resolvedTheme]) + + const handleEditorChange = useCallback( + (value: string | undefined) => { + if (value === undefined) return + + // Clear existing timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + // Set new timer to save after 1 second of inactivity + debounceTimerRef.current = setTimeout(() => { + onChange(value) + debounceTimerRef.current = null + }, 1000) + }, + [onChange] + ) + + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + } + }, []) + + return ( +
+ +
+ ) +} diff --git a/apps/blog/src/app/(public)/editor/editor-themes.ts b/apps/blog/src/app/(public)/editor/editor-themes.ts new file mode 100644 index 0000000..d7f4166 --- /dev/null +++ b/apps/blog/src/app/(public)/editor/editor-themes.ts @@ -0,0 +1,121 @@ +import type { editor } from 'monaco-editor' + +export const monacoThemeLight: editor.IStandaloneThemeData = { + base: 'vs', + inherit: true, + rules: [ + { token: 'comment', foreground: '9699a3', fontStyle: 'italic' }, + { token: 'keyword', foreground: '9d7cd8' }, + { token: 'string', foreground: '587539' }, + { token: 'number', foreground: 'b15c00' }, + { token: 'regexp', foreground: '587539' }, + { token: 'type', foreground: '007197' }, + { token: 'class', foreground: '007197' }, + { token: 'function', foreground: '34548a' }, + { token: 'variable', foreground: '3760bf' }, + { token: 'constant', foreground: 'b15c00' }, + { token: 'property', foreground: '3760bf' }, + { token: 'operator', foreground: '9d7cd8' }, + { token: 'tag', foreground: '8c4351' }, + { token: 'attribute.name', foreground: 'b15c00' }, + { token: 'attribute.value', foreground: '587539' }, + ], + colors: { + // 'editor.background': '#d5d6db', + 'editor.foreground': '#3760bf', + 'editor.lineHighlightBackground': '#e9e9ed', + 'editor.selectionBackground': '#b3d3ff', + 'editor.inactiveSelectionBackground': '#d7e3f4', + 'editorLineNumber.foreground': '#a1a6c5', + 'editorLineNumber.activeForeground': '#3760bf', + 'editorCursor.foreground': '#3760bf', + 'editorWhitespace.foreground': '#b8bcd0', + 'editorIndentGuide.background': '#c7c9d1', + 'editorIndentGuide.activeBackground': '#a1a6c5', + }, +} + +export const monacoThemeDark: editor.IStandaloneThemeData = { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'comment', foreground: '565f89', fontStyle: 'italic' }, + { token: 'keyword', foreground: 'bb9af7' }, + { token: 'string', foreground: '9ece6a' }, + { token: 'number', foreground: 'ff9e64' }, + { token: 'regexp', foreground: '9ece6a' }, + { token: 'type', foreground: '2ac3de' }, + { token: 'class', foreground: '2ac3de' }, + { token: 'function', foreground: '7aa2f7' }, + { token: 'variable', foreground: 'c0caf5' }, + { token: 'constant', foreground: 'ff9e64' }, + { token: 'property', foreground: '7dcfff' }, + { token: 'operator', foreground: '89ddff' }, + { token: 'tag', foreground: 'f7768e' }, + { token: 'attribute.name', foreground: 'e0af68' }, + { token: 'attribute.value', foreground: '9ece6a' }, + ], + colors: { + 'editor.background': '#17181c', + 'editor.foreground': '#c0caf5', + 'editor.lineHighlightBackground': '#292e42', + 'editor.selectionBackground': '#283457', + 'editor.inactiveSelectionBackground': '#28345780', + 'editorLineNumber.foreground': '#3b4261', + 'editorLineNumber.activeForeground': '#9aa5ce', + 'editorCursor.foreground': '#c0caf5', + 'editorWhitespace.foreground': '#3b4261', + 'editorIndentGuide.background': '#2f334d', + 'editorIndentGuide.activeBackground': '#565f89', + }, +} + +export const xtermLightTheme = { + background: '#ffffff', + foreground: '#24292e', + cursor: '#24292e', + cursorAccent: '#ffffff', + selectionBackground: '#c8e1ff', + selectionForeground: '#24292e', + black: '#24292e', + red: '#d73a49', + green: '#22863a', + yellow: '#b08800', + blue: '#0366d6', + magenta: '#6f42c1', + cyan: '#1b7c83', + white: '#6a737d', + brightBlack: '#959da5', + brightRed: '#cb2431', + brightGreen: '#22863a', + brightYellow: '#dbab09', + brightBlue: '#005cc5', + brightMagenta: '#5a32a3', + brightCyan: '#3192aa', + brightWhite: '#d1d5da', +} + +export const xtermDarkTheme = { + background: 'oklch(0.21 0.0075 275)', + foreground: '#e6edf3', + cursor: '#e6edf3', + cursorAccent: '#0d1117', + selectionBackground: '#1f6feb4d', + selectionForeground: '#e6edf3', + black: '#484f58', + red: '#ff7b72', + green: '#7ee787', + yellow: '#ffa657', + blue: '#79c0ff', + magenta: '#d2a8ff', + cyan: '#a5d6ff', + white: '#e6edf3', + brightBlack: '#6e7681', + brightRed: '#ffa198', + brightGreen: '#56d364', + brightYellow: '#ffb757', + brightBlue: '#a5d6ff', + brightMagenta: '#d2a8ff', + brightCyan: '#b3d8ff', + brightWhite: '#f0f6fc', +} diff --git a/apps/blog/src/app/(public)/editor/export-utils.tsx b/apps/blog/src/app/(public)/editor/export-utils.tsx new file mode 100644 index 0000000..c2039de --- /dev/null +++ b/apps/blog/src/app/(public)/editor/export-utils.tsx @@ -0,0 +1,107 @@ +import { saveAs } from 'file-saver' +import JSZip from 'jszip' + +function filterSourceFiles( + files: Record +): Record { + const sourceFiles: Record = {} + + // Patterns to exclude (generated files and dependencies) + const excludePatterns = [ + /^node_modules\//, + /^\.pnpm\//, + // /^pnpm-lock\.yaml$/, + /^package-lock\.json$/, + /^yarn\.lock$/, + /^dist\//, + /^build\//, + /^\.next\//, + /^out\//, + /^coverage\//, + /^\.turbo\//, + ] + + for (const [path, content] of Object.entries(files)) { + // Check if path matches any exclude pattern + const shouldExclude = excludePatterns.some((pattern) => pattern.test(path)) + + if (!shouldExclude) { + sourceFiles[path] = content + } + } + + console.log( + `[v0] Filtered ${Object.keys(files).length} files to ${Object.keys(sourceFiles).length} source files` + ) + return sourceFiles +} + +export async function exportAsZip( + files: Record, + projectName: string +) { + const zip = new JSZip() + + // Add all files to the zip + for (const [path, content] of Object.entries(files)) { + zip.file(path, content) + } + + // Generate the zip file + const blob = await zip.generateAsync({ type: 'blob' }) + + // Download the zip + saveAs(blob, `${projectName.toLowerCase().replace(/\s+/g, '-')}.zip`) +} + +export function saveToLocalStorage( + projectId: string, + files: Record +) { + try { + const sourceFiles = filterSourceFiles(files) + + const key = `webcontainer-project-${projectId}` + localStorage.setItem(key, JSON.stringify(sourceFiles)) + console.log( + `[v0] Project saved to localStorage: ${key} (${Object.keys(sourceFiles).length} source files)` + ) + return true + } catch (error) { + console.error('[v0] Error saving to localStorage:', error) + return false + } +} + +export function loadFromLocalStorage( + projectId: string +): Record | null { + try { + const key = `webcontainer-project-${projectId}` + const data = localStorage.getItem(key) + if (data) { + console.log(`[v0] Project loaded from localStorage: ${key}`) + return JSON.parse(data) + } + } catch (error) { + console.error('[v0] Error loading from localStorage:', error) + } + return null +} + +export async function copyProjectToClipboard(files: Record) { + try { + const fileList = Object.entries(files) + .map(([path, content]) => { + return `// ${path}\n${content}\n\n${'='.repeat(80)}\n` + }) + .join('\n') + + await navigator.clipboard.writeText(fileList) + console.log('[v0] Project copied to clipboard') + return true + } catch (error) { + console.error('[v0] Error copying to clipboard:', error) + return false + } +} diff --git a/apps/blog/src/app/(public)/editor/file-tree.tsx b/apps/blog/src/app/(public)/editor/file-tree.tsx new file mode 100644 index 0000000..cb7df2b --- /dev/null +++ b/apps/blog/src/app/(public)/editor/file-tree.tsx @@ -0,0 +1,324 @@ +'use client' + +import { + Alert, + AlertDescription, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertTitle, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from '@shadcn/ui' +import { + AlertCircleIcon, + ChevronDown, + ChevronRight, + File, + FilePlus, + Folder, + FolderPlus, + XIcon, +} from 'lucide-react' +import { useState } from 'react' + +import type { FileNode } from './types' + +interface FileTreeProps { + nodes: FileNode[] + onFileSelect: (path: string) => void + selectedFile: string | null + onCreateFile?: (path: string) => Promise + onCreateFolder?: (path: string) => Promise + onDelete?: (path: string, isDirectory: boolean) => Promise +} + +export function FileTree({ + nodes, + onFileSelect, + selectedFile, + onCreateFile, + onCreateFolder, + onDelete, +}: FileTreeProps) { + const [showNewFileDialog, setShowNewFileDialog] = useState(false) + const [showNewFolderDialog, setShowNewFolderDialog] = useState(false) + const [newItemName, setNewItemName] = useState('') + + const handleCreateFile = async () => { + if (!newItemName.trim() || !onCreateFile) return + + await onCreateFile(newItemName.trim()) + setNewItemName('') + setShowNewFileDialog(false) + } + + const handleCreateFolder = async () => { + if (!newItemName.trim() || !onCreateFolder) return + + await onCreateFolder(newItemName.trim()) + setNewItemName('') + setShowNewFolderDialog(false) + } + + return ( +
+
+ + +
+ + {nodes.map((node) => ( + + ))} + + + + + Create File + + Enter the file name (e.g.,{' '} + + src/utils.ts + {' '} + or + README.md) + + +
+ + setNewItemName(e.target.value)} + placeholder="src/example.ts" + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFile() + }} + autoFocus + /> +
+ + + + +
+
+ + + + + Create New Folder + + Enter the folder path (e.g., "src/components" or + "utils") + + +
+ + setNewItemName(e.target.value)} + placeholder="src/components" + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFolder() + }} + autoFocus + /> +
+ + + + +
+
+
+ ) +} + +interface FileTreeNodeProps { + node: FileNode + onFileSelect: (path: string) => void + selectedFile: string | null + onDelete?: (path: string, isDirectory: boolean) => Promise + level: number +} + +function FileTreeNode({ + node, + onFileSelect, + selectedFile, + onDelete, + level, +}: FileTreeNodeProps) { + const [isExpanded, setIsExpanded] = useState(level === 0) + const [isHovered, setIsHovered] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const isSelected = selectedFile === node.path + + const handleClick = () => { + if (node.type === 'directory') { + setIsExpanded(!isExpanded) + } else { + onFileSelect(node.path) + } + } + + const handleDelete = async () => { + if (!onDelete) return + await onDelete(node.path, node.type === 'directory') + setShowDeleteDialog(false) + } + + return ( +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {node.type === 'directory' ? ( + <> + {isExpanded ? ( + + ) : ( + + )} + + + ) : ( + <> + + + + )} + {node.name} + + {isHovered && onDelete && ( + + )} +
+ + {node.type === 'directory' && isExpanded && node.children && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} + + + + + + Delete {node.type === 'directory' ? 'Folder' : 'File'} + + + Are you sure you want to delete{' '} + + + {node.path} + + + ? + + + This action cannot be undone. + + {node.type === 'directory' + ? ' This will permanently remove all files inside this folder.' + : `This will permanently remove the ${node.type} from your + project.`} + + + + + + Cancel + + Delete + + + + +
+ ) +} diff --git a/apps/blog/src/app/(public)/editor/page.tsx b/apps/blog/src/app/(public)/editor/page.tsx new file mode 100644 index 0000000..4b8432c --- /dev/null +++ b/apps/blog/src/app/(public)/editor/page.tsx @@ -0,0 +1,928 @@ +'use client' + +import { Monaco } from '@monaco-editor/react' +import { Button } from '@shadcn/ui' +import type { WebContainer } from '@webcontainer/api' +import type { FitAddon } from '@xterm/addon-fit' +import type { Terminal as XTerm } from '@xterm/xterm' +import { Eye, EyeOff, Loader2 } from 'lucide-react' +import dynamic from 'next/dynamic' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels' +import { toast } from 'sonner' + +import { CodeEditor } from './code-editor' +import { + copyProjectToClipboard, + exportAsZip, + loadFromLocalStorage, + saveToLocalStorage, +} from './export-utils' +import { FileTree } from './file-tree' +import { Preview } from './preview' +import { ProjectSelector } from './project-selector' +import { exampleProjects } from './projects' +import { Toolbar } from './toolbar' +import type { FileNode, Project } from './types' +import { useDebounce } from './use-debounce' +import { + clearFileSystem, + convertFilesToFileTree, + getWebContainerInstance, + readAllFiles, + watchFileSystem, +} from './webcontainer' + +const Terminal = dynamic( + () => import('./terminal').then((mod) => ({ default: mod.Terminal })), + { + ssr: false, + loading: () => ( +
+ Loading terminal... +
+ ), + } +) + +export default function PlaygroundPage() { + const [selectedProject, setSelectedProject] = useState(null) + const [fileTree, setFileTree] = useState([]) + const [selectedFile, setSelectedFile] = useState(null) + const [fileContent, setFileContent] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isInstalling, setIsInstalling] = useState(false) + const [error, setError] = useState(null) + const [crossOriginIsolated, setCrossOriginIsolated] = useState< + boolean | null + >(null) + const [isMounted, setIsMounted] = useState(false) + const [previewUrl, setPreviewUrl] = useState(null) + const [showPreview, setShowPreview] = useState(true) + const [isServerStarting, setIsServerStarting] = useState(false) + const debouncedLoadTypes = useDebounce(loadTypeDefinitions, 500) + + const webcontainerRef = useRef(null) + const terminalRef = useRef(null) + const shellWriterRef = useRef(null) + const terminalReadyRef = useRef(false) + const fileWatcherRef = useRef<(() => void) | null>(null) + const monacoRef = useRef(null) + + const WEBCONTAINER_BIN_PATH = 'node_modules/.bin:/usr/local/bin:/usr/bin:/bin' + + const getLanguageFromPath = (path: string): string => { + const ext = path.split('.').pop()?.toLowerCase() + const languageMap: Record = { + js: 'javascript', + jsx: 'javascript', + ts: 'typescript', + tsx: 'typescript', + json: 'json', + html: 'html', + css: 'css', + md: 'markdown', + } + return languageMap[ext || ''] || 'plaintext' + } + + const buildFileTree = ( + files: Record, + directories: string[] = [] + ): FileNode[] => { + const tree: FileNode[] = [] + const pathMap = new Map() + + for (const dirPath of directories) { + const parts = dirPath.split('/') + let currentPath = '' + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const parentPath = currentPath + currentPath = currentPath ? `${currentPath}/${part}` : part + + if (!pathMap.has(currentPath)) { + const node: FileNode = { + name: part, + type: 'directory', + path: currentPath, + children: [], + } + + pathMap.set(currentPath, node) + + if (parentPath) { + const parent = pathMap.get(parentPath) + if (parent && parent.children) { + parent.children.push(node) + } + } else { + tree.push(node) + } + } + } + } + + const sortedPaths = Object.keys(files).sort() + + for (const path of sortedPaths) { + const parts = path.split('/') + let currentPath = '' + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const parentPath = currentPath + currentPath = currentPath ? `${currentPath}/${part}` : part + + if (!pathMap.has(currentPath)) { + const isFile = i === parts.length - 1 + const node: FileNode = { + name: part, + type: isFile ? 'file' : 'directory', + path: currentPath, + children: isFile ? undefined : [], + content: isFile ? files[path] : undefined, + } + + pathMap.set(currentPath, node) + + if (parentPath) { + const parent = pathMap.get(parentPath) + if (parent && parent.children) { + parent.children.push(node) + } + } else { + tree.push(node) + } + } + } + } + + const sortNodes = (nodes: FileNode[]): FileNode[] => { + return nodes + .sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') return -1 + if (a.type === 'file' && b.type === 'directory') return 1 + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + }) + .map((node) => { + if (node.children && node.children.length > 0) { + return { + ...node, + children: sortNodes(node.children), + } + } + return node + }) + } + + return sortNodes(tree) + } + + const syncFilesFromWebContainer = useCallback(async () => { + if (!webcontainerRef.current || !selectedProject) return + + try { + const { files, directories } = await readAllFiles(webcontainerRef.current) + console.log( + '[v0] Synced files from WebContainer:', + Object.keys(files).length, + 'files,', + directories.length, + 'directories' + ) + + setSelectedProject({ + ...selectedProject, + files, + }) + + const tree = buildFileTree(files, directories) + setFileTree(tree) + + if (selectedFile && files[selectedFile]) { + setFileContent(files[selectedFile]) + } + } catch (error) { + console.error('[v0] Error syncing files:', error) + } + }, [selectedProject, selectedFile]) + + const setupFileWatching = useCallback(() => { + if (!webcontainerRef.current) return + + if (fileWatcherRef.current) { + console.log('[v0] Cleaning up existing file watchers') + fileWatcherRef.current() + fileWatcherRef.current = null + } + + console.log('[v0] Setting up file system watcher') + + fileWatcherRef.current = watchFileSystem( + webcontainerRef.current, + ({ files, directories }) => { + console.log( + `[v0] File system updated, syncing ${Object.keys(files).length} files and ${directories.length} directories to UI` + ) + + // Update project state with new files + setSelectedProject((prev) => { + if (!prev) return prev + return { + ...prev, + files, + } + }) + + // Rebuild file tree + const tree = buildFileTree(files, directories) + setFileTree(tree) + + // Update current file content if it changed + setSelectedFile((currentFile) => { + if (currentFile && files[currentFile]) { + setFileContent(files[currentFile]) + } + return currentFile + }) + + toast('Files Updated', { + description: 'File system changes detected', + duration: 2000, + }) + } + ) + + console.log('[v0] File system watcher setup complete') + }, [toast]) + + const handleExportZip = useCallback(async () => { + if (!selectedProject) return + + try { + await syncFilesFromWebContainer() + + await exportAsZip(selectedProject.files, selectedProject.name) + + toast('Export Successful', { + description: 'Project exported as ZIP file', + }) + } catch (error) { + console.error('[v0] Error exporting ZIP:', error) + toast.error('Export Failed', { + description: 'Failed to export project', + }) + } + }, [selectedProject, syncFilesFromWebContainer, toast]) + + const handleSave = useCallback(async () => { + if (!selectedProject) return + + try { + await syncFilesFromWebContainer() + + const success = saveToLocalStorage( + selectedProject.id, + selectedProject.files + ) + + if (success) { + toast('Saved', { + description: 'Project saved to browser storage', + }) + } else { + throw new Error('Failed to save') + } + } catch (error) { + console.error('[v0] Error saving:', error) + toast.error('Save Failed', { + description: 'Failed to save project', + }) + } + }, [selectedProject, syncFilesFromWebContainer, toast]) + + const handleCopy = useCallback(async () => { + if (!selectedProject) return + + try { + await syncFilesFromWebContainer() + + const success = await copyProjectToClipboard(selectedProject.files) + + if (success) { + toast('Copied', { + description: 'All files copied to clipboard', + }) + } else { + throw new Error('Failed to copy') + } + } catch (error) { + console.error('[v0] Error copying:', error) + toast.error('Copy Failed', { + description: 'Failed to copy files', + }) + } + }, [selectedProject, syncFilesFromWebContainer, toast]) + + const handleProjectSelect = async (project: Project) => { + setIsLoading(true) + setError(null) + terminalReadyRef.current = false + setPreviewUrl(null) + setIsServerStarting(false) + + if (fileWatcherRef.current) { + fileWatcherRef.current() + fileWatcherRef.current = null + } + + try { + console.log('[v0] Loading project:', project.name) + + const savedFiles = loadFromLocalStorage(project.id) + const projectToLoad = savedFiles + ? { ...project, files: savedFiles } + : project + + if (savedFiles) { + toast('Loaded Saved Version', { + description: 'Restored your previous changes', + }) + } + + setSelectedProject(projectToLoad) + + const webcontainer = await getWebContainerInstance() + console.log('[v0] WebContainer instance obtained') + + webcontainerRef.current = webcontainer + + webcontainer.on('server-ready', (port, url) => { + console.log('[v0] Server ready on port', port, 'at', url) + setPreviewUrl(url) + setIsServerStarting(false) + toast('Server Ready', { + description: `Preview available on port ${port}`, + duration: 3000, + }) + }) + + console.log('[v0] Clearing file system...') + await clearFileSystem(webcontainer) + + const tree = buildFileTree(projectToLoad.files) + setFileTree(tree) + + console.log('[v0] Mounting file tree...') + const fileTreeStructure = convertFilesToFileTree(projectToLoad.files) + await webcontainer.mount(fileTreeStructure) + console.log('[v0] File tree mounted successfully') + + const firstFile = Object.keys(projectToLoad.files)[0] + setSelectedFile(firstFile) + setFileContent(projectToLoad.files[firstFile]) + } catch (error) { + console.error('[v0] Error loading project:', error) + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + setError(`Error loading project: ${errorMessage}`) + } finally { + setIsLoading(false) + } + } + + const handleFileSelect = (path: string) => { + setSelectedFile(path) + if (selectedProject) { + setFileContent(selectedProject.files[path] || '') + } + } + + const handleFileChange = async (newContent: string) => { + setFileContent(newContent) + + if (selectedFile && webcontainerRef.current) { + try { + await webcontainerRef.current.fs.writeFile(selectedFile, newContent) + + if (selectedProject) { + setSelectedProject({ + ...selectedProject, + files: { + ...selectedProject.files, + [selectedFile]: newContent, + }, + }) + } + } catch (error) { + console.error('Error writing file:', error) + } + } + } + + const handleTerminalReady = useCallback( + async (xterm: XTerm, fitAddon: FitAddon) => { + if (terminalReadyRef.current) { + console.log('[v0] Terminal already initialized, skipping') + return + } + + terminalReadyRef.current = true + terminalRef.current = xterm + + if (!webcontainerRef.current) { + console.log('[v0] WebContainer not ready yet') + return + } + + console.log('[v0] Initializing terminal...') + xterm.writeln('\x1b[1;34mWebContainer Terminal\x1b[0m') + xterm.writeln('Initializing...\n') + + setIsInstalling(true) + + try { + console.log('[v0] Starting pnpm install...') + const installProcess = await webcontainerRef.current.spawn('pnpm', [ + 'install', + ]) + + installProcess.output.pipeTo( + new WritableStream({ + write(data) { + xterm.write(data) + }, + }) + ) + + const exitCode = await installProcess.exit + console.log('[v0] pnpm install exit code:', exitCode) + + if (exitCode === 0) { + xterm.writeln( + '\n\x1b[1;32m✓ Dependencies installed successfully\x1b[0m\n' + ) + + console.log('[v0] Loading type definitions...') + if (webcontainerRef.current && monacoRef.current) { + await loadTypeDefinitions( + monacoRef.current, + webcontainerRef.current + ) + console.log('[v0] Type definitions loaded.') + } else { + console.warn( + '[v0] Monaco or WebContainer not ready for type loading' + ) + } + + toast('Initialized', { + description: 'Dependencies installed successfully', + }) + + console.log('[v0] Setting up file watching after install') + setupFileWatching() + } else { + xterm.writeln('\n\x1b[1;31m✗ Installation failed\x1b[0m\n') + } + + console.log('[v0] Starting shell...') + const shellProcess = await webcontainerRef.current.spawn('jsh', { + terminal: { + cols: xterm.cols, + rows: xterm.rows, + }, + env: { + PATH: WEBCONTAINER_BIN_PATH, + NODE_NO_WARNINGS: '1', + }, + }) + + shellProcess.output.pipeTo( + new WritableStream({ + write(data) { + xterm.write(data) + }, + }) + ) + + const input = shellProcess.input.getWriter() + shellWriterRef.current = input + + xterm.onData((data) => { + input.write(data) + }) + + console.log('[v0] Terminal ready') + } catch (error) { + console.error('[v0] Terminal error:', error) + xterm.writeln('\x1b[1;31mError initializing terminal\x1b[0m') + } finally { + setIsInstalling(false) + } + }, + [setupFileWatching, toast] + ) + + const handleCreateFile = useCallback( + async (path: string) => { + if (!webcontainerRef.current) return + + try { + // Create parent directories if they don't exist + const parts = path.split('/') + if (parts.length > 1) { + const dirPath = parts.slice(0, -1).join('/') + await webcontainerRef.current.fs.mkdir(dirPath, { recursive: true }) + } + + // Create the file with empty content + await webcontainerRef.current.fs.writeFile(path, '') + + toast('File Created', { + description: `Created ${path}`, + }) + + // Select the newly created file + setSelectedFile(path) + setFileContent('') + } catch (error) { + console.error('[v0] Error creating file:', error) + toast.error('Error', { + description: 'Failed to create file', + }) + } + }, + [toast] + ) + + const handleCreateFolder = useCallback( + async (path: string) => { + if (!webcontainerRef.current) return + + try { + await webcontainerRef.current.fs.mkdir(path, { recursive: true }) + + toast('Folder Created', { + description: `Created ${path}`, + }) + } catch (error) { + console.error('[v0] Error creating folder:', error) + toast.error('Error', { + description: 'Failed to create folder', + }) + } + }, + [toast] + ) + + const handleDelete = useCallback( + async (path: string, isDirectory: boolean) => { + if (!webcontainerRef.current) return + + try { + if (isDirectory) { + await webcontainerRef.current.fs.rm(path, { + recursive: true, + force: true, + }) + } else { + await webcontainerRef.current.fs.rm(path) + } + + // If the deleted file was selected, clear selection + if (selectedFile === path || selectedFile?.startsWith(path + '/')) { + setSelectedFile(null) + setFileContent('') + } + + toast(isDirectory ? 'Folder Deleted' : 'File Deleted', { + description: `Deleted ${path}`, + }) + } catch (error) { + console.error('[v0] Error deleting:', error) + toast.error('Error', { + description: `Failed to delete ${isDirectory ? 'folder' : 'file'}`, + }) + } + }, + [selectedFile, toast] + ) + + useEffect(() => { + return () => { + if (fileWatcherRef.current) { + fileWatcherRef.current() + } + } + }, []) + + useEffect(() => { + setIsMounted(true) + }, []) + + useEffect(() => { + if (!isMounted) return + + const isIsolated = + typeof crossOriginIsolated !== 'undefined' && crossOriginIsolated + setCrossOriginIsolated(isIsolated) + + if (isIsolated) { + console.log('[v0] Cross-Origin Isolation is enabled ✓') + } + }, [isMounted, crossOriginIsolated]) + + useEffect(() => { + if (!webcontainerRef.current) return + + const handleLockfileChange = ( + eventType: string, + filename: string | Uint8Array | null + ) => { + // Check for the specific lockfile change + if (filename === 'pnpm-lock.yaml') { + console.log( + `[TypeWatcher] Lockfile changed (${eventType}). Debouncing type reload...` + ) + // Trigger the debounced type reload function + debouncedLoadTypes(monacoRef.current, webcontainerRef.current!) + } + } + + // Watch for changes to the lockfile to indicate new dependencies are installed + const watcher = webcontainerRef.current.fs.watch( + '/pnpm-lock.yaml', + {}, + handleLockfileChange + ) + + return () => { + console.log('[TypeWatcher] Closing lockfile watcher.') + watcher.close() + } + }, [webcontainerRef.current, debouncedLoadTypes]) + + useEffect(() => { + ;(async function () { + if (monacoRef.current && webcontainerRef.current) { + await loadTypeDefinitions(monacoRef.current, webcontainerRef.current) + } + })() + }, [webcontainerRef.current, monacoRef.current]) + + if (!isMounted) { + return ( +
+
+ +

Loading...

+
+
+ ) + } + + if (!selectedProject) { + return ( + + ) + } + + if (isLoading) { + return ( +
+
+ +

Loading project...

+
+
+ ) + } + + if (error) { + return ( +
+
+
⚠️
+

Failed to Load Project

+

{error}

+ +
+
+ ) + } + + return ( +
+
+ setSelectedProject(null)} + /> + + + + + {/* File Tree Panel */} + +
+
+

+ Explorer +

+
+ +
+
+ + + + {/* Editor + Preview Panel */} + + + {/* Editor Panel */} + +
+ {selectedFile ? ( + <> +
+ + {selectedFile} + +
+
+ { + monacoRef.current = monaco + }} + /> +
+ + ) : ( +
+
+
📝
+

Select a file to edit

+
+
+ )} +
+
+ + {/* Preview Panel */} + {showPreview && ( + <> + + +
+
+ + Preview + + +
+
+ +
+
+
+ + )} +
+
+
+
+ + + + {/* Terminal Panel */} + +
+
+ + Terminal + + {isInstalling && ( + + + Installing dependencies... + + )} +
+
+ +
+
+
+
+
+ + {!showPreview && ( + + )} +
+ ) +} + +// -------------------------------------------------- +// Utils +// -------------------------------------------------- + +export async function loadTypeDefinitions( + monaco: Monaco, + webcontainer: WebContainer, + basePath = '/node_modules/.pnpm' +) { + async function readDirRecursive(dir: string): Promise { + let entries + try { + entries = await webcontainer.fs.readdir(dir, { withFileTypes: true }) + } catch (err) { + console.error('[TypeLoader:readdir] Failed', dir, err) + return + } + + for (const entry of entries) { + const fullPath = `${dir}/${entry.name}` + console.debug(fullPath) + + if (entry.isDirectory()) { + await readDirRecursive(fullPath) + } else if ( + entry.name.endsWith('.d.ts') || + entry.name === 'package.json' + ) { + try { + const content = await webcontainer.fs.readFile(fullPath, 'utf-8') + console.debug( + `[TypeLoader] READ SUCCESS: ${fullPath} (Length: ${content.length})` + ) + const normalized = fullPath.replace( + /.*\/node_modules\//, + '/node_modules/' + ) + const virtualPath = `file://${normalized}` + monaco.languages.typescript.typescriptDefaults.addExtraLib( + content, + virtualPath + ) + monaco.languages.typescript.javascriptDefaults.addExtraLib( + content, + virtualPath + ) + } catch (e) { + console.error(`[TypeLoader:readFile] Failed to read ${fullPath}:`, e) + } + } + } + } + + console.log('[TypeLoader] Scanning for .d.ts files...') + await readDirRecursive(basePath) + console.log('[TypeLoader] Type definitions loaded into Monaco.') +} diff --git a/apps/blog/src/app/(public)/editor/preview.tsx b/apps/blog/src/app/(public)/editor/preview.tsx new file mode 100644 index 0000000..e5bfd7e --- /dev/null +++ b/apps/blog/src/app/(public)/editor/preview.tsx @@ -0,0 +1,161 @@ +'use client' + +import { Button, Input } from '@shadcn/ui' +import { ArrowLeft, ArrowRight, Loader2, RefreshCw } from 'lucide-react' +import type React from 'react' +import { useEffect, useRef, useState } from 'react' + +interface PreviewProps { + url: string | null + isLoading?: boolean +} + +export function Preview({ url, isLoading }: PreviewProps) { + const iframeRef = useRef(null) + const [currentPath, setCurrentPath] = useState('/') + const [inputValue, setInputValue] = useState('/') + const [history, setHistory] = useState(['/']) + const [historyIndex, setHistoryIndex] = useState(0) + + useEffect(() => { + if (url) { + setCurrentPath('/') + setInputValue('/') + setHistory(['/']) + setHistoryIndex(0) + } + }, [url]) + + useEffect(() => { + if (iframeRef.current && url) { + const fullUrl = url + (currentPath === '/' ? '' : currentPath) + iframeRef.current.src = fullUrl + } + }, [url, currentPath]) + + const handleNavigate = (e: React.FormEvent) => { + e.preventDefault() + let path = inputValue.trim() + if (!path.startsWith('/')) { + path = '/' + path + } + setCurrentPath(path) + + // Add to history + const newHistory = history.slice(0, historyIndex + 1) + newHistory.push(path) + setHistory(newHistory) + setHistoryIndex(newHistory.length - 1) + } + + const handleBack = () => { + if (historyIndex > 0) { + const newIndex = historyIndex - 1 + setHistoryIndex(newIndex) + const path = history[newIndex] + setCurrentPath(path) + setInputValue(path) + } + } + + const handleForward = () => { + if (historyIndex < history.length - 1) { + const newIndex = historyIndex + 1 + setHistoryIndex(newIndex) + const path = history[newIndex] + setCurrentPath(path) + setInputValue(path) + } + } + + const handleRefresh = () => { + if (iframeRef.current) { + // iframeRef.current.src = iframeRef.current.src + } + } + + if (!url && !isLoading) { + return ( +
+
+
🚀
+

Start a dev server to see preview

+

+ Run{' '} + npm run dev{' '} + or pnpm dev +

+
+
+ ) + } + + if (isLoading) { + return ( +
+
+ +

Starting server...

+
+
+ ) + } + + return ( +
+
+ + + +
+ setInputValue(e.target.value)} + placeholder="/path" + className="h-8 text-sm font-mono" + /> +
+
+ +
+