+
{children}
diff --git a/apps/blog/src/app/(public)/articles/[collection]/[slug]/page.tsx b/apps/blog/src/app/(public)/articles/[collection]/[slug]/page.tsx
index 7ad0935..7fe6d60 100644
--- a/apps/blog/src/app/(public)/articles/[collection]/[slug]/page.tsx
+++ b/apps/blog/src/app/(public)/articles/[collection]/[slug]/page.tsx
@@ -7,9 +7,8 @@ import { Suspense } from 'react'
import { Toc } from '@/components/toc'
import { ArticleSkeleton } from '@/components/ui/skeleton'
-import collections from '@/data/collections.json'
import config from '@/data/config.json'
-import { getArticleBySlug, getCollectionByName } from '@/lib/articles'
+import { getArticleBySlug, getArticles } from '@/lib/articles'
type Props = {
params: Promise<{
@@ -29,17 +28,13 @@ async function getContent(collection: string, slug: string) {
}
}
-export async function generateStaticParams() {
- let paths: Array<{ collection: string; slug: string }> = []
+export function generateStaticParams() {
+ const articles = getArticles()
- for (const collection of collections) {
- const files = getCollectionByName(collection).map((file) => ({
- collection,
- slug: file.slug,
- }))
-
- paths = paths.concat(files)
- }
+ const paths = articles.map((a) => ({
+ collection: a.collection,
+ slug: a.slug,
+ }))
return paths
}
@@ -131,7 +126,7 @@ export default async function BlogArticle({ params }: Props) {
{category}
-
+
{title}
diff --git a/apps/blog/src/app/(public)/articles/layout.tsx b/apps/blog/src/app/(public)/articles/layout.tsx
index 7a1dc09..a3b892c 100644
--- a/apps/blog/src/app/(public)/articles/layout.tsx
+++ b/apps/blog/src/app/(public)/articles/layout.tsx
@@ -1,4 +1,4 @@
-import { unstable_ViewTransition as ViewTransition } from 'react'
+import { ViewTransition } from 'react'
export default function Layout({ children }: { children: React.ReactNode }) {
return {children}
diff --git a/apps/blog/src/app/(public)/editor/atoms/current-file.ts b/apps/blog/src/app/(public)/editor/atoms/current-file.ts
new file mode 100644
index 0000000..2a05b17
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/atoms/current-file.ts
@@ -0,0 +1,33 @@
+import { atom, useAtomValue, useSetAtom } from 'jotai'
+
+// --- Files ----------------------------------------------
+
+type CurrentFileAtomValue = {
+ path: string | null
+ content: string
+}
+
+export const currentFileAtom = atom({
+ path: null,
+ content: '',
+})
+
+export function useCurrentFile() {
+ const currentFile = useAtomValue(currentFileAtom)
+ const setCurrentFile = useSetAtom(currentFileAtom)
+
+ const setPath = (path: string | null) =>
+ setCurrentFile((state) => ({ ...state, path }))
+ const setContent = (content: string) =>
+ setCurrentFile((state) => ({ ...state, content }))
+ const clear = () => setCurrentFile((state) => ({ path: null, content: '' }))
+
+ return {
+ path: currentFile.path,
+ content: currentFile.content,
+ clear,
+ setPath,
+ setContent,
+ setCurrentFile,
+ } as const
+}
diff --git a/apps/blog/src/app/(public)/editor/atoms/panels.ts b/apps/blog/src/app/(public)/editor/atoms/panels.ts
new file mode 100644
index 0000000..0ddcdb2
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/atoms/panels.ts
@@ -0,0 +1,36 @@
+import { atom, useAtomValue, useSetAtom } from 'jotai'
+
+type Panel = 'preview' | 'explorer' | 'editor' | 'terminal'
+
+type PanelsAtomValue = { [Key in Panel]: boolean }
+
+export const panelsAtom = atom({
+ editor: true,
+ explorer: true,
+ preview: true,
+ terminal: true,
+})
+
+export function usePanelVisible() {
+ const panels = useAtomValue(panelsAtom)
+ const setPanels = useSetAtom(panelsAtom)
+
+ const isPanelVisible = (panel: Panel) => panels[panel]
+ const togglePanelHandler = (panel: Panel) => () =>
+ setPanels((s) => ({ ...s, [panel]: !s[panel] }))
+
+ const toggleEditor = togglePanelHandler('editor')
+ const toggleExplorer = togglePanelHandler('explorer')
+ const togglePreview = togglePanelHandler('preview')
+ const toggleTerminal = togglePanelHandler('terminal')
+
+ return [
+ isPanelVisible,
+ {
+ toggleEditor,
+ toggleExplorer,
+ togglePreview,
+ toggleTerminal,
+ },
+ ] as const
+}
diff --git a/apps/blog/src/app/(public)/editor/components/code-editor.tsx b/apps/blog/src/app/(public)/editor/components/code-editor.tsx
new file mode 100644
index 0000000..f9976c5
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/code-editor.tsx
@@ -0,0 +1,224 @@
+'use client'
+
+import { Editor, type Monaco, type OnMount } from '@monaco-editor/react'
+import { useTheme } from 'next-themes'
+import { useCallback, useEffect, useRef } from 'react'
+
+import { useCurrentFile } from '../atoms/current-file'
+import { setupWorkspaceFormatters } from '../services/formatters'
+import { monacoThemeDark, monacoThemeLight } from '../services/themes'
+
+interface CodeEditorProps {
+ value: string
+ onChange: (value: string) => void
+ onSave?: (value: string) => void
+ language: string
+ filePath: string
+ onMonacoReady?: (monaco: Monaco) => void
+ files?: Record
+}
+
+export function CodeEditor({
+ value,
+ onChange,
+ onSave,
+ language,
+ filePath,
+ onMonacoReady,
+ files,
+}: CodeEditorProps) {
+ const editorRef = useRef(null)
+ const monacoRef = useRef(null)
+ const { resolvedTheme } = useTheme()
+ const debounceTimerRef = useRef(null)
+ const currentFile = useCurrentFile()
+
+ const handleEditorDidMount: OnMount = async (editor, monaco) => {
+ editorRef.current = editor
+ monacoRef.current = monaco
+
+ if (onMonacoReady) {
+ onMonacoReady(monaco)
+ await setupWorkspaceFormatters(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({
+ allowJs: true,
+ allowNonTsExtensions: true,
+ allowSyntheticDefaultImports: true,
+ checkJs: true,
+ esModuleInterop: true,
+ exactOptionalPropertyTypes: true,
+ jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
+ module: monaco.languages.typescript.ModuleKind.ESNext,
+ moduleDetection: 'force',
+ moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
+ resolveJsonModule: true,
+ skipLibCheck: true,
+ strict: true,
+ target: monaco.languages.typescript.ScriptTarget.ESNext,
+ types: ['node'],
+ })
+
+ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
+ allowJs: true,
+ allowSyntheticDefaultImports: true,
+ checkJs: true,
+ esModuleInterop: true,
+ jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
+ module: monaco.languages.typescript.ModuleKind.ESNext,
+ moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
+ resolveJsonModule: true,
+ target: monaco.languages.typescript.ScriptTarget.ESNext,
+ 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 document
+ await ed.getAction('editor.action.formatDocument')?.run()
+ // Get formatted content
+ const formattedContent = ed.getValue()
+ // Save
+ if (onSave) {
+ onSave(formattedContent)
+ }
+ },
+ })
+
+ if (files) {
+ // Setup peek file definition
+ monaco.editor.registerEditorOpener({
+ openCodeEditor(editor, uri) {
+ const model = monaco.editor.getModel(uri)
+
+ if (!model) return false
+
+ const fullPath = uri.path.replace(/^\//, '')
+
+ if (!files[fullPath]) {
+ editor.trigger(
+ 'registerEditorOpener',
+ 'editor.action.peekDefinition',
+ {}
+ )
+ return false
+ }
+
+ currentFile.setPath(fullPath)
+
+ return true
+ },
+ })
+
+ // Setup imports between project files
+ for (const [path, content] of Object.entries(files)) {
+ if (!/\.(ts|tsx|js|jsx|json)$/.test(path)) continue
+
+ const virtualPath = `file:///${path}`
+ monaco.languages.typescript.typescriptDefaults.addExtraLib(
+ content,
+ virtualPath
+ )
+ monaco.languages.typescript.javascriptDefaults.addExtraLib(
+ content,
+ virtualPath
+ )
+ }
+ }
+ }
+
+ 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/components/file-tree.tsx b/apps/blog/src/app/(public)/editor/components/file-tree.tsx
new file mode 100644
index 0000000..696eee4
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/file-tree.tsx
@@ -0,0 +1,857 @@
+'use client'
+
+import { print } from '@blog/utils'
+import {
+ Alert,
+ AlertDescription,
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertTitle,
+ Button,
+ cn,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+} from '@shadcn/ui'
+import {
+ AlertCircleIcon,
+ ChevronDown,
+ ChevronRight,
+ CopyMinus,
+ Edit2,
+ File,
+ FilePlus,
+ Folder,
+ FolderPlus,
+ XIcon,
+} from 'lucide-react'
+import type React from 'react'
+import { useState } from 'react'
+
+import type { FileNode } from '../services/types'
+import { getWebContainerInstance, readAllFiles } from '../services/webcontainer'
+
+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
+ onRename?: (
+ oldPath: string,
+ newPath: string,
+ isDirectory: boolean
+ ) => Promise
+ onMove?: (
+ sourcePath: string,
+ targetPath: string,
+ isDirectory: boolean
+ ) => Promise
+}
+
+interface DragState {
+ path: string
+ isDirectory: boolean
+ name: string
+}
+
+export function FileTree({
+ nodes,
+ onFileSelect,
+ selectedFile,
+ onCreateFile,
+ onCreateFolder,
+ onDelete,
+ onRename,
+ onMove,
+}: FileTreeProps) {
+ const [showNewFileDialog, setShowNewFileDialog] = useState(false)
+ const [showNewFolderDialog, setShowNewFolderDialog] = useState(false)
+ const [newItemName, setNewItemName] = useState('')
+ const [isRootDragOver, setIsRootDragOver] = useState(false)
+ const [dragState, setDragState] = useState(null)
+ const [highlightedFolderPath, setHighlightedFolderPath] = useState<
+ string | null
+ >(null)
+ const [expandedFolders, setExpandedFolders] = useState>(
+ new Set(nodes.filter((n) => n.type === 'directory').map((n) => n.path))
+ )
+
+ const checkIfPathExists = (path: string): boolean => {
+ const checkNodes = (nodeList: FileNode[]): boolean => {
+ for (const node of nodeList) {
+ if (node.path === path) return true
+ if (node.type === 'directory' && node.children) {
+ if (checkNodes(node.children)) return true
+ }
+ }
+ return false
+ }
+ return checkNodes(nodes)
+ }
+
+ const isFileNameInvalid =
+ newItemName.trim() !== '' && checkIfPathExists(newItemName.trim())
+ const isFolderNameInvalid =
+ newItemName.trim() !== '' && checkIfPathExists(newItemName.trim())
+
+ const handleCollapseAll = () => {
+ setExpandedFolders(new Set())
+ }
+
+ const handleCreateFile = async () => {
+ if (!newItemName.trim() || !onCreateFile || isFileNameInvalid) return
+
+ await onCreateFile(newItemName.trim())
+ setNewItemName('')
+ setShowNewFileDialog(false)
+ }
+
+ const handleCreateFolder = async () => {
+ if (!newItemName.trim() || !onCreateFolder || isFolderNameInvalid) return
+
+ await onCreateFolder(newItemName.trim())
+ setNewItemName('')
+ setShowNewFolderDialog(false)
+ }
+
+ const handleRootDragOver = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ if (dragState && dragState.path.includes('/')) {
+ const hasCollision = nodes.some((n) => n.name === dragState.name)
+ if (hasCollision) {
+ e.dataTransfer.dropEffect = 'none'
+ return
+ }
+ }
+
+ e.dataTransfer.dropEffect = 'move'
+ setIsRootDragOver(true)
+ setHighlightedFolderPath('')
+ }
+
+ const handleRootDragLeave = (e: React.DragEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const x = e.clientX
+ const y = e.clientY
+
+ if (
+ x <= rect.left ||
+ x >= rect.right ||
+ y <= rect.top ||
+ y >= rect.bottom
+ ) {
+ setIsRootDragOver(false)
+ setHighlightedFolderPath(null)
+ }
+ }
+
+ const handleRootDrop = async (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsRootDragOver(false)
+ setHighlightedFolderPath(null)
+
+ if (!onMove || !dragState) return
+
+ const hasCollision = nodes.some((n) => n.name === dragState.name)
+ if (hasCollision) {
+ print.warn('Cannot move to root: item with same name already exists')
+ return
+ }
+
+ try {
+ const sourcePath = dragState.path
+ const isDirectory = dragState.isDirectory
+
+ if (sourcePath.includes('/')) {
+ const fileName = sourcePath.split('/').pop()
+ if (!fileName) return
+
+ const targetPath = ''
+
+ try {
+ const webcontainer = await getWebContainerInstance()
+
+ if (!webcontainer) return
+
+ if (isDirectory) {
+ const files = await readAllFiles(webcontainer)
+ const filesToMove = Object.keys(files.files).filter((f) =>
+ f.startsWith(sourcePath + '/')
+ )
+
+ await webcontainer.fs.mkdir(fileName, { recursive: true })
+
+ for (const file of filesToMove) {
+ const relPath = file.substring(sourcePath.length + 1)
+ const newFilePath = `${fileName}/${relPath}`
+ const content = await webcontainer.fs.readFile(file, 'utf-8')
+ const newFileDir = newFilePath.split('/').slice(0, -1).join('/')
+ if (newFileDir) {
+ await webcontainer.fs.mkdir(newFileDir, { recursive: true })
+ }
+ await webcontainer.fs.writeFile(newFilePath, content)
+ }
+
+ await webcontainer.fs.rm(sourcePath, {
+ recursive: true,
+ force: true,
+ })
+ } else {
+ const content = await webcontainer.fs.readFile(sourcePath, 'utf-8')
+ await webcontainer.fs.writeFile(fileName, content)
+ await webcontainer.fs.rm(sourcePath)
+ }
+
+ await onMove(sourcePath, targetPath, isDirectory)
+ } catch (error) {
+ print.error('Failed to move to root:', error)
+ }
+ }
+ } catch (error) {
+ print.error('Failed to parse drag data:', error)
+ }
+ }
+
+ const handleRootDragEnd = () => {
+ setIsRootDragOver(false)
+ setHighlightedFolderPath(null)
+ setDragState(null)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {nodes.map((node) => (
+
+ ))}
+ {isRootDragOver && (
+
+ Drop here to move to root folder
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+interface FileTreeNodeProps {
+ node: FileNode
+ onFileSelect: (path: string) => void
+ selectedFile: string | null
+ onDelete?: (path: string, isDirectory: boolean) => Promise
+ onRename?: (
+ oldPath: string,
+ newPath: string,
+ isDirectory: boolean
+ ) => Promise
+ onMove?: (
+ sourcePath: string,
+ targetPath: string,
+ isDirectory: boolean
+ ) => Promise
+ level: number
+ expandedFolders: Set
+ setExpandedFolders: React.Dispatch>>
+ dragState: DragState | null
+ setDragState: React.Dispatch>
+ highlightedFolderPath: string | null
+ setHighlightedFolderPath: React.Dispatch>
+ allNodes?: FileNode[]
+}
+
+function FileTreeNode({
+ node,
+ onFileSelect,
+ selectedFile,
+ onDelete,
+ onRename,
+ onMove,
+ level,
+ expandedFolders,
+ setExpandedFolders,
+ dragState,
+ setDragState,
+ highlightedFolderPath,
+ setHighlightedFolderPath,
+ allNodes,
+}: FileTreeNodeProps) {
+ const [isHovered, setIsHovered] = useState(false)
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ const [isRenaming, setIsRenaming] = useState(false)
+ const [renameValue, setRenameValue] = useState(node.name)
+ const [isDragOver, setIsDragOver] = useState(false)
+ const isSelected = selectedFile === node.path
+ const isExpanded = expandedFolders.has(node.path)
+
+ const isHighlighted =
+ highlightedFolderPath !== null &&
+ (node.path === highlightedFolderPath ||
+ (node.path.startsWith(highlightedFolderPath + '/') &&
+ highlightedFolderPath !== '') ||
+ (highlightedFolderPath === '' && !node.path.includes('/')))
+
+ const checkRenameConflict = (): boolean => {
+ if (!renameValue.trim() || renameValue === node.name) return false
+
+ const pathParts = node.path.split('/')
+ pathParts[pathParts.length - 1] = renameValue.trim()
+ const newPath = pathParts.join('/')
+
+ // Check if the new path already exists
+ const checkNodes = (nodeList: FileNode[]): boolean => {
+ for (const n of nodeList) {
+ if (n.path === newPath) return true
+ if (n.type === 'directory' && n.children) {
+ if (checkNodes(n.children)) return true
+ }
+ }
+ return false
+ }
+ return allNodes ? checkNodes(allNodes) : false
+ }
+
+ const isRenameInvalid = renameValue.trim() !== '' && checkRenameConflict()
+
+ const getNodesInFolder = (folderPath: string): FileNode[] => {
+ if (!allNodes) return []
+
+ const findFolder = (nodes: FileNode[], path: string): FileNode | null => {
+ for (const n of nodes) {
+ if (n.path === path && n.type === 'directory') return n
+ if (n.type === 'directory' && n.children) {
+ const found = findFolder(n.children, path)
+ if (found) return found
+ }
+ }
+ return null
+ }
+
+ if (folderPath === '') {
+ return allNodes
+ }
+
+ const folder = findFolder(allNodes, folderPath)
+ return folder?.children || []
+ }
+
+ const handleClick = () => {
+ if (isRenaming) return
+ if (node.type === 'directory') {
+ setExpandedFolders((prev) => {
+ const newFolders = new Set(prev)
+ if (isExpanded) {
+ newFolders.delete(node.path)
+ } else {
+ newFolders.add(node.path)
+ }
+ return newFolders
+ })
+ } else {
+ onFileSelect(node.path)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!onDelete) return
+ await onDelete(node.path, node.type === 'directory')
+ setShowDeleteDialog(false)
+ }
+
+ const handleStartRename = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setIsRenaming(true)
+ setRenameValue(node.name)
+ }
+
+ const handleRename = async () => {
+ if (
+ !onRename ||
+ !renameValue.trim() ||
+ renameValue === node.name ||
+ isRenameInvalid
+ ) {
+ setIsRenaming(false)
+ setRenameValue(node.name)
+ return
+ }
+
+ const pathParts = node.path.split('/')
+ pathParts[pathParts.length - 1] = renameValue.trim()
+ const newPath = pathParts.join('/')
+
+ await onRename(node.path, newPath, node.type === 'directory')
+ setIsRenaming(false)
+ }
+
+ const handleRenameKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleRename()
+ } else if (e.key === 'Escape') {
+ setIsRenaming(false)
+ setRenameValue(node.name)
+ }
+ }
+
+ const handleDragStart = (e: React.DragEvent) => {
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setData(
+ 'text/plain',
+ JSON.stringify({
+ path: node.path,
+ isDirectory: node.type === 'directory',
+ })
+ )
+ setDragState({
+ path: node.path,
+ isDirectory: node.type === 'directory',
+ name: node.name,
+ })
+ }
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ if (!dragState) return
+
+ let targetFolder: string
+ if (node.type === 'directory') {
+ targetFolder = node.path
+ } else {
+ const pathParts = node.path.split('/')
+ targetFolder =
+ pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : ''
+ }
+
+ const sourceParent = dragState.path.includes('/')
+ ? dragState.path.substring(0, dragState.path.lastIndexOf('/'))
+ : ''
+
+ const isSameParent = sourceParent === targetFolder
+ const isMovingIntoSelf =
+ dragState.path === targetFolder ||
+ node.path.startsWith(dragState.path + '/')
+
+ const targetChildren =
+ node.type === 'directory'
+ ? node.children || []
+ : getNodesInFolder(targetFolder)
+ const hasCollision = targetChildren.some(
+ (child) => child.name === dragState.name
+ )
+
+ if (isSameParent || isMovingIntoSelf || hasCollision) {
+ e.dataTransfer.dropEffect = 'none'
+ setIsDragOver(false)
+ setHighlightedFolderPath(null)
+ } else {
+ e.dataTransfer.dropEffect = 'move'
+ setIsDragOver(true)
+ setHighlightedFolderPath(targetFolder)
+ }
+ }
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const x = e.clientX
+ const y = e.clientY
+
+ if (
+ x <= rect.left ||
+ x >= rect.right ||
+ y <= rect.top ||
+ y >= rect.bottom
+ ) {
+ setIsDragOver(false)
+ setHighlightedFolderPath(null)
+ }
+ }
+
+ const handleDragEnd = () => {
+ setIsDragOver(false)
+ setHighlightedFolderPath(null)
+ setDragState(null)
+ }
+
+ const handleDrop = async (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragOver(false)
+ setHighlightedFolderPath(null)
+
+ if (!onMove || !dragState) return
+
+ try {
+ const sourcePath = dragState.path
+ const isDirectory = dragState.isDirectory
+ const sourceName = dragState.name
+
+ let targetFolder: string
+
+ if (node.type === 'directory') {
+ targetFolder = node.path
+ } else {
+ const pathParts = node.path.split('/')
+ targetFolder =
+ pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : ''
+ }
+
+ const sourceParent = sourcePath.includes('/')
+ ? sourcePath.substring(0, sourcePath.lastIndexOf('/'))
+ : ''
+
+ if (
+ sourcePath === targetFolder ||
+ node.path.startsWith(sourcePath + '/')
+ ) {
+ return
+ }
+
+ if (sourceParent === targetFolder) {
+ return
+ }
+
+ const targetChildren =
+ node.type === 'directory'
+ ? node.children || []
+ : getNodesInFolder(targetFolder)
+ const hasCollision = targetChildren.some(
+ (child) => child.name === sourceName
+ )
+
+ if (hasCollision) {
+ print.warn(
+ 'Cannot move: item with same name already exists in target folder'
+ )
+ return
+ }
+
+ await onMove(sourcePath, targetFolder, isDirectory)
+ } catch (error) {
+ print.error('Failed to parse drag data:', error)
+ }
+ }
+
+ return (
+
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ draggable={!isRenaming}
+ onDragStart={handleDragStart}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ onDragEnd={handleDragEnd}
+ >
+ {node.type === 'directory' ? (
+ <>
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ {isRenaming ? (
+
setRenameValue(e.target.value)}
+ onBlur={handleRename}
+ onKeyDown={handleRenameKeyDown}
+ className={`h-5 px-1 py-0 text-sm flex-1 rounded-xs border-none focus-visible:ring-0 ${isRenameInvalid ? 'opacity-50' : ''}`}
+ autoFocus
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+
{node.name}
+ )}
+
+ {isHovered && !isRenaming && (
+
+ {onRename && (
+
+ )}
+ {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/components/mobile-view.tsx b/apps/blog/src/app/(public)/editor/components/mobile-view.tsx
new file mode 100644
index 0000000..750b19b
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/mobile-view.tsx
@@ -0,0 +1,110 @@
+'use client'
+
+import { Button } from '@shadcn/ui'
+import { Monitor, Smartphone } from 'lucide-react'
+
+interface MobileViewProps {
+ projectName?: string
+ onBack?: () => void
+}
+
+export function MobileView({ projectName, onBack }: MobileViewProps) {
+ return (
+
+
+ {/* Icon */}
+
+
+ {/* Title */}
+
+
+ Desktop Experience Required
+
+
+ The code playground is optimized for desktop screens. Please switch
+ to a larger device for the best experience.
+
+
+
+ {/* Project info if available */}
+ {projectName && (
+
+
+
+
Project loaded:
+
+ {projectName}
+
+
+
+ )}
+
+ {/* Feature list */}
+
+
+ Why Desktop?
+
+
+
+
+
+ Full-featured Monaco code editor with syntax highlighting
+
+
+
+
+
+ Multiple resizable panels for efficient workflow
+
+
+
+
+
+ Integrated terminal with full keyboard support
+
+
+
+
+
+ Live preview with WebContainer technology
+
+
+
+
+
+ {/* Recommended screen size */}
+
+
+ Recommended: 1280px width or larger
+
+
+ {/* Back button if available */}
+ {onBack && (
+
+ )}
+
+ {/* Device indicator */}
+
+
+
+ Currently on mobile device
+
+
+
+
+ )
+}
diff --git a/apps/blog/src/app/(public)/editor/components/preview.tsx b/apps/blog/src/app/(public)/editor/components/preview.tsx
new file mode 100644
index 0000000..8bb0dce
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/preview.tsx
@@ -0,0 +1,209 @@
+'use client'
+
+import { Button, Input } from '@shadcn/ui'
+import {
+ ArrowLeft,
+ ArrowRight,
+ Fullscreen,
+ Loader2,
+ Minimize,
+ 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)
+ const [isPreviewFullscreen, setIsPreviewFullscreen] = useState(false)
+
+ 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])
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && isPreviewFullscreen) {
+ setIsPreviewFullscreen(false)
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [isPreviewFullscreen])
+
+ 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) {
+ // eslint-disable-next-line no-self-assign -- used to force iframe reload
+ 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 (
+
+ )
+ }
+
+ const previewContent = (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+
+ if (isPreviewFullscreen) {
+ return (
+
+ {previewContent}
+
+ )
+ }
+
+ return (
+
+ {previewContent}
+
+ )
+}
diff --git a/apps/blog/src/app/(public)/editor/components/project-selector.tsx b/apps/blog/src/app/(public)/editor/components/project-selector.tsx
new file mode 100644
index 0000000..5673b96
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/project-selector.tsx
@@ -0,0 +1,197 @@
+'use client'
+
+import {
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@shadcn/ui'
+import { ChevronDown, FolderOpen, Search, X } from 'lucide-react'
+import { useMemo, useState } from 'react'
+
+import type { Project } from '../services/types'
+
+interface ProjectSelectorProps {
+ projects: Project[]
+ onSelectProject: (project: Project) => void
+}
+
+export function ProjectSelector({
+ projects,
+ onSelectProject,
+}: ProjectSelectorProps) {
+ const [searchQuery, setSearchQuery] = useState('')
+
+ const filteredProjects = useMemo(() => {
+ if (!searchQuery.trim()) return projects
+
+ const query = searchQuery.toLowerCase()
+ return projects.filter(
+ (project) =>
+ project.name.toLowerCase().includes(query) ||
+ project.description.toLowerCase().includes(query)
+ )
+ }, [projects, searchQuery])
+
+ return (
+ <>
+ {/* Header */}
+
+
+ Select a Project
+
+
+ Choose a project to start coding in your browser
+
+
+
+ {/* Search bar */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full h-11 pl-10 pr-10 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 transition-shadow"
+ autoFocus
+ />
+ {searchQuery && (
+
+ )}
+
+
+
+ {/* Project list */}
+
+ {filteredProjects.length === 0 ? (
+
+
+ No projects found matching "{searchQuery}"
+
+
+ ) : (
+
+ {filteredProjects.map((project, index) => (
+
+ ))}
+
+ )}
+
+
+ {/* Footer hint */}
+
+
+
+
+
+ ↑
+
+
+ ↓
+
+ to navigate
+
+
+
+ ⏎
+
+ to select
+
+
+
{filteredProjects.length} projects
+
+
+ >
+ )
+}
+
+interface ProjectSelectorDropdownProps {
+ projectName: string
+ projects?: Project[]
+ currentProjectId?: string
+ onBack: () => void
+ onProjectChange?: (project: Project) => void
+}
+
+export function ProjectSelectorDropdown({
+ projectName,
+ projects = [],
+ currentProjectId,
+ onBack,
+ onProjectChange,
+}: ProjectSelectorDropdownProps) {
+ return (
+ projects.length > 0 &&
+ onProjectChange && (
+
+
+
+
+
+
+ Switch Project
+
+
+ {projects.map((project) => (
+ onProjectChange(project)}
+ className="gap-2"
+ disabled={project.id === currentProjectId}
+ >
+
+ {project.name}
+ {project.id === currentProjectId && (
+ ✓
+ )}
+
+ ))}
+
+
+ ←
+ Back to All Projects
+
+
+
+ )
+ )
+}
diff --git a/apps/blog/src/app/(public)/editor/components/terminal.tsx b/apps/blog/src/app/(public)/editor/components/terminal.tsx
new file mode 100644
index 0000000..66d13ea
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/terminal.tsx
@@ -0,0 +1,114 @@
+'use client'
+
+import { FitAddon } from '@xterm/addon-fit'
+import { Terminal as XTerm } from '@xterm/xterm'
+import { useTheme } from 'next-themes'
+import { useEffect, useRef } from 'react'
+
+import '@xterm/xterm/css/xterm.css'
+
+import { xtermDarkTheme, xtermLightTheme } from '../services/themes'
+
+interface TerminalProps {
+ onReady: (terminal: XTerm, fitAddon: FitAddon) => void
+}
+
+export function Terminal({ onReady }: TerminalProps) {
+ const terminalRef = useRef(null)
+ const xtermRef = useRef(null)
+ const fitAddonRef = useRef(null)
+ const onReadyCalledRef = useRef(false)
+ const { resolvedTheme } = useTheme()
+
+ useEffect(() => {
+ if (xtermRef.current) {
+ const theme = resolvedTheme === 'dark' ? xtermDarkTheme : xtermLightTheme
+ xtermRef.current.options.theme = theme
+ }
+ }, [resolvedTheme])
+
+ useEffect(() => {
+ if (!terminalRef.current || xtermRef.current) return
+
+ const initTerminal = () => {
+ if (!terminalRef.current) return
+
+ const theme = resolvedTheme === 'dark' ? xtermDarkTheme : xtermLightTheme
+
+ const xterm = new XTerm({
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: 'Geist Mono, monospace',
+ theme,
+ convertEol: true,
+ })
+
+ const fitAddon = new FitAddon()
+ xterm.loadAddon(fitAddon)
+
+ xterm.open(terminalRef.current)
+
+ requestAnimationFrame(() => {
+ try {
+ fitAddon.fit()
+ } catch (error) {
+ console.error('Error fitting terminal:', error)
+ setTimeout(() => {
+ try {
+ fitAddon.fit()
+ } catch (retryError) {
+ console.error('Error fitting terminal on retry:', retryError)
+ }
+ }, 100)
+ }
+ })
+
+ xtermRef.current = xterm
+ fitAddonRef.current = fitAddon
+
+ if (!onReadyCalledRef.current) {
+ onReadyCalledRef.current = true
+ onReady(xterm, fitAddon)
+ }
+ }
+
+ const timeoutId = setTimeout(initTerminal, 50)
+
+ const handleResize = () => {
+ if (fitAddonRef.current) {
+ try {
+ fitAddonRef.current.fit()
+ } catch (error) {
+ console.error('Error fitting terminal on resize:', error)
+ }
+ }
+ }
+
+ let resizeObserver: ResizeObserver | null = null
+
+ if (terminalRef.current) {
+ resizeObserver = new ResizeObserver(() => {
+ handleResize()
+ })
+ resizeObserver.observe(terminalRef.current)
+ }
+
+ window.addEventListener('resize', handleResize)
+
+ return () => {
+ clearTimeout(timeoutId)
+ window.removeEventListener('resize', handleResize)
+ if (resizeObserver) {
+ resizeObserver.disconnect()
+ }
+ if (xtermRef.current) {
+ xtermRef.current.dispose()
+ xtermRef.current = null
+ }
+ }
+ // Empty dependency array ensures terminal will only init once - DO NOT CHANGE THIS
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ return
+}
diff --git a/apps/blog/src/app/(public)/editor/components/theme-toggle.tsx b/apps/blog/src/app/(public)/editor/components/theme-toggle.tsx
new file mode 100644
index 0000000..ca766a7
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/theme-toggle.tsx
@@ -0,0 +1,39 @@
+'use client'
+
+import {
+ Button,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@shadcn/ui'
+import { Moon, Sun } from 'lucide-react'
+import { useTheme } from 'next-themes'
+
+export function ModeToggle({ compact = false }: { compact?: boolean }) {
+ const { setTheme, theme } = useTheme()
+
+ return (
+
+
+
+
+
+ Toggle theme
+
+
+ )
+}
diff --git a/apps/blog/src/app/(public)/editor/components/toolbar.tsx b/apps/blog/src/app/(public)/editor/components/toolbar.tsx
new file mode 100644
index 0000000..7f658ad
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/components/toolbar.tsx
@@ -0,0 +1,312 @@
+'use client'
+
+import {
+ Button,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@shadcn/ui'
+import {
+ Code2,
+ Copy,
+ Download,
+ Folders,
+ Maximize2,
+ Minimize2,
+ Monitor,
+ Package,
+ PanelLeft,
+ Play,
+ RotateCcw,
+ Save,
+ TerminalIcon,
+} from 'lucide-react'
+import { useEffect, useState } from 'react'
+
+import { usePanelVisible } from '../atoms/panels'
+
+import { ModeToggle } from './theme-toggle'
+
+interface ToolbarProps {
+ onExportZip: () => void
+ onSave: () => void
+ onReset: () => void
+ onCopy: () => void
+ onInstall?: () => void
+ isInstalling?: boolean
+ onToggleFullscreen?: () => void
+ isFullscreen: boolean
+ onRunCommand?: () => void
+ isRunning: boolean
+ onOpenProjectSelector?: () => void
+}
+
+export function Toolbar({
+ onExportZip,
+ onSave,
+ onReset,
+ onCopy,
+ onInstall,
+ isInstalling = false,
+ isFullscreen = true,
+ onToggleFullscreen,
+ onRunCommand,
+ isRunning = false,
+ onOpenProjectSelector,
+}: ToolbarProps) {
+ const [mounted, setMounted] = useState(false)
+ const [
+ isPanelVisible,
+ { toggleEditor, toggleExplorer, togglePreview, toggleTerminal },
+ ] = usePanelVisible()
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ return (
+
+ {/* Brand */}
+
+
+ MT
+
+
+ Moa Torres | Editor
+
+
+
+ {/* Project Selector Button */}
+ {onOpenProjectSelector && (
+
+
+
+
+ Open Project Selector
+
+ )}
+
+
+
+ {/* Action buttons grouped */}
+
+
+ {/* Run button */}
+ {onRunCommand && (
+
+
+
+
+ Run dev server (npm run dev)
+
+ )}
+
+ {/* View controls and project actions group */}
+
+ {/* Panel toggle buttons */}
+
+
+
+
+
+ {isPanelVisible('explorer') ? 'Hide Explorer' : 'Show Explorer'}
+
+
+
+
+
+
+
+
+ {isPanelVisible('editor') ? 'Hide Editor' : 'Show Editor'}
+
+
+
+
+
+
+
+
+ {isPanelVisible('preview') ? 'Hide Preview' : 'Show Preview'}
+
+
+
+
+
+
+
+
+ {isPanelVisible('terminal') ? 'Hide Terminal' : 'Show Terminal'}
+
+
+
+ {/* Divider */}
+
+
+ {/* Fullscreen toggle */}
+ {onToggleFullscreen && (
+
+
+
+
+
+ {isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
+
+
+ )}
+
+ {/* Theme toggle */}
+ {mounted &&
}
+
+ {/* Divider */}
+
+
+ {/* Save to browser */}
+
+
+
+
+ Save to Browser
+
+
+ {/* Reset original project */}
+
+
+
+
+ Reset Project
+
+
+ {/* Export as ZIP */}
+
+
+
+
+ Export as ZIP
+
+
+ {/* Copy all files */}
+
+
+
+
+ Copy All Files
+
+
+
+ {/* Install button if available */}
+ {onInstall && (
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/blog/src/app/(public)/editor/contexts/webcontainer-context.tsx b/apps/blog/src/app/(public)/editor/contexts/webcontainer-context.tsx
new file mode 100644
index 0000000..b123ae9
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/contexts/webcontainer-context.tsx
@@ -0,0 +1,507 @@
+'use client'
+
+import { useMonaco } from '@monaco-editor/react'
+import type { WebContainer } from '@webcontainer/api'
+import type { FitAddon } from '@xterm/addon-fit'
+import type { Terminal as XTerm } from '@xterm/xterm'
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+import { toast } from 'sonner'
+
+import {
+ clearFileSystem,
+ getWebContainerInstance,
+ readAllFiles,
+ watchFileSystem,
+} from '../services/webcontainer'
+
+interface WebContainerContextType {
+ // WebContainer instance and refs
+ webcontainer: WebContainer | null
+ terminal: XTerm | null
+ shellWriter: WritableStreamDefaultWriter | null
+
+ // State
+ isInstalling: boolean
+ previewUrl: string | null
+ setPreviewUrl: (url: string | null) => void
+
+ // Methods
+ initializeWebContainer: () => Promise
+ initializeTerminal: (
+ xterm: XTerm,
+ fitAddon: FitAddon,
+ onReady?: () => void
+ ) => Promise
+ writeFile: (path: string, content: string) => Promise
+ createFile: (path: string) => Promise
+ createFolder: (path: string) => Promise
+ deleteItem: (path: string, isDirectory: boolean) => Promise
+ renameItem: (
+ oldPath: string,
+ newPath: string,
+ isDirectory: boolean
+ ) => Promise
+ moveItem: (
+ sourcePath: string,
+ targetPath: string,
+ isDirectory: boolean
+ ) => Promise
+ readFiles: () => Promise<{
+ files: Record
+ directories: string[]
+ }>
+ setupFileWatcher: (
+ onChange: (data: {
+ files: Record
+ directories: string[]
+ }) => void,
+ options?: { ignoreIf?: () => boolean }
+ ) => () => void
+ runCommand: (command: string) => Promise
+ clearContainer: () => Promise
+}
+
+const WebContainerContext = createContext(null)
+
+const WEBCONTAINER_BIN_PATH = 'node_modules/.bin:/usr/local/bin:/usr/bin:/bin'
+
+export function WebContainerProvider({ children }: { children: ReactNode }) {
+ const webcontainerRef = useRef(null)
+ const terminalRef = useRef(null)
+ const shellWriterRef = useRef(null)
+ const terminalReadyRef = useRef(false)
+ const isInstallingRef = useRef(false)
+ const monaco = useMonaco()
+
+ const [webcontainer, setWebcontainer] = useState(null)
+ const [isInstalling, setIsInstalling] = useState(false)
+ const [previewUrl, setPreviewUrlState] = useState(null)
+
+ const setPreviewUrl = useCallback((url: string | null) => {
+ setPreviewUrlState(url)
+ }, [])
+
+ // Initialize WebContainer
+ const initializeWebContainer =
+ useCallback(async (): Promise => {
+ try {
+ const instance = await getWebContainerInstance()
+
+ webcontainerRef.current = instance
+
+ setWebcontainer(instance)
+
+ // Listen for server-ready events
+ instance.on('server-ready', (port, url) => {
+ setPreviewUrl(url)
+ toast('Server Ready', {
+ description: `Preview available on port ${port}`,
+ duration: 3000,
+ })
+ })
+
+ // Clear file system
+ await clearFileSystem(instance)
+
+ return instance
+ } catch (error) {
+ console.error('WebContainer initialization error:', error)
+ throw error
+ }
+ }, [setPreviewUrl])
+
+ const initializeTerminal = useCallback(
+ async (xterm: XTerm, fitAddon: FitAddon, onReady?: () => void) => {
+ if (terminalReadyRef.current && terminalRef.current === xterm) {
+ return
+ }
+
+ terminalReadyRef.current = true
+ terminalRef.current = xterm
+
+ if (!webcontainerRef.current) return
+
+ xterm.writeln('\x1b[1;34m[moatorres.co] Terminal\x1b[0m\n')
+ xterm.writeln('Initializing...\n')
+
+ setIsInstalling(true)
+
+ try {
+ const installProcess = await webcontainerRef.current.spawn('pnpm', [
+ 'install',
+ ])
+
+ installProcess.output.pipeTo(
+ new WritableStream({
+ write(data) {
+ xterm.write(data)
+ },
+ })
+ )
+
+ const exitCode = await installProcess.exit
+
+ if (exitCode === 0) {
+ xterm.writeln(
+ '\n\x1b[1;32m✓ Dependencies installed successfully\x1b[0m\n'
+ )
+ toast('Initialized', {
+ description: 'Dependencies installed successfully',
+ })
+ } else {
+ xterm.writeln('\n\x1b[1;31m✗ Installation failed\x1b[0m\n')
+ }
+
+ // Start 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)
+ })
+ } catch (error) {
+ xterm.writeln('\x1b[1;31mError initializing terminal\x1b[0m')
+ console.error('Terminal error:', error)
+ } finally {
+ setIsInstalling(false)
+
+ // Callback when ready
+ onReady?.()
+ }
+ },
+ [monaco]
+ )
+
+ // File operations
+ const writeFile = useCallback(async (path: string, content: string) => {
+ if (!webcontainerRef.current) {
+ throw new Error('WebContainer not initialized')
+ }
+
+ try {
+ await webcontainerRef.current.fs.writeFile(path, content)
+ } catch (error) {
+ console.error('Error writing file:', error)
+ throw error
+ }
+ }, [])
+
+ const createFile = useCallback(async (path: string) => {
+ if (!webcontainerRef.current) {
+ throw new Error('WebContainer not initialized')
+ }
+
+ try {
+ const parts = path.split('/')
+
+ if (parts.length > 1) {
+ const dirPath = parts.slice(0, -1).join('/')
+ await webcontainerRef.current.fs.mkdir(dirPath, { recursive: true })
+ }
+
+ await webcontainerRef.current.fs.writeFile(path, '')
+
+ toast('File Created', {
+ description: `Created ${path}`,
+ })
+ } catch (error) {
+ console.error('Error creating file:', error)
+ toast.error('Error', {
+ description: 'Failed to create file',
+ })
+ throw error
+ }
+ }, [])
+
+ const createFolder = useCallback(async (path: string) => {
+ if (!webcontainerRef.current) {
+ throw new Error('WebContainer not initialized')
+ }
+
+ try {
+ await webcontainerRef.current.fs.mkdir(path, { recursive: true })
+
+ toast('Folder Created', {
+ description: `Created ${path}`,
+ })
+ } catch (error) {
+ console.error('Error creating folder:', error)
+ toast.error('Error', {
+ description: 'Failed to create folder',
+ })
+ throw error
+ }
+ }, [])
+
+ const deleteItem = useCallback(async (path: string, isDirectory: boolean) => {
+ if (!webcontainerRef.current) {
+ throw new Error('WebContainer not initialized')
+ }
+
+ try {
+ if (isDirectory) {
+ await webcontainerRef.current.fs.rm(path, {
+ recursive: true,
+ force: true,
+ })
+ } else {
+ await webcontainerRef.current.fs.rm(path)
+ }
+
+ toast(isDirectory ? 'Folder Deleted' : 'File Deleted', {
+ description: `Deleted ${path}`,
+ })
+ } catch (error) {
+ console.error('Error deleting:', error)
+ toast.error('Error', {
+ description: `Failed to delete ${isDirectory ? 'folder' : 'file'}`,
+ })
+ throw error
+ }
+ }, [])
+
+ const renameItem = useCallback(
+ async (oldPath: string, newPath: string, isDirectory: boolean) => {
+ if (!webcontainerRef.current) {
+ throw new Error('WebContainer not initialized')
+ }
+
+ try {
+ if (isDirectory) {
+ const files = await readAllFiles(webcontainerRef.current)
+ const filesToMove = Object.keys(files.files).filter((f) =>
+ f.startsWith(oldPath + '/')
+ )
+
+ await webcontainerRef.current.fs.mkdir(newPath, { recursive: true })
+
+ for (const file of filesToMove) {
+ const newFilePath = file.replace(oldPath, newPath)
+ const content = await webcontainerRef.current.fs.readFile(
+ file,
+ 'utf-8'
+ )
+ await webcontainerRef.current.fs.writeFile(newFilePath, content)
+ }
+
+ await webcontainerRef.current.fs.rm(oldPath, {
+ recursive: true,
+ force: true,
+ })
+ } else {
+ const content = await webcontainerRef.current.fs.readFile(
+ oldPath,
+ 'utf-8'
+ )
+ const newDir = newPath.split('/').slice(0, -1).join('/')
+ if (newDir) {
+ await webcontainerRef.current.fs.mkdir(newDir, { recursive: true })
+ }
+ await webcontainerRef.current.fs.writeFile(newPath, content)
+ await webcontainerRef.current.fs.rm(oldPath)
+ }
+
+ toast(isDirectory ? 'Folder Renamed' : 'File Renamed', {
+ description: `Renamed to ${newPath.split('/').pop()}`,
+ })
+ } catch (error) {
+ console.error('Error renaming:', error)
+ toast.error('Error', {
+ description: `Failed to rename ${isDirectory ? 'folder' : 'file'}`,
+ })
+ throw error
+ }
+ },
+ []
+ )
+
+ const moveItem = useCallback(
+ async (sourcePath: string, targetPath: string, isDirectory: boolean) => {
+ if (!webcontainerRef.current) {
+ throw new Error('WebContainer not initialized')
+ }
+
+ try {
+ const fileName = sourcePath.split('/').pop()
+ const newPath = targetPath ? `${targetPath}/${fileName}` : fileName!
+
+ if (isDirectory) {
+ const files = await readAllFiles(webcontainerRef.current)
+ const filesToMove = Object.keys(files.files).filter((f) =>
+ f.startsWith(sourcePath + '/')
+ )
+
+ await webcontainerRef.current.fs.mkdir(newPath, { recursive: true })
+
+ for (const file of filesToMove) {
+ const relPath = file.substring(sourcePath.length + 1)
+ const newFilePath = `${newPath}/${relPath}`
+ const content = await webcontainerRef.current.fs.readFile(
+ file,
+ 'utf-8'
+ )
+ const newFileDir = newFilePath.split('/').slice(0, -1).join('/')
+ if (newFileDir) {
+ await webcontainerRef.current.fs.mkdir(newFileDir, {
+ recursive: true,
+ })
+ }
+ await webcontainerRef.current.fs.writeFile(newFilePath, content)
+ }
+
+ await webcontainerRef.current.fs.rm(sourcePath, {
+ recursive: true,
+ force: true,
+ })
+ } else {
+ const content = await webcontainerRef.current.fs.readFile(
+ sourcePath,
+ 'utf-8'
+ )
+ await webcontainerRef.current.fs.writeFile(newPath, content)
+ await webcontainerRef.current.fs.rm(sourcePath)
+ }
+
+ const destination = targetPath ? targetPath : 'root'
+ toast(isDirectory ? 'Folder Moved' : 'File Moved', {
+ description: `Moved to ${destination}`,
+ })
+ } catch (error) {
+ console.error('Error moving:', error)
+ toast.error('Error', {
+ description: `Failed to move ${isDirectory ? 'folder' : 'file'}`,
+ })
+ throw error
+ }
+ },
+ []
+ )
+
+ const readFiles = useCallback(async () => {
+ if (!webcontainerRef.current) {
+ throw new Error('WebContainer not initialized')
+ }
+
+ return await readAllFiles(webcontainerRef.current)
+ }, [])
+
+ const setupFileWatcher = useCallback(
+ (
+ onChange: (data: {
+ files: Record
+ directories: string[]
+ }) => void,
+ options?: { ignoreIf?: () => boolean }
+ ) => {
+ if (!webcontainerRef.current) {
+ console.error('WebContainer not initialized')
+ return () => {
+ // noop
+ }
+ }
+
+ const cleanup = watchFileSystem(
+ webcontainerRef.current,
+ onChange,
+ options
+ )
+
+ return cleanup
+ },
+ []
+ )
+
+ const runCommand = useCallback(async (command: string) => {
+ if (!shellWriterRef.current) {
+ throw new Error('Terminal shell not initialized')
+ }
+
+ try {
+ shellWriterRef.current.write(`${command}\n`)
+ } catch (error) {
+ console.error('Error running command:', error)
+ throw error
+ }
+ }, [])
+
+ const clearContainer = useCallback(async () => {
+ if (!webcontainerRef.current) return
+
+ terminalReadyRef.current = false
+ terminalRef.current = null
+ shellWriterRef.current = null
+
+ await clearFileSystem(webcontainerRef.current)
+ }, [])
+
+ // Sync isInstalling ref with state
+ useEffect(() => {
+ isInstallingRef.current = isInstalling
+ }, [isInstalling])
+
+ const value: WebContainerContextType = {
+ webcontainer,
+ terminal: terminalRef.current,
+ shellWriter: shellWriterRef.current,
+ isInstalling,
+ previewUrl,
+ setPreviewUrl,
+ initializeWebContainer,
+ initializeTerminal,
+ writeFile,
+ createFile,
+ createFolder,
+ deleteItem,
+ renameItem,
+ moveItem,
+ readFiles,
+ setupFileWatcher,
+ runCommand,
+ clearContainer,
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useWebContainer() {
+ const context = useContext(WebContainerContext)
+
+ if (!context) {
+ throw new Error(
+ 'useWebContainer must be used within a WebContainerProvider'
+ )
+ }
+
+ return context
+}
diff --git a/apps/blog/src/app/(public)/editor/hooks/use-debounce.ts b/apps/blog/src/app/(public)/editor/hooks/use-debounce.ts
new file mode 100644
index 0000000..31be76b
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/hooks/use-debounce.ts
@@ -0,0 +1,44 @@
+import { useCallback, useEffect, useRef } from 'react'
+
+/**
+ * Custom hook to debounce a function call.
+ * @param callback The function to debounce.
+ * @param delay The delay in milliseconds.
+ * @returns A debounced version of the callback function.
+ */
+export function useDebounce any>(
+ callback: T,
+ delay: number
+): T {
+ const timeoutRef = useRef | null>(null)
+ const callbackRef = useRef(callback)
+
+ // Update callbackRef whenever the callback changes
+ useEffect(() => {
+ callbackRef.current = callback
+ }, [callback])
+
+ // The debounced function
+ const debouncedCallback = useCallback(
+ (...args: Parameters) => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ timeoutRef.current = setTimeout(() => {
+ callbackRef.current(...args)
+ }, delay)
+ },
+ [delay]
+ ) as T
+
+ // Cleanup function
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [])
+
+ return debouncedCallback
+}
diff --git a/apps/blog/src/app/(public)/editor/hooks/use-type-loader.ts b/apps/blog/src/app/(public)/editor/hooks/use-type-loader.ts
new file mode 100644
index 0000000..c5b591b
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/hooks/use-type-loader.ts
@@ -0,0 +1,243 @@
+import { Monaco } from '@monaco-editor/react'
+import type { WebContainer } from '@webcontainer/api'
+import { useCallback, useEffect, useRef } from 'react'
+
+import { useDebounce } from './use-debounce'
+
+const DEBOUNCE_MS = 100
+
+const loadedTypeHashes = new Map()
+
+const monacoTypeDisposables = new Map void }>()
+
+function clearLoadedTypeDefinitions() {
+ for (const [, d] of monacoTypeDisposables) {
+ try {
+ d.dispose()
+ } catch (error) {
+ console.error('Error disposing library', error)
+ }
+ }
+ monacoTypeDisposables.clear()
+}
+
+const DEP_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']
+
+export function useTypeLoader(
+ monaco: Monaco | null,
+ wc: WebContainer | null,
+ isInstalling: boolean
+) {
+ const isLoadingRef = useRef(false)
+ const pendingRef = useRef(false)
+ const ignoreInstallRef = useRef(isInstalling)
+
+ const scheduleLoad = useCallback(async () => {
+ if (!monaco || !wc) return
+ if (isLoadingRef.current) {
+ pendingRef.current = true
+ return
+ }
+
+ isLoadingRef.current = true
+ try {
+ await loadTypeDefinitions(monaco, wc)
+ } finally {
+ isLoadingRef.current = false
+ if (pendingRef.current) {
+ pendingRef.current = false
+ setTimeout(() => scheduleLoad(), DEBOUNCE_MS)
+ }
+ }
+ }, [monaco, wc])
+
+ const reloadTypes = useCallback(async () => {
+ if (!monaco || !wc) return
+ if (isLoadingRef.current) return
+ isLoadingRef.current = true
+
+ try {
+ console.log('[TypeLoader] Dependencies changed, reloading types.')
+ await loadTypeDefinitions(monaco, wc)
+ } finally {
+ isLoadingRef.current = false
+ if (pendingRef.current) {
+ pendingRef.current = false
+ setTimeout(() => scheduleLoad(), 250)
+ }
+ }
+ }, [monaco, wc, scheduleLoad])
+
+ const reloadTypeDefintions = useDebounce(reloadTypes, DEBOUNCE_MS)
+
+ useEffect(() => {
+ ignoreInstallRef.current = isInstalling
+ }, [isInstalling])
+
+ useEffect(() => {
+ if (!wc || !monaco) return
+ const decoder = new TextDecoder()
+
+ const refreshOpenModel = async (name: string) => {
+ try {
+ const models = monaco.editor.getModels()
+ await Promise.all(
+ models.map(async (model) => {
+ const path = model.uri.path.replace(/^\//, '')
+ if (name.includes('package.json') && path.endsWith(name)) {
+ const content = await wc.fs.readFile(path, 'utf-8')
+ if (model.getValue() !== content) {
+ model.setValue(content)
+ console.log(`[TypeLoader] Refreshed content of ${path}`)
+ }
+ }
+ })
+ )
+ } catch (err) {
+ console.warn('[TypeLoader] Model refresh failed:', err)
+ }
+ }
+
+ const onChange = (
+ _type: string,
+ filename: string | Uint8Array | null
+ ) => {
+ if (!filename) return
+ const name =
+ typeof filename === 'string' ? filename : decoder.decode(filename)
+ refreshOpenModel(name)
+
+ if (!DEP_FILES.some((f) => name.endsWith(f))) return
+
+ console.debug(`[TypeLoader] File ${name} changed`)
+
+ reloadTypeDefintions()
+ }
+
+ const watcher = wc.fs.watch('/', { recursive: true }, onChange)
+
+ return () => {
+ watcher.close()
+ }
+ }, [wc, monaco, reloadTypeDefintions])
+
+ // Dispose libraries on unmount
+ useEffect(() => {
+ return () => {
+ clearLoadedTypeDefinitions()
+ }
+ }, [])
+}
+
+/**
+ * Load .d.ts files into Monaco in a memory-safe, batched way.
+ */
+export async function loadTypeDefinitions(
+ monaco: Monaco,
+ webcontainer: WebContainer,
+ basePath = '/node_modules/.pnpm',
+ batchSize = 50,
+ delayMs = 10
+) {
+ console.log('[TypeLoader] Scanning for .d.ts files...')
+ const fileQueue: { path: string; virtualPath: string }[] = []
+
+ async function scanDir(dir: string) {
+ let entries
+ try {
+ entries = await webcontainer.fs.readdir(dir, { withFileTypes: true })
+ } catch {
+ return
+ }
+
+ for (const entry of entries) {
+ const fullPath = `${dir}/${entry.name}`
+ if (entry.isDirectory()) {
+ await scanDir(fullPath)
+ } else if (
+ entry.name.endsWith('.d.ts') ||
+ entry.name === 'package.json'
+ ) {
+ const normalized = fullPath.replace(
+ /.*\/node_modules\//,
+ '/node_modules/'
+ )
+ const virtualPath = `file://${normalized}`
+ fileQueue.push({ path: fullPath, virtualPath })
+ }
+ }
+ }
+
+ await scanDir(basePath)
+ console.log(`[TypeLoader] Queued ${fileQueue.length} types`)
+
+ const newTypeHashes = new Map()
+
+ let reused = 0
+
+ for (let i = 0; i < fileQueue.length; i += batchSize) {
+ const batch = fileQueue.slice(i, i + batchSize)
+ await Promise.all(
+ batch.map(async ({ path, virtualPath }) => {
+ try {
+ const content = await webcontainer.fs.readFile(path, 'utf-8')
+ const hashBuffer = await crypto.subtle.digest(
+ 'SHA-1',
+ new TextEncoder().encode(content)
+ )
+ const hashHex = Array.from(new Uint8Array(hashBuffer))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')
+
+ newTypeHashes.set(virtualPath, hashHex)
+
+ // unchanged → skip
+ if (loadedTypeHashes.get(virtualPath) === hashHex) {
+ reused++
+ return
+ }
+
+ const existing = monacoTypeDisposables.get(virtualPath)
+
+ if (existing) existing.dispose()
+
+ const d1 = monaco.languages.typescript.typescriptDefaults.addExtraLib(
+ content,
+ virtualPath
+ )
+ const d2 = monaco.languages.typescript.javascriptDefaults.addExtraLib(
+ content,
+ virtualPath
+ )
+
+ monacoTypeDisposables.set(virtualPath, {
+ dispose: () => {
+ d1.dispose()
+ d2.dispose()
+ },
+ })
+ } catch (e) {
+ console.warn(`[TypeLoader] Failed to read or add ${path}:`, e)
+ }
+ })
+ )
+
+ if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs))
+ }
+
+ for (const [vpath, dlib] of monacoTypeDisposables.entries()) {
+ if (!newTypeHashes.has(vpath)) {
+ dlib.dispose()
+ monacoTypeDisposables.delete(vpath)
+ loadedTypeHashes.delete(vpath)
+ }
+ }
+
+ loadedTypeHashes.clear()
+
+ for (const [k, v] of newTypeHashes.entries()) loadedTypeHashes.set(k, v)
+
+ console.log(
+ `[TypeLoader] Reused ${reused} of ${loadedTypeHashes.size} project types.`
+ )
+}
diff --git a/apps/blog/src/app/(public)/editor/layout.tsx b/apps/blog/src/app/(public)/editor/layout.tsx
new file mode 100644
index 0000000..5b8ffe4
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/layout.tsx
@@ -0,0 +1,14 @@
+import { Provider as AtomsProvider } from 'jotai'
+import { ViewTransition } from 'react'
+
+import { WebContainerProvider } from './contexts/webcontainer-context'
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
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..dfa3609
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/page.tsx
@@ -0,0 +1,746 @@
+'use client'
+
+import type { Monaco } from '@monaco-editor/react'
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ Button,
+ useMobile,
+} from '@shadcn/ui'
+import type { FitAddon } from '@xterm/addon-fit'
+import type { Terminal as XTerm } from '@xterm/xterm'
+import { FolderDown, Loader, 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 { useCurrentFile } from './atoms/current-file'
+import { usePanelVisible } from './atoms/panels'
+import { CodeEditor } from './components/code-editor'
+import { FileTree } from './components/file-tree'
+import { MobileView } from './components/mobile-view'
+import { Preview } from './components/preview'
+import { ProjectSelector } from './components/project-selector'
+import { Toolbar } from './components/toolbar'
+import { useWebContainer } from './contexts/webcontainer-context'
+import { useTypeLoader } from './hooks/use-type-loader'
+import { exampleProjects } from './services/projects'
+import type { FileNode, Project } from './services/types'
+import {
+ buildFileTree,
+ clearFromLocalStorage,
+ copyProjectToClipboard,
+ exportAsZip,
+ getLanguageFromPath,
+ loadFromLocalStorage,
+ saveToLocalStorage,
+} from './services/utils'
+import { convertFilesToFileTree } from './services/webcontainer'
+
+const Terminal = dynamic(
+ () =>
+ import('./components/terminal').then((mod) => ({ default: mod.Terminal })),
+ {
+ ssr: false,
+ loading: () => (
+
+
+ Loading terminal...
+
+ ),
+ }
+)
+
+export default function PlaygroundPage() {
+ const {
+ webcontainer,
+ previewUrl,
+ setPreviewUrl,
+ isInstalling,
+ initializeWebContainer,
+ initializeTerminal,
+ writeFile,
+ createFile,
+ createFolder,
+ deleteItem,
+ renameItem,
+ moveItem,
+ readFiles,
+ setupFileWatcher,
+ runCommand,
+ } = useWebContainer()
+
+ const [selectedProject, setSelectedProject] = useState(null)
+ const [fileTree, setFileTree] = useState([])
+ const currentFile = useCurrentFile()
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [isRunning, setIsRunning] = useState(false)
+ const [isFullscreen, setIsFullscreen] = useState(true)
+ const [showProjectSelector, setShowProjectSelector] = useState(false)
+ const [isPanelVisible, { toggleEditor }] = usePanelVisible()
+
+ const monacoRef = useRef(null)
+ const fileWatcherRef = useRef<(() => void) | null>(null)
+
+ const isMobile = useMobile()
+
+ useTypeLoader(monacoRef.current, webcontainer, isInstalling)
+
+ const syncFilesFromWebContainer = useCallback(async () => {
+ if (!webcontainer || !selectedProject) return
+ try {
+ const { files, directories } = await readFiles()
+ setSelectedProject((prev) => {
+ if (!prev) return prev
+ return { ...prev, files }
+ })
+
+ const tree = buildFileTree(files, directories)
+ setFileTree(tree)
+
+ if (currentFile.path && files[currentFile.path]) {
+ currentFile.setContent(files[currentFile.path])
+ }
+ } catch (error) {
+ console.error('Error syncing files:', error)
+ }
+ }, [selectedProject, currentFile, webcontainer, readFiles])
+
+ const setupFileWatching = useCallback(() => {
+ if (!webcontainer) return
+
+ fileWatcherRef.current = setupFileWatcher(
+ ({ files, directories }) => {
+ setSelectedProject((prev) => {
+ if (!prev) return prev
+ return { ...prev, files }
+ })
+
+ const tree = buildFileTree(files, directories)
+
+ setFileTree(tree)
+
+ /**
+ * This conditional check guarantees that file content synchronization is safe
+ * and scoped only to the file currently tracked by the editor.
+ *
+ * It prevents a data leak/overwrite scenario where:
+ *
+ * - A previously opened file’s contents could accidentally overwrite the buffer
+ * of a newly created or switched file if the watcher fired between transitions.
+ *
+ * - The editor would show stale content from a deleted or replaced file.
+ */
+ currentFile.setCurrentFile((state) => {
+ if (currentFile.path && files[currentFile.path]) {
+ currentFile.setContent(files[currentFile.path])
+ }
+ return state
+ })
+ },
+ { ignoreIf: () => isInstalling }
+ )
+ }, [webcontainer, currentFile, setupFileWatcher, isInstalling])
+
+ const handleExportZip = useCallback(async () => {
+ if (!selectedProject) return
+
+ try {
+ await syncFilesFromWebContainer()
+
+ await exportAsZip(selectedProject.files, selectedProject.name)
+
+ toast('Exporting Project', {
+ description: 'Exporting project as ZIP file',
+ icon: ,
+ action: {
+ label: 'Dismiss',
+ onClick: () => void 0,
+ },
+ })
+ } catch (error) {
+ console.error('Error exporting ZIP:', error)
+ toast.error('Export Failed', {
+ description: 'Failed to export project',
+ })
+ }
+ }, [selectedProject, syncFilesFromWebContainer])
+
+ 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('Error saving:', error)
+ toast.error('Save Failed', {
+ description: 'Failed to save project',
+ })
+ }
+ }, [selectedProject, syncFilesFromWebContainer])
+
+ 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('Error copying:', error)
+ toast.error('Copy Failed', {
+ description: 'Failed to copy files',
+ })
+ }
+ }, [selectedProject, syncFilesFromWebContainer])
+
+ const handleReset = useCallback(async () => {
+ if (!selectedProject) return
+
+ try {
+ // Clear localStorage for this project
+ clearFromLocalStorage(selectedProject.id)
+
+ toast('Resetting Project', {
+ description: 'Clearing saved changes and reloading original...',
+ })
+
+ // Find the original project from exampleProjects
+ const originalProject = exampleProjects.find(
+ (p) => p.id === selectedProject.id
+ )
+
+ if (originalProject) {
+ // Reload the original project
+ await handleProjectSelect(originalProject)
+
+ toast('Project Reset', {
+ description: 'Project has been reset to original state',
+ })
+ }
+ } catch (error) {
+ console.error('Error resetting project:', error)
+ toast.error('Reset Failed', {
+ description: 'Failed to reset project',
+ })
+ }
+ }, [selectedProject])
+
+ const handleProjectSelect = async (project: Project) => {
+ setIsLoading(true)
+ setError(null)
+ setPreviewUrl(null)
+
+ if (fileWatcherRef.current) {
+ fileWatcherRef.current()
+ fileWatcherRef.current = null
+ }
+
+ try {
+ const savedFiles = loadFromLocalStorage(project.id)
+ const projectToLoad = savedFiles
+ ? { ...project, files: savedFiles }
+ : project
+
+ if (savedFiles) {
+ toast('Loaded Saved Version', {
+ description: 'Restored your previous changes',
+ dismissible: true,
+ })
+ }
+
+ setSelectedProject(projectToLoad)
+
+ const container = await initializeWebContainer()
+
+ const tree = buildFileTree(projectToLoad.files)
+ setFileTree(tree)
+
+ const fileTreeStructure = convertFilesToFileTree(projectToLoad.files)
+
+ if (!container) {
+ throw new Error('WebContainer failed to initialize')
+ }
+
+ await container.mount(fileTreeStructure)
+
+ currentFile.setCurrentFile(() => ({
+ path: projectToLoad.initialFile,
+ content: projectToLoad.files[projectToLoad.initialFile],
+ }))
+ } catch (error) {
+ console.error('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) => {
+ currentFile.setPath(path)
+ if (selectedProject) {
+ currentFile.setContent(selectedProject.files?.[path] ?? '')
+ }
+ if (!isPanelVisible('editor')) {
+ toggleEditor()
+ }
+ }
+
+ const handleFileChange = async (newContent: string) => {
+ currentFile.setContent(newContent)
+
+ if (currentFile.path && webcontainer) {
+ try {
+ await writeFile(currentFile.path, newContent)
+
+ if (selectedProject) {
+ setSelectedProject({
+ ...selectedProject,
+ files: { ...selectedProject.files, [currentFile.path]: newContent },
+ })
+ }
+ } catch (error) {
+ console.error('Error writing file:', error)
+ }
+ }
+ }
+
+ const handleTerminalReady = useCallback(
+ async (xterm: XTerm, fitAddon: FitAddon) => {
+ setupFileWatching()
+ await initializeTerminal(xterm, fitAddon)
+ },
+ [initializeTerminal, setupFileWatching]
+ )
+
+ const handleCreateFile = useCallback(
+ async (path: string) => {
+ await createFile(path)
+ currentFile.setCurrentFile(() => ({ path, content: '' }))
+ },
+ [createFile, currentFile]
+ )
+
+ const handleCreateFolder = useCallback(
+ async (path: string) => {
+ await createFolder(path)
+ },
+ [createFolder]
+ )
+
+ const handleDelete = useCallback(
+ async (path: string, isDirectory: boolean) => {
+ await deleteItem(path, isDirectory)
+
+ if (
+ currentFile.path === path ||
+ currentFile.path?.startsWith(path + '/')
+ ) {
+ currentFile.clear()
+ }
+ },
+ [deleteItem, currentFile]
+ )
+
+ const handleRename = useCallback(
+ async (oldPath: string, newPath: string, isDirectory: boolean) => {
+ await renameItem(oldPath, newPath, isDirectory)
+
+ if (currentFile.path === oldPath) {
+ currentFile.setPath(newPath)
+ }
+ },
+ [renameItem, currentFile]
+ )
+
+ const handleMove = useCallback(
+ async (sourcePath: string, targetPath: string, isDirectory: boolean) => {
+ await moveItem(sourcePath, targetPath, isDirectory)
+
+ const fileName = sourcePath.split('/').pop()
+ const newPath = targetPath ? `${targetPath}/${fileName}` : fileName!
+
+ if (currentFile.path === sourcePath) {
+ currentFile.setPath(newPath)
+ }
+ },
+ [moveItem, currentFile]
+ )
+
+ const handleRunCommand = useCallback(async () => {
+ if (isRunning) return
+
+ setIsRunning(true)
+
+ try {
+ await runCommand('npm run dev')
+
+ toast('Starting Server', {
+ description: 'Running npm run dev...',
+ })
+
+ setTimeout(() => {
+ setIsRunning(false)
+ }, 3000)
+ } catch (error) {
+ console.error('Error running command:', error)
+ toast.error('Run Failed', {
+ description: 'Failed to start dev server',
+ })
+ setIsRunning(false)
+ }
+ }, [isRunning, runCommand])
+
+ const handleOpenProjectSelector = useCallback(() => {
+ setShowProjectSelector(true)
+ }, [])
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && showProjectSelector) {
+ setShowProjectSelector(false)
+ }
+ }
+
+ window.addEventListener('keydown', handleEscape)
+ return () => window.removeEventListener('keydown', handleEscape)
+ }, [showProjectSelector])
+
+ useEffect(() => {
+ return () => {
+ if (fileWatcherRef.current) {
+ fileWatcherRef.current()
+ }
+ }
+ }, [])
+
+ if (isMobile) {
+ return (
+ setSelectedProject(null) : undefined}
+ />
+ )
+ }
+
+ if (!selectedProject) {
+ return (
+
+ )
+ }
+
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
⚠️
+
Failed to Load Project
+
{error}
+
+
+
+ )
+ }
+
+ return (
+
+
+
setIsFullscreen(!isFullscreen)}
+ onRunCommand={handleRunCommand}
+ isRunning={isRunning}
+ onOpenProjectSelector={handleOpenProjectSelector}
+ />
+
+ {isLoading && (
+
+
+
+
+ Loading project...
+
+
+ Setting up your workspace
+
+
+
+ )}
+
+ {error && (
+
+
+
⚠️
+
+ Failed to Load Project
+
+
{error}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ Explorer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {currentFile.path ? (
+ <>
+
+
+
+ {currentFile.path
+ .split('/')
+ .map((segment, index, arr) => {
+ const isLast = index === arr.length - 1
+ const path = arr
+ .slice(0, index + 1)
+ .join('/')
+
+ return (
+
+ {isLast ? (
+
+ {segment}
+
+ ) : (
+ <>
+
+ {segment}
+
+
+ >
+ )}
+
+ )
+ })}
+
+
+
+
+ {
+ monacoRef.current = monaco
+ }}
+ />
+
+ >
+ ) : (
+
+
+
📝
+
Select a file to edit
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Terminal
+
+ {isInstalling && (
+
+
+ Installing dependencies...
+
+ )}
+
+
+
+
+
+
+
+
+
+ {showProjectSelector && (
+
+
setShowProjectSelector(false)}
+ />
+
+
+
+
{
+ handleProjectSelect(project)
+ setShowProjectSelector(false)
+ }}
+ />
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/blog/src/app/(public)/editor/services/formatters.ts b/apps/blog/src/app/(public)/editor/services/formatters.ts
new file mode 100644
index 0000000..1a5bbde
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/services/formatters.ts
@@ -0,0 +1,56 @@
+import { createStreaming, type Formatter } from '@dprint/formatter'
+import { Monaco } from '@monaco-editor/react'
+
+const formatters = new Map
()
+
+const LOCAL_REGISTRY = '/vendor/dprint/plugins/'
+
+const PLUGIN_REGISTRY: Record = {
+ 'json-0.21.0.wasm': ['json'],
+ 'typescript-0.95.12.wasm': ['typescript', 'javascript'],
+}
+
+interface FormatterPlugin {
+ language: string
+ url: string
+}
+
+const plugins: FormatterPlugin[] = Object.entries(PLUGIN_REGISTRY).flatMap(
+ ([plugin, languages]) =>
+ languages.map((language) => ({ language, url: LOCAL_REGISTRY + plugin }))
+)
+
+async function getFormatter(url: string): Promise {
+ const cached = formatters.get(url)
+ if (cached) return cached
+
+ const formatter = await createStreaming(fetch(url))
+ formatters.set(url, formatter)
+ return formatter
+}
+
+export async function setupWorkspaceFormatters(monaco: Monaco) {
+ monaco.editor.addEditorAction({
+ id: 'format',
+ label: 'Format',
+ keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
+ run: (editor) => {
+ const action = editor.getAction('editor.action.formatDocument')
+ if (action) action.run()
+ },
+ })
+
+ for (const { language, url } of plugins) {
+ const formatter = await getFormatter(url)
+
+ monaco.languages.registerDocumentFormattingEditProvider(language, {
+ provideDocumentFormattingEdits(model) {
+ const text = formatter.formatText({
+ fileText: model.getValue(),
+ filePath: model.uri.toString(),
+ })
+ return [{ text, range: model.getFullModelRange() }]
+ },
+ })
+ }
+}
diff --git a/apps/blog/src/app/(public)/editor/services/project-loader.ts b/apps/blog/src/app/(public)/editor/services/project-loader.ts
new file mode 100644
index 0000000..3f36b48
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/services/project-loader.ts
@@ -0,0 +1,64 @@
+'use server'
+
+import { randomBytes } from 'crypto'
+import { opendir, readdir, readFile, writeFile } from 'fs/promises'
+import { dirname, join, relative, resolve } from 'path'
+import { fileURLToPath } from 'url'
+
+import { Project } from './types'
+
+export async function* walk(root: string): AsyncGenerator {
+ for await (const entry of await opendir(root)) {
+ const fullPath = join(root, entry.name)
+ if (entry.isDirectory()) yield* walk(fullPath)
+ else if (entry.isFile()) yield fullPath
+ }
+}
+
+export async function loadProjectFromDir(projectDir: string): Promise {
+ const pkgPath = join(projectDir, 'package.json')
+ const pkgJson = JSON.parse(await readFile(pkgPath, 'utf-8'))
+
+ const id = pkgJson.name ?? `prj-${randomBytes(3).toString('hex')}`
+ const name = pkgJson.metadata.displayName ?? id
+ const description = pkgJson.description
+ const initialFile =
+ pkgJson.metadata.initialFile.replace(/^\.?\//, '') ?? 'package.json'
+
+ const files: Record = {}
+ const ignoredPaths = ['dist', 'node_modules', '.git', '.next']
+
+ for await (const filePath of walk(projectDir)) {
+ if (ignoredPaths.some((ignored) => filePath.includes(ignored))) continue
+ const relPath = relative(projectDir, filePath)
+ const content = await readFile(filePath, 'utf-8')
+ files[relPath] = content
+ }
+
+ return { id, name, description, initialFile, files }
+}
+
+// TODO: Remove this after bootstrap refactor
+export async function parseProjects(
+ projectsDir = '../../../../../../../playground'
+) {
+ const basePath = resolve(dirname(fileURLToPath(import.meta.url)), projectsDir)
+ const entries = await readdir(basePath, { withFileTypes: true })
+
+ const projects = []
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ const projectPath = resolve(basePath, entry.name)
+ const loaded = await loadProjectFromDir(projectPath)
+ projects.push(loaded)
+ }
+ }
+
+ const output = resolve(
+ dirname(fileURLToPath(import.meta.url)),
+ '../../../../data/projects.json'
+ )
+ await writeFile(output, JSON.stringify(projects, null, 2))
+
+ return projects
+}
diff --git a/apps/blog/src/app/(public)/editor/services/projects.ts b/apps/blog/src/app/(public)/editor/services/projects.ts
new file mode 100644
index 0000000..9aa9ef8
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/services/projects.ts
@@ -0,0 +1,19 @@
+'use client'
+
+import { memoize } from '@blog/utils'
+
+import projects from '@/data/projects.json'
+
+import type { Project } from './types'
+
+/**
+ * An array of playground projects.
+ *
+ * Casting from unknown because `project.files` is a union of all projects' files
+ * and inferred type would be Record otherwise.
+ *
+ * @todo Desambiguate name `projects` (a "project" could be unrelated to the playground)
+ */
+export const getProjects = memoize(() => projects as unknown as Project[])
+
+export const exampleProjects = getProjects()
diff --git a/apps/blog/src/app/(public)/editor/services/themes.ts b/apps/blog/src/app/(public)/editor/services/themes.ts
new file mode 100644
index 0000000..d7f4166
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/services/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/services/types.ts b/apps/blog/src/app/(public)/editor/services/types.ts
new file mode 100644
index 0000000..0773eb1
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/services/types.ts
@@ -0,0 +1,15 @@
+export interface FileNode {
+ name: string
+ type: 'file' | 'directory'
+ path: string
+ children?: FileNode[]
+ content?: string
+}
+
+export interface Project {
+ id: string
+ name: string
+ description: string
+ initialFile: string
+ files: Record
+}
diff --git a/apps/blog/src/app/(public)/editor/services/utils.ts b/apps/blog/src/app/(public)/editor/services/utils.ts
new file mode 100644
index 0000000..5089e27
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/services/utils.ts
@@ -0,0 +1,235 @@
+import { print } from '@blog/utils'
+import { saveAs } from 'file-saver'
+import JSZip from 'jszip'
+
+import { FileNode } from './types'
+
+const STORAGE_PREFIX = 'webcontainer-project-'
+
+export 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',
+ sh: 'shell',
+ yml: 'yaml',
+ yaml: 'yaml',
+ }
+ return languageMap[ext || ''] || 'plaintext'
+}
+
+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
+ })
+}
+
+export 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)
+ }
+ }
+ }
+ }
+
+ return sortNodes(tree)
+}
+
+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
+ }
+ }
+
+ print.log(
+ `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 = `${STORAGE_PREFIX}${projectId}`
+ localStorage.setItem(key, JSON.stringify(sourceFiles))
+ print.log(
+ `Project saved to localStorage: ${key} (${Object.keys(sourceFiles).length} source files)`
+ )
+ return true
+ } catch (error) {
+ print.error('Error saving to localStorage:', error)
+ return false
+ }
+}
+
+export function loadFromLocalStorage(
+ projectId: string
+): Record | null {
+ try {
+ const key = `${STORAGE_PREFIX}${projectId}`
+ const data = localStorage.getItem(key)
+ if (data) {
+ print.log(`Project loaded from localStorage: ${key}`)
+ return JSON.parse(data)
+ }
+ } catch (error) {
+ print.error('Error loading from localStorage:', error)
+ }
+ return null
+}
+
+export function clearFromLocalStorage(projectId: string): boolean {
+ try {
+ const key = `${STORAGE_PREFIX}${projectId}`
+ localStorage.removeItem(key)
+ return true
+ } catch (error) {
+ print.error('Error removing from localStorage:', error)
+ return false
+ }
+}
+
+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)
+ print.log('Project copied to clipboard')
+ return true
+ } catch (error) {
+ print.error('Error copying to clipboard:', error)
+ return false
+ }
+}
diff --git a/apps/blog/src/app/(public)/editor/services/webcontainer.ts b/apps/blog/src/app/(public)/editor/services/webcontainer.ts
new file mode 100644
index 0000000..b761ba0
--- /dev/null
+++ b/apps/blog/src/app/(public)/editor/services/webcontainer.ts
@@ -0,0 +1,216 @@
+'use client'
+
+import { print } from '@blog/utils'
+import type { WebContainer } from '@webcontainer/api'
+
+let webcontainerInstance: WebContainer | null = null
+let isBooting = false
+let bootPromise: Promise | null = null
+
+export function resetWebContainerInstance() {
+ print.log('Resetting WebContainer instance')
+ webcontainerInstance = null
+ isBooting = false
+ bootPromise = null
+}
+
+export async function getWebContainerInstance(): Promise {
+ if (typeof crossOriginIsolated !== 'undefined' && !crossOriginIsolated) {
+ throw new Error(
+ 'Cross-Origin Isolation is not enabled. WebContainers require COOP and COEP headers to be set.'
+ )
+ }
+
+ if (webcontainerInstance) {
+ print.log('Reusing existing WebContainer instance')
+ return webcontainerInstance
+ }
+
+ if (isBooting && bootPromise) {
+ print.log('Waiting for existing boot process')
+ return bootPromise
+ }
+
+ isBooting = true
+ print.log('Booting new WebContainer instance')
+
+ bootPromise = (async () => {
+ try {
+ const { WebContainer } = await import('@webcontainer/api')
+ const instance = await WebContainer.boot()
+ webcontainerInstance = instance
+ print.log('WebContainer booted successfully')
+ return instance
+ } catch (error) {
+ print.error('Failed to boot WebContainer:', error)
+ resetWebContainerInstance()
+ throw error
+ } finally {
+ isBooting = false
+ bootPromise = null
+ }
+ })()
+
+ return bootPromise
+}
+
+export async function clearFileSystem(instance: WebContainer) {
+ try {
+ const files = await instance.fs.readdir('/', { withFileTypes: true })
+ for (const file of files) {
+ try {
+ await instance.fs.rm(file.name, { recursive: true, force: true })
+ } catch (error) {
+ print.log(`Could not remove ${file.name}:`, error)
+ }
+ }
+ } catch (error) {
+ print.error('Error clearing file system:', error)
+ }
+}
+
+export function convertFilesToFileTree(files: Record) {
+ const tree: any = {}
+
+ for (const [path, content] of Object.entries(files)) {
+ const parts = path.split('/')
+ let current = tree
+
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i]
+ const isFile = i === parts.length - 1
+
+ if (isFile) {
+ current[part] = {
+ file: {
+ contents: content,
+ },
+ }
+ } else {
+ if (!current[part]) {
+ current[part] = {
+ directory: {},
+ }
+ }
+ current = current[part].directory
+ }
+ }
+ }
+
+ return tree
+}
+
+export async function readAllFiles(instance: WebContainer): Promise<{
+ files: Record
+ directories: string[]
+}> {
+ const files: Record = {}
+ const directories: string[] = []
+
+ async function readDir(path: string) {
+ try {
+ const entries = await instance.fs.readdir(path, { withFileTypes: true })
+
+ for (const entry of entries) {
+ const fullPath =
+ path === '/' ? `/${entry.name}` : `${path}/${entry.name}`
+
+ const relativePath = fullPath.startsWith('/')
+ ? fullPath.slice(1)
+ : fullPath
+
+ // Skip node_modules and hidden files
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
+ continue
+ }
+
+ if (entry.isDirectory()) {
+ directories.push(relativePath)
+ await readDir(fullPath)
+ } else if (entry.isFile()) {
+ try {
+ const content = await instance.fs.readFile(fullPath, 'utf-8')
+ files[relativePath] = content
+ } catch (error) {
+ print.error(`Error reading file ${fullPath}:`, error)
+ }
+ }
+ }
+ } catch (error) {
+ print.error(`Error reading directory ${path}:`, error)
+ }
+ }
+
+ await readDir('/')
+
+ return { files, directories }
+}
+
+export function watchFileSystem(
+ instance: WebContainer,
+ callback: (data: {
+ files: Record
+ directories: string[]
+ }) => void,
+ options?: { ignoreIf?: () => boolean }
+): () => void {
+ const watchers: Array<{ close: () => void }> = []
+
+ let isProcessing = false
+ const DEBOUNCE_MS = 200
+
+ async function handleChange(
+ eventType: string,
+ filename: string | Uint8Array | null
+ ) {
+ // Respect an external ignore predicate (e.g. while installing)
+ if (options?.ignoreIf?.()) {
+ print.log('Skipping FS change processing (ignored by predicate)')
+ return
+ }
+
+ if (isProcessing) return
+
+ isProcessing = true
+
+ print.log(`File system change detected: ${eventType} ${filename || ''}`)
+
+ try {
+ const data = await readAllFiles(instance)
+ print.log(
+ `Re-read ${Object.keys(data.files).length} files and ${data.directories.length} directories from WebContainer`
+ )
+ callback(data)
+ } catch (error) {
+ print.error('Error reading files after change:', error)
+ } finally {
+ setTimeout(() => {
+ isProcessing = false
+ }, DEBOUNCE_MS)
+ }
+ }
+
+ try {
+ print.log('Setting up recursive file system watcher on root directory')
+
+ const watcher = instance.fs.watch('/', { recursive: true }, handleChange)
+
+ watchers.push({
+ close: () => {
+ try {
+ watcher.close()
+ print.log('Closed root directory watcher')
+ } catch (error) {
+ print.error('Error closing watcher:', error)
+ }
+ },
+ })
+ } catch (error) {
+ print.error('Error setting up file system watcher:', error)
+ }
+
+ return () => {
+ print.log('Cleaning up file system watchers')
+ watchers.forEach((watcher) => watcher.close())
+ }
+}
diff --git a/apps/blog/src/app/(public)/layout.tsx b/apps/blog/src/app/(public)/layout.tsx
index 22a70ea..d756420 100644
--- a/apps/blog/src/app/(public)/layout.tsx
+++ b/apps/blog/src/app/(public)/layout.tsx
@@ -1,4 +1,4 @@
-import { unstable_ViewTransition as ViewTransition } from 'react'
+import { ViewTransition } from 'react'
import { Footer } from '@/components/layout/footer'
import { Header } from '@/components/layout/header'
diff --git a/apps/blog/src/app/(public)/login/layout.tsx b/apps/blog/src/app/(public)/login/layout.tsx
index 9d302cd..024038f 100644
--- a/apps/blog/src/app/(public)/login/layout.tsx
+++ b/apps/blog/src/app/(public)/login/layout.tsx
@@ -1,4 +1,4 @@
-import { unstable_ViewTransition as ViewTransition } from 'react'
+import { ViewTransition } from 'react'
import { NonceProvider } from '@/components/context/nonce'
import { Page, PageSection } from '@/components/page'
diff --git a/apps/blog/src/app/(public)/photos/layout.tsx b/apps/blog/src/app/(public)/photos/layout.tsx
index 7a1dc09..a3b892c 100644
--- a/apps/blog/src/app/(public)/photos/layout.tsx
+++ b/apps/blog/src/app/(public)/photos/layout.tsx
@@ -1,4 +1,4 @@
-import { unstable_ViewTransition as ViewTransition } from 'react'
+import { ViewTransition } from 'react'
export default function Layout({ children }: { children: React.ReactNode }) {
return {children}
diff --git a/apps/blog/src/app/(public)/quotes/layout.tsx b/apps/blog/src/app/(public)/quotes/layout.tsx
index 7a1dc09..a3b892c 100644
--- a/apps/blog/src/app/(public)/quotes/layout.tsx
+++ b/apps/blog/src/app/(public)/quotes/layout.tsx
@@ -1,4 +1,4 @@
-import { unstable_ViewTransition as ViewTransition } from 'react'
+import { ViewTransition } from 'react'
export default function Layout({ children }: { children: React.ReactNode }) {
return {children}
diff --git a/apps/blog/src/app/(public)/snippets/layout.tsx b/apps/blog/src/app/(public)/snippets/layout.tsx
index 9216faa..c212357 100644
--- a/apps/blog/src/app/(public)/snippets/layout.tsx
+++ b/apps/blog/src/app/(public)/snippets/layout.tsx
@@ -1,4 +1,4 @@
-import { unstable_ViewTransition as ViewTransition } from 'react'
+import { ViewTransition } from 'react'
import { NonceProvider } from '@/components/context/nonce'
import { generateNonce } from '@/lib/nonce'
diff --git a/apps/blog/src/app/(public)/snippets/snippet-list.tsx b/apps/blog/src/app/(public)/snippets/snippet-list.tsx
index 010b8c5..62ef5f2 100644
--- a/apps/blog/src/app/(public)/snippets/snippet-list.tsx
+++ b/apps/blog/src/app/(public)/snippets/snippet-list.tsx
@@ -15,7 +15,7 @@ import {
TabsContent,
TabsList,
TabsTrigger,
- useIsMobile,
+ useMobile,
} from '@shadcn/ui'
import { Code } from 'codice'
import { formatDistanceToNow } from 'date-fns'
@@ -51,7 +51,7 @@ export default function SnippetList() {
)
const router = useRouter()
const nonce = useNonce()
- const isMobile = useIsMobile()
+ const isMobile = useMobile()
React.useEffect(() => {
const loadSnippets = async () => {
diff --git a/apps/blog/src/app/api/execute/route.ts b/apps/blog/src/app/api/execute/route.ts
index 65ec5b8..3c1e656 100644
--- a/apps/blog/src/app/api/execute/route.ts
+++ b/apps/blog/src/app/api/execute/route.ts
@@ -3,8 +3,8 @@
import { execFile } from 'child_process'
import { resolve } from 'path'
-import * as z from '@zod/mini'
import { NextRequest, NextResponse } from 'next/server'
+import * as z from 'zod'
import 'server-only'
diff --git a/apps/blog/src/app/api/login/route.ts b/apps/blog/src/app/api/login/route.ts
index 0b1ad42..9e2430d 100644
--- a/apps/blog/src/app/api/login/route.ts
+++ b/apps/blog/src/app/api/login/route.ts
@@ -1,7 +1,7 @@
'use server'
-import * as z from '@zod/mini'
import { NextResponse } from 'next/server'
+import * as z from 'zod'
import { checkPassword, signToken } from '@/lib/session'
diff --git a/apps/blog/src/app/fonts.ts b/apps/blog/src/app/fonts.ts
index 1c1698e..c3daf45 100644
--- a/apps/blog/src/app/fonts.ts
+++ b/apps/blog/src/app/fonts.ts
@@ -1,39 +1,12 @@
import { cn } from '@shadcn/ui'
-import {
- Geist,
- Geist_Mono,
- Instrument_Sans,
- Inter,
- Mulish,
- Noto_Sans_Mono,
-} from 'next/font/google'
+import { Geist_Mono, Inter } from 'next/font/google'
import LocalFont from 'next/font/local'
-const fontSans = Geist({
- subsets: ['latin'],
- variable: '--font-sans',
-})
-
const fontMono = Geist_Mono({
subsets: ['latin'],
variable: '--font-mono',
})
-const fontInstrument = Instrument_Sans({
- subsets: ['latin'],
- variable: '--font-instrument',
-})
-
-const fontNotoMono = Noto_Sans_Mono({
- subsets: ['latin'],
- variable: '--font-noto-mono',
-})
-
-const fontMullish = Mulish({
- subsets: ['latin'],
- variable: '--font-mullish',
-})
-
const fontInter = Inter({
subsets: ['latin'],
variable: '--font-inter',
@@ -64,11 +37,4 @@ export const VisualSans = LocalFont({
],
})
-export const fontVariables = cn(
- fontSans.variable,
- fontMono.variable,
- fontInstrument.variable,
- fontNotoMono.variable,
- fontMullish.variable,
- fontInter.variable
-)
+export const fontVariables = cn(fontMono.variable, fontInter.variable)
diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css
index e012cc5..2fff59a 100644
--- a/apps/blog/src/app/global.css
+++ b/apps/blog/src/app/global.css
@@ -6,6 +6,7 @@
@import '../assets/styles/sugar-high.css';
@import '../assets/styles/themes.css';
+@import '../assets/styles/xterm.css';
@source '../../../../libs/ui/src/**';
@@ -93,7 +94,7 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.59 0.2041 277.12);
+ --primary: oklch(0.62 0.19 260);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
diff --git a/apps/blog/src/app/layout.tsx b/apps/blog/src/app/layout.tsx
index da4d09a..c5d3f1a 100644
--- a/apps/blog/src/app/layout.tsx
+++ b/apps/blog/src/app/layout.tsx
@@ -2,11 +2,10 @@ import { cn, Toaster } from '@shadcn/ui'
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'
import type { Metadata } from 'next'
-import { cookies } from 'next/headers'
-import { unstable_ViewTransition as ViewTransition } from 'react'
+import { ViewTransition } from 'react'
-import { SessionProvider, ThemeProvider } from '@/components/context/session'
-import { ActiveThemeProvider } from '@/components/context/theme'
+import { SessionProvider } from '@/components/context/session'
+import { ThemeProvider } from '@/components/context/theme'
import config from '@/data/config.json'
import './global.css'
@@ -89,9 +88,6 @@ export const metadata: Metadata = {
export default async function RootLayout({
children,
}: React.PropsWithChildren) {
- const activeTheme = await cookies().then((store) => store.get('theme')?.value)
- const isScaled = activeTheme?.endsWith('-scaled')
-
return (
@@ -102,7 +98,7 @@ export default async function RootLayout({
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
}
- } catch (_) {}
+ } catch {}
`,
}}
/>
@@ -111,26 +107,16 @@ export default async function RootLayout({
-
-
- {children}
-
-
-
-
+
+ {children}
+
+
+
diff --git a/apps/blog/src/assets/styles/xterm.css b/apps/blog/src/assets/styles/xterm.css
new file mode 100644
index 0000000..8dcc14a
--- /dev/null
+++ b/apps/blog/src/assets/styles/xterm.css
@@ -0,0 +1,10 @@
+/* Add padding inside the terminal content */
+.xterm .xterm-screen {
+ padding-inline: calc(0.25rem * 4);
+ box-sizing: border-box;
+}
+
+/* Optional: prevent scrollbars from showing due to padding */
+.xterm .xterm-viewport {
+ overflow: hidden !important;
+}
diff --git a/apps/blog/src/components/article-footer.tsx b/apps/blog/src/components/article-footer.tsx
index 84fc53d..096f665 100644
--- a/apps/blog/src/components/article-footer.tsx
+++ b/apps/blog/src/components/article-footer.tsx
@@ -1,97 +1,50 @@
'use client'
-import { capitalize } from '@blog/utils'
import { Button, cn } from '@shadcn/ui'
import { Share2 } from 'lucide-react'
import * as React from 'react'
-import * as icons from '@/components/icons'
+import { XIcon } from '@/components/icons'
-type SocialShare = {
- url: string
- icon: keyof typeof icons
- platform: string
- hidden?: boolean
- active?: boolean
-}
-
-const socials: SocialShare[] = [
- {
- platform: 'Twitter',
- icon: 'XIcon',
- url: `https://twitter.com/intent/tweet?url=${encodeURIComponent(
- window.location.href
- )}&text=${encodeURIComponent(document.title)}`,
- active: true,
- hidden: true,
- },
- {
- platform: 'WhatsApp',
- icon: 'WhatsAppIcon',
- url: ``,
- active: false,
- hidden: true,
- },
- {
- platform: 'Facebook',
- icon: 'FacebookIcon',
- url: ``,
- active: false,
- hidden: true,
- },
-]
+export function ArticleFooter({ className }: { className?: string }) {
+ const twitterShare = () => {
+ if (window && document) {
+ const href = encodeURIComponent(window.location.href)
+ const title = encodeURIComponent(document.title)
+ const url = `https://twitter.com/intent/tweet?url=${href}&text=${title}`
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+ }
-export function ArticleFooter(className: { className?: string }) {
- const browserSharing = () => {
+ const browserShare = () => {
if (navigator.share) {
- navigator.share({
- title: document.title,
- url: window.location.href,
- })
+ navigator.share({ title: document.title, url: window.location.href })
}
}
return (