Compare commits

..

28 Commits

Author SHA1 Message Date
James Murdza
6b2b870020 refactor: improve readability of the connection manager code 2024-10-25 19:27:32 -06:00
James Murdza
90ea90f610 fix: support filesystem change notifications for multiple connections 2024-10-25 19:27:30 -06:00
James Murdza
eb973e0f83 fix: call handlers without callbacks 2024-10-25 19:02:18 -06:00
James Murdza
6613291977 fix: only close the terminals and file manager when the owner is disconnected from all sockets 2024-10-25 14:14:50 -06:00
James Murdza
836dd51ccc refactor: improve naming 2024-10-25 07:36:43 -06:00
James Murdza
701c4fcf84 chore: add comments 2024-10-25 07:32:34 -06:00
James Murdza
8381455f4d refactor: separate connection manager logic 2024-10-25 07:32:34 -06:00
James Murdza
486791f53e refactor: simplify error handling 2024-10-25 07:32:34 -06:00
James Murdza
21026a3c53 fix: use correct path format for deployment 2024-10-25 07:32:34 -06:00
James Murdza
f83dcfcf8f refactor: simplify file list structure 2024-10-25 07:32:34 -06:00
James Murdza
250296f0e9 fix: correct usage of sandboxFiles 2024-10-25 07:32:34 -06:00
James Murdza
2eb2388e12 refactor: restructure error handling 2024-10-25 07:32:34 -06:00
James Murdza
a6f457ef59 refactor: move initialization code to SandboxManager 2024-10-25 07:32:28 -06:00
James Murdza
15fbd4ce41 refactor: create sandboxManager class 2024-10-25 00:03:04 -06:00
James Murdza
d6d9448aa4 refactor: export handlers as an object 2024-10-24 23:39:53 -06:00
James Murdza
d3e987b0ab refactor: move rate limiting to handler functions 2024-10-24 23:36:04 -06:00
James Murdza
3bc555ca47 refactor: fix handler arguments 2024-10-24 23:13:01 -06:00
James Murdza
6f8bebe7dd refactor: reuse try...catch and rate limiting code across handlers 2024-10-24 22:18:01 -06:00
James Murdza
0fe652d873 refactor: package websocket event arguments as objects 2024-10-24 20:00:50 -06:00
James Murdza
f1c1f50abf refactor: apply consistant callback usage 2024-10-24 19:15:03 -06:00
James Murdza
ca8c7ae0aa chore: change errors to warnings 2024-10-24 17:38:43 -06:00
James Murdza
f6cded11f4 refactor: move socket authentication middleware to a separate file 2024-10-24 17:37:34 -06:00
James Murdza
b1ada9e204 fix: type errors from refactoring 2024-10-24 17:37:12 -06:00
James Murdza
13c3670e4d refactor: pass context to handlers in handlerContext object 2024-10-24 17:15:58 -06:00
James Murdza
e439816671 refactor: keep disconnect handler in main file 2024-10-24 17:10:23 -06:00
James Murdza
ef018385ef refactor: move event logic to a separate file 2024-10-24 16:34:13 -06:00
James Murdza
cec6b0c8c5 refactor: separate socket event handlers into functions 2024-10-24 16:20:24 -06:00
James Murdza
6ec17fad7e refactor: move DokkuResponse to types 2024-10-24 15:59:21 -06:00
133 changed files with 14961 additions and 20803 deletions

1
.gitignore vendored
View File

@ -40,6 +40,7 @@ wrangler.toml
dist
backend/server/projects
backend/database/drizzle
app.yaml
ingressController.yaml

View File

@ -1,7 +1,6 @@
{
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"insertFinalNewline": true,
"useTabs": false
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"insertFinalNewline": true
}

View File

@ -1,3 +0,0 @@
{
"recommendations": ["esbenp.prettier-vscode"]
}

View File

@ -4,10 +4,5 @@
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

110
README.md
View File

