Compare commits

...

50 Commits

Author SHA1 Message Date
Akhilesh Rangani
bcbbe5abfd
chore: add database setup guide. 2025-01-13 07:42:53 -05:00
Akhileshrangani4
15426cac57 feat: update wrangler and add database migrations 2025-01-13 07:10:29 -05:00
James Murdza
3b8c9e12e4
Merge pull request #19 from Code-Victor/fix/layout-issues
Fix/layout issues
2025-01-08 16:16:06 -08:00
Hamzat Victor
1f4e70788e feat: enhance scrollbars with radix ui & shadcn 2025-01-08 23:04:21 +01:00
Hamzat Victor
b0d444980a fix: make layout more concrete with css grids 2025-01-08 23:03:51 +01:00
James Murdza
cfa9485e97
Merge pull request #17 from Code-Victor/feat/improve-seo
Feat/improve seo
2025-01-08 09:07:43 -08:00
James Murdza
960a104011
Merge pull request #16 from Code-Victor/feat/update-profile-ui
Feat/update profile UI
2025-01-08 09:07:35 -08:00
Hamzat Victor
59880fcd6e feat: update Og image 2025-01-08 17:44:52 +01:00
Hamzat Victor
92584a3ea6 feat: add Seo tags(open graph & twitter) 2025-01-08 01:41:41 +01:00
Hamzat Victor
cfc0ae6cf4 feat: update app icon 2025-01-08 01:41:22 +01:00
Hamzat Victor
e4f4bcfdcc feat: add OG image for social media 2025-01-08 01:41:12 +01:00
Hamzat Victor
108fa4ff1f feat: update profile logo and navbar 2025-01-08 00:16:29 +01:00
Hamzat Victor
103faf1d09 feat: update profile UI visual hierachy 2025-01-08 00:16:06 +01:00
Anirudh
2c7527055f fix: chat msg 2025-01-07 07:57:12 -08:00
Anirudh
bde591006e fix: use foreground colors for AI CHat 2025-01-07 07:57:09 -08:00
James Murdza
94ffbaf60f
Merge pull request #15 from techwithani/main
fix: small bugs
2025-01-06 21:21:11 -08:00
Anirudh
e62849ab96 fix: use latest theme provider 2025-01-07 04:22:57 +00:00
Anirudh
250fb45cb9 fix: use resolvedTheme 2025-01-07 04:16:05 +00:00
Anirudh
49d0690081 fix: hover colors of proj card 2025-01-07 04:13:21 +00:00
Anirudh
f38bf79e54 fix: new button light theme 2025-01-07 04:12:03 +00:00
Anirudh
1eb17a0ac5 fix: frontend monaco theme
fix: drizzle migrations folder
2025-01-07 04:01:45 +00:00
Akhileshrangani4
996eb4339e fix: handle undefined socialLinks in ProfileCard component 2025-01-06 06:31:30 -05:00
Akhilesh Rangani
5faafd477b
Merge pull request #14 from Code-Victor/feat/new-profile-ui-n-fixes
Feat/new profile UI n fixes
2025-01-06 05:52:06 -05:00
Hamzat Victor
b88feee86e feat: liked project can now be deleted 2025-01-06 03:16:05 +01:00
Hamzat Victor
ceeb1fbce3 feat: complete new UI for Profiles, fix notfound error on username change 2025-01-06 02:52:32 +01:00
Hamzat Victor
024e30bd99 feat: update backend schema and code for new profile UI 2025-01-06 02:51:24 +01:00
Akhileshrangani4
947f0c6f0d feat: schema change to add github token 2025-01-05 14:17:11 -05:00
James Murdza
f800008487
docs: add section on AWS deployment in the README 2025-01-02 13:43:50 -08:00
Akhileshrangani4
1d21d55437 fix: prevent duplicate tabs when clicking file paths
Modify selectFile function to check for existing tabs before creating new ones.
When clicking file paths in AIChat, it will now switch to the existing tab
instead of creating duplicates.
2025-01-01 08:33:13 -05:00
Akhileshrangani4
274c945fac fix: ctrl/cmd + z with applied code 2025-01-01 08:16:16 -05:00
James Murdza
a41cdff55f fix: close the SSH channel when a Dokku command is finished 2024-12-31 18:22:31 -08:00
James Murdza
182d527416
chore: add dokku-daemon instructions to README 2024-12-31 13:13:59 -08:00
James Murdza
f3f91ea16f feat: hide the deploy button when the Dokku server is not connected 2024-12-31 10:40:45 -08:00
Akhileshrangani4
e8590703f8 fix: apply button file save 2024-12-31 08:16:46 -05:00
James Murdza
093a4e9758 fix: correctly generate remoteFileId for paths without a leading slash 2024-12-09 13:59:47 -08:00
Akhilesh Rangani
6a60f4d286
Update README.md 2024-12-06 14:32:17 -05:00
Akhileshrangani4
260110724e chore: update .env.example 2024-12-06 14:24:59 -05:00
James Murdza
ee51ae7a33 feat: correctly show whether a project has been deployed 2024-12-01 20:07:01 -08:00
Akhileshrangani4
0d0eed34b2 fix: apply code- discard button 2024-12-01 22:15:16 -05:00
Akhileshrangani4
4e1c5cac27 feat: Add clickable file paths in AI chat responses
- Detect file paths with dots in directory names (e.g. next/styles/SignIn.module.css)
- Create new files when path ends with "(new file)"
- Use existing socket connection and file management system
2024-12-01 18:52:28 -05:00
Akhileshrangani4
0ae89341d2 feat: file path above each code snippet 2024-12-01 14:29:23 -05:00
James Murdza
1630a5a9cd docs: add OpenAI API key to README 2024-11-30 18:53:24 -08:00
James Murdza
ffdcdef56c docs: remove AI worker row from README 2024-11-30 18:53:24 -08:00
Akhileshrangani4
6612692d98 feat: introduce apply button functionality (v0.1)
### Summary
- Added a new "Apply" button to code snippets provided by the AI assistant.
- The button is designed to seamlessly merge the AI-generated snippet into the relevant file in the editor.

### Current Issues
1. **Sticky Accept/Decline Buttons:** These activate for every snippet instead of being limited to the relevant snippet.
2. **Discard Button:** Currently non-functional.
3. **Highlight Inconsistencies:** The green-red code highlights for old and new code are inconsistent.

### To Do
- Implement a toast notification when the "Apply" button is pressed on an irrelevant tab to prevent code application errors.

### Workflow Implemented
1. The "Apply" button is added alongside "Copy" and "Reply" for AI-generated code snippets.
2. Upon clicking "Apply," the code snippet and relevant file content (active file) are sent to a secondary model (GPT-4O).
3. The system prompt for GPT-4O instructs it to merge the snippet with the file content:
   - Ensure the original file functionality remains intact.
   - Integrate the code snippet seamlessly.
4. The output from GPT-4O is injected directly into the code editor.
5. Changes are visually highlighted:
   - Green for new code.
   - Red for removed code.
6. Highlights remain until the user explicitly accepts or discards the changes.
2024-11-30 21:52:17 -05:00
Akhileshrangani4
534b148b86 feat: add dynamic file structure context in AI chat
- Improved file structure formatting with tree-like visualization
- Added filtering for ignored files and folders
- Added scripts section to template context
- Fixed folder hierarchy display with proper indentation
- Maintains sorting with folders first, then files alphabetically
- Now uses actual project files instead of template structure

Example output:
├── app/
│   ├── api/
│   └── page.tsx
├── components/
└── package.json
2024-11-30 15:53:30 -05:00
Akhileshrangani4
e384607d24 chore: context tab updates
- Context tab updates with the latest file changes and will not be removed from context when a message is sent
2024-11-30 02:26:48 -05:00
Akhileshrangani4
e7d9989931 feat: sticky copy-reply button on chat code-snippets 2024-11-30 01:34:07 -05:00
Akhileshrangani4
42305d67b9 fix: terminal paste functionality 2024-11-30 00:04:04 -05:00
Akhileshrangani4
06dadf3a0b fix: image handling in context 2024-11-29 21:50:08 -05:00
Akhileshrangani4
ba7a1dcc2c chore: formatting the code of recent changes 2024-11-29 13:05:35 -05:00
64 changed files with 5823 additions and 1456 deletions

1
.gitignore vendored
View File

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

View File

