diff --git a/.github/workflows/nxcloud.yaml b/.github/workflows/nxcloud.yaml index 1bd3858..de1560a 100644 --- a/.github/workflows/nxcloud.yaml +++ b/.github/workflows/nxcloud.yaml @@ -1,9 +1,5 @@ name: NxCloud -env: - APP_CONFIG_FILE: 'apps/blog/info.json' - NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} - on: push: branches: @@ -17,19 +13,25 @@ permissions: jobs: main: runs-on: ubuntu-latest + + env: + NX_BASE: origin/master + NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + filter: tree:0 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v6 with: - node-version: 22.8.0 + node-version: 22.20.0 - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.15.4 + version: 10.19.0 run_install: false - name: Get pnpm store directory @@ -44,8 +46,11 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- + - run: pnpm dlx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="build" + - name: Install dependencies run: pnpm install --frozen-lockfile - uses: nrwl/nx-set-shas@v4 + - run: npx nx-cloud record -- nx format:check - run: pnpm exec nx affected -t lint test build diff --git a/.nvmrc b/.nvmrc index 2660004..c004e35 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v22.15.1 +v22.20.0 diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000..2c28fff --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +playground diff --git a/.prettierignore b/.prettierignore index 89c05fb..de7af9a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,8 @@ /.nx/cache /.nx/workspace-data .next +next-env.d.ts pnpm-lock.yaml dist node_modules +playground diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a473f3..11d3c00 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,21 +1,23 @@ { "css.customData": [".vscode/tailwind.json"], - "editor.tabSize": 2, - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", + "css.validate": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll": "explicit", + "source.organizeImports": "explicit" }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.inlineSuggest.enabled": true, "// activates tailwindcss intellisense for libraries": "", "editor.quickSuggestions": { "strings": true }, - "css.validate": false, - "editor.inlineSuggest.enabled": true, + "editor.tabSize": 2, + "eslint.format.enable": true, + "eslint.validate": ["typescript", "javascript", "tsx", "jsx", "json"], "// adds tailwindcss intellisense support within cva function calls": "", "tailwindCSS.experimental.classRegex": [ ["cva\\(((?:[^()]|\\([^()]*\\))*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(((?:[^()]|\\([^()]*\\))*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] - ], - "eslint.validate": ["json"] + ] } diff --git a/apps/blog/eslint.config.mjs b/apps/blog/eslint.config.mjs index 1c9aa1e..c7de10a 100644 --- a/apps/blog/eslint.config.mjs +++ b/apps/blog/eslint.config.mjs @@ -1,22 +1,25 @@ -import { FlatCompat } from '@eslint/eslintrc' -import { dirname } from 'path' -import { fileURLToPath } from 'url' -import js from '@eslint/js' -import { fixupConfigRules } from '@eslint/compat' +import { defineConfig } from 'eslint/config' + import nx from '@nx/eslint-plugin' +import nextPlugin from 'eslint-config-next' +import reactHooksPlugin from 'eslint-plugin-react-hooks' import baseConfig from '../../eslint.config.mjs' -const compat = new FlatCompat({ - baseDirectory: dirname(fileURLToPath(import.meta.url)), - recommendedConfig: js.configs.recommended, -}) -// eslint-disable-next-line import/no-anonymous-default-export -export default [ - ...fixupConfigRules(compat.extends('next')), - ...fixupConfigRules(compat.extends('next/core-web-vitals')), +export default defineConfig([ ...baseConfig, ...nx.configs['flat/react-typescript'], + nextPlugin, + { + plugins: { + 'react-hooks': reactHooksPlugin, + }, + rules: { + 'react-hooks/rules-of-hooks': 'warn', + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/set-state-in-effect': 'warn', + }, + }, { ignores: ['.next/**/*', './src/data/**/*', './public/_*', 'next-env.d.ts'], }, -] +]) diff --git a/apps/blog/index.d.ts b/apps/blog/index.d.ts index a9ab534..d032632 100644 --- a/apps/blog/index.d.ts +++ b/apps/blog/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ declare module '*.svg' { const content: any export const ReactComponent: any diff --git a/apps/blog/next-env.d.ts b/apps/blog/next-env.d.ts index 830fb59..9edff1c 100644 --- a/apps/blog/next-env.d.ts +++ b/apps/blog/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/blog/next.config.mjs b/apps/blog/next.config.mjs index 3f43b62..f47611e 100644 --- a/apps/blog/next.config.mjs +++ b/apps/blog/next.config.mjs @@ -1,11 +1,10 @@ // @ts-check -import { composePlugins, withNx } from '@nx/next' - import createMDX from '@next/mdx' -import remarkGfm from 'remark-gfm' -import remarkSugarHigh from 'remark-sugar-high' +import { composePlugins } from '@nx/next' import rehypeAutolinkHeadings from 'rehype-autolink-headings' import rehypeSlug from 'rehype-slug' +import remarkGfm from 'remark-gfm' +import remarkSugarHigh from 'remark-sugar-high' const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 @@ -33,11 +32,21 @@ const nextConfig = { }, ], }, + { + source: '/:path*', + headers: [ + { + key: 'Cross-Origin-Embedder-Policy', + value: 'require-corp', + }, + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin', + }, + ], + }, ] }, - nx: { - svgr: false, - }, pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], poweredByHeader: false, reactStrictMode: true, @@ -58,7 +67,10 @@ const nextConfig = { webpack: (config, { dev }) => { // resolve imports with extension names on dev mode (e.g. import ansi from "./ansi.js") config.resolve.extensionAlias = { - '.js': ['.ts', '.tsx', '.js'], + '.js': ['.ts', '.tsx', '.js', '.jsx'], + '.mjs': ['.mts', '.mjs'], + '.cjs': ['.cts', '.cjs'], + '.jsx': ['.tsx', '.jsx'], } return config }, @@ -83,6 +95,6 @@ const withMdx = createMDX({ }, }) -const withPlugins = composePlugins(withNx, withMdx) +const withPlugins = composePlugins(withMdx) export default withPlugins(nextConfig) diff --git a/apps/blog/package.json b/apps/blog/package.json index f361448..d847334 100644 --- a/apps/blog/package.json +++ b/apps/blog/package.json @@ -4,54 +4,59 @@ "private": true, "description": "A blog built with Next.js and MDX.", "dependencies": { - "@effect/platform-node": "^0.86.4", - "@effect/printer": "^0.44.8", - "@effect/printer-ansi": "^0.44.8", - "@effect/rpc": "^0.62.4", - "@effect/typeclass": "^0.35.8", + "@blog/utils": "workspace:*", + "@dprint/formatter": "^0.4.1", + "@effect-atom/atom-react": "^0.4.0", + "@effect/cli": "^0.72.0", + "@effect/platform": "^0.93.0", + "@effect/platform-node": "^0.100.0", + "@monaco-editor/react": "^4.7.0", + "@shadcn/ui": "workspace:*", "@vercel/analytics": "^1.5.0", - "@vercel/edge-config": "^1.4.0", + "@vercel/edge-config": "^1.4.3", "@vercel/speed-insights": "^1.2.0", - "bcryptjs": "^3.0.2", + "@webcontainer/api": "^1.6.1", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", - "codice": "^1.3.2", - "date-fns": "^3.6.0", - "effect": "^3.16.8", - "esbuild": "^0.25.10", - "framer-motion": "^12.7.4", - "jose": "^6.0.11", - "lucide-react": "^0.503.0", - "next": "^15.5.4", + "codice": "^1.5.2", + "date-fns": "^4.1.0", + "effect": "^3.19.2", + "esbuild": "^0.25.12", + "file-saver": "^2.0.5", + "framer-motion": "^12.23.24", + "jose": "^6.1.0", + "jotai": "^2.15.1", + "jszip": "^3.10.1", + "lucide-react": "^0.553.0", + "monaco-editor": "^0.54.0", + "next": "^16.0.1", "next-themes": "^0.4.6", - "react": "^19.1.1", - "react-day-picker": "^8.10.1", - "react-dom": "^19.1.1", + "react": "^19.2.0", + "react-day-picker": "^9.7.0", + "react-dom": "^19.2.0", + "react-resizable-panels": "^3.0.6", "recharts": "^2.15.3", - "sonner": "^2.0.3", - "tailwindcss": "^4.1.4", - "unfurl.js": "^6.4.0" + "sonner": "^2.0.7", + "tailwindcss": "^4.1.17", + "zod": "^4.1.12" }, "devDependencies": { - "@blog/utils": "workspace:*", - "@effect/cli": "^0.64.2", - "@effect/platform": "^0.91.1", - "@mdx-js/loader": "^3.1.0", - "@mdx-js/react": "^3.1.0", - "@next/mdx": "^15.3.1", - "@shadcn/ui": "workspace:*", - "@tailwindcss/postcss": "^4.1.4", - "@tailwindcss/typography": "^0.5.16", + "@mdx-js/loader": "^3.1.1", + "@mdx-js/react": "^3.1.1", + "@next/mdx": "^16.0.1", + "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/typography": "^0.5.19", + "@types/file-saver": "^2.0.7", "@types/mdx": "^2.0.13", - "@zod/mini": "4.0.0-beta.20250430T185432", - "clsx": "^2.1.1", - "dotenv": "^16.5.0", + "dotenv": "^17.2.3", "image-size": "^2.0.2", - "prettier": "^3.6.2", + "postcss": "^8.5.6", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", "remark-sugar-high": "^0.6.0", - "tailwind-merge": "^3.2.0", - "tw-animate-css": "^1.2.9" + "tw-animate-css": "^1.4.0" } } diff --git a/apps/blog/project.json b/apps/blog/project.json index 1592b81..c035dfa 100644 --- a/apps/blog/project.json +++ b/apps/blog/project.json @@ -8,8 +8,10 @@ "bootstrap": { "executor": "nx:run-commands", "outputs": ["{projectRoot}/src/data"], + "dependsOn": ["^build"], "options": { "env": { + "APP_CONFIG_FILE": "apps/blog/info.json", "NODE_OPTIONS": "--no-warnings --loader @mdx-js/node-loader" }, "cwd": "{workspaceRoot}", @@ -17,10 +19,16 @@ } }, "build": { - "dependsOn": ["bootstrap"] + "dependsOn": ["bootstrap"], + "options": { + "args": ["--webpack"] + } }, "dev": { - "dependsOn": ["bootstrap"] + "dependsOn": ["bootstrap"], + "options": { + "args": ["--webpack"] + } } } } diff --git a/apps/blog/public/vendor/dprint/plugins/json-0.21.0.wasm b/apps/blog/public/vendor/dprint/plugins/json-0.21.0.wasm new file mode 100644 index 0000000..fa14a0d Binary files /dev/null and b/apps/blog/public/vendor/dprint/plugins/json-0.21.0.wasm differ diff --git a/apps/blog/public/vendor/dprint/plugins/typescript-0.95.12.wasm b/apps/blog/public/vendor/dprint/plugins/typescript-0.95.12.wasm new file mode 100644 index 0000000..d0ff64e Binary files /dev/null and b/apps/blog/public/vendor/dprint/plugins/typescript-0.95.12.wasm differ diff --git a/apps/blog/src/app/(private)/about/layout.tsx b/apps/blog/src/app/(private)/about/layout.tsx index 417aa19..f7c5d2a 100644 --- a/apps/blog/src/app/(private)/about/layout.tsx +++ b/apps/blog/src/app/(private)/about/layout.tsx @@ -1,4 +1,4 @@ -import { unstable_ViewTransition as ViewTransition } from 'react' +import { ViewTransition } from 'react' import { Page } from '@/components/page' diff --git a/apps/blog/src/app/(private)/about/page.tsx b/apps/blog/src/app/(private)/about/page.tsx index ce644d2..c465aef 100644 --- a/apps/blog/src/app/(private)/about/page.tsx +++ b/apps/blog/src/app/(private)/about/page.tsx @@ -160,7 +160,7 @@ export default function AboutPage() {
-
+
Portrait of Moa Torres{children} diff --git a/apps/blog/src/app/(public)/articles/[collection]/[slug]/layout.tsx b/apps/blog/src/app/(public)/articles/[collection]/[slug]/layout.tsx index a88b0ea..21b67df 100644 --- a/apps/blog/src/app/(public)/articles/[collection]/[slug]/layout.tsx +++ b/apps/blog/src/app/(public)/articles/[collection]/[slug]/layout.tsx @@ -1,4 +1,4 @@ -import { unstable_ViewTransition as ViewTransition } from 'react' +import { ViewTransition } from 'react' import { ArticleFooter } from '@/components/article-footer' import { FloatingActions } from '@/components/floating-actions' @@ -11,7 +11,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { -
+
{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 +
+ )} +
+ + + + + Create File + + Enter the file name (e.g.,{' '} + + src/utils.ts + {' '} + or + README.md) + + +
+ + setNewItemName(e.target.value)} + placeholder="src/example.ts" + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFile() + }} + className={cn( + 'focus-visible:ring-accent', + isFileNameInvalid && 'opacity-50 bg-foreground/10' + )} + autoFocus + /> + {isFileNameInvalid && ( +

+ A file or folder with this path already exists +

+ )} +
+ + + + +
+
+ + + + + Create New Folder + + Enter the folder path (e.g.,{' '} + + src/components + {' '} + or + utils) + + +
+ + setNewItemName(e.target.value)} + placeholder="src/components" + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFolder() + }} + className={cn( + 'focus-visible:ring-accent', + isFolderNameInvalid && 'opacity-50 bg-foreground/10' + )} + autoFocus + /> + {isFolderNameInvalid && ( +

+ A file or folder with this path already exists +

+ )} +
+ + + + +
+
+
+ ) +} + +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 ( +
+
+ +

Starting server...

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