@ -2,7 +2,7 @@
![2024-10-2307 17 42-ezgif com-resize](https://github.com/user-attachments/assets/a4057129-81a7-4a31-a093-c8bc8189ae72)
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat.
Sandbox is an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration and AI chat.
For the latest updates, join our Discord server: [discord.gitwit.dev](https://discord.gitwit.dev/).
@ -10,11 +10,12 @@ For the latest updates, join our Discord server: [discord.gitwit.dev](https://di
Notes:
- Double-check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
- Double check that whatever you change "SUPERDUPERSECRET" to, it's the same in all config files.
- Right now we are loading project templates from a custom Cloudflare bucket which isn't covered in this guide, but that be updated/fixed very soon.
### 0. Requirements
The application uses NodeJS for the backend, NextJS for the frontend, and Cloudflare workers for additional backend tasks.
The application uses NodeJS for the backend, NextJS for the frontend and Cloudflare workers for additional backend tasks.
Needed accounts to set up:
@ -22,7 +23,6 @@ Needed accounts to set up:
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
- [E2B](https://e2b.dev/): Used for the terminals and live preview.
- [Cloudflare](https://www.cloudflare.com/): Used for relational data storage (D2) and file storage (R2).
- [Anthropic](https://anthropic.com/) and [OpenAI](https://openai.com/): API keys for code generation.
A quick overview of the tech before we start: The deployment uses a **NextJS** app for the frontend and an **ExpressJS** server on the backend. Presumably that's because NextJS integrates well with Clerk middleware but not with Socket.io.
@ -42,6 +42,7 @@ Run `npm install` in:
/backend/database
/backend/storage
/backend/server
/backend/ai
```
### 2. Adding Clerk
@ -78,8 +79,6 @@ npx wrangler deploy
### 4. Deploying the database
Follow this [guide](https://docs.google.com/document/d/1w5dA5daic_sIYB5Seni1KvnFx51pPV2so6lLdN2xa7Q/edit?usp=sharing) for more info.
Create a database:
```
@ -145,7 +144,21 @@ Update `/backend/server/.env`:
E2B_API_KEY='🔑'
```
### 9. Configuring the frontend
### 9. Adding AI code generation
In the `/backend/ai` directory:
```
npx wrangler deploy
```
Update `/backend/server/.env`:
```
AI_WORKER_URL='https://ai.🍎.workers.dev'
```
### 10. Configuring the frontend
Update `/frontend/.env`:
@ -153,11 +166,9 @@ Update `/frontend/.env`:
NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
ANTHROPIC_API_KEY='🔑'
OPENAI_API_KEY='🔑'
```
### 10. Running the IDE
### 11. Running the IDE
Run `npm run dev` simultaneously in:
@ -176,15 +187,6 @@ Setting up deployments first requires a separate domain (such as gitwit.app, whi
We then deploy Dokku on a separate server, according to this guide: https://dev.to/jamesmurdza/host-your-own-paas-platform-as-a-service-on-amazon-web-services-3f0d
And we install [dokku-daemon](https://github.com/dokku/dokku-daemon) with the following commands:
```
git clone https://github.com/dokku/dokku-daemon
cd dokku-daemon
sudo make install
systemctl start dokku-daemon
```
The Sandbox platform connects to the Dokku server via SSH, using SSH keys specifically generated for this connection. The SSH key is stored on the Sandbox server, and the following environment variables are set in /backend/server/.env:
```bash
@ -193,57 +195,15 @@ DOKKU_USERNAME=
DOKKU_KEY=
```
## Deploying to AWS
The backend server and deployments server can be deployed using AWS's EC2 service. See [our video guide](https://www.youtube.com/watch?v=WN8HQnimjmk) on how to do this.
## Creating Custom Templates
Anyone can contribute a custom template for integration in Sandbox. Since Sandbox is built on E2B, there is no limitation to what langauge or runtime a Sandbox can use.
We're working on a process whereby anyone can contribute a custom template that others can use in the Sandbox environment. The process includes:
Currently there are five templates:
- Creating a [custom E2B Sandbox](https://e2b.dev/docs/sandbox-template) including the template files and dependencies
- Creating a file to specify the run command (e.g. "npm run dev")
- Testing the template with Dokku for deployment
- [jamesmurdza/dokku-reactjs-template](https://github.com/jamesmurdza/dokku-reactjs-template)
- [jamesmurdza/dokku-vanillajs-template](https://github.com/jamesmurdza/dokku-vanillajs-template)
- [jamesmurdza/dokku-nextjs-template](https://github.com/jamesmurdza/dokku-nextjs-template)
- [jamesmurdza/dokku-streamlit-template](https://github.com/jamesmurdza/dokku-streamlit-template)
- [omarrwd/dokku-php-template](https://github.com/omarrwd/dokku-php-template)
To create your own template, you can fork one of the above templates or start with a new blank repository. The template should have at least an `e2b.Dockerfile`, which is used by E2B to create the development environment. Optionally, a `Dockerfile` can be added which will be used to create the project build when it is deployed.
To test the template, you must have an [E2B account](https://e2b.dev/) and the [E2B CLI tools](https://e2b.dev/docs/cli) installed. Then, in the Terminal, run:
```
e2b auth login
```
Then, navigate to your template directory and run the following command where **TEMPLATENAME** is the name of your template:
```
e2b template build -d e2b.Dockerfile -n TEMPLATENAME
```
Finally, to test your template run:
```
e2b sandbox spawn TEMPLATENAME
cd project
```
You will see a URL in the form of `https://xxxxxxxxxxxxxxxxxxx.e2b-staging.com`.
Now, run the command to start your development server.
To see the running server, visit the public url `https://<PORT>-xxxxxxxxxxxxxxxxxxx.e2b-staging.com`.
If you've done this and it works, let us know and we'll add your template to Sandbox! Please reach out to us [on Discord](https://discord.gitwit.dev/) with any questions or to submit your working template.
Note: In the future, we will add a way to specify the command triggered by the "Run" button (e.g. "npm run dev").
For more information, see:
- [Custom E2B Sandboxes](https://e2b.dev/docs/sandbox-template)
- [Dokku Builders](https://dokku.com/docs/deployment/builders/builder-management/)
Please reach out to us [on Discord](https://discord.gitwit.dev/) if you're interested in contributing.
## Contributing
@ -262,15 +222,17 @@ backend/
├── database/
│ ├── src
│ └── drizzle
└── storage
├── storage
└── ai
```
| Path | Description |
| ------------------ | -------------------------------------------------------------------------- |
| `frontend` | The Next.js application for the frontend. |
| `backend/server` | The Express websocket server. |
| `backend/database` | API for interfacing with the D1 database (SQLite). |
| Path | Description |
| ------------------ | ------------------------------------------------------------ |
| `frontend` | The Next.js application for the frontend. |
| `backend/server` | The Express websocket server. |
| `backend/database` | API for interfacing with the D1 database (SQLite). |
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
| `backend/ai` | API for making requests to Workers AI . |
### Development
@ -291,10 +253,6 @@ cd sandbox
git checkout -b my-new-branch
```
### Code formatting
This repository uses [Prettier](https://marketplace.cursorapi.com/items?itemName=esbenp.prettier-vscode) for code formatting, which you will be prompted to install when you open the project. The formatting rules are specified in [.prettierrc](.prettierrc).
### Commit convention
Before you create a Pull Request, please check that you use the [Conventional Commits format](https://www.conventionalcommits.org/en/v1.0.0/)

12
backend/ai/.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space

172
backend/ai/.gitignore vendored Normal file
View File

@ -0,0 +1,172 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/

3319
backend/ai/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
backend/ai/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "ai",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20240512.0",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.27.2"
}
}

128
backend/ai/src/index.ts Normal file
View File

@ -0,0 +1,128 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { MessageParam } from "@anthropic-ai/sdk/src/resources/messages.js"
export interface Env {
ANTHROPIC_API_KEY: string
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Handle CORS preflight requests
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
}
if (request.method !== "GET" && request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 })
}
let body
let isEditCodeWidget = false
if (request.method === "POST") {
body = (await request.json()) as {
messages: unknown
context: unknown
activeFileContent: string
}
} else {
const url = new URL(request.url)
const fileName = url.searchParams.get("fileName") || ""
const code = url.searchParams.get("code") || ""
const line = url.searchParams.get("line") || ""
const instructions = url.searchParams.get("instructions") || ""
body = {
messages: [{ role: "human", content: instructions }],
context: `File: ${fileName}\nLine: ${line}\nCode:\n${code}`,
activeFileContent: code,
}
isEditCodeWidget = true
}
const messages = body.messages
const context = body.context
const activeFileContent = body.activeFileContent
if (!Array.isArray(messages) || messages.length === 0) {
return new Response("Invalid or empty messages", { status: 400 })
}
let systemMessage
if (isEditCodeWidget) {
systemMessage = `You are an AI code editor. Your task is to modify the given code based on the user's instructions. Only output the modified code, without any explanations or markdown formatting. The code should be a direct replacement for the existing code.
Context:
${context}
Active File Content:
${activeFileContent}
Instructions: ${messages[0].content}
Respond only with the modified code that can directly replace the existing code.`
} else {
systemMessage = `You are an intelligent programming assistant. Please respond to the following request concisely. If your response includes code, please format it using triple backticks (\`\`\`) with the appropriate language identifier. For example:
\`\`\`python
print("Hello, World!")
\`\`\`
Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
${context ? `Context:\n${context}\n` : ""}
${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ""}`
}
const anthropicMessages = messages.map((msg) => ({
role: msg.role === "human" ? "user" : "assistant",
content: msg.content,
})) as MessageParam[]
try {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
const stream = await anthropic.messages.create({
model: "claude-3-5-sonnet-20240620",
max_tokens: 1024,
system: systemMessage,
messages: anthropicMessages,
stream: true,
})
const encoder = new TextEncoder()
const streamResponse = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
const bytes = encoder.encode(chunk.delta.text)
controller.enqueue(bytes)
}
}
controller.close()
},
})
return new Response(streamResponse, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
} catch (error) {
console.error("Error:", error)
return new Response("Internal Server Error", { status: 500 })
}
},
}

View File

@ -0,0 +1,30 @@
// test/index.spec.ts
import {
createExecutionContext,
env,
SELF,
waitOnExecutionContext,
} from "cloudflare:test"
import { describe, expect, it } from "vitest"
import worker from "../src/index"
// For now, you'll need to do something like this to get a correctly-typed
// `Request` to pass to `worker.fetch()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>
describe("Hello World worker", () => {
it("responds with Hello World! (unit style)", async () => {
const request = new IncomingRequest("http://example.com")
// Create an empty context to pass to `worker.fetch()`.
const ctx = createExecutionContext()
const response = await worker.fetch(request, env, ctx)
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
await waitOnExecutionContext(ctx)
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
it("responds with Hello World! (integration style)", async () => {
const response = await SELF.fetch("https://example.com")
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`)
})
})

View File

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"],
"exclude": []
}

106
backend/ai/tsconfig.json Normal file
View File

@ -0,0 +1,106 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "Bundler" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
"types": [
"@cloudflare/workers-types/2023-07-01"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
"resolveJsonModule": true /* Enable importing .json files */,
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
"noEmit": true /* Disable emitting files from a compilation. */,
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
// "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"exclude": ["test"]
}

View File

@ -0,0 +1,11 @@
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
})

3
backend/ai/worker-configuration.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
// Generated by Wrangler
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
interface Env {}

View File

@ -0,0 +1,10 @@
name = "ai"
main = "src/index.ts"
compatibility_date = "2024-05-12"
compatibility_flags = ["nodejs_compat"]
[ai]
binding = "AI"
[vars]
ANTHROPIC_API_KEY = ""

View File

@ -1,46 +0,0 @@
CREATE TABLE `sandbox` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`visibility` text,
`createdAt` integer DEFAULT CURRENT_TIMESTAMP,
`user_id` text NOT NULL,
`likeCount` integer DEFAULT 0,
`viewCount` integer DEFAULT 0,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `sandbox_likes` (
`user_id` text NOT NULL,
`sandbox_id` text NOT NULL,
`createdAt` integer DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(`sandbox_id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sandbox_id`) REFERENCES `sandbox`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`username` text NOT NULL,
`avatarUrl` text,
`githubToken` text,
`createdAt` integer DEFAULT CURRENT_TIMESTAMP,
`generations` integer DEFAULT 0,
`tier` text DEFAULT 'FREE',
`tierExpiresAt` integer,
`lastResetDate` integer
);
--> statement-breakpoint
CREATE TABLE `users_to_sandboxes` (
`userId` text NOT NULL,
`sandboxId` text NOT NULL,
`sharedOn` integer,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`sandboxId`) REFERENCES `sandbox`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `sandbox_id_unique` ON `sandbox` (`id`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_id_unique` ON `user` (`id`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);

View File

@ -1,3 +0,0 @@
ALTER TABLE user ADD `bio` text;--> statement-breakpoint
ALTER TABLE user ADD `personalWebsite` text;--> statement-breakpoint
ALTER TABLE user ADD `links` text DEFAULT '[]';

View File

@ -1 +0,0 @@
ALTER TABLE sandbox ADD `containerId` text;

View File

@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "sqlite",
"id": "4ada398d-7e4e-448f-8cea-a10b4d844600",
"id": "6570ba20-a672-400c-8147-7ba533784918",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"sandbox": {
@ -35,36 +35,12 @@
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"likeCount": {
"name": "likeCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"viewCount": {
"name": "viewCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
}
},
"indexes": {
@ -94,72 +70,6 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"sandbox_likes": {
"name": "sandbox_likes",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"sandbox_likes_user_id_user_id_fk": {
"name": "sandbox_likes_user_id_user_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sandbox_likes_sandbox_id_sandbox_id_fk": {
"name": "sandbox_likes_sandbox_id_sandbox_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "sandbox",
"columnsFrom": [
"sandbox_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sandbox_likes_sandbox_id_user_id_pk": {
"columns": [
"sandbox_id",
"user_id"
],
"name": "sandbox_likes_sandbox_id_user_id_pk"
}
},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
@ -183,65 +93,6 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatarUrl": {
"name": "avatarUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"githubToken": {
"name": "githubToken",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"generations": {
"name": "generations",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"tier": {
"name": "tier",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'FREE'"
},
"tierExpiresAt": {
"name": "tierExpiresAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastResetDate": {
"name": "lastResetDate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
@ -251,13 +102,6 @@
"id"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
@ -280,13 +124,6 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sharedOn": {
"name": "sharedOn",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},

View File

@ -1,353 +1,175 @@
{
"version": "5",
"dialect": "sqlite",
"id": "80c0b0b2-bb0e-449a-b447-c21863686f58",
"prevId": "4ada398d-7e4e-448f-8cea-a10b4d844600",
"tables": {
"sandbox": {
"name": "sandbox",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"likeCount": {
"name": "likeCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"viewCount": {
"name": "viewCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
}
"version": "5",
"dialect": "sqlite",
"id": "9f64104a-4954-40c0-8155-17755ea0a243",
"prevId": "6570ba20-a672-400c-8147-7ba533784918",
"tables": {
"sandbox": {
"name": "sandbox",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"indexes": {
"sandbox_id_unique": {
"name": "sandbox_id_unique",
"columns": [
"id"
],
"isUnique": true
}
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"foreignKeys": {
"sandbox_user_id_user_id_fk": {
"name": "sandbox_user_id_user_id_fk",
"tableFrom": "sandbox",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"sandbox_likes": {
"name": "sandbox_likes",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"sandbox_likes_user_id_user_id_fk": {
"name": "sandbox_likes_user_id_user_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sandbox_likes_sandbox_id_sandbox_id_fk": {
"name": "sandbox_likes_sandbox_id_sandbox_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "sandbox",
"columnsFrom": [
"sandbox_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sandbox_likes_sandbox_id_user_id_pk": {
"columns": [
"sandbox_id",
"user_id"
],
"name": "sandbox_likes_sandbox_id_user_id_pk"
}
},
"uniqueConstraints": {}
"indexes": {
"sandbox_id_unique": {
"name": "sandbox_id_unique",
"columns": [
"id"
],
"isUnique": true
}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatarUrl": {
"name": "avatarUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"githubToken": {
"name": "githubToken",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"generations": {
"name": "generations",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"bio": {
"name": "bio",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"personalWebsite": {
"name": "personalWebsite",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"links": {
"name": "links",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"tier": {
"name": "tier",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'FREE'"
},
"tierExpiresAt": {
"name": "tierExpiresAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastResetDate": {
"name": "lastResetDate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_id_unique": {
"name": "user_id_unique",
"columns": [
"id"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
"foreignKeys": {
"sandbox_user_id_user_id_fk": {
"name": "sandbox_user_id_user_id_fk",
"tableFrom": "sandbox",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"users_to_sandboxes": {
"name": "users_to_sandboxes",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandboxId": {
"name": "sandboxId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sharedOn": {
"name": "sharedOn",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_to_sandboxes_userId_user_id_fk": {
"name": "users_to_sandboxes_userId_user_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"users_to_sandboxes_sandboxId_sandbox_id_fk": {
"name": "users_to_sandboxes_sandboxId_sandbox_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "sandbox",
"columnsFrom": [
"sandboxId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_id_unique": {
"name": "user_id_unique",
"columns": [
"id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users_to_sandboxes": {
"name": "users_to_sandboxes",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandboxId": {
"name": "sandboxId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_to_sandboxes_userId_user_id_fk": {
"name": "users_to_sandboxes_userId_user_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"users_to_sandboxes_sandboxId_sandbox_id_fk": {
"name": "users_to_sandboxes_sandboxId_sandbox_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "sandbox",
"columnsFrom": [
"sandboxId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@ -1,8 +1,8 @@
{
"version": "5",
"dialect": "sqlite",
"id": "51abcf01-2921-4885-8058-d1ccd576f3e1",
"prevId": "80c0b0b2-bb0e-449a-b447-c21863686f58",
"id": "5baf10d6-7697-42ba-a11a-ee4c7bd7e91e",
"prevId": "9f64104a-4954-40c0-8155-17755ea0a243",
"tables": {
"sandbox": {
"name": "sandbox",
@ -35,43 +35,12 @@
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"likeCount": {
"name": "likeCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"viewCount": {
"name": "viewCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"containerId": {
"name": "containerId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
@ -101,72 +70,6 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"sandbox_likes": {
"name": "sandbox_likes",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"sandbox_likes_user_id_user_id_fk": {
"name": "sandbox_likes_user_id_user_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sandbox_likes_sandbox_id_sandbox_id_fk": {
"name": "sandbox_likes_sandbox_id_sandbox_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "sandbox",
"columnsFrom": [
"sandbox_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sandbox_likes_sandbox_id_user_id_pk": {
"columns": [
"sandbox_id",
"user_id"
],
"name": "sandbox_likes_sandbox_id_user_id_pk"
}
},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
@ -190,87 +93,6 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatarUrl": {
"name": "avatarUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"githubToken": {
"name": "githubToken",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"generations": {
"name": "generations",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"bio": {
"name": "bio",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"personalWebsite": {
"name": "personalWebsite",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"links": {
"name": "links",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"tier": {
"name": "tier",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'FREE'"
},
"tierExpiresAt": {
"name": "tierExpiresAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastResetDate": {
"name": "lastResetDate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
@ -280,13 +102,6 @@
"id"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
@ -309,13 +124,6 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sharedOn": {
"name": "sharedOn",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},

View File

@ -0,0 +1,175 @@
{
"version": "5",
"dialect": "sqlite",
"id": "37e38b82-1494-4818-8c26-b9024cce3fa9",
"prevId": "5baf10d6-7697-42ba-a11a-ee4c7bd7e91e",
"tables": {
"sandbox": {
"name": "sandbox",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sandbox_id_unique": {
"name": "sandbox_id_unique",
"columns": [
"id"
],
"isUnique": true
}
},
"foreignKeys": {
"sandbox_user_id_user_id_fk": {
"name": "sandbox_user_id_user_id_fk",
"tableFrom": "sandbox",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_id_unique": {
"name": "user_id_unique",
"columns": [
"id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users_to_sandboxes": {
"name": "users_to_sandboxes",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandboxId": {
"name": "sandboxId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_to_sandboxes_userId_user_id_fk": {
"name": "users_to_sandboxes_userId_user_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"users_to_sandboxes_sandboxId_sandbox_id_fk": {
"name": "users_to_sandboxes_sandboxId_sandbox_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "sandbox",
"columnsFrom": [
"sandboxId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@ -5,22 +5,50 @@
{
"idx": 0,
"version": "5",
"when": 1736155854410,
"tag": "0000_sudden_wallop",
"when": 1714540200800,
"tag": "0000_big_rogue",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1736169498666,
"tag": "0001_dusty_komodo",
"when": 1714541190588,
"tag": "0001_empty_black_knight",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1736768910615,
"tag": "0002_chemical_brother_voodoo",
"when": 1714541209173,
"tag": "0002_sour_ego",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1714541233589,
"tag": "0003_pale_overlord",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1714565073180,
"tag": "0004_cuddly_wolf_cub",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1714950365718,
"tag": "0005_last_the_twelve",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1716432225404,
"tag": "0006_lively_mattie_franklin",
"breakpoints": true
}
]

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
"drizzle-kit": "^0.20.17",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.101.0"
"wrangler": "^3.0.0"
},
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",

View File

@ -5,599 +5,304 @@ import { z } from "zod"
import { and, eq, sql } from "drizzle-orm"
import * as schema from "./schema"
import { Sandbox, sandbox, sandboxLikes, user, usersToSandboxes } from "./schema"
import { sandbox, user, usersToSandboxes } from "./schema"
export interface Env {
DB: D1Database
STORAGE: any
KEY: string
STORAGE_WORKER_URL: string
DB: D1Database
STORAGE: any
KEY: string
STORAGE_WORKER_URL: string
}
// https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
// npm run generate
// npx wrangler d1 execute d1-sandbox --local --file=./drizzle/<FILE>
interface SandboxWithLiked extends Sandbox {
liked: boolean
}
interface UserResponse extends Omit<schema.User, "sandbox"> {
sandbox: SandboxWithLiked[]
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
const success = new Response("Success", { status: 200 })
const invalidRequest = new Response("Invalid Request", { status: 400 })
const notFound = new Response("Not Found", { status: 404 })
const methodNotAllowed = new Response("Method Not Allowed", { status: 405 })
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
const success = new Response("Success", { status: 200 })
const invalidRequest = new Response("Invalid Request", { status: 400 })
const notFound = new Response("Not Found", { status: 404 })
const methodNotAllowed = new Response("Method Not Allowed", { status: 405 })
const url = new URL(request.url)
const path = url.pathname
const method = request.method
const url = new URL(request.url)
const path = url.pathname
const method = request.method
if (request.headers.get("Authorization") !== env.KEY) {
return new Response("Unauthorized", { status: 401 })
}
if (request.headers.get("Authorization") !== env.KEY) {
return new Response("Unauthorized", { status: 401 })
}
const db = drizzle(env.DB, { schema })
const db = drizzle(env.DB, { schema })
if (path === "/api/sandbox") {
if (method === "GET") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
const res = await db.query.sandbox.findFirst({
where: (sandbox, { eq }) => eq(sandbox.id, id),
with: {
usersToSandboxes: true,
},
})
return json(res ?? {})
} else {
const res = await db.select().from(sandbox).all()
return json(res ?? {})
}
} else if (method === "DELETE") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
await db.delete(sandboxLikes).where(eq(sandboxLikes.sandboxId, id))
await db
.delete(usersToSandboxes)
.where(eq(usersToSandboxes.sandboxId, id))
await db.delete(sandbox).where(eq(sandbox.id, id))
if (path === "/api/sandbox") {
if (method === "GET") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
const res = await db.query.sandbox.findFirst({
where: (sandbox, { eq }) => eq(sandbox.id, id),
with: {
usersToSandboxes: true,
},
})
return json(res ?? {})
} else {
const res = await db.select().from(sandbox).all()
return json(res ?? {})
}
} else if (method === "DELETE") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
await db
.delete(usersToSandboxes)
.where(eq(usersToSandboxes.sandboxId, id))
await db.delete(sandbox).where(eq(sandbox.id, id))
const deleteStorageRequest = new Request(
`${env.STORAGE_WORKER_URL}/api/project`,
{
method: "DELETE",
body: JSON.stringify({ sandboxId: id }),
headers: {
"Content-Type": "application/json",
Authorization: `${env.KEY}`,
},
}
)
await env.STORAGE.fetch(deleteStorageRequest)
const deleteStorageRequest = new Request(
`${env.STORAGE_WORKER_URL}/api/project`,
{
method: "DELETE",
body: JSON.stringify({ sandboxId: id }),
headers: {
"Content-Type": "application/json",
Authorization: `${env.KEY}`,
},
}
)
await env.STORAGE.fetch(deleteStorageRequest)
return success
} else {
return invalidRequest
}
} else if (method === "POST") {
const postSchema = z.object({
id: z.string(),
name: z.string().optional(),
visibility: z.enum(["public", "private"]).optional(),
})
return success
} else {
return invalidRequest
}
} else if (method === "POST") {
const postSchema = z.object({
id: z.string(),
name: z.string().optional(),
visibility: z.enum(["public", "private"]).optional(),
})
const body = await request.json()
const { id, name, visibility } = postSchema.parse(body)
const sb = await db
.update(sandbox)
.set({ name, visibility })
.where(eq(sandbox.id, id))
.returning()
.get()
const body = await request.json()
const { id, name, visibility } = postSchema.parse(body)
const sb = await db
.update(sandbox)
.set({ name, visibility })
.where(eq(sandbox.id, id))
.returning()
.get()
return success
} else if (method === "PUT") {
const initSchema = z.object({
type: z.string(),
name: z.string(),
userId: z.string(),
visibility: z.enum(["public", "private"]),
})
return success
} else if (method === "PUT") {
const initSchema = z.object({
type: z.string(),
name: z.string(),
userId: z.string(),
visibility: z.enum(["public", "private"]),
})
const body = await request.json()
const { type, name, userId, visibility } = initSchema.parse(body)
const body = await request.json()
const { type, name, userId, visibility } = initSchema.parse(body)
const userSandboxes = await db
.select()
.from(sandbox)
.where(eq(sandbox.userId, userId))
.all()
const userSandboxes = await db
.select()
.from(sandbox)
.where(eq(sandbox.userId, userId))
.all()
if (userSandboxes.length >= 8) {
return new Response("You reached the maximum # of sandboxes.", {
status: 400,
})
}
if (userSandboxes.length >= 8) {
return new Response("You reached the maximum # of sandboxes.", {
status: 400,
})
}
const sb = await db
.insert(sandbox)
.values({ type, name, userId, visibility, createdAt: new Date() })
.returning()
.get()
const sb = await db
.insert(sandbox)
.values({ type, name, userId, visibility, createdAt: new Date() })
.returning()
.get()
const initStorageRequest = new Request(
`${env.STORAGE_WORKER_URL}/api/init`,
{
method: "POST",
body: JSON.stringify({ sandboxId: sb.id, type }),
headers: {
"Content-Type": "application/json",
Authorization: `${env.KEY}`,
},
}
)
const initStorageRequest = new Request(
`${env.STORAGE_WORKER_URL}/api/init`,
{
method: "POST",
body: JSON.stringify({ sandboxId: sb.id, type }),
headers: {
"Content-Type": "application/json",
Authorization: `${env.KEY}`,
},
}
)
await env.STORAGE.fetch(initStorageRequest)
await env.STORAGE.fetch(initStorageRequest)
return new Response(sb.id, { status: 200 })
} else {
return methodNotAllowed
}
} else if (path === "/api/sandbox/share") {
if (method === "GET") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
const res = await db.query.usersToSandboxes.findMany({
where: (uts, { eq }) => eq(uts.userId, id),
})
return new Response(sb.id, { status: 200 })
} else {
return methodNotAllowed
}
} else if (path === "/api/sandbox/share") {
if (method === "GET") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
const res = await db.query.usersToSandboxes.findMany({
where: (uts, { eq }) => eq(uts.userId, id),
})
const owners = await Promise.all(
res.map(async (r) => {
const sb = await db.query.sandbox.findFirst({
where: (sandbox, { eq }) => eq(sandbox.id, r.sandboxId),
with: {
author: true,
},
})
if (!sb) return
return {
id: sb.id,
name: sb.name,
type: sb.type,
author: sb.author.name,
authorAvatarUrl: sb.author.avatarUrl,
sharedOn: r.sharedOn,
}
})
)
const owners = await Promise.all(
res.map(async (r) => {
const sb = await db.query.sandbox.findFirst({
where: (sandbox, { eq }) => eq(sandbox.id, r.sandboxId),
with: {
author: true,
},
})
if (!sb) return
return {
id: sb.id,
name: sb.name,
type: sb.type,
author: sb.author.name,
sharedOn: r.sharedOn,
}
})
)
return json(owners ?? {})
} else return invalidRequest
} else if (method === "POST") {
const shareSchema = z.object({
sandboxId: z.string(),
email: z.string(),
})
return json(owners ?? {})
} else return invalidRequest
} else if (method === "POST") {
const shareSchema = z.object({
sandboxId: z.string(),
email: z.string(),
})
const body = await request.json()
const { sandboxId, email } = shareSchema.parse(body)
const body = await request.json()
const { sandboxId, email } = shareSchema.parse(body)
const user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.email, email),
with: {
sandbox: true,
usersToSandboxes: true,
},
})
const user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.email, email),
with: {
sandbox: true,
usersToSandboxes: true,
},
})
if (!user) {
return new Response("No user associated with email.", { status: 400 })
}
if (!user) {
return new Response("No user associated with email.", { status: 400 })
}
if (user.sandbox.find((sb) => sb.id === sandboxId)) {
return new Response("Cannot share with yourself!", { status: 400 })
}
if (user.sandbox.find((sb) => sb.id === sandboxId)) {
return new Response("Cannot share with yourself!", { status: 400 })
}
if (user.usersToSandboxes.find((uts) => uts.sandboxId === sandboxId)) {
return new Response("User already has access.", { status: 400 })
}
if (user.usersToSandboxes.find((uts) => uts.sandboxId === sandboxId)) {
return new Response("User already has access.", { status: 400 })
}
await db
.insert(usersToSandboxes)
.values({ userId: user.id, sandboxId, sharedOn: new Date() })
.get()
await db
.insert(usersToSandboxes)
.values({ userId: user.id, sandboxId, sharedOn: new Date() })
.get()
return success
} else if (method === "DELETE") {
const deleteShareSchema = z.object({
sandboxId: z.string(),
userId: z.string(),
})
return success
} else if (method === "DELETE") {
const deleteShareSchema = z.object({
sandboxId: z.string(),
userId: z.string(),
})
const body = await request.json()
const { sandboxId, userId } = deleteShareSchema.parse(body)
const body = await request.json()
const { sandboxId, userId } = deleteShareSchema.parse(body)
await db
.delete(usersToSandboxes)
.where(
and(
eq(usersToSandboxes.userId, userId),
eq(usersToSandboxes.sandboxId, sandboxId)
)
)
await db
.delete(usersToSandboxes)
.where(
and(
eq(usersToSandboxes.userId, userId),
eq(usersToSandboxes.sandboxId, sandboxId)
)
)
return success
} else return methodNotAllowed
} else if (path === "/api/sandbox/like") {
if (method === "POST") {
const likeSchema = z.object({
sandboxId: z.string(),
userId: z.string(),
})
return success
} else return methodNotAllowed
} else if (path === "/api/sandbox/generate" && method === "POST") {
const generateSchema = z.object({
userId: z.string(),
})
const body = await request.json()
const { userId } = generateSchema.parse(body)
try {
const body = await request.json()
const { sandboxId, userId } = likeSchema.parse(body)
const dbUser = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, userId),
})
if (!dbUser) {
return new Response("User not found.", { status: 400 })
}
if (dbUser.generations !== null && dbUser.generations >= 10) {
return new Response("You reached the maximum # of generations.", {
status: 400,
})
}
// Check if user has already liked
const existingLike = await db.query.sandboxLikes.findFirst({
where: (likes, { and, eq }) =>
and(eq(likes.sandboxId, sandboxId), eq(likes.userId, userId)),
})
await db
.update(user)
.set({ generations: sql`${user.generations} + 1` })
.where(eq(user.id, userId))
.get()
if (existingLike) {
// Unlike
await db
.delete(sandboxLikes)
.where(
and(
eq(sandboxLikes.sandboxId, sandboxId),
eq(sandboxLikes.userId, userId)
)
)
return success
} else if (path === "/api/user") {
if (method === "GET") {
const params = url.searchParams
await db
.update(sandbox)
.set({
likeCount: sql`${sandbox.likeCount} - 1`,
})
.where(eq(sandbox.id, sandboxId))
if (params.has("id")) {
const id = params.get("id") as string
const res = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, id),
with: {
sandbox: {
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
},
usersToSandboxes: true,
},
})
return json(res ?? {})
} else {
const res = await db.select().from(user).all()
return json(res ?? {})
}
} else if (method === "POST") {
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
return json({
message: "Unlike successful",
liked: false,
})
} else {
// Like
await db.insert(sandboxLikes).values({
sandboxId,
userId,
createdAt: new Date(),
})
const body = await request.json()
const { id, name, email } = userSchema.parse(body)
await db
.update(sandbox)
.set({
likeCount: sql`${sandbox.likeCount} + 1`,
})
.where(eq(sandbox.id, sandboxId))
return json({
message: "Like successful",
liked: true,
})
}
} catch (error) {
return new Response("Invalid request format", { status: 400 })
}
} else if (method === "GET") {
const params = url.searchParams
const sandboxId = params.get("sandboxId")
const userId = params.get("userId")
if (!sandboxId || !userId) {
return invalidRequest
}
const like = await db.query.sandboxLikes.findFirst({
where: (likes, { and, eq }) =>
and(eq(likes.sandboxId, sandboxId), eq(likes.userId, userId)),
})
return json({
liked: !!like,
})
} else {
return methodNotAllowed
}
} else if (path === "/api/user") {
if (method === "GET") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
const res = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, id),
with: {
sandbox: {
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
with: {
likes: true,
},
},
usersToSandboxes: true,
},
})
if (res) {
const transformedUser: UserResponse = {
...res,
sandbox: res.sandbox.map(
(sb): SandboxWithLiked => ({
...sb,
liked: sb.likes.some((like) => like.userId === id),
})
),
}
return json(transformedUser)
}
return json(res ?? {})
} else if (params.has("username")) {
const username = params.get("username") as string
const userId = params.get("currentUserId")
const res = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.username, username),
with: {
sandbox: {
orderBy: (sandbox, { desc }) => [desc(sandbox.createdAt)],
with: {
likes: true,
},
},
usersToSandboxes: true,
},
})
if (res) {
const transformedUser: UserResponse = {
...res,
sandbox: res.sandbox.map(
(sb): SandboxWithLiked => ({
...sb,
liked: sb.likes.some((like) => like.userId === userId),
})
),
}
return json(transformedUser)
}
return json(res ?? {})
} else {
const res = await db.select().from(user).all()
return json(res ?? {})
}
} else if (method === "POST") {
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
username: z.string(),
avatarUrl: z.string().optional(),
githubToken: z.string().nullable().optional(),
createdAt: z.string().optional(),
generations: z.number().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).optional(),
tierExpiresAt: z.number().optional(),
lastResetDate: z.number().optional(),
})
const body = await request.json()
const {
id,
name,
email,
username,
avatarUrl,
githubToken,
createdAt,
generations,
tier,
tierExpiresAt,
lastResetDate,
} = userSchema.parse(body)
const res = await db
.insert(user)
.values({
id,
name,
email,
username,
avatarUrl,
githubToken,
createdAt: createdAt ? new Date(createdAt) : new Date(),
generations,
tier,
tierExpiresAt,
lastResetDate,
})
.returning()
.get()
return json({ res })
} else if (method === "DELETE") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
await db.delete(user).where(eq(user.id, id))
return success
} else return invalidRequest
} else if (method === "PUT") {
const updateUserSchema = z.object({
id: z.string(),
name: z.string().optional(),
bio: z.string().optional(),
personalWebsite: z.string().optional(),
links: z
.array(
z.object({
url: z.string(),
platform: z.enum(schema.KNOWN_PLATFORMS),
})
)
.optional(),
email: z.string().email().optional(),
username: z.string().optional(),
avatarUrl: z.string().optional(),
githubToken: z.string().nullable().optional(),
generations: z.number().optional(),
})
try {
const body = await request.json()
const validatedData = updateUserSchema.parse(body)
const { id, username, ...updateData } = validatedData
// If username is being updated, check for existing username
if (username) {
const existingUser = await db
.select()
.from(user)
.where(eq(user.username, username))
.get()
if (existingUser && existingUser.id !== id) {
return json({ error: "Username already exists" }, { status: 409 })
}
}
const cleanUpdateData = {
...updateData,
...(username ? { username } : {}),
}
const res = await db
.update(user)
.set(cleanUpdateData)
.where(eq(user.id, id))
.returning()
.get()
if (!res) {
return json({ error: "User not found" }, { status: 404 })
}
return json({ res })
} catch (error) {
if (error instanceof z.ZodError) {
return json({ error: error.errors }, { status: 400 })
}
return json({ error: "Internal server error" }, { status: 500 })
}
} else {
return methodNotAllowed
}
} else if (path === "/api/user/check-username") {
if (method === "GET") {
const params = url.searchParams
const username = params.get("username")
if (!username) return invalidRequest
const exists = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.username, username),
})
return json({ exists: !!exists })
}
return methodNotAllowed
} else if (
path === "/api/user/increment-generations" &&
method === "POST"
) {
const schema = z.object({
userId: z.string(),
})
const body = await request.json()
const { userId } = schema.parse(body)
await db
.update(user)
.set({ generations: sql`${user.generations} + 1` })
.where(eq(user.id, userId))
.get()
return success
} else if (path === "/api/user/update-tier" && method === "POST") {
const schema = z.object({
userId: z.string(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]),
tierExpiresAt: z.date(),
})
const body = await request.json()
const { userId, tier, tierExpiresAt } = schema.parse(body)
await db
.update(user)
.set({
tier,
tierExpiresAt: tierExpiresAt.getTime(),
// Reset generations when upgrading tier
generations: 0,
})
.where(eq(user.id, userId))
.get()
return success
} else if (path === "/api/user/check-reset" && method === "POST") {
const schema = z.object({
userId: z.string(),
})
const body = await request.json()
const { userId } = schema.parse(body)
const dbUser = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, userId),
})
if (!dbUser) {
return new Response("User not found", { status: 404 })
}
const now = new Date()
const lastReset = dbUser.lastResetDate
? new Date(dbUser.lastResetDate)
: new Date(0)
if (
now.getMonth() !== lastReset.getMonth() ||
now.getFullYear() !== lastReset.getFullYear()
) {
await db
.update(user)
.set({
generations: 0,
lastResetDate: now.getTime(),
})
.where(eq(user.id, userId))
.get()
return new Response("Reset successful", { status: 200 })
}
return new Response("No reset needed", { status: 200 })
} else return notFound
},
const res = await db
.insert(user)
.values({ id, name, email })
.returning()
.get()
return json({ res })
} else if (method === "DELETE") {
const params = url.searchParams
if (params.has("id")) {
const id = params.get("id") as string
await db.delete(user).where(eq(user.id, id))
return success
} else return invalidRequest
} else {
return methodNotAllowed
}
} else return notFound
},
}

View File

@ -1,140 +1,69 @@
import { createId } from "@paralleldrive/cuid2"
import { relations, sql } from "drizzle-orm"
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { relations } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
export const KNOWN_PLATFORMS = [
"github",
"twitter",
"instagram",
"bluesky",
"linkedin",
"youtube",
"twitch",
"discord",
"mastodon",
"threads",
"gitlab",
"generic",
] as const
export type KnownPlatform = (typeof KNOWN_PLATFORMS)[number]
export type UserLink = {
url: string
platform: KnownPlatform
}
// #region Tables
export const user = sqliteTable("user", {
id: text("id")
.$defaultFn(() => createId())
.primaryKey()
.unique(),
name: text("name").notNull(),
email: text("email").notNull(),
username: text("username").notNull().unique(),
avatarUrl: text("avatarUrl"),
githubToken: text("githubToken"),
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
sql`CURRENT_TIMESTAMP`
),
generations: integer("generations").default(0),
bio: text("bio"),
personalWebsite: text("personalWebsite"),
links: text("links", { mode: "json" }).default("[]").$type<UserLink[]>(),
tier: text("tier", { enum: ["FREE", "PRO", "ENTERPRISE"] }).default("FREE"),
tierExpiresAt: integer("tierExpiresAt"),
lastResetDate: integer("lastResetDate"),
id: text("id")
.$defaultFn(() => createId())
.primaryKey()
.unique(),
name: text("name").notNull(),
email: text("email").notNull(),
image: text("image"),
generations: integer("generations").default(0),
})
export type User = typeof user.$inferSelect
export const userRelations = relations(user, ({ many }) => ({
sandbox: many(sandbox),
usersToSandboxes: many(usersToSandboxes),
}))
export const sandbox = sqliteTable("sandbox", {
id: text("id")
.$defaultFn(() => createId())
.primaryKey()
.unique(),
name: text("name").notNull(),
type: text("type").notNull(),
visibility: text("visibility", { enum: ["public", "private"] }),
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
sql`CURRENT_TIMESTAMP`
),
userId: text("user_id")
.notNull()
.references(() => user.id),
likeCount: integer("likeCount").default(0),
viewCount: integer("viewCount").default(0),
containerId: text("containerId"),
id: text("id")
.$defaultFn(() => createId())
.primaryKey()
.unique(),
name: text("name").notNull(),
type: text("type").notNull(),
visibility: text("visibility", { enum: ["public", "private"] }),
createdAt: integer("createdAt", { mode: "timestamp_ms" }),
userId: text("user_id")
.notNull()
.references(() => user.id),
})
export type Sandbox = typeof sandbox.$inferSelect
export const sandboxLikes = sqliteTable(
"sandbox_likes",
{
userId: text("user_id")
.notNull()
.references(() => user.id),
sandboxId: text("sandbox_id")
.notNull()
.references(() => sandbox.id),
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
sql`CURRENT_TIMESTAMP`
),
},
(table) => ({
pk: primaryKey({ columns: [table.sandboxId, table.userId] }),
})
)
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
author: one(user, {
fields: [sandbox.userId],
references: [user.id],
}),
usersToSandboxes: many(usersToSandboxes),
}))
export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
userId: text("userId")
.notNull()
.references(() => user.id),
sandboxId: text("sandboxId")
.notNull()
.references(() => sandbox.id),
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
userId: text("userId")
.notNull()
.references(() => user.id),
sandboxId: text("sandboxId")
.notNull()
.references(() => sandbox.id),
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
})
// #region Relations
export const userRelations = relations(user, ({ many }) => ({
sandbox: many(sandbox),
usersToSandboxes: many(usersToSandboxes),
likes: many(sandboxLikes),
}))
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
author: one(user, {
fields: [sandbox.userId],
references: [user.id],
}),
usersToSandboxes: many(usersToSandboxes),
likes: many(sandboxLikes),
}))
export const sandboxLikesRelations = relations(sandboxLikes, ({ one }) => ({
user: one(user, {
fields: [sandboxLikes.userId],
references: [user.id],
}),
sandbox: one(sandbox, {
fields: [sandboxLikes.sandboxId],
references: [sandbox.id],
}),
}))
export const usersToSandboxesRelations = relations(
usersToSandboxes,
({ one }) => ({
group: one(sandbox, {
fields: [usersToSandboxes.sandboxId],
references: [sandbox.id],
}),
user: one(user, {
fields: [usersToSandboxes.userId],
references: [user.id],
}),
})
usersToSandboxes,
({ one }) => ({
group: one(sandbox, {
fields: [usersToSandboxes.sandboxId],
references: [sandbox.id],
}),
user: one(user, {
fields: [usersToSandboxes.userId],
references: [user.id],
}),
})
)
// #endregion

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,8 @@
"concurrently": "^8.2.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"e2b": "^1.0.5",
"e2b": "^0.16.2-beta.47",
"express": "^4.19.2",
"jzip": "^1.0.0",
"rate-limiter-flexible": "^5.0.3",
"simple-git": "^3.25.0",
"socket.io": "^4.7.5",
@ -26,7 +25,6 @@
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jszip": "^3.4.1",
"@types/node": "^20.12.7",
"@types/ssh2": "^1.15.0",
"nodemon": "^3.1.0",

View File

@ -0,0 +1,90 @@
// AIWorker class for handling AI-related operations
export class AIWorker {
private aiWorkerUrl: string
private cfAiKey: string
private databaseWorkerUrl: string
private workersKey: string
// Constructor to initialize AIWorker with necessary URLs and keys
constructor(
aiWorkerUrl: string,
cfAiKey: string,
databaseWorkerUrl: string,
workersKey: string
) {
this.aiWorkerUrl = aiWorkerUrl
this.cfAiKey = cfAiKey
this.databaseWorkerUrl = databaseWorkerUrl
this.workersKey = workersKey
}
// Method to generate code based on user input
async generateCode(
userId: string,
fileName: string,
code: string,
line: number,
instructions: string
): Promise<{ response: string; success: boolean }> {
try {
const fetchPromise = fetch(
`${process.env.DATABASE_WORKER_URL}/api/sandbox/generate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.WORKERS_KEY}`,
},
body: JSON.stringify({
userId: userId,
}),
}
)
// Generate code from cloudflare workers AI
const generateCodePromise = fetch(
`${process.env.AI_WORKER_URL}/api?fileName=${encodeURIComponent(
fileName
)}&code=${encodeURIComponent(code)}&line=${encodeURIComponent(
line
)}&instructions=${encodeURIComponent(instructions)}`,
{
headers: {
"Content-Type": "application/json",
Authorization: `${process.env.CF_AI_KEY}`,
},
}
)
const [fetchResponse, generateCodeResponse] = await Promise.all([
fetchPromise,
generateCodePromise,
])
if (!generateCodeResponse.ok) {
throw new Error(`HTTP error! status: ${generateCodeResponse.status}`)
}
const reader = generateCodeResponse.body?.getReader()
const decoder = new TextDecoder()
let result = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value, { stream: true })
}
}
// The result should now contain only the modified code
return { response: result.trim(), success: true }
} catch (e: any) {
console.error("Error generating code:", e)
return {
response: "Error generating code. Please try again.",
success: false,
}
}
}
}

View File

@ -1,61 +1,58 @@
import { Socket } from "socket.io"
class Counter {
private count: number = 0
private count: number = 0
increment() {
this.count++
}
increment() {
this.count++
}
decrement() {
this.count = Math.max(0, this.count - 1)
}
decrement() {
this.count = Math.max(0, this.count - 1)
}
getValue(): number {
return this.count
}
getValue(): number {
return this.count
}
}
// Owner Connection Management
export class ConnectionManager {
// Counts how many times the owner is connected to a sandbox
private ownerConnections: Record<string, Counter> = {}
// Stores all sockets connected to a given sandbox
private sockets: Record<string, Set<Socket>> = {}
// Counts how many times the owner is connected to a sandbox
private ownerConnections: Record<string, Counter> = {}
// Stores all sockets connected to a given sandbox
private sockets: Record<string, Set<Socket>> = {}
// Checks if the owner of a sandbox is connected
ownerIsConnected(sandboxId: string): boolean {
return this.ownerConnections[sandboxId]?.getValue() > 0
}
// Adds a connection for a sandbox
addConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
this.sockets[sandboxId] ??= new Set()
this.sockets[sandboxId].add(socket)
// If the connection is for the owner, increments the owner connection counter
if (isOwner) {
this.ownerConnections[sandboxId] ??= new Counter()
this.ownerConnections[sandboxId].increment()
// Checks if the owner of a sandbox is connected
ownerIsConnected(sandboxId: string): boolean {
return this.ownerConnections[sandboxId]?.getValue() > 0
}
}
// Removes a connection for a sandbox
removeConnectionForSandbox(
socket: Socket,
sandboxId: string,
isOwner: boolean
) {
this.sockets[sandboxId]?.delete(socket)
// Adds a connection for a sandbox
addConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
this.sockets[sandboxId] ??= new Set()
this.sockets[sandboxId].add(socket)
// If the connection being removed is for the owner, decrements the owner connection counter
if (isOwner) {
this.ownerConnections[sandboxId]?.decrement()
// If the connection is for the owner, increments the owner connection counter
if (isOwner) {
this.ownerConnections[sandboxId] ??= new Counter()
this.ownerConnections[sandboxId].increment()
}
}
// Removes a connection for a sandbox
removeConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
this.sockets[sandboxId]?.delete(socket)
// If the connection being removed is for the owner, decrements the owner connection counter
if (isOwner) {
this.ownerConnections[sandboxId]?.decrement()
}
}
// Returns the set of sockets connected to a given sandbox
connectionsForSandbox(sandboxId: string): Set<Socket> {
return this.sockets[sandboxId] ?? new Set();
}
}
// Returns the set of sockets connected to a given sandbox
connectionsForSandbox(sandboxId: string): Set<Socket> {
return this.sockets[sandboxId] ?? new Set()
}
}

View File

@ -31,30 +31,9 @@ export class DokkuClient extends SSHSocketClient {
// List all deployed Dokku apps
async listApps(): Promise<string[]> {
const response = await this.sendCommand("--quiet apps:list")
return response.output.split("\n")
}
// Get the creation timestamp of an app
async getAppCreatedAt(appName: string): Promise<number> {
const response = await this.sendCommand(
`apps:report --app-created-at ${appName}`
)
const createdAt = parseInt(response.output.trim(), 10)
if (isNaN(createdAt)) {
throw new Error(
`Failed to retrieve creation timestamp for app ${appName}`
)
}
return createdAt
}
// Check if an app exists
async appExists(appName: string): Promise<boolean> {
const response = await this.sendCommand(`apps:exists ${appName}`)
return response.output.includes("App") === false
const response = await this.sendCommand("apps:list")
// Split the output by newline and remove the header
return response.output.split("\n").slice(1)
}
}

View File

@ -1,5 +1,4 @@
import { FilesystemEvent, Sandbox, WatchHandle } from "e2b"
import JSZip from "jszip"
import path from "path"
import RemoteFileStorage from "./RemoteFileStorage"
import { MAX_BODY_SIZE } from "./ratelimit"
@ -24,11 +23,7 @@ function generateFileStructure(paths: string[]): (TFolder | TFile)[] {
}
} else {
if (isFile) {
const file: TFile = {
id: `/${parts.join("/")}`,
type: "file",
name: part,
}
const file: TFile = { id: `/${parts.join("/")}`, type: "file", name: part }
current.children.push(file)
} else {
const folder: TFolder = {
@ -80,9 +75,7 @@ export class FileManager {
if (isFile) {
const fileId = `/${parts.join("/")}`
const data = await RemoteFileStorage.fetchFileContent(
`projects/${this.sandboxId}${fileId}`
)
const data = await RemoteFileStorage.fetchFileContent(`projects/${this.sandboxId}${fileId}`)
fileData.push({ id: fileId, data })
}
}
@ -92,17 +85,13 @@ export class FileManager {
// Convert local file path to remote path
private getRemoteFileId(localId: string): string {
return [
"projects",
this.sandboxId,
localId.startsWith("/") ? localId : localId,
].join("/")
return `projects/${this.sandboxId}${localId}`
}
// Convert remote file path to local file path
private getLocalFileId(remoteId: string): string | undefined {
const allParts = remoteId.split("/")
if (allParts[1] !== this.sandboxId) return undefined
if (allParts[1] !== this.sandboxId) return undefined;
return allParts.slice(2).join("/")
}
@ -110,7 +99,7 @@ export class FileManager {
private getLocalFileIds(remoteIds: string[]): string[] {
return remoteIds
.map(this.getLocalFileId.bind(this))
.filter((id) => id !== undefined)
.filter((id) => id !== undefined);
}
// Download files from remote storage
@ -129,21 +118,9 @@ export class FileManager {
return this.files
}
private async loadLocalFiles() {
// Reload file list from the container to include template files
const result = await this.sandbox.commands.run(
`find "${this.dirName}" -type f`
) // List all files recursively
const localPaths = result.stdout.split("\n").filter((path) => path) // Split the output into an array and filter out empty strings
const relativePaths = localPaths.map((filePath) =>
path.posix.relative(this.dirName, filePath)
) // Convert absolute paths to relative paths
this.files = generateFileStructure(relativePaths)
}
// Initialize the FileManager
async initialize() {
// Download files from remote file storage
await this.updateFileStructure()
await this.updateFileData()
@ -151,7 +128,7 @@ export class FileManager {
// Copy all files from the project to the container
const promises = this.fileData.map(async (file) => {
try {
const filePath = path.posix.join(this.dirName, file.id)
const filePath = path.join(this.dirName, file.id)
const parentDirectory = path.dirname(filePath)
if (!this.sandbox.files.exists(parentDirectory)) {
await this.sandbox.files.makeDir(parentDirectory)
@ -163,8 +140,6 @@ export class FileManager {
})
await Promise.all(promises)
await this.loadLocalFiles()
// Make the logged in user the owner of all project files
this.fixPermissions()
@ -188,7 +163,9 @@ export class FileManager {
// Change the owner of the project directory to user
private async fixPermissions() {
try {
await this.sandbox.commands.run(`sudo chown -R user "${this.dirName}"`)
await this.sandbox.commands.run(
`sudo chown -R user "${this.dirName}"`
)
} catch (e: any) {
console.log("Failed to fix permissions: " + e)
}
@ -197,7 +174,7 @@ export class FileManager {
// Watch a directory for changes
async watchDirectory(directory: string): Promise<WatchHandle | undefined> {
try {
const handle = await this.sandbox.files.watchDir(
const handle = await this.sandbox.files.watch(
directory,
async (event: FilesystemEvent) => {
try {
@ -210,10 +187,7 @@ export class FileManager {
// This is the absolute file path in the container
const containerFilePath = path.posix.join(directory, event.name)
// This is the file path relative to the project directory
const sandboxFilePath = removeDirName(
containerFilePath,
this.dirName
)
const sandboxFilePath = removeDirName(containerFilePath, this.dirName)
// This is the directory being watched relative to the project directory
const sandboxDirectory = removeDirName(directory, this.dirName)
@ -230,13 +204,77 @@ export class FileManager {
// Handle file/directory creation event
if (event.type === "create") {
await this.loadLocalFiles()
const folder = findFolderById(
this.files,
sandboxDirectory
) as TFolder
const isDir = await this.isDirectory(containerFilePath)
const newItem = isDir
? ({
id: sandboxFilePath,
name: event.name,
type: "folder",
children: [],
} as TFolder)
: ({
id: sandboxFilePath,
name: event.name,
type: "file",
} as TFile)
if (folder) {
// If the folder exists, add the new item (file/folder) as a child
folder.children.push(newItem)
} else {
// If folder doesn't exist, add the new item to the root
this.files.push(newItem)
}
if (!isDir) {
const fileData = await this.sandbox.files.read(
containerFilePath
)
const fileContents =
typeof fileData === "string" ? fileData : ""
this.fileData.push({
id: sandboxFilePath,
data: fileContents,
})
}
console.log(`Create ${sandboxFilePath}`)
}
// Handle file/directory removal or rename event
else if (event.type === "remove" || event.type == "rename") {
await this.loadLocalFiles()
const folder = findFolderById(
this.files,
sandboxDirectory
) as TFolder
const isDir = await this.isDirectory(containerFilePath)
const isFileMatch = (file: TFolder | TFile | TFileData) =>
file.id === sandboxFilePath ||
file.id.startsWith(containerFilePath + "/")
if (folder) {
// Remove item from its parent folder
folder.children = folder.children.filter(
(file: TFolder | TFile) => !isFileMatch(file)
)
} else {
// Remove from the root if it's not inside a folder
this.files = this.files.filter(
(file: TFolder | TFile) => !isFileMatch(file)
)
}
// Also remove any corresponding file data
this.fileData = this.fileData.filter(
(file: TFileData) => !isFileMatch(file)
)
console.log(`Removed: ${sandboxFilePath}`)
}
@ -286,7 +324,7 @@ export class FileManager {
)
}
},
{ timeoutMs: 0 }
{ timeout: 0 }
)
this.fileWatchers.push(handle)
return handle
@ -310,16 +348,13 @@ export class FileManager {
// Get file content
async getFile(fileId: string): Promise<string | undefined> {
const filePath = path.posix.join(this.dirName, fileId)
const fileContent = await this.sandbox.files.read(filePath)
return fileContent
const file = this.fileData.find((f) => f.id === fileId)
return file?.data
}
// Get folder content
async getFolder(folderId: string): Promise<string[]> {
const remotePaths = await RemoteFileStorage.getFolder(
this.getRemoteFileId(folderId)
)
const remotePaths = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
return this.getLocalFileIds(remotePaths)
}
@ -330,65 +365,12 @@ export class FileManager {
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
throw new Error("File size too large. Please reduce the file size.")
}
// Save to remote storage
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
const file = this.fileData.find((f) => f.id === fileId)
if (!file) return
file.data = body
// Update local file data cache
let file = this.fileData.find((f) => f.id === fileId)
if (file) {
file.data = body
} else {
file = {
id: fileId,
data: body,
}
this.fileData.push(file)
}
// Save to sandbox filesystem
const filePath = path.posix.join(this.dirName, fileId)
await this.sandbox.files.write(filePath, body)
// Instead of updating the entire file structure, just ensure this file exists in it
const parts = fileId.split('/').filter(Boolean)
let current = this.files
let currentPath = ''
// Navigate/create the path to the file
for (let i = 0; i < parts.length - 1; i++) {
currentPath += '/' + parts[i]
let folder = current.find(
(f) => f.type === 'folder' && f.name === parts[i]
) as TFolder
if (!folder) {
folder = {
id: currentPath,
type: 'folder',
name: parts[i],
children: [],
}
current.push(folder)
}
current = folder.children
}
// Add/update the file in the structure if it doesn't exist
const fileName = parts[parts.length - 1]
const existingFile = current.find(
(f) => f.type === 'file' && f.name === fileName
)
if (!existingFile) {
current.push({
id: fileId,
type: 'file',
name: fileName,
})
}
this.refreshFileList?.(this.files)
await this.sandbox.files.write(path.posix.join(this.dirName, file.id), body)
this.fixPermissions()
}
@ -411,11 +393,7 @@ export class FileManager {
fileData.id = newFileId
file.id = newFileId
await RemoteFileStorage.renameFile(
this.getRemoteFileId(fileId),
this.getRemoteFileId(newFileId),
fileData.data
)
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
return this.updateFileStructure()
}
@ -447,88 +425,22 @@ export class FileManager {
await this.sandbox.files.write(path.posix.join(this.dirName, id), "")
await this.fixPermissions()
this.files.push({
id,
name,
type: "file",
})
this.fileData.push({
id,
data: "",
})
await RemoteFileStorage.createFile(this.getRemoteFileId(id))
return true
}
public async loadFileContent(): Promise<TFileData[]> {
// Get all file paths, excluding node_modules
const result = await this.sandbox.commands.run(
`find "${this.dirName}" -path "${this.dirName}/node_modules" -prune -o -type f -print`
)
const filePaths = result.stdout.split("\n").filter((path) => path) ?? []
console.log("Paths found for download (excluding node_modules):", filePaths)
// Add files to zip with synchronized content
for (const filePath of filePaths) {
const relativePath = filePath.replace(this.dirName, "") // Remove base directory from path
try {
// Read the file content from the sandbox
const content = await this.sandbox.files.read(filePath)
// Find the existing file data entry or create a new one
const fileDataEntry = this.fileData.find(
(f) => f.id === relativePath
) || {
id: relativePath,
data: typeof content === "string" ? content : "",
}
// Update the file data entry if it already exists, otherwise add it to the list
if (!this.fileData.includes(fileDataEntry)) {
this.fileData.push(fileDataEntry)
} else {
fileDataEntry.data = typeof content === "string" ? content : ""
}
} catch (error) {
console.error(`Failed to read content for ${relativePath}:`, error)
}
}
return this.fileData
}
public async getFilesForDownload(): Promise<string> {
// Create new JSZip instance
const zip = new JSZip()
await this.loadFileContent()
if (this.fileData.length === 0) {
console.error(
"No files found in the sandbox project directory for download."
)
return ""
}
// Add files to zip with synchronized content
for (const fileDataEntry of this.fileData) {
const relativePath = fileDataEntry.id
const content = fileDataEntry.data
zip.file(relativePath, content)
console.log(`Added file to ZIP: ${relativePath}`)
}
// Generate zip file
const zipBlob = await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 6,
},
})
// Convert Blob to Base64
const zipBlobArrayBuffer = await zipBlob.arrayBuffer()
const zipBlobBase64 = btoa(
String.fromCharCode(...new Uint8Array(zipBlobArrayBuffer))
)
return zipBlobBase64
}
// Create a new folder
async createFolder(name: string): Promise<void> {
const id = `/${name}`
@ -546,11 +458,7 @@ export class FileManager {
await this.moveFileInContainer(fileId, newFileId)
await this.fixPermissions()
await RemoteFileStorage.renameFile(
this.getRemoteFileId(fileId),
this.getRemoteFileId(newFileId),
fileData.data
)
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
fileData.id = newFileId
file.id = newFileId
@ -562,6 +470,9 @@ export class FileManager {
if (!file) return this.files
await this.sandbox.files.remove(path.posix.join(this.dirName, fileId))
this.fileData = this.fileData.filter(
(f) => f.id !== fileId
)
await RemoteFileStorage.deleteFile(this.getRemoteFileId(fileId))
return this.updateFileStructure()
@ -569,13 +480,14 @@ export class FileManager {
// Delete a folder
async deleteFolder(folderId: string): Promise<(TFolder | TFile)[]> {
const files = await RemoteFileStorage.getFolder(
this.getRemoteFileId(folderId)
)
const files = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
await Promise.all(
files.map(async (file) => {
await this.sandbox.files.remove(path.posix.join(this.dirName, file))
this.fileData = this.fileData.filter(
(f) => f.id !== file
)
await RemoteFileStorage.deleteFile(this.getRemoteFileId(file))
})
)
@ -587,7 +499,7 @@ export class FileManager {
async closeWatchers() {
await Promise.all(
this.fileWatchers.map(async (handle: WatchHandle) => {
await handle.stop()
await handle.close()
})
)
}

View File

@ -61,7 +61,11 @@ export const RemoteFileStorage = {
return res.ok
},
renameFile: async (fileId: string, newFileId: string, data: string) => {
renameFile: async (
fileId: string,
newFileId: string,
data: string
) => {
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/rename`, {
method: "POST",
headers: {
@ -107,7 +111,7 @@ export const RemoteFileStorage = {
}
)
return (await res.json()).size
},
}
}
export default RemoteFileStorage

View File

@ -13,10 +13,7 @@ export class SSHSocketClient {
private conn: Client
private config: SSHConfig
private socketPath: string
private _isConnected: boolean = false
public get isConnected(): boolean {
return this._isConnected
}
private isConnected: boolean = false
// Constructor initializes the SSH client and sets up configuration
constructor(config: SSHConfig, socketPath: string) {
@ -37,7 +34,7 @@ export class SSHSocketClient {
private closeConnection() {
console.log("Closing SSH connection...")
this.conn.end()
this._isConnected = false
this.isConnected = false
process.exit(0)
}
@ -47,17 +44,17 @@ export class SSHSocketClient {
this.conn
.on("ready", () => {
console.log("SSH connection established")
this._isConnected = true
this.isConnected = true
resolve()
})
.on("error", (err) => {
console.error("SSH connection error:", err)
this._isConnected = false
this.isConnected = false
reject(err)
})
.on("close", () => {
console.log("SSH connection closed")
this._isConnected = false
this.isConnected = false
})
.connect(this.config)
})
@ -89,13 +86,10 @@ export class SSHSocketClient {
)
})
.on("data", (data: Buffer) => {
// Netcat remains open until it is closed, so we close the connection once we receive data.
resolve(data.toString())
stream.close()
})
.stderr.on("data", (data: Buffer) => {
reject(new Error(data.toString()))
stream.close()
})
}
)

View File

@ -1,305 +0,0 @@
import { Sandbox as E2BSandbox } from "e2b"
import { Socket } from "socket.io"
import { CONTAINER_TIMEOUT } from "./constants"
import { DokkuClient } from "./DokkuClient"
import { FileManager } from "./FileManager"
import {
createFileRL,
createFolderRL,
deleteFileRL,
renameFileRL,
saveFileRL,
} from "./ratelimit"
import { SecureGitClient } from "./SecureGitClient"
import { TerminalManager } from "./TerminalManager"
import { TFile, TFolder } from "./types"
import { LockManager } from "./utils"
const lockManager = new LockManager()
// Define a type for SocketHandler functions
type SocketHandler<T = Record<string, any>> = (args: T) => any
// Extract port number from a string
function extractPortNumber(inputString: string): number | null {
const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, "")
const regex = /http:\/\/localhost:(\d+)/
const match = cleanedString.match(regex)
return match ? parseInt(match[1]) : null
}
type ServerContext = {
dokkuClient: DokkuClient | null
gitClient: SecureGitClient | null
}
export class Sandbox {
// Sandbox properties:
sandboxId: string
type: string
fileManager: FileManager | null
terminalManager: TerminalManager | null
container: E2BSandbox | null
// Server context:
dokkuClient: DokkuClient | null
gitClient: SecureGitClient | null
constructor(
sandboxId: string,
type: string,
{ dokkuClient, gitClient }: ServerContext
) {
// Sandbox properties:
this.sandboxId = sandboxId
this.type = type
this.fileManager = null
this.terminalManager = null
this.container = null
// Server context:
this.dokkuClient = dokkuClient
this.gitClient = gitClient
}
// Initializes the container for the sandbox environment
async initialize(
fileWatchCallback: ((files: (TFolder | TFile)[]) => void) | undefined
) {
// Acquire a lock to ensure exclusive access to the sandbox environment
await lockManager.acquireLock(this.sandboxId, async () => {
// Check if a container already exists and is running
if (this.container && (await this.container.isRunning())) {
console.log(`Found existing container ${this.sandboxId}`)
} else {
console.log("Creating container", this.sandboxId)
// Create a new container with a specified template and timeout
const templateTypes = [
"vanillajs",
"reactjs",
"nextjs",
"streamlit",
"php",
]
const template = templateTypes.includes(this.type)
? `gitwit-${this.type}`
: `base`
this.container = await E2BSandbox.create(template, {
timeoutMs: CONTAINER_TIMEOUT,
})
}
})
// Ensure a container was successfully created
if (!this.container) throw new Error("Failed to create container")
// Initialize the terminal manager if it hasn't been set up yet
if (!this.terminalManager) {
this.terminalManager = new TerminalManager(this.container)
console.log(`Terminal manager set up for ${this.sandboxId}`)
}
// Initialize the file manager if it hasn't been set up yet
if (!this.fileManager) {
this.fileManager = new FileManager(
this.sandboxId,
this.container,
fileWatchCallback ?? null
)
// Initialize the file manager and emit the initial files
await this.fileManager.initialize()
}
}
// Called when the client disconnects from the Sandbox
async disconnect() {
// Close all terminals managed by the terminal manager
await this.terminalManager?.closeAllTerminals()
// This way the terminal manager will be set up again if we reconnect
this.terminalManager = null
// Close all file watchers managed by the file manager
await this.fileManager?.closeWatchers()
// This way the file manager will be set up again if we reconnect
this.fileManager = null
}
handlers(connection: { userId: string; isOwner: boolean; socket: Socket }) {
// Handle heartbeat from a socket connection
const handleHeartbeat: SocketHandler = (_: any) => {
// Only keep the sandbox alive if the owner is still connected
if (connection.isOwner) {
this.container?.setTimeout(CONTAINER_TIMEOUT)
}
}
// Handle getting a file
const handleGetFile: SocketHandler = ({ fileId }: any) => {
return this.fileManager?.getFile(fileId)
}
// Handle getting a folder
const handleGetFolder: SocketHandler = ({ folderId }: any) => {
return this.fileManager?.getFolder(folderId)
}
// Handle saving a file
const handleSaveFile: SocketHandler = async ({ fileId, body }: any) => {
await saveFileRL.consume(connection.userId, 1)
return this.fileManager?.saveFile(fileId, body)
}
// Handle moving a file
const handleMoveFile: SocketHandler = ({ fileId, folderId }: any) => {
return this.fileManager?.moveFile(fileId, folderId)
}
// Handle listing apps
const handleListApps: SocketHandler = async (_: any) => {
if (!this.dokkuClient)
throw Error("Failed to retrieve apps list: No Dokku client")
return { success: true, apps: await this.dokkuClient.listApps() }
}
// Handle getting app creation timestamp
const handleGetAppCreatedAt: SocketHandler = async ({ appName }) => {
if (!this.dokkuClient)
throw new Error(
"Failed to retrieve app creation timestamp: No Dokku client"
)
return {
success: true,
createdAt: await this.dokkuClient.getAppCreatedAt(appName),
}
}
// Handle checking if an app exists
const handleAppExists: SocketHandler = async ({ appName }) => {
if (!this.dokkuClient) {
console.log("Failed to check app existence: No Dokku client")
return {
success: false,
}
}
if (!this.dokkuClient.isConnected) {
console.log(
"Failed to check app existence: The Dokku client is not connected"
)
return {
success: false,
}
}
return {
success: true,
exists: await this.dokkuClient.appExists(appName),
}
}
// Handle deploying code
const handleDeploy: SocketHandler = async (_: any) => {
if (!this.gitClient) throw Error("No git client")
if (!this.fileManager) throw Error("No file manager")
await this.gitClient.pushFiles(
await this.fileManager?.loadFileContent(),
this.sandboxId
)
return { success: true }
}
// Handle creating a file
const handleCreateFile: SocketHandler = async ({ name }: any) => {
await createFileRL.consume(connection.userId, 1)
return { success: await this.fileManager?.createFile(name) }
}
// Handle creating a folder
const handleCreateFolder: SocketHandler = async ({ name }: any) => {
await createFolderRL.consume(connection.userId, 1)
return { success: await this.fileManager?.createFolder(name) }
}
// Handle renaming a file
const handleRenameFile: SocketHandler = async ({
fileId,
newName,
}: any) => {
await renameFileRL.consume(connection.userId, 1)
return this.fileManager?.renameFile(fileId, newName)
}
// Handle deleting a file
const handleDeleteFile: SocketHandler = async ({ fileId }: any) => {
await deleteFileRL.consume(connection.userId, 1)
return this.fileManager?.deleteFile(fileId)
}
// Handle deleting a folder
const handleDeleteFolder: SocketHandler = ({ folderId }: any) => {
return this.fileManager?.deleteFolder(folderId)
}
// Handle creating a terminal session
const handleCreateTerminal: SocketHandler = async ({ id }: any) => {
await lockManager.acquireLock(this.sandboxId, async () => {
await this.terminalManager?.createTerminal(
id,
(responseString: string) => {
connection.socket.emit("terminalResponse", {
id,
data: responseString,
})
const port = extractPortNumber(responseString)
if (port) {
connection.socket.emit(
"previewURL",
"https://" + this.container?.getHost(port)
)
}
}
)
})
}
// Handle resizing a terminal
const handleResizeTerminal: SocketHandler = ({ dimensions }: any) => {
this.terminalManager?.resizeTerminal(dimensions)
}
// Handle sending data to a terminal
const handleTerminalData: SocketHandler = ({ id, data }: any) => {
return this.terminalManager?.sendTerminalData(id, data)
}
// Handle closing a terminal
const handleCloseTerminal: SocketHandler = ({ id }: any) => {
return this.terminalManager?.closeTerminal(id)
}
// Handle downloading files by download button
const handleDownloadFiles: SocketHandler = async () => {
if (!this.fileManager) throw Error("No file manager")
// Get the Base64 encoded ZIP string
const zipBase64 = await this.fileManager.getFilesForDownload()
return { zipBlob: zipBase64 }
}
return {
heartbeat: handleHeartbeat,
getFile: handleGetFile,
downloadFiles: handleDownloadFiles,
getFolder: handleGetFolder,
saveFile: handleSaveFile,
moveFile: handleMoveFile,
listApps: handleListApps,
getAppCreatedAt: handleGetAppCreatedAt,
getAppExists: handleAppExists,
deploy: handleDeploy,
createFile: handleCreateFile,
createFolder: handleCreateFolder,
renameFile: handleRenameFile,
deleteFile: handleDeleteFile,
deleteFolder: handleDeleteFolder,
createTerminal: handleCreateTerminal,
resizeTerminal: handleResizeTerminal,
terminalData: handleTerminalData,
closeTerminal: handleCloseTerminal,
}
}
}

View File

@ -0,0 +1,243 @@
import { Sandbox as E2BSandbox } from "e2b"
import { Socket } from "socket.io"
import { AIWorker } from "./AIWorker"
import { CONTAINER_TIMEOUT } from "./constants"
import { DokkuClient } from "./DokkuClient"
import { FileManager } from "./FileManager"
import {
createFileRL,
createFolderRL,
deleteFileRL,
renameFileRL,
saveFileRL,
} from "./ratelimit"
import { SecureGitClient } from "./SecureGitClient"
import { TerminalManager } from "./TerminalManager"
import { TFile, TFolder } from "./types"
import { LockManager } from "./utils"
const lockManager = new LockManager()
// Define a type for SocketHandler functions
type SocketHandler<T = Record<string, any>> = (args: T) => any;
// Extract port number from a string
function extractPortNumber(inputString: string): number | null {
const cleanedString = inputString.replace(/\x1B\[[0-9;]*m/g, "")
const regex = /http:\/\/localhost:(\d+)/
const match = cleanedString.match(regex)
return match ? parseInt(match[1]) : null
}
type ServerContext = {
aiWorker: AIWorker;
dokkuClient: DokkuClient | null;
gitClient: SecureGitClient | null;
};
export class Sandbox {
// Sandbox properties:
sandboxId: string;
fileManager: FileManager | null;
terminalManager: TerminalManager | null;
container: E2BSandbox | null;
// Server context:
dokkuClient: DokkuClient | null;
gitClient: SecureGitClient | null;
aiWorker: AIWorker;
constructor(sandboxId: string, { aiWorker, dokkuClient, gitClient }: ServerContext) {
// Sandbox properties:
this.sandboxId = sandboxId;
this.fileManager = null;
this.terminalManager = null;
this.container = null;
// Server context:
this.aiWorker = aiWorker;
this.dokkuClient = dokkuClient;
this.gitClient = gitClient;
}
// Initializes the container for the sandbox environment
async initialize(
fileWatchCallback: ((files: (TFolder | TFile)[]) => void) | undefined
) {
// Acquire a lock to ensure exclusive access to the sandbox environment
await lockManager.acquireLock(this.sandboxId, async () => {
// Check if a container already exists and is running
if (this.container && await this.container.isRunning()) {
console.log(`Found existing container ${this.sandboxId}`)
} else {
console.log("Creating container", this.sandboxId)
// Create a new container with a specified timeout
this.container = await E2BSandbox.create({
timeoutMs: CONTAINER_TIMEOUT,
})
}
})
// Ensure a container was successfully created
if (!this.container) throw new Error("Failed to create container")
// Initialize the terminal manager if it hasn't been set up yet
if (!this.terminalManager) {
this.terminalManager = new TerminalManager(this.container)
console.log(`Terminal manager set up for ${this.sandboxId}`)
}
// Initialize the file manager if it hasn't been set up yet
if (!this.fileManager) {
this.fileManager = new FileManager(
this.sandboxId,
this.container,
fileWatchCallback ?? null
)
// Initialize the file manager and emit the initial files
await this.fileManager.initialize()
}
}
// Called when the client disconnects from the Sandbox
async disconnect() {
// Close all terminals managed by the terminal manager
await this.terminalManager?.closeAllTerminals()
// This way the terminal manager will be set up again if we reconnect
this.terminalManager = null;
// Close all file watchers managed by the file manager
await this.fileManager?.closeWatchers()
// This way the file manager will be set up again if we reconnect
this.fileManager = null;
}
handlers(connection: { userId: string, isOwner: boolean, socket: Socket }) {
// Handle heartbeat from a socket connection
const handleHeartbeat: SocketHandler = (_: any) => {
// Only keep the sandbox alive if the owner is still connected
if (connection.isOwner) {
this.container?.setTimeout(CONTAINER_TIMEOUT)
}
}
// Handle getting a file
const handleGetFile: SocketHandler = ({ fileId }: any) => {
return this.fileManager?.getFile(fileId)
}
// Handle getting a folder
const handleGetFolder: SocketHandler = ({ folderId }: any) => {
return this.fileManager?.getFolder(folderId)
}
// Handle saving a file
const handleSaveFile: SocketHandler = async ({ fileId, body }: any) => {
await saveFileRL.consume(connection.userId, 1);
return this.fileManager?.saveFile(fileId, body)
}
// Handle moving a file
const handleMoveFile: SocketHandler = ({ fileId, folderId }: any) => {
return this.fileManager?.moveFile(fileId, folderId)
}
// Handle listing apps
const handleListApps: SocketHandler = async (_: any) => {
if (!this.dokkuClient) throw Error("Failed to retrieve apps list: No Dokku client")
return { success: true, apps: await this.dokkuClient.listApps() }
}
// Handle deploying code
const handleDeploy: SocketHandler = async (_: any) => {
if (!this.gitClient) throw Error("No git client")
if (!this.fileManager) throw Error("No file manager")
await this.gitClient.pushFiles(this.fileManager?.fileData, this.sandboxId)
return { success: true }
}
// Handle creating a file
const handleCreateFile: SocketHandler = async ({ name }: any) => {
await createFileRL.consume(connection.userId, 1);
return { "success": await this.fileManager?.createFile(name) }
}
// Handle creating a folder
const handleCreateFolder: SocketHandler = async ({ name }: any) => {
await createFolderRL.consume(connection.userId, 1);
return { "success": await this.fileManager?.createFolder(name) }
}
// Handle renaming a file
const handleRenameFile: SocketHandler = async ({ fileId, newName }: any) => {
await renameFileRL.consume(connection.userId, 1)
return this.fileManager?.renameFile(fileId, newName)
}
// Handle deleting a file
const handleDeleteFile: SocketHandler = async ({ fileId }: any) => {
await deleteFileRL.consume(connection.userId, 1)
return this.fileManager?.deleteFile(fileId)
}
// Handle deleting a folder
const handleDeleteFolder: SocketHandler = ({ folderId }: any) => {
return this.fileManager?.deleteFolder(folderId)
}
// Handle creating a terminal session
const handleCreateTerminal: SocketHandler = async ({ id }: any) => {
await lockManager.acquireLock(this.sandboxId, async () => {
await this.terminalManager?.createTerminal(id, (responseString: string) => {
connection.socket.emit("terminalResponse", { id, data: responseString })
const port = extractPortNumber(responseString)
if (port) {
connection.socket.emit(
"previewURL",
"https://" + this.container?.getHost(port)
)
}
})
})
}
// Handle resizing a terminal
const handleResizeTerminal: SocketHandler = ({ dimensions }: any) => {
this.terminalManager?.resizeTerminal(dimensions)
}
// Handle sending data to a terminal
const handleTerminalData: SocketHandler = ({ id, data }: any) => {
return this.terminalManager?.sendTerminalData(id, data)
}
// Handle closing a terminal
const handleCloseTerminal: SocketHandler = ({ id }: any) => {
return this.terminalManager?.closeTerminal(id)
}
// Handle generating code
const handleGenerateCode: SocketHandler = ({ fileName, code, line, instructions }: any) => {
return this.aiWorker.generateCode(connection.userId, fileName, code, line, instructions)
}
return {
"heartbeat": handleHeartbeat,
"getFile": handleGetFile,
"getFolder": handleGetFolder,
"saveFile": handleSaveFile,
"moveFile": handleMoveFile,
"list": handleListApps,
"deploy": handleDeploy,
"createFile": handleCreateFile,
"createFolder": handleCreateFolder,
"renameFile": handleRenameFile,
"deleteFile": handleDeleteFile,
"deleteFolder": handleDeleteFolder,
"createTerminal": handleCreateTerminal,
"resizeTerminal": handleResizeTerminal,
"terminalData": handleTerminalData,
"closeTerminal": handleCloseTerminal,
"generateCode": handleGenerateCode,
};
}
}

View File

@ -57,7 +57,7 @@ export class SecureGitClient {
// Add files to the repository
for (const { id, data } of fileData) {
await git.add(id.startsWith("/") ? id.slice(1) : id)
await git.add(id)
}
// Commit the changes

View File

@ -1,8 +1,8 @@
import { CommandHandle, Sandbox } from "e2b"
import { ProcessHandle, Sandbox } from "e2b"
// Terminal class to manage a pseudo-terminal (PTY) in a sandbox environment
export class Terminal {
private pty: CommandHandle | undefined // Holds the PTY process handle
private pty: ProcessHandle | undefined // Holds the PTY process handle
private sandbox: Sandbox // Reference to the sandbox environment
// Constructor initializes the Terminal with a sandbox
@ -24,7 +24,7 @@ export class Terminal {
this.pty = await this.sandbox.pty.create({
rows,
cols,
timeoutMs: 0,
timeout: 0,
onData: (data: Uint8Array) => {
onData(new TextDecoder().decode(data)) // Convert received data to string and pass to handler
},

View File

@ -4,19 +4,20 @@ import express, { Express } from "express"
import fs from "fs"
import { createServer } from "http"
import { Server, Socket } from "socket.io"
import { AIWorker } from "./AIWorker"
import { ConnectionManager } from "./ConnectionManager"
import { DokkuClient } from "./DokkuClient"
import { Sandbox } from "./Sandbox"
import { Sandbox } from "./SandboxManager"
import { SecureGitClient } from "./SecureGitClient"
import { socketAuth } from "./socketAuth" // Import the new socketAuth middleware
import { socketAuth } from "./socketAuth"; // Import the new socketAuth middleware
import { TFile, TFolder } from "./types"
// Log errors and send a notification to the client
export const handleErrors = (message: string, error: any, socket: Socket) => {
console.error(message, error)
socket.emit("error", `${message} ${error.message ?? error}`)
}
console.error(message, error);
socket.emit("error", `${message} ${error.message ?? error}`);
};
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
@ -63,10 +64,10 @@ if (!process.env.DOKKU_KEY)
const dokkuClient =
process.env.DOKKU_HOST && process.env.DOKKU_KEY && process.env.DOKKU_USERNAME
? new DokkuClient({
host: process.env.DOKKU_HOST,
username: process.env.DOKKU_USERNAME,
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
})
host: process.env.DOKKU_HOST,
username: process.env.DOKKU_USERNAME,
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
})
: null
dokkuClient?.connect()
@ -74,11 +75,19 @@ dokkuClient?.connect()
const gitClient =
process.env.DOKKU_HOST && process.env.DOKKU_KEY
? new SecureGitClient(
`dokku@${process.env.DOKKU_HOST}`,
process.env.DOKKU_KEY
)
`dokku@${process.env.DOKKU_HOST}`,
process.env.DOKKU_KEY
)
: null
// Add this near the top of the file, after other initializations
const aiWorker = new AIWorker(
process.env.AI_WORKER_URL!,
process.env.CF_AI_KEY!,
process.env.DATABASE_WORKER_URL!,
process.env.WORKERS_KEY!
)
// Handle a client connecting to the server
io.on("connection", async (socket) => {
try {
@ -87,7 +96,6 @@ io.on("connection", async (socket) => {
userId: string
sandboxId: string
isOwner: boolean
type: string
}
// Register the connection
@ -101,81 +109,70 @@ io.on("connection", async (socket) => {
try {
// Create or retrieve the sandbox manager for the given sandbox ID
const sandbox =
sandboxes[data.sandboxId] ??
new Sandbox(data.sandboxId, data.type, {
dokkuClient,
gitClient,
})
sandboxes[data.sandboxId] = sandbox
const sandboxManager = sandboxes[data.sandboxId] ?? new Sandbox(
data.sandboxId,
{
aiWorker, dokkuClient, gitClient,
}
)
sandboxes[data.sandboxId] = sandboxManager
// This callback recieves an update when the file list changes, and notifies all relevant connections.
const sendFileNotifications = (files: (TFolder | TFile)[]) => {
connections
.connectionsForSandbox(data.sandboxId)
.forEach((socket: Socket) => {
socket.emit("loaded", files)
})
}
connections.connectionsForSandbox(data.sandboxId).forEach((socket: Socket) => {
socket.emit("loaded", files);
});
};
// Initialize the sandbox container
// The file manager and terminal managers will be set up if they have been closed
await sandbox.initialize(sendFileNotifications)
socket.emit("loaded", sandbox.fileManager?.files)
await sandboxManager.initialize(sendFileNotifications)
socket.emit("loaded", sandboxManager.fileManager?.files)
// Register event handlers for the sandbox
// For each event handler, listen on the socket for that event
// Pass connection-specific information to the handlers
Object.entries(
sandbox.handlers({
userId: data.userId,
isOwner: data.isOwner,
socket,
})
).forEach(([event, handler]) => {
socket.on(
event,
async (options: any, callback?: (response: any) => void) => {
try {
const result = await handler(options)
callback?.(result)
} catch (e: any) {
handleErrors(`Error processing event "${event}":`, e, socket)
}
Object.entries(sandboxManager.handlers({
userId: data.userId,
isOwner: data.isOwner,
socket
})).forEach(([event, handler]) => {
socket.on(event, async (options: any, callback?: (response: any) => void) => {
try {
const result = await handler(options)
callback?.(result);
} catch (e: any) {
handleErrors(`Error processing event "${event}":`, e, socket);
}
)
})
socket.emit("ready")
});
});
// Handle disconnection event
socket.on("disconnect", async () => {
try {
// Deregister the connection
connections.removeConnectionForSandbox(
socket,
data.sandboxId,
data.isOwner
)
connections.removeConnectionForSandbox(socket, data.sandboxId, data.isOwner)
// If the owner has disconnected from all sockets, close open terminals and file watchers.o
// The sandbox itself will timeout after the heartbeat stops.
if (data.isOwner && !connections.ownerIsConnected(data.sandboxId)) {
await sandbox.disconnect()
await sandboxManager.disconnect()
socket.broadcast.emit(
"disableAccess",
"The sandbox owner has disconnected."
)
}
} catch (e: any) {
handleErrors("Error disconnecting:", e, socket)
handleErrors("Error disconnecting:", e, socket);
}
})
} catch (e: any) {
handleErrors(`Error initializing sandbox ${data.sandboxId}:`, e, socket)
handleErrors(`Error initializing sandbox ${data.sandboxId}:`, e, socket);
}
} catch (e: any) {
handleErrors("Error connecting:", e, socket)
handleErrors("Error connecting:", e, socket);
}
})

View File

@ -1,75 +1,63 @@
import { Socket } from "socket.io"
import { z } from "zod"
import { Sandbox, User } from "./types"
import { User } from "./types"
// Middleware for socket authentication
export const socketAuth = async (socket: Socket, next: Function) => {
// Define the schema for handshake query validation
const handshakeSchema = z.object({
userId: z.string(),
sandboxId: z.string(),
EIO: z.string(),
transport: z.string(),
})
// Define the schema for handshake query validation
const handshakeSchema = z.object({
userId: z.string(),
sandboxId: z.string(),
EIO: z.string(),
transport: z.string(),
})
const q = socket.handshake.query
const parseQuery = handshakeSchema.safeParse(q)
const q = socket.handshake.query
const parseQuery = handshakeSchema.safeParse(q)
// Check if the query is valid according to the schema
if (!parseQuery.success) {
next(new Error("Invalid request."))
return
}
const { sandboxId, userId } = parseQuery.data
// Fetch user data from the database
const dbUser = await fetch(
`${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
// Check if the query is valid according to the schema
if (!parseQuery.success) {
next(new Error("Invalid request."))
return
}
)
const dbUserJSON = (await dbUser.json()) as User
// Fetch sandbox data from the database
const dbSandbox = await fetch(
`${process.env.DATABASE_WORKER_URL}/api/sandbox?id=${sandboxId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
const { sandboxId, userId } = parseQuery.data
// Fetch user data from the database
const dbUser = await fetch(
`${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`,
{
headers: {
Authorization: `${process.env.WORKERS_KEY}`,
},
}
)
const dbUserJSON = (await dbUser.json()) as User
// Check if user data was retrieved successfully
if (!dbUserJSON) {
next(new Error("DB error."))
return
}
)
const dbSandboxJSON = (await dbSandbox.json()) as Sandbox
// Check if user data was retrieved successfully
if (!dbUserJSON) {
next(new Error("DB error."))
return
}
// Check if the user owns the sandbox or has shared access
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
(uts) => uts.sandboxId === sandboxId
)
// Check if the user owns the sandbox or has shared access
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
(uts) => uts.sandboxId === sandboxId
)
// If user doesn't own or have shared access to the sandbox, deny access
if (!sandbox && !sharedSandboxes) {
next(new Error("Invalid credentials."))
return
}
// If user doesn't own or have shared access to the sandbox, deny access
if (!sandbox && !sharedSandboxes) {
next(new Error("Invalid credentials."))
return
}
// Set socket data with user information
socket.data = {
userId,
sandboxId: sandboxId,
isOwner: sandbox !== undefined,
}
// Set socket data with user information
socket.data = {
userId,
sandboxId: sandboxId,
isOwner: sandbox !== undefined,
type: dbSandboxJSON.type,
}
// Allow the connection
next()
// Allow the connection
next()
}

View File

@ -12,7 +12,7 @@ export type User = {
export type Sandbox = {
id: string
name: string
type: "reactjs" | "vanillajs" | "nextjs" | "streamlit"
type: "react" | "node"
visibility: "public" | "private"
createdAt: Date
userId: string

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,10 @@
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.1.0",
"@cloudflare/workers-types": "^4.20241106.0",
"@cloudflare/workers-types": "^4.20240419.0",
"typescript": "^5.0.4",
"vitest": "1.3.0",
"wrangler": "^3.86.0"
"wrangler": "^3.0.0"
},
"dependencies": {
"p-limit": "^6.1.0",

View File

@ -1,4 +1,4 @@
import { ExecutionContext, R2Bucket, Headers as CFHeaders } from "@cloudflare/workers-types"
import pLimit from "p-limit"
import { z } from "zod"
export interface Env {
@ -76,13 +76,14 @@ export default {
if (obj === null) {
return new Response(`${fileId} not found`, { status: 404 })
}
const headers = new Headers() as unknown as CFHeaders
const headers = new Headers()
headers.set("etag", obj.httpEtag)
obj.writeHttpMetadata(headers)
const text = await obj.text()
return new Response(text, {
headers: Object.fromEntries(headers.entries()),
headers,
})
} else return invalidRequest
} else if (method === "POST") {
@ -135,7 +136,33 @@ export default {
return success
} else if (path === "/api/init" && method === "POST") {
// This API path no longer does anything, because template files are stored in E2B sandbox templates.
const initSchema = z.object({
sandboxId: z.string(),
type: z.string(),
})
const body = await request.json()
const { sandboxId, type } = initSchema.parse(body)
console.log(`Copying template: ${type}`)
// List all objects under the directory
const { objects } = await env.Templates.list({ prefix: type })
// Copy each object to the new directory with a 5 concurrency limit
const limit = pLimit(5)
await Promise.all(
objects.map(({ key }) =>
limit(async () => {
const destinationKey = key.replace(type, `projects/${sandboxId}`)
const fileBody = await env.Templates.get(key).then(
(res) => res?.body ?? ""
)
await env.R2.put(destinationKey, fileBody)
})
)
)
return success
} else {
return notFound

View File

@ -34,7 +34,7 @@
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
"types": [
"@cloudflare/workers-types"
"@cloudflare/workers-types/2023-07-01"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
"resolveJsonModule": true /* Enable importing .json files */,

View File

@ -3,21 +3,16 @@ CLERK_SECRET_KEY=
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
LIVEBLOCKS_SECRET_KEY=
NEXT_PUBLIC_SERVER_PORT=4000
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Set WORKER_URLs after deploying the workers.
# Set NEXT_PUBLIC_WORKERS_KEY to be the same as KEY in /backend/storage/wrangler.toml.
NEXT_PUBLIC_DATABASE_WORKER_URL=https://database.your-worker.workers.dev
NEXT_PUBLIC_STORAGE_WORKER_URL=https://storage.your-worker.workers.dev
NEXT_PUBLIC_WORKERS_KEY=SUPERDUPERSECRET
NEXT_PUBLIC_DATABASE_WORKER_URL=
NEXT_PUBLIC_STORAGE_WORKER_URL=
NEXT_PUBLIC_WORKERS_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
ANTHROPIC_API_KEY=
OPENAI_API_KEY=

View File

@ -1,4 +1,4 @@
// import { Room } from "@/components/editor/live/room"
import { Room } from "@/components/editor/live/room"
import Loading from "@/components/editor/loading"
import Navbar from "@/components/editor/navbar"
import { TerminalProvider } from "@/context/TerminalContext"
@ -51,11 +51,7 @@ const getSharedUsers = async (usersToSandboxes: UsersToSandboxes[]) => {
}
)
const userData: User = await userRes.json()
return {
id: userData.id,
name: userData.name,
avatarUrl: userData.avatarUrl,
}
return { id: userData.id, name: userData.name }
})
)
@ -91,17 +87,21 @@ export default async function CodePage({ params }: { params: { id: string } }) {
}
return (
<TerminalProvider>
{/* <Room id={sandboxId}> */}
<div className="overflow-hidden overscroll-none w-screen h-screen grid [grid-template-rows:3.5rem_auto] bg-background">
<Navbar
userData={userData}
sandboxData={sandboxData}
shared={shared as { id: string; name: string; avatarUrl: string }[]}
/>
<CodeEditor userData={userData} sandboxData={sandboxData} />
<>
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
<Room id={sandboxId}>
<TerminalProvider>
<Navbar
userData={userData}
sandboxData={sandboxData}
shared={shared}
/>
<div className="w-screen flex grow">
<CodeEditor userData={userData} sandboxData={sandboxData} />
</div>
</TerminalProvider>
</Room>
</div>
{/* </Room> */}
</TerminalProvider>
</>
)
}

View File

@ -35,7 +35,6 @@ export default async function DashboardPage() {
type: "react" | "node"
author: string
sharedOn: Date
authorAvatarUrl: string
}[]
return (

View File

@ -1,5 +1,4 @@
import { User } from "@/lib/types"
import { generateUniqueUsername } from "@/lib/username-generator"
import { currentUser } from "@clerk/nextjs"
import { redirect } from "next/navigation"
@ -25,27 +24,6 @@ export default async function AppAuthLayout({
const dbUserJSON = (await dbUser.json()) as User
if (!dbUserJSON.id) {
// Try to get GitHub username if available
const githubUsername = user.externalAccounts.find(
(account) => account.provider === "github"
)?.username
const username =
githubUsername ||
(await generateUniqueUsername(async (username) => {
// Check if username exists in database
const userCheck = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user/check-username?username=${username}`,
{
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
},
}
)
const exists = await userCheck.json()
return exists.exists
}))
const res = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user`,
{
@ -58,20 +36,9 @@ export default async function AppAuthLayout({
id: user.id,
name: user.firstName + " " + user.lastName,
email: user.emailAddresses[0].emailAddress,
username: username,
avatarUrl: user.imageUrl || null,
createdAt: new Date().toISOString(),
}),
}
)
if (!res.ok) {
const error = await res.text()
console.error("Failed to create user:", error)
} else {
const data = await res.json()
console.log("User created successfully:", data)
}
}
return <>{children}</>

View File

@ -1,65 +0,0 @@
import ProfilePage from "@/components/profile"
import ProfileNavbar from "@/components/profile/navbar"
import { SandboxWithLiked, User } from "@/lib/types"
import { currentUser } from "@clerk/nextjs"
import { notFound } from "next/navigation"
export default async function Page({
params: { username: rawUsername },
}: {
params: { username: string }
}) {
const username = decodeURIComponent(rawUsername).replace("@", "")
const loggedInClerkUser = await currentUser()
const [profileOwnerResponse, loggedInUserResponse] = await Promise.all([
fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?username=${username}&currentUserId=${loggedInClerkUser?.id}`,
{
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
},
}
),
fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${loggedInClerkUser?.id}`,
{
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
},
}
),
])
const profileOwner = (await profileOwnerResponse.json()) as User
const loggedInUser = (await loggedInUserResponse.json()) as User
if (!Boolean(profileOwner?.id)) {
notFound()
}
const publicSandboxes: SandboxWithLiked[] = []
const privateSandboxes: SandboxWithLiked[] = []
profileOwner?.sandbox?.forEach((sandbox) => {
if (sandbox.visibility === "public") {
publicSandboxes.push(sandbox as SandboxWithLiked)
} else if (sandbox.visibility === "private") {
privateSandboxes.push(sandbox as SandboxWithLiked)
}
})
const isUserLoggedIn = Boolean(loggedInUser?.id)
return (
<section>
<ProfileNavbar userData={loggedInUser} />
<ProfilePage
publicSandboxes={publicSandboxes}
privateSandboxes={
profileOwner?.id === loggedInUser.id ? privateSandboxes : []
}
profileOwner={profileOwner}
loggedInUser={isUserLoggedIn ? loggedInUser : null}
/>
</section>
)
}

View File

@ -1,237 +0,0 @@
import {
ignoredFiles,
ignoredFolders,
} from "@/components/editor/AIChat/lib/ignored-paths"
import { templateConfigs } from "@/lib/templates"
import { TIERS } from "@/lib/tiers"
import { TFile, TFolder } from "@/lib/types"
import { Anthropic } from "@anthropic-ai/sdk"
import { currentUser } from "@clerk/nextjs"
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
})
// Format file structure for context
function formatFileStructure(
items: (TFile | TFolder)[] | undefined,
prefix = ""
): string {
if (!items || !Array.isArray(items)) {
return "No files available"
}
// Sort items to show folders first, then files
const sortedItems = [...items].sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name)
return a.type === "folder" ? -1 : 1
})
return sortedItems
.map((item) => {
if (
item.type === "file" &&
!ignoredFiles.some(
(pattern) =>
item.name.endsWith(pattern.replace("*", "")) ||
item.name === pattern
)
) {
return `${prefix}├── ${item.name}`
} else if (
item.type === "folder" &&
!ignoredFolders.some((folder) => folder === item.name)
) {
const folderContent = formatFileStructure(
item.children,
`${prefix}`
)
return `${prefix}├── ${item.name}/\n${folderContent}`
}
return null
})
.filter(Boolean)
.join("\n")
}
export async function POST(request: Request) {
try {
const user = await currentUser()
if (!user) {
return new Response("Unauthorized", { status: 401 })
}
// Check and potentially reset monthly usage
const resetResponse = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user/check-reset`,
{
method: "POST",
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ userId: user.id }),
}
)
if (!resetResponse.ok) {
console.error("Failed to check usage reset")
}
// Get user data and check tier
const dbUser = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${user.id}`,
{
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
},
}
)
const userData = await dbUser.json()
// Get tier settings
const tierSettings =
TIERS[userData.tier as keyof typeof TIERS] || TIERS.FREE
if (userData.generations >= tierSettings.generations) {
return new Response(
`AI generation limit reached for your ${userData.tier || "FREE"} tier`,
{ status: 429 }
)
}
const {
messages,
context,
activeFileContent,
isEditMode,
fileName,
line,
templateType,
files,
projectName,
} = await request.json()
// Get template configuration
const templateConfig = templateConfigs[templateType]
// Create template context
const templateContext = templateConfig
? `
Project Template: ${templateConfig.name}
Current File Structure:
${files ? formatFileStructure(files) : "No files available"}
Conventions:
${templateConfig.conventions.join("\n")}
Dependencies:
${JSON.stringify(templateConfig.dependencies, null, 2)}
Scripts:
${JSON.stringify(templateConfig.scripts, null, 2)}
`
: ""
// Create system message based on mode
let systemMessage
if (isEditMode) {
systemMessage = `You are an AI code editor working in a ${templateType} project. Your task is to modify the given code based on the user's instructions. Only output the modified code, without any explanations or markdown formatting. The code should be a direct replacement for the existing code. If there is no code to modify, refer to the active file content and only output the code that is relevant to the user's instructions.
${templateContext}
File: ${fileName}
Line: ${line}
Context:
${context || "No additional context provided"}
Active File Content:
${activeFileContent}
Instructions: ${messages[0].content}
Respond only with the modified code that can directly replace the existing code.`
} else {
systemMessage = `You are an intelligent programming assistant for a ${templateType} project. Please respond to the following request concisely. When providing code:
1. Format it using triple backticks (\`\`\`) with the appropriate language identifier.
2. Always specify the complete file path in the format:
${projectName}/filepath/to/file.ext
3. If creating a new file, specify the path as:
${projectName}/filepath/to/file.ext (new file)
4. Format your code blocks as:
${projectName}/filepath/to/file.ext
\`\`\`language
code here
\`\`\`
If multiple files are involved, repeat the format for each file. Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point.
This is the project template:
${templateContext}
${context ? `Context:\n${context}\n` : ""}
${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ""}`
}
// Create stream response
const stream = await anthropic.messages.create({
model: tierSettings.model,
max_tokens: tierSettings.maxTokens,
system: systemMessage,
messages: messages.map((msg: { role: string; content: string }) => ({
role: msg.role === "human" ? "user" : "assistant",
content: msg.content,
})),
stream: true,
})
// Increment user's generation count
await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user/increment-generations`,
{
method: "POST",
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ userId: user.id }),
}
)
// Return streaming response
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
controller.enqueue(encoder.encode(chunk.delta.text))
}
}
controller.close()
},
}),
{
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}
)
} catch (error) {
console.error("AI generation error:", error)
return new Response(
error instanceof Error ? error.message : "Internal Server Error",
{ status: 500 }
)
}
}

View File

@ -1,61 +1,57 @@
// import { colors } from "@/lib/colors"
// import { User } from "@/lib/types"
import { colors } from "@/lib/colors"
import { User } from "@/lib/types"
import { currentUser } from "@clerk/nextjs"
// import { Liveblocks } from "@liveblocks/node"
import { Liveblocks } from "@liveblocks/node"
import { NextRequest } from "next/server"
// const API_KEY = process.env.LIVEBLOCKS_SECRET_KEY!
const API_KEY = process.env.LIVEBLOCKS_SECRET_KEY!
// const liveblocks = new Liveblocks({
// secret: API_KEY!,
// })
const liveblocks = new Liveblocks({
secret: API_KEY!,
})
export async function POST(request: NextRequest) {
// Temporarily return unauthorized while Liveblocks is disabled
return new Response("Liveblocks collaboration temporarily disabled", { status: 503 })
const clerkUser = await currentUser()
// Original implementation commented out:
// const clerkUser = await currentUser()
//
// if (!clerkUser) {
// return new Response("Unauthorized", { status: 401 })
// }
//
// const res = await fetch(
// `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${clerkUser.id}`,
// {
// headers: {
// Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
// },
// }
// )
// const user = (await res.json()) as User
//
// const colorNames = Object.keys(colors)
// const randomColor = colorNames[
// Math.floor(Math.random() * colorNames.length)
// ] as keyof typeof colors
// const code = colors[randomColor]
//
// // Create a session for the current user
// // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
// const session = liveblocks.prepareSession(user.id, {
// userInfo: {
// name: user.name,
// email: user.email,
// color: randomColor,
// },
// })
//
// // Give the user access to the room
// user.sandbox.forEach((sandbox) => {
// session.allow(`${sandbox.id}`, session.FULL_ACCESS)
// })
// user.usersToSandboxes.forEach((userToSandbox) => {
// session.allow(`${userToSandbox.sandboxId}`, session.FULL_ACCESS)
// })
//
// // Authorize the user and return the result
// const { body, status } = await session.authorize()
// return new Response(body, { status })
if (!clerkUser) {
return new Response("Unauthorized", { status: 401 })
}
const res = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${clerkUser.id}`,
{
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
},
}
)
const user = (await res.json()) as User
const colorNames = Object.keys(colors)
const randomColor = colorNames[
Math.floor(Math.random() * colorNames.length)
] as keyof typeof colors
const code = colors[randomColor]
// Create a session for the current user
// userInfo is made available in Liveblocks presence hooks, e.g. useOthers
const session = liveblocks.prepareSession(user.id, {
userInfo: {
name: user.name,
email: user.email,
color: randomColor,
},
})
// Give the user access to the room
user.sandbox.forEach((sandbox) => {
session.allow(`${sandbox.id}`, session.FULL_ACCESS)
})
user.usersToSandboxes.forEach((userToSandbox) => {
session.allow(`${userToSandbox.sandboxId}`, session.FULL_ACCESS)
})
// Authorize the user and return the result
const { body, status } = await session.authorize()
return new Response(body, { status })
}

View File

@ -1,69 +0,0 @@
import OpenAI from "openai"
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
export async function POST(request: Request) {
try {
const { originalCode, newCode, fileName } = await request.json()
const systemPrompt = `You are a code merging assistant. Your task is to merge the new code snippet with the original file content while:
1. Preserving the original file's functionality
2. Ensuring proper integration of the new code
3. Maintaining consistent style and formatting
4. Resolving any potential conflicts
5. Output ONLY the raw code without any:
- Code fence markers (\`\`\`)
- Language identifiers (typescript, javascript, etc.)
- Explanations or comments
- Markdown formatting
The output should be the exact code that will replace the existing code, nothing more and nothing less.
Important: When merging, preserve the original code structure as much as possible. Only make necessary changes to integrate the new code while maintaining the original code's organization and style.`
const mergedCode = `Original file (${fileName}):\n${originalCode}\n\nNew code to merge:\n${newCode}`
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: mergedCode },
],
prediction: {
type: "content",
content: mergedCode,
},
stream: true,
})
// Clean and stream response
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
async start(controller) {
let buffer = ""
for await (const chunk of response) {
if (chunk.choices[0]?.delta?.content) {
buffer += chunk.choices[0].delta.content
// Clean any code fence markers that might appear in the stream
const cleanedContent = buffer
.replace(/^```[\w-]*\n|```\s*$/gm, "") // Remove code fences
.replace(/^(javascript|typescript|python|html|css)\n/gm, "") // Remove language identifiers
controller.enqueue(encoder.encode(cleanedContent))
buffer = ""
}
}
controller.close()
},
})
)
} catch (error) {
console.error("Merge error:", error)
return new Response(
error instanceof Error ? error.message : "Failed to merge code",
{ status: 500 }
)
}
}

View File

@ -1,42 +0,0 @@
import { currentUser } from "@clerk/nextjs"
export async function POST(request: Request) {
try {
const user = await currentUser()
if (!user) {
return new Response("Unauthorized", { status: 401 })
}
const { tier } = await request.json()
// handle payment processing here
const response = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user/update-tier`,
{
method: "POST",
headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: user.id,
tier,
tierExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
}),
}
)
if (!response.ok) {
throw new Error("Failed to upgrade tier")
}
return new Response("Tier upgraded successfully")
} catch (error) {
console.error("Tier upgrade error:", error)
return new Response(
error instanceof Error ? error.message : "Internal Server Error",
{ status: 500 }
)
}
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -102,27 +102,26 @@
.light .gradient-button-bg {
background: radial-gradient(
circle at top,
#f5f5f5 0%,
/* Very light gray */ #e0e0e0 50% /* Soft gray */
);
#262626 0%,
#f5f5f5 50%
); /* Dark gray -> Light gray */
}
.light .gradient-button {
background: radial-gradient(
circle at bottom,
hsl(0, 0%, 85%) -10%,
/* Slightly darker gray */ hsl(0, 0%, 95%) 50% /* Very soft light gray */
);
hsl(0, 10%, 25%) -10%,
#9d9d9d 50%
); /* Light gray -> Almost white */
}
.light .gradient-button-bg > div:hover {
background: radial-gradient(
circle at bottom,
hsl(0, 0%, 80%) -10%,
/* Slightly darker gray for hover */ hsl(0, 0%, 90%) 80% /* Softer gray */
);
hsl(0, 10%, 25%) -10%,
#9d9d9d 80%
); /* Light gray -> Almost white */
}
.inline-decoration::before {
content: "Generate";
color: #525252;
@ -176,23 +175,3 @@
.tab-scroll::-webkit-scrollbar {
display: none;
}
.added-line-decoration {
background-color: rgba(0, 255, 0, 0.1);
}
.removed-line-decoration {
background-color: rgba(255, 0, 0, 0.1);
}
.added-line-glyph {
background-color: #28a745;
width: 4px !important;
margin-left: 3px;
}
.removed-line-glyph {
background-color: #dc3545;
width: 4px !important;
margin-left: 3px;
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -1,7 +1,7 @@
import { Toaster } from "@/components/ui/sonner"
import { ThemeProvider } from "@/components/ui/theme-provider"
import { PreviewProvider } from "@/context/PreviewContext"
import { SocketProvider } from "@/context/SocketContext"
import { SocketProvider } from '@/context/SocketContext'
import { ClerkProvider } from "@clerk/nextjs"
import { Analytics } from "@vercel/analytics/react"
import { GeistMono } from "geist/font/mono"
@ -11,22 +11,7 @@ import "./globals.css"
export const metadata: Metadata = {
title: "Sandbox",
description:
"an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat",
openGraph: {
type: "website",
url: "https://sandbox.gitwit.dev",
title: "Sandbox",
description:
"an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat",
},
twitter: {
site: "https://sandbox.gitwit.dev",
title: "Sandbox by Gitwit",
description:
"an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat",
creator: "@gitwitdev",
},
description: "A collaborative, AI-powered cloud code editing environment",
}
export default function RootLayout({

View File

@ -1 +0,0 @@
About Sandbox by Gitwit

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -18,38 +18,11 @@ export default function AboutModal({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Help & Support</DialogTitle>
<DialogTitle>About this project</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* <div className="text-sm text-muted-foreground">
Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration.
</div> */}
<div className="text-sm text-muted-foreground">
Get help and support through our Discord community or by creating issues on GitHub:
</div>
<div className="space-y-2">
<div className="text-sm">
<a
href="https://discord.gitwit.dev/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Join our Discord community
</a>
</div>
<div className="text-sm">
<a
href="https://github.com/jamesmurdza/sandbox/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Report issues on GitHub
</a>
</div>
</div>
<div className="text-sm text-muted-foreground">
Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration.
</div>
</DialogContent>
</Dialog>

View File

@ -25,7 +25,6 @@ export default function Dashboard({
type: "react" | "node"
author: string
sharedOn: Date
authorAvatarUrl?: string
}[]
}) {
const [screen, setScreen] = useState<TScreen>("projects")
@ -78,14 +77,14 @@ export default function Dashboard({
<FolderDot className="w-4 h-4 mr-2" />
My Projects
</Button>
{/* <Button
<Button
variant="ghost"
onClick={() => setScreen("shared")}
className={activeScreen("shared")}
>
<Users className="w-4 h-4 mr-2" />
Shared With Me
</Button> */}
</Button>
{/* <Button
variant="ghost"
onClick={() => setScreen("settings")}
@ -96,7 +95,7 @@ export default function Dashboard({
</Button> */}
</div>
<div className="flex flex-col">
<a target="_blank" href="https://github.com/jamesmurdza/sandbox">
<a target="_blank" href="https://github.com/ishaan1013/sandbox">
<Button
variant="ghost"
className="justify-start w-full font-normal text-muted-foreground"
@ -111,7 +110,7 @@ export default function Dashboard({
className="justify-start font-normal text-muted-foreground"
>
<HelpCircle className="w-4 h-4 mr-2" />
Help
About
</Button>
</div>
</div>
@ -122,12 +121,7 @@ export default function Dashboard({
) : null}
</>
) : screen === "shared" ? (
<DashboardSharedWithMe
shared={shared.map((item) => ({
...item,
authorAvatarUrl: item.authorAvatarUrl || "",
}))}
/>
<DashboardSharedWithMe shared={shared} />
) : screen === "settings" ? null : null}
</div>
</>

View File

@ -8,20 +8,15 @@ import DashboardNavbarSearch from "./search"
export default function DashboardNavbar({ userData }: { userData: User }) {
return (
<div className=" py-2 px-4 w-full flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2">
<div className="h-16 px-4 w-full flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-4">
<Link
href="/"
className="ring-offset-2 ring-offset-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none rounded-sm"
>
<Image src={Logo} alt="Logo" width={36} height={36} />
</Link>
<h1 className="text-xl">
<span className="font-semibold">Sandbox</span>{" "}
<span className="text-xs font-medium text-muted-foreground">
by gitwit
</span>
</h1>
<div className="text-sm font-medium flex items-center">Sandbox</div>
</div>
<div className="flex items-center space-x-4">
<DashboardNavbarSearch />

View File

@ -11,13 +11,13 @@ import {
} from "@/components/ui/dropdown-menu"
export default function ProjectCardDropdown({
visibility,
sandbox,
onVisibilityChange,
onDelete,
}: {
visibility: Sandbox["visibility"]
onVisibilityChange: () => void
onDelete: () => void
sandbox: Sandbox
onVisibilityChange: (sandbox: Sandbox) => void
onDelete: (sandbox: Sandbox) => void
}) {
return (
<DropdownMenu modal={false}>
@ -26,7 +26,7 @@ export default function ProjectCardDropdown({
e.preventDefault()
e.stopPropagation()
}}
className="h-6 w-6 z-10 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 rounded-sm outline-foreground"
className="h-6 w-6 flex items-center justify-center transition-colors bg-transparent hover:bg-muted-foreground/25 rounded-sm outline-foreground"
>
<Ellipsis className="w-4 h-4" />
</DropdownMenuTrigger>
@ -34,11 +34,11 @@ export default function ProjectCardDropdown({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onVisibilityChange()
onVisibilityChange(sandbox)
}}
className="cursor-pointer"
>
{visibility === "public" ? (
{sandbox.visibility === "public" ? (
<>
<Lock className="mr-2 h-4 w-4" />
<span>Make Private</span>
@ -53,7 +53,7 @@ export default function ProjectCardDropdown({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onDelete()
onDelete(sandbox)
}}
className="!text-destructive cursor-pointer"
>

View File

@ -1,235 +1,59 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { toggleLike } from "@/lib/actions"
import { projectTemplates } from "@/lib/data"
import { Sandbox } from "@/lib/types"
import { cn } from "@/lib/utils"
import { useUser } from "@clerk/nextjs"
import { AnimatePresence, motion } from "framer-motion"
import { Clock, Eye, Globe, Heart, Lock } from "lucide-react"
import { Clock, Globe, Lock } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
memo,
MouseEventHandler,
useEffect,
useMemo,
useOptimistic,
useState,
useTransition,
} from "react"
import { useEffect, useState } from "react"
import ProjectCardDropdown from "./dropdown"
import { CanvasRevealEffect } from "./revealEffect"
type BaseProjectCardProps = {
id: string
name: string
type: string
visibility: "public" | "private"
createdAt: Date
likeCount: number
liked?: boolean
viewCount: number
}
type AuthenticatedProjectCardProps = BaseProjectCardProps & {
isAuthenticated: true
onVisibilityChange: (
sandbox: Pick<Sandbox, "id" | "name" | "visibility">
) => void
onDelete: (sandbox: Pick<Sandbox, "id" | "name">) => void
export default function ProjectCard({
children,
sandbox,
onVisibilityChange,
onDelete,
deletingId,
}: {
children?: React.ReactNode
sandbox: Sandbox
onVisibilityChange: (sandbox: Sandbox) => void
onDelete: (sandbox: Sandbox) => void
deletingId: string
}
type UnauthenticatedProjectCardProps = BaseProjectCardProps & {
isAuthenticated: false
}
type ProjectCardProps =
| AuthenticatedProjectCardProps
| UnauthenticatedProjectCardProps
const StatItem = memo(({ icon: Icon, value }: { icon: any; value: number }) => (
<div className="flex items-center space-x-1">
<Icon className="size-4" />
<span className="text-xs">{value}</span>
</div>
))
StatItem.displayName = "StatItem"
const formatDate = (date: Date): string => {
const now = new Date()
const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / 60000)
if (diffInMinutes < 1) return "Now"
if (diffInMinutes < 60) return `${diffInMinutes}m ago`
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`
return `${Math.floor(diffInMinutes / 1440)}d ago`
}
const ProjectMetadata = memo(
({
id,
visibility,
createdAt,
likeCount,
liked,
viewCount,
}: Pick<
BaseProjectCardProps,
"visibility" | "createdAt" | "likeCount" | "liked" | "viewCount" | "id"
>) => {
const { user } = useUser()
const [date, setDate] = useState<string>()
const Icon = visibility === "private" ? Lock : Globe
useEffect(() => {
setDate(formatDate(new Date(createdAt)))
}, [createdAt])
return (
<div className="flex flex-col text-muted-foreground space-y-2 text-sm z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="size-4" />
<span className="text-xs">
{visibility === "private" ? "Private" : "Public"}
</span>
</div>
</div>
<div className="flex gap-3">
<div className="flex items-center gap-2">
<Clock className="size-4" /> <span className="text-xs">{date}</span>
</div>
<LikeButton
sandboxId={id}
initialIsLiked={!!liked}
initialLikeCount={likeCount}
userId={user?.id ?? null}
/>
<StatItem icon={Eye} value={viewCount} />
</div>
</div>
)
}
)
ProjectMetadata.displayName = "ProjectMetadata"
interface LikeButtonProps {
sandboxId: string
userId: string | null
initialLikeCount: number
initialIsLiked: boolean
}
export function LikeButton({
sandboxId,
userId,
initialLikeCount,
initialIsLiked,
}: LikeButtonProps) {
// Optimistic state for like status and count
const [{ isLiked, likeCount }, optimisticUpdateLike] = useOptimistic(
{ isLiked: initialIsLiked, likeCount: initialLikeCount },
(state, optimisticValue: boolean) => {
return {
isLiked: optimisticValue,
likeCount: state.likeCount + (optimisticValue ? 1 : -1),
}
}
)
const [isPending, startTransition] = useTransition()
const handleLike: MouseEventHandler<HTMLButtonElement> = async (e) => {
e.stopPropagation() // Prevent click event from bubbling up which leads to navigation to /code/:id
if (!userId) return
startTransition(async () => {
const newLikeState = !isLiked
try {
optimisticUpdateLike(newLikeState)
await toggleLike(sandboxId, userId)
} catch (error) {
console.log("error", error)
optimisticUpdateLike(!newLikeState)
}
})
}
return (
<Button
variant="ghost"
size="sm"
disabled={!userId || isPending}
onClick={handleLike}
className="gap-1 px-1 rounded-full"
>
<Heart
className={cn("size-4", isLiked ? "stroke-red-500 fill-red-500" : "")}
/>
<span className="text-xs">{likeCount}</span>
</Button>
)
}
function ProjectCardComponent({
id,
name,
type,
visibility,
createdAt,
likeCount,
viewCount,
...props
}: ProjectCardProps) {
}) {
const [hovered, setHovered] = useState(false)
const [date, setDate] = useState<string>()
const router = useRouter()
const projectIcon = useMemo(
() =>
projectTemplates.find((p) => p.id === type)?.icon ??
"/project-icons/node.svg",
[type]
)
useEffect(() => {
const createdAt = new Date(sandbox.createdAt)
const now = new Date()
const diffInMinutes = Math.floor(
(now.getTime() - createdAt.getTime()) / 60000
)
const handleVisibilityChange = () => {
if (props.isAuthenticated) {
props.onVisibilityChange({
id,
name,
visibility,
})
if (diffInMinutes < 1) {
setDate("Now")
} else if (diffInMinutes < 60) {
setDate(`${diffInMinutes}m ago`)
} else if (diffInMinutes < 1440) {
setDate(`${Math.floor(diffInMinutes / 60)}h ago`)
} else {
setDate(`${Math.floor(diffInMinutes / 1440)}d ago`)
}
}
const handleDelete = () => {
if (props.isAuthenticated) {
props.onDelete({
id,
name,
})
}
}
}, [sandbox])
const projectIcon =
projectTemplates.find((p) => p.id === sandbox.type)?.icon ??
"/project-icons/node.svg"
return (
<Card
tabIndex={0}
onClick={() => router.push(`/code/${id}`)}
onClick={() => router.push(`/code/${sandbox.id}`)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => 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
${
props.isAuthenticated && props.deletingId === id
? "opacity-50 pointer-events-none cursor-events-none"
: "cursor-pointer"
}
`}
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`}
>
<AnimatePresence>
{hovered && (
@ -238,64 +62,38 @@ function ProjectCardComponent({
animate={{ opacity: 1 }}
className="h-full w-full absolute inset-0"
>
<CanvasRevealEffect
animationSpeed={3}
containerClassName="bg-muted"
colors={colors[type]}
dotSize={2}
/>
<div className="absolute inset-0 [mask-image:radial-gradient(400px_at_center,white,transparent)] bg-background/75" />
{children}
</motion.div>
)}
</AnimatePresence>
<div className="space-x-2 flex items-center justify-start w-full z-10">
<Image
alt={`${type} project icon`}
src={projectIcon}
width={20}
height={20}
<Image alt="" src={projectIcon} width={20} height={20} />
<div className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden">
{sandbox.name}
</div>
<ProjectCardDropdown
sandbox={sandbox}
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
/>
<Link
href={`/code/${id}`}
className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden before:content-[''] before:absolute before:z-0 before:top-0 before:left-0 before:w-full before:h-full before:rounded-xl"
>
{name}
</Link>
{props.isAuthenticated && (
<ProjectCardDropdown
onVisibilityChange={handleVisibilityChange}
onDelete={handleDelete}
visibility={visibility}
/>
)}
</div>
<ProjectMetadata
visibility={visibility}
createdAt={createdAt}
likeCount={likeCount}
viewCount={viewCount}
id={id}
liked={props.liked}
/>
<div className="flex flex-col text-muted-foreground space-y-0.5 text-sm z-10">
<div className="flex items-center">
{sandbox.visibility === "private" ? (
<>
<Lock className="w-3 h-3 mr-2" /> Private
</>
) : (
<>
<Globe className="w-3 h-3 mr-2" /> Public
</>
)}
</div>
<div className="flex items-center">
<Clock className="w-3 h-3 mr-2" /> {date}
</div>
</div>
</Card>
)
}
ProjectCardComponent.displayName = "ProjectCard"
const ProjectCard = memo(ProjectCardComponent)
export default ProjectCard
const colors: { [key: string]: number[][] } = {
react: [
[71, 207, 237],
[30, 126, 148],
],
node: [
[86, 184, 72],
[59, 112, 52],
],
}

View File

@ -2,9 +2,11 @@
import { deleteSandbox, updateSandbox } from "@/lib/actions"
import { Sandbox } from "@/lib/types"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import ProjectCard from "./projectCard"
import { CanvasRevealEffect } from "./projectCard/revealEffect"
const colors: { [key: string]: number[][] } = {
react: [
@ -26,27 +28,11 @@ export default function DashboardProjects({
}) {
const [deletingId, setDeletingId] = useState<string>("")
const onVisibilityChange = useMemo(
() => async (sandbox: Pick<Sandbox, "id" | "name" | "visibility">) => {
const newVisibility =
sandbox.visibility === "public" ? "private" : "public"
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
await updateSandbox({
id: sandbox.id,
visibility: newVisibility,
})
},
[]
)
const onDelete = useMemo(
() => async (sandbox: Pick<Sandbox, "id" | "name">) => {
setDeletingId(sandbox.id)
toast(`Project ${sandbox.name} deleted.`)
await deleteSandbox(sandbox.id)
},
[]
)
const onDelete = async (sandbox: Sandbox) => {
setDeletingId(sandbox.id)
toast(`Project ${sandbox.name} deleted.`)
await deleteSandbox(sandbox.id)
}
useEffect(() => {
if (deletingId) {
@ -54,6 +40,15 @@ export default function DashboardProjects({
}
}, [sandboxes])
const onVisibilityChange = async (sandbox: Sandbox) => {
const newVisibility = sandbox.visibility === "public" ? "private" : "public"
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
await updateSandbox({
id: sandbox.id,
visibility: newVisibility,
})
}
return (
<div className="grow p-4 flex flex-col">
<div className="text-xl font-medium mb-8">
@ -69,14 +64,30 @@ export default function DashboardProjects({
}
}
return (
<ProjectCard
<Link
key={sandbox.id}
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
deletingId={deletingId}
isAuthenticated
{...sandbox}
/>
href={`/code/${sandbox.id}`}
className={`${
deletingId === sandbox.id
? "pointer-events-none opacity-50 cursor-events-none"
: "cursor-pointer"
} transition-all focus-visible:outline-none focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-ring rounded-lg`}
>
<ProjectCard
sandbox={sandbox}
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
deletingId={deletingId}
>
<CanvasRevealEffect
animationSpeed={3}
containerClassName="bg-black"
colors={colors[sandbox.type]}
dotSize={2}
/>
<div className="absolute inset-0 [mask-image:radial-gradient(400px_at_center,white,transparent)] bg-background/75" />
</ProjectCard>
</Link>
)
})}
</div>

View File

@ -6,7 +6,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { projectTemplates } from "@/lib/data"
import { ChevronRight } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
@ -19,9 +18,8 @@ export default function DashboardSharedWithMe({
shared: {
id: string
name: string
type: string
type: "react" | "node"
author: string
authorAvatarUrl: string
sharedOn: Date
}[]
}) {
@ -47,8 +45,9 @@ export default function DashboardSharedWithMe({
<Image
alt=""
src={
projectTemplates.find((p) => p.id === sandbox.type)
?.icon ?? "/project-icons/node.svg"
sandbox.type === "react"
? "/project-icons/react.svg"
: "/project-icons/node.svg"
}
width={20}
height={20}
@ -59,11 +58,7 @@ export default function DashboardSharedWithMe({
</TableCell>
<TableCell>
<div className="flex items-center">
<Avatar
name={sandbox.author}
avatarUrl={sandbox.authorAvatarUrl}
className="mr-2"
/>
<Avatar name={sandbox.author} className="mr-2" />
{sandbox.author}
</div>
</TableCell>

View File

@ -1,77 +0,0 @@
import { Check, Loader2 } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "../../ui/button"
interface ApplyButtonProps {
code: string
activeFileName: string
activeFileContent: string
editorRef: { current: any }
onApply: (mergedCode: string, originalCode: string) => void
}
export default function ApplyButton({
code,
activeFileName,
activeFileContent,
editorRef,
onApply,
}: ApplyButtonProps) {
const [isApplying, setIsApplying] = useState(false)
const handleApply = async () => {
setIsApplying(true)
try {
const response = await fetch("/api/merge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
originalCode: activeFileContent,
newCode: String(code),
fileName: activeFileName,
}),
})
if (!response.ok) {
throw new Error(await response.text())
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let mergedCode = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
mergedCode += decoder.decode(value, { stream: true })
}
}
onApply(mergedCode.trim(), activeFileContent)
} catch (error) {
console.error("Error applying code:", error)
toast.error(
error instanceof Error ? error.message : "Failed to apply code changes"
)
} finally {
setIsApplying(false)
}
}
return (
<Button
onClick={handleApply}
size="sm"
variant="ghost"
className="p-1 h-6"
disabled={isApplying}
>
{isApplying ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
</Button>
)
}

View File

@ -1,9 +1,13 @@
import { TFile, TFolder } from "@/lib/types"
import { Image as ImageIcon, Paperclip, Send, StopCircle } from "lucide-react"
import { useEffect } from "react"
import { Send, StopCircle } from "lucide-react"
import { Button } from "../../ui/button"
import { looksLikeCode } from "./lib/chatUtils"
import { ALLOWED_FILE_TYPES, ChatInputProps } from "./types"
interface ChatInputProps {
input: string
setInput: (input: string) => void
isGenerating: boolean
handleSend: () => void
handleStopGeneration: () => void
}
export default function ChatInput({
input,
@ -11,235 +15,37 @@ export default function ChatInput({
isGenerating,
handleSend,
handleStopGeneration,
onImageUpload,
addContextTab,
activeFileName,
editorRef,
lastCopiedRangeRef,
contextTabs,
onRemoveTab,
textareaRef,
}: ChatInputProps) {
// Auto-resize textarea as content changes
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"
}
}, [input])
// Handle keyboard events for sending messages
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
if (e.ctrlKey) {
e.preventDefault()
handleSend(true) // Send with full context
} else if (!e.shiftKey && !isGenerating) {
e.preventDefault()
handleSend(false)
}
} else if (
e.key === "Backspace" &&
input === "" &&
contextTabs.length > 0
) {
e.preventDefault()
// Remove the last context tab
const lastTab = contextTabs[contextTabs.length - 1]
onRemoveTab(lastTab.id)
}
}
// Handle paste events for image and code
const handlePaste = async (e: React.ClipboardEvent) => {
// Handle image paste
const items = Array.from(e.clipboardData.items)
for (const item of items) {
if (item.type.startsWith("image/")) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
try {
// Convert image to base64 string for context tab title and timestamp
const reader = new FileReader()
reader.onload = () => {
const base64String = reader.result as string
addContextTab(
"image",
`Image ${new Date()
.toLocaleTimeString("en-US", {
hour12: true,
hour: "2-digit",
minute: "2-digit",
})
.replace(/(\d{2}):(\d{2})/, "$1:$2")}`,
base64String
)
}
reader.readAsDataURL(file)
} catch (error) {
console.error("Error processing pasted image:", error)
}
return
}
}
// Get text from clipboard
const text = e.clipboardData.getData("text")
// If text doesn't contain newlines or doesn't look like code, let it paste normally
if (!text || !text.includes("\n") || !looksLikeCode(text)) {
return
}
e.preventDefault()
const editor = editorRef.current
const currentSelection = editor?.getSelection()
const lines = text.split("\n")
// TODO: FIX THIS: even when i paste the outside code, it shows the active file name,it works when no tabs are open, just does not work when the tab is open
// If selection exists in editor, use file name and line numbers
if (currentSelection && !currentSelection.isEmpty()) {
addContextTab(
"code",
`${activeFileName} (${currentSelection.startLineNumber}-${currentSelection.endLineNumber})`,
text,
{
start: currentSelection.startLineNumber,
end: currentSelection.endLineNumber,
}
)
return
}
// If we have stored line range from a copy operation in the editor
if (lastCopiedRangeRef.current) {
const range = lastCopiedRangeRef.current
addContextTab(
"code",
`${activeFileName} (${range.startLine}-${range.endLine})`,
text,
{ start: range.startLine, end: range.endLine }
)
return
}
// For code pasted from outside the editor
addContextTab("code", `Pasted Code (1-${lines.length})`, text, {
start: 1,
end: lines.length,
})
}
// Handle image upload from local machine via input
const handleImageUpload = () => {
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) onImageUpload(file)
}
input.click()
}
// Helper function to flatten the file tree
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
return items.reduce((acc: TFile[], item) => {
if (item.type === "file") {
acc.push(item)
} else {
acc.push(...getAllFiles(item.children))
}
return acc
}, [])
}
// Handle file upload from local machine via input
const handleFileUpload = () => {
const input = document.createElement("input")
input.type = "file"
input.accept = ".txt,.md,.csv,.json,.js,.ts,.html,.css,.pdf"
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
if (!(file.type in ALLOWED_FILE_TYPES)) {
alert(
"Unsupported file type. Please upload text, code, or PDF files."
)
return
}
const reader = new FileReader()
reader.onload = () => {
addContextTab("file", file.name, reader.result as string)
}
reader.readAsText(file)
}
}
input.click()
}
return (
<div className="space-y-2">
<div className="flex space-x-2 min-w-0">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
className="flex-grow p-2 border rounded-lg min-w-0 bg-input resize-none overflow-hidden"
placeholder="Type your message..."
<div className="flex space-x-2 min-w-0">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && !isGenerating && handleSend()}
className="flex-grow p-2 border rounded-lg min-w-0 bg-input"
placeholder="Type your message..."
disabled={isGenerating}
/>
{isGenerating ? (
<Button
onClick={handleStopGeneration}
variant="destructive"
size="icon"
className="h-10 w-10"
>
<StopCircle className="w-4 h-4" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={isGenerating}
rows={1}
/>
{/* Render stop generation button */}
{isGenerating ? (
<Button
onClick={handleStopGeneration}
variant="destructive"
size="icon"
className="h-10 w-10"
>
<StopCircle className="w-4 h-4" />
</Button>
) : (
<Button
onClick={() => handleSend(false)}
disabled={isGenerating}
size="icon"
className="h-10 w-10"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
<div className="flex items-center justify-end gap-2">
{/* Render file upload button */}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 sm:px-3"
onClick={handleFileUpload}
size="icon"
className="h-10 w-10"
>
<Paperclip className="h-3 w-3 sm:mr-1" />
<span className="hidden sm:inline">File</span>
<Send className="w-4 h-4" />
</Button>
{/* Render image upload button */}
<Button
variant="ghost"
size="sm"
className="h-6 px-2 sm:px-3"
onClick={handleImageUpload}
>
<ImageIcon className="h-3 w-3 sm:mr-1" />
<span className="hidden sm:inline">Image</span>
</Button>
</div>
)}
</div>
)
}

View File

@ -1,35 +1,32 @@
import { Check, Copy, CornerUpLeft } from "lucide-react"
import { Check, ChevronDown, ChevronUp, Copy, CornerUpLeft } from "lucide-react"
import React, { useState } from "react"
import ReactMarkdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import remarkGfm from "remark-gfm"
import { Button } from "../../ui/button"
import ContextTabs from "./ContextTabs"
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
import { createMarkdownComponents } from "./lib/markdownComponents"
import { MessageProps } from "./types"
interface MessageProps {
message: {
role: "user" | "assistant"
content: string
context?: string
}
setContext: (context: string | null) => void
setIsContextExpanded: (isExpanded: boolean) => void
}
export default function ChatMessage({
message,
setContext,
setIsContextExpanded,
socket,
handleApplyCode,
activeFileName,
activeFileContent,
editorRef,
mergeDecorationsCollection,
setMergeDecorationsCollection,
selectFile,
}: MessageProps) {
// State for expanded message index
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
number | null
>(null)
// State for copied text
const [copiedText, setCopiedText] = useState<string | null>(null)
// Render copy button for text content
const renderCopyButton = (text: any) => (
<Button
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
@ -45,36 +42,12 @@ export default function ChatMessage({
</Button>
)
// Set context for code when asking about code
const askAboutCode = (code: any) => {
const contextString = stringifyContent(code)
const newContext = `Regarding this code:\n${contextString}`
// Format timestamp to match chat message format (HH:MM PM)
const timestamp = new Date().toLocaleTimeString("en-US", {
hour12: true,
hour: "2-digit",
minute: "2-digit",
})
// Instead of replacing context, append to it
if (message.role === "assistant") {
// For assistant messages, create a new context tab with the response content and timestamp
setContext(newContext, `AI Response (${timestamp})`, {
start: 1,
end: contextString.split("\n").length,
})
} else {
// For user messages, create a new context tab with the selected content and timestamp
setContext(newContext, `User Chat (${timestamp})`, {
start: 1,
end: contextString.split("\n").length,
})
}
setContext(`Regarding this code:\n${contextString}`)
setIsContextExpanded(false)
}
// Render markdown elements for code and text
const renderMarkdownElement = (props: any) => {
const { node, children } = props
const content = stringifyContent(children)
@ -92,7 +65,6 @@ export default function ChatMessage({
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
{/* Render markdown element */}
{React.createElement(
node.tagName,
{
@ -107,44 +79,43 @@ export default function ChatMessage({
)
}
// Create markdown components
const components = createMarkdownComponents(
renderCopyButton,
renderMarkdownElement,
askAboutCode,
activeFileName,
activeFileContent,
editorRef,
handleApplyCode,
selectFile,
mergeDecorationsCollection,
setMergeDecorationsCollection,
)
return (
<div className="text-left relative">
<div
className={`relative p-2 rounded-lg ${
message.role === "user"
? "bg-foreground text-background"
: "bg-background text-foreground"
? "bg-[#262626] text-white"
: "bg-transparent text-white"
} max-w-full`}
>
{/* Render context tabs */}
{message.role === "user" && message.context && (
{message.role === "user" && (
<div className="absolute top-0 right-0 flex opacity-0 group-hover:opacity-30 transition-opacity">
{renderCopyButton(message.content)}
<Button
onClick={() => askAboutCode(message.content)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
)}
{message.context && (
<div className="mb-2 bg-input rounded-lg">
<ContextTabs
socket={socket}
activeFileName=""
onAddFile={() => {}}
contextTabs={parseContextToTabs(message.context)}
onRemoveTab={() => {}}
isExpanded={expandedMessageIndex === 0}
onToggleExpand={() =>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() =>
setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)
}
className="[&_div:first-child>div:first-child>div]:bg-[#0D0D0D] [&_button:first-child]:hidden [&_button:last-child]:hidden"
/>
>
<span className="text-sm text-gray-300">Context</span>
{expandedMessageIndex === 0 ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</div>
{expandedMessageIndex === 0 && (
<div className="relative">
<div className="absolute top-0 right-0 flex p-1">
@ -152,7 +123,6 @@ export default function ChatMessage({
message.context.replace(/^Regarding this code:\n/, "")
)}
</div>
{/* Render code textarea */}
{(() => {
const code = message.context.replace(
/^Regarding this code:\n/,
@ -166,12 +136,9 @@ export default function ChatMessage({
value={code}
onChange={(e) => {
const updatedContext = `Regarding this code:\n${e.target.value}`
setContext(updatedContext, "Selected Content", {
start: 1,
end: e.target.value.split("\n").length,
})
setContext(updatedContext)
}}
className="w-full p-2 bg-[#1e1e1e] text-foreground font-mono text-sm rounded"
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded"
rows={code.split("\n").length}
style={{
resize: "vertical",
@ -186,23 +153,68 @@ export default function ChatMessage({
)}
</div>
)}
{/* Render copy and ask about code buttons */}
{message.role === "user" && (
<div className="absolute top-0 right-0 p-1 flex opacity-40">
{renderCopyButton(message.content)}
<Button
onClick={() => askAboutCode(message.content)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
)}
{/* Render markdown content */}
{message.role === "assistant" ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "")
return match ? (
<div className="relative border border-input rounded-md my-4">
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
{match[1]}
</div>
<div className="absolute top-0 right-0 flex">
{renderCopyButton(children)}
<Button
onClick={() => askAboutCode(children)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
<div className="pt-6">
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
padding: "0.5rem",
fontSize: "0.875rem",
}}
>
{stringifyContent(children)}
</SyntaxHighlighter>
</div>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
p: renderMarkdownElement,
h1: renderMarkdownElement,
h2: renderMarkdownElement,
h3: renderMarkdownElement,
h4: renderMarkdownElement,
h5: renderMarkdownElement,
h6: renderMarkdownElement,
ul: (props) => (
<ul className="list-disc pl-6 mb-4 space-y-2">
{props.children}
</ul>
),
ol: (props) => (
<ol className="list-decimal pl-6 mb-4 space-y-2">
{props.children}
</ol>
),
}}
>
{message.content}
</ReactMarkdown>
) : (
@ -212,43 +224,3 @@ export default function ChatMessage({
</div>
)
}
// Parse context to tabs for context tabs component
function parseContextToTabs(context: string) {
// Use specific regex patterns to avoid matching import statements
const sections = context.split(/(?=File |Code from |Image \d{1,2}:)/)
return sections
.map((section, index) => {
const lines = section.trim().split("\n")
const titleLine = lines[0]
let content = lines.slice(1).join("\n").trim()
// Remove code block markers for display
content = content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "")
// Determine the type of context
const isFile = titleLine.startsWith("File ")
const isImage = titleLine.startsWith("Image ")
const type = isFile ? "file" : isImage ? "image" : "code"
const name = titleLine
.replace(/^(File |Code from |Image )/, "")
.replace(":", "")
.trim()
// Skip if the content is empty or if it's just an import statement
if (!content || content.trim().startsWith('from "')) {
return null
}
return {
id: `context-${index}`,
type: type as "file" | "code" | "image",
name: name,
content: content,
}
})
.filter(
(tab): tab is NonNullable<typeof tab> =>
tab !== null && tab.content.length > 0
)
}

View File

@ -0,0 +1,60 @@
import { ChevronDown, ChevronUp, X } from "lucide-react"
interface ContextDisplayProps {
context: string | null
isContextExpanded: boolean
setIsContextExpanded: (isExpanded: boolean) => void
setContext: (context: string | null) => void
}
export default function ContextDisplay({
context,
isContextExpanded,
setIsContextExpanded,
setContext,
}: ContextDisplayProps) {
if (!context) return null
return (
<div className="mb-2 bg-input p-2 rounded-lg">
<div className="flex justify-between items-center">
<div
className="flex-grow cursor-pointer"
onClick={() => setIsContextExpanded(!isContextExpanded)}
>
<span className="text-sm text-gray-300">Context</span>
</div>
<div className="flex items-center">
{isContextExpanded ? (
<ChevronUp
size={16}
className="cursor-pointer"
onClick={() => setIsContextExpanded(false)}
/>
) : (
<ChevronDown
size={16}
className="cursor-pointer"
onClick={() => setIsContextExpanded(true)}
/>
)}
<X
size={16}
className="ml-2 cursor-pointer text-gray-400 hover:text-gray-200"
onClick={() => setContext(null)}
/>
</div>
</div>
{isContextExpanded && (
<textarea
value={context.replace(/^Regarding this code:\n/, "")}
onChange={(e) =>
setContext(`Regarding this code:\n${e.target.value}`)
}
className="w-full mt-2 p-2 bg-#1e1e1e text-white rounded"
rows={5}
/>
)}
</div>
)
}

View File

@ -1,177 +0,0 @@
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { TFile, TFolder } from "@/lib/types"
import { FileText, Image as ImageIcon, Plus, X } from "lucide-react"
import { useState } from "react"
import { Button } from "../../ui/button"
import { ContextTab, ContextTabsProps } from "./types"
// Ignore certain folders and files from the file tree
import { ignoredFiles, ignoredFolders } from "./lib/ignored-paths"
export default function ContextTabs({
contextTabs,
onRemoveTab,
className,
files = [],
onFileSelect,
}: ContextTabsProps & { className?: string }) {
// State for preview tab
const [previewTab, setPreviewTab] = useState<ContextTab | null>(null)
const [searchQuery, setSearchQuery] = useState("")
// Allow preview for images and code selections from editor
const togglePreview = (tab: ContextTab) => {
if (!tab.lineRange && tab.type !== "image") {
return
}
// Toggle preview for images and code selections from editor
if (previewTab?.id === tab.id) {
setPreviewTab(null)
} else {
setPreviewTab(tab)
}
}
// Remove tab from context when clicking on X
const handleRemoveTab = (id: string) => {
if (previewTab?.id === id) {
setPreviewTab(null)
}
onRemoveTab(id)
}
// Get all files from the file tree to search for context
const getAllFiles = (items: (TFile | TFolder)[]): TFile[] => {
return items.reduce((acc: TFile[], item) => {
// Add file if it's not ignored
if (
item.type === "file" &&
!ignoredFiles.some(
(pattern: string) =>
item.name.endsWith(pattern.replace("*", "")) ||
item.name === pattern
)
) {
acc.push(item)
// Add all files from folder if it's not ignored
} else if (
item.type === "folder" &&
!ignoredFolders.some((folder: string) => folder === item.name)
) {
acc.push(...getAllFiles(item.children))
}
return acc
}, [])
}
// Get all files from the file tree to search for context when adding context
const allFiles = getAllFiles(files)
const filteredFiles = allFiles.filter((file) =>
file.name.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<div className={`border-none ${className || ""}`}>
<div className="flex flex-col">
<div className="flex items-center gap-1 overflow-hidden mb-2 flex-wrap">
{/* Add context tab button */}
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<Plus className="h-4 w-4" />
</Button>
</PopoverTrigger>
{/* Add context tab popover */}
<PopoverContent className="w-64 p-2">
<div className="flex gap-2 mb-2">
<Input
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
<div className="max-h-[200px] overflow-y-auto">
{filteredFiles.map((file) => (
<Button
key={file.id}
variant="ghost"
className="w-full justify-start text-sm mb-1"
onClick={() => onFileSelect?.(file)}
>
<FileText className="h-4 w-4 mr-2" />
{file.name}
</Button>
))}
</div>
</PopoverContent>
</Popover>
{/* Add context tab button */}
{contextTabs.length === 0 && (
<div className="flex items-center gap-1 px-2 rounded">
<span className="text-sm text-muted-foreground">Add Context</span>
</div>
)}
{/* Render context tabs */}
{contextTabs.map((tab) => (
<div
key={tab.id}
className="flex items-center gap-1 px-2 bg-input rounded text-sm cursor-pointer hover:bg-muted"
onClick={() => togglePreview(tab)}
>
{tab.type === "image" && <ImageIcon className="h-3 w-3" />}
<span>{tab.name}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4"
onClick={(e) => {
e.stopPropagation()
handleRemoveTab(tab.id)
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* Preview Section */}
{previewTab && (
<div className="p-2 bg-input rounded-md max-h-[200px] overflow-auto mb-2">
{previewTab.type === "image" ? (
<img
src={previewTab.content}
alt={previewTab.name}
className="max-w-full h-auto"
/>
) : (
previewTab.lineRange && (
<>
<div className="text-xs text-muted-foreground mt-1">
Lines {previewTab.lineRange.start}-
{previewTab.lineRange.end}
</div>
<pre className="text-xs font-mono whitespace-pre-wrap">
{previewTab.content}
</pre>
</>
)
)}
{/* Render file context tab */}
{previewTab.type === "file" && (
<pre className="text-xs font-mono whitespace-pre-wrap">
{previewTab.content}
</pre>
)}
</div>
)}
</div>
</div>
)
}

View File

@ -1,196 +1,50 @@
import { ScrollArea } from "@/components/ui/scroll-area"
import { useSocket } from "@/context/SocketContext"
import { TFile } from "@/lib/types"
import { ChevronDown, X } from "lucide-react"
import { nanoid } from "nanoid"
import { X } from "lucide-react"
import { useEffect, useRef, useState } from "react"
import LoadingDots from "../../ui/LoadingDots"
import ChatInput from "./ChatInput"
import ChatMessage from "./ChatMessage"
import ContextTabs from "./ContextTabs"
import ContextDisplay from "./ContextDisplay"
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
import { AIChatProps, ContextTab, Message } from "./types"
interface Message {
role: "user" | "assistant"
content: string
context?: string
}
export default function AIChat({
activeFileContent,
activeFileName,
onClose,
editorRef,
lastCopiedRangeRef,
files,
templateType,
handleApplyCode,
selectFile,
mergeDecorationsCollection,
setMergeDecorationsCollection,
projectName,
}: AIChatProps) {
// Initialize socket and messages
const { socket } = useSocket()
}: {
activeFileContent: string
activeFileName: string
onClose: () => void
}) {
const [messages, setMessages] = useState<Message[]>([])
// Initialize input and state for generating messages
const [input, setInput] = useState("")
const [isGenerating, setIsGenerating] = useState(false)
// Initialize chat container ref and abort controller ref
const chatContainerRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
// Initialize context tabs and state for expanding context
const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
const [context, setContext] = useState<string | null>(null)
const [isContextExpanded, setIsContextExpanded] = useState(false)
const [isLoading, setIsLoading] = useState(false)
// Initialize textarea ref
const textareaRef = useRef<HTMLTextAreaElement>(null)
// state variables for auto scroll and scroll button
const [autoScroll, setAutoScroll] = useState(true)
const [showScrollButton, setShowScrollButton] = useState(false)
// scroll to bottom of chat when messages change
useEffect(() => {
if (autoScroll) {
scrollToBottom()
scrollToBottom()
}, [messages])
const scrollToBottom = () => {
if (chatContainerRef.current) {
setTimeout(() => {
chatContainerRef.current?.scrollTo({
top: chatContainerRef.current.scrollHeight,
behavior: "smooth",
})
}, 100)
}
}, [messages, autoScroll])
// scroll to bottom of chat when messages change
const scrollToBottom = (force: boolean = false) => {
if (!chatContainerRef.current || (!autoScroll && !force)) return
chatContainerRef.current.scrollTo({
top: chatContainerRef.current.scrollHeight,
behavior: force ? "smooth" : "auto",
})
}
// function to handle scroll events
const handleScroll = () => {
if (!chatContainerRef.current) return
const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 50
setAutoScroll(isAtBottom)
setShowScrollButton(!isAtBottom)
}
// scroll event listener
useEffect(() => {
const container = chatContainerRef.current
if (container) {
container.addEventListener("scroll", handleScroll)
return () => container.removeEventListener("scroll", handleScroll)
}
}, [])
// Add context tab to context tabs
const addContextTab = (
type: string,
name: string,
content: string,
lineRange?: { start: number; end: number }
) => {
const newTab = {
id: nanoid(),
type: type as "file" | "code" | "image",
name,
content,
lineRange,
}
setContextTabs((prev) => [...prev, newTab])
}
// Remove context tab from context tabs
const removeContextTab = (id: string) => {
setContextTabs((prev) => prev.filter((tab) => tab.id !== id))
}
// Add file to context tabs
const handleAddFile = (tab: ContextTab) => {
setContextTabs((prev) => [...prev, tab])
}
// Format code content to remove starting and ending code block markers if they exist
const formatCodeContent = (content: string) => {
return content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "")
}
// Get combined context from context tabs
const getCombinedContext = () => {
if (contextTabs.length === 0) return ""
return contextTabs
.map((tab) => {
if (tab.type === "file") {
const fileExt = tab.name.split(".").pop() || "txt"
const cleanContent = formatCodeContent(tab.content)
return `File ${tab.name}:\n\`\`\`${fileExt}\n${cleanContent}\n\`\`\``
} else if (tab.type === "code") {
const cleanContent = formatCodeContent(tab.content)
return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\``
} else if (tab.type === "image") {
return `Image ${tab.name}:\n${tab.content}`
}
return `${tab.name}:\n${tab.content}`
})
.join("\n\n")
}
// Handle sending message with context
const handleSendWithContext = () => {
const combinedContext = getCombinedContext()
handleSend(
input,
combinedContext,
messages,
setMessages,
setInput,
setIsContextExpanded,
setIsGenerating,
setIsLoading,
abortControllerRef,
activeFileContent,
false,
templateType,
files,
projectName
)
}
// Set context for the chat
const setContext = (
context: string | null,
name: string,
range?: { start: number; end: number }
) => {
if (!context) {
setContextTabs([])
return
}
// Always add a new tab instead of updating existing ones
addContextTab("code", name, context, range)
}
// update context tabs when file contents change
useEffect(() => {
setContextTabs((prevTabs) =>
prevTabs.map((tab) => {
if (tab.type === "file" && tab.name === activeFileName) {
const fileExt = tab.name.split(".").pop() || "txt"
return {
...tab,
content: `\`\`\`${fileExt}\n${activeFileContent}\n\`\`\``,
}
}
return tab
})
)
}, [activeFileContent, activeFileName])
return (
<div className="flex flex-col h-screen w-full">
<div className="flex justify-between items-center p-2 border-b">
@ -209,88 +63,46 @@ export default function AIChat({
</button>
</div>
</div>
<ScrollArea
<div
ref={chatContainerRef}
className="flex-grow p-4 space-y-4 relative"
className="flex-grow overflow-y-auto p-4 space-y-4"
>
{messages.map((message, messageIndex) => (
// Render chat message component for each message
<ChatMessage
key={messageIndex}
message={message}
setContext={setContext}
setIsContextExpanded={setIsContextExpanded}
socket={socket}
handleApplyCode={handleApplyCode}
activeFileName={activeFileName}
activeFileContent={activeFileContent}
editorRef={editorRef}
mergeDecorationsCollection={mergeDecorationsCollection}
setMergeDecorationsCollection={setMergeDecorationsCollection}
selectFile={selectFile}
/>
))}
{isLoading && <LoadingDots />}
{/* Add scroll to bottom button */}
{showScrollButton && (
<button
onClick={() => scrollToBottom(true)}
className="fixed bottom-36 right-6 bg-primary text-primary-foreground rounded-md border border-primary p-0.5 shadow-lg hover:bg-primary/90 transition-all"
aria-label="Scroll to bottom"
>
<ChevronDown className="h-5 w-5" />
</button>
)}
</ScrollArea>
</div>
<div className="p-4 border-t mb-14">
{/* Render context tabs component */}
<ContextTabs
activeFileName={activeFileName}
onAddFile={handleAddFile}
contextTabs={contextTabs}
onRemoveTab={removeContextTab}
isExpanded={isContextExpanded}
onToggleExpand={() => setIsContextExpanded(!isContextExpanded)}
files={files}
socket={socket}
onFileSelect={(file: TFile) => {
socket?.emit("getFile", { fileId: file.id }, (response: string) => {
const fileExt = file.name.split(".").pop() || "txt"
const formattedContent = `\`\`\`${fileExt}\n${response}\n\`\`\``
addContextTab("file", file.name, formattedContent)
if (textareaRef.current) {
textareaRef.current.focus()
}
})
}}
<ContextDisplay
context={context}
isContextExpanded={isContextExpanded}
setIsContextExpanded={setIsContextExpanded}
setContext={setContext}
/>
{/* Render chat input component */}
<ChatInput
textareaRef={textareaRef}
addContextTab={addContextTab}
editorRef={editorRef}
input={input}
setInput={setInput}
isGenerating={isGenerating}
handleSend={handleSendWithContext}
handleSend={() =>
handleSend(
input,
context,
messages,
setMessages,
setInput,
setIsContextExpanded,
setIsGenerating,
setIsLoading,
abortControllerRef,
activeFileContent
)
}
handleStopGeneration={() => handleStopGeneration(abortControllerRef)}
onImageUpload={(file) => {
const reader = new FileReader()
reader.onload = (e) => {
if (e.target?.result) {
addContextTab("image", file.name, e.target.result as string)
}
}
reader.readAsDataURL(file)
}}
lastCopiedRangeRef={lastCopiedRangeRef}
activeFileName={activeFileName}
contextTabs={contextTabs.map((tab) => ({
...tab,
title: tab.id,
}))}
onRemoveTab={removeContextTab}
/>
</div>
</div>

View File

@ -1,40 +1,30 @@
import { TFile, TFolder } from "@/lib/types"
import React from "react"
// Stringify content for chat message component
export const stringifyContent = (
content: any,
seen = new WeakSet()
): string => {
// Stringify content if it's a string
if (typeof content === "string") {
return content
}
// Stringify content if it's null
if (content === null) {
return "null"
}
// Stringify content if it's undefined
if (content === undefined) {
return "undefined"
}
// Stringify content if it's a number or boolean
if (typeof content === "number" || typeof content === "boolean") {
return content.toString()
}
// Stringify content if it's a function
if (typeof content === "function") {
return content.toString()
}
// Stringify content if it's a symbol
if (typeof content === "symbol") {
return content.toString()
}
// Stringify content if it's a bigint
if (typeof content === "bigint") {
return content.toString() + "n"
}
// Stringify content if it's a valid React element
if (React.isValidElement(content)) {
return React.Children.toArray(
(content as React.ReactElement).props.children
@ -42,13 +32,11 @@ export const stringifyContent = (
.map((child) => stringifyContent(child, seen))
.join("")
}
// Stringify content if it's an array
if (Array.isArray(content)) {
return (
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
)
}
// Stringify content if it's an object
if (typeof content === "object") {
if (seen.has(content)) {
return "[Circular]"
@ -63,23 +51,19 @@ export const stringifyContent = (
return Object.prototype.toString.call(content)
}
}
// Stringify content if it's a primitive value
return String(content)
}
// Copy to clipboard for chat message component
export const copyToClipboard = (
text: string,
setCopiedText: (text: string | null) => void
) => {
// Copy text to clipboard for chat message component
navigator.clipboard.writeText(text).then(() => {
setCopiedText(text)
setTimeout(() => setCopiedText(null), 2000)
})
}
// Handle send for chat message component
export const handleSend = async (
input: string,
context: string | null,
@ -90,34 +74,16 @@ export const handleSend = async (
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string,
isEditMode: boolean = false,
templateType: string,
files: (TFile | TFolder)[],
projectName: string
activeFileContent: string
) => {
// Return if input is empty and context is null
if (input.trim() === "" && !context) return
// Get timestamp for chat message component
const timestamp = new Date()
.toLocaleTimeString("en-US", {
hour12: true,
hour: "2-digit",
minute: "2-digit",
})
.replace(/(\d{2}):(\d{2})/, "$1:$2")
// Create user message for chat message component
const userMessage = {
const newMessage = {
role: "user" as const,
content: input,
context: context || undefined,
timestamp: timestamp,
}
// Update messages for chat message component
const updatedMessages = [...messages, userMessage]
const updatedMessages = [...messages, newMessage]
setMessages(updatedMessages)
setInput("")
setIsContextExpanded(false)
@ -127,49 +93,41 @@ export const handleSend = async (
abortControllerRef.current = new AbortController()
try {
// Create anthropic messages for chat message component
const anthropicMessages = updatedMessages.map((msg) => ({
role: msg.role === "user" ? "human" : "assistant",
content: msg.content,
}))
// Fetch AI response for chat message component
const response = await fetch("/api/ai", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: anthropicMessages,
context: context || undefined,
activeFileContent: activeFileContent,
isEditMode: isEditMode,
templateType: templateType,
files: files,
projectName: projectName,
}),
signal: abortControllerRef.current.signal,
})
const response = await fetch(
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: anthropicMessages,
context: context || undefined,
activeFileContent: activeFileContent,
}),
signal: abortControllerRef.current.signal,
}
)
// Throw error if response is not ok
if (!response.ok) {
const error = await response.text()
throw new Error(error)
throw new Error("Failed to get AI response")
}
// Get reader for chat message component
const reader = response.body?.getReader()
const decoder = new TextDecoder()
const assistantMessage = { role: "assistant" as const, content: "" }
setMessages([...updatedMessages, assistantMessage])
setIsLoading(false)
// Initialize buffer for chat message component
let buffer = ""
const updateInterval = 100
let lastUpdateTime = Date.now()
// Read response from reader for chat message component
if (reader) {
while (true) {
const { done, value } = await reader.read()
@ -188,7 +146,6 @@ export const handleSend = async (
}
}
// Update messages for chat message component
setMessages((prev) => {
const updatedMessages = [...prev]
const lastMessage = updatedMessages[updatedMessages.length - 1]
@ -197,15 +154,13 @@ export const handleSend = async (
})
}
} catch (error: any) {
// Handle abort error for chat message component
if (error.name === "AbortError") {
console.log("Generation aborted")
} else {
console.error("Error fetching AI response:", error)
const errorMessage = {
role: "assistant" as const,
content:
error.message || "Sorry, I encountered an error. Please try again.",
content: "Sorry, I encountered an error. Please try again.",
}
setMessages((prev) => [...prev, errorMessage])
}
@ -216,7 +171,6 @@ export const handleSend = async (
}
}
// Handle stop generation for chat message component
export const handleStopGeneration = (
abortControllerRef: React.MutableRefObject<AbortController | null>
) => {
@ -224,30 +178,3 @@ export const handleStopGeneration = (
abortControllerRef.current.abort()
}
}
// Check if text looks like code for chat message component
export const looksLikeCode = (text: string): boolean => {
const codeIndicators = [
/^import\s+/m, // import statements
/^function\s+/m, // function declarations
/^class\s+/m, // class declarations
/^const\s+/m, // const declarations
/^let\s+/m, // let declarations
/^var\s+/m, // var declarations
/[{}\[\]();]/, // common code syntax
/^\s*\/\//m, // comments
/^\s*\/\*/m, // multi-line comments
/=>/, // arrow functions
/^export\s+/m, // export statements
]
return codeIndicators.some((pattern) => pattern.test(text))
}
// Add this new function after looksLikeCode function
export const isFilePath = (text: string): boolean => {
// Match patterns like next/styles/SignIn.module.css or path/to/file.ext (new file)
const pattern =
/^(?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+(\s+\(new file\))?$/
return pattern.test(text)
}

View File

@ -1,102 +0,0 @@
// Ignore certain folders and files from the file tree
export const ignoredFolders = [
// Package managers
"node_modules",
"venv",
".env",
"env",
".venv",
"virtualenv",
"pip-wheel-metadata",
// Build outputs
".next",
"dist",
"build",
"out",
"__pycache__",
".webpack",
".serverless",
"storybook-static",
// Version control
".git",
".svn",
".hg", // Mercurial
// Cache and temp files
".cache",
"coverage",
"tmp",
".temp",
".npm",
".pnpm",
".yarn",
".eslintcache",
".stylelintcache",
// IDE specific
".idea",
".vscode",
".vs",
".sublime",
// Framework specific
".streamlit",
".next",
"static",
".pytest_cache",
".nuxt",
".docusaurus",
".remix",
".parcel-cache",
"public/build", // Remix/Rails
".turbo", // Turborepo
// Logs
"logs",
"*.log",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
"pnpm-debug.log*",
] as const
export const ignoredFiles = [
".DS_Store",
".env.local",
".env.development",
".env.production",
".env.test",
".env*.local",
".gitignore",
".npmrc",
".yarnrc",
".editorconfig",
".prettierrc",
".eslintrc",
".browserslistrc",
"tsconfig.tsbuildinfo",
"*.pyc",
"*.pyo",
"*.pyd",
"*.so",
"*.dll",
"*.dylib",
"*.class",
"*.exe",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"composer.lock",
"poetry.lock",
"Gemfile.lock",
"*.min.js",
"*.min.css",
"*.map",
"*.chunk.*",
"*.hot-update.*",
".vercel",
".netlify",
] as const

View File

@ -1,207 +0,0 @@
import { useSocket } from "@/context/SocketContext"
import { TTab } from "@/lib/types"
import { Check, CornerUpLeft, FileText, X } from "lucide-react"
import monaco from "monaco-editor"
import { Components } from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import { Button } from "../../../ui/button"
import ApplyButton from "../ApplyButton"
import { isFilePath, stringifyContent } from "./chatUtils"
// Create markdown components for chat message component
export const createMarkdownComponents = (
renderCopyButton: (text: any) => JSX.Element,
renderMarkdownElement: (props: any) => JSX.Element,
askAboutCode: (code: any) => void,
activeFileName: string,
activeFileContent: string,
editorRef: any,
handleApplyCode: (mergedCode: string, originalCode: string) => void,
selectFile: (tab: TTab) => void,
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection,
setMergeDecorationsCollection?: (collection: undefined) => void
): Components => ({
code: ({
node,
className,
children,
...props
}: {
node?: import("hast").Element
className?: string
children?: React.ReactNode
[key: string]: any
}) => {
const match = /language-(\w+)/.exec(className || "")
return match ? (
<div className="relative border border-input rounded-md mt-8 my-2 translate-y-[-1rem]">
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 rounded-tl">
{match[1]}
</div>
<div className="sticky top-0 right-0 flex justify-end z-10">
<div className="flex border border-input shadow-lg bg-background rounded-md">
{renderCopyButton(children)}
<div className="w-px bg-input"></div>
{!mergeDecorationsCollection ? (
<ApplyButton
code={String(children)}
activeFileName={activeFileName}
activeFileContent={activeFileContent}
editorRef={editorRef}
onApply={handleApplyCode}
/>
) : (
<>
<Button
onClick={() => {
if (
setMergeDecorationsCollection &&
mergeDecorationsCollection &&
editorRef?.current
) {
mergeDecorationsCollection?.clear()
setMergeDecorationsCollection(undefined)
}
}}
size="sm"
variant="ghost"
className="p-1 h-6"
title="Accept Changes"
>
<Check className="w-4 h-4 text-green-500" />
</Button>
<div className="w-px bg-input"></div>
<Button
onClick={() => {
if (editorRef?.current && mergeDecorationsCollection) {
const model = editorRef.current.getModel()
if (model && (model as any).originalContent) {
editorRef.current?.setValue(
(model as any).originalContent
)
mergeDecorationsCollection.clear()
setMergeDecorationsCollection?.(undefined)
}
}
}}
size="sm"
variant="ghost"
className="p-1 h-6"
title="Discard Changes"
>
<X className="w-4 h-4 text-red-500" />
</Button>
</>
)}
<div className="w-px bg-input"></div>
<Button
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
askAboutCode(children)
}}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
</div>
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
padding: "0.5rem",
fontSize: "0.875rem",
}}
>
{stringifyContent(children)}
</SyntaxHighlighter>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
)
},
// Render markdown elements
p: ({ node, children, ...props }) => {
const content = stringifyContent(children)
const { socket } = useSocket()
if (isFilePath(content)) {
const isNewFile = content.endsWith("(new file)")
const filePath = (
isNewFile ? content.replace(" (new file)", "") : content
)
.split("/")
.filter((part, index) => index !== 0)
.join("/")
const handleFileClick = () => {
if (isNewFile) {
socket?.emit(
"createFile",
{
name: filePath,
},
(response: any) => {
if (response.success) {
const tab: TTab = {
id: filePath,
name: filePath.split("/").pop() || "",
saved: true,
type: "file",
}
selectFile(tab)
}
}
)
} else {
const tab: TTab = {
id: filePath,
name: filePath.split("/").pop() || "",
saved: true,
type: "file",
}
selectFile(tab)
}
}
return (
<div
onClick={handleFileClick}
className="group flex items-center gap-2 px-2 py-1 bg-secondary/50 rounded-md my-2 text-xs hover:bg-secondary cursor-pointer w-fit"
>
<FileText className="h-4 w-4" />
<span className="font-mono group-hover:underline">{content}</span>
</div>
)
}
return renderMarkdownElement({ node, children, ...props })
},
h1: ({ node, children, ...props }) =>
renderMarkdownElement({ node, children, ...props }),
h2: ({ node, children, ...props }) =>
renderMarkdownElement({ node, children, ...props }),
h3: ({ node, children, ...props }) =>
renderMarkdownElement({ node, children, ...props }),
h4: ({ node, children, ...props }) =>
renderMarkdownElement({ node, children, ...props }),
h5: ({ node, children, ...props }) =>
renderMarkdownElement({ node, children, ...props }),
h6: ({ node, children, ...props }) =>
renderMarkdownElement({ node, children, ...props }),
ul: (props) => (
<ul className="list-disc pl-6 mb-4 space-y-2">{props.children}</ul>
),
ol: (props) => (
<ol className="list-decimal pl-6 mb-4 space-y-2">{props.children}</ol>
),
})

View File

@ -1,133 +0,0 @@
import { TemplateConfig } from "@/lib/templates"
import { TFile, TFolder, TTab } from "@/lib/types"
import * as monaco from "monaco-editor"
import { Socket } from "socket.io-client"
// Allowed file types for context tabs
export const ALLOWED_FILE_TYPES = {
// Text files
"text/plain": true,
"text/markdown": true,
"text/csv": true,
// Code files
"application/json": true,
"text/javascript": true,
"text/typescript": true,
"text/html": true,
"text/css": true,
// Documents
"application/pdf": true,
// Images
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
"image/svg+xml": true,
} as const
// Message interface
export interface Message {
role: "user" | "assistant"
content: string
context?: string
}
// Context tab interface
export interface ContextTab {
id: string
type: "file" | "code" | "image"
name: string
content: string
lineRange?: { start: number; end: number }
}
// AIChat props interface
export interface AIChatProps {
activeFileContent: string
activeFileName: string
onClose: () => void
editorRef: React.MutableRefObject<
monaco.editor.IStandaloneCodeEditor | undefined
>
lastCopiedRangeRef: React.MutableRefObject<{
startLine: number
endLine: number
} | null>
files: (TFile | TFolder)[]
templateType: string
templateConfig?: TemplateConfig
projectName: string
handleApplyCode: (mergedCode: string, originalCode: string) => void
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
setMergeDecorationsCollection?: (collection: undefined) => void
selectFile: (tab: TTab) => void
}
// Chat input props interface
export interface ChatInputProps {
input: string
setInput: (input: string) => void
isGenerating: boolean
handleSend: (useFullContext?: boolean) => void
handleStopGeneration: () => void
onImageUpload: (file: File) => void
addContextTab: (
type: string,
title: string,
content: string,
lineRange?: { start: number; end: number }
) => void
activeFileName?: string
editorRef: React.MutableRefObject<
monaco.editor.IStandaloneCodeEditor | undefined
>
lastCopiedRangeRef: React.MutableRefObject<{
startLine: number
endLine: number
} | null>
contextTabs: {
id: string
type: string
title: string
content: string
lineRange?: { start: number; end: number }
}[]
onRemoveTab: (id: string) => void
textareaRef: React.RefObject<HTMLTextAreaElement>
}
// Chat message props interface
export interface MessageProps {
message: {
role: "user" | "assistant"
content: string
context?: string
}
setContext: (
context: string | null,
name: string,
range?: { start: number; end: number }
) => void
setIsContextExpanded: (isExpanded: boolean) => void
socket: Socket | null
handleApplyCode: (mergedCode: string, originalCode: string) => void
activeFileName: string
activeFileContent: string
editorRef: any
mergeDecorationsCollection?: monaco.editor.IEditorDecorationsCollection
setMergeDecorationsCollection?: (collection: undefined) => void
selectFile: (tab: TTab) => void
}
// Context tabs props interface
export interface ContextTabsProps {
activeFileName: string
onAddFile: (tab: ContextTab) => void
contextTabs: ContextTab[]
onRemoveTab: (id: string) => void
isExpanded: boolean
onToggleExpand: () => void
files?: (TFile | TFolder)[]
onFileSelect?: (file: TFile) => void
socket: Socket | null
}

View File

@ -5,12 +5,14 @@ import { Editor } from "@monaco-editor/react"
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
import { usePathname, useRouter } from "next/navigation"
import { useCallback, useEffect, useRef, useState } from "react"
import { Socket } from "socket.io-client"
import { toast } from "sonner"
import { Button } from "../ui/button"
// import monaco from "monaco-editor"
export default function GenerateInput({
user,
socket,
width,
data,
editor,
@ -19,6 +21,7 @@ export default function GenerateInput({
onClose,
}: {
user: User
socket: Socket
width: number
data: {
fileName: string
@ -56,56 +59,32 @@ export default function GenerateInput({
}: {
regenerate?: boolean
}) => {
try {
setLoading({ generate: !regenerate, regenerate })
setCurrentPrompt(input)
const response = await fetch("/api/ai", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: [
{
role: "user",
content: regenerate ? currentPrompt : input,
},
],
context: null,
activeFileContent: data.code,
isEditMode: true,
fileName: data.fileName,
line: data.line,
}),
})
if (!response.ok) {
const error = await response.text()
toast.error(error)
return
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let result = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
result += decoder.decode(value, { stream: true })
}
}
setCode(result.trim())
router.refresh()
} catch (error) {
console.error("Generation error:", error)
toast.error("Failed to generate code")
} finally {
setLoading({ generate: false, regenerate: false })
if (user.generations >= 1000) {
toast.error("You reached the maximum # of generations.")
return
}
setLoading({ generate: !regenerate, regenerate })
setCurrentPrompt(input)
socket.emit(
"generateCode",
{
fileName: data.fileName,
code: data.code,
line: data.line,
instructions: regenerate ? currentPrompt : input
},
(res: { response: string; success: boolean }) => {
console.log("Generated code", res.response, res.success)
// if (!res.success) {
// toast.error("Failed to generate code.");
// return;
// }
setCode(res.response)
router.refresh()
}
)
}
const handleGenerateForm = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {

View File

@ -7,11 +7,11 @@ import * as monaco from "monaco-editor"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
// import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
// import LiveblocksProvider from "@liveblocks/yjs"
// import { MonacoBinding } from "y-monaco"
// import { Awareness } from "y-protocols/awareness"
// import * as Y from "yjs"
import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
import LiveblocksProvider from "@liveblocks/yjs"
import { MonacoBinding } from "y-monaco"
import { Awareness } from "y-protocols/awareness"
import * as Y from "yjs"
import {
ResizableHandle,
@ -23,6 +23,7 @@ import { useSocket } from "@/context/SocketContext"
import { parseTSConfigToMonacoOptions } from "@/lib/tsconfig"
import { Sandbox, TFile, TFolder, TTab, User } from "@/lib/types"
import {
addNew,
cn,
debounce,
deepMerge,
@ -45,7 +46,7 @@ import { Button } from "../ui/button"
import Tab from "../ui/tab"
import AIChat from "./AIChat"
import GenerateInput from "./generate"
// import { Cursors } from "./live/cursors"
import { Cursors } from "./live/cursors"
import DisableAccessModal from "./live/disableModal"
import Loading from "./loading"
import PreviewWindow from "./preview"
@ -62,7 +63,7 @@ export default function CodeEditor({
//SocketContext functions and effects
const { socket, setUserAndSandboxId } = useSocket()
// theme
const { resolvedTheme: theme } = useTheme()
const { theme } = useTheme()
useEffect(() => {
// Ensure userData.id and sandboxData.id are available before attempting to connect
if (userData.id && sandboxData.id) {
@ -104,15 +105,9 @@ export default function CodeEditor({
// Added this state to track the most recent content for each file
const [fileContents, setFileContents] = useState<Record<string, string>>({})
// Apply Button merger decoration state
const [mergeDecorations, setMergeDecorations] = useState<
monaco.editor.IModelDeltaDecoration[]
>([])
const [mergeDecorationsCollection, setMergeDecorationsCollection] =
useState<monaco.editor.IEditorDecorationsCollection>()
// Editor state
const [editorLanguage, setEditorLanguage] = useState("plaintext")
console.log("editor language: ", editorLanguage)
const [cursorLine, setCursorLine] = useState(0)
const [editorRef, setEditorRef] =
useState<monaco.editor.IStandaloneCodeEditor>()
@ -153,20 +148,20 @@ export default function CodeEditor({
const isOwner = sandboxData.userId === userData.id
const clerk = useClerk()
// // Liveblocks hooks
// const room = useRoom()
// const [provider, setProvider] = useState<TypedLiveblocksProvider>()
// const userInfo = useSelf((me) => me.info)
// Liveblocks hooks
const room = useRoom()
const [provider, setProvider] = useState<TypedLiveblocksProvider>()
const userInfo = useSelf((me) => me.info)
// // Liveblocks providers map to prevent reinitializing providers
// type ProviderData = {
// provider: LiveblocksProvider<never, never, never, never>
// yDoc: Y.Doc
// yText: Y.Text
// binding?: MonacoBinding
// onSync: (isSynced: boolean) => void
// }
// const providersMap = useRef(new Map<string, ProviderData>())
// Liveblocks providers map to prevent reinitializing providers
type ProviderData = {
provider: LiveblocksProvider<never, never, never, never>
yDoc: Y.Doc
yText: Y.Text
binding?: MonacoBinding
onSync: (isSynced: boolean) => void
}
const providersMap = useRef(new Map<string, ProviderData>())
// Refs for libraries / features
const editorContainerRef = useRef<HTMLDivElement>(null)
@ -178,12 +173,6 @@ export default function CodeEditor({
const editorPanelRef = useRef<ImperativePanelHandle>(null)
const previewWindowRef = useRef<{ refreshIframe: () => void }>(null)
// Ref to store the last copied range in the editor to be used in the AIChat component
const lastCopiedRangeRef = useRef<{
startLine: number
endLine: number
} | null>(null)
const debouncedSetIsSelected = useRef(
debounce((value: boolean) => {
setIsSelected(value)
@ -230,6 +219,7 @@ export default function CodeEditor({
let mergedConfig: any = { compilerOptions: {} }
for (const file of tsconfigFiles) {
const containerId = file.id.split("/").slice(0, 2).join("/")
const content = await fetchFileContent(file.id)
try {
@ -239,7 +229,8 @@ export default function CodeEditor({
if (tsConfig.references) {
for (const ref of tsConfig.references) {
const path = ref.path.replace("./", "")
const refContent = await fetchFileContent(path)
const fileId = `${containerId}/${path}`
const refContent = await fetchFileContent(fileId)
const referenceTsConfig = JSON.parse(refContent)
// Merge configurations
@ -266,17 +257,6 @@ export default function CodeEditor({
updatedOptions
)
}
// Store the last copied range in the editor to be used in the AIChat component
editor.onDidChangeCursorSelection((e) => {
const selection = editor.getSelection()
if (selection) {
lastCopiedRangeRef.current = {
startLine: selection.startLineNumber,
endLine: selection.endLineNumber,
}
}
})
}
// Call the function with your file structure
@ -382,57 +362,6 @@ export default function CodeEditor({
})
}, [editorRef])
// handle apply code
const handleApplyCode = useCallback(
(mergedCode: string, originalCode: string) => {
if (!editorRef) return
const model = editorRef.getModel()
if (!model) return // Store original content
;(model as any).originalContent = originalCode
// Calculate the full range of the document
const fullRange = model.getFullModelRange()
// Create decorations before applying the edit
const originalLines = originalCode.split("\n")
const mergedLines = mergedCode.split("\n")
const decorations: monaco.editor.IModelDeltaDecoration[] = []
for (
let i = 0;
i < Math.max(originalLines.length, mergedLines.length);
i++
) {
// Only highlight new lines (green highlights)
if (i >= originalLines.length || originalLines[i] !== mergedLines[i]) {
decorations.push({
range: new monaco.Range(i + 1, 1, i + 1, 1),
options: {
isWholeLine: true,
className: "added-line-decoration",
glyphMarginClassName: "added-line-glyph",
},
})
}
}
// Execute the edit operation
editorRef.executeEdits("apply-code", [
{
range: fullRange,
text: mergedCode,
forceMoveMarkers: true,
},
])
// Apply decorations after the edit
const newDecorations = editorRef.createDecorationsCollection(decorations)
setMergeDecorationsCollection(newDecorations)
},
[editorRef]
)
// Generate widget effect
useEffect(() => {
if (generate.show) {
@ -601,6 +530,8 @@ export default function CodeEditor({
tab.id === activeFileId ? { ...tab, saved: true } : tab
)
)
console.log(`Saving file...${activeFileId}`)
console.log(`Saving file...${content}`)
socket?.emit("saveFile", { fileId: activeFileId, body: content })
}
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
@ -631,82 +562,82 @@ export default function CodeEditor({
}
}, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef])
// // Liveblocks live collaboration setup effect
// useEffect(() => {
// const tab = tabs.find((t) => t.id === activeFileId)
// const model = editorRef?.getModel()
// Liveblocks live collaboration setup effect
useEffect(() => {
const tab = tabs.find((t) => t.id === activeFileId)
const model = editorRef?.getModel()
// if (!editorRef || !tab || !model) return
if (!editorRef || !tab || !model) return
// let providerData: ProviderData
let providerData: ProviderData
// // When a file is opened for the first time, create a new provider and store in providersMap.
// if (!providersMap.current.has(tab.id)) {
// const yDoc = new Y.Doc()
// const yText = yDoc.getText(tab.id)
// const yProvider = new LiveblocksProvider(room, yDoc)
// When a file is opened for the first time, create a new provider and store in providersMap.
if (!providersMap.current.has(tab.id)) {
const yDoc = new Y.Doc()
const yText = yDoc.getText(tab.id)
const yProvider = new LiveblocksProvider(room, yDoc)
// // Inserts the file content into the editor once when the tab is changed.
// const onSync = (isSynced: boolean) => {
// if (isSynced) {
// const text = yText.toString()
// if (text === "") {
// if (activeFileContent) {
// yText.insert(0, activeFileContent)
// } else {
// setTimeout(() => {
// yText.insert(0, editorRef.getValue())
// }, 0)
// }
// }
// }
// }
// Inserts the file content into the editor once when the tab is changed.
const onSync = (isSynced: boolean) => {
if (isSynced) {
const text = yText.toString()
if (text === "") {
if (activeFileContent) {
yText.insert(0, activeFileContent)
} else {
setTimeout(() => {
yText.insert(0, editorRef.getValue())
}, 0)
}
}
}
}
// yProvider.on("sync", onSync)
yProvider.on("sync", onSync)
// // Save the provider to the map.
// providerData = { provider: yProvider, yDoc, yText, onSync }
// providersMap.current.set(tab.id, providerData)
// } else {
// // When a tab is opened that has been open before, reuse the existing provider.
// providerData = providersMap.current.get(tab.id)!
// }
// Save the provider to the map.
providerData = { provider: yProvider, yDoc, yText, onSync }
providersMap.current.set(tab.id, providerData)
} else {
// When a tab is opened that has been open before, reuse the existing provider.
providerData = providersMap.current.get(tab.id)!
}
// const binding = new MonacoBinding(
// providerData.yText,
// model,
// new Set([editorRef]),
// providerData.provider.awareness as unknown as Awareness
// )
const binding = new MonacoBinding(
providerData.yText,
model,
new Set([editorRef]),
providerData.provider.awareness as unknown as Awareness
)
// providerData.binding = binding
// setProvider(providerData.provider)
providerData.binding = binding
setProvider(providerData.provider)
// return () => {
// // Cleanup logic
// if (binding) {
// binding.destroy()
// }
// if (providerData.binding) {
// providerData.binding = undefined
// }
// }
// }, [room, activeFileContent])
return () => {
// Cleanup logic
if (binding) {
binding.destroy()
}
if (providerData.binding) {
providerData.binding = undefined
}
}
}, [room, activeFileContent])
// // Added this effect to clean up when the component unmounts
// useEffect(() => {
// return () => {
// // Clean up all providers when the component unmounts
// providersMap.current.forEach((data) => {
// if (data.binding) {
// data.binding.destroy()
// }
// data.provider.disconnect()
// data.yDoc.destroy()
// })
// providersMap.current.clear()
// }
// }, [])
// Added this effect to clean up when the component unmounts
useEffect(() => {
return () => {
// Clean up all providers when the component unmounts
providersMap.current.forEach((data) => {
if (data.binding) {
data.binding.destroy()
}
data.provider.disconnect()
data.yDoc.destroy()
})
providersMap.current.clear()
}
}, [])
// Connection/disconnection effect
useEffect(() => {
@ -718,7 +649,7 @@ export default function CodeEditor({
// Socket event listener effect
useEffect(() => {
const onConnect = () => {}
const onConnect = () => { }
const onDisconnect = () => {
setTerminals([])
@ -793,32 +724,31 @@ export default function CodeEditor({
setGenerate((prev) => ({ ...prev, show: false }))
// Check if the tab already exists in the list of open tabs
const existingTab = tabs.find((t) => t.id === tab.id)
if (existingTab) {
// If the tab exists, just make it active
setActiveFileId(existingTab.id)
if (fileContents[existingTab.id]) {
setActiveFileContent(fileContents[existingTab.id])
const exists = tabs.find((t) => t.id === tab.id)
setTabs((prev) => {
if (exists) {
// If the tab exists, make it the active tab
setActiveFileId(exists.id)
return prev
}
// If the tab doesn't exist, add it to the list of tabs and make it active
return [...prev, tab]
})
// If the file's content is already cached, set it as the active content
if (fileContents[tab.id]) {
setActiveFileContent(fileContents[tab.id])
} else {
// If the tab doesn't exist, add it to the list and make it active
setTabs((prev) => [...prev, tab])
// Fetch content if not cached
if (!fileContents[tab.id]) {
debouncedGetFile(tab.id, (response: string) => {
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
setActiveFileContent(response)
})
} else {
setActiveFileContent(fileContents[tab.id])
}
// Otherwise, fetch the content of the file and cache it
debouncedGetFile(tab.id, (response: string) => {
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
setActiveFileContent(response)
})
}
// Set the editor language based on the file type
setEditorLanguage(processFileType(tab.name))
// Set the active file ID
// Set the active file ID to the new tab
setActiveFileId(tab.id)
}
@ -847,8 +777,8 @@ export default function CodeEditor({
? numTabs === 1
? null
: index < numTabs - 1
? tabs[index + 1].id
: tabs[index - 1].id
? tabs[index + 1].id
: tabs[index - 1].id
: activeFileId
setTabs((prev) => prev.filter((t) => t.id !== id))
@ -914,7 +844,9 @@ export default function CodeEditor({
}
const handleDeleteFile = (file: TFile) => {
socket?.emit("deleteFile", { fileId: file.id })
socket?.emit("deleteFile", { fileId: file.id }, (response: (TFolder | TFile)[]) => {
setFiles(response)
})
closeTab(file.id)
}
@ -926,13 +858,10 @@ export default function CodeEditor({
closeTabs(response)
)
socket?.emit(
"deleteFolder",
{ folderId: folder.id },
(response: (TFolder | TFile)[]) => {
setDeletingFolderId("")
}
)
socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => {
setFiles(response)
setDeletingFolderId("")
})
}
const togglePreviewPanel = () => {
@ -973,16 +902,16 @@ export default function CodeEditor({
<DisableAccessModal
message={disableAccess.message}
open={disableAccess.isDisabled}
setOpen={() => {}}
setOpen={() => { }}
/>
<Loading />
</>
)
return (
<div className="flex max-h-full overflow-hidden">
<>
{/* Copilot DOM elements */}
<PreviewProvider>
{/* Copilot DOM elements */}
<div ref={generateRef} />
<div ref={suggestionRef} className="absolute">
<AnimatePresence>
@ -1008,14 +937,15 @@ export default function CodeEditor({
{generate.show ? (
<GenerateInput
user={userData}
socket={socket!}
width={generate.width - 90}
data={{
fileName: tabs.find((t) => t.id === activeFileId)?.name ?? "",
code:
(isSelected && editorRef?.getSelection()
? editorRef
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
?.getModel()
?.getValueInRange(editorRef?.getSelection()!)
: editorRef?.getValue()) ?? "",
line: generate.line,
}}
@ -1097,9 +1027,8 @@ export default function CodeEditor({
handleDeleteFolder={handleDeleteFolder}
socket={socket!}
setFiles={setFiles}
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
deletingFolderId={deletingFolderId}
toggleAIChat={toggleAIChat}
isAIChatOpen={isAIChatOpen}
/>
{/* Outer ResizablePanelGroup for main layout */}
<ResizablePanelGroup
@ -1146,62 +1075,62 @@ export default function CodeEditor({
</div>
</>
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
clerk.loaded ? (
<>
{/* {provider && userInfo ? (
clerk.loaded ? (
<>
{provider && userInfo ? (
<Cursors yProvider={provider} userInfo={userInfo} />
) : null} */}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
// If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? "") // Update the active file content
// Mark the file as unsaved by setting 'saved' to false
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
) : null}
<Editor
height="100%"
language={editorLanguage}
beforeMount={handleEditorWillMount}
onMount={handleEditorMount}
onChange={(value) => {
// If the new content is different from the cached content, update it
if (value !== fileContents[activeFileId]) {
setActiveFileContent(value ?? "") // Update the active file content
// Mark the file as unsaved by setting 'saved' to false
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: false }
: tab
)
)
)
} else {
// If the content matches the cached content, mark the file as saved
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
} else {
// If the content matches the cached content, mark the file as saved
setTabs((prev) =>
prev.map((tab) =>
tab.id === activeFileId
? { ...tab, saved: true }
: tab
)
)
)
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme={theme === "light" ? "vs" : "vs-dark"}
value={activeFileContent}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<Loader2 className="animate-spin w-6 h-6 mr-3" />
Waiting for Clerk to load...
</div>
)}
}
}}
options={{
tabSize: 2,
minimap: {
enabled: false,
},
padding: {
bottom: 4,
top: 4,
},
scrollBeyondLastLine: false,
fixedOverflowWidgets: true,
fontFamily: "var(--font-geist-mono)",
}}
theme={theme === "light" ? "vs" : "vs-dark"}
value={activeFileContent}
/>
</>
) : (
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
<Loader2 className="animate-spin w-6 h-6 mr-3" />
Waiting for Clerk to load...
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle />
@ -1211,10 +1140,10 @@ export default function CodeEditor({
isAIChatOpen && isHorizontalLayout
? "horizontal"
: isAIChatOpen
? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
? "vertical"
: isHorizontalLayout
? "horizontal"
: "vertical"
}
>
<ResizablePanel
@ -1289,36 +1218,14 @@ export default function CodeEditor({
"No file selected"
}
onClose={toggleAIChat}
editorRef={{ current: editorRef }}
lastCopiedRangeRef={lastCopiedRangeRef}
files={files}
templateType={sandboxData.type}
projectName={sandboxData.name}
handleApplyCode={handleApplyCode}
mergeDecorationsCollection={mergeDecorationsCollection}
setMergeDecorationsCollection={setMergeDecorationsCollection}
selectFile={selectFile}
/>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</PreviewProvider>
</div>
</>
)
}
/**
* Configure the typescript compiler to detect JSX and load type definitions
*/
const defaultCompilerOptions: monaco.languages.typescript.CompilerOptions = {
allowJs: true,
allowSyntheticDefaultImports: true,
allowNonTsExtensions: true,
resolveJsonModule: true,
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
module: monaco.languages.typescript.ModuleKind.ESNext,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
target: monaco.languages.typescript.ScriptTarget.ESNext,
}

View File

@ -9,7 +9,7 @@ import {
import { useTerminal } from "@/context/TerminalContext"
import { Sandbox, User } from "@/lib/types"
import { Globe } from "lucide-react"
import { useEffect, useState } from "react"
import { useState } from "react"
export default function DeployButtonModal({
userData,
@ -18,21 +18,8 @@ export default function DeployButtonModal({
userData: User
data: Sandbox
}) {
const { deploy, getAppExists } = useTerminal()
const { deploy } = useTerminal()
const [isDeploying, setIsDeploying] = useState(false)
const [isDeployed, setIsDeployed] = useState(false)
const [deployButtonVisible, setDeployButtonEnabled] = useState(false)
useEffect(() => {
const checkDeployment = async () => {
if (getAppExists) {
const exists = await getAppExists(data.id)
setDeployButtonEnabled(exists.success)
setIsDeployed((exists.success && exists.exists) ?? false)
}
}
checkDeployment()
}, [data.id, getAppExists])
const handleDeploy = () => {
if (isDeploying) {
@ -43,7 +30,6 @@ export default function DeployButtonModal({
setIsDeploying(true)
deploy(() => {
setIsDeploying(false)
setIsDeployed(true)
})
}
}
@ -52,12 +38,10 @@ export default function DeployButtonModal({
<>
<Popover>
<PopoverTrigger asChild>
{deployButtonVisible && (
<Button variant="outline">
<Globe className="w-4 h-4 mr-2" />
Deploy
</Button>
)}
<Button variant="outline">
<Globe className="w-4 h-4 mr-2" />
Deploy
</Button>
</PopoverTrigger>
<PopoverContent
className="p-4 w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl rounded-lg shadow-lg"
@ -68,9 +52,8 @@ export default function DeployButtonModal({
<DeploymentOption
icon={<Globe className="text-gray-500 w-5 h-5" />}
domain={`${data.id}.gitwit.app`}
timestamp="Deployed 1m ago"
timestamp="Deployed 1h ago"
user={userData.name}
isDeployed={isDeployed}
/>
</div>
<Button
@ -78,7 +61,7 @@ export default function DeployButtonModal({
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
onClick={handleDeploy}
>
{isDeploying ? "Deploying..." : isDeployed ? "Update" : "Deploy"}
{isDeploying ? "Deploying..." : "Update"}
</Button>
</PopoverContent>
</Popover>
@ -91,33 +74,27 @@ function DeploymentOption({
domain,
timestamp,
user,
isDeployed,
}: {
icon: React.ReactNode
domain: string
timestamp: string
user: string
isDeployed: boolean
}) {
return (
<div className="flex flex-col gap-2 w-full text-left p-2 rounded-md border border-gray-700 bg-gray-900">
<div className="flex items-start gap-2 relative">
<div className="flex-shrink-0">{icon}</div>
{isDeployed ? (
<a
href={`https://${domain}`}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-gray-300 hover:underline"
>
{domain}
</a>
) : (
<span className="font-semibold text-gray-300">{domain}</span>
)}
<a
href={`https://${domain}`}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-gray-300 hover:underline"
>
{domain}
</a>
</div>
<p className="text-sm text-gray-400 mt-0 ml-7">
{isDeployed ? `${timestamp}${user}` : "Never deployed"}
{timestamp} {user}
</p>
</div>
)

View File

@ -1,41 +0,0 @@
// React component for download button
import { Button } from "@/components/ui/button"
import { useSocket } from "@/context/SocketContext"
import { Download } from "lucide-react"
export default function DownloadButton({ name }: { name: string }) {
const { socket } = useSocket()
const handleDownload = async () => {
socket?.emit(
"downloadFiles",
{ timestamp: Date.now() },
async (response: { zipBlob: string }) => {
const { zipBlob } = response
// Decode Base64 back to binary data
const binary = atob(zipBlob)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
const blob = new Blob([bytes], { type: "application/zip" })
// Create URL and download
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${name}.zip`
a.click()
window.URL.revokeObjectURL(url)
}
)
}
return (
<Button variant="outline" onClick={handleDownload}>
<Download className="w-4 h-4 mr-2" />
Download
</Button>
)
}

View File

@ -9,9 +9,8 @@ import { Pencil, Users } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useState } from "react"
// import { Avatars } from "../live/avatars"
import { Avatars } from "../live/avatars"
import DeployButtonModal from "./deploy"
import DownloadButton from "./downloadButton"
import EditSandboxModal from "./edit"
import RunButtonModal from "./run"
import ShareSandboxModal from "./share"
@ -23,7 +22,7 @@ export default function Navbar({
}: {
userData: User
sandboxData: Sandbox
shared: { id: string; name: string; avatarUrl: string }[]
shared: { id: string; name: string }[]
}) {
const [isEditOpen, setIsEditOpen] = useState(false)
const [isShareOpen, setIsShareOpen] = useState(false)
@ -70,16 +69,16 @@ export default function Navbar({
sandboxData={sandboxData}
/>
<div className="flex items-center h-full space-x-4">
{/* <Avatars /> */}
<Avatars />
{isOwner ? (
<>
<DeployButtonModal data={sandboxData} userData={userData} />
{/* <Button variant="outline" onClick={() => setIsShareOpen(true)}>
<Button variant="outline" onClick={() => setIsShareOpen(true)}>
<Users className="w-4 h-4 mr-2" />
Share
</Button> */}
<DownloadButton name={sandboxData.name} /></>
</Button>
</>
) : null}
<ThemeSwitcher />
<UserButton userData={userData} />

View File

@ -7,7 +7,6 @@ import { Sandbox } from "@/lib/types"
import { Play, StopCircle } from "lucide-react"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
import { templateConfigs } from "@/lib/templates"
export default function RunButtonModal({
isRunning,
@ -35,7 +34,7 @@ export default function RunButtonModal({
}
}
}, [terminals, isRunning])
// commands to run in the terminal
const handleRun = async () => {
if (isRunning && lastCreatedTerminalRef.current) {
await closeTerminal(lastCreatedTerminalRef.current)
@ -43,7 +42,10 @@ export default function RunButtonModal({
setIsPreviewCollapsed(true)
previewPanelRef.current?.collapse()
} else if (!isRunning && terminals.length < 4) {
const command = templateConfigs[sandboxData.type]?.runCommand || "npm run dev"
const command =
sandboxData.type === "streamlit"
? "pip install -r requirements.txt && streamlit run main.py --server.runOnSave true"
: "yarn install && yarn dev"
try {
// Create a new terminal with the appropriate command

View File

@ -43,7 +43,6 @@ export default function ShareSandboxModal({
shared: {
id: string
name: string
avatarUrl: string
}[]
}) {
const [loading, setLoading] = useState(false)

View File

@ -10,7 +10,7 @@ export default function SharedUser({
user,
sandboxId,
}: {
user: { id: string; name: string; avatarUrl: string }
user: { id: string; name: string }
sandboxId: string
}) {
const [loading, setLoading] = useState(false)
@ -24,7 +24,7 @@ export default function SharedUser({
return (
<div className="flex items-center justify-between">
<div className="flex items-center">
<Avatar name={user.name} avatarUrl={user.avatarUrl} className="mr-2" />
<Avatar name={user.name} className="mr-2" />
{user.name}
</div>
<Button

View File

@ -9,9 +9,8 @@ import SidebarFolder from "./folder"
import New from "./new"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
import { cn, sortFileExplorer } from "@/lib/utils"
import { sortFileExplorer } from "@/lib/utils"
import {
dropTargetForElements,
monitorForElements,
@ -26,9 +25,8 @@ export default function Sidebar({
handleDeleteFolder,
socket,
setFiles,
addNew,
deletingFolderId,
toggleAIChat,
isAIChatOpen,
}: {
sandboxData: Sandbox
files: (TFile | TFolder)[]
@ -43,9 +41,8 @@ export default function Sidebar({
handleDeleteFolder: (folder: TFolder) => void
socket: Socket
setFiles: (files: (TFile | TFolder)[]) => void
addNew: (name: string, type: "file" | "folder") => void
deletingFolderId: string
toggleAIChat: () => void
isAIChatOpen: boolean
}) {
const ref = useRef(null) // drop target
@ -92,7 +89,7 @@ export default function Sidebar({
"moveFile",
{
fileId,
folderId,
folderId
},
(response: (TFolder | TFile)[]) => {
setFiles(response)
@ -105,8 +102,8 @@ export default function Sidebar({
return (
<div className="h-full w-56 select-none flex flex-col text-sm">
<ScrollArea className="flex-grow overflow-auto px-2 pt-0 pb-4 relative">
<div className="flex w-full items-center justify-between h-8 pb-1 isolate z-10 sticky pt-2 top-0 bg-background">
<div className="flex-grow overflow-auto p-2 pb-[84px]">
<div className="flex w-full items-center justify-between h-8 mb-1">
<div className="text-muted-foreground">Explorer</div>
<div className="flex space-x-1">
<button
@ -175,13 +172,14 @@ export default function Sidebar({
stopEditing={() => {
setCreatingNew(null)
}}
addNew={addNew}
/>
) : null}
</>
)}
</div>
</ScrollArea>
<div className="flex flex-col p-2 bg-background">
</div>
<div className="fixed bottom-0 w-48 flex flex-col p-2 bg-background">
<Button
variant="ghost"
className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2"
@ -190,7 +188,7 @@ export default function Sidebar({
style={{ opacity: 1 }}
>
<Sparkles className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
AI Editor
Copilot
<div className="ml-auto">
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
<span className="text-xs"></span>G
@ -199,22 +197,12 @@ export default function Sidebar({
</Button>
<Button
variant="ghost"
className={cn(
"w-full justify-start text-sm font-normal h-8 px-2 mb-2 border-t",
isAIChatOpen
? "bg-muted-foreground/25 text-foreground"
: "text-muted-foreground"
)}
onClick={toggleAIChat}
aria-disabled={false}
className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2"
disabled
aria-disabled="true"
style={{ opacity: 1 }}
>
<MessageSquareMore
className={cn(
"h-4 w-4 mr-2",
isAIChatOpen ? "text-indigo-500" : "text-indigo-500 opacity-70"
)}
/>
<MessageSquareMore className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
AI Chat
<div className="ml-auto">
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">

View File

@ -9,10 +9,12 @@ export default function New({
socket,
type,
stopEditing,
addNew,
}: {
socket: Socket
type: "file" | "folder"
stopEditing: () => void
addNew: (name: string, type: "file" | "folder") => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
@ -23,9 +25,19 @@ export default function New({
const valid = validateName(name, "", type)
if (valid.status) {
if (type === "file") {
socket.emit("createFile", { name })
socket.emit(
"createFile",
{ name },
({ success }: { success: boolean }) => {
if (success) {
addNew(name, type)
}
}
)
} else {
socket.emit("createFolder", { name })
socket.emit("createFolder", { name }, () => {
addNew(name, type)
})
}
}
}

View File

@ -7,7 +7,7 @@ import "./xterm.css"
import { debounce } from "@/lib/utils"
import { Loader2 } from "lucide-react"
import { useTheme } from "next-themes"
import { ElementRef, useEffect, useRef, useCallback } from "react"
import { ElementRef, useEffect, useRef } from "react"
import { Socket } from "socket.io-client"
export default function EditorTerminal({
socket,
@ -22,12 +22,13 @@ export default function EditorTerminal({
setTerm: (term: Terminal) => void
visible: boolean
}) {
const { resolvedTheme: theme } = useTheme()
const { theme } = useTheme()
const terminalContainerRef = useRef<ElementRef<"div">>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
useEffect(() => {
if (!terminalContainerRef.current) return
// console.log("new terminal", id, term ? "reusing" : "creating");
const terminal = new Terminal({
cursorBlink: true,
@ -36,54 +37,13 @@ export default function EditorTerminal({
fontSize: 14,
lineHeight: 1.5,
letterSpacing: 0,
allowTransparency: true,
rightClickSelectsWord: true,
allowProposedApi: true, // for custom key events
})
// right-click paste handler
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault()
navigator.clipboard.readText().then((text) => {
if (text) {
socket.emit("terminalData", { id, data: text })
}
})
}
terminalContainerRef.current.addEventListener(
"contextmenu",
handleContextMenu
)
// keyboard paste handler
terminal.attachCustomKeyEventHandler((event: KeyboardEvent) => {
if (event.type === "keydown") {
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "v"
) {
event.preventDefault()
navigator.clipboard.readText().then((text) => {
if (text) {
socket.emit("terminalData", { id, data: text })
}
})
return false
}
}
return true
})
setTerm(terminal)
return () => {
const dispose = () => {
terminal.dispose()
terminalContainerRef.current?.removeEventListener(
"contextmenu",
handleContextMenu
)
}
return dispose
}, [])
useEffect(() => {
@ -121,6 +81,7 @@ export default function EditorTerminal({
const { width, height } = entry.contentRect
// Only call fit if the size has actually changed
if (
width !== terminalContainerRef.current.offsetWidth ||
height !== terminalContainerRef.current.offsetHeight
@ -131,9 +92,10 @@ export default function EditorTerminal({
console.error("Error during fit:", err)
}
}
}, 50)
}, 50) // Debounce for 50ms
)
// start observing for resize
resizeObserver.observe(terminalContainerRef.current)
return () => {
disposableOnData.dispose()
@ -162,7 +124,6 @@ export default function EditorTerminal({
ref={terminalContainerRef}
style={{ display: visible ? "block" : "none" }}
className="w-full h-full text-left"
tabIndex={0}
>
{term === null ? (
<div className="flex items-center text-muted-foreground p-2">

View File

@ -22,7 +22,7 @@ export default function Landing() {
</div>
<div className="flex items-center space-x-4">
<Button variant="outline" size="icon" asChild>
<a href="https://x.com/gitwitdev" target="_blank">
<a href="https://www.x.com/ishaandey_" target="_blank">
<svg
width="1200"
height="1227"
@ -45,21 +45,16 @@ export default function Landing() {
<h1 className="text-2xl font-medium text-center mt-16">
A Collaborative + AI-Powered Code Environment
</h1>
{/* <p className="text-muted-foreground mt-4 text-center ">
<p className="text-muted-foreground mt-4 text-center ">
Sandbox is an open-source cloud-based code editing environment with
custom AI code autocompletion and real-time collaboration.
</p> */}
<p className="text-muted-foreground mt-4 text-center ">
A cloud-based code editor featuring real-time collaboration,
intelligent code autocompletion, and an AI assistant to help you code
faster and smarter.
</p>
<div className="mt-8 flex space-x-4">
<Link href="/sign-up">
<CustomButton>Go To App</CustomButton>
</Link>
<a
href="https://github.com/jamesmurdza/sandbox"
href="https://github.com/ishaan1013/sandbox"
target="_blank"
className="group h-9 px-4 py-2 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>

View File

@ -1,749 +0,0 @@
"use client"
import NewProjectModal from "@/components/dashboard/newProject"
import ProjectCard from "@/components/dashboard/projectCard/"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "@/components/ui/card"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { deleteSandbox, updateSandbox, updateUser } from "@/lib/actions"
import { socialIcons } from "@/lib/data"
import { editUserSchema, EditUserSchema } from "@/lib/schema"
import { TIERS } from "@/lib/tiers"
import { SandboxWithLiked, User, UserLink } from "@/lib/types"
import { cn, parseSocialLink } from "@/lib/utils"
import { useUser } from "@clerk/nextjs"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Edit,
Globe,
Heart,
Info,
Loader2,
LucideIcon,
Package2,
PlusCircle,
Sparkles,
Trash2,
X,
} from "lucide-react"
import { useRouter } from "next/navigation"
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useTransition,
} from "react"
import { useFormState, useFormStatus } from "react-dom"
import { useFieldArray, useForm } from "react-hook-form"
import { toast } from "sonner"
import Avatar from "../ui/avatar"
import { Badge } from "../ui/badge"
import { Input } from "../ui/input"
import { Progress } from "../ui/progress"
import { Textarea } from "../ui/textarea"
// #region Profile Page
export default function ProfilePage({
publicSandboxes,
privateSandboxes,
profileOwner,
loggedInUser,
}: {
publicSandboxes: SandboxWithLiked[]
privateSandboxes: SandboxWithLiked[]
profileOwner: User
loggedInUser: User | null
}) {
const isOwnProfile = profileOwner.id === loggedInUser?.id
const sandboxes = useMemo(() => {
const allSandboxes = isOwnProfile
? [...publicSandboxes, ...privateSandboxes]
: publicSandboxes
return allSandboxes
}, [isOwnProfile, publicSandboxes, privateSandboxes])
return (
<>
<div className="container mx-auto p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-1">
<ProfileCard
name={profileOwner.name}
username={profileOwner.username}
avatarUrl={profileOwner.avatarUrl}
sandboxes={sandboxes}
joinedDate={profileOwner.createdAt}
generations={isOwnProfile ? loggedInUser.generations : undefined}
isOwnProfile={isOwnProfile}
tier={profileOwner.tier}
bio={profileOwner.bio}
personalWebsite={profileOwner.personalWebsite}
socialLinks={profileOwner.links}
/>
</div>
<div className="md:col-span-2">
<SandboxesPanel
{...{
publicSandboxes,
privateSandboxes,
isOwnProfile,
}}
/>
</div>
</div>
</>
)
}
// #endregion
// #region Profile Card
function ProfileCard({
name,
username,
avatarUrl,
sandboxes,
joinedDate,
generations,
isOwnProfile,
bio,
personalWebsite,
socialLinks = [],
tier,
}: {
name: string
username: string
avatarUrl: string | null
bio: string | null
personalWebsite: string | null
socialLinks: UserLink[]
sandboxes: SandboxWithLiked[]
joinedDate: Date
generations?: number
isOwnProfile: boolean
tier: string
}) {
const { user } = useUser()
const [isEditing, setIsEditing] = useState(false)
const joinedAt = useMemo(() => {
const date = new Date(joinedDate).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
})
return `Joined ${date}`
}, [joinedDate])
const toggleEdit = useCallback(() => {
setIsEditing((s) => !s)
}, [])
const stats = useMemo(() => {
const totalSandboxes = sandboxes.length
const totalLikes = sandboxes.reduce(
(sum, sandbox) => sum + sandbox.likeCount,
0
)
return {
sandboxes:
totalSandboxes === 1 ? "1 sandbox" : `${totalSandboxes} sandboxes`,
likes: totalLikes === 1 ? "1 like" : `${totalLikes} likes`,
}
}, [sandboxes])
const showAddMoreInfoBanner = useMemo(() => {
return !bio && !personalWebsite && (socialLinks?.length ?? 0) === 0
}, [personalWebsite, bio, socialLinks])
return (
<Card className="mb-6 md:mb-0 sticky top-6">
{isOwnProfile && (
<div className="absolute top-2 right-2 flex flex-col gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={toggleEdit}
aria-label={isEditing ? "close edit form" : "open edit form"}
size="smIcon"
variant="secondary"
className="rounded-full relative"
>
{isEditing ? (
<X className="size-4" />
) : showAddMoreInfoBanner ? (
<>
<Sparkles className="size-4 text-yellow-400 z-[2]" />
<div className="z-[1] absolute inset-0 rounded-full bg-secondary animate-ping" />
</>
) : (
<Edit className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{showAddMoreInfoBanner
? "Add more information to your profile"
: "Edit your profile"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<CardContent className="flex flex-col gap-4 pt-6">
{isEditing ? (
<div className="flex flex-col gap-2 items-center ">
<Avatar name={name} avatarUrl={avatarUrl} className="size-36" />
<EditProfileForm
{...{
name,
username,
avatarUrl,
bio,
personalWebsite,
socialLinks,
toggleEdit,
}}
/>
</div>
) : (
<>
<div className="flex flex-col gap-2 items-center">
<Avatar name={name} avatarUrl={avatarUrl} className="size-36" />
<div className="space-y-1">
<CardTitle className="text-2xl text-center">{name}</CardTitle>
<CardDescription className="text-center">{`@${username}`}</CardDescription>
</div>
{bio && <p className="text-sm text-center">{bio}</p>}
{(socialLinks.length > 0 || personalWebsite) && (
<div className="flex gap-2 justify-center">
{personalWebsite && (
<Button variant="secondary" size="smIcon" asChild>
<a
href={personalWebsite}
target="_blank"
rel="noopener noreferrer"
>
<Globe className="size-4" />
<span className="sr-only">Personal Website</span>
</a>
</Button>
)}
{socialLinks.map((link, index) => {
const Icon = socialIcons[link.platform]
return (
<Button
key={index}
variant="secondary"
size="smIcon"
asChild
>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
<Icon className="size-4" />
<span className="sr-only">{link.platform}</span>
</a>
</Button>
)
})}
</div>
)}
</div>
<div className="flex flex-col gap-1 items-center">
{typeof generations === "number" && (
<div className="flex justify-center">
<SubscriptionBadge
generations={generations}
tier={tier as keyof typeof TIERS}
/>
</div>
)}
<div className="flex gap-4">
<StatsItem icon={Package2} label={stats.sandboxes} />
<StatsItem icon={Heart} label={stats.likes} />
</div>
</div>
<p className="text-xs mt-2 text-muted-foreground text-center">
{joinedAt}
</p>
</>
)}
</CardContent>
</Card>
)
}
function EditProfileForm(props: {
name: string
username: string
avatarUrl: string | null
bio: string | null
personalWebsite: string | null
socialLinks: UserLink[]
toggleEdit: () => void
}) {
const router = useRouter()
const { user } = useUser()
const formRef = useRef<HTMLFormElement>(null)
const [formState, formAction] = useFormState(updateUser, {
message: "",
})
const [isPending, startTransition] = useTransition()
const { name, username, bio, personalWebsite, socialLinks, toggleEdit } =
props
const form = useForm<EditUserSchema>({
resolver: zodResolver(editUserSchema),
defaultValues: {
oldUsername: username,
id: user?.id,
name,
username,
bio: bio ?? "",
personalWebsite: personalWebsite ?? "",
links:
socialLinks.length > 0
? socialLinks
: [{ url: "", platform: "generic" }],
...(formState.fields ?? {}),
},
})
const { fields, append, remove } = useFieldArray({
name: "links",
control: form.control,
})
useEffect(() => {
const message = formState.message
if (!Boolean(message)) return
if ("error" in formState) {
toast.error(formState.message)
return
}
toast.success(formState.message as String)
toggleEdit()
if (formState?.newRoute) {
router.replace(formState.newRoute)
}
}, [formState])
return (
<Form {...form}>
<form
ref={formRef}
action={formAction}
onSubmit={(evt) => {
evt.preventDefault()
form.handleSubmit(() => {
startTransition(() => {
formAction(new FormData(formRef.current!))
})
})(evt)
}}
className="space-y-3 w-full"
>
<input type="hidden" name="id" value={user?.id} />
<input type="hidden" name="oldUsername" value={username} />
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="marie doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>User name</FormLabel>
<FormControl>
<div className="relative">
<Input
className="peer ps-6"
type="text"
placeholder="Username"
{...field}
/>
<span className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-2 text-sm text-muted-foreground peer-disabled:opacity-50">
@
</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="hi, I love building things!"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="personalWebsite"
render={({ field }) => (
<FormItem>
<FormLabel>Personal Website</FormLabel>
<FormControl>
<Input placeholder="https://chillguy.dev" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
{fields.map((field, index) => (
<FormField
control={form.control}
key={field.id}
name={`links.${index}`}
render={({ field: { onChange, value, ...field } }) => {
const Icon = socialIcons[value.platform] ?? socialIcons.generic
return (
<FormItem>
<FormLabel className={cn(index !== 0 && "sr-only")}>
Social Links
</FormLabel>
<FormDescription className={cn(index !== 0 && "sr-only")}>
Add links to your blogs or social media profiles.
</FormDescription>
<FormControl>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
{...field}
className="peer ps-9"
value={value.url}
onChange={(e) =>
onChange(parseSocialLink(e.currentTarget.value))
}
/>
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50">
<Icon
size={16}
strokeWidth={2}
aria-hidden="true"
/>
</div>
</div>
<Button
size="smIcon"
type="button"
variant="secondary"
onClick={() => remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => append({ url: "", platform: "generic" })}
>
Add URL
</Button>
</div>
<SubmitButton {...{ isPending }} />
</form>
</Form>
)
}
function SubmitButton({ isPending }: { isPending: boolean }) {
const formStatus = useFormStatus()
const { pending } = formStatus
const pend = pending || isPending
return (
<Button size="sm" type="submit" className="w-full mt-2" disabled={pend}>
{pend && <Loader2 className="animate-spin mr-2 h-4 w-4" />}
Save Changes
</Button>
)
}
// #endregion
// #region Sandboxes Panel
function SandboxesPanel({
publicSandboxes,
privateSandboxes,
isOwnProfile,
}: {
publicSandboxes: SandboxWithLiked[]
privateSandboxes: SandboxWithLiked[]
isOwnProfile: boolean
}) {
const [deletingId, setDeletingId] = useState<string>("")
const hasPublicSandboxes = publicSandboxes.length > 0
const hasPrivateSandboxes = privateSandboxes.length > 0
const onVisibilityChange = useMemo(
() =>
async (sandbox: Pick<SandboxWithLiked, "id" | "name" | "visibility">) => {
const newVisibility =
sandbox.visibility === "public" ? "private" : "public"
toast(`Project ${sandbox.name} is now ${newVisibility}.`)
await updateSandbox({
id: sandbox.id,
visibility: newVisibility,
})
},
[]
)
const onDelete = useMemo(
() => async (sandbox: Pick<SandboxWithLiked, "id" | "name">) => {
setDeletingId(sandbox.id)
toast(`Project ${sandbox.name} deleted.`)
await deleteSandbox(sandbox.id)
setDeletingId("")
},
[]
)
if (!isOwnProfile) {
return (
<div className="">
{hasPublicSandboxes ? (
<>
<h2 className="font-semibold text-xl mb-4">Sandboxes</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{publicSandboxes.map((sandbox) => {
return (
<Fragment key={sandbox.id}>
{isOwnProfile ? (
<ProjectCard
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
deletingId={deletingId}
isAuthenticated
{...sandbox}
/>
) : (
<ProjectCard isAuthenticated={false} {...sandbox} />
)}
</Fragment>
)
})}
</div>
</>
) : (
<EmptyState type="private" isOwnProfile={isOwnProfile} />
)}
</div>
)
}
return (
<Tabs defaultValue="public">
<TabsList className="mb-4">
<TabsTrigger value="public">Public</TabsTrigger>
<TabsTrigger value="private">Private</TabsTrigger>
</TabsList>
<TabsContent value="public">
{hasPublicSandboxes ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{publicSandboxes.map((sandbox) => {
return (
<Fragment key={sandbox.id}>
{isOwnProfile ? (
<ProjectCard
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
deletingId={deletingId}
isAuthenticated
{...sandbox}
/>
) : (
<ProjectCard isAuthenticated={false} {...sandbox} />
)}
</Fragment>
)
})}
</div>
) : (
<EmptyState type="public" isOwnProfile={isOwnProfile} />
)}
</TabsContent>
<TabsContent value="private">
{hasPrivateSandboxes ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{privateSandboxes.map((sandbox) => (
<ProjectCard
key={sandbox.id}
onVisibilityChange={onVisibilityChange}
onDelete={onDelete}
deletingId={deletingId}
isAuthenticated
{...sandbox}
/>
))}
</div>
) : (
<EmptyState type="private" isOwnProfile={isOwnProfile} />
)}
</TabsContent>
</Tabs>
)
}
// #endregion
// #region Empty State
function EmptyState({
type,
isOwnProfile,
}: {
type: "public" | "private"
isOwnProfile: boolean
}) {
const [newProjectModalOpen, setNewProjectModalOpen] = useState(false)
const text = useMemo(() => {
let title: string
let description: string
switch (type) {
case "public":
title = "No public sandboxes yet"
description = isOwnProfile
? "Create your first public sandbox to share your work with the world!"
: "user has no public sandboxes"
case "private":
title = "No private sandboxes yet"
description = isOwnProfile
? "Create your first private sandbox to start working on your personal projects!"
: "user has no private sandboxes"
}
return {
title,
description,
}
}, [type, isOwnProfile])
const openModal = useCallback(() => setNewProjectModalOpen(true), [])
return (
<>
<Card className="flex flex-col items-center justify-center p-6 text-center h-[300px]">
<PlusCircle className="h-12 w-12 text-muted-foreground mb-4" />
<CardTitle className="text-xl mb-2">{text.title}</CardTitle>
<CardDescription className="mb-4">{text.description}</CardDescription>
{isOwnProfile && (
<Button onClick={openModal}>
<PlusCircle className="h-4 w-4 mr-2" />
Create Sandbox
</Button>
)}
</Card>
<NewProjectModal
open={newProjectModalOpen}
setOpen={setNewProjectModalOpen}
/>
</>
)
}
// #endregion
// #region StatsItem
interface StatsItemProps {
icon: LucideIcon
label: string
}
const StatsItem = ({ icon: Icon, label }: StatsItemProps) => (
<div className="flex items-center gap-2">
<Icon size={16} />
<span className="text-sm text-muted-foreground">{label}</span>
</div>
)
// #endregion
// #region Sub Badge
const SubscriptionBadge = ({
generations,
tier = "FREE",
}: {
generations: number
tier?: keyof typeof TIERS
}) => {
return (
<div className="flex gap-2 items-center">
<Badge variant="secondary" className="text-sm cursor-pointer">
{tier}
</Badge>
<HoverCard>
<HoverCardTrigger>
<Button variant="ghost" size="smIcon" className="size-[26px]">
<Info size={16} />
</Button>
</HoverCardTrigger>
<HoverCardContent>
<div className="w-full space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium">AI Generations</span>
<span>{`${generations} / ${TIERS[tier].generations}`}</span>
</div>
<Progress
value={generations}
max={TIERS[tier].generations}
className="w-full"
/>
</div>
<Button size="sm" className="w-full mt-4">
<Sparkles className="mr-2 h-4 w-4" /> Upgrade to Pro
</Button>
</HoverCardContent>
</HoverCard>
</div>
)
}
// #endregion

View File

@ -1,38 +0,0 @@
import Logo from "@/assets/logo.svg"
import { ThemeSwitcher } from "@/components/ui/theme-switcher"
import UserButton from "@/components/ui/userButton"
import { User } from "@/lib/types"
import Image from "next/image"
import Link from "next/link"
import { Button } from "../ui/button"
export default function ProfileNavbar({ userData }: { userData: User }) {
return (
<nav className=" py-2 px-4 w-full flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2">
<Link
href="/"
className="ring-offset-2 ring-offset-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none rounded-sm"
>
<Image src={Logo} alt="Logo" width={36} height={36} />
</Link>
<h1 className="text-xl">
<span className="font-semibold">Sandbox</span>{" "}
<span className="text-xs font-medium text-muted-foreground">
by gitwit
</span>
</h1>
</div>
<div className="flex items-center space-x-4">
<ThemeSwitcher />
{Boolean(userData?.id) ? (
<UserButton userData={userData} />
) : (
<Link href="/sign-in">
<Button>Login</Button>
</Link>
)}
</div>
</nav>
)
}

View File

@ -1,59 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertDescription, AlertTitle }

View File

@ -2,40 +2,22 @@ import { cn } from "@/lib/utils"
export default function Avatar({
name,
avatarUrl,
className,
}: {
name: string
avatarUrl?: string | null
className?: string
}) {
// Generate initials from name if no avatarUrl is provided
const initials = name
? name
.split(" ")
.slice(0, 2)
.map((letter) => letter[0].toUpperCase())
.join("")
: "?"
return (
<div
className={cn(
"size-9 font-mono rounded-full overflow-hidden bg-gradient-to-t from-neutral-800 to-neutral-600 flex items-center justify-center text-sm font-medium",
className
className,
"w-5 h-5 font-mono rounded-full overflow-hidden bg-gradient-to-t from-neutral-800 to-neutral-600 flex items-center justify-center text-[0.5rem] font-medium"
)}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt={name || "User"}
width={20}
height={20}
className="w-full h-full object-cover"
/>
) : (
initials
)}
{name
.split(" ")
.slice(0, 2)
.map((letter) => letter[0].toUpperCase())}
</div>
)
}

View File

@ -1,36 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -5,7 +5,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition active:scale-[0.99] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@ -26,7 +26,7 @@ const buttonVariants = cva(
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
smIcon: "size-8",
smIcon: "h-8 w-8",
},
},
defaultVariants: {

View File

@ -1,8 +1,6 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import * as React from "react"
import {
Controller,
ControllerProps,
@ -12,8 +10,8 @@ import {
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
const Form = FormProvider
@ -95,7 +93,7 @@ const FormLabel = React.forwardRef<
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
className={cn(className)}
htmlFor={formItemId}
{...props}
/>
@ -167,12 +165,12 @@ const FormMessage = React.forwardRef<
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
}

View File

@ -1,29 +0,0 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

Some files were not shown because too many files have changed in this diff Show More