Compare commits
28 Commits
main
...
refactor-s
Author | SHA1 | Date | |
---|---|---|---|
|
6b2b870020 | ||
|
90ea90f610 | ||
|
eb973e0f83 | ||
|
6613291977 | ||
|
836dd51ccc | ||
|
701c4fcf84 | ||
|
8381455f4d | ||
|
486791f53e | ||
|
21026a3c53 | ||
|
f83dcfcf8f | ||
|
250296f0e9 | ||
|
2eb2388e12 | ||
|
a6f457ef59 | ||
|
15fbd4ce41 | ||
|
d6d9448aa4 | ||
|
d3e987b0ab | ||
|
3bc555ca47 | ||
|
6f8bebe7dd | ||
|
0fe652d873 | ||
|
f1c1f50abf | ||
|
ca8c7ae0aa | ||
|
f6cded11f4 | ||
|
b1ada9e204 | ||
|
13c3670e4d | ||
|
e439816671 | ||
|
ef018385ef | ||
|
cec6b0c8c5 | ||
|
6ec17fad7e |
1
.gitignore
vendored
1
.gitignore
vendored
@ -40,6 +40,7 @@ wrangler.toml
|
|||||||
|
|
||||||
dist
|
dist
|
||||||
backend/server/projects
|
backend/server/projects
|
||||||
|
backend/database/drizzle
|
||||||
|
|
||||||
app.yaml
|
app.yaml
|
||||||
ingressController.yaml
|
ingressController.yaml
|
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"insertFinalNewline": true,
|
"insertFinalNewline": true
|
||||||
"useTabs": false
|
|
||||||
}
|
}
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["esbenp.prettier-vscode"]
|
|
||||||
}
|
|
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -4,10 +4,5 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": "explicit",
|
"source.fixAll": "explicit",
|
||||||
"source.organizeImports": "explicit"
|
"source.organizeImports": "explicit"
|
||||||
},
|
}
|
||||||
"tailwindCSS.experimental.classRegex": [
|
|
||||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
|
||||||
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
|
||||||
],
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
}
|
}
|
||||||
|
110
README.md
110
README.md
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
![2024-10-2307 17 42-ezgif com-resize](https://github.com/user-attachments/assets/a4057129-81a7-4a31-a093-c8bc8189ae72)
|
![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/).
|
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:
|
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
|
### 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:
|
Needed accounts to set up:
|
||||||
|
|
||||||
@ -22,7 +23,6 @@ Needed accounts to set up:
|
|||||||
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
|
- [Liveblocks](https://liveblocks.io/): Used for collaborative editing.
|
||||||
- [E2B](https://e2b.dev/): Used for the terminals and live preview.
|
- [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).
|
- [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.
|
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/database
|
||||||
/backend/storage
|
/backend/storage
|
||||||
/backend/server
|
/backend/server
|
||||||
|
/backend/ai
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Adding Clerk
|
### 2. Adding Clerk
|
||||||
@ -78,8 +79,6 @@ npx wrangler deploy
|
|||||||
|
|
||||||
### 4. Deploying the database
|
### 4. Deploying the database
|
||||||
|
|
||||||
Follow this [guide](https://docs.google.com/document/d/1w5dA5daic_sIYB5Seni1KvnFx51pPV2so6lLdN2xa7Q/edit?usp=sharing) for more info.
|
|
||||||
|
|
||||||
Create a database:
|
Create a database:
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -145,7 +144,21 @@ Update `/backend/server/.env`:
|
|||||||
E2B_API_KEY='🔑'
|
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`:
|
Update `/frontend/.env`:
|
||||||
|
|
||||||
@ -153,11 +166,9 @@ Update `/frontend/.env`:
|
|||||||
NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
|
NEXT_PUBLIC_DATABASE_WORKER_URL='https://database.🍎.workers.dev'
|
||||||
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
|
NEXT_PUBLIC_STORAGE_WORKER_URL='https://storage.🍎.workers.dev'
|
||||||
NEXT_PUBLIC_WORKERS_KEY='SUPERDUPERSECRET'
|
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:
|
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
|
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:
|
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
|
```bash
|
||||||
@ -193,57 +195,15 @@ DOKKU_USERNAME=
|
|||||||
DOKKU_KEY=
|
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
|
## 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)
|
Please reach out to us [on Discord](https://discord.gitwit.dev/) if you're interested in contributing.
|
||||||
- [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/)
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@ -262,15 +222,17 @@ backend/
|
|||||||
├── database/
|
├── database/
|
||||||
│ ├── src
|
│ ├── src
|
||||||
│ └── drizzle
|
│ └── drizzle
|
||||||
└── storage
|
├── storage
|
||||||
|
└── ai
|
||||||
```
|
```
|
||||||
|
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
| ------------------ | -------------------------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------------------ |
|
||||||
| `frontend` | The Next.js application for the frontend. |
|
| `frontend` | The Next.js application for the frontend. |
|
||||||
| `backend/server` | The Express websocket server. |
|
| `backend/server` | The Express websocket server. |
|
||||||
| `backend/database` | API for interfacing with the D1 database (SQLite). |
|
| `backend/database` | API for interfacing with the D1 database (SQLite). |
|
||||||
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
|
| `backend/storage` | API for interfacing with R2 storage. Service-bound to `/backend/database`. |
|
||||||
|
| `backend/ai` | API for making requests to Workers AI . |
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
@ -291,10 +253,6 @@ cd sandbox
|
|||||||
git checkout -b my-new-branch
|
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
|
### 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/)
|
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
12
backend/ai/.editorconfig
Normal 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
172
backend/ai/.gitignore
vendored
Normal 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
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
22
backend/ai/package.json
Normal 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
128
backend/ai/src/index.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
30
backend/ai/test/index.spec.ts
Normal file
30
backend/ai/test/index.spec.ts
Normal 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!"`)
|
||||||
|
})
|
||||||
|
})
|
11
backend/ai/test/tsconfig.json
Normal file
11
backend/ai/test/tsconfig.json
Normal 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
106
backend/ai/tsconfig.json
Normal 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"]
|
||||||
|
}
|
11
backend/ai/vitest.config.ts
Normal file
11
backend/ai/vitest.config.ts
Normal 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
3
backend/ai/worker-configuration.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// Generated by Wrangler
|
||||||
|
// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
|
||||||
|
interface Env {}
|
10
backend/ai/wrangler.example.toml
Normal file
10
backend/ai/wrangler.example.toml
Normal 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 = ""
|
@ -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`);
|
|
@ -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 '[]';
|
|
@ -1 +0,0 @@
|
|||||||
ALTER TABLE sandbox ADD `containerId` text;
|
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "4ada398d-7e4e-448f-8cea-a10b4d844600",
|
"id": "6570ba20-a672-400c-8147-7ba533784918",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
@ -35,36 +35,12 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
|
||||||
"name": "createdAt",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "CURRENT_TIMESTAMP"
|
|
||||||
},
|
|
||||||
"user_id": {
|
"user_id": {
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"indexes": {
|
||||||
@ -94,72 +70,6 @@
|
|||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {}
|
"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": {
|
"user": {
|
||||||
"name": "user",
|
"name": "user",
|
||||||
"columns": {
|
"columns": {
|
||||||
@ -183,65 +93,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"indexes": {
|
||||||
@ -251,13 +102,6 @@
|
|||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
},
|
|
||||||
"user_username_unique": {
|
|
||||||
"name": "user_username_unique",
|
|
||||||
"columns": [
|
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
@ -280,13 +124,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
|
||||||
"sharedOn": {
|
|
||||||
"name": "sharedOn",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
|
@ -1,353 +1,175 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "80c0b0b2-bb0e-449a-b447-c21863686f58",
|
"id": "9f64104a-4954-40c0-8155-17755ea0a243",
|
||||||
"prevId": "4ada398d-7e4e-448f-8cea-a10b4d844600",
|
"prevId": "6570ba20-a672-400c-8147-7ba533784918",
|
||||||
"tables": {
|
"tables": {
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
"name": "sandbox",
|
"name": "sandbox",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": true,
|
"primaryKey": true,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"indexes": {
|
"name": {
|
||||||
"sandbox_id_unique": {
|
"name": "name",
|
||||||
"name": "sandbox_id_unique",
|
"type": "text",
|
||||||
"columns": [
|
"primaryKey": false,
|
||||||
"id"
|
"notNull": true,
|
||||||
],
|
"autoincrement": false
|
||||||
"isUnique": true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"type": {
|
||||||
"sandbox_user_id_user_id_fk": {
|
"name": "type",
|
||||||
"name": "sandbox_user_id_user_id_fk",
|
"type": "text",
|
||||||
"tableFrom": "sandbox",
|
"primaryKey": false,
|
||||||
"tableTo": "user",
|
"notNull": true,
|
||||||
"columnsFrom": [
|
"autoincrement": false
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
"visibility": {
|
||||||
"uniqueConstraints": {}
|
"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": {
|
"indexes": {
|
||||||
"name": "sandbox_likes",
|
"sandbox_id_unique": {
|
||||||
"columns": {
|
"name": "sandbox_id_unique",
|
||||||
"user_id": {
|
"columns": [
|
||||||
"name": "user_id",
|
"id"
|
||||||
"type": "text",
|
],
|
||||||
"primaryKey": false,
|
"isUnique": true
|
||||||
"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": {
|
"foreignKeys": {
|
||||||
"name": "user",
|
"sandbox_user_id_user_id_fk": {
|
||||||
"columns": {
|
"name": "sandbox_user_id_user_id_fk",
|
||||||
"id": {
|
"tableFrom": "sandbox",
|
||||||
"name": "id",
|
"tableTo": "user",
|
||||||
"type": "text",
|
"columnsFrom": [
|
||||||
"primaryKey": true,
|
"user_id"
|
||||||
"notNull": true,
|
],
|
||||||
"autoincrement": false
|
"columnsTo": [
|
||||||
},
|
"id"
|
||||||
"name": {
|
],
|
||||||
"name": "name",
|
"onDelete": "no action",
|
||||||
"type": "text",
|
"onUpdate": "no action"
|
||||||
"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": {}
|
|
||||||
},
|
},
|
||||||
"users_to_sandboxes": {
|
"compositePrimaryKeys": {},
|
||||||
"name": "users_to_sandboxes",
|
"uniqueConstraints": {}
|
||||||
"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": {}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"enums": {},
|
"user": {
|
||||||
"_meta": {
|
"name": "user",
|
||||||
"schemas": {},
|
"columns": {
|
||||||
"tables": {},
|
"id": {
|
||||||
"columns": {}
|
"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": {}
|
||||||
}
|
}
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "51abcf01-2921-4885-8058-d1ccd576f3e1",
|
"id": "5baf10d6-7697-42ba-a11a-ee4c7bd7e91e",
|
||||||
"prevId": "80c0b0b2-bb0e-449a-b447-c21863686f58",
|
"prevId": "9f64104a-4954-40c0-8155-17755ea0a243",
|
||||||
"tables": {
|
"tables": {
|
||||||
"sandbox": {
|
"sandbox": {
|
||||||
"name": "sandbox",
|
"name": "sandbox",
|
||||||
@ -35,43 +35,12 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
|
||||||
"name": "createdAt",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": "CURRENT_TIMESTAMP"
|
|
||||||
},
|
|
||||||
"user_id": {
|
"user_id": {
|
||||||
"name": "user_id",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"indexes": {
|
||||||
@ -101,72 +70,6 @@
|
|||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {}
|
"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": {
|
"user": {
|
||||||
"name": "user",
|
"name": "user",
|
||||||
"columns": {
|
"columns": {
|
||||||
@ -190,87 +93,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"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": {
|
"indexes": {
|
||||||
@ -280,13 +102,6 @@
|
|||||||
"id"
|
"id"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
},
|
|
||||||
"user_username_unique": {
|
|
||||||
"name": "user_username_unique",
|
|
||||||
"columns": [
|
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
@ -309,13 +124,6 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
|
||||||
"sharedOn": {
|
|
||||||
"name": "sharedOn",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
|
175
backend/database/drizzle/meta/0003_snapshot.json
Normal file
175
backend/database/drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
@ -5,22 +5,50 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1736155854410,
|
"when": 1714540200800,
|
||||||
"tag": "0000_sudden_wallop",
|
"tag": "0000_big_rogue",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1736169498666,
|
"when": 1714541190588,
|
||||||
"tag": "0001_dusty_komodo",
|
"tag": "0001_empty_black_knight",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 2,
|
"idx": 2,
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"when": 1736768910615,
|
"when": 1714541209173,
|
||||||
"tag": "0002_chemical_brother_voodoo",
|
"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
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
11756
backend/database/package-lock.json
generated
11756
backend/database/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,7 @@
|
|||||||
"drizzle-kit": "^0.20.17",
|
"drizzle-kit": "^0.20.17",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4",
|
||||||
"vitest": "1.3.0",
|
"vitest": "1.3.0",
|
||||||
"wrangler": "^3.101.0"
|
"wrangler": "^3.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
|
@ -5,599 +5,304 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { and, eq, sql } from "drizzle-orm"
|
import { and, eq, sql } from "drizzle-orm"
|
||||||
import * as schema from "./schema"
|
import * as schema from "./schema"
|
||||||
import { Sandbox, sandbox, sandboxLikes, user, usersToSandboxes } from "./schema"
|
import { sandbox, user, usersToSandboxes } from "./schema"
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
DB: D1Database
|
DB: D1Database
|
||||||
STORAGE: any
|
STORAGE: any
|
||||||
KEY: string
|
KEY: string
|
||||||
STORAGE_WORKER_URL: string
|
STORAGE_WORKER_URL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
|
// https://github.com/drizzle-team/drizzle-orm/tree/main/examples/cloudflare-d1
|
||||||
|
|
||||||
// npm run generate
|
// npm run generate
|
||||||
// npx wrangler d1 execute d1-sandbox --local --file=./drizzle/<FILE>
|
// 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 {
|
export default {
|
||||||
async fetch(
|
async fetch(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
ctx: ExecutionContext
|
ctx: ExecutionContext
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const success = new Response("Success", { status: 200 })
|
const success = new Response("Success", { status: 200 })
|
||||||
const invalidRequest = new Response("Invalid Request", { status: 400 })
|
const invalidRequest = new Response("Invalid Request", { status: 400 })
|
||||||
const notFound = new Response("Not Found", { status: 404 })
|
const notFound = new Response("Not Found", { status: 404 })
|
||||||
const methodNotAllowed = new Response("Method Not Allowed", { status: 405 })
|
const methodNotAllowed = new Response("Method Not Allowed", { status: 405 })
|
||||||
|
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
const method = request.method
|
const method = request.method
|
||||||
|
|
||||||
if (request.headers.get("Authorization") !== env.KEY) {
|
if (request.headers.get("Authorization") !== env.KEY) {
|
||||||
return new Response("Unauthorized", { status: 401 })
|
return new Response("Unauthorized", { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = drizzle(env.DB, { schema })
|
const db = drizzle(env.DB, { schema })
|
||||||
|
|
||||||
if (path === "/api/sandbox") {
|
if (path === "/api/sandbox") {
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
const res = await db.query.sandbox.findFirst({
|
const res = await db.query.sandbox.findFirst({
|
||||||
where: (sandbox, { eq }) => eq(sandbox.id, id),
|
where: (sandbox, { eq }) => eq(sandbox.id, id),
|
||||||
with: {
|
with: {
|
||||||
usersToSandboxes: true,
|
usersToSandboxes: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return json(res ?? {})
|
return json(res ?? {})
|
||||||
} else {
|
} else {
|
||||||
const res = await db.select().from(sandbox).all()
|
const res = await db.select().from(sandbox).all()
|
||||||
return json(res ?? {})
|
return json(res ?? {})
|
||||||
}
|
}
|
||||||
} else if (method === "DELETE") {
|
} else if (method === "DELETE") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
await db.delete(sandboxLikes).where(eq(sandboxLikes.sandboxId, id))
|
await db
|
||||||
await db
|
.delete(usersToSandboxes)
|
||||||
.delete(usersToSandboxes)
|
.where(eq(usersToSandboxes.sandboxId, id))
|
||||||
.where(eq(usersToSandboxes.sandboxId, id))
|
await db.delete(sandbox).where(eq(sandbox.id, id))
|
||||||
await db.delete(sandbox).where(eq(sandbox.id, id))
|
|
||||||
|
|
||||||
const deleteStorageRequest = new Request(
|
const deleteStorageRequest = new Request(
|
||||||
`${env.STORAGE_WORKER_URL}/api/project`,
|
`${env.STORAGE_WORKER_URL}/api/project`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: JSON.stringify({ sandboxId: id }),
|
body: JSON.stringify({ sandboxId: id }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `${env.KEY}`,
|
Authorization: `${env.KEY}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await env.STORAGE.fetch(deleteStorageRequest)
|
await env.STORAGE.fetch(deleteStorageRequest)
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else {
|
} else {
|
||||||
return invalidRequest
|
return invalidRequest
|
||||||
}
|
}
|
||||||
} else if (method === "POST") {
|
} else if (method === "POST") {
|
||||||
const postSchema = z.object({
|
const postSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
visibility: z.enum(["public", "private"]).optional(),
|
visibility: z.enum(["public", "private"]).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { id, name, visibility } = postSchema.parse(body)
|
const { id, name, visibility } = postSchema.parse(body)
|
||||||
const sb = await db
|
const sb = await db
|
||||||
.update(sandbox)
|
.update(sandbox)
|
||||||
.set({ name, visibility })
|
.set({ name, visibility })
|
||||||
.where(eq(sandbox.id, id))
|
.where(eq(sandbox.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else if (method === "PUT") {
|
} else if (method === "PUT") {
|
||||||
const initSchema = z.object({
|
const initSchema = z.object({
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
visibility: z.enum(["public", "private"]),
|
visibility: z.enum(["public", "private"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { type, name, userId, visibility } = initSchema.parse(body)
|
const { type, name, userId, visibility } = initSchema.parse(body)
|
||||||
|
|
||||||
const userSandboxes = await db
|
const userSandboxes = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sandbox)
|
.from(sandbox)
|
||||||
.where(eq(sandbox.userId, userId))
|
.where(eq(sandbox.userId, userId))
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
if (userSandboxes.length >= 8) {
|
if (userSandboxes.length >= 8) {
|
||||||
return new Response("You reached the maximum # of sandboxes.", {
|
return new Response("You reached the maximum # of sandboxes.", {
|
||||||
status: 400,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const sb = await db
|
const sb = await db
|
||||||
.insert(sandbox)
|
.insert(sandbox)
|
||||||
.values({ type, name, userId, visibility, createdAt: new Date() })
|
.values({ type, name, userId, visibility, createdAt: new Date() })
|
||||||
.returning()
|
.returning()
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
const initStorageRequest = new Request(
|
const initStorageRequest = new Request(
|
||||||
`${env.STORAGE_WORKER_URL}/api/init`,
|
`${env.STORAGE_WORKER_URL}/api/init`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ sandboxId: sb.id, type }),
|
body: JSON.stringify({ sandboxId: sb.id, type }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `${env.KEY}`,
|
Authorization: `${env.KEY}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
await env.STORAGE.fetch(initStorageRequest)
|
await env.STORAGE.fetch(initStorageRequest)
|
||||||
|
|
||||||
return new Response(sb.id, { status: 200 })
|
return new Response(sb.id, { status: 200 })
|
||||||
} else {
|
} else {
|
||||||
return methodNotAllowed
|
return methodNotAllowed
|
||||||
}
|
}
|
||||||
} else if (path === "/api/sandbox/share") {
|
} else if (path === "/api/sandbox/share") {
|
||||||
if (method === "GET") {
|
if (method === "GET") {
|
||||||
const params = url.searchParams
|
const params = url.searchParams
|
||||||
if (params.has("id")) {
|
if (params.has("id")) {
|
||||||
const id = params.get("id") as string
|
const id = params.get("id") as string
|
||||||
const res = await db.query.usersToSandboxes.findMany({
|
const res = await db.query.usersToSandboxes.findMany({
|
||||||
where: (uts, { eq }) => eq(uts.userId, id),
|
where: (uts, { eq }) => eq(uts.userId, id),
|
||||||
})
|
})
|
||||||
|
|
||||||
const owners = await Promise.all(
|
const owners = await Promise.all(
|
||||||
res.map(async (r) => {
|
res.map(async (r) => {
|
||||||
const sb = await db.query.sandbox.findFirst({
|
const sb = await db.query.sandbox.findFirst({
|
||||||
where: (sandbox, { eq }) => eq(sandbox.id, r.sandboxId),
|
where: (sandbox, { eq }) => eq(sandbox.id, r.sandboxId),
|
||||||
with: {
|
with: {
|
||||||
author: true,
|
author: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!sb) return
|
if (!sb) return
|
||||||
return {
|
return {
|
||||||
id: sb.id,
|
id: sb.id,
|
||||||
name: sb.name,
|
name: sb.name,
|
||||||
type: sb.type,
|
type: sb.type,
|
||||||
author: sb.author.name,
|
author: sb.author.name,
|
||||||
authorAvatarUrl: sb.author.avatarUrl,
|
sharedOn: r.sharedOn,
|
||||||
sharedOn: r.sharedOn,
|
}
|
||||||
}
|
})
|
||||||
})
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return json(owners ?? {})
|
return json(owners ?? {})
|
||||||
} else return invalidRequest
|
} else return invalidRequest
|
||||||
} else if (method === "POST") {
|
} else if (method === "POST") {
|
||||||
const shareSchema = z.object({
|
const shareSchema = z.object({
|
||||||
sandboxId: z.string(),
|
sandboxId: z.string(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { sandboxId, email } = shareSchema.parse(body)
|
const { sandboxId, email } = shareSchema.parse(body)
|
||||||
|
|
||||||
const user = await db.query.user.findFirst({
|
const user = await db.query.user.findFirst({
|
||||||
where: (user, { eq }) => eq(user.email, email),
|
where: (user, { eq }) => eq(user.email, email),
|
||||||
with: {
|
with: {
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
usersToSandboxes: true,
|
usersToSandboxes: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response("No user associated with email.", { status: 400 })
|
return new Response("No user associated with email.", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.sandbox.find((sb) => sb.id === sandboxId)) {
|
if (user.sandbox.find((sb) => sb.id === sandboxId)) {
|
||||||
return new Response("Cannot share with yourself!", { status: 400 })
|
return new Response("Cannot share with yourself!", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.usersToSandboxes.find((uts) => uts.sandboxId === sandboxId)) {
|
if (user.usersToSandboxes.find((uts) => uts.sandboxId === sandboxId)) {
|
||||||
return new Response("User already has access.", { status: 400 })
|
return new Response("User already has access.", { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(usersToSandboxes)
|
.insert(usersToSandboxes)
|
||||||
.values({ userId: user.id, sandboxId, sharedOn: new Date() })
|
.values({ userId: user.id, sandboxId, sharedOn: new Date() })
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else if (method === "DELETE") {
|
} else if (method === "DELETE") {
|
||||||
const deleteShareSchema = z.object({
|
const deleteShareSchema = z.object({
|
||||||
sandboxId: z.string(),
|
sandboxId: z.string(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { sandboxId, userId } = deleteShareSchema.parse(body)
|
const { sandboxId, userId } = deleteShareSchema.parse(body)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(usersToSandboxes)
|
.delete(usersToSandboxes)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(usersToSandboxes.userId, userId),
|
eq(usersToSandboxes.userId, userId),
|
||||||
eq(usersToSandboxes.sandboxId, sandboxId)
|
eq(usersToSandboxes.sandboxId, sandboxId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return success
|
return success
|
||||||
} else return methodNotAllowed
|
} else return methodNotAllowed
|
||||||
} else if (path === "/api/sandbox/like") {
|
} else if (path === "/api/sandbox/generate" && method === "POST") {
|
||||||
if (method === "POST") {
|
const generateSchema = z.object({
|
||||||
const likeSchema = z.object({
|
userId: z.string(),
|
||||||
sandboxId: z.string(),
|
})
|
||||||
userId: z.string(),
|
const body = await request.json()
|
||||||
})
|
const { userId } = generateSchema.parse(body)
|
||||||
|
|
||||||
try {
|
const dbUser = await db.query.user.findFirst({
|
||||||
const body = await request.json()
|
where: (user, { eq }) => eq(user.id, userId),
|
||||||
const { sandboxId, userId } = likeSchema.parse(body)
|
})
|
||||||
|
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
|
await db
|
||||||
const existingLike = await db.query.sandboxLikes.findFirst({
|
.update(user)
|
||||||
where: (likes, { and, eq }) =>
|
.set({ generations: sql`${user.generations} + 1` })
|
||||||
and(eq(likes.sandboxId, sandboxId), eq(likes.userId, userId)),
|
.where(eq(user.id, userId))
|
||||||
})
|
.get()
|
||||||
|
|
||||||
if (existingLike) {
|
return success
|
||||||
// Unlike
|
} else if (path === "/api/user") {
|
||||||
await db
|
if (method === "GET") {
|
||||||
.delete(sandboxLikes)
|
const params = url.searchParams
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sandboxLikes.sandboxId, sandboxId),
|
|
||||||
eq(sandboxLikes.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await db
|
if (params.has("id")) {
|
||||||
.update(sandbox)
|
const id = params.get("id") as string
|
||||||
.set({
|
const res = await db.query.user.findFirst({
|
||||||
likeCount: sql`${sandbox.likeCount} - 1`,
|
where: (user, { eq }) => eq(user.id, id),
|
||||||
})
|
with: {
|
||||||
.where(eq(sandbox.id, sandboxId))
|
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({
|
const body = await request.json()
|
||||||
message: "Unlike successful",
|
const { id, name, email } = userSchema.parse(body)
|
||||||
liked: false,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Like
|
|
||||||
await db.insert(sandboxLikes).values({
|
|
||||||
sandboxId,
|
|
||||||
userId,
|
|
||||||
createdAt: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await db
|
const res = await db
|
||||||
.update(sandbox)
|
.insert(user)
|
||||||
.set({
|
.values({ id, name, email })
|
||||||
likeCount: sql`${sandbox.likeCount} + 1`,
|
.returning()
|
||||||
})
|
.get()
|
||||||
.where(eq(sandbox.id, sandboxId))
|
return json({ res })
|
||||||
|
} else if (method === "DELETE") {
|
||||||
return json({
|
const params = url.searchParams
|
||||||
message: "Like successful",
|
if (params.has("id")) {
|
||||||
liked: true,
|
const id = params.get("id") as string
|
||||||
})
|
await db.delete(user).where(eq(user.id, id))
|
||||||
}
|
return success
|
||||||
} catch (error) {
|
} else return invalidRequest
|
||||||
return new Response("Invalid request format", { status: 400 })
|
} else {
|
||||||
}
|
return methodNotAllowed
|
||||||
} else if (method === "GET") {
|
}
|
||||||
const params = url.searchParams
|
} else return notFound
|
||||||
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
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
@ -1,140 +1,69 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { relations, sql } from "drizzle-orm"
|
import { relations } from "drizzle-orm"
|
||||||
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
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", {
|
export const user = sqliteTable("user", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.$defaultFn(() => createId())
|
.$defaultFn(() => createId())
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.unique(),
|
.unique(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
username: text("username").notNull().unique(),
|
image: text("image"),
|
||||||
avatarUrl: text("avatarUrl"),
|
generations: integer("generations").default(0),
|
||||||
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"),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type User = typeof user.$inferSelect
|
export type User = typeof user.$inferSelect
|
||||||
|
|
||||||
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
|
sandbox: many(sandbox),
|
||||||
|
usersToSandboxes: many(usersToSandboxes),
|
||||||
|
}))
|
||||||
|
|
||||||
export const sandbox = sqliteTable("sandbox", {
|
export const sandbox = sqliteTable("sandbox", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.$defaultFn(() => createId())
|
.$defaultFn(() => createId())
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.unique(),
|
.unique(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
visibility: text("visibility", { enum: ["public", "private"] }),
|
visibility: text("visibility", { enum: ["public", "private"] }),
|
||||||
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
|
createdAt: integer("createdAt", { mode: "timestamp_ms" }),
|
||||||
sql`CURRENT_TIMESTAMP`
|
userId: text("user_id")
|
||||||
),
|
.notNull()
|
||||||
userId: text("user_id")
|
.references(() => user.id),
|
||||||
.notNull()
|
|
||||||
.references(() => user.id),
|
|
||||||
likeCount: integer("likeCount").default(0),
|
|
||||||
viewCount: integer("viewCount").default(0),
|
|
||||||
containerId: text("containerId"),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Sandbox = typeof sandbox.$inferSelect
|
export type Sandbox = typeof sandbox.$inferSelect
|
||||||
|
|
||||||
export const sandboxLikes = sqliteTable(
|
export const sandboxRelations = relations(sandbox, ({ one, many }) => ({
|
||||||
"sandbox_likes",
|
author: one(user, {
|
||||||
{
|
fields: [sandbox.userId],
|
||||||
userId: text("user_id")
|
references: [user.id],
|
||||||
.notNull()
|
}),
|
||||||
.references(() => user.id),
|
usersToSandboxes: many(usersToSandboxes),
|
||||||
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 usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
export const usersToSandboxes = sqliteTable("users_to_sandboxes", {
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id),
|
.references(() => user.id),
|
||||||
sandboxId: text("sandboxId")
|
sandboxId: text("sandboxId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sandbox.id),
|
.references(() => sandbox.id),
|
||||||
sharedOn: integer("sharedOn", { mode: "timestamp_ms" }),
|
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(
|
export const usersToSandboxesRelations = relations(
|
||||||
usersToSandboxes,
|
usersToSandboxes,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
group: one(sandbox, {
|
group: one(sandbox, {
|
||||||
fields: [usersToSandboxes.sandboxId],
|
fields: [usersToSandboxes.sandboxId],
|
||||||
references: [sandbox.id],
|
references: [sandbox.id],
|
||||||
}),
|
}),
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [usersToSandboxes.userId],
|
fields: [usersToSandboxes.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// #endregion
|
|
||||||
|
585
backend/server/package-lock.json
generated
585
backend/server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,9 +14,8 @@
|
|||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"e2b": "^1.0.5",
|
"e2b": "^0.16.2-beta.47",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"jzip": "^1.0.0",
|
|
||||||
"rate-limiter-flexible": "^5.0.3",
|
"rate-limiter-flexible": "^5.0.3",
|
||||||
"simple-git": "^3.25.0",
|
"simple-git": "^3.25.0",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
@ -26,7 +25,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jszip": "^3.4.1",
|
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
"@types/ssh2": "^1.15.0",
|
"@types/ssh2": "^1.15.0",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
|
90
backend/server/src/AIWorker.ts
Normal file
90
backend/server/src/AIWorker.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,61 +1,58 @@
|
|||||||
import { Socket } from "socket.io"
|
import { Socket } from "socket.io"
|
||||||
|
|
||||||
class Counter {
|
class Counter {
|
||||||
private count: number = 0
|
private count: number = 0
|
||||||
|
|
||||||
increment() {
|
increment() {
|
||||||
this.count++
|
this.count++
|
||||||
}
|
}
|
||||||
|
|
||||||
decrement() {
|
decrement() {
|
||||||
this.count = Math.max(0, this.count - 1)
|
this.count = Math.max(0, this.count - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
getValue(): number {
|
getValue(): number {
|
||||||
return this.count
|
return this.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner Connection Management
|
// Owner Connection Management
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
// Counts how many times the owner is connected to a sandbox
|
// Counts how many times the owner is connected to a sandbox
|
||||||
private ownerConnections: Record<string, Counter> = {}
|
private ownerConnections: Record<string, Counter> = {}
|
||||||
// Stores all sockets connected to a given sandbox
|
// Stores all sockets connected to a given sandbox
|
||||||
private sockets: Record<string, Set<Socket>> = {}
|
private sockets: Record<string, Set<Socket>> = {}
|
||||||
|
|
||||||
// Checks if the owner of a sandbox is connected
|
// Checks if the owner of a sandbox is connected
|
||||||
ownerIsConnected(sandboxId: string): boolean {
|
ownerIsConnected(sandboxId: string): boolean {
|
||||||
return this.ownerConnections[sandboxId]?.getValue() > 0
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Removes a connection for a sandbox
|
// Adds a connection for a sandbox
|
||||||
removeConnectionForSandbox(
|
addConnectionForSandbox(socket: Socket, sandboxId: string, isOwner: boolean) {
|
||||||
socket: Socket,
|
this.sockets[sandboxId] ??= new Set()
|
||||||
sandboxId: string,
|
this.sockets[sandboxId].add(socket)
|
||||||
isOwner: boolean
|
|
||||||
) {
|
|
||||||
this.sockets[sandboxId]?.delete(socket)
|
|
||||||
|
|
||||||
// If the connection being removed is for the owner, decrements the owner connection counter
|
// If the connection is for the owner, increments the owner connection counter
|
||||||
if (isOwner) {
|
if (isOwner) {
|
||||||
this.ownerConnections[sandboxId]?.decrement()
|
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -31,30 +31,9 @@ export class DokkuClient extends SSHSocketClient {
|
|||||||
|
|
||||||
// List all deployed Dokku apps
|
// List all deployed Dokku apps
|
||||||
async listApps(): Promise<string[]> {
|
async listApps(): Promise<string[]> {
|
||||||
const response = await this.sendCommand("--quiet apps:list")
|
const response = await this.sendCommand("apps:list")
|
||||||
return response.output.split("\n")
|
// Split the output by newline and remove the header
|
||||||
}
|
return response.output.split("\n").slice(1)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { FilesystemEvent, Sandbox, WatchHandle } from "e2b"
|
import { FilesystemEvent, Sandbox, WatchHandle } from "e2b"
|
||||||
import JSZip from "jszip"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import RemoteFileStorage from "./RemoteFileStorage"
|
import RemoteFileStorage from "./RemoteFileStorage"
|
||||||
import { MAX_BODY_SIZE } from "./ratelimit"
|
import { MAX_BODY_SIZE } from "./ratelimit"
|
||||||
@ -24,11 +23,7 @@ function generateFileStructure(paths: string[]): (TFolder | TFile)[] {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isFile) {
|
if (isFile) {
|
||||||
const file: TFile = {
|
const file: TFile = { id: `/${parts.join("/")}`, type: "file", name: part }
|
||||||
id: `/${parts.join("/")}`,
|
|
||||||
type: "file",
|
|
||||||
name: part,
|
|
||||||
}
|
|
||||||
current.children.push(file)
|
current.children.push(file)
|
||||||
} else {
|
} else {
|
||||||
const folder: TFolder = {
|
const folder: TFolder = {
|
||||||
@ -80,9 +75,7 @@ export class FileManager {
|
|||||||
|
|
||||||
if (isFile) {
|
if (isFile) {
|
||||||
const fileId = `/${parts.join("/")}`
|
const fileId = `/${parts.join("/")}`
|
||||||
const data = await RemoteFileStorage.fetchFileContent(
|
const data = await RemoteFileStorage.fetchFileContent(`projects/${this.sandboxId}${fileId}`)
|
||||||
`projects/${this.sandboxId}${fileId}`
|
|
||||||
)
|
|
||||||
fileData.push({ id: fileId, data })
|
fileData.push({ id: fileId, data })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -92,17 +85,13 @@ export class FileManager {
|
|||||||
|
|
||||||
// Convert local file path to remote path
|
// Convert local file path to remote path
|
||||||
private getRemoteFileId(localId: string): string {
|
private getRemoteFileId(localId: string): string {
|
||||||
return [
|
return `projects/${this.sandboxId}${localId}`
|
||||||
"projects",
|
|
||||||
this.sandboxId,
|
|
||||||
localId.startsWith("/") ? localId : localId,
|
|
||||||
].join("/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert remote file path to local file path
|
// Convert remote file path to local file path
|
||||||
private getLocalFileId(remoteId: string): string | undefined {
|
private getLocalFileId(remoteId: string): string | undefined {
|
||||||
const allParts = remoteId.split("/")
|
const allParts = remoteId.split("/")
|
||||||
if (allParts[1] !== this.sandboxId) return undefined
|
if (allParts[1] !== this.sandboxId) return undefined;
|
||||||
return allParts.slice(2).join("/")
|
return allParts.slice(2).join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +99,7 @@ export class FileManager {
|
|||||||
private getLocalFileIds(remoteIds: string[]): string[] {
|
private getLocalFileIds(remoteIds: string[]): string[] {
|
||||||
return remoteIds
|
return remoteIds
|
||||||
.map(this.getLocalFileId.bind(this))
|
.map(this.getLocalFileId.bind(this))
|
||||||
.filter((id) => id !== undefined)
|
.filter((id) => id !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download files from remote storage
|
// Download files from remote storage
|
||||||
@ -129,21 +118,9 @@ export class FileManager {
|
|||||||
return this.files
|
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
|
// Initialize the FileManager
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
|
||||||
// Download files from remote file storage
|
// Download files from remote file storage
|
||||||
await this.updateFileStructure()
|
await this.updateFileStructure()
|
||||||
await this.updateFileData()
|
await this.updateFileData()
|
||||||
@ -151,7 +128,7 @@ export class FileManager {
|
|||||||
// Copy all files from the project to the container
|
// Copy all files from the project to the container
|
||||||
const promises = this.fileData.map(async (file) => {
|
const promises = this.fileData.map(async (file) => {
|
||||||
try {
|
try {
|
||||||
const filePath = path.posix.join(this.dirName, file.id)
|
const filePath = path.join(this.dirName, file.id)
|
||||||
const parentDirectory = path.dirname(filePath)
|
const parentDirectory = path.dirname(filePath)
|
||||||
if (!this.sandbox.files.exists(parentDirectory)) {
|
if (!this.sandbox.files.exists(parentDirectory)) {
|
||||||
await this.sandbox.files.makeDir(parentDirectory)
|
await this.sandbox.files.makeDir(parentDirectory)
|
||||||
@ -163,8 +140,6 @@ export class FileManager {
|
|||||||
})
|
})
|
||||||
await Promise.all(promises)
|
await Promise.all(promises)
|
||||||
|
|
||||||
await this.loadLocalFiles()
|
|
||||||
|
|
||||||
// Make the logged in user the owner of all project files
|
// Make the logged in user the owner of all project files
|
||||||
this.fixPermissions()
|
this.fixPermissions()
|
||||||
|
|
||||||
@ -188,7 +163,9 @@ export class FileManager {
|
|||||||
// Change the owner of the project directory to user
|
// Change the owner of the project directory to user
|
||||||
private async fixPermissions() {
|
private async fixPermissions() {
|
||||||
try {
|
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) {
|
} catch (e: any) {
|
||||||
console.log("Failed to fix permissions: " + e)
|
console.log("Failed to fix permissions: " + e)
|
||||||
}
|
}
|
||||||
@ -197,7 +174,7 @@ export class FileManager {
|
|||||||
// Watch a directory for changes
|
// Watch a directory for changes
|
||||||
async watchDirectory(directory: string): Promise<WatchHandle | undefined> {
|
async watchDirectory(directory: string): Promise<WatchHandle | undefined> {
|
||||||
try {
|
try {
|
||||||
const handle = await this.sandbox.files.watchDir(
|
const handle = await this.sandbox.files.watch(
|
||||||
directory,
|
directory,
|
||||||
async (event: FilesystemEvent) => {
|
async (event: FilesystemEvent) => {
|
||||||
try {
|
try {
|
||||||
@ -210,10 +187,7 @@ export class FileManager {
|
|||||||
// This is the absolute file path in the container
|
// This is the absolute file path in the container
|
||||||
const containerFilePath = path.posix.join(directory, event.name)
|
const containerFilePath = path.posix.join(directory, event.name)
|
||||||
// This is the file path relative to the project directory
|
// This is the file path relative to the project directory
|
||||||
const sandboxFilePath = removeDirName(
|
const sandboxFilePath = removeDirName(containerFilePath, this.dirName)
|
||||||
containerFilePath,
|
|
||||||
this.dirName
|
|
||||||
)
|
|
||||||
// This is the directory being watched relative to the project directory
|
// This is the directory being watched relative to the project directory
|
||||||
const sandboxDirectory = removeDirName(directory, this.dirName)
|
const sandboxDirectory = removeDirName(directory, this.dirName)
|
||||||
|
|
||||||
@ -230,13 +204,77 @@ export class FileManager {
|
|||||||
|
|
||||||
// Handle file/directory creation event
|
// Handle file/directory creation event
|
||||||
if (event.type === "create") {
|
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}`)
|
console.log(`Create ${sandboxFilePath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle file/directory removal or rename event
|
// Handle file/directory removal or rename event
|
||||||
else if (event.type === "remove" || event.type == "rename") {
|
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}`)
|
console.log(`Removed: ${sandboxFilePath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,7 +324,7 @@ export class FileManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeoutMs: 0 }
|
{ timeout: 0 }
|
||||||
)
|
)
|
||||||
this.fileWatchers.push(handle)
|
this.fileWatchers.push(handle)
|
||||||
return handle
|
return handle
|
||||||
@ -310,16 +348,13 @@ export class FileManager {
|
|||||||
|
|
||||||
// Get file content
|
// Get file content
|
||||||
async getFile(fileId: string): Promise<string | undefined> {
|
async getFile(fileId: string): Promise<string | undefined> {
|
||||||
const filePath = path.posix.join(this.dirName, fileId)
|
const file = this.fileData.find((f) => f.id === fileId)
|
||||||
const fileContent = await this.sandbox.files.read(filePath)
|
return file?.data
|
||||||
return fileContent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get folder content
|
// Get folder content
|
||||||
async getFolder(folderId: string): Promise<string[]> {
|
async getFolder(folderId: string): Promise<string[]> {
|
||||||
const remotePaths = await RemoteFileStorage.getFolder(
|
const remotePaths = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
|
||||||
this.getRemoteFileId(folderId)
|
|
||||||
)
|
|
||||||
return this.getLocalFileIds(remotePaths)
|
return this.getLocalFileIds(remotePaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,65 +365,12 @@ export class FileManager {
|
|||||||
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
if (Buffer.byteLength(body, "utf-8") > MAX_BODY_SIZE) {
|
||||||
throw new Error("File size too large. Please reduce the file size.")
|
throw new Error("File size too large. Please reduce the file size.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to remote storage
|
|
||||||
await RemoteFileStorage.saveFile(this.getRemoteFileId(fileId), body)
|
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
|
await this.sandbox.files.write(path.posix.join(this.dirName, file.id), body)
|
||||||
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)
|
|
||||||
this.fixPermissions()
|
this.fixPermissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,11 +393,7 @@ export class FileManager {
|
|||||||
fileData.id = newFileId
|
fileData.id = newFileId
|
||||||
file.id = newFileId
|
file.id = newFileId
|
||||||
|
|
||||||
await RemoteFileStorage.renameFile(
|
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
|
||||||
this.getRemoteFileId(fileId),
|
|
||||||
this.getRemoteFileId(newFileId),
|
|
||||||
fileData.data
|
|
||||||
)
|
|
||||||
return this.updateFileStructure()
|
return this.updateFileStructure()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,88 +425,22 @@ export class FileManager {
|
|||||||
await this.sandbox.files.write(path.posix.join(this.dirName, id), "")
|
await this.sandbox.files.write(path.posix.join(this.dirName, id), "")
|
||||||
await this.fixPermissions()
|
await this.fixPermissions()
|
||||||
|
|
||||||
|
this.files.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type: "file",
|
||||||
|
})
|
||||||
|
|
||||||
|
this.fileData.push({
|
||||||
|
id,
|
||||||
|
data: "",
|
||||||
|
})
|
||||||
|
|
||||||
await RemoteFileStorage.createFile(this.getRemoteFileId(id))
|
await RemoteFileStorage.createFile(this.getRemoteFileId(id))
|
||||||
|
|
||||||
return true
|
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
|
// Create a new folder
|
||||||
async createFolder(name: string): Promise<void> {
|
async createFolder(name: string): Promise<void> {
|
||||||
const id = `/${name}`
|
const id = `/${name}`
|
||||||
@ -546,11 +458,7 @@ export class FileManager {
|
|||||||
|
|
||||||
await this.moveFileInContainer(fileId, newFileId)
|
await this.moveFileInContainer(fileId, newFileId)
|
||||||
await this.fixPermissions()
|
await this.fixPermissions()
|
||||||
await RemoteFileStorage.renameFile(
|
await RemoteFileStorage.renameFile(this.getRemoteFileId(fileId), this.getRemoteFileId(newFileId), fileData.data)
|
||||||
this.getRemoteFileId(fileId),
|
|
||||||
this.getRemoteFileId(newFileId),
|
|
||||||
fileData.data
|
|
||||||
)
|
|
||||||
|
|
||||||
fileData.id = newFileId
|
fileData.id = newFileId
|
||||||
file.id = newFileId
|
file.id = newFileId
|
||||||
@ -562,6 +470,9 @@ export class FileManager {
|
|||||||
if (!file) return this.files
|
if (!file) return this.files
|
||||||
|
|
||||||
await this.sandbox.files.remove(path.posix.join(this.dirName, fileId))
|
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))
|
await RemoteFileStorage.deleteFile(this.getRemoteFileId(fileId))
|
||||||
return this.updateFileStructure()
|
return this.updateFileStructure()
|
||||||
@ -569,13 +480,14 @@ export class FileManager {
|
|||||||
|
|
||||||
// Delete a folder
|
// Delete a folder
|
||||||
async deleteFolder(folderId: string): Promise<(TFolder | TFile)[]> {
|
async deleteFolder(folderId: string): Promise<(TFolder | TFile)[]> {
|
||||||
const files = await RemoteFileStorage.getFolder(
|
const files = await RemoteFileStorage.getFolder(this.getRemoteFileId(folderId))
|
||||||
this.getRemoteFileId(folderId)
|
|
||||||
)
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
await this.sandbox.files.remove(path.posix.join(this.dirName, 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))
|
await RemoteFileStorage.deleteFile(this.getRemoteFileId(file))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@ -587,7 +499,7 @@ export class FileManager {
|
|||||||
async closeWatchers() {
|
async closeWatchers() {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.fileWatchers.map(async (handle: WatchHandle) => {
|
this.fileWatchers.map(async (handle: WatchHandle) => {
|
||||||
await handle.stop()
|
await handle.close()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,11 @@ export const RemoteFileStorage = {
|
|||||||
return res.ok
|
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`, {
|
const res = await fetch(`${process.env.STORAGE_WORKER_URL}/api/rename`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@ -107,7 +111,7 @@ export const RemoteFileStorage = {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return (await res.json()).size
|
return (await res.json()).size
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RemoteFileStorage
|
export default RemoteFileStorage
|
@ -13,10 +13,7 @@ export class SSHSocketClient {
|
|||||||
private conn: Client
|
private conn: Client
|
||||||
private config: SSHConfig
|
private config: SSHConfig
|
||||||
private socketPath: string
|
private socketPath: string
|
||||||
private _isConnected: boolean = false
|
private isConnected: boolean = false
|
||||||
public get isConnected(): boolean {
|
|
||||||
return this._isConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constructor initializes the SSH client and sets up configuration
|
// Constructor initializes the SSH client and sets up configuration
|
||||||
constructor(config: SSHConfig, socketPath: string) {
|
constructor(config: SSHConfig, socketPath: string) {
|
||||||
@ -37,7 +34,7 @@ export class SSHSocketClient {
|
|||||||
private closeConnection() {
|
private closeConnection() {
|
||||||
console.log("Closing SSH connection...")
|
console.log("Closing SSH connection...")
|
||||||
this.conn.end()
|
this.conn.end()
|
||||||
this._isConnected = false
|
this.isConnected = false
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,17 +44,17 @@ export class SSHSocketClient {
|
|||||||
this.conn
|
this.conn
|
||||||
.on("ready", () => {
|
.on("ready", () => {
|
||||||
console.log("SSH connection established")
|
console.log("SSH connection established")
|
||||||
this._isConnected = true
|
this.isConnected = true
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
console.error("SSH connection error:", err)
|
console.error("SSH connection error:", err)
|
||||||
this._isConnected = false
|
this.isConnected = false
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
.on("close", () => {
|
.on("close", () => {
|
||||||
console.log("SSH connection closed")
|
console.log("SSH connection closed")
|
||||||
this._isConnected = false
|
this.isConnected = false
|
||||||
})
|
})
|
||||||
.connect(this.config)
|
.connect(this.config)
|
||||||
})
|
})
|
||||||
@ -89,13 +86,10 @@ export class SSHSocketClient {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on("data", (data: Buffer) => {
|
.on("data", (data: Buffer) => {
|
||||||
// Netcat remains open until it is closed, so we close the connection once we receive data.
|
|
||||||
resolve(data.toString())
|
resolve(data.toString())
|
||||||
stream.close()
|
|
||||||
})
|
})
|
||||||
.stderr.on("data", (data: Buffer) => {
|
.stderr.on("data", (data: Buffer) => {
|
||||||
reject(new Error(data.toString()))
|
reject(new Error(data.toString()))
|
||||||
stream.close()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
243
backend/server/src/SandboxManager.ts
Normal file
243
backend/server/src/SandboxManager.ts
Normal 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -57,7 +57,7 @@ export class SecureGitClient {
|
|||||||
|
|
||||||
// Add files to the repository
|
// Add files to the repository
|
||||||
for (const { id, data } of fileData) {
|
for (const { id, data } of fileData) {
|
||||||
await git.add(id.startsWith("/") ? id.slice(1) : id)
|
await git.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit the changes
|
// Commit the changes
|
||||||
|
@ -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
|
// Terminal class to manage a pseudo-terminal (PTY) in a sandbox environment
|
||||||
export class Terminal {
|
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
|
private sandbox: Sandbox // Reference to the sandbox environment
|
||||||
|
|
||||||
// Constructor initializes the Terminal with a sandbox
|
// Constructor initializes the Terminal with a sandbox
|
||||||
@ -24,7 +24,7 @@ export class Terminal {
|
|||||||
this.pty = await this.sandbox.pty.create({
|
this.pty = await this.sandbox.pty.create({
|
||||||
rows,
|
rows,
|
||||||
cols,
|
cols,
|
||||||
timeoutMs: 0,
|
timeout: 0,
|
||||||
onData: (data: Uint8Array) => {
|
onData: (data: Uint8Array) => {
|
||||||
onData(new TextDecoder().decode(data)) // Convert received data to string and pass to handler
|
onData(new TextDecoder().decode(data)) // Convert received data to string and pass to handler
|
||||||
},
|
},
|
||||||
|
@ -4,19 +4,20 @@ import express, { Express } from "express"
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { Server, Socket } from "socket.io"
|
import { Server, Socket } from "socket.io"
|
||||||
|
import { AIWorker } from "./AIWorker"
|
||||||
|
|
||||||
import { ConnectionManager } from "./ConnectionManager"
|
import { ConnectionManager } from "./ConnectionManager"
|
||||||
import { DokkuClient } from "./DokkuClient"
|
import { DokkuClient } from "./DokkuClient"
|
||||||
import { Sandbox } from "./Sandbox"
|
import { Sandbox } from "./SandboxManager"
|
||||||
import { SecureGitClient } from "./SecureGitClient"
|
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"
|
import { TFile, TFolder } from "./types"
|
||||||
|
|
||||||
// Log errors and send a notification to the client
|
// Log errors and send a notification to the client
|
||||||
export const handleErrors = (message: string, error: any, socket: Socket) => {
|
export const handleErrors = (message: string, error: any, socket: Socket) => {
|
||||||
console.error(message, error)
|
console.error(message, error);
|
||||||
socket.emit("error", `${message} ${error.message ?? error}`)
|
socket.emit("error", `${message} ${error.message ?? error}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Handle uncaught exceptions
|
// Handle uncaught exceptions
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
@ -63,10 +64,10 @@ if (!process.env.DOKKU_KEY)
|
|||||||
const dokkuClient =
|
const dokkuClient =
|
||||||
process.env.DOKKU_HOST && process.env.DOKKU_KEY && process.env.DOKKU_USERNAME
|
process.env.DOKKU_HOST && process.env.DOKKU_KEY && process.env.DOKKU_USERNAME
|
||||||
? new DokkuClient({
|
? new DokkuClient({
|
||||||
host: process.env.DOKKU_HOST,
|
host: process.env.DOKKU_HOST,
|
||||||
username: process.env.DOKKU_USERNAME,
|
username: process.env.DOKKU_USERNAME,
|
||||||
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
|
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
dokkuClient?.connect()
|
dokkuClient?.connect()
|
||||||
|
|
||||||
@ -74,11 +75,19 @@ dokkuClient?.connect()
|
|||||||
const gitClient =
|
const gitClient =
|
||||||
process.env.DOKKU_HOST && process.env.DOKKU_KEY
|
process.env.DOKKU_HOST && process.env.DOKKU_KEY
|
||||||
? new SecureGitClient(
|
? new SecureGitClient(
|
||||||
`dokku@${process.env.DOKKU_HOST}`,
|
`dokku@${process.env.DOKKU_HOST}`,
|
||||||
process.env.DOKKU_KEY
|
process.env.DOKKU_KEY
|
||||||
)
|
)
|
||||||
: null
|
: 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
|
// Handle a client connecting to the server
|
||||||
io.on("connection", async (socket) => {
|
io.on("connection", async (socket) => {
|
||||||
try {
|
try {
|
||||||
@ -87,7 +96,6 @@ io.on("connection", async (socket) => {
|
|||||||
userId: string
|
userId: string
|
||||||
sandboxId: string
|
sandboxId: string
|
||||||
isOwner: boolean
|
isOwner: boolean
|
||||||
type: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the connection
|
// Register the connection
|
||||||
@ -101,81 +109,70 @@ io.on("connection", async (socket) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create or retrieve the sandbox manager for the given sandbox ID
|
// Create or retrieve the sandbox manager for the given sandbox ID
|
||||||
const sandbox =
|
const sandboxManager = sandboxes[data.sandboxId] ?? new Sandbox(
|
||||||
sandboxes[data.sandboxId] ??
|
data.sandboxId,
|
||||||
new Sandbox(data.sandboxId, data.type, {
|
{
|
||||||
dokkuClient,
|
aiWorker, dokkuClient, gitClient,
|
||||||
gitClient,
|
}
|
||||||
})
|
)
|
||||||
sandboxes[data.sandboxId] = sandbox
|
sandboxes[data.sandboxId] = sandboxManager
|
||||||
|
|
||||||
// This callback recieves an update when the file list changes, and notifies all relevant connections.
|
// This callback recieves an update when the file list changes, and notifies all relevant connections.
|
||||||
const sendFileNotifications = (files: (TFolder | TFile)[]) => {
|
const sendFileNotifications = (files: (TFolder | TFile)[]) => {
|
||||||
connections
|
connections.connectionsForSandbox(data.sandboxId).forEach((socket: Socket) => {
|
||||||
.connectionsForSandbox(data.sandboxId)
|
socket.emit("loaded", files);
|
||||||
.forEach((socket: Socket) => {
|
});
|
||||||
socket.emit("loaded", files)
|
};
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the sandbox container
|
// Initialize the sandbox container
|
||||||
// The file manager and terminal managers will be set up if they have been closed
|
// The file manager and terminal managers will be set up if they have been closed
|
||||||
await sandbox.initialize(sendFileNotifications)
|
await sandboxManager.initialize(sendFileNotifications)
|
||||||
socket.emit("loaded", sandbox.fileManager?.files)
|
socket.emit("loaded", sandboxManager.fileManager?.files)
|
||||||
|
|
||||||
// Register event handlers for the sandbox
|
// Register event handlers for the sandbox
|
||||||
// For each event handler, listen on the socket for that event
|
// For each event handler, listen on the socket for that event
|
||||||
// Pass connection-specific information to the handlers
|
// Pass connection-specific information to the handlers
|
||||||
Object.entries(
|
Object.entries(sandboxManager.handlers({
|
||||||
sandbox.handlers({
|
userId: data.userId,
|
||||||
userId: data.userId,
|
isOwner: data.isOwner,
|
||||||
isOwner: data.isOwner,
|
socket
|
||||||
socket,
|
})).forEach(([event, handler]) => {
|
||||||
})
|
socket.on(event, async (options: any, callback?: (response: any) => void) => {
|
||||||
).forEach(([event, handler]) => {
|
try {
|
||||||
socket.on(
|
const result = await handler(options)
|
||||||
event,
|
callback?.(result);
|
||||||
async (options: any, callback?: (response: any) => void) => {
|
} catch (e: any) {
|
||||||
try {
|
handleErrors(`Error processing event "${event}":`, e, socket);
|
||||||
const result = await handler(options)
|
|
||||||
callback?.(result)
|
|
||||||
} catch (e: any) {
|
|
||||||
handleErrors(`Error processing event "${event}":`, e, socket)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
socket.emit("ready")
|
|
||||||
|
|
||||||
// Handle disconnection event
|
// Handle disconnection event
|
||||||
socket.on("disconnect", async () => {
|
socket.on("disconnect", async () => {
|
||||||
try {
|
try {
|
||||||
// Deregister the connection
|
// Deregister the connection
|
||||||
connections.removeConnectionForSandbox(
|
connections.removeConnectionForSandbox(socket, data.sandboxId, data.isOwner)
|
||||||
socket,
|
|
||||||
data.sandboxId,
|
|
||||||
data.isOwner
|
|
||||||
)
|
|
||||||
|
|
||||||
// If the owner has disconnected from all sockets, close open terminals and file watchers.o
|
// If the owner has disconnected from all sockets, close open terminals and file watchers.o
|
||||||
// The sandbox itself will timeout after the heartbeat stops.
|
// The sandbox itself will timeout after the heartbeat stops.
|
||||||
if (data.isOwner && !connections.ownerIsConnected(data.sandboxId)) {
|
if (data.isOwner && !connections.ownerIsConnected(data.sandboxId)) {
|
||||||
await sandbox.disconnect()
|
await sandboxManager.disconnect()
|
||||||
socket.broadcast.emit(
|
socket.broadcast.emit(
|
||||||
"disableAccess",
|
"disableAccess",
|
||||||
"The sandbox owner has disconnected."
|
"The sandbox owner has disconnected."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
handleErrors("Error disconnecting:", e, socket)
|
handleErrors("Error disconnecting:", e, socket);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
handleErrors(`Error initializing sandbox ${data.sandboxId}:`, e, socket)
|
handleErrors(`Error initializing sandbox ${data.sandboxId}:`, e, socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
handleErrors("Error connecting:", e, socket)
|
handleErrors("Error connecting:", e, socket);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1,75 +1,63 @@
|
|||||||
import { Socket } from "socket.io"
|
import { Socket } from "socket.io"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Sandbox, User } from "./types"
|
import { User } from "./types"
|
||||||
|
|
||||||
// Middleware for socket authentication
|
// Middleware for socket authentication
|
||||||
export const socketAuth = async (socket: Socket, next: Function) => {
|
export const socketAuth = async (socket: Socket, next: Function) => {
|
||||||
// Define the schema for handshake query validation
|
// Define the schema for handshake query validation
|
||||||
const handshakeSchema = z.object({
|
const handshakeSchema = z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
sandboxId: z.string(),
|
sandboxId: z.string(),
|
||||||
EIO: z.string(),
|
EIO: z.string(),
|
||||||
transport: z.string(),
|
transport: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const q = socket.handshake.query
|
const q = socket.handshake.query
|
||||||
const parseQuery = handshakeSchema.safeParse(q)
|
const parseQuery = handshakeSchema.safeParse(q)
|
||||||
|
|
||||||
// Check if the query is valid according to the schema
|
// Check if the query is valid according to the schema
|
||||||
if (!parseQuery.success) {
|
if (!parseQuery.success) {
|
||||||
next(new Error("Invalid request."))
|
next(new Error("Invalid request."))
|
||||||
return
|
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}`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
const dbUserJSON = (await dbUser.json()) as User
|
|
||||||
|
|
||||||
// Fetch sandbox data from the database
|
const { sandboxId, userId } = parseQuery.data
|
||||||
const dbSandbox = await fetch(
|
// Fetch user data from the database
|
||||||
`${process.env.DATABASE_WORKER_URL}/api/sandbox?id=${sandboxId}`,
|
const dbUser = await fetch(
|
||||||
{
|
`${process.env.DATABASE_WORKER_URL}/api/user?id=${userId}`,
|
||||||
headers: {
|
{
|
||||||
Authorization: `${process.env.WORKERS_KEY}`,
|
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
|
// Check if the user owns the sandbox or has shared access
|
||||||
if (!dbUserJSON) {
|
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
|
||||||
next(new Error("DB error."))
|
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
|
||||||
return
|
(uts) => uts.sandboxId === sandboxId
|
||||||
}
|
)
|
||||||
|
|
||||||
// Check if the user owns the sandbox or has shared access
|
// If user doesn't own or have shared access to the sandbox, deny access
|
||||||
const sandbox = dbUserJSON.sandbox.find((s) => s.id === sandboxId)
|
if (!sandbox && !sharedSandboxes) {
|
||||||
const sharedSandboxes = dbUserJSON.usersToSandboxes.find(
|
next(new Error("Invalid credentials."))
|
||||||
(uts) => uts.sandboxId === sandboxId
|
return
|
||||||
)
|
}
|
||||||
|
|
||||||
// If user doesn't own or have shared access to the sandbox, deny access
|
// Set socket data with user information
|
||||||
if (!sandbox && !sharedSandboxes) {
|
socket.data = {
|
||||||
next(new Error("Invalid credentials."))
|
userId,
|
||||||
return
|
sandboxId: sandboxId,
|
||||||
}
|
isOwner: sandbox !== undefined,
|
||||||
|
}
|
||||||
|
|
||||||
// Set socket data with user information
|
// Allow the connection
|
||||||
socket.data = {
|
next()
|
||||||
userId,
|
|
||||||
sandboxId: sandboxId,
|
|
||||||
isOwner: sandbox !== undefined,
|
|
||||||
type: dbSandboxJSON.type,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow the connection
|
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ export type User = {
|
|||||||
export type Sandbox = {
|
export type Sandbox = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: "reactjs" | "vanillajs" | "nextjs" | "streamlit"
|
type: "react" | "node"
|
||||||
visibility: "public" | "private"
|
visibility: "public" | "private"
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
userId: string
|
userId: string
|
||||||
|
6221
backend/storage/package-lock.json
generated
6221
backend/storage/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,10 +11,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
"@cloudflare/vitest-pool-workers": "^0.1.0",
|
||||||
"@cloudflare/workers-types": "^4.20241106.0",
|
"@cloudflare/workers-types": "^4.20240419.0",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4",
|
||||||
"vitest": "1.3.0",
|
"vitest": "1.3.0",
|
||||||
"wrangler": "^3.86.0"
|
"wrangler": "^3.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-limit": "^6.1.0",
|
"p-limit": "^6.1.0",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ExecutionContext, R2Bucket, Headers as CFHeaders } from "@cloudflare/workers-types"
|
import pLimit from "p-limit"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
export interface Env {
|
export interface Env {
|
||||||
@ -76,13 +76,14 @@ export default {
|
|||||||
if (obj === null) {
|
if (obj === null) {
|
||||||
return new Response(`${fileId} not found`, { status: 404 })
|
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)
|
headers.set("etag", obj.httpEtag)
|
||||||
obj.writeHttpMetadata(headers)
|
obj.writeHttpMetadata(headers)
|
||||||
|
|
||||||
const text = await obj.text()
|
const text = await obj.text()
|
||||||
|
|
||||||
return new Response(text, {
|
return new Response(text, {
|
||||||
headers: Object.fromEntries(headers.entries()),
|
headers,
|
||||||
})
|
})
|
||||||
} else return invalidRequest
|
} else return invalidRequest
|
||||||
} else if (method === "POST") {
|
} else if (method === "POST") {
|
||||||
@ -135,7 +136,33 @@ export default {
|
|||||||
|
|
||||||
return success
|
return success
|
||||||
} else if (path === "/api/init" && method === "POST") {
|
} 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
|
return success
|
||||||
} else {
|
} else {
|
||||||
return notFound
|
return notFound
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||||
"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. */,
|
] /* Specify type package names to be included without being referenced in a source file. */,
|
||||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
"resolveJsonModule": true /* Enable importing .json files */,
|
"resolveJsonModule": true /* Enable importing .json files */,
|
||||||
|
@ -3,21 +3,16 @@ CLERK_SECRET_KEY=
|
|||||||
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
|
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
|
||||||
LIVEBLOCKS_SECRET_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_SERVER_URL=http://localhost:4000
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
|
||||||
# Set WORKER_URLs after deploying the workers.
|
# Set WORKER_URLs after deploying the workers.
|
||||||
# Set NEXT_PUBLIC_WORKERS_KEY to be the same as KEY in /backend/storage/wrangler.toml.
|
# 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_DATABASE_WORKER_URL=
|
||||||
NEXT_PUBLIC_STORAGE_WORKER_URL=https://storage.your-worker.workers.dev
|
NEXT_PUBLIC_STORAGE_WORKER_URL=
|
||||||
NEXT_PUBLIC_WORKERS_KEY=SUPERDUPERSECRET
|
NEXT_PUBLIC_WORKERS_KEY=
|
||||||
|
|
||||||
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
|
||||||
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
|
||||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
|
||||||
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
|
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard
|
||||||
|
|
||||||
ANTHROPIC_API_KEY=
|
|
||||||
OPENAI_API_KEY=
|
|
@ -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 Loading from "@/components/editor/loading"
|
||||||
import Navbar from "@/components/editor/navbar"
|
import Navbar from "@/components/editor/navbar"
|
||||||
import { TerminalProvider } from "@/context/TerminalContext"
|
import { TerminalProvider } from "@/context/TerminalContext"
|
||||||
@ -51,11 +51,7 @@ const getSharedUsers = async (usersToSandboxes: UsersToSandboxes[]) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
const userData: User = await userRes.json()
|
const userData: User = await userRes.json()
|
||||||
return {
|
return { id: userData.id, name: userData.name }
|
||||||
id: userData.id,
|
|
||||||
name: userData.name,
|
|
||||||
avatarUrl: userData.avatarUrl,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -91,17 +87,21 @@ export default async function CodePage({ params }: { params: { id: string } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalProvider>
|
<>
|
||||||
{/* <Room id={sandboxId}> */}
|
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
|
||||||
<div className="overflow-hidden overscroll-none w-screen h-screen grid [grid-template-rows:3.5rem_auto] bg-background">
|
<Room id={sandboxId}>
|
||||||
<Navbar
|
<TerminalProvider>
|
||||||
userData={userData}
|
<Navbar
|
||||||
sandboxData={sandboxData}
|
userData={userData}
|
||||||
shared={shared as { id: string; name: string; avatarUrl: string }[]}
|
sandboxData={sandboxData}
|
||||||
/>
|
shared={shared}
|
||||||
<CodeEditor userData={userData} sandboxData={sandboxData} />
|
/>
|
||||||
|
<div className="w-screen flex grow">
|
||||||
|
<CodeEditor userData={userData} sandboxData={sandboxData} />
|
||||||
|
</div>
|
||||||
|
</TerminalProvider>
|
||||||
|
</Room>
|
||||||
</div>
|
</div>
|
||||||
{/* </Room> */}
|
</>
|
||||||
</TerminalProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,6 @@ export default async function DashboardPage() {
|
|||||||
type: "react" | "node"
|
type: "react" | "node"
|
||||||
author: string
|
author: string
|
||||||
sharedOn: Date
|
sharedOn: Date
|
||||||
authorAvatarUrl: string
|
|
||||||
}[]
|
}[]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { User } from "@/lib/types"
|
import { User } from "@/lib/types"
|
||||||
import { generateUniqueUsername } from "@/lib/username-generator"
|
|
||||||
import { currentUser } from "@clerk/nextjs"
|
import { currentUser } from "@clerk/nextjs"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
@ -25,27 +24,6 @@ export default async function AppAuthLayout({
|
|||||||
const dbUserJSON = (await dbUser.json()) as User
|
const dbUserJSON = (await dbUser.json()) as User
|
||||||
|
|
||||||
if (!dbUserJSON.id) {
|
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(
|
const res = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user`,
|
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user`,
|
||||||
{
|
{
|
||||||
@ -58,20 +36,9 @@ export default async function AppAuthLayout({
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.firstName + " " + user.lastName,
|
name: user.firstName + " " + user.lastName,
|
||||||
email: user.emailAddresses[0].emailAddress,
|
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}</>
|
return <>{children}</>
|
||||||
|
@ -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}¤tUserId=${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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,61 +1,57 @@
|
|||||||
// import { colors } from "@/lib/colors"
|
import { colors } from "@/lib/colors"
|
||||||
// import { User } from "@/lib/types"
|
import { User } from "@/lib/types"
|
||||||
import { currentUser } from "@clerk/nextjs"
|
import { currentUser } from "@clerk/nextjs"
|
||||||
// import { Liveblocks } from "@liveblocks/node"
|
import { Liveblocks } from "@liveblocks/node"
|
||||||
import { NextRequest } from "next/server"
|
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({
|
const liveblocks = new Liveblocks({
|
||||||
// secret: API_KEY!,
|
secret: API_KEY!,
|
||||||
// })
|
})
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
// Temporarily return unauthorized while Liveblocks is disabled
|
const clerkUser = await currentUser()
|
||||||
return new Response("Liveblocks collaboration temporarily disabled", { status: 503 })
|
|
||||||
|
|
||||||
// Original implementation commented out:
|
if (!clerkUser) {
|
||||||
// const clerkUser = await currentUser()
|
return new Response("Unauthorized", { status: 401 })
|
||||||
//
|
}
|
||||||
// if (!clerkUser) {
|
|
||||||
// return new Response("Unauthorized", { status: 401 })
|
const res = await fetch(
|
||||||
// }
|
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${clerkUser.id}`,
|
||||||
//
|
{
|
||||||
// const res = await fetch(
|
headers: {
|
||||||
// `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/user?id=${clerkUser.id}`,
|
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
||||||
// {
|
},
|
||||||
// headers: {
|
}
|
||||||
// Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
|
)
|
||||||
// },
|
const user = (await res.json()) as User
|
||||||
// }
|
|
||||||
// )
|
const colorNames = Object.keys(colors)
|
||||||
// const user = (await res.json()) as User
|
const randomColor = colorNames[
|
||||||
//
|
Math.floor(Math.random() * colorNames.length)
|
||||||
// const colorNames = Object.keys(colors)
|
] as keyof typeof colors
|
||||||
// const randomColor = colorNames[
|
const code = colors[randomColor]
|
||||||
// Math.floor(Math.random() * colorNames.length)
|
|
||||||
// ] as keyof typeof colors
|
// Create a session for the current user
|
||||||
// const code = colors[randomColor]
|
// userInfo is made available in Liveblocks presence hooks, e.g. useOthers
|
||||||
//
|
const session = liveblocks.prepareSession(user.id, {
|
||||||
// // Create a session for the current user
|
userInfo: {
|
||||||
// // userInfo is made available in Liveblocks presence hooks, e.g. useOthers
|
name: user.name,
|
||||||
// const session = liveblocks.prepareSession(user.id, {
|
email: user.email,
|
||||||
// userInfo: {
|
color: randomColor,
|
||||||
// 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)
|
||||||
// // Give the user access to the room
|
})
|
||||||
// user.sandbox.forEach((sandbox) => {
|
user.usersToSandboxes.forEach((userToSandbox) => {
|
||||||
// session.allow(`${sandbox.id}`, session.FULL_ACCESS)
|
session.allow(`${userToSandbox.sandboxId}`, 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 })
|
||||||
// // Authorize the user and return the result
|
|
||||||
// const { body, status } = await session.authorize()
|
|
||||||
// return new Response(body, { status })
|
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
BIN
frontend/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
@ -102,27 +102,26 @@
|
|||||||
.light .gradient-button-bg {
|
.light .gradient-button-bg {
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
circle at top,
|
circle at top,
|
||||||
#f5f5f5 0%,
|
#262626 0%,
|
||||||
/* Very light gray */ #e0e0e0 50% /* Soft gray */
|
#f5f5f5 50%
|
||||||
);
|
); /* Dark gray -> Light gray */
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .gradient-button {
|
.light .gradient-button {
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
circle at bottom,
|
circle at bottom,
|
||||||
hsl(0, 0%, 85%) -10%,
|
hsl(0, 10%, 25%) -10%,
|
||||||
/* Slightly darker gray */ hsl(0, 0%, 95%) 50% /* Very soft light gray */
|
#9d9d9d 50%
|
||||||
);
|
); /* Light gray -> Almost white */
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .gradient-button-bg > div:hover {
|
.light .gradient-button-bg > div:hover {
|
||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
circle at bottom,
|
circle at bottom,
|
||||||
hsl(0, 0%, 80%) -10%,
|
hsl(0, 10%, 25%) -10%,
|
||||||
/* Slightly darker gray for hover */ hsl(0, 0%, 90%) 80% /* Softer gray */
|
#9d9d9d 80%
|
||||||
);
|
); /* Light gray -> Almost white */
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-decoration::before {
|
.inline-decoration::before {
|
||||||
content: "Generate";
|
content: "Generate";
|
||||||
color: #525252;
|
color: #525252;
|
||||||
@ -176,23 +175,3 @@
|
|||||||
.tab-scroll::-webkit-scrollbar {
|
.tab-scroll::-webkit-scrollbar {
|
||||||
display: none;
|
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 |
@ -1,7 +1,7 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import { ThemeProvider } from "@/components/ui/theme-provider"
|
import { ThemeProvider } from "@/components/ui/theme-provider"
|
||||||
import { PreviewProvider } from "@/context/PreviewContext"
|
import { PreviewProvider } from "@/context/PreviewContext"
|
||||||
import { SocketProvider } from "@/context/SocketContext"
|
import { SocketProvider } from '@/context/SocketContext'
|
||||||
import { ClerkProvider } from "@clerk/nextjs"
|
import { ClerkProvider } from "@clerk/nextjs"
|
||||||
import { Analytics } from "@vercel/analytics/react"
|
import { Analytics } from "@vercel/analytics/react"
|
||||||
import { GeistMono } from "geist/font/mono"
|
import { GeistMono } from "geist/font/mono"
|
||||||
@ -11,22 +11,7 @@ import "./globals.css"
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Sandbox",
|
title: "Sandbox",
|
||||||
description:
|
description: "A collaborative, AI-powered cloud code editing environment",
|
||||||
"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",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
@ -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 |
@ -18,38 +18,11 @@ export default function AboutModal({
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Help & Support</DialogTitle>
|
<DialogTitle>About this project</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="text-sm text-muted-foreground">
|
||||||
{/* <div className="text-sm text-muted-foreground">
|
Sandbox is an open-source cloud-based code editing environment with
|
||||||
Sandbox is an open-source cloud-based code editing environment with
|
custom AI code autocompletion and real-time collaboration.
|
||||||
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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -25,7 +25,6 @@ export default function Dashboard({
|
|||||||
type: "react" | "node"
|
type: "react" | "node"
|
||||||
author: string
|
author: string
|
||||||
sharedOn: Date
|
sharedOn: Date
|
||||||
authorAvatarUrl?: string
|
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
const [screen, setScreen] = useState<TScreen>("projects")
|
const [screen, setScreen] = useState<TScreen>("projects")
|
||||||
@ -78,14 +77,14 @@ export default function Dashboard({
|
|||||||
<FolderDot className="w-4 h-4 mr-2" />
|
<FolderDot className="w-4 h-4 mr-2" />
|
||||||
My Projects
|
My Projects
|
||||||
</Button>
|
</Button>
|
||||||
{/* <Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setScreen("shared")}
|
onClick={() => setScreen("shared")}
|
||||||
className={activeScreen("shared")}
|
className={activeScreen("shared")}
|
||||||
>
|
>
|
||||||
<Users className="w-4 h-4 mr-2" />
|
<Users className="w-4 h-4 mr-2" />
|
||||||
Shared With Me
|
Shared With Me
|
||||||
</Button> */}
|
</Button>
|
||||||
{/* <Button
|
{/* <Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setScreen("settings")}
|
onClick={() => setScreen("settings")}
|
||||||
@ -96,7 +95,7 @@ export default function Dashboard({
|
|||||||
</Button> */}
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<a target="_blank" href="https://github.com/jamesmurdza/sandbox">
|
<a target="_blank" href="https://github.com/ishaan1013/sandbox">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="justify-start w-full font-normal text-muted-foreground"
|
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"
|
className="justify-start font-normal text-muted-foreground"
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-4 h-4 mr-2" />
|
<HelpCircle className="w-4 h-4 mr-2" />
|
||||||
Help
|
About
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -122,12 +121,7 @@ export default function Dashboard({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : screen === "shared" ? (
|
) : screen === "shared" ? (
|
||||||
<DashboardSharedWithMe
|
<DashboardSharedWithMe shared={shared} />
|
||||||
shared={shared.map((item) => ({
|
|
||||||
...item,
|
|
||||||
authorAvatarUrl: item.authorAvatarUrl || "",
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
) : screen === "settings" ? null : null}
|
) : screen === "settings" ? null : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -8,20 +8,15 @@ import DashboardNavbarSearch from "./search"
|
|||||||
|
|
||||||
export default function DashboardNavbar({ userData }: { userData: User }) {
|
export default function DashboardNavbar({ userData }: { userData: User }) {
|
||||||
return (
|
return (
|
||||||
<div className=" py-2 px-4 w-full flex items-center justify-between border-b border-border">
|
<div className="h-16 px-4 w-full flex items-center justify-between border-b border-border">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-4">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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"
|
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} />
|
<Image src={Logo} alt="Logo" width={36} height={36} />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-xl">
|
<div className="text-sm font-medium flex items-center">Sandbox</div>
|
||||||
<span className="font-semibold">Sandbox</span>{" "}
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
by gitwit
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<DashboardNavbarSearch />
|
<DashboardNavbarSearch />
|
||||||
|
@ -11,13 +11,13 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
export default function ProjectCardDropdown({
|
export default function ProjectCardDropdown({
|
||||||
visibility,
|
sandbox,
|
||||||
onVisibilityChange,
|
onVisibilityChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
visibility: Sandbox["visibility"]
|
sandbox: Sandbox
|
||||||
onVisibilityChange: () => void
|
onVisibilityChange: (sandbox: Sandbox) => void
|
||||||
onDelete: () => void
|
onDelete: (sandbox: Sandbox) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
@ -26,7 +26,7 @@ export default function ProjectCardDropdown({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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" />
|
<Ellipsis className="w-4 h-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@ -34,11 +34,11 @@ export default function ProjectCardDropdown({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onVisibilityChange()
|
onVisibilityChange(sandbox)
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{visibility === "public" ? (
|
{sandbox.visibility === "public" ? (
|
||||||
<>
|
<>
|
||||||
<Lock className="mr-2 h-4 w-4" />
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
<span>Make Private</span>
|
<span>Make Private</span>
|
||||||
@ -53,7 +53,7 @@ export default function ProjectCardDropdown({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onDelete()
|
onDelete(sandbox)
|
||||||
}}
|
}}
|
||||||
className="!text-destructive cursor-pointer"
|
className="!text-destructive cursor-pointer"
|
||||||
>
|
>
|
||||||
|
@ -1,235 +1,59 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { toggleLike } from "@/lib/actions"
|
|
||||||
import { projectTemplates } from "@/lib/data"
|
import { projectTemplates } from "@/lib/data"
|
||||||
import { Sandbox } from "@/lib/types"
|
import { Sandbox } from "@/lib/types"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { useUser } from "@clerk/nextjs"
|
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
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 Image from "next/image"
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import { useEffect, useState } from "react"
|
||||||
memo,
|
|
||||||
MouseEventHandler,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useOptimistic,
|
|
||||||
useState,
|
|
||||||
useTransition,
|
|
||||||
} from "react"
|
|
||||||
import ProjectCardDropdown from "./dropdown"
|
import ProjectCardDropdown from "./dropdown"
|
||||||
import { CanvasRevealEffect } from "./revealEffect"
|
|
||||||
|
|
||||||
type BaseProjectCardProps = {
|
export default function ProjectCard({
|
||||||
id: string
|
children,
|
||||||
name: string
|
sandbox,
|
||||||
type: string
|
onVisibilityChange,
|
||||||
visibility: "public" | "private"
|
onDelete,
|
||||||
createdAt: Date
|
deletingId,
|
||||||
likeCount: number
|
}: {
|
||||||
liked?: boolean
|
children?: React.ReactNode
|
||||||
viewCount: number
|
sandbox: Sandbox
|
||||||
}
|
onVisibilityChange: (sandbox: Sandbox) => void
|
||||||
|
onDelete: (sandbox: Sandbox) => void
|
||||||
type AuthenticatedProjectCardProps = BaseProjectCardProps & {
|
|
||||||
isAuthenticated: true
|
|
||||||
onVisibilityChange: (
|
|
||||||
sandbox: Pick<Sandbox, "id" | "name" | "visibility">
|
|
||||||
) => void
|
|
||||||
onDelete: (sandbox: Pick<Sandbox, "id" | "name">) => void
|
|
||||||
deletingId: string
|
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 [hovered, setHovered] = useState(false)
|
||||||
|
const [date, setDate] = useState<string>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const projectIcon = useMemo(
|
useEffect(() => {
|
||||||
() =>
|
const createdAt = new Date(sandbox.createdAt)
|
||||||
projectTemplates.find((p) => p.id === type)?.icon ??
|
const now = new Date()
|
||||||
"/project-icons/node.svg",
|
const diffInMinutes = Math.floor(
|
||||||
[type]
|
(now.getTime() - createdAt.getTime()) / 60000
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
if (diffInMinutes < 1) {
|
||||||
if (props.isAuthenticated) {
|
setDate("Now")
|
||||||
props.onVisibilityChange({
|
} else if (diffInMinutes < 60) {
|
||||||
id,
|
setDate(`${diffInMinutes}m ago`)
|
||||||
name,
|
} else if (diffInMinutes < 1440) {
|
||||||
visibility,
|
setDate(`${Math.floor(diffInMinutes / 60)}h ago`)
|
||||||
})
|
} else {
|
||||||
|
setDate(`${Math.floor(diffInMinutes / 1440)}d ago`)
|
||||||
}
|
}
|
||||||
}
|
}, [sandbox])
|
||||||
|
const projectIcon =
|
||||||
const handleDelete = () => {
|
projectTemplates.find((p) => p.id === sandbox.type)?.icon ??
|
||||||
if (props.isAuthenticated) {
|
"/project-icons/node.svg"
|
||||||
props.onDelete({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => router.push(`/code/${id}`)}
|
onClick={() => router.push(`/code/${sandbox.id}`)}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
className={`
|
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`}
|
||||||
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"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hovered && (
|
{hovered && (
|
||||||
@ -238,64 +62,38 @@ function ProjectCardComponent({
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className="h-full w-full absolute inset-0"
|
className="h-full w-full absolute inset-0"
|
||||||
>
|
>
|
||||||
<CanvasRevealEffect
|
{children}
|
||||||
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" />
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="space-x-2 flex items-center justify-start w-full z-10">
|
<div className="space-x-2 flex items-center justify-start w-full z-10">
|
||||||
<Image
|
<Image alt="" src={projectIcon} width={20} height={20} />
|
||||||
alt={`${type} project icon`}
|
<div className="font-medium static whitespace-nowrap w-full text-ellipsis overflow-hidden">
|
||||||
src={projectIcon}
|
{sandbox.name}
|
||||||
width={20}
|
</div>
|
||||||
height={20}
|
<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>
|
</div>
|
||||||
|
<div className="flex flex-col text-muted-foreground space-y-0.5 text-sm z-10">
|
||||||
<ProjectMetadata
|
<div className="flex items-center">
|
||||||
visibility={visibility}
|
{sandbox.visibility === "private" ? (
|
||||||
createdAt={createdAt}
|
<>
|
||||||
likeCount={likeCount}
|
<Lock className="w-3 h-3 mr-2" /> Private
|
||||||
viewCount={viewCount}
|
</>
|
||||||
id={id}
|
) : (
|
||||||
liked={props.liked}
|
<>
|
||||||
/>
|
<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>
|
</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],
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { deleteSandbox, updateSandbox } from "@/lib/actions"
|
import { deleteSandbox, updateSandbox } from "@/lib/actions"
|
||||||
import { Sandbox } from "@/lib/types"
|
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 { toast } from "sonner"
|
||||||
import ProjectCard from "./projectCard"
|
import ProjectCard from "./projectCard"
|
||||||
|
import { CanvasRevealEffect } from "./projectCard/revealEffect"
|
||||||
|
|
||||||
const colors: { [key: string]: number[][] } = {
|
const colors: { [key: string]: number[][] } = {
|
||||||
react: [
|
react: [
|
||||||
@ -26,27 +28,11 @@ export default function DashboardProjects({
|
|||||||
}) {
|
}) {
|
||||||
const [deletingId, setDeletingId] = useState<string>("")
|
const [deletingId, setDeletingId] = useState<string>("")
|
||||||
|
|
||||||
const onVisibilityChange = useMemo(
|
const onDelete = async (sandbox: Sandbox) => {
|
||||||
() => async (sandbox: Pick<Sandbox, "id" | "name" | "visibility">) => {
|
setDeletingId(sandbox.id)
|
||||||
const newVisibility =
|
toast(`Project ${sandbox.name} deleted.`)
|
||||||
sandbox.visibility === "public" ? "private" : "public"
|
await deleteSandbox(sandbox.id)
|
||||||
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)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deletingId) {
|
if (deletingId) {
|
||||||
@ -54,6 +40,15 @@ export default function DashboardProjects({
|
|||||||
}
|
}
|
||||||
}, [sandboxes])
|
}, [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 (
|
return (
|
||||||
<div className="grow p-4 flex flex-col">
|
<div className="grow p-4 flex flex-col">
|
||||||
<div className="text-xl font-medium mb-8">
|
<div className="text-xl font-medium mb-8">
|
||||||
@ -69,14 +64,30 @@ export default function DashboardProjects({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProjectCard
|
<Link
|
||||||
key={sandbox.id}
|
key={sandbox.id}
|
||||||
onVisibilityChange={onVisibilityChange}
|
href={`/code/${sandbox.id}`}
|
||||||
onDelete={onDelete}
|
className={`${
|
||||||
deletingId={deletingId}
|
deletingId === sandbox.id
|
||||||
isAuthenticated
|
? "pointer-events-none opacity-50 cursor-events-none"
|
||||||
{...sandbox}
|
: "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>
|
</div>
|
||||||
|
@ -6,7 +6,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { projectTemplates } from "@/lib/data"
|
|
||||||
import { ChevronRight } from "lucide-react"
|
import { ChevronRight } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@ -19,9 +18,8 @@ export default function DashboardSharedWithMe({
|
|||||||
shared: {
|
shared: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: "react" | "node"
|
||||||
author: string
|
author: string
|
||||||
authorAvatarUrl: string
|
|
||||||
sharedOn: Date
|
sharedOn: Date
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
@ -47,8 +45,9 @@ export default function DashboardSharedWithMe({
|
|||||||
<Image
|
<Image
|
||||||
alt=""
|
alt=""
|
||||||
src={
|
src={
|
||||||
projectTemplates.find((p) => p.id === sandbox.type)
|
sandbox.type === "react"
|
||||||
?.icon ?? "/project-icons/node.svg"
|
? "/project-icons/react.svg"
|
||||||
|
: "/project-icons/node.svg"
|
||||||
}
|
}
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
@ -59,11 +58,7 @@ export default function DashboardSharedWithMe({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Avatar
|
<Avatar name={sandbox.author} className="mr-2" />
|
||||||
name={sandbox.author}
|
|
||||||
avatarUrl={sandbox.authorAvatarUrl}
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
|
||||||
{sandbox.author}
|
{sandbox.author}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,9 +1,13 @@
|
|||||||
import { TFile, TFolder } from "@/lib/types"
|
import { Send, StopCircle } from "lucide-react"
|
||||||
import { Image as ImageIcon, Paperclip, Send, StopCircle } from "lucide-react"
|
|
||||||
import { useEffect } from "react"
|
|
||||||
import { Button } from "../../ui/button"
|
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({
|
export default function ChatInput({
|
||||||
input,
|
input,
|
||||||
@ -11,235 +15,37 @@ export default function ChatInput({
|
|||||||
isGenerating,
|
isGenerating,
|
||||||
handleSend,
|
handleSend,
|
||||||
handleStopGeneration,
|
handleStopGeneration,
|
||||||
onImageUpload,
|
|
||||||
addContextTab,
|
|
||||||
activeFileName,
|
|
||||||
editorRef,
|
|
||||||
lastCopiedRangeRef,
|
|
||||||
contextTabs,
|
|
||||||
onRemoveTab,
|
|
||||||
textareaRef,
|
|
||||||
}: ChatInputProps) {
|
}: 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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="flex space-x-2 min-w-0">
|
||||||
<div className="flex space-x-2 min-w-0">
|
<input
|
||||||
<textarea
|
type="text"
|
||||||
ref={textareaRef}
|
value={input}
|
||||||
value={input}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onKeyPress={(e) => e.key === "Enter" && !isGenerating && handleSend()}
|
||||||
onKeyDown={handleKeyDown}
|
className="flex-grow p-2 border rounded-lg min-w-0 bg-input"
|
||||||
onPaste={handlePaste}
|
placeholder="Type your message..."
|
||||||
className="flex-grow p-2 border rounded-lg min-w-0 bg-input resize-none overflow-hidden"
|
disabled={isGenerating}
|
||||||
placeholder="Type your message..."
|
/>
|
||||||
|
{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}
|
disabled={isGenerating}
|
||||||
rows={1}
|
size="icon"
|
||||||
/>
|
className="h-10 w-10"
|
||||||
{/* 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}
|
|
||||||
>
|
>
|
||||||
<Paperclip className="h-3 w-3 sm:mr-1" />
|
<Send className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">File</span>
|
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 React, { useState } from "react"
|
||||||
import ReactMarkdown from "react-markdown"
|
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 remarkGfm from "remark-gfm"
|
||||||
import { Button } from "../../ui/button"
|
import { Button } from "../../ui/button"
|
||||||
import ContextTabs from "./ContextTabs"
|
|
||||||
import { copyToClipboard, stringifyContent } from "./lib/chatUtils"
|
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({
|
export default function ChatMessage({
|
||||||
message,
|
message,
|
||||||
setContext,
|
setContext,
|
||||||
setIsContextExpanded,
|
setIsContextExpanded,
|
||||||
socket,
|
|
||||||
handleApplyCode,
|
|
||||||
activeFileName,
|
|
||||||
activeFileContent,
|
|
||||||
editorRef,
|
|
||||||
mergeDecorationsCollection,
|
|
||||||
setMergeDecorationsCollection,
|
|
||||||
selectFile,
|
|
||||||
}: MessageProps) {
|
}: MessageProps) {
|
||||||
// State for expanded message index
|
|
||||||
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
const [expandedMessageIndex, setExpandedMessageIndex] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null)
|
>(null)
|
||||||
|
|
||||||
// State for copied text
|
|
||||||
const [copiedText, setCopiedText] = useState<string | null>(null)
|
const [copiedText, setCopiedText] = useState<string | null>(null)
|
||||||
|
|
||||||
// Render copy button for text content
|
|
||||||
const renderCopyButton = (text: any) => (
|
const renderCopyButton = (text: any) => (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
|
onClick={() => copyToClipboard(stringifyContent(text), setCopiedText)}
|
||||||
@ -45,36 +42,12 @@ export default function ChatMessage({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set context for code when asking about code
|
|
||||||
const askAboutCode = (code: any) => {
|
const askAboutCode = (code: any) => {
|
||||||
const contextString = stringifyContent(code)
|
const contextString = stringifyContent(code)
|
||||||
const newContext = `Regarding this code:\n${contextString}`
|
setContext(`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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setIsContextExpanded(false)
|
setIsContextExpanded(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render markdown elements for code and text
|
|
||||||
const renderMarkdownElement = (props: any) => {
|
const renderMarkdownElement = (props: any) => {
|
||||||
const { node, children } = props
|
const { node, children } = props
|
||||||
const content = stringifyContent(children)
|
const content = stringifyContent(children)
|
||||||
@ -92,7 +65,6 @@ export default function ChatMessage({
|
|||||||
<CornerUpLeft className="w-4 h-4" />
|
<CornerUpLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* Render markdown element */}
|
|
||||||
{React.createElement(
|
{React.createElement(
|
||||||
node.tagName,
|
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 (
|
return (
|
||||||
<div className="text-left relative">
|
<div className="text-left relative">
|
||||||
<div
|
<div
|
||||||
className={`relative p-2 rounded-lg ${
|
className={`relative p-2 rounded-lg ${
|
||||||
message.role === "user"
|
message.role === "user"
|
||||||
? "bg-foreground text-background"
|
? "bg-[#262626] text-white"
|
||||||
: "bg-background text-foreground"
|
: "bg-transparent text-white"
|
||||||
} max-w-full`}
|
} max-w-full`}
|
||||||
>
|
>
|
||||||
{/* Render context tabs */}
|
{message.role === "user" && (
|
||||||
{message.role === "user" && message.context && (
|
<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">
|
<div className="mb-2 bg-input rounded-lg">
|
||||||
<ContextTabs
|
<div
|
||||||
socket={socket}
|
className="flex justify-between items-center cursor-pointer"
|
||||||
activeFileName=""
|
onClick={() =>
|
||||||
onAddFile={() => {}}
|
|
||||||
contextTabs={parseContextToTabs(message.context)}
|
|
||||||
onRemoveTab={() => {}}
|
|
||||||
isExpanded={expandedMessageIndex === 0}
|
|
||||||
onToggleExpand={() =>
|
|
||||||
setExpandedMessageIndex(expandedMessageIndex === 0 ? null : 0)
|
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 && (
|
{expandedMessageIndex === 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute top-0 right-0 flex p-1">
|
<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/, "")
|
message.context.replace(/^Regarding this code:\n/, "")
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Render code textarea */}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const code = message.context.replace(
|
const code = message.context.replace(
|
||||||
/^Regarding this code:\n/,
|
/^Regarding this code:\n/,
|
||||||
@ -166,12 +136,9 @@ export default function ChatMessage({
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updatedContext = `Regarding this code:\n${e.target.value}`
|
const updatedContext = `Regarding this code:\n${e.target.value}`
|
||||||
setContext(updatedContext, "Selected Content", {
|
setContext(updatedContext)
|
||||||
start: 1,
|
|
||||||
end: e.target.value.split("\n").length,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
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}
|
rows={code.split("\n").length}
|
||||||
style={{
|
style={{
|
||||||
resize: "vertical",
|
resize: "vertical",
|
||||||
@ -186,23 +153,68 @@ export default function ChatMessage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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" ? (
|
{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}
|
{message.content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : (
|
) : (
|
||||||
@ -212,43 +224,3 @@ export default function ChatMessage({
|
|||||||
</div>
|
</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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
60
frontend/components/editor/AIChat/ContextDisplay.tsx
Normal file
60
frontend/components/editor/AIChat/ContextDisplay.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,196 +1,50 @@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { X } from "lucide-react"
|
||||||
import { useSocket } from "@/context/SocketContext"
|
|
||||||
import { TFile } from "@/lib/types"
|
|
||||||
import { ChevronDown, X } from "lucide-react"
|
|
||||||
import { nanoid } from "nanoid"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import LoadingDots from "../../ui/LoadingDots"
|
import LoadingDots from "../../ui/LoadingDots"
|
||||||
import ChatInput from "./ChatInput"
|
import ChatInput from "./ChatInput"
|
||||||
import ChatMessage from "./ChatMessage"
|
import ChatMessage from "./ChatMessage"
|
||||||
import ContextTabs from "./ContextTabs"
|
import ContextDisplay from "./ContextDisplay"
|
||||||
import { handleSend, handleStopGeneration } from "./lib/chatUtils"
|
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({
|
export default function AIChat({
|
||||||
activeFileContent,
|
activeFileContent,
|
||||||
activeFileName,
|
activeFileName,
|
||||||
onClose,
|
onClose,
|
||||||
editorRef,
|
}: {
|
||||||
lastCopiedRangeRef,
|
activeFileContent: string
|
||||||
files,
|
activeFileName: string
|
||||||
templateType,
|
onClose: () => void
|
||||||
handleApplyCode,
|
}) {
|
||||||
selectFile,
|
|
||||||
mergeDecorationsCollection,
|
|
||||||
setMergeDecorationsCollection,
|
|
||||||
projectName,
|
|
||||||
}: AIChatProps) {
|
|
||||||
// Initialize socket and messages
|
|
||||||
const { socket } = useSocket()
|
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
|
||||||
// Initialize input and state for generating messages
|
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
|
||||||
// Initialize chat container ref and abort controller ref
|
|
||||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const abortControllerRef = useRef<AbortController | null>(null)
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
const [context, setContext] = useState<string | null>(null)
|
||||||
// Initialize context tabs and state for expanding context
|
|
||||||
const [contextTabs, setContextTabs] = useState<ContextTab[]>([])
|
|
||||||
const [isContextExpanded, setIsContextExpanded] = useState(false)
|
const [isContextExpanded, setIsContextExpanded] = useState(false)
|
||||||
const [isLoading, setIsLoading] = 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(() => {
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-screen w-full">
|
<div className="flex flex-col h-screen w-full">
|
||||||
<div className="flex justify-between items-center p-2 border-b">
|
<div className="flex justify-between items-center p-2 border-b">
|
||||||
@ -209,88 +63,46 @@ export default function AIChat({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea
|
<div
|
||||||
ref={chatContainerRef}
|
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) => (
|
{messages.map((message, messageIndex) => (
|
||||||
// Render chat message component for each message
|
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={messageIndex}
|
key={messageIndex}
|
||||||
message={message}
|
message={message}
|
||||||
setContext={setContext}
|
setContext={setContext}
|
||||||
setIsContextExpanded={setIsContextExpanded}
|
setIsContextExpanded={setIsContextExpanded}
|
||||||
socket={socket}
|
|
||||||
handleApplyCode={handleApplyCode}
|
|
||||||
activeFileName={activeFileName}
|
|
||||||
activeFileContent={activeFileContent}
|
|
||||||
editorRef={editorRef}
|
|
||||||
mergeDecorationsCollection={mergeDecorationsCollection}
|
|
||||||
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
|
||||||
selectFile={selectFile}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoading && <LoadingDots />}
|
{isLoading && <LoadingDots />}
|
||||||
|
</div>
|
||||||
{/* 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 className="p-4 border-t mb-14">
|
<div className="p-4 border-t mb-14">
|
||||||
{/* Render context tabs component */}
|
<ContextDisplay
|
||||||
<ContextTabs
|
context={context}
|
||||||
activeFileName={activeFileName}
|
isContextExpanded={isContextExpanded}
|
||||||
onAddFile={handleAddFile}
|
setIsContextExpanded={setIsContextExpanded}
|
||||||
contextTabs={contextTabs}
|
setContext={setContext}
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/* Render chat input component */}
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
textareaRef={textareaRef}
|
|
||||||
addContextTab={addContextTab}
|
|
||||||
editorRef={editorRef}
|
|
||||||
input={input}
|
input={input}
|
||||||
setInput={setInput}
|
setInput={setInput}
|
||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
handleSend={handleSendWithContext}
|
handleSend={() =>
|
||||||
|
handleSend(
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
setInput,
|
||||||
|
setIsContextExpanded,
|
||||||
|
setIsGenerating,
|
||||||
|
setIsLoading,
|
||||||
|
abortControllerRef,
|
||||||
|
activeFileContent
|
||||||
|
)
|
||||||
|
}
|
||||||
handleStopGeneration={() => handleStopGeneration(abortControllerRef)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,40 +1,30 @@
|
|||||||
import { TFile, TFolder } from "@/lib/types"
|
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
// Stringify content for chat message component
|
|
||||||
export const stringifyContent = (
|
export const stringifyContent = (
|
||||||
content: any,
|
content: any,
|
||||||
seen = new WeakSet()
|
seen = new WeakSet()
|
||||||
): string => {
|
): string => {
|
||||||
// Stringify content if it's a string
|
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
// Stringify content if it's null
|
|
||||||
if (content === null) {
|
if (content === null) {
|
||||||
return "null"
|
return "null"
|
||||||
}
|
}
|
||||||
// Stringify content if it's undefined
|
|
||||||
if (content === undefined) {
|
if (content === undefined) {
|
||||||
return "undefined"
|
return "undefined"
|
||||||
}
|
}
|
||||||
// Stringify content if it's a number or boolean
|
|
||||||
if (typeof content === "number" || typeof content === "boolean") {
|
if (typeof content === "number" || typeof content === "boolean") {
|
||||||
return content.toString()
|
return content.toString()
|
||||||
}
|
}
|
||||||
// Stringify content if it's a function
|
|
||||||
if (typeof content === "function") {
|
if (typeof content === "function") {
|
||||||
return content.toString()
|
return content.toString()
|
||||||
}
|
}
|
||||||
// Stringify content if it's a symbol
|
|
||||||
if (typeof content === "symbol") {
|
if (typeof content === "symbol") {
|
||||||
return content.toString()
|
return content.toString()
|
||||||
}
|
}
|
||||||
// Stringify content if it's a bigint
|
|
||||||
if (typeof content === "bigint") {
|
if (typeof content === "bigint") {
|
||||||
return content.toString() + "n"
|
return content.toString() + "n"
|
||||||
}
|
}
|
||||||
// Stringify content if it's a valid React element
|
|
||||||
if (React.isValidElement(content)) {
|
if (React.isValidElement(content)) {
|
||||||
return React.Children.toArray(
|
return React.Children.toArray(
|
||||||
(content as React.ReactElement).props.children
|
(content as React.ReactElement).props.children
|
||||||
@ -42,13 +32,11 @@ export const stringifyContent = (
|
|||||||
.map((child) => stringifyContent(child, seen))
|
.map((child) => stringifyContent(child, seen))
|
||||||
.join("")
|
.join("")
|
||||||
}
|
}
|
||||||
// Stringify content if it's an array
|
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return (
|
return (
|
||||||
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
|
"[" + content.map((item) => stringifyContent(item, seen)).join(", ") + "]"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Stringify content if it's an object
|
|
||||||
if (typeof content === "object") {
|
if (typeof content === "object") {
|
||||||
if (seen.has(content)) {
|
if (seen.has(content)) {
|
||||||
return "[Circular]"
|
return "[Circular]"
|
||||||
@ -63,23 +51,19 @@ export const stringifyContent = (
|
|||||||
return Object.prototype.toString.call(content)
|
return Object.prototype.toString.call(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stringify content if it's a primitive value
|
|
||||||
return String(content)
|
return String(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy to clipboard for chat message component
|
|
||||||
export const copyToClipboard = (
|
export const copyToClipboard = (
|
||||||
text: string,
|
text: string,
|
||||||
setCopiedText: (text: string | null) => void
|
setCopiedText: (text: string | null) => void
|
||||||
) => {
|
) => {
|
||||||
// Copy text to clipboard for chat message component
|
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
setCopiedText(text)
|
setCopiedText(text)
|
||||||
setTimeout(() => setCopiedText(null), 2000)
|
setTimeout(() => setCopiedText(null), 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle send for chat message component
|
|
||||||
export const handleSend = async (
|
export const handleSend = async (
|
||||||
input: string,
|
input: string,
|
||||||
context: string | null,
|
context: string | null,
|
||||||
@ -90,34 +74,16 @@ export const handleSend = async (
|
|||||||
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
|
setIsGenerating: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
|
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||||
activeFileContent: string,
|
activeFileContent: string
|
||||||
isEditMode: boolean = false,
|
|
||||||
templateType: string,
|
|
||||||
files: (TFile | TFolder)[],
|
|
||||||
projectName: string
|
|
||||||
) => {
|
) => {
|
||||||
// Return if input is empty and context is null
|
|
||||||
if (input.trim() === "" && !context) return
|
if (input.trim() === "" && !context) return
|
||||||
|
|
||||||
// Get timestamp for chat message component
|
const newMessage = {
|
||||||
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 = {
|
|
||||||
role: "user" as const,
|
role: "user" as const,
|
||||||
content: input,
|
content: input,
|
||||||
context: context || undefined,
|
context: context || undefined,
|
||||||
timestamp: timestamp,
|
|
||||||
}
|
}
|
||||||
|
const updatedMessages = [...messages, newMessage]
|
||||||
// Update messages for chat message component
|
|
||||||
const updatedMessages = [...messages, userMessage]
|
|
||||||
setMessages(updatedMessages)
|
setMessages(updatedMessages)
|
||||||
setInput("")
|
setInput("")
|
||||||
setIsContextExpanded(false)
|
setIsContextExpanded(false)
|
||||||
@ -127,49 +93,41 @@ export const handleSend = async (
|
|||||||
abortControllerRef.current = new AbortController()
|
abortControllerRef.current = new AbortController()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create anthropic messages for chat message component
|
|
||||||
const anthropicMessages = updatedMessages.map((msg) => ({
|
const anthropicMessages = updatedMessages.map((msg) => ({
|
||||||
role: msg.role === "user" ? "human" : "assistant",
|
role: msg.role === "user" ? "human" : "assistant",
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Fetch AI response for chat message component
|
const response = await fetch(
|
||||||
const response = await fetch("/api/ai", {
|
`${process.env.NEXT_PUBLIC_AI_WORKER_URL}/api`,
|
||||||
method: "POST",
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify({
|
},
|
||||||
messages: anthropicMessages,
|
body: JSON.stringify({
|
||||||
context: context || undefined,
|
messages: anthropicMessages,
|
||||||
activeFileContent: activeFileContent,
|
context: context || undefined,
|
||||||
isEditMode: isEditMode,
|
activeFileContent: activeFileContent,
|
||||||
templateType: templateType,
|
}),
|
||||||
files: files,
|
signal: abortControllerRef.current.signal,
|
||||||
projectName: projectName,
|
}
|
||||||
}),
|
)
|
||||||
signal: abortControllerRef.current.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Throw error if response is not ok
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.text()
|
throw new Error("Failed to get AI response")
|
||||||
throw new Error(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get reader for chat message component
|
|
||||||
const reader = response.body?.getReader()
|
const reader = response.body?.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
const assistantMessage = { role: "assistant" as const, content: "" }
|
const assistantMessage = { role: "assistant" as const, content: "" }
|
||||||
setMessages([...updatedMessages, assistantMessage])
|
setMessages([...updatedMessages, assistantMessage])
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|
||||||
// Initialize buffer for chat message component
|
|
||||||
let buffer = ""
|
let buffer = ""
|
||||||
const updateInterval = 100
|
const updateInterval = 100
|
||||||
let lastUpdateTime = Date.now()
|
let lastUpdateTime = Date.now()
|
||||||
|
|
||||||
// Read response from reader for chat message component
|
|
||||||
if (reader) {
|
if (reader) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
@ -188,7 +146,6 @@ export const handleSend = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update messages for chat message component
|
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const updatedMessages = [...prev]
|
const updatedMessages = [...prev]
|
||||||
const lastMessage = updatedMessages[updatedMessages.length - 1]
|
const lastMessage = updatedMessages[updatedMessages.length - 1]
|
||||||
@ -197,15 +154,13 @@ export const handleSend = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Handle abort error for chat message component
|
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
console.log("Generation aborted")
|
console.log("Generation aborted")
|
||||||
} else {
|
} else {
|
||||||
console.error("Error fetching AI response:", error)
|
console.error("Error fetching AI response:", error)
|
||||||
const errorMessage = {
|
const errorMessage = {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content:
|
content: "Sorry, I encountered an error. Please try again.",
|
||||||
error.message || "Sorry, I encountered an error. Please try again.",
|
|
||||||
}
|
}
|
||||||
setMessages((prev) => [...prev, errorMessage])
|
setMessages((prev) => [...prev, errorMessage])
|
||||||
}
|
}
|
||||||
@ -216,7 +171,6 @@ export const handleSend = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle stop generation for chat message component
|
|
||||||
export const handleStopGeneration = (
|
export const handleStopGeneration = (
|
||||||
abortControllerRef: React.MutableRefObject<AbortController | null>
|
abortControllerRef: React.MutableRefObject<AbortController | null>
|
||||||
) => {
|
) => {
|
||||||
@ -224,30 +178,3 @@ export const handleStopGeneration = (
|
|||||||
abortControllerRef.current.abort()
|
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)
|
|
||||||
}
|
|
||||||
|
@ -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
|
|
@ -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>
|
|
||||||
),
|
|
||||||
})
|
|
@ -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
|
|
||||||
}
|
|
@ -5,12 +5,14 @@ import { Editor } from "@monaco-editor/react"
|
|||||||
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
|
import { Check, Loader2, RotateCw, Sparkles, X } from "lucide-react"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { Socket } from "socket.io-client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
// import monaco from "monaco-editor"
|
// import monaco from "monaco-editor"
|
||||||
|
|
||||||
export default function GenerateInput({
|
export default function GenerateInput({
|
||||||
user,
|
user,
|
||||||
|
socket,
|
||||||
width,
|
width,
|
||||||
data,
|
data,
|
||||||
editor,
|
editor,
|
||||||
@ -19,6 +21,7 @@ export default function GenerateInput({
|
|||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
user: User
|
user: User
|
||||||
|
socket: Socket
|
||||||
width: number
|
width: number
|
||||||
data: {
|
data: {
|
||||||
fileName: string
|
fileName: string
|
||||||
@ -56,56 +59,32 @@ export default function GenerateInput({
|
|||||||
}: {
|
}: {
|
||||||
regenerate?: boolean
|
regenerate?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
if (user.generations >= 1000) {
|
||||||
setLoading({ generate: !regenerate, regenerate })
|
toast.error("You reached the maximum # of generations.")
|
||||||
setCurrentPrompt(input)
|
return
|
||||||
|
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
const handleGenerateForm = useCallback(
|
||||||
(e: React.FormEvent<HTMLFormElement>) => {
|
(e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
@ -7,11 +7,11 @@ import * as monaco from "monaco-editor"
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
// import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
|
import { TypedLiveblocksProvider, useRoom, useSelf } from "@/liveblocks.config"
|
||||||
// import LiveblocksProvider from "@liveblocks/yjs"
|
import LiveblocksProvider from "@liveblocks/yjs"
|
||||||
// import { MonacoBinding } from "y-monaco"
|
import { MonacoBinding } from "y-monaco"
|
||||||
// import { Awareness } from "y-protocols/awareness"
|
import { Awareness } from "y-protocols/awareness"
|
||||||
// import * as Y from "yjs"
|
import * as Y from "yjs"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
@ -23,6 +23,7 @@ import { useSocket } from "@/context/SocketContext"
|
|||||||
import { parseTSConfigToMonacoOptions } from "@/lib/tsconfig"
|
import { parseTSConfigToMonacoOptions } from "@/lib/tsconfig"
|
||||||
import { Sandbox, TFile, TFolder, TTab, User } from "@/lib/types"
|
import { Sandbox, TFile, TFolder, TTab, User } from "@/lib/types"
|
||||||
import {
|
import {
|
||||||
|
addNew,
|
||||||
cn,
|
cn,
|
||||||
debounce,
|
debounce,
|
||||||
deepMerge,
|
deepMerge,
|
||||||
@ -45,7 +46,7 @@ import { Button } from "../ui/button"
|
|||||||
import Tab from "../ui/tab"
|
import Tab from "../ui/tab"
|
||||||
import AIChat from "./AIChat"
|
import AIChat from "./AIChat"
|
||||||
import GenerateInput from "./generate"
|
import GenerateInput from "./generate"
|
||||||
// import { Cursors } from "./live/cursors"
|
import { Cursors } from "./live/cursors"
|
||||||
import DisableAccessModal from "./live/disableModal"
|
import DisableAccessModal from "./live/disableModal"
|
||||||
import Loading from "./loading"
|
import Loading from "./loading"
|
||||||
import PreviewWindow from "./preview"
|
import PreviewWindow from "./preview"
|
||||||
@ -62,7 +63,7 @@ export default function CodeEditor({
|
|||||||
//SocketContext functions and effects
|
//SocketContext functions and effects
|
||||||
const { socket, setUserAndSandboxId } = useSocket()
|
const { socket, setUserAndSandboxId } = useSocket()
|
||||||
// theme
|
// theme
|
||||||
const { resolvedTheme: theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Ensure userData.id and sandboxData.id are available before attempting to connect
|
// Ensure userData.id and sandboxData.id are available before attempting to connect
|
||||||
if (userData.id && sandboxData.id) {
|
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
|
// Added this state to track the most recent content for each file
|
||||||
const [fileContents, setFileContents] = useState<Record<string, string>>({})
|
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
|
// Editor state
|
||||||
const [editorLanguage, setEditorLanguage] = useState("plaintext")
|
const [editorLanguage, setEditorLanguage] = useState("plaintext")
|
||||||
|
console.log("editor language: ", editorLanguage)
|
||||||
const [cursorLine, setCursorLine] = useState(0)
|
const [cursorLine, setCursorLine] = useState(0)
|
||||||
const [editorRef, setEditorRef] =
|
const [editorRef, setEditorRef] =
|
||||||
useState<monaco.editor.IStandaloneCodeEditor>()
|
useState<monaco.editor.IStandaloneCodeEditor>()
|
||||||
@ -153,20 +148,20 @@ export default function CodeEditor({
|
|||||||
const isOwner = sandboxData.userId === userData.id
|
const isOwner = sandboxData.userId === userData.id
|
||||||
const clerk = useClerk()
|
const clerk = useClerk()
|
||||||
|
|
||||||
// // Liveblocks hooks
|
// Liveblocks hooks
|
||||||
// const room = useRoom()
|
const room = useRoom()
|
||||||
// const [provider, setProvider] = useState<TypedLiveblocksProvider>()
|
const [provider, setProvider] = useState<TypedLiveblocksProvider>()
|
||||||
// const userInfo = useSelf((me) => me.info)
|
const userInfo = useSelf((me) => me.info)
|
||||||
|
|
||||||
// // Liveblocks providers map to prevent reinitializing providers
|
// Liveblocks providers map to prevent reinitializing providers
|
||||||
// type ProviderData = {
|
type ProviderData = {
|
||||||
// provider: LiveblocksProvider<never, never, never, never>
|
provider: LiveblocksProvider<never, never, never, never>
|
||||||
// yDoc: Y.Doc
|
yDoc: Y.Doc
|
||||||
// yText: Y.Text
|
yText: Y.Text
|
||||||
// binding?: MonacoBinding
|
binding?: MonacoBinding
|
||||||
// onSync: (isSynced: boolean) => void
|
onSync: (isSynced: boolean) => void
|
||||||
// }
|
}
|
||||||
// const providersMap = useRef(new Map<string, ProviderData>())
|
const providersMap = useRef(new Map<string, ProviderData>())
|
||||||
|
|
||||||
// Refs for libraries / features
|
// Refs for libraries / features
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||||
@ -178,12 +173,6 @@ export default function CodeEditor({
|
|||||||
const editorPanelRef = useRef<ImperativePanelHandle>(null)
|
const editorPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
const previewWindowRef = useRef<{ refreshIframe: () => void }>(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(
|
const debouncedSetIsSelected = useRef(
|
||||||
debounce((value: boolean) => {
|
debounce((value: boolean) => {
|
||||||
setIsSelected(value)
|
setIsSelected(value)
|
||||||
@ -230,6 +219,7 @@ export default function CodeEditor({
|
|||||||
let mergedConfig: any = { compilerOptions: {} }
|
let mergedConfig: any = { compilerOptions: {} }
|
||||||
|
|
||||||
for (const file of tsconfigFiles) {
|
for (const file of tsconfigFiles) {
|
||||||
|
const containerId = file.id.split("/").slice(0, 2).join("/")
|
||||||
const content = await fetchFileContent(file.id)
|
const content = await fetchFileContent(file.id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -239,7 +229,8 @@ export default function CodeEditor({
|
|||||||
if (tsConfig.references) {
|
if (tsConfig.references) {
|
||||||
for (const ref of tsConfig.references) {
|
for (const ref of tsConfig.references) {
|
||||||
const path = ref.path.replace("./", "")
|
const path = ref.path.replace("./", "")
|
||||||
const refContent = await fetchFileContent(path)
|
const fileId = `${containerId}/${path}`
|
||||||
|
const refContent = await fetchFileContent(fileId)
|
||||||
const referenceTsConfig = JSON.parse(refContent)
|
const referenceTsConfig = JSON.parse(refContent)
|
||||||
|
|
||||||
// Merge configurations
|
// Merge configurations
|
||||||
@ -266,17 +257,6 @@ export default function CodeEditor({
|
|||||||
updatedOptions
|
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
|
// Call the function with your file structure
|
||||||
@ -382,57 +362,6 @@ export default function CodeEditor({
|
|||||||
})
|
})
|
||||||
}, [editorRef])
|
}, [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
|
// Generate widget effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (generate.show) {
|
if (generate.show) {
|
||||||
@ -601,6 +530,8 @@ export default function CodeEditor({
|
|||||||
tab.id === activeFileId ? { ...tab, saved: true } : tab
|
tab.id === activeFileId ? { ...tab, saved: true } : tab
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
console.log(`Saving file...${activeFileId}`)
|
||||||
|
console.log(`Saving file...${content}`)
|
||||||
socket?.emit("saveFile", { fileId: activeFileId, body: content })
|
socket?.emit("saveFile", { fileId: activeFileId, body: content })
|
||||||
}
|
}
|
||||||
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
|
}, Number(process.env.FILE_SAVE_DEBOUNCE_DELAY) || 1000),
|
||||||
@ -631,82 +562,82 @@ export default function CodeEditor({
|
|||||||
}
|
}
|
||||||
}, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef])
|
}, [activeFileId, tabs, debouncedSaveData, setIsAIChatOpen, editorRef])
|
||||||
|
|
||||||
// // Liveblocks live collaboration setup effect
|
// Liveblocks live collaboration setup effect
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// const tab = tabs.find((t) => t.id === activeFileId)
|
const tab = tabs.find((t) => t.id === activeFileId)
|
||||||
// const model = editorRef?.getModel()
|
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.
|
// When a file is opened for the first time, create a new provider and store in providersMap.
|
||||||
// if (!providersMap.current.has(tab.id)) {
|
if (!providersMap.current.has(tab.id)) {
|
||||||
// const yDoc = new Y.Doc()
|
const yDoc = new Y.Doc()
|
||||||
// const yText = yDoc.getText(tab.id)
|
const yText = yDoc.getText(tab.id)
|
||||||
// const yProvider = new LiveblocksProvider(room, yDoc)
|
const yProvider = new LiveblocksProvider(room, yDoc)
|
||||||
|
|
||||||
// // Inserts the file content into the editor once when the tab is changed.
|
// Inserts the file content into the editor once when the tab is changed.
|
||||||
// const onSync = (isSynced: boolean) => {
|
const onSync = (isSynced: boolean) => {
|
||||||
// if (isSynced) {
|
if (isSynced) {
|
||||||
// const text = yText.toString()
|
const text = yText.toString()
|
||||||
// if (text === "") {
|
if (text === "") {
|
||||||
// if (activeFileContent) {
|
if (activeFileContent) {
|
||||||
// yText.insert(0, activeFileContent)
|
yText.insert(0, activeFileContent)
|
||||||
// } else {
|
} else {
|
||||||
// setTimeout(() => {
|
setTimeout(() => {
|
||||||
// yText.insert(0, editorRef.getValue())
|
yText.insert(0, editorRef.getValue())
|
||||||
// }, 0)
|
}, 0)
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// yProvider.on("sync", onSync)
|
yProvider.on("sync", onSync)
|
||||||
|
|
||||||
// // Save the provider to the map.
|
// Save the provider to the map.
|
||||||
// providerData = { provider: yProvider, yDoc, yText, onSync }
|
providerData = { provider: yProvider, yDoc, yText, onSync }
|
||||||
// providersMap.current.set(tab.id, providerData)
|
providersMap.current.set(tab.id, providerData)
|
||||||
// } else {
|
} else {
|
||||||
// // When a tab is opened that has been open before, reuse the existing provider.
|
// When a tab is opened that has been open before, reuse the existing provider.
|
||||||
// providerData = providersMap.current.get(tab.id)!
|
providerData = providersMap.current.get(tab.id)!
|
||||||
// }
|
}
|
||||||
|
|
||||||
// const binding = new MonacoBinding(
|
const binding = new MonacoBinding(
|
||||||
// providerData.yText,
|
providerData.yText,
|
||||||
// model,
|
model,
|
||||||
// new Set([editorRef]),
|
new Set([editorRef]),
|
||||||
// providerData.provider.awareness as unknown as Awareness
|
providerData.provider.awareness as unknown as Awareness
|
||||||
// )
|
)
|
||||||
|
|
||||||
// providerData.binding = binding
|
providerData.binding = binding
|
||||||
// setProvider(providerData.provider)
|
setProvider(providerData.provider)
|
||||||
|
|
||||||
// return () => {
|
return () => {
|
||||||
// // Cleanup logic
|
// Cleanup logic
|
||||||
// if (binding) {
|
if (binding) {
|
||||||
// binding.destroy()
|
binding.destroy()
|
||||||
// }
|
}
|
||||||
// if (providerData.binding) {
|
if (providerData.binding) {
|
||||||
// providerData.binding = undefined
|
providerData.binding = undefined
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }, [room, activeFileContent])
|
}, [room, activeFileContent])
|
||||||
|
|
||||||
// // Added this effect to clean up when the component unmounts
|
// Added this effect to clean up when the component unmounts
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// return () => {
|
return () => {
|
||||||
// // Clean up all providers when the component unmounts
|
// Clean up all providers when the component unmounts
|
||||||
// providersMap.current.forEach((data) => {
|
providersMap.current.forEach((data) => {
|
||||||
// if (data.binding) {
|
if (data.binding) {
|
||||||
// data.binding.destroy()
|
data.binding.destroy()
|
||||||
// }
|
}
|
||||||
// data.provider.disconnect()
|
data.provider.disconnect()
|
||||||
// data.yDoc.destroy()
|
data.yDoc.destroy()
|
||||||
// })
|
})
|
||||||
// providersMap.current.clear()
|
providersMap.current.clear()
|
||||||
// }
|
}
|
||||||
// }, [])
|
}, [])
|
||||||
|
|
||||||
// Connection/disconnection effect
|
// Connection/disconnection effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -718,7 +649,7 @@ export default function CodeEditor({
|
|||||||
|
|
||||||
// Socket event listener effect
|
// Socket event listener effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onConnect = () => {}
|
const onConnect = () => { }
|
||||||
|
|
||||||
const onDisconnect = () => {
|
const onDisconnect = () => {
|
||||||
setTerminals([])
|
setTerminals([])
|
||||||
@ -793,32 +724,31 @@ export default function CodeEditor({
|
|||||||
setGenerate((prev) => ({ ...prev, show: false }))
|
setGenerate((prev) => ({ ...prev, show: false }))
|
||||||
|
|
||||||
// Check if the tab already exists in the list of open tabs
|
// Check if the tab already exists in the list of open tabs
|
||||||
const existingTab = tabs.find((t) => t.id === tab.id)
|
const exists = tabs.find((t) => t.id === tab.id)
|
||||||
|
setTabs((prev) => {
|
||||||
if (existingTab) {
|
if (exists) {
|
||||||
// If the tab exists, just make it active
|
// If the tab exists, make it the active tab
|
||||||
setActiveFileId(existingTab.id)
|
setActiveFileId(exists.id)
|
||||||
if (fileContents[existingTab.id]) {
|
return prev
|
||||||
setActiveFileContent(fileContents[existingTab.id])
|
|
||||||
}
|
}
|
||||||
|
// 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 {
|
} else {
|
||||||
// If the tab doesn't exist, add it to the list and make it active
|
// Otherwise, fetch the content of the file and cache it
|
||||||
setTabs((prev) => [...prev, tab])
|
debouncedGetFile(tab.id, (response: string) => {
|
||||||
|
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
|
||||||
// Fetch content if not cached
|
setActiveFileContent(response)
|
||||||
if (!fileContents[tab.id]) {
|
})
|
||||||
debouncedGetFile(tab.id, (response: string) => {
|
|
||||||
setFileContents((prev) => ({ ...prev, [tab.id]: response }))
|
|
||||||
setActiveFileContent(response)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setActiveFileContent(fileContents[tab.id])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the editor language based on the file type
|
// Set the editor language based on the file type
|
||||||
setEditorLanguage(processFileType(tab.name))
|
setEditorLanguage(processFileType(tab.name))
|
||||||
// Set the active file ID
|
// Set the active file ID to the new tab
|
||||||
setActiveFileId(tab.id)
|
setActiveFileId(tab.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -847,8 +777,8 @@ export default function CodeEditor({
|
|||||||
? numTabs === 1
|
? numTabs === 1
|
||||||
? null
|
? null
|
||||||
: index < numTabs - 1
|
: index < numTabs - 1
|
||||||
? tabs[index + 1].id
|
? tabs[index + 1].id
|
||||||
: tabs[index - 1].id
|
: tabs[index - 1].id
|
||||||
: activeFileId
|
: activeFileId
|
||||||
|
|
||||||
setTabs((prev) => prev.filter((t) => t.id !== id))
|
setTabs((prev) => prev.filter((t) => t.id !== id))
|
||||||
@ -914,7 +844,9 @@ export default function CodeEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteFile = (file: TFile) => {
|
const handleDeleteFile = (file: TFile) => {
|
||||||
socket?.emit("deleteFile", { fileId: file.id })
|
socket?.emit("deleteFile", { fileId: file.id }, (response: (TFolder | TFile)[]) => {
|
||||||
|
setFiles(response)
|
||||||
|
})
|
||||||
closeTab(file.id)
|
closeTab(file.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -926,13 +858,10 @@ export default function CodeEditor({
|
|||||||
closeTabs(response)
|
closeTabs(response)
|
||||||
)
|
)
|
||||||
|
|
||||||
socket?.emit(
|
socket?.emit("deleteFolder", { folderId: folder.id }, (response: (TFolder | TFile)[]) => {
|
||||||
"deleteFolder",
|
setFiles(response)
|
||||||
{ folderId: folder.id },
|
setDeletingFolderId("")
|
||||||
(response: (TFolder | TFile)[]) => {
|
})
|
||||||
setDeletingFolderId("")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const togglePreviewPanel = () => {
|
const togglePreviewPanel = () => {
|
||||||
@ -973,16 +902,16 @@ export default function CodeEditor({
|
|||||||
<DisableAccessModal
|
<DisableAccessModal
|
||||||
message={disableAccess.message}
|
message={disableAccess.message}
|
||||||
open={disableAccess.isDisabled}
|
open={disableAccess.isDisabled}
|
||||||
setOpen={() => {}}
|
setOpen={() => { }}
|
||||||
/>
|
/>
|
||||||
<Loading />
|
<Loading />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-h-full overflow-hidden">
|
<>
|
||||||
|
{/* Copilot DOM elements */}
|
||||||
<PreviewProvider>
|
<PreviewProvider>
|
||||||
{/* Copilot DOM elements */}
|
|
||||||
<div ref={generateRef} />
|
<div ref={generateRef} />
|
||||||
<div ref={suggestionRef} className="absolute">
|
<div ref={suggestionRef} className="absolute">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@ -1008,14 +937,15 @@ export default function CodeEditor({
|
|||||||
{generate.show ? (
|
{generate.show ? (
|
||||||
<GenerateInput
|
<GenerateInput
|
||||||
user={userData}
|
user={userData}
|
||||||
|
socket={socket!}
|
||||||
width={generate.width - 90}
|
width={generate.width - 90}
|
||||||
data={{
|
data={{
|
||||||
fileName: tabs.find((t) => t.id === activeFileId)?.name ?? "",
|
fileName: tabs.find((t) => t.id === activeFileId)?.name ?? "",
|
||||||
code:
|
code:
|
||||||
(isSelected && editorRef?.getSelection()
|
(isSelected && editorRef?.getSelection()
|
||||||
? editorRef
|
? editorRef
|
||||||
?.getModel()
|
?.getModel()
|
||||||
?.getValueInRange(editorRef?.getSelection()!)
|
?.getValueInRange(editorRef?.getSelection()!)
|
||||||
: editorRef?.getValue()) ?? "",
|
: editorRef?.getValue()) ?? "",
|
||||||
line: generate.line,
|
line: generate.line,
|
||||||
}}
|
}}
|
||||||
@ -1097,9 +1027,8 @@ export default function CodeEditor({
|
|||||||
handleDeleteFolder={handleDeleteFolder}
|
handleDeleteFolder={handleDeleteFolder}
|
||||||
socket={socket!}
|
socket={socket!}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
|
addNew={(name, type) => addNew(name, type, setFiles, sandboxData)}
|
||||||
deletingFolderId={deletingFolderId}
|
deletingFolderId={deletingFolderId}
|
||||||
toggleAIChat={toggleAIChat}
|
|
||||||
isAIChatOpen={isAIChatOpen}
|
|
||||||
/>
|
/>
|
||||||
{/* Outer ResizablePanelGroup for main layout */}
|
{/* Outer ResizablePanelGroup for main layout */}
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@ -1146,62 +1075,62 @@ export default function CodeEditor({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
|
) : // Note clerk.loaded is required here due to a bug: https://github.com/clerk/javascript/issues/1643
|
||||||
clerk.loaded ? (
|
clerk.loaded ? (
|
||||||
<>
|
<>
|
||||||
{/* {provider && userInfo ? (
|
{provider && userInfo ? (
|
||||||
<Cursors yProvider={provider} userInfo={userInfo} />
|
<Cursors yProvider={provider} userInfo={userInfo} />
|
||||||
) : null} */}
|
) : null}
|
||||||
<Editor
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
language={editorLanguage}
|
language={editorLanguage}
|
||||||
beforeMount={handleEditorWillMount}
|
beforeMount={handleEditorWillMount}
|
||||||
onMount={handleEditorMount}
|
onMount={handleEditorMount}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
// If the new content is different from the cached content, update it
|
// If the new content is different from the cached content, update it
|
||||||
if (value !== fileContents[activeFileId]) {
|
if (value !== fileContents[activeFileId]) {
|
||||||
setActiveFileContent(value ?? "") // Update the active file content
|
setActiveFileContent(value ?? "") // Update the active file content
|
||||||
// Mark the file as unsaved by setting 'saved' to false
|
// Mark the file as unsaved by setting 'saved' to false
|
||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
prev.map((tab) =>
|
prev.map((tab) =>
|
||||||
tab.id === activeFileId
|
tab.id === activeFileId
|
||||||
? { ...tab, saved: false }
|
? { ...tab, saved: false }
|
||||||
: tab
|
: tab
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
} else {
|
||||||
} else {
|
// If the content matches the cached content, mark the file as saved
|
||||||
// If the content matches the cached content, mark the file as saved
|
setTabs((prev) =>
|
||||||
setTabs((prev) =>
|
prev.map((tab) =>
|
||||||
prev.map((tab) =>
|
tab.id === activeFileId
|
||||||
tab.id === activeFileId
|
? { ...tab, saved: true }
|
||||||
? { ...tab, saved: true }
|
: tab
|
||||||
: tab
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}}
|
||||||
}}
|
options={{
|
||||||
options={{
|
tabSize: 2,
|
||||||
tabSize: 2,
|
minimap: {
|
||||||
minimap: {
|
enabled: false,
|
||||||
enabled: false,
|
},
|
||||||
},
|
padding: {
|
||||||
padding: {
|
bottom: 4,
|
||||||
bottom: 4,
|
top: 4,
|
||||||
top: 4,
|
},
|
||||||
},
|
scrollBeyondLastLine: false,
|
||||||
scrollBeyondLastLine: false,
|
fixedOverflowWidgets: true,
|
||||||
fixedOverflowWidgets: true,
|
fontFamily: "var(--font-geist-mono)",
|
||||||
fontFamily: "var(--font-geist-mono)",
|
}}
|
||||||
}}
|
theme={theme === "light" ? "vs" : "vs-dark"}
|
||||||
theme={theme === "light" ? "vs" : "vs-dark"}
|
value={activeFileContent}
|
||||||
value={activeFileContent}
|
/>
|
||||||
/>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<div className="w-full h-full flex items-center justify-center text-xl font-medium text-muted-foreground/50 select-none">
|
||||||
<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" />
|
||||||
<Loader2 className="animate-spin w-6 h-6 mr-3" />
|
Waiting for Clerk to load...
|
||||||
Waiting for Clerk to load...
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
@ -1211,10 +1140,10 @@ export default function CodeEditor({
|
|||||||
isAIChatOpen && isHorizontalLayout
|
isAIChatOpen && isHorizontalLayout
|
||||||
? "horizontal"
|
? "horizontal"
|
||||||
: isAIChatOpen
|
: isAIChatOpen
|
||||||
? "vertical"
|
? "vertical"
|
||||||
: isHorizontalLayout
|
: isHorizontalLayout
|
||||||
? "horizontal"
|
? "horizontal"
|
||||||
: "vertical"
|
: "vertical"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
@ -1289,36 +1218,14 @@ export default function CodeEditor({
|
|||||||
"No file selected"
|
"No file selected"
|
||||||
}
|
}
|
||||||
onClose={toggleAIChat}
|
onClose={toggleAIChat}
|
||||||
editorRef={{ current: editorRef }}
|
|
||||||
lastCopiedRangeRef={lastCopiedRangeRef}
|
|
||||||
files={files}
|
|
||||||
templateType={sandboxData.type}
|
|
||||||
projectName={sandboxData.name}
|
|
||||||
handleApplyCode={handleApplyCode}
|
|
||||||
mergeDecorationsCollection={mergeDecorationsCollection}
|
|
||||||
setMergeDecorationsCollection={setMergeDecorationsCollection}
|
|
||||||
selectFile={selectFile}
|
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</PreviewProvider>
|
</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,
|
|
||||||
}
|
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
import { useTerminal } from "@/context/TerminalContext"
|
import { useTerminal } from "@/context/TerminalContext"
|
||||||
import { Sandbox, User } from "@/lib/types"
|
import { Sandbox, User } from "@/lib/types"
|
||||||
import { Globe } from "lucide-react"
|
import { Globe } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
export default function DeployButtonModal({
|
export default function DeployButtonModal({
|
||||||
userData,
|
userData,
|
||||||
@ -18,21 +18,8 @@ export default function DeployButtonModal({
|
|||||||
userData: User
|
userData: User
|
||||||
data: Sandbox
|
data: Sandbox
|
||||||
}) {
|
}) {
|
||||||
const { deploy, getAppExists } = useTerminal()
|
const { deploy } = useTerminal()
|
||||||
const [isDeploying, setIsDeploying] = useState(false)
|
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 = () => {
|
const handleDeploy = () => {
|
||||||
if (isDeploying) {
|
if (isDeploying) {
|
||||||
@ -43,7 +30,6 @@ export default function DeployButtonModal({
|
|||||||
setIsDeploying(true)
|
setIsDeploying(true)
|
||||||
deploy(() => {
|
deploy(() => {
|
||||||
setIsDeploying(false)
|
setIsDeploying(false)
|
||||||
setIsDeployed(true)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,12 +38,10 @@ export default function DeployButtonModal({
|
|||||||
<>
|
<>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
{deployButtonVisible && (
|
<Button variant="outline">
|
||||||
<Button variant="outline">
|
<Globe className="w-4 h-4 mr-2" />
|
||||||
<Globe className="w-4 h-4 mr-2" />
|
Deploy
|
||||||
Deploy
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<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"
|
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
|
<DeploymentOption
|
||||||
icon={<Globe className="text-gray-500 w-5 h-5" />}
|
icon={<Globe className="text-gray-500 w-5 h-5" />}
|
||||||
domain={`${data.id}.gitwit.app`}
|
domain={`${data.id}.gitwit.app`}
|
||||||
timestamp="Deployed 1m ago"
|
timestamp="Deployed 1h ago"
|
||||||
user={userData.name}
|
user={userData.name}
|
||||||
isDeployed={isDeployed}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -78,7 +61,7 @@ export default function DeployButtonModal({
|
|||||||
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
|
className="mt-4 w-full bg-[#0a0a0a] text-white hover:bg-[#262626]"
|
||||||
onClick={handleDeploy}
|
onClick={handleDeploy}
|
||||||
>
|
>
|
||||||
{isDeploying ? "Deploying..." : isDeployed ? "Update" : "Deploy"}
|
{isDeploying ? "Deploying..." : "Update"}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@ -91,33 +74,27 @@ function DeploymentOption({
|
|||||||
domain,
|
domain,
|
||||||
timestamp,
|
timestamp,
|
||||||
user,
|
user,
|
||||||
isDeployed,
|
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
domain: string
|
domain: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
user: string
|
user: string
|
||||||
isDeployed: boolean
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
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 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 items-start gap-2 relative">
|
||||||
<div className="flex-shrink-0">{icon}</div>
|
<div className="flex-shrink-0">{icon}</div>
|
||||||
{isDeployed ? (
|
<a
|
||||||
<a
|
href={`https://${domain}`}
|
||||||
href={`https://${domain}`}
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
className="font-semibold text-gray-300 hover:underline"
|
||||||
className="font-semibold text-gray-300 hover:underline"
|
>
|
||||||
>
|
{domain}
|
||||||
{domain}
|
</a>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="font-semibold text-gray-300">{domain}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-400 mt-0 ml-7">
|
<p className="text-sm text-gray-400 mt-0 ml-7">
|
||||||
{isDeployed ? `${timestamp} • ${user}` : "Never deployed"}
|
{timestamp} • {user}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -9,9 +9,8 @@ import { Pencil, Users } from "lucide-react"
|
|||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
// import { Avatars } from "../live/avatars"
|
import { Avatars } from "../live/avatars"
|
||||||
import DeployButtonModal from "./deploy"
|
import DeployButtonModal from "./deploy"
|
||||||
import DownloadButton from "./downloadButton"
|
|
||||||
import EditSandboxModal from "./edit"
|
import EditSandboxModal from "./edit"
|
||||||
import RunButtonModal from "./run"
|
import RunButtonModal from "./run"
|
||||||
import ShareSandboxModal from "./share"
|
import ShareSandboxModal from "./share"
|
||||||
@ -23,7 +22,7 @@ export default function Navbar({
|
|||||||
}: {
|
}: {
|
||||||
userData: User
|
userData: User
|
||||||
sandboxData: Sandbox
|
sandboxData: Sandbox
|
||||||
shared: { id: string; name: string; avatarUrl: string }[]
|
shared: { id: string; name: string }[]
|
||||||
}) {
|
}) {
|
||||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||||
const [isShareOpen, setIsShareOpen] = useState(false)
|
const [isShareOpen, setIsShareOpen] = useState(false)
|
||||||
@ -70,16 +69,16 @@ export default function Navbar({
|
|||||||
sandboxData={sandboxData}
|
sandboxData={sandboxData}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center h-full space-x-4">
|
<div className="flex items-center h-full space-x-4">
|
||||||
{/* <Avatars /> */}
|
<Avatars />
|
||||||
|
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<>
|
<>
|
||||||
<DeployButtonModal data={sandboxData} userData={userData} />
|
<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" />
|
<Users className="w-4 h-4 mr-2" />
|
||||||
Share
|
Share
|
||||||
</Button> */}
|
</Button>
|
||||||
<DownloadButton name={sandboxData.name} /></>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
<UserButton userData={userData} />
|
<UserButton userData={userData} />
|
||||||
|
@ -7,7 +7,6 @@ import { Sandbox } from "@/lib/types"
|
|||||||
import { Play, StopCircle } from "lucide-react"
|
import { Play, StopCircle } from "lucide-react"
|
||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { templateConfigs } from "@/lib/templates"
|
|
||||||
|
|
||||||
export default function RunButtonModal({
|
export default function RunButtonModal({
|
||||||
isRunning,
|
isRunning,
|
||||||
@ -35,7 +34,7 @@ export default function RunButtonModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [terminals, isRunning])
|
}, [terminals, isRunning])
|
||||||
// commands to run in the terminal
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (isRunning && lastCreatedTerminalRef.current) {
|
if (isRunning && lastCreatedTerminalRef.current) {
|
||||||
await closeTerminal(lastCreatedTerminalRef.current)
|
await closeTerminal(lastCreatedTerminalRef.current)
|
||||||
@ -43,7 +42,10 @@ export default function RunButtonModal({
|
|||||||
setIsPreviewCollapsed(true)
|
setIsPreviewCollapsed(true)
|
||||||
previewPanelRef.current?.collapse()
|
previewPanelRef.current?.collapse()
|
||||||
} else if (!isRunning && terminals.length < 4) {
|
} 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 {
|
try {
|
||||||
// Create a new terminal with the appropriate command
|
// Create a new terminal with the appropriate command
|
||||||
|
@ -43,7 +43,6 @@ export default function ShareSandboxModal({
|
|||||||
shared: {
|
shared: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
avatarUrl: string
|
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
@ -10,7 +10,7 @@ export default function SharedUser({
|
|||||||
user,
|
user,
|
||||||
sandboxId,
|
sandboxId,
|
||||||
}: {
|
}: {
|
||||||
user: { id: string; name: string; avatarUrl: string }
|
user: { id: string; name: string }
|
||||||
sandboxId: string
|
sandboxId: string
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@ -24,7 +24,7 @@ export default function SharedUser({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Avatar name={user.name} avatarUrl={user.avatarUrl} className="mr-2" />
|
<Avatar name={user.name} className="mr-2" />
|
||||||
{user.name}
|
{user.name}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
@ -9,9 +9,8 @@ import SidebarFolder from "./folder"
|
|||||||
import New from "./new"
|
import New from "./new"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { cn, sortFileExplorer } from "@/lib/utils"
|
import { sortFileExplorer } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
dropTargetForElements,
|
dropTargetForElements,
|
||||||
monitorForElements,
|
monitorForElements,
|
||||||
@ -26,9 +25,8 @@ export default function Sidebar({
|
|||||||
handleDeleteFolder,
|
handleDeleteFolder,
|
||||||
socket,
|
socket,
|
||||||
setFiles,
|
setFiles,
|
||||||
|
addNew,
|
||||||
deletingFolderId,
|
deletingFolderId,
|
||||||
toggleAIChat,
|
|
||||||
isAIChatOpen,
|
|
||||||
}: {
|
}: {
|
||||||
sandboxData: Sandbox
|
sandboxData: Sandbox
|
||||||
files: (TFile | TFolder)[]
|
files: (TFile | TFolder)[]
|
||||||
@ -43,9 +41,8 @@ export default function Sidebar({
|
|||||||
handleDeleteFolder: (folder: TFolder) => void
|
handleDeleteFolder: (folder: TFolder) => void
|
||||||
socket: Socket
|
socket: Socket
|
||||||
setFiles: (files: (TFile | TFolder)[]) => void
|
setFiles: (files: (TFile | TFolder)[]) => void
|
||||||
|
addNew: (name: string, type: "file" | "folder") => void
|
||||||
deletingFolderId: string
|
deletingFolderId: string
|
||||||
toggleAIChat: () => void
|
|
||||||
isAIChatOpen: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef(null) // drop target
|
const ref = useRef(null) // drop target
|
||||||
|
|
||||||
@ -92,7 +89,7 @@ export default function Sidebar({
|
|||||||
"moveFile",
|
"moveFile",
|
||||||
{
|
{
|
||||||
fileId,
|
fileId,
|
||||||
folderId,
|
folderId
|
||||||
},
|
},
|
||||||
(response: (TFolder | TFile)[]) => {
|
(response: (TFolder | TFile)[]) => {
|
||||||
setFiles(response)
|
setFiles(response)
|
||||||
@ -105,8 +102,8 @@ export default function Sidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-56 select-none flex flex-col text-sm">
|
<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-grow overflow-auto p-2 pb-[84px]">
|
||||||
<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 w-full items-center justify-between h-8 mb-1">
|
||||||
<div className="text-muted-foreground">Explorer</div>
|
<div className="text-muted-foreground">Explorer</div>
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1">
|
||||||
<button
|
<button
|
||||||
@ -175,13 +172,14 @@ export default function Sidebar({
|
|||||||
stopEditing={() => {
|
stopEditing={() => {
|
||||||
setCreatingNew(null)
|
setCreatingNew(null)
|
||||||
}}
|
}}
|
||||||
|
addNew={addNew}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
<div className="flex flex-col p-2 bg-background">
|
<div className="fixed bottom-0 w-48 flex flex-col p-2 bg-background">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2"
|
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 }}
|
style={{ opacity: 1 }}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
|
<Sparkles className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
|
||||||
AI Editor
|
Copilot
|
||||||
<div className="ml-auto">
|
<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">
|
<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
|
<span className="text-xs">⌘</span>G
|
||||||
@ -199,22 +197,12 @@ export default function Sidebar({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className="w-full justify-start text-sm text-muted-foreground font-normal h-8 px-2 mb-2"
|
||||||
"w-full justify-start text-sm font-normal h-8 px-2 mb-2 border-t",
|
disabled
|
||||||
isAIChatOpen
|
aria-disabled="true"
|
||||||
? "bg-muted-foreground/25 text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
onClick={toggleAIChat}
|
|
||||||
aria-disabled={false}
|
|
||||||
style={{ opacity: 1 }}
|
style={{ opacity: 1 }}
|
||||||
>
|
>
|
||||||
<MessageSquareMore
|
<MessageSquareMore className="h-4 w-4 mr-2 text-indigo-500 opacity-70" />
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 mr-2",
|
|
||||||
isAIChatOpen ? "text-indigo-500" : "text-indigo-500 opacity-70"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
AI Chat
|
AI Chat
|
||||||
<div className="ml-auto">
|
<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">
|
<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">
|
||||||
|
@ -9,10 +9,12 @@ export default function New({
|
|||||||
socket,
|
socket,
|
||||||
type,
|
type,
|
||||||
stopEditing,
|
stopEditing,
|
||||||
|
addNew,
|
||||||
}: {
|
}: {
|
||||||
socket: Socket
|
socket: Socket
|
||||||
type: "file" | "folder"
|
type: "file" | "folder"
|
||||||
stopEditing: () => void
|
stopEditing: () => void
|
||||||
|
addNew: (name: string, type: "file" | "folder") => void
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@ -23,9 +25,19 @@ export default function New({
|
|||||||
const valid = validateName(name, "", type)
|
const valid = validateName(name, "", type)
|
||||||
if (valid.status) {
|
if (valid.status) {
|
||||||
if (type === "file") {
|
if (type === "file") {
|
||||||
socket.emit("createFile", { name })
|
socket.emit(
|
||||||
|
"createFile",
|
||||||
|
{ name },
|
||||||
|
({ success }: { success: boolean }) => {
|
||||||
|
if (success) {
|
||||||
|
addNew(name, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
socket.emit("createFolder", { name })
|
socket.emit("createFolder", { name }, () => {
|
||||||
|
addNew(name, type)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ import "./xterm.css"
|
|||||||
import { debounce } from "@/lib/utils"
|
import { debounce } from "@/lib/utils"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { ElementRef, useEffect, useRef, useCallback } from "react"
|
import { ElementRef, useEffect, useRef } from "react"
|
||||||
import { Socket } from "socket.io-client"
|
import { Socket } from "socket.io-client"
|
||||||
export default function EditorTerminal({
|
export default function EditorTerminal({
|
||||||
socket,
|
socket,
|
||||||
@ -22,12 +22,13 @@ export default function EditorTerminal({
|
|||||||
setTerm: (term: Terminal) => void
|
setTerm: (term: Terminal) => void
|
||||||
visible: boolean
|
visible: boolean
|
||||||
}) {
|
}) {
|
||||||
const { resolvedTheme: theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const terminalContainerRef = useRef<ElementRef<"div">>(null)
|
const terminalContainerRef = useRef<ElementRef<"div">>(null)
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminalContainerRef.current) return
|
if (!terminalContainerRef.current) return
|
||||||
|
// console.log("new terminal", id, term ? "reusing" : "creating");
|
||||||
|
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
@ -36,54 +37,13 @@ export default function EditorTerminal({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
letterSpacing: 0,
|
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)
|
setTerm(terminal)
|
||||||
|
const dispose = () => {
|
||||||
return () => {
|
|
||||||
terminal.dispose()
|
terminal.dispose()
|
||||||
terminalContainerRef.current?.removeEventListener(
|
|
||||||
"contextmenu",
|
|
||||||
handleContextMenu
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return dispose
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -121,6 +81,7 @@ export default function EditorTerminal({
|
|||||||
|
|
||||||
const { width, height } = entry.contentRect
|
const { width, height } = entry.contentRect
|
||||||
|
|
||||||
|
// Only call fit if the size has actually changed
|
||||||
if (
|
if (
|
||||||
width !== terminalContainerRef.current.offsetWidth ||
|
width !== terminalContainerRef.current.offsetWidth ||
|
||||||
height !== terminalContainerRef.current.offsetHeight
|
height !== terminalContainerRef.current.offsetHeight
|
||||||
@ -131,9 +92,10 @@ export default function EditorTerminal({
|
|||||||
console.error("Error during fit:", err)
|
console.error("Error during fit:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 50)
|
}, 50) // Debounce for 50ms
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// start observing for resize
|
||||||
resizeObserver.observe(terminalContainerRef.current)
|
resizeObserver.observe(terminalContainerRef.current)
|
||||||
return () => {
|
return () => {
|
||||||
disposableOnData.dispose()
|
disposableOnData.dispose()
|
||||||
@ -162,7 +124,6 @@ export default function EditorTerminal({
|
|||||||
ref={terminalContainerRef}
|
ref={terminalContainerRef}
|
||||||
style={{ display: visible ? "block" : "none" }}
|
style={{ display: visible ? "block" : "none" }}
|
||||||
className="w-full h-full text-left"
|
className="w-full h-full text-left"
|
||||||
tabIndex={0}
|
|
||||||
>
|
>
|
||||||
{term === null ? (
|
{term === null ? (
|
||||||
<div className="flex items-center text-muted-foreground p-2">
|
<div className="flex items-center text-muted-foreground p-2">
|
||||||
|
@ -22,7 +22,7 @@ export default function Landing() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Button variant="outline" size="icon" asChild>
|
<Button variant="outline" size="icon" asChild>
|
||||||
<a href="https://x.com/gitwitdev" target="_blank">
|
<a href="https://www.x.com/ishaandey_" target="_blank">
|
||||||
<svg
|
<svg
|
||||||
width="1200"
|
width="1200"
|
||||||
height="1227"
|
height="1227"
|
||||||
@ -45,21 +45,16 @@ export default function Landing() {
|
|||||||
<h1 className="text-2xl font-medium text-center mt-16">
|
<h1 className="text-2xl font-medium text-center mt-16">
|
||||||
A Collaborative + AI-Powered Code Environment
|
A Collaborative + AI-Powered Code Environment
|
||||||
</h1>
|
</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
|
Sandbox is an open-source cloud-based code editing environment with
|
||||||
custom AI code autocompletion and real-time collaboration.
|
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>
|
</p>
|
||||||
<div className="mt-8 flex space-x-4">
|
<div className="mt-8 flex space-x-4">
|
||||||
<Link href="/sign-up">
|
<Link href="/sign-up">
|
||||||
<CustomButton>Go To App</CustomButton>
|
<CustomButton>Go To App</CustomButton>
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/jamesmurdza/sandbox"
|
href="https://github.com/ishaan1013/sandbox"
|
||||||
target="_blank"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
@ -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
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
@ -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 }
|
|
@ -2,40 +2,22 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
export default function Avatar({
|
export default function Avatar({
|
||||||
name,
|
name,
|
||||||
avatarUrl,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
name: string
|
name: string
|
||||||
avatarUrl?: string | null
|
|
||||||
className?: string
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 ? (
|
{name
|
||||||
<img
|
.split(" ")
|
||||||
src={avatarUrl}
|
.slice(0, 2)
|
||||||
alt={name || "User"}
|
.map((letter) => letter[0].toUpperCase())}
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
initials
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
|
@ -5,7 +5,7 @@ import * as React from "react"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@ -26,7 +26,7 @@ const buttonVariants = cva(
|
|||||||
sm: "h-8 rounded-md px-3 text-xs",
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
lg: "h-10 rounded-md px-8",
|
lg: "h-10 rounded-md px-8",
|
||||||
icon: "h-9 w-9",
|
icon: "h-9 w-9",
|
||||||
smIcon: "size-8",
|
smIcon: "h-8 w-8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import * as React from "react"
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
ControllerProps,
|
ControllerProps,
|
||||||
@ -12,8 +10,8 @@ import {
|
|||||||
useFormContext,
|
useFormContext,
|
||||||
} from "react-hook-form"
|
} from "react-hook-form"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Form = FormProvider
|
const Form = FormProvider
|
||||||
|
|
||||||
@ -95,7 +93,7 @@ const FormLabel = React.forwardRef<
|
|||||||
return (
|
return (
|
||||||
<Label
|
<Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(error && "text-destructive", className)}
|
className={cn(className)}
|
||||||
htmlFor={formItemId}
|
htmlFor={formItemId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@ -167,12 +165,12 @@ const FormMessage = React.forwardRef<
|
|||||||
FormMessage.displayName = "FormMessage"
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
useFormField,
|
|
||||||
Form,
|
Form,
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormMessage,
|
|
||||||
FormField,
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useFormField,
|
||||||
}
|
}
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user