@ -2,7 +2,7 @@
![2024-10-2307 17 42-ezgif com-resize](https://github.com/user-attachments/assets/a4057129-81a7-4a31-a093-c8bc8189ae72) ![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,11 @@ 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.
### 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,6 +22,7 @@ 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.
@ -41,7 +42,6 @@ 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,6 +78,8 @@ 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:
``` ```
@ -152,6 +154,7 @@ 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='🔑' ANTHROPIC_API_KEY='🔑'
OPENAI_API_KEY='🔑'
``` ```
### 10. Running the IDE ### 10. Running the IDE
@ -173,6 +176,15 @@ 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
@ -181,6 +193,10 @@ 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. 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.
@ -246,8 +262,7 @@ backend/
├── database/ ├── database/
│ ├── src │ ├── src
│ └── drizzle │ └── drizzle
├── storage └── storage
└── ai
``` ```
| Path | Description | | Path | Description |
@ -256,7 +271,6 @@ backend/
| `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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{ {
"version": "5", "version": "5",
"dialect": "sqlite", "dialect": "sqlite",
"id": "1288b006-6410-4b1c-8c96-d9797878a116", "id": "4ada398d-7e4e-448f-8cea-a10b4d844600",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"sandbox": { "sandbox": {
@ -94,6 +94,72 @@
"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": {
@ -132,6 +198,13 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"githubToken": {
"name": "githubToken",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": { "createdAt": {
"name": "createdAt", "name": "createdAt",
"type": "integer", "type": "integer",

View File

@ -0,0 +1,353 @@
{
"version": "5",
"dialect": "sqlite",
"id": "80c0b0b2-bb0e-449a-b447-c21863686f58",
"prevId": "4ada398d-7e4e-448f-8cea-a10b4d844600",
"tables": {
"sandbox": {
"name": "sandbox",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"likeCount": {
"name": "likeCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"viewCount": {
"name": "viewCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
}
},
"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": {}
},
"sandbox_likes": {
"name": "sandbox_likes",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"sandbox_likes_user_id_user_id_fk": {
"name": "sandbox_likes_user_id_user_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sandbox_likes_sandbox_id_sandbox_id_fk": {
"name": "sandbox_likes_sandbox_id_sandbox_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "sandbox",
"columnsFrom": [
"sandbox_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sandbox_likes_sandbox_id_user_id_pk": {
"columns": [
"sandbox_id",
"user_id"
],
"name": "sandbox_likes_sandbox_id_user_id_pk"
}
},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatarUrl": {
"name": "avatarUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"githubToken": {
"name": "githubToken",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"generations": {
"name": "generations",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"bio": {
"name": "bio",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"personalWebsite": {
"name": "personalWebsite",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"links": {
"name": "links",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"tier": {
"name": "tier",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'FREE'"
},
"tierExpiresAt": {
"name": "tierExpiresAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastResetDate": {
"name": "lastResetDate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_id_unique": {
"name": "user_id_unique",
"columns": [
"id"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users_to_sandboxes": {
"name": "users_to_sandboxes",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandboxId": {
"name": "sandboxId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sharedOn": {
"name": "sharedOn",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_to_sandboxes_userId_user_id_fk": {
"name": "users_to_sandboxes_userId_user_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"users_to_sandboxes_sandboxId_sandbox_id_fk": {
"name": "users_to_sandboxes_sandboxId_sandbox_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "sandbox",
"columnsFrom": [
"sandboxId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@ -0,0 +1,360 @@
{
"version": "5",
"dialect": "sqlite",
"id": "51abcf01-2921-4885-8058-d1ccd576f3e1",
"prevId": "80c0b0b2-bb0e-449a-b447-c21863686f58",
"tables": {
"sandbox": {
"name": "sandbox",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"likeCount": {
"name": "likeCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"viewCount": {
"name": "viewCount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"containerId": {
"name": "containerId",
"type": "text",
"primaryKey": false,
"notNull": false,
"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": {}
},
"sandbox_likes": {
"name": "sandbox_likes",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandbox_id": {
"name": "sandbox_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"sandbox_likes_user_id_user_id_fk": {
"name": "sandbox_likes_user_id_user_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sandbox_likes_sandbox_id_sandbox_id_fk": {
"name": "sandbox_likes_sandbox_id_sandbox_id_fk",
"tableFrom": "sandbox_likes",
"tableTo": "sandbox",
"columnsFrom": [
"sandbox_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"sandbox_likes_sandbox_id_user_id_pk": {
"columns": [
"sandbox_id",
"user_id"
],
"name": "sandbox_likes_sandbox_id_user_id_pk"
}
},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatarUrl": {
"name": "avatarUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"githubToken": {
"name": "githubToken",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"generations": {
"name": "generations",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"bio": {
"name": "bio",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"personalWebsite": {
"name": "personalWebsite",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"links": {
"name": "links",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'[]'"
},
"tier": {
"name": "tier",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'FREE'"
},
"tierExpiresAt": {
"name": "tierExpiresAt",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastResetDate": {
"name": "lastResetDate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_id_unique": {
"name": "user_id_unique",
"columns": [
"id"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users_to_sandboxes": {
"name": "users_to_sandboxes",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sandboxId": {
"name": "sandboxId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sharedOn": {
"name": "sharedOn",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_to_sandboxes_userId_user_id_fk": {
"name": "users_to_sandboxes_userId_user_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"users_to_sandboxes_sandboxId_sandbox_id_fk": {
"name": "users_to_sandboxes_sandboxId_sandbox_id_fk",
"tableFrom": "users_to_sandboxes",
"tableTo": "sandbox",
"columnsFrom": [
"sandboxId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@ -5,8 +5,22 @@
{ {
"idx": 0, "idx": 0,
"version": "5", "version": "5",
"when": 1732568535771, "when": 1736155854410,
"tag": "0000_rapid_korath", "tag": "0000_sudden_wallop",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1736169498666,
"tag": "0001_dusty_komodo",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1736768910615,
"tag": "0002_chemical_brother_voodoo",
"breakpoints": true "breakpoints": true
} }
] ]

File diff suppressed because it is too large Load Diff

View File

@ -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.86.0" "wrangler": "^3.101.0"
}, },
"dependencies": { "dependencies": {
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",

View File

@ -5,13 +5,7 @@ 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 { import { Sandbox, sandbox, sandboxLikes, user, usersToSandboxes } from "./schema"
Sandbox,
sandbox,
sandboxLikes,
user,
usersToSandboxes,
} from "./schema"
export interface Env { export interface Env {
DB: D1Database DB: D1Database
@ -73,6 +67,7 @@ export default {
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))
@ -245,6 +240,88 @@ export default {
return success return success
} else return methodNotAllowed } else return methodNotAllowed
} else if (path === "/api/sandbox/like") {
if (method === "POST") {
const likeSchema = z.object({
sandboxId: z.string(),
userId: z.string(),
})
try {
const body = await request.json()
const { sandboxId, userId } = likeSchema.parse(body)
// Check if user has already liked
const existingLike = await db.query.sandboxLikes.findFirst({
where: (likes, { and, eq }) =>
and(eq(likes.sandboxId, sandboxId), eq(likes.userId, userId)),
})
if (existingLike) {
// Unlike
await db
.delete(sandboxLikes)
.where(
and(
eq(sandboxLikes.sandboxId, sandboxId),
eq(sandboxLikes.userId, userId)
)
)
await db
.update(sandbox)
.set({
likeCount: sql`${sandbox.likeCount} - 1`,
})
.where(eq(sandbox.id, sandboxId))
return json({
message: "Unlike successful",
liked: false,
})
} else {
// Like
await db.insert(sandboxLikes).values({
sandboxId,
userId,
createdAt: new Date(),
})
await db
.update(sandbox)
.set({
likeCount: sql`${sandbox.likeCount} + 1`,
})
.where(eq(sandbox.id, sandboxId))
return json({
message: "Like successful",
liked: true,
})
}
} catch (error) {
return new Response("Invalid request format", { status: 400 })
}
} else if (method === "GET") {
const params = url.searchParams
const sandboxId = params.get("sandboxId")
const userId = params.get("userId")
if (!sandboxId || !userId) {
return invalidRequest
}
const like = await db.query.sandboxLikes.findFirst({
where: (likes, { and, eq }) =>
and(eq(likes.sandboxId, sandboxId), eq(likes.userId, userId)),
})
return json({
liked: !!like,
})
} else {
return methodNotAllowed
}
} else if (path === "/api/user") { } else if (path === "/api/user") {
if (method === "GET") { if (method === "GET") {
const params = url.searchParams const params = url.searchParams
@ -316,6 +393,7 @@ export default {
email: z.string().email(), email: z.string().email(),
username: z.string(), username: z.string(),
avatarUrl: z.string().optional(), avatarUrl: z.string().optional(),
githubToken: z.string().nullable().optional(),
createdAt: z.string().optional(), createdAt: z.string().optional(),
generations: z.number().optional(), generations: z.number().optional(),
tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).optional(), tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).optional(),
@ -325,7 +403,19 @@ export default {
const body = await request.json() const body = await request.json()
const { id, name, email, username, avatarUrl, createdAt, generations, tier, tierExpiresAt, lastResetDate } = userSchema.parse(body) const {
id,
name,
email,
username,
avatarUrl,
githubToken,
createdAt,
generations,
tier,
tierExpiresAt,
lastResetDate,
} = userSchema.parse(body)
const res = await db const res = await db
.insert(user) .insert(user)
.values({ .values({
@ -334,6 +424,7 @@ export default {
email, email,
username, username,
avatarUrl, avatarUrl,
githubToken,
createdAt: createdAt ? new Date(createdAt) : new Date(), createdAt: createdAt ? new Date(createdAt) : new Date(),
generations, generations,
tier, tier,
@ -354,9 +445,20 @@ export default {
const updateUserSchema = z.object({ const updateUserSchema = z.object({
id: z.string(), id: z.string(),
name: z.string().optional(), 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(), email: z.string().email().optional(),
username: z.string().optional(), username: z.string().optional(),
avatarUrl: z.string().optional(), avatarUrl: z.string().optional(),
githubToken: z.string().nullable().optional(),
generations: z.number().optional(), generations: z.number().optional(),
}) })
@ -418,7 +520,10 @@ export default {
return json({ exists: !!exists }) return json({ exists: !!exists })
} }
return methodNotAllowed return methodNotAllowed
} else if (path === "/api/user/increment-generations" && method === "POST") { } else if (
path === "/api/user/increment-generations" &&
method === "POST"
) {
const schema = z.object({ const schema = z.object({
userId: z.string(), userId: z.string(),
}) })
@ -449,7 +554,7 @@ export default {
tier, tier,
tierExpiresAt: tierExpiresAt.getTime(), tierExpiresAt: tierExpiresAt.getTime(),
// Reset generations when upgrading tier // Reset generations when upgrading tier
generations: 0 generations: 0,
}) })
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.get() .get()
@ -472,14 +577,19 @@ export default {
} }
const now = new Date() const now = new Date()
const lastReset = dbUser.lastResetDate ? new Date(dbUser.lastResetDate) : new Date(0) const lastReset = dbUser.lastResetDate
? new Date(dbUser.lastResetDate)
: new Date(0)
if (now.getMonth() !== lastReset.getMonth() || now.getFullYear() !== lastReset.getFullYear()) { if (
now.getMonth() !== lastReset.getMonth() ||
now.getFullYear() !== lastReset.getFullYear()
) {
await db await db
.update(user) .update(user)
.set({ .set({
generations: 0, generations: 0,
lastResetDate: now.getTime() lastResetDate: now.getTime(),
}) })
.where(eq(user.id, userId)) .where(eq(user.id, userId))
.get() .get()

View File

@ -2,6 +2,26 @@ import { createId } from "@paralleldrive/cuid2"
import { relations, sql } from "drizzle-orm" import { relations, sql } from "drizzle-orm"
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core" import { integer, primaryKey, 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 // #region Tables
export const user = sqliteTable("user", { export const user = sqliteTable("user", {
id: text("id") id: text("id")
@ -12,10 +32,14 @@ export const user = sqliteTable("user", {
email: text("email").notNull(), email: text("email").notNull(),
username: text("username").notNull().unique(), username: text("username").notNull().unique(),
avatarUrl: text("avatarUrl"), avatarUrl: text("avatarUrl"),
githubToken: text("githubToken"),
createdAt: integer("createdAt", { mode: "timestamp_ms" }).default( createdAt: integer("createdAt", { mode: "timestamp_ms" }).default(
sql`CURRENT_TIMESTAMP` sql`CURRENT_TIMESTAMP`
), ),
generations: integer("generations").default(0), 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"), tier: text("tier", { enum: ["FREE", "PRO", "ENTERPRISE"] }).default("FREE"),
tierExpiresAt: integer("tierExpiresAt"), tierExpiresAt: integer("tierExpiresAt"),
lastResetDate: integer("lastResetDate"), lastResetDate: integer("lastResetDate"),
@ -39,6 +63,7 @@ export const sandbox = sqliteTable("sandbox", {
.references(() => user.id), .references(() => user.id),
likeCount: integer("likeCount").default(0), likeCount: integer("likeCount").default(0),
viewCount: integer("viewCount").default(0), viewCount: integer("viewCount").default(0),
containerId: text("containerId"),
}) })
export type Sandbox = typeof sandbox.$inferSelect export type Sandbox = typeof sandbox.$inferSelect

View File

@ -7,7 +7,6 @@ PORT=4000
WORKERS_KEY= WORKERS_KEY=
DATABASE_WORKER_URL= DATABASE_WORKER_URL=
STORAGE_WORKER_URL= STORAGE_WORKER_URL=
AI_WORKER_URL=
E2B_API_KEY= E2B_API_KEY=
DOKKU_HOST= DOKKU_HOST=
DOKKU_USERNAME= DOKKU_USERNAME=

View File

@ -31,9 +31,30 @@ 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("apps:list") const response = await this.sendCommand("--quiet apps:list")
// Split the output by newline and remove the header return response.output.split("\n")
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
} }
} }

View File

@ -92,7 +92,11 @@ 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 `projects/${this.sandboxId}${localId}` return [
"projects",
this.sandboxId,
localId.startsWith("/") ? localId : localId,
].join("/")
} }
// Convert remote file path to local file path // Convert remote file path to local file path
@ -326,13 +330,15 @@ 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)
// Update local file data cache
let file = this.fileData.find((f) => f.id === fileId) let file = this.fileData.find((f) => f.id === fileId)
if (file) { if (file) {
file.data = body file.data = body
} else { } else {
// If the file wasn't in our cache, add it
file = { file = {
id: fileId, id: fileId,
data: body, data: body,
@ -340,7 +346,49 @@ export class FileManager {
this.fileData.push(file) this.fileData.push(file)
} }
await this.sandbox.files.write(path.posix.join(this.dirName, fileId), body) // 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()
} }

View File

@ -13,7 +13,10 @@ 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) {
@ -34,7 +37,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)
} }
@ -44,17 +47,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)
}) })
@ -86,10 +89,13 @@ 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()
}) })
} }
) )

View File

@ -156,6 +156,40 @@ export class Sandbox {
return { success: true, apps: await this.dokkuClient.listApps() } 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 // Handle deploying code
const handleDeploy: SocketHandler = async (_: any) => { const handleDeploy: SocketHandler = async (_: any) => {
if (!this.gitClient) throw Error("No git client") if (!this.gitClient) throw Error("No git client")
@ -253,7 +287,9 @@ export class Sandbox {
getFolder: handleGetFolder, getFolder: handleGetFolder,
saveFile: handleSaveFile, saveFile: handleSaveFile,
moveFile: handleMoveFile, moveFile: handleMoveFile,
list: handleListApps, listApps: handleListApps,
getAppCreatedAt: handleGetAppCreatedAt,
getAppExists: handleAppExists,
deploy: handleDeploy, deploy: handleDeploy,
createFile: handleCreateFile, createFile: handleCreateFile,
createFolder: handleCreateFolder, createFolder: handleCreateFolder,

View File

@ -146,6 +146,8 @@ io.on("connection", async (socket) => {
) )
}) })
socket.emit("ready")
// Handle disconnection event // Handle disconnection event
socket.on("disconnect", async () => { socket.on("disconnect", async () => {
try { try {

View File

@ -3,18 +3,21 @@ CLERK_SECRET_KEY=
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
LIVEBLOCKS_SECRET_KEY= LIVEBLOCKS_SECRET_KEY=
NEXT_PUBLIC_SERVER_URL=http://localhost:4000 NEXT_PUBLIC_SERVER_PORT=4000
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_SERVER_URL=http://localhost:4000
# 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.
# These URLs should begin with https:// in production 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_AI_WORKER_URL=
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=

View File

@ -91,23 +91,17 @@ export default async function CodePage({ params }: { params: { id: string } }) {
} }
return ( return (
<>
<div className="overflow-hidden overscroll-none w-screen flex flex-col h-screen bg-background">
{/* <Room id={sandboxId}> */}
<TerminalProvider> <TerminalProvider>
{/* <Room id={sandboxId}> */}
<div className="overflow-hidden overscroll-none w-screen h-screen grid [grid-template-rows:3.5rem_auto] bg-background">
<Navbar <Navbar
userData={userData} userData={userData}
sandboxData={sandboxData} sandboxData={sandboxData}
shared={ shared={shared as { id: string; name: string; avatarUrl: string }[]}
shared as { id: string; name: string; avatarUrl: string }[]
}
/> />
<div className="w-screen flex grow">
<CodeEditor userData={userData} sandboxData={sandboxData} /> <CodeEditor userData={userData} sandboxData={sandboxData} />
</div> </div>
</TerminalProvider>
{/* </Room> */} {/* </Room> */}
</div> </TerminalProvider>
</>
) )
} }

View File

@ -1,12 +1,59 @@
import { currentUser } from "@clerk/nextjs" import {
import { Anthropic } from "@anthropic-ai/sdk" ignoredFiles,
import { TIERS } from "@/lib/tiers" ignoredFolders,
} from "@/components/editor/AIChat/lib/ignored-paths"
import { templateConfigs } from "@/lib/templates" 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({ const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!, 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) { export async function POST(request: Request) {
try { try {
const user = await currentUser() const user = await currentUser()
@ -43,7 +90,8 @@ export async function POST(request: Request) {
const userData = await dbUser.json() const userData = await dbUser.json()
// Get tier settings // Get tier settings
const tierSettings = TIERS[userData.tier as keyof typeof TIERS] || TIERS.FREE const tierSettings =
TIERS[userData.tier as keyof typeof TIERS] || TIERS.FREE
if (userData.generations >= tierSettings.generations) { if (userData.generations >= tierSettings.generations) {
return new Response( return new Response(
`AI generation limit reached for your ${userData.tier || "FREE"} tier`, `AI generation limit reached for your ${userData.tier || "FREE"} tier`,
@ -58,27 +106,32 @@ export async function POST(request: Request) {
isEditMode, isEditMode,
fileName, fileName,
line, line,
templateType templateType,
files,
projectName,
} = await request.json() } = await request.json()
// Get template configuration // Get template configuration
const templateConfig = templateConfigs[templateType] const templateConfig = templateConfigs[templateType]
// Create template context // Create template context
const templateContext = templateConfig ? ` const templateContext = templateConfig
? `
Project Template: ${templateConfig.name} Project Template: ${templateConfig.name}
File Structure: Current File Structure:
${Object.entries(templateConfig.fileStructure) ${files ? formatFileStructure(files) : "No files available"}
.map(([path, info]) => `${path} - ${info.description}`)
.join('\n')}
Conventions: Conventions:
${templateConfig.conventions.join('\n')} ${templateConfig.conventions.join("\n")}
Dependencies: Dependencies:
${JSON.stringify(templateConfig.dependencies, null, 2)} ${JSON.stringify(templateConfig.dependencies, null, 2)}
` : ''
Scripts:
${JSON.stringify(templateConfig.scripts, null, 2)}
`
: ""
// Create system message based on mode // Create system message based on mode
let systemMessage let systemMessage
@ -100,13 +153,23 @@ Instructions: ${messages[0].content}
Respond only with the modified code that can directly replace the existing code.` Respond only with the modified code that can directly replace the existing code.`
} else { } else {
systemMessage = `You are an intelligent programming assistant for a ${templateType} project. 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: systemMessage = `You are an intelligent programming assistant for a ${templateType} project. Please respond to the following request concisely. When providing code:
\`\`\`python 1. Format it using triple backticks (\`\`\`) with the appropriate language identifier.
print("Hello, World!") 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
\`\`\` \`\`\`
Provide a clear and concise explanation along with any code snippets. Keep your response brief and to the point. 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: This is the project template:
${templateContext} ${templateContext}
@ -146,7 +209,10 @@ ${activeFileContent ? `Active File Content:\n${activeFileContent}\n` : ""}`
new ReadableStream({ new ReadableStream({
async start(controller) { async start(controller) {
for await (const chunk of stream) { for await (const chunk of stream) {
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") { if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
controller.enqueue(encoder.encode(chunk.delta.text)) controller.enqueue(encoder.encode(chunk.delta.text))
} }
} }

View File

@ -0,0 +1,69 @@
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 }
)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -102,26 +102,27 @@
.light .gradient-button-bg { .light .gradient-button-bg {
background: radial-gradient( background: radial-gradient(
circle at top, circle at top,
#262626 0%, #f5f5f5 0%,
#f5f5f5 50% /* Very light gray */ #e0e0e0 50% /* Soft gray */
); /* Dark gray -> Light gray */ );
} }
.light .gradient-button { .light .gradient-button {
background: radial-gradient( background: radial-gradient(
circle at bottom, circle at bottom,
hsl(0, 10%, 25%) -10%, hsl(0, 0%, 85%) -10%,
#9d9d9d 50% /* Slightly darker gray */ hsl(0, 0%, 95%) 50% /* Very soft light gray */
); /* 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, 10%, 25%) -10%, hsl(0, 0%, 80%) -10%,
#9d9d9d 80% /* Slightly darker gray for hover */ hsl(0, 0%, 90%) 80% /* Softer gray */
); /* Light gray -> Almost white */ );
} }
.inline-decoration::before { .inline-decoration::before {
content: "Generate"; content: "Generate";
color: #525252; color: #525252;
@ -175,3 +176,23 @@
.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;
}

11
frontend/app/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -11,7 +11,22 @@ import "./globals.css"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sandbox", title: "Sandbox",
description: "A collaborative, AI-powered cloud code editing environment", description:
"an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat",
openGraph: {
type: "website",
url: "https://sandbox.gitwit.dev",
title: "Sandbox",
description:
"an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat",
},
twitter: {
site: "https://sandbox.gitwit.dev",
title: "Sandbox by Gitwit",
description:
"an open-source cloud-based code editing environment with custom AI code generation, live preview, real-time collaboration, and AI chat",
creator: "@gitwitdev",
},
} }
export default function RootLayout({ export default function RootLayout({

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -9,14 +9,19 @@ 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=" py-2 px-4 w-full flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-2">
<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>
<div className="text-sm font-medium flex items-center">Sandbox</div> <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>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<DashboardNavbarSearch /> <DashboardNavbarSearch />

View File

@ -240,7 +240,7 @@ function ProjectCardComponent({
> >
<CanvasRevealEffect <CanvasRevealEffect
animationSpeed={3} animationSpeed={3}
containerClassName="bg-black" containerClassName="bg-muted"
colors={colors[type]} colors={colors[type]}
dotSize={2} dotSize={2}
/> />

View File

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

View File

@ -13,6 +13,13 @@ export default function ChatMessage({
setContext, setContext,
setIsContextExpanded, setIsContextExpanded,
socket, socket,
handleApplyCode,
activeFileName,
activeFileContent,
editorRef,
mergeDecorationsCollection,
setMergeDecorationsCollection,
selectFile,
}: MessageProps) { }: MessageProps) {
// State for expanded message index // State for expanded message index
const [expandedMessageIndex, setExpandedMessageIndex] = useState< const [expandedMessageIndex, setExpandedMessageIndex] = useState<
@ -104,7 +111,14 @@ export default function ChatMessage({
const components = createMarkdownComponents( const components = createMarkdownComponents(
renderCopyButton, renderCopyButton,
renderMarkdownElement, renderMarkdownElement,
askAboutCode askAboutCode,
activeFileName,
activeFileContent,
editorRef,
handleApplyCode,
selectFile,
mergeDecorationsCollection,
setMergeDecorationsCollection,
) )
return ( return (
@ -112,8 +126,8 @@ export default function ChatMessage({
<div <div
className={`relative p-2 rounded-lg ${ className={`relative p-2 rounded-lg ${
message.role === "user" message.role === "user"
? "bg-[#262626] text-white" ? "bg-foreground text-background"
: "bg-transparent text-white" : "bg-background text-foreground"
} max-w-full`} } max-w-full`}
> >
{/* Render context tabs */} {/* Render context tabs */}
@ -157,7 +171,7 @@ export default function ChatMessage({
end: e.target.value.split("\n").length, end: e.target.value.split("\n").length,
}) })
}} }}
className="w-full p-2 bg-[#1e1e1e] text-white font-mono text-sm rounded" className="w-full p-2 bg-[#1e1e1e] text-foreground font-mono text-sm rounded"
rows={code.split("\n").length} rows={code.split("\n").length}
style={{ style={{
resize: "vertical", resize: "vertical",
@ -201,7 +215,8 @@ export default function ChatMessage({
// Parse context to tabs for context tabs component // Parse context to tabs for context tabs component
function parseContextToTabs(context: string) { function parseContextToTabs(context: string) {
const sections = context.split(/(?=File |Code from )/) // Use specific regex patterns to avoid matching import statements
const sections = context.split(/(?=File |Code from |Image \d{1,2}:)/)
return sections return sections
.map((section, index) => { .map((section, index) => {
const lines = section.trim().split("\n") const lines = section.trim().split("\n")
@ -211,16 +226,29 @@ function parseContextToTabs(context: string) {
// Remove code block markers for display // Remove code block markers for display
content = content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "") content = content.replace(/^```[\w-]*\n/, "").replace(/\n```$/, "")
// Determine if the context is a file or code // Determine the type of context
const isFile = titleLine.startsWith("File ") const isFile = titleLine.startsWith("File ")
const name = titleLine.replace(/^(File |Code from )/, "").replace(":", "") 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 { return {
id: `context-${index}`, id: `context-${index}`,
type: isFile ? ("file" as const) : ("code" as const), type: type as "file" | "code" | "image",
name: name, name: name,
content: content, content: content,
} }
}) })
.filter((tab) => tab.content.length > 0) .filter(
(tab): tab is NonNullable<typeof tab> =>
tab !== null && tab.content.length > 0
)
} }

View File

@ -1,6 +1,7 @@
import { ScrollArea } from "@/components/ui/scroll-area"
import { useSocket } from "@/context/SocketContext" import { useSocket } from "@/context/SocketContext"
import { TFile } from "@/lib/types" import { TFile } from "@/lib/types"
import { X, ChevronDown } from "lucide-react" import { ChevronDown, X } from "lucide-react"
import { nanoid } from "nanoid" 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"
@ -18,6 +19,11 @@ export default function AIChat({
lastCopiedRangeRef, lastCopiedRangeRef,
files, files,
templateType, templateType,
handleApplyCode,
selectFile,
mergeDecorationsCollection,
setMergeDecorationsCollection,
projectName,
}: AIChatProps) { }: AIChatProps) {
// Initialize socket and messages // Initialize socket and messages
const { socket } = useSocket() const { socket } = useSocket()
@ -75,8 +81,8 @@ export default function AIChat({
useEffect(() => { useEffect(() => {
const container = chatContainerRef.current const container = chatContainerRef.current
if (container) { if (container) {
container.addEventListener('scroll', handleScroll) container.addEventListener("scroll", handleScroll)
return () => container.removeEventListener('scroll', handleScroll) return () => container.removeEventListener("scroll", handleScroll)
} }
}, []) }, [])
@ -125,6 +131,8 @@ export default function AIChat({
} else if (tab.type === "code") { } else if (tab.type === "code") {
const cleanContent = formatCodeContent(tab.content) const cleanContent = formatCodeContent(tab.content)
return `Code from ${tab.name}:\n\`\`\`typescript\n${cleanContent}\n\`\`\`` 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}` return `${tab.name}:\n${tab.content}`
}) })
@ -146,10 +154,10 @@ export default function AIChat({
abortControllerRef, abortControllerRef,
activeFileContent, activeFileContent,
false, false,
templateType templateType,
files,
projectName
) )
// Clear context tabs after sending
setContextTabs([])
} }
// Set context for the chat // Set context for the chat
@ -167,6 +175,22 @@ export default function AIChat({
addContextTab("code", name, context, range) 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">
@ -185,9 +209,9 @@ export default function AIChat({
</button> </button>
</div> </div>
</div> </div>
<div <ScrollArea
ref={chatContainerRef} ref={chatContainerRef}
className="flex-grow overflow-y-auto p-4 space-y-4 relative" className="flex-grow p-4 space-y-4 relative"
> >
{messages.map((message, messageIndex) => ( {messages.map((message, messageIndex) => (
// Render chat message component for each message // Render chat message component for each message
@ -197,6 +221,13 @@ export default function AIChat({
setContext={setContext} setContext={setContext}
setIsContextExpanded={setIsContextExpanded} setIsContextExpanded={setIsContextExpanded}
socket={socket} socket={socket}
handleApplyCode={handleApplyCode}
activeFileName={activeFileName}
activeFileContent={activeFileContent}
editorRef={editorRef}
mergeDecorationsCollection={mergeDecorationsCollection}
setMergeDecorationsCollection={setMergeDecorationsCollection}
selectFile={selectFile}
/> />
))} ))}
{isLoading && <LoadingDots />} {isLoading && <LoadingDots />}
@ -211,7 +242,7 @@ export default function AIChat({
<ChevronDown className="h-5 w-5" /> <ChevronDown className="h-5 w-5" />
</button> </button>
)} )}
</div> </ScrollArea>
<div className="p-4 border-t mb-14"> <div className="p-4 border-t mb-14">
{/* Render context tabs component */} {/* Render context tabs component */}
<ContextTabs <ContextTabs

View File

@ -1,3 +1,4 @@
import { TFile, TFolder } from "@/lib/types"
import React from "react" import React from "react"
// Stringify content for chat message component // Stringify content for chat message component
@ -91,7 +92,9 @@ export const handleSend = async (
abortControllerRef: React.MutableRefObject<AbortController | null>, abortControllerRef: React.MutableRefObject<AbortController | null>,
activeFileContent: string, activeFileContent: string,
isEditMode: boolean = false, isEditMode: boolean = false,
templateType: string templateType: string,
files: (TFile | TFolder)[],
projectName: string
) => { ) => {
// Return if input is empty and context is null // Return if input is empty and context is null
if (input.trim() === "" && !context) return if (input.trim() === "" && !context) return
@ -131,8 +134,7 @@ export const handleSend = async (
})) }))
// Fetch AI response for chat message component // Fetch AI response for chat message component
const response = await fetch("/api/ai", const response = await fetch("/api/ai", {
{
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -143,10 +145,11 @@ export const handleSend = async (
activeFileContent: activeFileContent, activeFileContent: activeFileContent,
isEditMode: isEditMode, isEditMode: isEditMode,
templateType: templateType, templateType: templateType,
files: files,
projectName: projectName,
}), }),
signal: abortControllerRef.current.signal, signal: abortControllerRef.current.signal,
} })
)
// Throw error if response is not ok // Throw error if response is not ok
if (!response.ok) { if (!response.ok) {
@ -201,7 +204,8 @@ export const handleSend = async (
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: error.message || "Sorry, I encountered an error. Please try again.", content:
error.message || "Sorry, I encountered an error. Please try again.",
} }
setMessages((prev) => [...prev, errorMessage]) setMessages((prev) => [...prev, errorMessage])
} }
@ -239,3 +243,11 @@ export const looksLikeCode = (text: string): boolean => {
return codeIndicators.some((pattern) => pattern.test(text)) return codeIndicators.some((pattern) => pattern.test(text))
} }
// Add this new function after looksLikeCode function
export const isFilePath = (text: string): boolean => {
// Match patterns like next/styles/SignIn.module.css or path/to/file.ext (new file)
const pattern =
/^(?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+(\s+\(new file\))?$/
return pattern.test(text)
}

View File

@ -1,15 +1,26 @@
import { CornerUpLeft } from "lucide-react" 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 { Components } from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter" import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism" import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"
import { Button } from "../../../ui/button" import { Button } from "../../../ui/button"
import { stringifyContent } from "./chatUtils" import ApplyButton from "../ApplyButton"
import { isFilePath, stringifyContent } from "./chatUtils"
// Create markdown components for chat message component // Create markdown components for chat message component
export const createMarkdownComponents = ( export const createMarkdownComponents = (
renderCopyButton: (text: any) => JSX.Element, renderCopyButton: (text: any) => JSX.Element,
renderMarkdownElement: (props: any) => JSX.Element, renderMarkdownElement: (props: any) => JSX.Element,
askAboutCode: (code: any) => void 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 => ({ ): Components => ({
code: ({ code: ({
node, node,
@ -25,12 +36,66 @@ export const createMarkdownComponents = (
const match = /language-(\w+)/.exec(className || "") const match = /language-(\w+)/.exec(className || "")
return match ? ( return match ? (
<div className="relative border border-input rounded-md my-4"> <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 bg-#1e1e1e rounded-tl"> <div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 rounded-tl">
{match[1]} {match[1]}
</div> </div>
<div className="absolute top-0 right-0 flex"> <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)} {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 <Button
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
@ -44,7 +109,7 @@ export const createMarkdownComponents = (
<CornerUpLeft className="w-4 h-4" /> <CornerUpLeft className="w-4 h-4" />
</Button> </Button>
</div> </div>
<div className="pt-6"> </div>
<SyntaxHighlighter <SyntaxHighlighter
style={vscDarkPlus as any} style={vscDarkPlus as any}
language={match[1]} language={match[1]}
@ -58,7 +123,6 @@ export const createMarkdownComponents = (
{stringifyContent(children)} {stringifyContent(children)}
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
</div>
) : ( ) : (
<code className={className} {...props}> <code className={className} {...props}>
{children} {children}
@ -66,8 +130,62 @@ export const createMarkdownComponents = (
) )
}, },
// Render markdown elements // Render markdown elements
p: ({ node, children, ...props }) => p: ({ node, children, ...props }) => {
renderMarkdownElement({ 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 }) => h1: ({ node, children, ...props }) =>
renderMarkdownElement({ node, children, ...props }), renderMarkdownElement({ node, children, ...props }),
h2: ({ node, children, ...props }) => h2: ({ node, children, ...props }) =>

View File

@ -1,5 +1,5 @@
import { TemplateConfig } from "@/lib/templates" import { TemplateConfig } from "@/lib/templates"
import { TFile, TFolder } from "@/lib/types" import { TFile, TFolder, TTab } from "@/lib/types"
import * as monaco from "monaco-editor" import * as monaco from "monaco-editor"
import { Socket } from "socket.io-client" import { Socket } from "socket.io-client"
@ -56,6 +56,11 @@ export interface AIChatProps {
files: (TFile | TFolder)[] files: (TFile | TFolder)[]
templateType: string templateType: string
templateConfig?: TemplateConfig 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 // Chat input props interface
@ -105,6 +110,13 @@ export interface MessageProps {
) => void ) => void
setIsContextExpanded: (isExpanded: boolean) => void setIsContextExpanded: (isExpanded: boolean) => void
socket: Socket | null 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 // Context tabs props interface

View File

@ -66,15 +66,17 @@ export default function GenerateInput({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
messages: [{ messages: [
{
role: "user", role: "user",
content: regenerate ? currentPrompt : input content: regenerate ? currentPrompt : input,
}], },
],
context: null, context: null,
activeFileContent: data.code, activeFileContent: data.code,
isEditMode: true, isEditMode: true,
fileName: data.fileName, fileName: data.fileName,
line: data.line line: data.line,
}), }),
}) })

View File

@ -62,7 +62,7 @@ export default function CodeEditor({
//SocketContext functions and effects //SocketContext functions and effects
const { socket, setUserAndSandboxId } = useSocket() const { socket, setUserAndSandboxId } = useSocket()
// theme // theme
const { theme } = useTheme() const { resolvedTheme: 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,6 +104,13 @@ 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")
const [cursorLine, setCursorLine] = useState(0) const [cursorLine, setCursorLine] = useState(0)
@ -375,6 +382,57 @@ 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) {
@ -735,31 +793,32 @@ 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 exists = tabs.find((t) => t.id === tab.id) const existingTab = tabs.find((t) => t.id === tab.id)
setTabs((prev) => {
if (exists) {
// If the tab exists, make it the active tab
setActiveFileId(exists.id)
return prev
}
// If the tab doesn't exist, add it to the list of tabs and make it active
return [...prev, tab]
})
// If the file's content is already cached, set it as the active content if (existingTab) {
if (fileContents[tab.id]) { // If the tab exists, just make it active
setActiveFileContent(fileContents[tab.id]) setActiveFileId(existingTab.id)
if (fileContents[existingTab.id]) {
setActiveFileContent(fileContents[existingTab.id])
}
} else { } else {
// Otherwise, fetch the content of the file and cache it // If the tab doesn't exist, add it to the list and make it active
setTabs((prev) => [...prev, tab])
// Fetch content if not cached
if (!fileContents[tab.id]) {
debouncedGetFile(tab.id, (response: string) => { debouncedGetFile(tab.id, (response: string) => {
setFileContents((prev) => ({ ...prev, [tab.id]: response })) setFileContents((prev) => ({ ...prev, [tab.id]: response }))
setActiveFileContent(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 to the new tab // Set the active file ID
setActiveFileId(tab.id) setActiveFileId(tab.id)
} }
@ -921,9 +980,9 @@ export default function CodeEditor({
) )
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>
@ -1234,13 +1293,18 @@ export default function CodeEditor({
lastCopiedRangeRef={lastCopiedRangeRef} lastCopiedRangeRef={lastCopiedRangeRef}
files={files} files={files}
templateType={sandboxData.type} templateType={sandboxData.type}
projectName={sandboxData.name}
handleApplyCode={handleApplyCode}
mergeDecorationsCollection={mergeDecorationsCollection}
setMergeDecorationsCollection={setMergeDecorationsCollection}
selectFile={selectFile}
/> />
</ResizablePanel> </ResizablePanel>
</> </>
)} )}
</ResizablePanelGroup> </ResizablePanelGroup>
</PreviewProvider> </PreviewProvider>
</> </div>
) )
} }

View File

@ -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 { useState } from "react" import { useEffect, useState } from "react"
export default function DeployButtonModal({ export default function DeployButtonModal({
userData, userData,
@ -18,8 +18,21 @@ export default function DeployButtonModal({
userData: User userData: User
data: Sandbox data: Sandbox
}) { }) {
const { deploy } = useTerminal() const { deploy, getAppExists } = 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) {
@ -30,6 +43,7 @@ export default function DeployButtonModal({
setIsDeploying(true) setIsDeploying(true)
deploy(() => { deploy(() => {
setIsDeploying(false) setIsDeploying(false)
setIsDeployed(true)
}) })
} }
} }
@ -38,10 +52,12 @@ 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"
@ -52,8 +68,9 @@ 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 1h ago" timestamp="Deployed 1m ago"
user={userData.name} user={userData.name}
isDeployed={isDeployed}
/> />
</div> </div>
<Button <Button
@ -61,7 +78,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..." : "Update"} {isDeploying ? "Deploying..." : isDeployed ? "Update" : "Deploy"}
</Button> </Button>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -74,16 +91,19 @@ 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"
@ -92,9 +112,12 @@ function DeploymentOption({
> >
{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">
{timestamp} {user} {isDeployed ? `${timestamp}${user}` : "Never deployed"}
</p> </p>
</div> </div>
) )

View File

@ -9,6 +9,7 @@ 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 { cn, sortFileExplorer } from "@/lib/utils"
import { import {
@ -104,8 +105,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">
<div className="flex-grow overflow-auto p-2 pb-[84px]"> <ScrollArea className="flex-grow overflow-auto px-2 pt-0 pb-4 relative">
<div className="flex w-full items-center justify-between h-8 mb-1"> <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="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
@ -179,8 +180,8 @@ export default function Sidebar({
</> </>
)} )}
</div> </div>
</div> </ScrollArea>
<div className="fixed bottom-0 w-48 flex flex-col p-2 bg-background"> <div className="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"

View File

@ -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 } from "react" import { ElementRef, useEffect, useRef, useCallback } 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,13 +22,12 @@ export default function EditorTerminal({
setTerm: (term: Terminal) => void setTerm: (term: Terminal) => void
visible: boolean visible: boolean
}) { }) {
const { theme } = useTheme() const { resolvedTheme: 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,
@ -37,13 +36,54 @@ 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(() => {
@ -81,7 +121,6 @@ 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
@ -92,10 +131,9 @@ export default function EditorTerminal({
console.error("Error during fit:", err) console.error("Error during fit:", err)
} }
} }
}, 50) // Debounce for 50ms }, 50)
) )
// start observing for resize
resizeObserver.observe(terminalContainerRef.current) resizeObserver.observe(terminalContainerRef.current)
return () => { return () => {
disposableOnData.dispose() disposableOnData.dispose()
@ -124,6 +162,7 @@ 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">

View File

@ -9,19 +9,38 @@ import {
CardDescription, CardDescription,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { import {
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
} from "@/components/ui/hover-card" } from "@/components/ui/hover-card"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 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 { deleteSandbox, updateSandbox, updateUser } from "@/lib/actions"
import { socialIcons } from "@/lib/data"
import { editUserSchema, EditUserSchema } from "@/lib/schema"
import { TIERS } from "@/lib/tiers" import { TIERS } from "@/lib/tiers"
import { SandboxWithLiked, User } from "@/lib/types" import { SandboxWithLiked, User, UserLink } from "@/lib/types"
import { cn, parseSocialLink } from "@/lib/utils"
import { useUser } from "@clerk/nextjs" import { useUser } from "@clerk/nextjs"
import { zodResolver } from "@hookform/resolvers/zod"
import { import {
Edit, Edit,
Globe,
Heart, Heart,
Info, Info,
Loader2, Loader2,
@ -29,17 +48,27 @@ import {
Package2, Package2,
PlusCircle, PlusCircle,
Sparkles, Sparkles,
Trash2,
X, X,
} from "lucide-react" } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Fragment, useCallback, useEffect, useMemo, useState } from "react" import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useTransition,
} from "react"
import { useFormState, useFormStatus } from "react-dom" import { useFormState, useFormStatus } from "react-dom"
import { useFieldArray, useForm } from "react-hook-form"
import { toast } from "sonner" import { toast } from "sonner"
import Avatar from "../ui/avatar" import Avatar from "../ui/avatar"
import { Badge } from "../ui/badge" import { Badge } from "../ui/badge"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { Progress } from "../ui/progress" import { Progress } from "../ui/progress"
import { Textarea } from "../ui/textarea"
// #region Profile Page // #region Profile Page
export default function ProfilePage({ export default function ProfilePage({
publicSandboxes, publicSandboxes,
@ -75,6 +104,9 @@ export default function ProfilePage({
generations={isOwnProfile ? loggedInUser.generations : undefined} generations={isOwnProfile ? loggedInUser.generations : undefined}
isOwnProfile={isOwnProfile} isOwnProfile={isOwnProfile}
tier={profileOwner.tier} tier={profileOwner.tier}
bio={profileOwner.bio}
personalWebsite={profileOwner.personalWebsite}
socialLinks={profileOwner.links}
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
@ -101,11 +133,17 @@ function ProfileCard({
joinedDate, joinedDate,
generations, generations,
isOwnProfile, isOwnProfile,
bio,
personalWebsite,
socialLinks = [],
tier, tier,
}: { }: {
name: string name: string
username: string username: string
avatarUrl: string | null avatarUrl: string | null
bio: string | null
personalWebsite: string | null
socialLinks: UserLink[]
sandboxes: SandboxWithLiked[] sandboxes: SandboxWithLiked[]
joinedDate: Date joinedDate: Date
generations?: number generations?: number
@ -113,9 +151,8 @@ function ProfileCard({
tier: string tier: string
}) { }) {
const { user } = useUser() const { user } = useUser()
const router = useRouter()
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [formState, formAction] = useFormState(updateUser, {})
const joinedAt = useMemo(() => { const joinedAt = useMemo(() => {
const date = new Date(joinedDate).toLocaleDateString("en-US", { const date = new Date(joinedDate).toLocaleDateString("en-US", {
month: "long", month: "long",
@ -140,100 +177,126 @@ function ProfileCard({
} }
}, [sandboxes]) }, [sandboxes])
useEffect(() => { const showAddMoreInfoBanner = useMemo(() => {
if ("message" in formState) { return !bio && !personalWebsite && (socialLinks?.length ?? 0) === 0
toast.success(formState.message as String) }, [personalWebsite, bio, socialLinks])
toggleEdit()
if ("newRoute" in formState && typeof formState.newRoute === "string") {
router.replace(formState.newRoute)
}
}
if ("error" in formState) {
const error = formState.error
if (typeof error === "string") {
toast.error(error)
} else {
toast.error("An Error Occured")
}
}
}, [formState])
return ( return (
<Card className="mb-6 md:mb-0 sticky top-6"> <Card className="mb-6 md:mb-0 sticky top-6">
{isOwnProfile && ( {isOwnProfile && (
<div className="absolute top-2 right-2 flex flex-col gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
onClick={toggleEdit} onClick={toggleEdit}
aria-label={isEditing ? "close edit form" : "open edit form"} aria-label={isEditing ? "close edit form" : "open edit form"}
size="smIcon" size="smIcon"
variant="secondary" variant="secondary"
className="rounded-full absolute top-2 right-2" className="rounded-full relative"
> >
{isEditing ? <X className="size-4" /> : <Edit className="size-4" />} {isEditing ? (
</Button> <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" />
)} )}
<CardContent className="flex flex-col gap-4 items-center pt-6"> </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" /> <Avatar name={name} avatarUrl={avatarUrl} className="size-36" />
<EditProfileForm
{!isEditing ? ( {...{
<div className="space-y-2"> 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> <CardTitle className="text-2xl text-center">{name}</CardTitle>
<CardDescription className="text-center">{`@${username}`}</CardDescription> <CardDescription className="text-center">{`@${username}`}</CardDescription>
</div> </div>
) : ( {bio && <p className="text-sm text-center">{bio}</p>}
<form action={formAction} className="flex flex-col gap-2"> {(socialLinks.length > 0 || personalWebsite) && (
<Input <div className="flex gap-2 justify-center">
name="id" {personalWebsite && (
placeholder="ID" <Button variant="secondary" size="smIcon" asChild>
className="hidden " <a
value={user?.id} href={personalWebsite}
/> target="_blank"
<Input rel="noopener noreferrer"
name="oldUsername" >
placeholder="ID" <Globe className="size-4" />
className="hidden " <span className="sr-only">Personal Website</span>
value={user?.username ?? undefined} </a>
/> </Button>
<div className="space-y-1">
<Label htmlFor="input-name">Name</Label>
<Input
id="input-name"
name="name"
placeholder="Name"
defaultValue={name}
/>
</div>
<div className="space-y-1">
<Label htmlFor="input-username">User name</Label>
<div className="relative">
<Input
id="input-username"
className="peer ps-6"
type="text"
name="username"
placeholder="Username"
defaultValue={username}
/>
<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>
</div>
<SubmitButton />
</form>
)} )}
{!isEditing && ( {socialLinks.map((link, index) => {
<> const Icon = socialIcons[link.platform]
<div className="flex gap-6"> 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={Package2} label={stats.sandboxes} />
<StatsItem icon={Heart} label={stats.likes} /> <StatsItem icon={Heart} label={stats.likes} />
</div> </div>
<div className="flex flex-col items-center gap-2">
<p className="text-xs text-muted-foreground">{joinedAt}</p>
{typeof generations === "number" && (
<SubscriptionBadge generations={generations} tier={tier as keyof typeof TIERS} />
)}
</div> </div>
<p className="text-xs mt-2 text-muted-foreground text-center">
{joinedAt}
</p>
</> </>
)} )}
</CardContent> </CardContent>
@ -241,13 +304,213 @@ function ProfileCard({
) )
} }
function SubmitButton() { function EditProfileForm(props: {
const { pending } = useFormStatus() 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 ( return (
<Button size="sm" type="submit" className="w-full mt-2" disabled={pending}> <Form {...form}>
{pending && <Loader2 className="animate-spin mr-2 h-4 w-4" />} <form
Save 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> </Button>
) )
} }
@ -438,14 +701,20 @@ interface StatsItemProps {
const StatsItem = ({ icon: Icon, label }: StatsItemProps) => ( const StatsItem = ({ icon: Icon, label }: StatsItemProps) => (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon size={18} /> <Icon size={16} />
<span className="text-sm text-muted-foreground">{label}</span> <span className="text-sm text-muted-foreground">{label}</span>
</div> </div>
) )
// #endregion // #endregion
// #region Sub Badge // #region Sub Badge
const SubscriptionBadge = ({ generations, tier = "FREE" }: { generations: number, tier?: keyof typeof TIERS }) => { const SubscriptionBadge = ({
generations,
tier = "FREE",
}: {
generations: number
tier?: keyof typeof TIERS
}) => {
return ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Badge variant="secondary" className="text-sm cursor-pointer"> <Badge variant="secondary" className="text-sm cursor-pointer">
@ -453,8 +722,8 @@ const SubscriptionBadge = ({ generations, tier = "FREE" }: { generations: number
</Badge> </Badge>
<HoverCard> <HoverCard>
<HoverCardTrigger> <HoverCardTrigger>
<Button variant="ghost" size="smIcon"> <Button variant="ghost" size="smIcon" className="size-[26px]">
<Info size={20} /> <Info size={16} />
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent> <HoverCardContent>

View File

@ -9,14 +9,19 @@ import { Button } from "../ui/button"
export default function ProfileNavbar({ userData }: { userData: User }) { export default function ProfileNavbar({ userData }: { userData: User }) {
return ( return (
<nav className=" py-2 px-4 w-full flex items-center justify-between border-b border-border"> <nav className=" py-2 px-4 w-full flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-2">
<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>
<div className="text-sm font-medium flex items-center">Sandbox</div> <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>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<ThemeSwitcher /> <ThemeSwitcher />

View File

@ -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: "h-8 w-8", smIcon: "size-8",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

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

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -1,8 +1,11 @@
"use client" "use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes" import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider> return <NextThemesProvider {...props}>{children}</NextThemesProvider>
} }

View File

@ -1,7 +1,7 @@
"use client" "use client"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react" import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -29,4 +29,4 @@ const TooltipContent = React.forwardRef<
)) ))
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -43,7 +43,11 @@ const TIER_INFO = {
}, },
} as const } as const
export default function UserButton({ userData: initialUserData }: { userData: User }) { export default function UserButton({
userData: initialUserData,
}: {
userData: User
}) {
const [userData, setUserData] = useState<User>(initialUserData) const [userData, setUserData] = useState<User>(initialUserData)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const { signOut } = useClerk() const { signOut } = useClerk()
@ -57,7 +61,7 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
headers: { headers: {
Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`, Authorization: `${process.env.NEXT_PUBLIC_WORKERS_KEY}`,
}, },
cache: 'no-store' cache: "no-store",
} }
) )
if (res.ok) { if (res.ok) {
@ -75,9 +79,12 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
} }
}, [isOpen]) }, [isOpen])
const tierInfo = TIER_INFO[userData.tier as keyof typeof TIER_INFO] || TIER_INFO.FREE const tierInfo =
TIER_INFO[userData.tier as keyof typeof TIER_INFO] || TIER_INFO.FREE
const TierIcon = tierInfo.icon const TierIcon = tierInfo.icon
const usagePercentage = Math.floor((userData.generations || 0) * 100 / tierInfo.limit) const usagePercentage = Math.floor(
((userData.generations || 0) * 100) / tierInfo.limit
)
const handleUpgrade = async () => { const handleUpgrade = async () => {
router.push(`/@${userData.username}`) router.push(`/@${userData.username}`)
@ -98,7 +105,6 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" asChild> <DropdownMenuItem className="cursor-pointer" asChild>
<Link href={"/dashboard"}> <Link href={"/dashboard"}>
<LayoutDashboard className="mr-2 size-4" /> <LayoutDashboard className="mr-2 size-4" />
@ -114,12 +120,13 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="py-1.5 px-2 w-full"> <div className="py-1.5 px-2 w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<TierIcon className={`h-4 w-4 ${tierInfo.color}`} /> <TierIcon className={`h-4 w-4 ${tierInfo.color}`} />
<span className="text-sm font-medium">{userData.tier || "FREE"} Plan</span> <span className="text-sm font-medium">
{userData.tier || "FREE"} Plan
</span>
</div> </div>
{(userData.tier === "FREE" || userData.tier === "PRO") && ( {(userData.tier === "FREE" || userData.tier === "PRO") && (
<Button <Button
@ -139,15 +146,19 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
<div className="w-full"> <div className="w-full">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2"> <div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>AI Usage</span> <span>AI Usage</span>
<span>{userData.generations}/{tierInfo.limit}</span> <span>
{userData.generations}/{tierInfo.limit}
</span>
</div> </div>
<div className="rounded-full w-full h-2 overflow-hidden bg-secondary mb-1"> <div className="rounded-full w-full h-2 overflow-hidden bg-secondary mb-1">
<div <div
className={`h-full rounded-full transition-all duration-300 ${ className={`h-full rounded-full transition-all duration-300 ${
usagePercentage > 90 ? 'bg-red-500' : usagePercentage > 90
usagePercentage > 75 ? 'bg-yellow-500' : ? "bg-red-500"
tierInfo.color.replace('text-', 'bg-') : usagePercentage > 75
? "bg-yellow-500"
: tierInfo.color.replace("text-", "bg-")
}`} }`}
style={{ style={{
width: `${Math.min(usagePercentage, 100)}%`, width: `${Math.min(usagePercentage, 100)}%`,
@ -173,4 +184,3 @@ export default function UserButton({ userData: initialUserData }: { userData: Us
</DropdownMenu> </DropdownMenu>
) )
} }

View File

@ -20,6 +20,9 @@ interface TerminalContextType {
createNewTerminal: (command?: string) => Promise<void> createNewTerminal: (command?: string) => Promise<void>
closeTerminal: (id: string) => void closeTerminal: (id: string) => void
deploy: (callback: () => void) => void deploy: (callback: () => void) => void
getAppExists:
| ((appName: string) => Promise<{ success: boolean; exists?: boolean }>)
| null
} }
const TerminalContext = createContext<TerminalContextType | undefined>( const TerminalContext = createContext<TerminalContextType | undefined>(
@ -35,6 +38,19 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
>([]) >([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("") const [activeTerminalId, setActiveTerminalId] = useState<string>("")
const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false) const [creatingTerminal, setCreatingTerminal] = useState<boolean>(false)
const [isSocketReady, setIsSocketReady] = useState<boolean>(false)
// Listen for the "ready" signal from the socket
React.useEffect(() => {
if (socket) {
socket.on("ready", () => {
setIsSocketReady(true)
})
}
return () => {
if (socket) socket.off("ready")
}
}, [socket])
const createNewTerminal = async (command?: string): Promise<void> => { const createNewTerminal = async (command?: string): Promise<void> => {
if (!socket) return if (!socket) return
@ -78,6 +94,20 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
}) })
} }
const getAppExists = async (
appName: string
): Promise<{ success: boolean; exists?: boolean }> => {
console.log("Is there a socket: " + !!socket)
if (!socket) {
console.error("Couldn't check if app exists: No socket")
return { success: false }
}
const response: { success: boolean; exists?: boolean } = await new Promise(
(resolve) => socket.emit("getAppExists", { appName }, resolve)
)
return response
}
const value = { const value = {
terminals, terminals,
setTerminals, setTerminals,
@ -88,6 +118,7 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({
createNewTerminal, createNewTerminal,
closeTerminal, closeTerminal,
deploy, deploy,
getAppExists: isSocketReady ? getAppExists : null,
} }
return ( return (

View File

@ -2,6 +2,9 @@
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { z } from "zod" import { z } from "zod"
import { editUserSchema } from "./schema"
import { UserLink } from "./types"
import { parseSocialLink } from "./utils"
export async function createSandbox(body: { export async function createSandbox(body: {
type: string type: string
@ -94,7 +97,7 @@ export async function unshareSandbox(sandboxId: string, userId: string) {
} }
export async function toggleLike(sandboxId: string, userId: string) { export async function toggleLike(sandboxId: string, userId: string) {
await fetch( const res = await fetch(
`${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/sandbox/like`, `${process.env.NEXT_PUBLIC_DATABASE_WORKER_URL}/api/sandbox/like`,
{ {
method: "POST", method: "POST",
@ -123,20 +126,31 @@ const UpdateErrorSchema = z.object({
.optional(), .optional(),
}) })
export async function updateUser(prevState: any, formData: FormData) { interface FormState {
const data = Object.fromEntries(formData) message: string
error?: any
const schema = z.object({ newRoute?: string
id: z.string(), fields?: Record<string, unknown>
username: z.string(), }
oldUsername: z.string(), export async function updateUser(
name: z.string(), prevState: any,
formData: FormData
): Promise<FormState> {
let data = Object.fromEntries(formData)
let links: UserLink[] = []
Object.entries(data).forEach(([key, value]) => {
if (key.startsWith("link")) {
const [_, index] = key.split(".")
if (value) {
links.splice(parseInt(index), 0, parseSocialLink(value as string))
delete data[key]
}
}
}) })
console.log(data) // @ts-ignore
data.links = links
try { try {
const validatedData = schema.parse(data) const validatedData = editUserSchema.parse(data)
const changedUsername = validatedData.username !== validatedData.oldUsername const changedUsername = validatedData.username !== validatedData.oldUsername
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`,
@ -150,6 +164,9 @@ export async function updateUser(prevState: any, formData: FormData) {
id: validatedData.id, id: validatedData.id,
username: data.username ?? undefined, username: data.username ?? undefined,
name: data.name ?? undefined, name: data.name ?? undefined,
bio: data.bio ?? undefined,
personalWebsite: data.personalWebsite ?? undefined,
links: data.links ?? undefined,
}), }),
} }
) )
@ -160,11 +177,11 @@ export async function updateUser(prevState: any, formData: FormData) {
const parseResult = UpdateErrorSchema.safeParse(responseData) const parseResult = UpdateErrorSchema.safeParse(responseData)
if (!parseResult.success) { if (!parseResult.success) {
return { error: "Unexpected error occurred" } return {
message: "Unexpected error occurred",
error: parseResult.error,
fields: validatedData,
} }
if (parseResult.data.error) {
return parseResult.data
} }
if (changedUsername) { if (changedUsername) {
@ -175,12 +192,13 @@ export async function updateUser(prevState: any, formData: FormData) {
return { message: "Successfully updated" } return { message: "Successfully updated" }
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
console.log(error)
return { return {
error: error.errors?.[0].message, message: "Invalid data",
error: error.errors,
fields: data,
} }
} }
return { error: "An unexpected error occurred" } return { message: "An unexpected error occurred", fields: data }
} }
} }

View File

@ -0,0 +1,14 @@
export const KNOWN_PLATFORMS = [
"github",
"twitter",
"instagram",
"bluesky",
"linkedin",
"youtube",
"twitch",
"discord",
"mastodon",
"threads",
"gitlab",
"generic",
] as const

View File

@ -1,3 +1,37 @@
import {
AtSign,
Github,
GitlabIcon as GitlabLogo,
Globe,
Instagram,
Link,
Linkedin,
MessageCircle,
Twitch,
Twitter,
Youtube,
} from "lucide-react"
import { KnownPlatform } from "../types"
export const socialIcons: Record<
KnownPlatform | "website",
React.ComponentType<any>
> = {
github: Github,
twitter: Twitter,
instagram: Instagram,
bluesky: AtSign,
linkedin: Linkedin,
youtube: Youtube,
twitch: Twitch,
discord: MessageCircle,
mastodon: AtSign,
threads: AtSign,
gitlab: GitlabLogo,
generic: Link,
website: Globe,
}
export const projectTemplates: { export const projectTemplates: {
id: string id: string
name: string name: string
@ -38,6 +72,6 @@ export const projectTemplates: {
name: "PHP", name: "PHP",
description: "PHP development environment", description: "PHP development environment",
icon: "/project-icons/php.svg", icon: "/project-icons/php.svg",
disabled: false disabled: false,
}, },
] ]

View File

@ -0,0 +1,20 @@
import { z } from "zod"
import { KNOWN_PLATFORMS } from "../constants"
export const editUserSchema = z.object({
id: z.string().trim(),
username: z.string().trim().min(1, "Username must be at least 1 character"),
oldUsername: z.string().trim(),
name: z.string().trim().min(1, "Name must be at least 1 character"),
bio: z.string().trim().optional(),
personalWebsite: z.string().trim().optional(),
links: z
.array(
z.object({
url: z.string().trim(),
platform: z.enum(KNOWN_PLATFORMS),
})
)
.catch([]),
})
export type EditUserSchema = z.infer<typeof editUserSchema>

View File

@ -1,7 +1,7 @@
export interface TemplateConfig { export interface TemplateConfig {
id: string id: string
name: string, name: string
runCommand: string, runCommand: string
fileStructure: { fileStructure: {
[key: string]: { [key: string]: {
purpose: string purpose: string
@ -25,67 +25,67 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"src/": { "src/": {
purpose: "source", purpose: "source",
description: "Contains all React components and application logic" description: "Contains all React components and application logic",
}, },
"src/components/": { "src/components/": {
purpose: "components", purpose: "components",
description: "Reusable React components" description: "Reusable React components",
}, },
"src/lib/": { "src/lib/": {
purpose: "utilities", purpose: "utilities",
description: "Utility functions and shared code" description: "Utility functions and shared code",
}, },
"src/App.tsx": { "src/App.tsx": {
purpose: "entry", purpose: "entry",
description: "Main application component" description: "Main application component",
}, },
"src/index.tsx": { "src/index.tsx": {
purpose: "entry", purpose: "entry",
description: "Application entry point" description: "Application entry point",
}, },
"src/index.css": { "src/index.css": {
purpose: "styles", purpose: "styles",
description: "Global CSS styles" description: "Global CSS styles",
}, },
"public/": { "public/": {
purpose: "static", purpose: "static",
description: "Static assets and index.html" description: "Static assets and index.html",
}, },
"tsconfig.json": { "tsconfig.json": {
purpose: "config", purpose: "config",
description: "TypeScript configuration" description: "TypeScript configuration",
}, },
"vite.config.ts": { "vite.config.ts": {
purpose: "config", purpose: "config",
description: "Vite bundler configuration" description: "Vite bundler configuration",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Project dependencies and scripts" description: "Project dependencies and scripts",
} },
}, },
conventions: [ conventions: [
"Use functional components with hooks", "Use functional components with hooks",
"Follow React naming conventions (PascalCase for components)", "Follow React naming conventions (PascalCase for components)",
"Keep components small and focused", "Keep components small and focused",
"Use TypeScript for type safety" "Use TypeScript for type safety",
], ],
dependencies: { dependencies: {
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", clsx: "^2.1.1",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
"react": "^18.3.1", react: "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
}, },
scripts: { scripts: {
"dev": "vite", dev: "vite",
"build": "tsc && vite build", build: "tsc && vite build",
"preview": "vite preview", preview: "vite preview",
} },
}, },
// Next.js template config // Next.js template config
nextjs: { nextjs: {
@ -95,71 +95,71 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"pages/": { "pages/": {
purpose: "routing", purpose: "routing",
description: "Page components and API routes" description: "Page components and API routes",
}, },
"pages/api/": { "pages/api/": {
purpose: "api", purpose: "api",
description: "API route handlers" description: "API route handlers",
}, },
"pages/_app.tsx": { "pages/_app.tsx": {
purpose: "entry", purpose: "entry",
description: "Application wrapper component" description: "Application wrapper component",
}, },
"pages/index.tsx": { "pages/index.tsx": {
purpose: "page", purpose: "page",
description: "Homepage component" description: "Homepage component",
}, },
"public/": { "public/": {
purpose: "static", purpose: "static",
description: "Static assets and files" description: "Static assets and files",
}, },
"styles/": { "styles/": {
purpose: "styles", purpose: "styles",
description: "CSS modules and global styles" description: "CSS modules and global styles",
}, },
"styles/globals.css": { "styles/globals.css": {
purpose: "styles", purpose: "styles",
description: "Global CSS styles" description: "Global CSS styles",
}, },
"styles/Home.module.css": { "styles/Home.module.css": {
purpose: "styles", purpose: "styles",
description: "Homepage-specific styles" description: "Homepage-specific styles",
}, },
"next.config.js": { "next.config.js": {
purpose: "config", purpose: "config",
description: "Next.js configuration" description: "Next.js configuration",
}, },
"next-env.d.ts": { "next-env.d.ts": {
purpose: "types", purpose: "types",
description: "Next.js TypeScript declarations" description: "Next.js TypeScript declarations",
}, },
"tsconfig.json": { "tsconfig.json": {
purpose: "config", purpose: "config",
description: "TypeScript configuration" description: "TypeScript configuration",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Project dependencies and scripts" description: "Project dependencies and scripts",
} },
}, },
conventions: [ conventions: [
"Use file-system based routing", "Use file-system based routing",
"Keep API routes in pages/api", "Keep API routes in pages/api",
"Use CSS Modules for component styles", "Use CSS Modules for component styles",
"Follow Next.js data fetching patterns" "Follow Next.js data fetching patterns",
], ],
dependencies: { dependencies: {
"next": "^14.1.0", next: "^14.1.0",
"react": "^18.2.0", react: "^18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"tailwindcss": "^3.4.1" tailwindcss: "^3.4.1",
}, },
scripts: { scripts: {
"dev": "next dev", dev: "next dev",
"build": "next build", build: "next build",
"start": "next start", start: "next start",
"lint": "next lint", lint: "next lint",
} },
}, },
// Streamlit template config // Streamlit template config
streamlit: { streamlit: {
@ -169,35 +169,35 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"main.py": { "main.py": {
purpose: "entry", purpose: "entry",
description: "Main Streamlit application file" description: "Main Streamlit application file",
}, },
"requirements.txt": { "requirements.txt": {
purpose: "dependencies", purpose: "dependencies",
description: "Python package dependencies" description: "Python package dependencies",
}, },
"Procfile": { Procfile: {
purpose: "deployment", purpose: "deployment",
description: "Deployment configuration for hosting platforms" description: "Deployment configuration for hosting platforms",
}, },
"venv/": { "venv/": {
purpose: "environment", purpose: "environment",
description: "Python virtual environment directory" description: "Python virtual environment directory",
} },
}, },
conventions: [ conventions: [
"Use Streamlit components for UI", "Use Streamlit components for UI",
"Follow PEP 8 style guide", "Follow PEP 8 style guide",
"Keep dependencies in requirements.txt", "Keep dependencies in requirements.txt",
"Use virtual environment for isolation" "Use virtual environment for isolation",
], ],
dependencies: { dependencies: {
"streamlit": "^1.40.0", streamlit: "^1.40.0",
"altair": "^5.5.0" altair: "^5.5.0",
}, },
scripts: { scripts: {
"start": "streamlit run main.py", start: "streamlit run main.py",
"dev": "./venv/bin/streamlit run main.py --server.runOnSave true" dev: "./venv/bin/streamlit run main.py --server.runOnSave true",
} },
}, },
// HTML template config // HTML template config
vanillajs: { vanillajs: {
@ -207,43 +207,43 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"index.html": { "index.html": {
purpose: "entry", purpose: "entry",
description: "Main HTML entry point" description: "Main HTML entry point",
}, },
"style.css": { "style.css": {
purpose: "styles", purpose: "styles",
description: "Global CSS styles" description: "Global CSS styles",
}, },
"script.js": { "script.js": {
purpose: "scripts", purpose: "scripts",
description: "JavaScript application logic" description: "JavaScript application logic",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Project dependencies and scripts" description: "Project dependencies and scripts",
}, },
"package-lock.json": { "package-lock.json": {
purpose: "config", purpose: "config",
description: "Locked dependency versions" description: "Locked dependency versions",
}, },
"vite.config.js": { "vite.config.js": {
purpose: "config", purpose: "config",
description: "Vite bundler configuration" description: "Vite bundler configuration",
} },
}, },
conventions: [ conventions: [
"Use semantic HTML elements", "Use semantic HTML elements",
"Keep CSS modular and organized", "Keep CSS modular and organized",
"Write clean, modular JavaScript", "Write clean, modular JavaScript",
"Follow modern ES6+ practices" "Follow modern ES6+ practices",
], ],
dependencies: { dependencies: {
"vite": "^5.0.12" vite: "^5.0.12",
}, },
scripts: { scripts: {
"dev": "vite", dev: "vite",
"build": "vite build", build: "vite build",
"preview": "vite preview" preview: "vite preview",
} },
}, },
// PHP template config // PHP template config
php: { php: {
@ -253,38 +253,38 @@ export interface TemplateConfig {
fileStructure: { fileStructure: {
"index.php": { "index.php": {
purpose: "entry", purpose: "entry",
description: "Main PHP entry point" description: "Main PHP entry point",
}, },
"package.json": { "package.json": {
purpose: "config", purpose: "config",
description: "Frontend dependencies and scripts" description: "Frontend dependencies and scripts",
}, },
"package-lock.json": { "package-lock.json": {
purpose: "config", purpose: "config",
description: "Locked dependency versions" description: "Locked dependency versions",
}, },
"vite.config.js": { "vite.config.js": {
purpose: "config", purpose: "config",
description: "Vite configuration for frontend assets" description: "Vite configuration for frontend assets",
}, },
"node_modules/": { "node_modules/": {
purpose: "dependencies", purpose: "dependencies",
description: "Frontend dependency files" description: "Frontend dependency files",
} },
}, },
conventions: [ conventions: [
"Follow PSR-12 coding standards", "Follow PSR-12 coding standards",
"Use modern PHP 8+ features", "Use modern PHP 8+ features",
"Organize assets with Vite", "Organize assets with Vite",
"Keep PHP logic separate from presentation" "Keep PHP logic separate from presentation",
], ],
dependencies: { dependencies: {
"vite": "^5.0.0" vite: "^5.0.0",
}, },
scripts: { scripts: {
"dev": "vite", dev: "vite",
"build": "vite build", build: "vite build",
"preview": "vite preview" preview: "vite preview",
} },
} },
} }

View File

@ -1,5 +1,7 @@
// DB Types // DB Types
import { KNOWN_PLATFORMS } from "./constants"
export type User = { export type User = {
id: string id: string
name: string name: string
@ -8,11 +10,20 @@ export type User = {
avatarUrl: string | null avatarUrl: string | null
createdAt: Date createdAt: Date
generations: number generations: number
sandbox: Sandbox[] bio: string | null
usersToSandboxes: UsersToSandboxes[] personalWebsite: string | null
links: UserLink[]
tier: "FREE" | "PRO" | "ENTERPRISE" tier: "FREE" | "PRO" | "ENTERPRISE"
tierExpiresAt: Date tierExpiresAt: Date
lastResetDate?: number lastResetDate: number
sandbox: Sandbox[]
usersToSandboxes: UsersToSandboxes[]
}
export type KnownPlatform = (typeof KNOWN_PLATFORMS)[number]
export type UserLink = {
url: string
platform: KnownPlatform
} }
export type Sandbox = { export type Sandbox = {

View File

@ -2,7 +2,7 @@ import { type ClassValue, clsx } from "clsx"
// import { toast } from "sonner" // import { toast } from "sonner"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import fileExtToLang from "./file-extension-to-language.json" import fileExtToLang from "./file-extension-to-language.json"
import { TFile, TFolder } from "./types" import { KnownPlatform, TFile, TFolder, UserLink } from "./types"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -98,3 +98,57 @@ export function sortFileExplorer(
return item return item
}) })
} }
export function parseSocialLink(url: string): UserLink {
try {
// Handle empty or invalid URLs
if (!url) return { url: "", platform: "generic" }
// Remove protocol and www prefix for consistent parsing
const cleanUrl = url
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.split("/")[0] // Get just the domain part
// Platform detection mapping
const platformPatterns: Record<
Exclude<KnownPlatform, "generic">,
RegExp
> = {
github: /github\.com/,
twitter: /(?:twitter\.com|x\.com|t\.co)/,
instagram: /instagram\.com/,
bluesky: /(?:bsky\.app|bluesky\.social)/,
linkedin: /linkedin\.com/,
youtube: /(?:youtube\.com|youtu\.be)/,
twitch: /twitch\.tv/,
discord: /discord\.(?:gg|com)/,
mastodon: /mastodon\.(?:social|online|world)/,
threads: /threads\.net/,
gitlab: /gitlab\.com/,
}
// Check URL against each pattern
for (const [platform, pattern] of Object.entries(platformPatterns)) {
if (pattern.test(cleanUrl)) {
return {
url,
platform: platform as KnownPlatform,
}
}
}
// Fall back to generic if no match found
return {
url,
platform: "generic",
}
} catch (error) {
console.error("Error parsing social link:", error)
return {
url: url || "",
platform: "generic",
}
}
}

View File

@ -13,7 +13,7 @@
"@clerk/nextjs": "^4.29.12", "@clerk/nextjs": "^4.29.12",
"@clerk/themes": "^1.7.12", "@clerk/themes": "^1.7.12",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.9.1",
"@liveblocks/client": "^1.12.0", "@liveblocks/client": "^1.12.0",
"@liveblocks/node": "^1.12.0", "@liveblocks/node": "^1.12.0",
"@liveblocks/react": "^1.12.0", "@liveblocks/react": "^1.12.0",
@ -27,14 +27,15 @@
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.6",
"@react-three/fiber": "^8.16.6", "@react-three/fiber": "^8.16.6",
"@uiw/codemirror-theme-vscode": "^4.23.5", "@uiw/codemirror-theme-vscode": "^4.23.5",
"@uiw/react-codemirror": "^4.23.5", "@uiw/react-codemirror": "^4.23.5",
@ -53,11 +54,12 @@
"lucide-react": "^0.365.0", "lucide-react": "^0.365.0",
"monaco-themes": "^0.4.4", "monaco-themes": "^0.4.4",
"next": "14.1.3", "next": "14.1.3",
"next-themes": "^0.3.0", "next-themes": "^0.4.4",
"openai": "^4.73.1",
"posthog-js": "^1.147.0", "posthog-js": "^1.147.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.54.2",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.16", "react-resizable-panels": "^2.0.16",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
@ -72,7 +74,7 @@
"y-monaco": "^0.1.5", "y-monaco": "^0.1.5",
"y-protocols": "^1.0.6", "y-protocols": "^1.0.6",
"yjs": "^13.6.15", "yjs": "^13.6.15",
"zod": "^3.23.8" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",
@ -908,9 +910,9 @@
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
}, },
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.3.4", "version": "3.9.1",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.4.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz",
"integrity": "sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==", "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==",
"peerDependencies": { "peerDependencies": {
"react-hook-form": "^7.0.0" "react-hook-form": "^7.0.0"
} }
@ -1364,6 +1366,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz",
@ -1533,6 +1553,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz",
@ -1631,6 +1669,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz",
@ -2115,18 +2171,39 @@
} }
}, },
"node_modules/@radix-ui/react-label": { "node_modules/@radix-ui/react-label": {
"version": "2.0.2", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz",
"integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==", "integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "2.0.1"
"@radix-ui/react-primitive": "1.0.3"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"@types/react-dom": "*", "@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0" "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"dependencies": {
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/react": { "@types/react": {
@ -2177,6 +2254,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz",
@ -2692,6 +2787,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress": { "node_modules/@radix-ui/react-progress": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz",
@ -2817,6 +2930,161 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz",
"integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"dependencies": {
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
@ -2860,7 +3128,7 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
"integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
@ -2878,6 +3146,37 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": { "node_modules/@radix-ui/react-switch": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.0.3.tgz",
@ -3191,23 +3490,22 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.1.3", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz",
"integrity": "sha512-Z4w1FIS0BqVFI2c1jZvb/uDVJijJjJ2ZMuPV81oVgTZ7g3BZxobplnMVvXtFWgtozdvYJ+MFWtwkM5S2HnAong==", "integrity": "sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.0", "@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1", "@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-id": "1.1.0", "@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.0", "@radix-ui/react-popper": "1.2.1",
"@radix-ui/react-portal": "1.1.2", "@radix-ui/react-portal": "1.1.3",
"@radix-ui/react-presence": "1.1.1", "@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-slot": "1.1.0", "@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.0" "@radix-ui/react-visually-hidden": "1.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@ -3225,18 +3523,16 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
"license": "MIT"
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-arrow": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
"integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", "integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-primitive": "2.0.0" "@radix-ui/react-primitive": "2.0.1"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@ -3254,10 +3550,9 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
@ -3284,14 +3579,13 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.1", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz",
"integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.0", "@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0" "@radix-ui/react-use-escape-keydown": "1.1.0"
}, },
@ -3329,16 +3623,15 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": {
"version": "1.2.0", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
"integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", "integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/react-dom": "^2.0.0", "@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.0", "@radix-ui/react-arrow": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.0", "@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0",
@ -3360,28 +3653,12 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-portal": {
"version": "1.1.2", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz",
"integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-layout-effect": "1.1.0" "@radix-ui/react-use-layout-effect": "1.1.0"
}, },
"peerDependencies": { "peerDependencies": {
@ -3400,12 +3677,11 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0" "@radix-ui/react-use-layout-effect": "1.1.0"
}, },
"peerDependencies": { "peerDependencies": {
@ -3424,12 +3700,11 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-slot": "1.1.0" "@radix-ui/react-slot": "1.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@ -3446,24 +3721,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@ -3501,7 +3758,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
"integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0" "@radix-ui/react-use-callback-ref": "1.1.0"
}, },
@ -3534,7 +3790,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz",
"integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/rect": "1.1.0" "@radix-ui/rect": "1.1.0"
}, },
@ -3552,7 +3807,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0" "@radix-ui/react-use-layout-effect": "1.1.0"
}, },
@ -3567,12 +3821,11 @@
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz",
"integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-primitive": "2.0.0" "@radix-ui/react-primitive": "2.0.1"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@ -3592,8 +3845,7 @@
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/rect": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="
"license": "MIT"
}, },
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.0.1", "version": "1.0.1",
@ -7324,12 +7576,13 @@
} }
}, },
"node_modules/next-themes": { "node_modules/next-themes": {
"version": "0.3.0", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz",
"integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==", "integrity": "sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==",
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^16.8 || ^17 || ^18", "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18" "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
@ -7492,6 +7745,65 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openai": {
"version": "4.73.1",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.73.1.tgz",
"integrity": "sha512-nWImDJBcUsqrhy7yJScXB4+iqjzbUEgzfA3un/6UnHFdwWhjX24oztj69Ped/njABfOdLcO/F7CeWTI5dt8Xmg==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/openai/node_modules/@types/node": {
"version": "18.19.67",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.67.tgz",
"integrity": "sha512-wI8uHusga+0ZugNp0Ol/3BqQfEcCCNfojtO6Oou9iVNGPTL6QNSdnUdqq85fRgIorLhLMuPIKpsN98QE9Nh+KQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/openai/node_modules/@types/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/openai/node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ora": { "node_modules/ora": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz",
@ -7968,18 +8280,18 @@
} }
}, },
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.51.3", "version": "7.54.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
"integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==", "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
"engines": { "engines": {
"node": ">=12.22.0" "node": ">=18.0.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/react-hook-form" "url": "https://opencollective.com/react-hook-form"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17 || ^18" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-markdown": { "node_modules/react-markdown": {
@ -9664,9 +9976,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.23.8", "version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@ -14,7 +14,7 @@
"@clerk/nextjs": "^4.29.12", "@clerk/nextjs": "^4.29.12",
"@clerk/themes": "^1.7.12", "@clerk/themes": "^1.7.12",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@hookform/resolvers": "^3.3.4", "@hookform/resolvers": "^3.9.1",
"@liveblocks/client": "^1.12.0", "@liveblocks/client": "^1.12.0",
"@liveblocks/node": "^1.12.0", "@liveblocks/node": "^1.12.0",
"@liveblocks/react": "^1.12.0", "@liveblocks/react": "^1.12.0",
@ -28,14 +28,15 @@
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.6",
"@react-three/fiber": "^8.16.6", "@react-three/fiber": "^8.16.6",
"@uiw/codemirror-theme-vscode": "^4.23.5", "@uiw/codemirror-theme-vscode": "^4.23.5",
"@uiw/react-codemirror": "^4.23.5", "@uiw/react-codemirror": "^4.23.5",
@ -54,11 +55,12 @@
"lucide-react": "^0.365.0", "lucide-react": "^0.365.0",
"monaco-themes": "^0.4.4", "monaco-themes": "^0.4.4",
"next": "14.1.3", "next": "14.1.3",
"next-themes": "^0.3.0", "next-themes": "^0.4.4",
"openai": "^4.73.1",
"posthog-js": "^1.147.0", "posthog-js": "^1.147.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.51.3", "react-hook-form": "^7.54.2",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.16", "react-resizable-panels": "^2.0.16",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
@ -73,7 +75,7 @@
"y-monaco": "^0.1.5", "y-monaco": "^0.1.5",
"y-protocols": "^1.0.6", "y-protocols": "^1.0.6",
"yjs": "^13.6.15", "yjs": "^13.6.15",
"zod": "^3.23.8" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/estree": "^1.0.6", "@types/estree": "^1.0.6",