diff --git a/frontend/.env.example b/frontend/.env.example index 908ff5a..0f1864a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,6 +3,8 @@ CLERK_SECRET_KEY= NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= LIVEBLOCKS_SECRET_KEY= +VERCEL_ENV=development + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/frontend/app/(app)/code/[id]/page.tsx b/frontend/app/(app)/code/[id]/page.tsx index 11365e4..aeb84cf 100644 --- a/frontend/app/(app)/code/[id]/page.tsx +++ b/frontend/app/(app)/code/[id]/page.tsx @@ -1,79 +1,75 @@ -import Navbar from "@/components/editor/navbar"; -import { Room } from "@/components/editor/live/room"; -import { Sandbox, User, UsersToSandboxes } from "@/lib/types"; -import { currentUser } from "@clerk/nextjs"; -import { notFound, redirect } from "next/navigation"; -import Editor from "@/components/editor"; +import Navbar from "@/components/editor/navbar" +import { Room } from "@/components/editor/live/room" +import { Sandbox, User, UsersToSandboxes } from "@/lib/types" +import { currentUser } from "@clerk/nextjs" +import { notFound, redirect } from "next/navigation" +import Editor from "@/components/editor" -export const revalidate = 0; +export const revalidate = 0 const getUserData = async (id: string) => { const userRes = await fetch( `https://database.ishaan1013.workers.dev/api/user?id=${id}` - ); - const userData: User = await userRes.json(); - return userData; -}; + ) + const userData: User = await userRes.json() + return userData +} const getSandboxData = async (id: string) => { const sandboxRes = await fetch( `https://database.ishaan1013.workers.dev/api/sandbox?id=${id}` - ); - const sandboxData: Sandbox = await sandboxRes.json(); - return sandboxData; -}; + ) + const sandboxData: Sandbox = await sandboxRes.json() + return sandboxData +} const getSharedUsers = async (usersToSandboxes: UsersToSandboxes[]) => { if (!usersToSandboxes) { - return []; + return [] } const shared = await Promise.all( usersToSandboxes.map(async (user) => { const userRes = await fetch( `https://database.ishaan1013.workers.dev/api/user?id=${user.userId}` - ); - const userData: User = await userRes.json(); - return { id: userData.id, name: userData.name }; + ) + const userData: User = await userRes.json() + return { id: userData.id, name: userData.name } }) - ); + ) - return shared; -}; + return shared +} export default async function CodePage({ params }: { params: { id: string } }) { - const user = await currentUser(); - const sandboxId = params.id; + const user = await currentUser() + const sandboxId = params.id if (!user) { - redirect("/"); + redirect("/") } - const userData = await getUserData(user.id); - const sandboxData = await getSandboxData(sandboxId); - const shared = await getSharedUsers(sandboxData.usersToSandboxes); + const userData = await getUserData(user.id) + const sandboxData = await getSandboxData(sandboxId) + const shared = await getSharedUsers(sandboxData.usersToSandboxes) - const isOwner = sandboxData.userId === user.id; - const isSharedUser = shared.some((uts) => uts.id === user.id); + const isOwner = sandboxData.userId === user.id + const isSharedUser = shared.some((uts) => uts.id === user.id) if (!isOwner && !isSharedUser) { - return notFound(); + return notFound() } - console.log("sandboxes: ", sandboxData); + console.log("sandboxes: ", sandboxData) return (
- +
- ); + ) } diff --git a/frontend/components/dashboard/projectCard/index.tsx b/frontend/components/dashboard/projectCard/index.tsx index a206499..b082140 100644 --- a/frontend/components/dashboard/projectCard/index.tsx +++ b/frontend/components/dashboard/projectCard/index.tsx @@ -1,13 +1,13 @@ -"use client"; +"use client" -import { AnimatePresence, motion } from "framer-motion"; -import Image from "next/image"; -import { useEffect, useState } from "react"; -import ProjectCardDropdown from "./dropdown"; -import { Clock, Globe, Lock } from "lucide-react"; -import { Sandbox } from "@/lib/types"; -import { Card } from "@/components/ui/card"; -import { useRouter } from "next/navigation"; +import { AnimatePresence, motion } from "framer-motion" +import Image from "next/image" +import { useEffect, useState } from "react" +import ProjectCardDropdown from "./dropdown" +import { Clock, Globe, Lock } from "lucide-react" +import { Sandbox } from "@/lib/types" +import { Card } from "@/components/ui/card" +import { useRouter } from "next/navigation" export default function ProjectCard({ children, @@ -16,33 +16,33 @@ export default function ProjectCard({ onDelete, deletingId, }: { - children?: React.ReactNode; - sandbox: Sandbox; - onVisibilityChange: (sandbox: Sandbox) => void; - onDelete: (sandbox: Sandbox) => void; - deletingId: string; + children?: React.ReactNode + sandbox: Sandbox + onVisibilityChange: (sandbox: Sandbox) => void + onDelete: (sandbox: Sandbox) => void + deletingId: string }) { - const [hovered, setHovered] = useState(false); - const [date, setDate] = useState(); - const router = useRouter(); + const [hovered, setHovered] = useState(false) + const [date, setDate] = useState() + const router = useRouter() useEffect(() => { - const createdAt = new Date(sandbox.createdAt); - const now = new Date(); + const createdAt = new Date(sandbox.createdAt) + const now = new Date() const diffInMinutes = Math.floor( (now.getTime() - createdAt.getTime()) / 60000 - ); + ) if (diffInMinutes < 1) { - setDate("Now"); + setDate("Now") } else if (diffInMinutes < 60) { - setDate(`${diffInMinutes}m ago`); + setDate(`${diffInMinutes}m ago`) } else if (diffInMinutes < 1440) { - setDate(`${Math.floor(diffInMinutes / 60)}h ago`); + setDate(`${Math.floor(diffInMinutes / 60)}h ago`) } else { - setDate(`${Math.floor(diffInMinutes / 1440)}d ago`); + setDate(`${Math.floor(diffInMinutes / 1440)}d ago`) } - }, [sandbox]); + }, [sandbox]) return ( setHovered(false)} className={`group/canvas-card p-4 h-48 flex flex-col justify-between items-start hover:border-muted-foreground/50 relative overflow-hidden transition-all`} > - {/* + {hovered && ( )} - */} +
- ); + ) } diff --git a/frontend/components/editor/editor.tsx b/frontend/components/editor/editor.tsx index 1cfc8c9..c3f3a20 100644 --- a/frontend/components/editor/editor.tsx +++ b/frontend/components/editor/editor.tsx @@ -1,110 +1,109 @@ -"use client"; +"use client" -import { useEffect, useRef, useState } from "react"; -import monaco from "monaco-editor"; -import Editor, { BeforeMount, OnMount } from "@monaco-editor/react"; -import { io } from "socket.io-client"; -import { toast } from "sonner"; -import { useClerk } from "@clerk/nextjs"; +import { useEffect, useRef, useState } from "react" +import monaco from "monaco-editor" +import Editor, { BeforeMount, OnMount } from "@monaco-editor/react" +import { io } from "socket.io-client" +import { toast } from "sonner" +import { useClerk } from "@clerk/nextjs" -import * as Y from "yjs"; -import LiveblocksProvider from "@liveblocks/yjs"; -import { MonacoBinding } from "y-monaco"; -import { Awareness } from "y-protocols/awareness"; -import { TypedLiveblocksProvider, useRoom } from "@/liveblocks.config"; +import * as Y from "yjs" +import LiveblocksProvider from "@liveblocks/yjs" +import { MonacoBinding } from "y-monaco" +import { Awareness } from "y-protocols/awareness" +import { TypedLiveblocksProvider, useRoom } from "@/liveblocks.config" import { ResizableHandle, ResizablePanel, ResizablePanelGroup, -} from "@/components/ui/resizable"; -import { FileJson, Loader2, TerminalSquare } from "lucide-react"; -import Tab from "../ui/tab"; -import Sidebar from "./sidebar"; -import GenerateInput from "./generate"; -import { Sandbox, User, TFile, TFolder, TTab } from "@/lib/types"; -import { addNew, processFileType, validateName } from "@/lib/utils"; -import { Cursors } from "./live/cursors"; -import { Terminal } from "@xterm/xterm"; -import DisableAccessModal from "./live/disableModal"; -import Loading from "./loading"; -import PreviewWindow from "./preview"; -import Terminals from "./terminals"; -import { ImperativePanelHandle } from "react-resizable-panels"; +} from "@/components/ui/resizable" +import { FileJson, Loader2, TerminalSquare } from "lucide-react" +import Tab from "../ui/tab" +import Sidebar from "./sidebar" +import GenerateInput from "./generate" +import { Sandbox, User, TFile, TFolder, TTab } from "@/lib/types" +import { addNew, processFileType, validateName } from "@/lib/utils" +import { Cursors } from "./live/cursors" +import { Terminal } from "@xterm/xterm" +import DisableAccessModal from "./live/disableModal" +import Loading from "./loading" +import PreviewWindow from "./preview" +import Terminals from "./terminals" +import { ImperativePanelHandle } from "react-resizable-panels" export default function CodeEditor({ userData, sandboxData, ip, }: { - userData: User; - sandboxData: Sandbox; - ip: string; + userData: User + sandboxData: Sandbox + ip: string }) { const socket = io( - // `ws://${sandboxData.id}.ws.ishaand.com?userId=${userData.id}&sandboxId=${sandboxData.id}` `http://${ip}:4000?userId=${userData.id}&sandboxId=${sandboxData.id}`, { timeout: 2000, } - ); + ) - const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true); + const [isPreviewCollapsed, setIsPreviewCollapsed] = useState(true) const [disableAccess, setDisableAccess] = useState({ isDisabled: false, message: "", - }); + }) // File state - const [files, setFiles] = useState<(TFolder | TFile)[]>([]); - const [tabs, setTabs] = useState([]); - const [activeFileId, setActiveFileId] = useState(""); - const [activeFileContent, setActiveFileContent] = useState(""); - const [deletingFolderId, setDeletingFolderId] = useState(""); + const [files, setFiles] = useState<(TFolder | TFile)[]>([]) + const [tabs, setTabs] = useState([]) + const [activeFileId, setActiveFileId] = useState("") + const [activeFileContent, setActiveFileContent] = useState("") + const [deletingFolderId, setDeletingFolderId] = useState("") // Editor state - const [editorLanguage, setEditorLanguage] = useState("plaintext"); - const [cursorLine, setCursorLine] = useState(0); + const [editorLanguage, setEditorLanguage] = useState("plaintext") + const [cursorLine, setCursorLine] = useState(0) const [editorRef, setEditorRef] = - useState(); + useState() // AI Copilot state - const [ai, setAi] = useState(false); + const [ai, setAi] = useState(false) const [generate, setGenerate] = useState<{ - show: boolean; - id: string; - line: number; - widget: monaco.editor.IContentWidget | undefined; - pref: monaco.editor.ContentWidgetPositionPreference[]; - width: number; - }>({ show: false, line: 0, id: "", widget: undefined, pref: [], width: 0 }); + show: boolean + id: string + line: number + widget: monaco.editor.IContentWidget | undefined + pref: monaco.editor.ContentWidgetPositionPreference[] + width: number + }>({ show: false, line: 0, id: "", widget: undefined, pref: [], width: 0 }) const [decorations, setDecorations] = useState<{ - options: monaco.editor.IModelDeltaDecoration[]; - instance: monaco.editor.IEditorDecorationsCollection | undefined; - }>({ options: [], instance: undefined }); + options: monaco.editor.IModelDeltaDecoration[] + instance: monaco.editor.IEditorDecorationsCollection | undefined + }>({ options: [], instance: undefined }) // Terminal state const [terminals, setTerminals] = useState< { - id: string; - terminal: Terminal | null; + id: string + terminal: Terminal | null }[] - >([]); + >([]) - const isOwner = sandboxData.userId === userData.id; - const clerk = useClerk(); + const isOwner = sandboxData.userId === userData.id + const clerk = useClerk() // Liveblocks hooks - const room = useRoom(); - const [provider, setProvider] = useState(); + const room = useRoom() + const [provider, setProvider] = useState() // Refs for libraries / features - const editorContainerRef = useRef(null); - const monacoRef = useRef(null); - const generateRef = useRef(null); - const generateWidgetRef = useRef(null); - const previewPanelRef = useRef(null); - const editorPanelRef = useRef(null); + const editorContainerRef = useRef(null) + const monacoRef = useRef(null) + const generateRef = useRef(null) + const generateWidgetRef = useRef(null) + const previewPanelRef = useRef(null) + const editorPanelRef = useRef(null) // Pre-mount editor keybindings const handleEditorWillMount: BeforeMount = (monaco) => { @@ -113,21 +112,21 @@ export default function CodeEditor({ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyG, command: "null", }, - ]); - }; + ]) + } // Post-mount editor keybindings and actions const handleEditorMount: OnMount = (editor, monaco) => { - setEditorRef(editor); - monacoRef.current = monaco; + setEditorRef(editor) + monacoRef.current = monaco editor.onDidChangeCursorPosition((e) => { - const { column, lineNumber } = e.position; - if (lineNumber === cursorLine) return; - setCursorLine(lineNumber); + const { column, lineNumber } = e.position + if (lineNumber === cursorLine) return + setCursorLine(lineNumber) - const model = editor.getModel(); - const endColumn = model?.getLineContent(lineNumber).length || 0; + const model = editor.getModel() + const endColumn = model?.getLineContent(lineNumber).length || 0 setDecorations((prev) => { return { @@ -145,18 +144,18 @@ export default function CodeEditor({ }, }, ], - }; - }); - }); + } + }) + }) editor.onDidBlurEditorText((e) => { setDecorations((prev) => { return { ...prev, options: [], - }; - }); - }); + } + }) + }) editor.addAction({ id: "generate", @@ -170,11 +169,11 @@ export default function CodeEditor({ ...prev, show: !prev.show, pref: [monaco.editor.ContentWidgetPositionPreference.BELOW], - }; - }); + } + }) }, - }); - }; + }) + } // Generate widget effect useEffect(() => { @@ -183,32 +182,32 @@ export default function CodeEditor({ return { ...prev, show: false, - }; - }); - return; + } + }) + return } if (generate.show) { editorRef?.changeViewZones(function (changeAccessor) { - if (!generateRef.current) return; + if (!generateRef.current) return const id = changeAccessor.addZone({ afterLineNumber: cursorLine, heightInLines: 3, domNode: generateRef.current, - }); + }) setGenerate((prev) => { - return { ...prev, id, line: cursorLine }; - }); - }); + return { ...prev, id, line: cursorLine } + }) + }) - if (!generateWidgetRef.current) return; - const widgetElement = generateWidgetRef.current; + if (!generateWidgetRef.current) return + const widgetElement = generateWidgetRef.current const contentWidget = { getDomNode: () => { - return widgetElement; + return widgetElement }, getId: () => { - return "generate.widget"; + return "generate.widget" }, getPosition: () => { return { @@ -217,232 +216,232 @@ export default function CodeEditor({ column: 1, }, preference: generate.pref, - }; + } }, - }; + } // window width - sidebar width, times the percentage of the editor panel const width = editorPanelRef.current ? (editorPanelRef.current.getSize() / 100) * (window.innerWidth - 224) - : 400; //fallback + : 400 //fallback setGenerate((prev) => { return { ...prev, widget: contentWidget, width, - }; - }); - editorRef?.addContentWidget(contentWidget); + } + }) + editorRef?.addContentWidget(contentWidget) if (generateRef.current && generateWidgetRef.current) { - editorRef?.applyFontInfo(generateRef.current); - editorRef?.applyFontInfo(generateWidgetRef.current); + editorRef?.applyFontInfo(generateRef.current) + editorRef?.applyFontInfo(generateWidgetRef.current) } } else { editorRef?.changeViewZones(function (changeAccessor) { - changeAccessor.removeZone(generate.id); + changeAccessor.removeZone(generate.id) setGenerate((prev) => { - return { ...prev, id: "" }; - }); - }); + return { ...prev, id: "" } + }) + }) - if (!generate.widget) return; - editorRef?.removeContentWidget(generate.widget); + if (!generate.widget) return + editorRef?.removeContentWidget(generate.widget) setGenerate((prev) => { return { ...prev, widget: undefined, - }; - }); + } + }) } - }, [generate.show]); + }, [generate.show]) // Decorations effect for generate widget tips useEffect(() => { if (decorations.options.length === 0) { - decorations.instance?.clear(); + decorations.instance?.clear() } - if (!ai) return; + if (!ai) return if (decorations.instance) { - decorations.instance.set(decorations.options); + decorations.instance.set(decorations.options) } else { - const instance = editorRef?.createDecorationsCollection(); - instance?.set(decorations.options); + const instance = editorRef?.createDecorationsCollection() + instance?.set(decorations.options) setDecorations((prev) => { return { ...prev, instance, - }; - }); + } + }) } - }, [decorations.options]); + }, [decorations.options]) // Save file keybinding logic effect useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "s" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); + e.preventDefault() setTabs((prev) => prev.map((tab) => tab.id === activeFileId ? { ...tab, saved: true } : tab ) - ); + ) - socket.emit("saveFile", activeFileId, editorRef?.getValue()); + socket.emit("saveFile", activeFileId, editorRef?.getValue()) } - }; - document.addEventListener("keydown", down); + } + document.addEventListener("keydown", down) return () => { - document.removeEventListener("keydown", down); - }; - }, [tabs, activeFileId]); + document.removeEventListener("keydown", down) + } + }, [tabs, activeFileId]) // Liveblocks live collaboration setup effect useEffect(() => { - const tab = tabs.find((t) => t.id === activeFileId); - const model = editorRef?.getModel(); + const tab = tabs.find((t) => t.id === activeFileId) + const model = editorRef?.getModel() - if (!editorRef || !tab || !model) return; + if (!editorRef || !tab || !model) return - const yDoc = new Y.Doc(); - const yText = yDoc.getText(tab.id); - const yProvider: any = new LiveblocksProvider(room, yDoc); + const yDoc = new Y.Doc() + const yText = yDoc.getText(tab.id) + const yProvider: any = new LiveblocksProvider(room, yDoc) const onSync = (isSynced: boolean) => { if (isSynced) { - const text = yText.toString(); + const text = yText.toString() if (text === "") { if (activeFileContent) { - yText.insert(0, activeFileContent); + yText.insert(0, activeFileContent) } else { setTimeout(() => { - yText.insert(0, editorRef.getValue()); - }, 0); + yText.insert(0, editorRef.getValue()) + }, 0) } } } - }; + } - yProvider.on("sync", onSync); + yProvider.on("sync", onSync) - setProvider(yProvider); + setProvider(yProvider) const binding = new MonacoBinding( yText, model, new Set([editorRef]), yProvider.awareness as Awareness - ); + ) return () => { - yDoc.destroy(); - yProvider.destroy(); - binding.destroy(); - yProvider.off("sync", onSync); - }; - }, [editorRef, room, activeFileContent]); + yDoc.destroy() + yProvider.destroy() + binding.destroy() + yProvider.off("sync", onSync) + } + }, [editorRef, room, activeFileContent]) // Connection/disconnection effect useEffect(() => { - socket.connect(); + socket.connect() return () => { - socket.disconnect(); - }; - }, []); + socket.disconnect() + } + }, []) // Socket event listener effect useEffect(() => { - const onConnect = () => {}; + const onConnect = () => {} const onDisconnect = () => { - setTerminals([]); - }; + setTerminals([]) + } const onLoadedEvent = (files: (TFolder | TFile)[]) => { - setFiles(files); - }; + setFiles(files) + } const onRateLimit = (message: string) => { - toast.error(message); - }; + toast.error(message) + } const onTerminalResponse = (response: { id: string; data: string }) => { - const term = terminals.find((t) => t.id === response.id); + const term = terminals.find((t) => t.id === response.id) if (term && term.terminal) { - term.terminal.write(response.data); + term.terminal.write(response.data) } - }; + } const onDisableAccess = (message: string) => { if (!isOwner) setDisableAccess({ isDisabled: true, message, - }); - }; + }) + } - socket.on("connect", onConnect); - socket.on("disconnect", onDisconnect); - socket.on("loaded", onLoadedEvent); - socket.on("rateLimit", onRateLimit); - socket.on("terminalResponse", onTerminalResponse); - socket.on("disableAccess", onDisableAccess); + socket.on("connect", onConnect) + socket.on("disconnect", onDisconnect) + socket.on("loaded", onLoadedEvent) + socket.on("rateLimit", onRateLimit) + socket.on("terminalResponse", onTerminalResponse) + socket.on("disableAccess", onDisableAccess) return () => { - socket.off("connect", onConnect); - socket.off("disconnect", onDisconnect); - socket.off("loaded", onLoadedEvent); - socket.off("rateLimit", onRateLimit); - socket.off("terminalResponse", onTerminalResponse); - socket.off("disableAccess", onDisableAccess); - }; + socket.off("connect", onConnect) + socket.off("disconnect", onDisconnect) + socket.off("loaded", onLoadedEvent) + socket.off("rateLimit", onRateLimit) + socket.off("terminalResponse", onTerminalResponse) + socket.off("disableAccess", onDisableAccess) + } // }, []); - }, [terminals]); + }, [terminals]) // Helper functions for tabs: // Select file and load content const selectFile = (tab: TTab) => { - if (tab.id === activeFileId) return; + if (tab.id === activeFileId) return setGenerate((prev) => { return { ...prev, show: false, - }; - }); - const exists = tabs.find((t) => t.id === tab.id); + } + }) + const exists = tabs.find((t) => t.id === tab.id) setTabs((prev) => { if (exists) { - setActiveFileId(exists.id); - return prev; + setActiveFileId(exists.id) + return prev } - return [...prev, tab]; - }); + return [...prev, tab] + }) socket.emit("getFile", tab.id, (response: string) => { - setActiveFileContent(response); - }); - setEditorLanguage(processFileType(tab.name)); - setActiveFileId(tab.id); - }; + setActiveFileContent(response) + }) + setEditorLanguage(processFileType(tab.name)) + setActiveFileId(tab.id) + } // Close tab and remove from tabs const closeTab = (id: string) => { - const numTabs = tabs.length; - const index = tabs.findIndex((t) => t.id === id); + const numTabs = tabs.length + const index = tabs.findIndex((t) => t.id === id) - console.log("closing tab", id, index); + console.log("closing tab", id, index) - if (index === -1) return; + if (index === -1) return const nextId = activeFileId === id @@ -451,49 +450,49 @@ export default function CodeEditor({ : index < numTabs - 1 ? tabs[index + 1].id : tabs[index - 1].id - : activeFileId; + : activeFileId - setTabs((prev) => prev.filter((t) => t.id !== id)); + setTabs((prev) => prev.filter((t) => t.id !== id)) if (!nextId) { - setActiveFileId(""); + setActiveFileId("") } else { - const nextTab = tabs.find((t) => t.id === nextId); + const nextTab = tabs.find((t) => t.id === nextId) if (nextTab) { - selectFile(nextTab); + selectFile(nextTab) } } - }; + } const closeTabs = (ids: string[]) => { - const numTabs = tabs.length; + const numTabs = tabs.length - if (numTabs === 0) return; + if (numTabs === 0) return - const allIndexes = ids.map((id) => tabs.findIndex((t) => t.id === id)); + const allIndexes = ids.map((id) => tabs.findIndex((t) => t.id === id)) - const indexes = allIndexes.filter((index) => index !== -1); - if (indexes.length === 0) return; + const indexes = allIndexes.filter((index) => index !== -1) + if (indexes.length === 0) return - console.log("closing tabs", ids, indexes); + console.log("closing tabs", ids, indexes) - const activeIndex = tabs.findIndex((t) => t.id === activeFileId); + const activeIndex = tabs.findIndex((t) => t.id === activeFileId) - const newTabs = tabs.filter((t) => !ids.includes(t.id)); - setTabs(newTabs); + const newTabs = tabs.filter((t) => !ids.includes(t.id)) + setTabs(newTabs) if (indexes.length === numTabs) { - setActiveFileId(""); + setActiveFileId("") } else { const nextTab = newTabs.length > activeIndex ? newTabs[activeIndex] - : newTabs[newTabs.length - 1]; + : newTabs[newTabs.length - 1] if (nextTab) { - selectFile(nextTab); + selectFile(nextTab) } } - }; + } const handleRename = ( id: string, @@ -501,40 +500,40 @@ export default function CodeEditor({ oldName: string, type: "file" | "folder" ) => { - const valid = validateName(newName, oldName, type); + const valid = validateName(newName, oldName, type) if (!valid.status) { - if (valid.message) toast.error("Invalid file name."); - return false; + if (valid.message) toast.error("Invalid file name.") + return false } - socket.emit("renameFile", id, newName); + socket.emit("renameFile", id, newName) setTabs((prev) => prev.map((tab) => (tab.id === id ? { ...tab, name: newName } : tab)) - ); + ) - return true; - }; + return true + } const handleDeleteFile = (file: TFile) => { socket.emit("deleteFile", file.id, (response: (TFolder | TFile)[]) => { - setFiles(response); - }); - closeTab(file.id); - }; + setFiles(response) + }) + closeTab(file.id) + } const handleDeleteFolder = (folder: TFolder) => { - setDeletingFolderId(folder.id); - console.log("deleting folder", folder.id); + setDeletingFolderId(folder.id) + console.log("deleting folder", folder.id) socket.emit("getFolder", folder.id, (response: string[]) => closeTabs(response) - ); + ) socket.emit("deleteFolder", folder.id, (response: (TFolder | TFile)[]) => { - setFiles(response); - setDeletingFolderId(""); - }); - }; + setFiles(response) + setDeletingFolderId("") + }) + } // On disabled access for shared users, show un-interactable loading placeholder + info modal if (disableAccess.isDisabled) @@ -547,7 +546,7 @@ export default function CodeEditor({ /> - ); + ) return ( <> @@ -569,41 +568,41 @@ export default function CodeEditor({ }} onExpand={() => { editorRef?.changeViewZones(function (changeAccessor) { - changeAccessor.removeZone(generate.id); + changeAccessor.removeZone(generate.id) - if (!generateRef.current) return; + if (!generateRef.current) return const id = changeAccessor.addZone({ afterLineNumber: cursorLine, heightInLines: 12, domNode: generateRef.current, - }); + }) setGenerate((prev) => { - return { ...prev, id }; - }); - }); + return { ...prev, id } + }) + }) }} onAccept={(code: string) => { - const line = generate.line; + const line = generate.line setGenerate((prev) => { return { ...prev, show: !prev.show, - }; - }); - const file = editorRef?.getValue(); + } + }) + const file = editorRef?.getValue() - const lines = file?.split("\n") || []; - lines.splice(line - 1, 0, code); - const updatedFile = lines.join("\n"); - editorRef?.setValue(updatedFile); + const lines = file?.split("\n") || [] + lines.splice(line - 1, 0, code) + const updatedFile = lines.join("\n") + editorRef?.setValue(updatedFile) }} onClose={() => { setGenerate((prev) => { return { ...prev, show: !prev.show, - }; - }); + } + }) }} /> ) : null} @@ -643,7 +642,7 @@ export default function CodeEditor({ saved={tab.saved} selected={activeFileId === tab.id} onClick={(e) => { - selectFile(tab); + selectFile(tab) }} onClose={() => closeTab(tab.id)} > @@ -680,7 +679,7 @@ export default function CodeEditor({ ? { ...tab, saved: true } : tab ) - ); + ) } else { setTabs((prev) => prev.map((tab) => @@ -688,7 +687,7 @@ export default function CodeEditor({ ? { ...tab, saved: false } : tab ) - ); + ) } }} options={{ @@ -732,8 +731,8 @@ export default function CodeEditor({ { - previewPanelRef.current?.expand(); - setIsPreviewCollapsed(false); + previewPanelRef.current?.expand() + setIsPreviewCollapsed(false) }} ip={ip} /> @@ -761,5 +760,5 @@ export default function CodeEditor({ - ); + ) } diff --git a/frontend/components/editor/generate.tsx b/frontend/components/editor/generate.tsx index e8bcc47..b1c6123 100644 --- a/frontend/components/editor/generate.tsx +++ b/frontend/components/editor/generate.tsx @@ -1,13 +1,13 @@ -"use client"; +"use client" -import { useEffect, useRef, useState } from "react"; -import { Button } from "../ui/button"; -import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"; -import { Socket } from "socket.io-client"; -import { Editor } from "@monaco-editor/react"; -import { User } from "@/lib/types"; -import { toast } from "sonner"; -import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react" +import { Button } from "../ui/button" +import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react" +import { Socket } from "socket.io-client" +import { Editor } from "@monaco-editor/react" +import { User } from "@/lib/types" +import { toast } from "sonner" +import { usePathname, useRouter } from "next/navigation" // import monaco from "monaco-editor" export default function GenerateInput({ @@ -20,54 +20,54 @@ export default function GenerateInput({ onAccept, onClose, }: { - user: User; - socket: Socket; - width: number; + user: User + socket: Socket + width: number data: { - fileName: string; - code: string; - line: number; - }; + fileName: string + code: string + line: number + } editor: { - language: string; - }; - onExpand: () => void; - onAccept: (code: string) => void; - onClose: () => void; + language: string + } + onExpand: () => void + onAccept: (code: string) => void + onClose: () => void }) { - const pathname = usePathname(); - const router = useRouter(); - const inputRef = useRef(null); + const pathname = usePathname() + const router = useRouter() + const inputRef = useRef(null) - const [code, setCode] = useState(""); - const [expanded, setExpanded] = useState(false); + const [code, setCode] = useState("") + const [expanded, setExpanded] = useState(false) const [loading, setLoading] = useState({ generate: false, regenerate: false, - }); - const [input, setInput] = useState(""); - const [currentPrompt, setCurrentPrompt] = useState(""); + }) + const [input, setInput] = useState("") + const [currentPrompt, setCurrentPrompt] = useState("") useEffect(() => { setTimeout(() => { - inputRef.current?.focus(); - }, 100); - }, [inputRef.current]); + inputRef.current?.focus() + }, 100) + }, [inputRef.current]) const handleGenerate = async ({ regenerate = false, }: { - regenerate?: boolean; + regenerate?: boolean }) => { if (user.generations >= 10) { toast.error( "You reached the maximum # of generations. Contact @ishaandey_ on X/Twitter to reset :)" - ); - return; + ) + return } - setLoading({ generate: !regenerate, regenerate }); - setCurrentPrompt(input); + setLoading({ generate: !regenerate, regenerate }) + setCurrentPrompt(input) socket.emit( "generateCode", data.fileName, @@ -75,25 +75,25 @@ export default function GenerateInput({ data.line, regenerate ? currentPrompt : input, (res: { response: string; success: boolean }) => { - console.log("Generated code", res.response, res.success); + console.log("Generated code", res.response, res.success) // if (!res.success) { // toast.error("Failed to generate code."); // return; // } - setCode(res.response); - router.refresh(); + setCode(res.response) + router.refresh() } - ); - }; + ) + } useEffect(() => { if (code) { - setExpanded(true); - onExpand(); - setLoading({ generate: false, regenerate: false }); + setExpanded(true) + onExpand() + setLoading({ generate: false, regenerate: false }) } - }, [code]); + }, [code]) return (
@@ -105,7 +105,7 @@ export default function GenerateInput({ }} value={input} onChange={(e) => setInput(e.target.value)} - placeholder="✨ Generate code with a prompt" + placeholder="Generate code with a prompt" className="h-8 w-full rounded-md border border-muted-foreground bg-transparent px-3 py-1 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" /> @@ -192,5 +192,5 @@ export default function GenerateInput({ ) : null}
- ); + ) } diff --git a/frontend/components/editor/index.tsx b/frontend/components/editor/index.tsx index 25b37fd..ee556a1 100644 --- a/frontend/components/editor/index.tsx +++ b/frontend/components/editor/index.tsx @@ -1,68 +1,51 @@ -"use client"; +"use client" -import dynamic from "next/dynamic"; -import Loading from "@/components/editor/loading"; -import { Sandbox, User } from "@/lib/types"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { getTaskIp, startServer } from "@/lib/actions"; -import { checkServiceStatus } from "@/lib/utils"; +import dynamic from "next/dynamic" +import Loading from "@/components/editor/loading" +import { Sandbox, User } from "@/lib/types" +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { getTaskIp, startServer } from "@/lib/actions" +import { checkServiceStatus, setupServer } from "@/lib/utils" const CodeEditor = dynamic(() => import("@/components/editor/editor"), { ssr: false, loading: () => , -}); +}) export default function Editor({ - isOwner, userData, sandboxData, }: { - isOwner: boolean; - userData: User; - sandboxData: Sandbox; + userData: User + sandboxData: Sandbox }) { - const [isServiceRunning, setIsServiceRunning] = useState(false); - const [isDeploymentActive, setIsDeploymentActive] = useState(false); - const [taskIp, setTaskIp] = useState(); - const [didFail, setDidFail] = useState(false); + const isDev = process.env.VERCEL_ENV === "development" + + const [isServiceRunning, setIsServiceRunning] = useState(false) + const [isDeploymentActive, setIsDeploymentActive] = useState(false) + const [taskIp, setTaskIp] = useState() + const [didFail, setDidFail] = useState(false) useEffect(() => { - if (!isOwner) { - toast.error("You are not the owner of this sandbox. (TEMPORARY)"); - setDidFail(true); - return; + if (isDev) { + setIsServiceRunning(true) + setIsDeploymentActive(true) + setTaskIp("localhost") + return } - startServer(sandboxData.id).then((response) => { - if (!response.success) { - toast.error(response.message); - setDidFail(true); - } else { - setIsServiceRunning(true); + setupServer({ + sandboxId: sandboxData.id, + setIsServiceRunning, + setIsDeploymentActive, + setTaskIp, + setDidFail, + toast, + }) + }, []) - checkServiceStatus(sandboxData.id) - .then(() => { - setIsDeploymentActive(true); - - getTaskIp(sandboxData.id) - .then((ip) => { - setTaskIp(ip); - }) - .catch(() => { - setDidFail(true); - toast.error("An error occurred while getting your server IP."); - }); - }) - .catch(() => { - toast.error("An error occurred while initializing your server."); - setDidFail(true); - }); - } - }); - }, []); - - if (didFail) return ; + if (didFail) return if (!isServiceRunning || !isDeploymentActive || !taskIp) return ( - ); + ) return ( - ); + ) } diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts index 432b859..d335a87 100644 --- a/frontend/lib/actions.ts +++ b/frontend/lib/actions.ts @@ -1,21 +1,21 @@ -"use server"; +"use server" -import { revalidatePath } from "next/cache"; -import ecsClient, { ec2Client } from "./ecs"; +import { revalidatePath } from "next/cache" +import ecsClient, { ec2Client } from "./ecs" import { CreateServiceCommand, DescribeClustersCommand, DescribeServicesCommand, DescribeTasksCommand, ListTasksCommand, -} from "@aws-sdk/client-ecs"; -import { DescribeNetworkInterfacesCommand } from "@aws-sdk/client-ec2"; +} from "@aws-sdk/client-ecs" +import { DescribeNetworkInterfacesCommand } from "@aws-sdk/client-ec2" export async function createSandbox(body: { - type: string; - name: string; - userId: string; - visibility: string; + type: string + name: string + userId: string + visibility: string }) { const res = await fetch( "https://database.ishaan1013.workers.dev/api/sandbox", @@ -26,15 +26,15 @@ export async function createSandbox(body: { }, body: JSON.stringify(body), } - ); + ) - return await res.text(); + return await res.text() } export async function updateSandbox(body: { - id: string; - name?: string; - visibility?: "public" | "private"; + id: string + name?: string + visibility?: "public" | "private" }) { await fetch("https://database.ishaan1013.workers.dev/api/sandbox", { method: "POST", @@ -42,17 +42,17 @@ export async function updateSandbox(body: { "Content-Type": "application/json", }, body: JSON.stringify(body), - }); + }) - revalidatePath("/dashboard"); + revalidatePath("/dashboard") } export async function deleteSandbox(id: string) { await fetch(`https://database.ishaan1013.workers.dev/api/sandbox?id=${id}`, { method: "DELETE", - }); + }) - revalidatePath("/dashboard"); + revalidatePath("/dashboard") } export async function shareSandbox(sandboxId: string, email: string) { @@ -65,15 +65,15 @@ export async function shareSandbox(sandboxId: string, email: string) { }, body: JSON.stringify({ sandboxId, email }), } - ); - const text = await res.text(); + ) + const text = await res.text() if (res.status !== 200) { - return { success: false, message: text }; + return { success: false, message: text } } - revalidatePath(`/code/${sandboxId}`); - return { success: true, message: "Shared successfully." }; + revalidatePath(`/code/${sandboxId}`) + return { success: true, message: "Shared successfully." } } export async function unshareSandbox(sandboxId: string, userId: string) { @@ -83,102 +83,102 @@ export async function unshareSandbox(sandboxId: string, userId: string) { "Content-Type": "application/json", }, body: JSON.stringify({ sandboxId, userId }), - }); + }) - revalidatePath(`/code/${sandboxId}`); + revalidatePath(`/code/${sandboxId}`) } export async function describeService(serviceName: string) { const command = new DescribeServicesCommand({ cluster: process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!, services: [serviceName], - }); + }) - return await ecsClient.send(command); + return await ecsClient.send(command) } export async function getTaskIp(serviceName: string) { const listCommand = new ListTasksCommand({ cluster: process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!, serviceName, - }); + }) - const listResponse = await ecsClient.send(listCommand); - const taskArns = listResponse.taskArns; + const listResponse = await ecsClient.send(listCommand) + const taskArns = listResponse.taskArns const describeCommand = new DescribeTasksCommand({ cluster: process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!, tasks: taskArns, - }); + }) - const describeResponse = await ecsClient.send(describeCommand); - const tasks = describeResponse.tasks; - const taskAttachment = tasks?.[0].attachments?.[0].details; + const describeResponse = await ecsClient.send(describeCommand) + const tasks = describeResponse.tasks + const taskAttachment = tasks?.[0].attachments?.[0].details if (!taskAttachment) { - throw new Error("Task attachment not found"); + throw new Error("Task attachment not found") } const eni = taskAttachment.find( (detail) => detail.name === "networkInterfaceId" - )?.value; + )?.value if (!eni) { - throw new Error("Network interface not found"); + throw new Error("Network interface not found") } const describeNetworkInterfacesCommand = new DescribeNetworkInterfacesCommand( { NetworkInterfaceIds: [eni], } - ); + ) const describeNetworkInterfacesResponse = await ec2Client.send( describeNetworkInterfacesCommand - ); + ) const ip = describeNetworkInterfacesResponse.NetworkInterfaces?.[0].Association - ?.PublicIp; + ?.PublicIp if (!ip) { - throw new Error("Public IP not found"); + throw new Error("Public IP not found") } - return ip; + return ip } -async function doesServiceExist(serviceName: string) { - const response = await describeService(serviceName); +export async function doesServiceExist(serviceName: string) { + const response = await describeService(serviceName) const activeServices = response.services?.filter( (service) => service.status === "ACTIVE" - ); + ) - console.log("activeServices: ", activeServices); + console.log("activeServices: ", activeServices) - return activeServices?.length === 1; + return activeServices?.length === 1 } async function countServices() { const command = new DescribeClustersCommand({ clusters: [process.env.NEXT_PUBLIC_AWS_ECS_CLUSTER!], - }); + }) - const response = await ecsClient.send(command); - return response.clusters?.[0].activeServicesCount!; + const response = await ecsClient.send(command) + return response.clusters?.[0].activeServicesCount! } export async function startServer( serviceName: string ): Promise<{ success: boolean; message: string }> { - const serviceExists = await doesServiceExist(serviceName); + const serviceExists = await doesServiceExist(serviceName) if (serviceExists) { - return { success: true, message: "" }; + return { success: true, message: "" } } - const activeServices = await countServices(); + const activeServices = await countServices() if (activeServices >= 100) { return { success: false, message: "Too many servers are running! Please try again later or contact @ishaandey_ on Twitter/X.", - }; + } } const command = new CreateServiceCommand({ @@ -201,13 +201,13 @@ export async function startServer( assignPublicIp: "ENABLED", }, }, - }); + }) try { - const response = await ecsClient.send(command); - console.log("started server:", response.service?.serviceName); + const response = await ecsClient.send(command) + console.log("started server:", response.service?.serviceName) - return { success: true, message: "" }; + return { success: true, message: "" } // store in workers kv: // { @@ -223,6 +223,6 @@ export async function startServer( return { success: false, message: `Error starting server: ${error.message}. Try again in a minute, or contact @ishaandey_ on Twitter/X if it still doesn't work.`, - }; + } } } diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 137d45d..c56ff4b 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -1,22 +1,27 @@ -import { type ClassValue, clsx } from "clsx"; +import { type ClassValue, clsx } from "clsx" // import { toast } from "sonner" -import { twMerge } from "tailwind-merge"; -import { Sandbox, TFile, TFolder } from "./types"; -import { Service } from "@aws-sdk/client-ecs"; -import { describeService } from "./actions"; +import { twMerge } from "tailwind-merge" +import { Sandbox, TFile, TFolder } from "./types" +import { Service } from "@aws-sdk/client-ecs" +import { + describeService, + doesServiceExist, + getTaskIp, + startServer, +} from "./actions" export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)) } export function processFileType(file: string) { - const ending = file.split(".").pop(); + const ending = file.split(".").pop() - if (ending === "ts" || ending === "tsx") return "typescript"; - if (ending === "js" || ending === "jsx") return "javascript"; + if (ending === "ts" || ending === "tsx") return "typescript" + if (ending === "js" || ending === "jsx") return "javascript" - if (ending) return ending; - return "plaintext"; + if (ending) return ending + return "plaintext" } export function validateName( @@ -25,7 +30,7 @@ export function validateName( type: "file" | "folder" ) { if (newName === oldName || newName.length === 0) { - return { status: false, message: "" }; + return { status: false, message: "" } } if ( newName.includes("/") || @@ -34,9 +39,9 @@ export function validateName( (type === "file" && !newName.includes(".")) || (type === "folder" && newName.includes(".")) ) { - return { status: false, message: "Invalid file name." }; + return { status: false, message: "Invalid file name." } } - return { status: true, message: "" }; + return { status: true, message: "" } } export function addNew( @@ -49,9 +54,9 @@ export function addNew( setFiles((prev) => [ ...prev, { id: `projects/${sandboxData.id}/${name}`, name, type: "file" }, - ]); + ]) } else { - console.log("adding folder"); + console.log("adding folder") setFiles((prev) => [ ...prev, { @@ -60,48 +65,92 @@ export function addNew( type: "folder", children: [], }, - ]); + ]) } } export function checkServiceStatus(serviceName: string): Promise { return new Promise((resolve, reject) => { - let tries = 0; + let tries = 0 const interval = setInterval(async () => { try { - tries++; + tries++ if (tries > 40) { - clearInterval(interval); - reject(new Error("Timed out.")); + clearInterval(interval) + reject(new Error("Timed out.")) } - const response = await describeService(serviceName); + const response = await describeService(serviceName) const activeServices = response.services?.filter( (service) => service.status === "ACTIVE" - ); - console.log("Checking activeServices status", activeServices); + ) + console.log("Checking activeServices status", activeServices) if (activeServices?.length === 1) { - const service = activeServices?.[0]; + const service = activeServices?.[0] if ( service.runningCount === service.desiredCount && service.deployments?.length === 1 ) { if (service.deployments[0].rolloutState === "COMPLETED") { - clearInterval(interval); - resolve(service); + clearInterval(interval) + resolve(service) } else if (service.deployments[0].rolloutState === "FAILED") { - clearInterval(interval); - reject(new Error("Deployment failed.")); + clearInterval(interval) + reject(new Error("Deployment failed.")) } } } } catch (error: any) { - clearInterval(interval); - reject(error); + clearInterval(interval) + reject(error) } - }, 3000); - }); + }, 3000) + }) +} + +export async function setupServer({ + sandboxId, + setIsServiceRunning, + setIsDeploymentActive, + setTaskIp, + setDidFail, + toast, +}: { + sandboxId: string + setIsServiceRunning: React.Dispatch> + setIsDeploymentActive: React.Dispatch> + setTaskIp: React.Dispatch> + setDidFail: React.Dispatch> + toast: any +}) { + const doesExist = await doesServiceExist(sandboxId) + + if (!doesExist) { + const response = await startServer(sandboxId) + + if (!response.success) { + toast.error(response.message) + setDidFail(true) + return + } + } + + setIsServiceRunning(true) + + try { + if (!doesExist) { + await checkServiceStatus(sandboxId) + } + + setIsDeploymentActive(true) + + const taskIp = await getTaskIp(sandboxId) + setTaskIp(taskIp) + } catch (error) { + toast.error("An error occurred while initializing your server.") + setDidFail(true) + } }