├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── backend ├── nodemon.json ├── package-lock.json ├── package.json ├── prisma │ ├── migrations │ │ ├── 20241030140234_init │ │ │ └── migration.sql │ │ ├── 20241030153621_init │ │ │ └── migration.sql │ │ ├── 20241101145954_init │ │ │ └── migration.sql │ │ └── migration_lock.toml │ ├── schema.prisma │ └── seed.ts ├── src │ ├── index.ts │ ├── prisma.ts │ ├── socketio.ts │ └── utils.ts └── tsconfig.json ├── frontend ├── .env.template ├── app │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components.json ├── components │ ├── editor │ │ ├── access.tsx │ │ ├── colorPicker.tsx │ │ ├── colorPreview.tsx │ │ ├── confirm.tsx │ │ ├── explorer.tsx │ │ ├── index.tsx │ │ ├── settings.tsx │ │ ├── tabs.tsx │ │ ├── toolbar.tsx │ │ └── upload.tsx │ ├── local-block │ │ └── index.tsx │ ├── providers │ │ ├── color-context.tsx │ │ ├── index.tsx │ │ └── theme-provider.tsx │ └── ui │ │ ├── button.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── file-uploader.tsx │ │ ├── input.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── tooltip-button.tsx │ │ └── tooltip.tsx ├── hooks │ ├── use-callback-ref.tsx │ ├── use-controllable-state.tsx │ └── use-screen-size.tsx ├── lib │ ├── actions.ts │ ├── collaboration.ts │ ├── colors.ts │ ├── decorations.ts │ ├── lang.ts │ ├── query.ts │ ├── rules.ts │ ├── types.ts │ └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prettier.config.js ├── styles │ └── color.css ├── tailwind.config.ts └── tsconfig.json ├── george └── asn │ ├── a00 │ ├── a00q01.grg │ └── a00q02.grg │ ├── a01 │ ├── a01q01.grg │ ├── a01q02.grg │ ├── a01q03.grg │ ├── a01q04.grg │ ├── a01q05.grg │ ├── a01q06.grg │ └── a01q07.grg │ ├── a02 │ ├── a02q01.grg │ ├── a02q02.grg │ ├── a02q03.grg │ ├── a02q04.grg │ ├── a02q05.grg │ └── a02q06.grg │ ├── a03 │ ├── a03q01.grg │ ├── a03q02.grg │ ├── a03q03.grg │ ├── a03q04.grg │ ├── a03q05.grg │ ├── a03q06.grg │ └── a03q07.grg │ └── p01 │ ├── p01q01.grg │ └── p01q02.grg ├── package-lock.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | coverage 11 | 12 | # next.js 13 | .next/ 14 | out/ 15 | 16 | # production 17 | build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | /backend/dist 40 | backend/prisma/dev.db 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Axiom 2 | 3 | Thanks for your interest in contributing! This guide will help you get started. 4 | 5 | ## Getting Started 6 | 7 | 1. Fork the repository 8 | 2. Clone your fork: `git clone https://github.com/ishaan1013/axiom.git` 9 | 3. Create a new branch: `git checkout -b feature/your-feature-name` 10 | 4. Follow the setup instructions in the [README](README.md#running-locally) 11 | 12 | ## Development Workflow 13 | 14 | ### Branch Naming 15 | 16 | - `feature/` - New features 17 | - `fix/` - Bug fixes 18 | - `refactor/` - Code refactoring 19 | 20 | ### Commit Messages 21 | 22 | Follow conventional commits: 23 | 24 | ``` 25 | type(scope): description 26 | 27 | [optional body] 28 | ``` 29 | 30 | Types: 31 | 32 | - `feat`: New feature 33 | - `fix`: Bug fix 34 | - `style`: Formatting changes 35 | - `refactor`: Code restructuring 36 | 37 | ### Code Style 38 | 39 | - Use TypeScript 40 | - Follow existing code formatting (Prettier) 41 | - Keep components small and focused 42 | - Use meaningful variable names 43 | 44 | ## Pull Requests 45 | 46 | 1. Update your branch with main: `git rebase main` 47 | 2. Push to your fork: `git push origin feature/your-feature-name` 48 | 3. Create a PR with: 49 | - Clear title and description 50 | - Link to related issues 51 | - Screenshots for UI changes 52 | - List of tested browsers/devices 53 | 54 | ## Project Structure 55 | 56 | ``` 57 | axiom/ 58 | ├── frontend/ # Next.js frontend 59 | │ ├── app/ # Pages and routing 60 | │ ├── components/ # React components 61 | │ └── lib/ # Utilities and helpers 62 | └── backend/ # Express backend 63 | ├── src/ # Source code 64 | └── prisma/ # Database schema 65 | ``` 66 | 67 | Notable files: 68 | 69 | - Frontend 70 | - `frontend/lib/lang.ts` - Language definition 71 | - `frontend/components/editor/index.tsx` - Editor component 72 | - `frontend/components/editor/explorer.tsx` - File explorer 73 | - `frontend/lib/query.ts` - [Tanstack Query](https://tanstack.com/query/latest) hooks; _write all query and mutation logic here_ 74 | - `frontend/lib/actions.ts` - Data fetching helpers; _write all data fetching logic here_ 75 | - Backend 76 | - `backend/src/index.ts` - Backend entry point and HTTP route handling 77 | - `backend/src/socketio.ts` - Socket.io event handling for workspace collaboration 78 | - `backend/src/utils.ts` - Prisma client operations; _write all database operations here_ 79 | - `backend/prisma/schema.prisma` - Prisma database schema 80 | 81 | ## Need Help? 82 | 83 | - Check existing [issues](https://github.com/ishaan1013/axiom/issues) 84 | - Create a new issue 85 | - Ask Ishaan (i2dey@uwaterloo.ca) or Rajan (r34agarw@uwaterloo.ca) 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ishaan Dey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Axiom 2 | 3 | An editor interface for George, for [SE212 (Logic and Computation)](https://student.cs.uwaterloo.ca/~se212/notes.html) at the University of Waterloo. 4 | 5 | ![axiom-cover](https://github.com/user-attachments/assets/bbec3b04-8443-4735-a149-289bc5abbdf3) 6 | 7 | ## Features 8 | 9 | - **Multiplayer collaboration with [Workspaces](#workspaces)** 10 | - Invite classmates by WatIAM ID 11 | - Manage collaborators and invitations 12 | - View other cursors and selections 13 | - **Intelligent language support** 14 | - Auto-incrementing line numbers 15 | - Auto-decrementing line numbers (`⌘+X`) 16 | - Auto-updating rule references 17 | - Auto-closing braces and indentations 18 | - Comments (`⌘+/`) 19 | - Jump to line definition (`⌘+Click`) 20 | - Hover tooltip for rule definitions 21 | - Boundaries above and below at `#check {x}` 22 | - Remove empty lines with line numbers with `⏎` 23 | - **User interface** 24 | - Tabs for opening multiple files 25 | - Collapsible and resizable panels 26 | - Sidebar explorer (`⌘+B`) 27 | - George output (`⌘+J`) 28 | - Keyboard shortcuts for all major features 29 | - VSCode-like editor features and shortcuts (we use the same editor library as VSCode) 30 | - Local assignment and project files, persisted in local storage 31 | - Upload a `.grg` file into the current tab (`⌘+U`) 32 | - Download the current file 33 | - **Settings menu (`⌘+K`)** 34 | - Light/dark mode 35 | - Toggle autocomplete 36 | - Individually customizable editor colours 37 | 38 | ## Workspaces 39 | 40 | Workspaces are shared folders for multiplayer collaboration. You can create a workspace by clicking the `+` button in the sidebar. You can invite and manage collaborators by clicking on the three dots or right-clicking on a workspace. You can view your invitations in Settings>Invites (`⌘+K`). 41 | 42 | Workspace rules: 43 | 44 | - **Permissionless** 45 | - Any user in a workspace has equal permission to invite users, revoke invitations, remove collaborators, edit files, and delete the workspace 46 | - Only invite users you trust! 47 | - **Unique folder** 48 | - You can only have one workspace for a folder. For example, you cannot have more than one workspace for `Project 1`. 49 | - You can not accept an invitation to a workspace for a folder that you already have access to. 50 | - **One at a time** 51 | - You can only have one workspace open at a time in the editor 52 | - Temporary limitation for backend simplicity, may be lifted in the future 53 | 54 | ## Contributing 55 | 56 | Please see [CONTRIBUTING.md](CONTRIBUTING.md) for information on contributing to Axiom. 57 | 58 | ## Running Locally 59 | 60 | ### Frontend 61 | 62 | We use Next.js 14 + Typescript, as well as these libraries: 63 | 64 | - Monaco editor 65 | - Y.js 66 | - Socket.io 67 | - Tanstack Query 68 | - TailwindCSS 69 | - Shadcn UI 70 | 71 | Set up environment variables in `frontend/.env.local`: 72 | 73 | ``` 74 | NEXT_PUBLIC_SERVER_URL=http://localhost:4000 75 | ``` 76 | 77 | Run the frontend: 78 | 79 | ``` 80 | cd frontend 81 | npm i 82 | npm run dev 83 | ``` 84 | 85 | > [!WARNING] 86 | > Since we're running locally without Waterloo's authentication, you must set the `watiam` property in local storage to your WatIAM ID (or any string). 87 | > Then create a user in the DB with that WatIAM ID for everything to work. The easiest way to do this is with prisma studio (run `npx prisma studio` in the backend directory). 88 | 89 | ### Backend 90 | 91 | We use a Typescript + Express + Node.js server for our HTTP and WebSockets server, as well as these libraries: 92 | 93 | - Socket.io 94 | - Y.js 95 | - Prisma 96 | 97 | Set up environment variables in `backend/.env`: 98 | 99 | ``` 100 | DATABASE_URL="file:./dev.db" 101 | ``` 102 | 103 | Run the backend: 104 | 105 | ``` 106 | cd backend 107 | npm i 108 | npm run dev 109 | ``` 110 | 111 | #### Prisma 112 | 113 | 1. Compile the Prisma Schema 114 | 115 | ``` 116 | npx prisma generate 117 | ``` 118 | 119 | 2. Run a migration to create your database tables with Prisma Migrate 120 | 121 | ``` 122 | npx prisma migrate dev --name init 123 | ``` 124 | 125 | 3. (Optional) Run the seed to initialize it with some values 126 | 127 | ``` 128 | npx prisma db seed 129 | ``` 130 | 131 | 4. Explore the data in Prisma Studio 132 | 133 | ``` 134 | npx prisma studio 135 | ``` 136 | -------------------------------------------------------------------------------- /backend/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "exec": "ts-node -r tsconfig-paths/register src/index.ts" 5 | } -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "tsc --project tsconfig.json && tsc-alias -p tsconfig.json", 9 | "start": "node dist/index.js", 10 | "dev": "concurrently \"npx tsc --watch\" \"npx nodemon\"" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@prisma/client": "^5.21.1", 17 | "@types/cors": "^2.8.17", 18 | "cors": "^2.8.5", 19 | "dotenv": "^16.4.5", 20 | "express": "^4.21.1", 21 | "socket.io": "^4.8.1", 22 | "yjs": "^13.6.20" 23 | }, 24 | "prisma": { 25 | "schema": "prisma/schema.prisma" 26 | }, 27 | "devDependencies": { 28 | "@types/express": "^5.0.0", 29 | "@types/node": "^22.8.4", 30 | "concurrently": "^9.0.1", 31 | "nodemon": "^3.1.7", 32 | "prisma": "^5.21.1", 33 | "ts-node": "^10.9.2", 34 | "tsc-alias": "^1.8.10", 35 | "tsconfig-paths": "^4.2.0", 36 | "typescript": "^5.6.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20241030140234_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "email" TEXT NOT NULL, 5 | "name" TEXT 6 | ); 7 | 8 | -- CreateIndex 9 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 10 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20241030153621_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to drop the column `email` on the `User` table. All the data in the column will be lost. 6 | - You are about to drop the column `name` on the `User` table. All the data in the column will be lost. 7 | 8 | */ 9 | -- CreateTable 10 | CREATE TABLE "Workspace" ( 11 | "id" TEXT NOT NULL PRIMARY KEY, 12 | "project" TEXT NOT NULL, 13 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "updatedAt" DATETIME NOT NULL 15 | ); 16 | 17 | -- CreateTable 18 | CREATE TABLE "File" ( 19 | "id" TEXT NOT NULL PRIMARY KEY, 20 | "path" TEXT NOT NULL, 21 | "name" TEXT NOT NULL, 22 | "content" TEXT NOT NULL, 23 | "workspaceId" TEXT NOT NULL, 24 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | "updatedAt" DATETIME NOT NULL, 26 | CONSTRAINT "File_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "_UserToWorkspace" ( 31 | "A" TEXT NOT NULL, 32 | "B" TEXT NOT NULL, 33 | CONSTRAINT "_UserToWorkspace_A_fkey" FOREIGN KEY ("A") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 34 | CONSTRAINT "_UserToWorkspace_B_fkey" FOREIGN KEY ("B") REFERENCES "Workspace" ("id") ON DELETE CASCADE ON UPDATE CASCADE 35 | ); 36 | 37 | -- RedefineTables 38 | PRAGMA defer_foreign_keys=ON; 39 | PRAGMA foreign_keys=OFF; 40 | CREATE TABLE "new_User" ( 41 | "id" TEXT NOT NULL PRIMARY KEY 42 | ); 43 | INSERT INTO "new_User" ("id") SELECT "id" FROM "User"; 44 | DROP TABLE "User"; 45 | ALTER TABLE "new_User" RENAME TO "User"; 46 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 47 | PRAGMA foreign_keys=ON; 48 | PRAGMA defer_foreign_keys=OFF; 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "_UserToWorkspace_AB_unique" ON "_UserToWorkspace"("A", "B"); 52 | 53 | -- CreateIndex 54 | CREATE INDEX "_UserToWorkspace_B_index" ON "_UserToWorkspace"("B"); 55 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20241101145954_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Invite" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "workspaceId" TEXT NOT NULL, 5 | "userId" TEXT NOT NULL, 6 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" DATETIME NOT NULL, 8 | CONSTRAINT "Invite_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "Workspace" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 9 | CONSTRAINT "Invite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 10 | ); 11 | -------------------------------------------------------------------------------- /backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // Prisma schema file for the project 2 | 3 | generator client { 4 | provider = "prisma-client-js" 5 | } 6 | 7 | datasource db { 8 | provider = "sqlite" 9 | url = env("DATABASE_URL") 10 | } 11 | 12 | model User { 13 | id String @id @unique 14 | workspaces Workspace[] 15 | invites Invite[] 16 | } 17 | 18 | model Workspace { 19 | id String @id @default(uuid()) 20 | users User[] 21 | project String 22 | files File[] @relation("WorkspaceToFiles") 23 | createdAt DateTime @default(now()) 24 | updatedAt DateTime @updatedAt 25 | invites Invite[] @relation("WorkspaceToInvites") 26 | } 27 | 28 | model File { 29 | id String @id @default(uuid()) 30 | path String 31 | name String 32 | content String 33 | workspace Workspace @relation("WorkspaceToFiles", fields: [workspaceId], references: [id], onDelete: Cascade) 34 | workspaceId String 35 | createdAt DateTime @default(now()) 36 | updatedAt DateTime @updatedAt 37 | 38 | @@unique([workspaceId, path]) 39 | } 40 | 41 | model Invite { 42 | id String @id @default(uuid()) 43 | workspace Workspace @relation("WorkspaceToInvites", fields: [workspaceId], references: [id], onDelete: Cascade) 44 | workspaceId String 45 | userId String 46 | user User @relation(fields: [userId], references: [id]) 47 | createdAt DateTime @default(now()) 48 | updatedAt DateTime @updatedAt 49 | } 50 | -------------------------------------------------------------------------------- /backend/prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../src/prisma"; 2 | 3 | async function main() { 4 | await prisma.user.deleteMany({}); 5 | await prisma.workspace.deleteMany({}); 6 | 7 | const user = await prisma.user.create({ 8 | data: { id: "r34agarw" }, 9 | }); 10 | 11 | const workspace = await prisma.workspace.create({ 12 | data: { 13 | project: "Assignment 0", 14 | users: { 15 | connect: { id: user.id }, 16 | }, 17 | }, 18 | include: { users: true }, 19 | }); 20 | 21 | console.log("Seeding completed"); 22 | console.log("User:", user); 23 | console.log("Workspace with connected User:", workspace); 24 | } 25 | 26 | main() 27 | .catch((e) => console.error(e)) 28 | .finally(async () => { 29 | await prisma.$disconnect(); 30 | }); 31 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Express, Request, Response } from "express"; 2 | import dotenv from "dotenv"; 3 | import { createServer } from "http"; 4 | import { promises as fs } from "fs"; 5 | import { Server } from "socket.io"; 6 | import { handleConnection } from "./socketio"; 7 | import { 8 | getWorkspacesForUser, 9 | createNewWorkspace, 10 | deleteWorkspaceById, 11 | createWorkspaceInvite, 12 | handleInviteResponse, 13 | removeUserFromWorkspace, 14 | getWorkspaceUsers, 15 | getWorkspaceInvites, 16 | getInvitesForUser, 17 | } from "./utils"; 18 | import cors from "cors"; 19 | import path from "path"; 20 | 21 | dotenv.config(); 22 | 23 | interface File { 24 | name: string; 25 | path: string; 26 | } 27 | 28 | interface FileMap { 29 | name: string; 30 | files: File[]; 31 | // Add other properties if necessary 32 | } 33 | 34 | const app: Express = express(); 35 | const port = process.env.PORT || 4000; 36 | 37 | const load_files_locally = true; 38 | 39 | app.use(express.json()); 40 | app.use(cors()); 41 | 42 | // Create an HTTP server 43 | const httpServer = createServer(app); 44 | 45 | // Initialize Socket.IO with the HTTP server 46 | const io = new Server(httpServer, { 47 | cors: { 48 | origin: "*", // Adjust for security if needed 49 | }, 50 | }); 51 | 52 | // Integrate the connection handler with Socket.IO 53 | handleConnection(io); 54 | 55 | const getFiles = async () => { 56 | const files = await fetch( 57 | "https://student.cs.uwaterloo.ca/~se212/files.json" 58 | ); 59 | return await files.json(); 60 | }; 61 | 62 | // Express route 63 | app.get("/", (req: Request, res: Response) => { 64 | res.send("SE212 Server"); 65 | }); 66 | 67 | // Get all workspaces 68 | app.get("/api/workspaces", async (req: Request, res: Response) => { 69 | try { 70 | const userId = req.query.userId as string; 71 | const workspaces = await getWorkspacesForUser(userId); 72 | res.json({ workspaces }); 73 | } catch (error) { 74 | console.error("Failed to fetch workspaces", error); 75 | res.status(500).json({ message: "Failed to fetch workspaces" }); 76 | } 77 | }); 78 | 79 | // Create workspace 80 | app.put("/api/workspaces", async (req: Request, res: Response) => { 81 | console.log("Creating workspace", req.body); 82 | try { 83 | const { userId, assignmentId } = req.body; 84 | const files_map = await getFiles(); // Await the result of getFiles 85 | let files: File[] = []; 86 | files_map.forEach((assignment: FileMap) => { 87 | if (assignment.name == assignmentId) { 88 | files = assignment.files; 89 | } 90 | }); 91 | console.log(`Files: ${files}`); 92 | // Check if files is not null before iterating 93 | let loaded_files = []; 94 | if (files) { 95 | if (load_files_locally) { 96 | for (const file of files) { 97 | let filename = file.name; 98 | console.log(file.path); 99 | let local_filepath = path.join(__dirname, "../../george", file.path); // Adjust the path as necessary 100 | const fileContent: string = await fs.readFile(local_filepath, "utf8"); 101 | loaded_files.push({ 102 | name: filename, 103 | path: local_filepath, 104 | content: fileContent, 105 | }); 106 | console.log({ 107 | name: filename, 108 | path: local_filepath, 109 | content: fileContent, 110 | }); 111 | } 112 | } else { 113 | files.forEach((file: File) => { 114 | // Loading files from remote, can't do that at the moment 115 | // let filename = file.name; 116 | // let filepath = file.path; 117 | // let fileContent = await getFile(filepath); 118 | }); 119 | } 120 | } 121 | console.log(`Starting workspace with files: ${files}`); 122 | const workspace = await createNewWorkspace( 123 | userId, 124 | assignmentId, 125 | loaded_files 126 | ); 127 | console.log("Finished creating workspace"); 128 | res.json({ workspaceId: workspace.id }); 129 | } catch (error) { 130 | console.error("Failed to create workspace", error); 131 | res.status(500).json({ message: "Failed to create workspace", error }); 132 | } 133 | }); 134 | 135 | // Delete workspace 136 | app.delete("/api/workspaces", async (req: Request, res: Response) => { 137 | try { 138 | const { workspaceId } = req.body; 139 | const deleted = await deleteWorkspaceById(workspaceId); 140 | res.json({ message: `Workspace ${deleted.project} deleted successfully` }); 141 | } catch (error) { 142 | res.status(500).json({ message: "Failed to delete workspace" }); 143 | } 144 | }); 145 | 146 | // Invite to workspace 147 | app.post("/api/workspaces/invite", async (req: Request, res: Response) => { 148 | try { 149 | const { userId, workspaceId } = req.body; 150 | const invite = await createWorkspaceInvite(workspaceId, userId); 151 | res.json({ inviteId: invite.id }); 152 | } catch (error) { 153 | console.error("Failed to create invite", error); 154 | res.status(500).json({ message: "Failed to create invite" }); 155 | } 156 | }); 157 | 158 | // Accept/decline invite 159 | app.post( 160 | "/api/workspaces/invite/accept", 161 | async (req: Request, res: Response) => { 162 | try { 163 | const { inviteId, accept } = req.body; 164 | await handleInviteResponse(inviteId, accept); 165 | res.json({ 166 | message: `Invitation ${accept ? "accepted" : "declined"} successfully`, 167 | }); 168 | } catch (error) { 169 | res.status(500).json({ message: "Failed to process invite response" }); 170 | } 171 | } 172 | ); 173 | 174 | // Delete invite 175 | app.delete("/api/workspaces/invite", async (req: Request, res: Response) => { 176 | try { 177 | const { inviteId } = req.body; 178 | await handleInviteResponse(inviteId, false); 179 | res.json({ message: "Invitation deleted successfully" }); 180 | } catch (error) { 181 | res.status(500).json({ message: "Failed to delete invite" }); 182 | } 183 | }); 184 | 185 | // Remove collaborator 186 | app.delete( 187 | "/api/workspaces/collaborator", 188 | async (req: Request, res: Response) => { 189 | try { 190 | const { userId, workspaceId } = req.body; 191 | await removeUserFromWorkspace(workspaceId, userId); 192 | res.json({ 193 | message: `Collaborator ${userId} removed successfully`, 194 | }); 195 | } catch (error) { 196 | res.status(500).json({ message: "Failed to remove collaborator" }); 197 | } 198 | } 199 | ); 200 | 201 | // Add these routes 202 | app.get("/api/workspaces/:id/users", async (req, res) => { 203 | try { 204 | const currentUserId = req.query.userId as string; 205 | const users = await getWorkspaceUsers(req.params.id, currentUserId); 206 | res.json(users); 207 | } catch (error) { 208 | res.status(500).json({ message: "Failed to fetch users" }); 209 | } 210 | }); 211 | 212 | app.get("/api/workspaces/:id/invites", async (req, res) => { 213 | try { 214 | const invites = await getWorkspaceInvites(req.params.id); 215 | res.json(invites); 216 | } catch (error) { 217 | res.status(500).json({ message: "Failed to fetch invites" }); 218 | } 219 | }); 220 | 221 | // Add this route 222 | app.get("/api/workspaces/invites/user/:userId", async (req, res) => { 223 | try { 224 | const invites = await getInvitesForUser(req.params.userId); 225 | res.json(invites); 226 | } catch (error) { 227 | res.status(500).json({ message: "Failed to fetch user invites" }); 228 | } 229 | }); 230 | 231 | // Start the server 232 | httpServer.listen(port, () => { 233 | console.log(`[server]: Server is running at http://localhost:${port}`); 234 | }); 235 | -------------------------------------------------------------------------------- /backend/src/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | export default prisma; -------------------------------------------------------------------------------- /backend/src/socketio.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from "socket.io"; 2 | import * as Y from "yjs"; 3 | 4 | import prisma from "./prisma"; 5 | import { debouncedUpdateFile } from "./utils"; 6 | 7 | // Data structure for a workspace 8 | interface Workspace { 9 | docs: Map< 10 | string, 11 | { 12 | yDoc: Y.Doc; 13 | content: string; 14 | lastSaved: number; 15 | } 16 | >; 17 | clients: Set; 18 | } 19 | 20 | // Map of all workspaces 21 | const workspaces = new Map(); 22 | 23 | // Replace the single awarenessStates map with a file-specific one 24 | // Map>> 25 | const fileAwareness = new Map>>(); 26 | 27 | // Add this to track which file each client is currently viewing 28 | const clientFiles = new Map(); 29 | 30 | export const handleConnection = (io: Server) => { 31 | io.on("connection", async (socket: Socket) => { 32 | const userId = socket.handshake.auth.userId; 33 | 34 | if (!userId) { 35 | socket.emit("error", "No user ID provided"); 36 | socket.disconnect(); 37 | return; 38 | } 39 | 40 | console.log(`New connection: ${socket.id} (User: ${userId})`); 41 | 42 | socket.on("joinRoom", async ({ workspaceId, path }) => { 43 | console.log("joinRoom", workspaceId); 44 | // Check if user has permission to access this workspace 45 | if (!(await checkUserAccess(userId, workspaceId))) { 46 | socket.emit("error", "Unauthorized access"); 47 | return; 48 | } 49 | 50 | // Initialize workspace if needed 51 | if (!workspaces.has(workspaceId)) { 52 | workspaces.set(workspaceId, { 53 | docs: new Map(), 54 | clients: new Set(), 55 | }); 56 | } 57 | 58 | const workspace = workspaces.get(workspaceId)!; 59 | workspace.clients.add(socket.id); 60 | socket.join(workspaceId); 61 | 62 | // Only load requested file 63 | if (!workspace.docs.has(path)) { 64 | const file = await prisma.file.findFirst({ 65 | where: { workspaceId, path }, 66 | }); 67 | 68 | if (!file) { 69 | socket.emit("error", "File not found"); 70 | return; 71 | } 72 | 73 | const yDoc = new Y.Doc(); 74 | const ytext = yDoc.getText("content"); 75 | ytext.insert(0, file.content); 76 | 77 | workspace.docs.set(path, { 78 | yDoc, 79 | content: file.content, 80 | lastSaved: Date.now(), 81 | }); 82 | } 83 | 84 | // Send current doc state 85 | const docData = workspace.docs.get(path)!; 86 | const update = Y.encodeStateAsUpdate(docData.yDoc); 87 | socket.emit("sync", Buffer.from(update).toString("base64")); 88 | 89 | // Update client's current file 90 | clientFiles.set(socket.id, { workspaceId, path }); 91 | 92 | // Initialize file awareness if needed 93 | if (!fileAwareness.has(workspaceId)) { 94 | fileAwareness.set(workspaceId, new Map()); 95 | } 96 | const workspaceAwareness = fileAwareness.get(workspaceId)!; 97 | if (!workspaceAwareness.has(path)) { 98 | workspaceAwareness.set(path, new Map()); 99 | } 100 | 101 | // Send current awareness states for this file 102 | const fileStates = workspaceAwareness.get(path)!; 103 | socket.emit("awareness-update", { 104 | states: Array.from(fileStates.entries()), 105 | }); 106 | }); 107 | 108 | socket.on("leaveRoom", ({ workspaceId }) => { 109 | console.log("leaveRoom", workspaceId); 110 | socket.leave(workspaceId); 111 | handleLeaveRoom(socket, workspaceId, userId); 112 | }); 113 | 114 | // Handle document updates from clients 115 | socket.on("doc-update", ({ workspaceId, path, update }) => { 116 | const workspace = workspaces.get(workspaceId); 117 | if (!workspace?.docs.has(path)) return; 118 | 119 | const docData = workspace.docs.get(path)!; 120 | const binaryUpdate = Buffer.from(update, "base64"); 121 | 122 | Y.applyUpdate(docData.yDoc, binaryUpdate); 123 | const newContent = docData.yDoc.getText("content").toString(); 124 | 125 | // Only save if content actually changed 126 | if (newContent !== docData.content) { 127 | docData.content = newContent; 128 | docData.lastSaved = Date.now(); 129 | console.log("saving file", workspaceId, path, newContent); 130 | debouncedUpdateFile(workspaceId, path, newContent); 131 | } 132 | 133 | socket.to(workspaceId).emit(`doc-update-${path}`, update); 134 | }); 135 | 136 | socket.on("requestFileContent", ({ workspaceId, path }) => { 137 | const workspace = workspaces.get(workspaceId); 138 | if (!workspace) return; 139 | 140 | const docData = workspace.docs.get(path); 141 | if (!docData) return; 142 | 143 | const content = docData.yDoc.getText("content").toString(); 144 | socket.emit("fileContent", { 145 | path, 146 | content, 147 | }); 148 | }); 149 | 150 | socket.on("awareness", ({ workspaceId, path, clientId, state }) => { 151 | const clientFile = clientFiles.get(socket.id); 152 | if (!clientFile || clientFile.workspaceId !== workspaceId) return; 153 | 154 | const workspaceAwareness = fileAwareness.get(workspaceId); 155 | if (!workspaceAwareness) return; 156 | 157 | const fileStates = workspaceAwareness.get(clientFile.path); 158 | if (!fileStates) return; 159 | 160 | // Update state for this client 161 | fileStates.set(socket.id, state); 162 | 163 | // Log awareness data for debugging 164 | console.log("\n=== Awareness Update ==="); 165 | console.log("File:", clientFile.path); 166 | console.log("Connected clients:"); 167 | fileStates.forEach((state, clientId) => { 168 | console.log(`\nClient ${clientId}:`); 169 | console.log("User:", state.user?.name); 170 | console.log("Color:", state.user?.color); 171 | console.log("Cursor:", state.user?.cursor?.position); 172 | console.log("Selection:", state.user?.cursor?.selection); 173 | }); 174 | console.log("========================\n"); 175 | 176 | // Only broadcast to others viewing the same file 177 | socket.to(workspaceId).emit("awareness-update", { 178 | path, 179 | clientId, 180 | state, 181 | }); 182 | }); 183 | 184 | socket.on("disconnect", async () => { 185 | // Find workspace ID from room 186 | const workspaceId = Array.from(socket.rooms).find((room) => 187 | workspaces.has(room) 188 | ); 189 | // Delete client from workspace 190 | if (workspaceId && workspaces.has(workspaceId)) { 191 | handleLeaveRoom(socket, workspaceId, userId); 192 | } 193 | }); 194 | }); 195 | }; 196 | 197 | async function checkUserAccess( 198 | userId: string, 199 | workspaceId: string 200 | ): Promise { 201 | const workspace = await prisma.workspace.findFirst({ 202 | where: { 203 | id: workspaceId, 204 | users: { 205 | some: { 206 | id: userId, 207 | }, 208 | }, 209 | }, 210 | }); 211 | return workspace !== null; 212 | } 213 | 214 | async function handleLeaveRoom( 215 | socket: Socket, 216 | workspaceId: string, 217 | userId: string 218 | ) { 219 | const workspace = workspaces.get(workspaceId); 220 | if (!workspace) return; 221 | 222 | workspace.clients.delete(socket.id); 223 | 224 | // Notify others that user left 225 | socket.to(workspaceId).emit("user-left", { 226 | clientId: socket.id, 227 | userId, 228 | }); 229 | 230 | // Clean up awareness data 231 | const clientFile = clientFiles.get(socket.id); 232 | if (clientFile) { 233 | const workspaceAwareness = fileAwareness.get(workspaceId); 234 | if (workspaceAwareness) { 235 | const fileStates = workspaceAwareness.get(clientFile.path); 236 | if (fileStates) { 237 | fileStates.delete(socket.id); 238 | 239 | // Clean up empty maps 240 | if (fileStates.size === 0) { 241 | workspaceAwareness.delete(clientFile.path); 242 | } 243 | if (workspaceAwareness.size === 0) { 244 | fileAwareness.delete(workspaceId); 245 | } 246 | 247 | // Remove this section - don't send awareness update on leave 248 | // socket.to(workspaceId).emit("awareness-update", { 249 | // path: clientFile.path, 250 | // states: Array.from(fileStates.entries()), 251 | // }); 252 | } 253 | } 254 | clientFiles.delete(socket.id); 255 | } 256 | 257 | // Delete workspace in memory if no connected clients left 258 | if (workspace.clients.size === 0) { 259 | workspace.docs.forEach((doc, path) => { 260 | workspace.docs.delete(path); 261 | }); 262 | workspaces.delete(workspaceId); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /backend/src/utils.ts: -------------------------------------------------------------------------------- 1 | import prisma from "./prisma"; 2 | 3 | // src/utils.ts 4 | export const greet = (name: string): string => { 5 | console.log(`Hello, ${name}!`); 6 | return "hi"; 7 | }; 8 | 9 | export async function getWorkspacesForUser(userId: string) { 10 | return await prisma.workspace.findMany({ 11 | where: { 12 | users: { 13 | some: { 14 | id: userId, 15 | }, 16 | }, 17 | }, 18 | include: { 19 | users: true, 20 | files: true, 21 | invites: true, 22 | }, 23 | }); 24 | } 25 | 26 | export async function createNewWorkspace( 27 | userId: string, 28 | project: string, 29 | files: { path: string; name: string; content: string }[] 30 | ) { 31 | if (!userId) throw new Error("userId is required"); 32 | 33 | await upsertUser(userId); 34 | 35 | return await prisma.workspace.create({ 36 | data: { 37 | users: { 38 | connect: { id: userId }, // Include the existing user in the users array 39 | }, 40 | project: project, 41 | files: { 42 | create: files.map((file) => ({ 43 | path: file.path, 44 | name: file.name, 45 | content: file.content, 46 | })), 47 | }, 48 | invites: { create: [] }, 49 | }, 50 | }); 51 | } 52 | 53 | export async function deleteWorkspaceById(workspaceId: string) { 54 | return await prisma.workspace.delete({ 55 | where: { id: workspaceId }, 56 | }); 57 | } 58 | 59 | export async function createWorkspaceInvite( 60 | workspaceId: string, 61 | userId: string 62 | ) { 63 | return await prisma.invite.create({ 64 | data: { 65 | workspaceId, 66 | userId, 67 | }, 68 | }); 69 | } 70 | 71 | export async function handleInviteResponse(inviteId: string, accept: boolean) { 72 | const invite = await prisma.invite.findUnique({ 73 | where: { id: inviteId }, 74 | include: { workspace: true }, 75 | }); 76 | 77 | if (accept && invite) { 78 | await prisma.workspace.update({ 79 | where: { id: invite.workspaceId }, 80 | data: { 81 | users: { 82 | connect: { id: invite.userId }, 83 | }, 84 | }, 85 | }); 86 | } 87 | 88 | return await prisma.invite.delete({ 89 | where: { id: inviteId }, 90 | }); 91 | } 92 | 93 | export async function removeUserFromWorkspace( 94 | workspaceId: string, 95 | userId: string 96 | ) { 97 | return await prisma.workspace.update({ 98 | where: { id: workspaceId }, 99 | data: { 100 | users: { 101 | disconnect: { id: userId }, 102 | }, 103 | }, 104 | }); 105 | } 106 | 107 | export async function updateFileContent( 108 | workspaceId: string, 109 | path: string, 110 | content: string 111 | ) { 112 | return await prisma.file.update({ 113 | where: { 114 | workspaceId_path: { 115 | workspaceId, 116 | path, 117 | }, 118 | }, 119 | data: { 120 | content, 121 | }, 122 | }); 123 | } 124 | 125 | function debounce any>( 126 | func: T, 127 | wait: number 128 | ): (...args: Parameters) => void { 129 | let timeout: NodeJS.Timeout; 130 | 131 | return (...args: Parameters) => { 132 | clearTimeout(timeout); 133 | timeout = setTimeout(() => func(...args), wait); 134 | }; 135 | } 136 | 137 | export const debouncedUpdateFile = debounce( 138 | async (workspaceId: string, path: string, content: string) => { 139 | try { 140 | await updateFileContent(workspaceId, path, content); 141 | } catch (error) { 142 | console.error("Failed to update file in DB:", error); 143 | } 144 | }, 145 | 500 146 | ); 147 | 148 | export async function upsertUser(userId: string) { 149 | if (!userId) throw new Error("userId is required"); 150 | 151 | return await prisma.user.upsert({ 152 | where: { id: userId }, 153 | create: { id: userId }, 154 | update: {}, 155 | }); 156 | } 157 | 158 | export async function getWorkspaceUsers( 159 | workspaceId: string, 160 | currentUserId: string 161 | ) { 162 | const workspace = await prisma.workspace.findUnique({ 163 | where: { id: workspaceId }, 164 | include: { users: true }, 165 | }); 166 | return workspace?.users.filter((user) => user.id !== currentUserId) || []; 167 | } 168 | 169 | export async function getWorkspaceInvites(workspaceId: string) { 170 | return await prisma.invite.findMany({ 171 | where: { workspaceId }, 172 | include: { user: true }, 173 | }); 174 | } 175 | 176 | export async function getInvitesForUser(userId: string) { 177 | return await prisma.invite.findMany({ 178 | where: { userId }, 179 | include: { 180 | workspace: { 181 | include: { 182 | users: true, 183 | }, 184 | }, 185 | }, 186 | }); 187 | } 188 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | "baseUrl": "./", 32 | "paths": { //add alias paths here 33 | "@utils/*": ["src/utils/*"], 34 | "@services/*": ["src/services/*"] 35 | }, /* Specify a set of entries that re-map imports to additional lookup locations. */ 36 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 37 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 38 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 39 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 40 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 41 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 42 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 43 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 44 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 45 | // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ 46 | // "resolveJsonModule": true, /* Enable importing .json files. */ 47 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 48 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 49 | 50 | /* JavaScript Support */ 51 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 52 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 53 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 54 | 55 | /* Emit */ 56 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 57 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 58 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 59 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "noEmit": true, /* Disable emitting files from a compilation. */ 62 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 63 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 64 | // "removeComments": true, /* Disable emitting comments. */ 65 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 66 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 67 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 68 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 69 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 70 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 71 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 72 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 73 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 74 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 75 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 76 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 77 | 78 | /* Interop Constraints */ 79 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 80 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 81 | // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ 82 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 83 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 84 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 85 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 86 | 87 | /* Type Checking */ 88 | "strict": true, /* Enable all strict type-checking options. */ 89 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 90 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 91 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 92 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 93 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 94 | // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ 95 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 96 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 97 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 98 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 99 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 100 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 101 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 102 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 103 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 104 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 105 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 106 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 107 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 108 | 109 | /* Completeness */ 110 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 111 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /frontend/.env.template: -------------------------------------------------------------------------------- 1 | 2 | NEXT_PUBLIC_SERVER_URL=http://localhost:4000 -------------------------------------------------------------------------------- /frontend/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ishaan1013/axiom/d10a235d4c068580cec02f12b08fb2b4c0217512/frontend/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /frontend/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ishaan1013/axiom/d10a235d4c068580cec02f12b08fb2b4c0217512/frontend/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body, 6 | :root { 7 | height: 100%; 8 | overflow: hidden; 9 | overscroll-behavior: none; 10 | } 11 | 12 | @layer utilities { 13 | .text-balance { 14 | text-wrap: balance; 15 | } 16 | } 17 | 18 | @layer base { 19 | :root { 20 | --background: 0 0% 100%; 21 | --foreground: 0 0% 3.9%; 22 | --card: 0 0% 100%; 23 | --card-foreground: 0 0% 3.9%; 24 | --popover: 0 0% 100%; 25 | --popover-foreground: 0 0% 3.9%; 26 | --primary: 0 0% 9%; 27 | --primary-foreground: 0 0% 98%; 28 | --secondary: 0 0% 96.1%; 29 | --secondary-foreground: 0 0% 9%; 30 | --muted: 0 0% 96.1%; 31 | --muted-foreground: 0 0% 45.1%; 32 | --accent: 0 0% 96.1%; 33 | --accent-foreground: 0 0% 9%; 34 | --destructive: 0 84.2% 60.2%; 35 | --destructive-foreground: 0 0% 98%; 36 | --border: 0 0% 89.8%; 37 | --input: 0 0% 89.8%; 38 | --ring: 0 0% 3.9%; 39 | --chart-1: 12 76% 61%; 40 | --chart-2: 173 58% 39%; 41 | --chart-3: 197 37% 24%; 42 | --chart-4: 43 74% 66%; 43 | --chart-5: 27 87% 67%; 44 | --radius: 0.5rem; 45 | } 46 | .dark { 47 | --background: 0 0% 3.9%; 48 | --foreground: 0 0% 98%; 49 | --card: 0 0% 3.9%; 50 | --card-foreground: 0 0% 98%; 51 | --popover: 0 0% 3.9%; 52 | --popover-foreground: 0 0% 98%; 53 | --primary: 0 0% 98%; 54 | --primary-foreground: 0 0% 9%; 55 | --secondary: 0 0% 14.9%; 56 | --secondary-foreground: 0 0% 98%; 57 | --muted: 0 0% 14.9%; 58 | --muted-foreground: 0 0% 63.9%; 59 | --accent: 0 0% 14.9%; 60 | --accent-foreground: 0 0% 98%; 61 | --destructive: 0 62.8% 30.6%; 62 | --destructive-foreground: 0 0% 98%; 63 | --border: 0 0% 14.9%; 64 | --input: 0 0% 14.9%; 65 | --ring: 0 0% 83.1%; 66 | --chart-1: 220 70% 50%; 67 | --chart-2: 160 60% 45%; 68 | --chart-3: 30 80% 55%; 69 | --chart-4: 280 65% 60%; 70 | --chart-5: 340 75% 55%; 71 | --tabs-bg: 0 0% 7.45%; 72 | } 73 | } 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | body { 80 | @apply bg-background text-foreground; 81 | } 82 | } 83 | 84 | .chrome-picker input { 85 | background-color: white !important; /* Ensure white background in light mode */ 86 | color: black !important; /* Text color */ 87 | border: 1px solid #ccc; /* Optional border */ 88 | } 89 | 90 | .readonly-editor .cursors-layer > .cursor { 91 | display: none !important; 92 | } -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | import Providers from "@/components/providers"; 5 | 6 | const geistSans = localFont({ 7 | src: "./fonts/GeistVF.woff", 8 | variable: "--font-geist-sans", 9 | weight: "100 200 300 400 500 600 700 800 900", 10 | }); 11 | const geistMono = localFont({ 12 | src: "./fonts/GeistMonoVF.woff", 13 | variable: "--font-geist-mono", 14 | weight: "100 200 300 400 500 600 700 800 900", 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: "Axiom", 19 | description: "SE212 Interface", 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 | 32 | {children} 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | import LocalBlock from "@/components/local-block"; 2 | import { getFiles } from "@/lib/actions"; 3 | import { FilesResponse } from "@/lib/types"; 4 | import dynamic from "next/dynamic"; 5 | 6 | // const EditorDynamic = dynamic(() => import("@/components/editor"), { 7 | // ssr: false, 8 | // }); 9 | 10 | export default async function Home() { 11 | const files: FilesResponse = await getFiles(); 12 | 13 | const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL; 14 | if (!serverUrl) return null; 15 | 16 | return ( 17 | <> 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /frontend/components/editor/access.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogHeader, 5 | DialogTitle, 6 | } from "@/components/ui/dialog"; 7 | import { MailX, Plus, Send, User, X, Loader2 } from "lucide-react"; 8 | import { Button } from "../ui/button"; 9 | import { Input } from "../ui/input"; 10 | import { 11 | useRemoveCollaborator, 12 | useDeleteInvite, 13 | useCollaborators, 14 | useWorkspaceInvites, 15 | useCreateInvite, 16 | } from "@/lib/query"; 17 | import { useState } from "react"; 18 | 19 | export default function ManageAccessModal({ 20 | open, 21 | setOpen, 22 | workspaceId, 23 | userId, 24 | }: { 25 | open: boolean; 26 | setOpen: (open: boolean) => void; 27 | workspaceId: string | null; 28 | userId: string; 29 | }) { 30 | const [inviteUsername, setInviteUsername] = useState(""); 31 | const createInvite = useCreateInvite(); 32 | 33 | const { data: collaborators = [], isLoading: loadingCollaborators } = 34 | useCollaborators(workspaceId, userId); 35 | 36 | const { data: invites = [], isLoading: loadingInvites } = 37 | useWorkspaceInvites(workspaceId); 38 | 39 | const removeCollaborator = useRemoveCollaborator(); 40 | const deleteInvite = useDeleteInvite(); 41 | 42 | const handleRemoveCollaborator = (collaboratorId: string) => { 43 | if (!workspaceId) return; 44 | removeCollaborator.mutate({ 45 | userId: collaboratorId, 46 | workspaceId, 47 | }); 48 | }; 49 | 50 | const handleRevokeInvite = (inviteId: string) => { 51 | if (!workspaceId) return; 52 | deleteInvite.mutate({ 53 | inviteId, 54 | workspaceId, 55 | }); 56 | }; 57 | 58 | const handleInvite = () => { 59 | if (!workspaceId || !inviteUsername) return; 60 | createInvite.mutate({ 61 | userId: inviteUsername, 62 | workspaceId, 63 | }); 64 | setInviteUsername(""); // Clear input after sending 65 | }; 66 | 67 | if (!workspaceId) return null; 68 | 69 | return ( 70 | 71 | 72 |
73 | 74 | Manage Workspace Access 75 | 76 |
77 | setInviteUsername(e.target.value)} 81 | /> 82 | 95 |
96 |
97 |
98 |
Collaborators
99 |
100 | {loadingCollaborators ? ( 101 |
102 | 103 | Loading... 104 |
105 | ) : collaborators.length === 0 ? ( 106 |
107 | No collaborators yet. 108 |
109 | ) : ( 110 | collaborators.map((collaborator) => ( 111 |
115 |
116 | 117 | {collaborator.id} 118 |
119 | 129 |
130 | )) 131 | )} 132 |
133 |
134 |
135 |
Pending Invites
136 |
137 | {loadingInvites ? ( 138 |
139 | 140 | Loading... 141 |
142 | ) : invites.length === 0 ? ( 143 |
144 | No pending invites. 145 |
146 | ) : ( 147 | invites.map((invite) => ( 148 |
152 |
153 | 154 | {invite.user.id} 155 |
156 | 166 |
167 | )) 168 | )} 169 |
170 |
171 |
172 |
173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /frontend/components/editor/colorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { HexColorPicker } from "react-colorful"; 3 | import { useColorTheme } from "@/components/providers/color-context"; 4 | import "@/styles/color.css"; 5 | 6 | import { 7 | Popover, 8 | PopoverContent, 9 | PopoverTrigger, 10 | } from "@/components/ui/popover"; 11 | import { Input } from "../ui/input"; 12 | 13 | export default function ColorPicker({ 14 | token, 15 | defaultColor, 16 | }: { 17 | token: string; 18 | defaultColor: string; 19 | }) { 20 | const colorTheme = useColorTheme(); 21 | const updateColor = colorTheme ? colorTheme.updateColor : () => {}; 22 | const [color, setColor] = useState(defaultColor); 23 | 24 | useEffect(() => { 25 | setColor(defaultColor); 26 | }, [defaultColor]); 27 | 28 | const handleChange = (color: string) => { 29 | if (color !== defaultColor) { 30 | setColor(color); 31 | updateColor(token, color); 32 | } 33 | }; 34 | 35 | const handleInputChange = (e: React.ChangeEvent) => { 36 | const newColor = e.target.value; 37 | if (/^#[0-9A-Fa-f]{6}$/.test(newColor)) { 38 | handleChange(newColor); 39 | } else { 40 | setColor(newColor); 41 | } 42 | }; 43 | 44 | return ( 45 | <> 46 | 47 | 51 | 52 |
53 | 54 |
55 |
56 |
HEX
57 | 64 |
65 |
66 |
67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /frontend/components/editor/colorPreview.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useColorTheme } from "@/components/providers/color-context"; 3 | import { useTheme } from "next-themes"; 4 | import { Editor, BeforeMount } from "@monaco-editor/react"; 5 | import { registerGeorge } from "@/lib/lang"; 6 | 7 | const PREVIEW_CODE = `#check TP 8 | a & !a <-> false 9 | 1) a & !a 10 | 2) false by contr // Comments`; 11 | 12 | export default function ColorPreview() { 13 | const { theme, resolvedTheme } = useTheme(); 14 | const colorThemeContext = useColorTheme(); 15 | const colorTheme = 16 | resolvedTheme === "dark" 17 | ? colorThemeContext?.darkTheme 18 | : colorThemeContext?.lightTheme; 19 | 20 | const handleEditorWillMount: BeforeMount = (monaco) => { 21 | monaco.languages.register({ id: "george" }); 22 | monaco.languages.setMonarchTokensProvider("george", { 23 | tokenizer: { 24 | root: [ 25 | // Comments - must be before other rules 26 | [/\/\/.*$/, "comment"], 27 | 28 | // Entire lines starting with # should be colored 29 | [ 30 | /^.*#(?:check\s+(?:PROP|ND|PC|Z|TP|ST|PREDTYPES|PRED|NONE)|[qua][ \t].*$)/, 31 | "constant.other", 32 | ], 33 | 34 | // Line numbers - must be before regular numbers 35 | [/^\s*(?:\d+|bc|ih)\)/, "variable.language"], 36 | 37 | // Numbers in references (after 'on') should be colored like line numbers 38 | [ 39 | /\bon\s+((?:\d+|bc|ih)(?:\s*[,-]\s*(?:\d+|bc|ih))*)/g, 40 | "variable.language", 41 | ], 42 | 43 | // All other numbers in expressions should be white 44 | [/(??!*]|diff|prod/, "constant.numeric"], 69 | 70 | // Identifiers with numbers 71 | [/[a-zA-Z_]\w*/, "identifier"], 72 | 73 | // Standalone numbers 74 | [/\b\d+\b/, "variable.language"], 75 | ], 76 | }, 77 | }); 78 | }; 79 | 80 | return ( 81 |
82 | 103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /frontend/components/editor/confirm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogHeader, 6 | DialogTitle, 7 | } from "@/components/ui/dialog"; 8 | import { Button } from "../ui/button"; 9 | 10 | export default function ConfirmModal({ 11 | open, 12 | setOpen, 13 | title, 14 | description, 15 | onConfirm, 16 | }: { 17 | open: boolean; 18 | setOpen: (open: boolean) => void; 19 | title: string; 20 | description: string; 21 | onConfirm: () => void; 22 | }) { 23 | return ( 24 | 25 | 26 | 27 | {title} 28 | {description} 29 | 30 |
31 | 34 | 43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/components/editor/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 2 | import { Keyboard, FileCode2, Users, Palette, HelpCircle } from "lucide-react"; 3 | import { useEffect, useState } from "react"; 4 | import { 5 | Select, 6 | SelectContent, 7 | SelectItem, 8 | SelectTrigger, 9 | SelectValue, 10 | } from "@/components/ui/select"; 11 | import { Button } from "../ui/button"; 12 | import { Switch } from "@/components/ui/switch"; 13 | import { 14 | Tooltip, 15 | TooltipContent, 16 | TooltipProvider, 17 | TooltipTrigger, 18 | } from "@/components/ui/tooltip"; 19 | import { useUserInvites, useRespondToInvite, useWorkspaces } from "@/lib/query"; 20 | import { toast } from "sonner"; 21 | import { InviteWithWorkspace } from "@/lib/types"; 22 | import { RotateCw } from "lucide-react"; 23 | import { TooltipButton } from "@/components/ui/tooltip-button"; 24 | import { useTheme } from "next-themes"; 25 | 26 | // import { darkTheme, lightTheme } from "@/lib/colors"; 27 | import ColorPicker from "./colorPicker"; 28 | 29 | import { useColorTheme } from "@/components/providers/color-context"; 30 | import ColorPreview from "./colorPreview"; 31 | 32 | type Category = "editor" | "shortcuts" | "invites" | "colours"; 33 | 34 | const categories = [ 35 | { id: "editor" as const, label: "Code Editor", icon: FileCode2 }, 36 | { id: "shortcuts" as const, label: "Shortcuts", icon: Keyboard }, 37 | { id: "invites" as const, label: "Invites", icon: Users }, 38 | { id: "colours" as const, label: "Colours", icon: Palette }, 39 | ]; 40 | 41 | const shortcuts = [ 42 | { label: "Ask George", shortcut: "⌘G" }, 43 | { label: "Toggle explorer panel", shortcut: "⌘B" }, 44 | { label: "Toggle output panel", shortcut: "⌘J" }, 45 | { label: "Open settings menu", shortcut: "⌘K" }, 46 | { label: "Upload into current file", shortcut: "⌘U" }, 47 | { label: "Delete & decrement lines", shortcut: "⌘X" }, 48 | ]; 49 | 50 | export default function SettingsModal({ 51 | open, 52 | setOpen, 53 | userId, 54 | autoComplete, 55 | setAutoComplete, 56 | acceptSuggestionOnEnter, 57 | setAcceptSuggestionOnEnter, 58 | isConnected, 59 | }: { 60 | open: boolean; 61 | setOpen: (open: boolean) => void; 62 | userId: string; 63 | autoComplete: boolean; 64 | setAutoComplete: (autoComplete: boolean) => void; 65 | acceptSuggestionOnEnter: boolean; 66 | setAcceptSuggestionOnEnter: (accept: boolean) => void; 67 | isConnected: boolean; 68 | }) { 69 | const [activeCategory, setActiveCategory] = useState("editor"); 70 | 71 | useEffect(() => { 72 | if (!open) setActiveCategory("editor"); 73 | }, [open]); 74 | 75 | const { data: workspaces } = useWorkspaces(userId); 76 | const { 77 | data: invites, 78 | refetch: refetchInvites, 79 | isLoading: invitesLoading, 80 | } = useUserInvites(userId); 81 | const respondToInvite = useRespondToInvite(); 82 | const { theme, setTheme, resolvedTheme } = useTheme(); 83 | 84 | const colorTheme = useColorTheme(); 85 | const darkThemeColors = colorTheme?.darkTheme.rules; 86 | const lightThemeColors = colorTheme?.lightTheme.rules; 87 | 88 | const [themeColors, setThemeColors] = useState( 89 | resolvedTheme === "dark" ? darkThemeColors : lightThemeColors 90 | ); 91 | 92 | const colorNames = [ 93 | "Comments", 94 | "George Commands", 95 | "Keywords", 96 | "Rules", 97 | "Operators", 98 | "Literals", 99 | "Line Numbers", 100 | ]; 101 | 102 | useEffect(() => { 103 | setThemeColors( 104 | resolvedTheme === "dark" ? darkThemeColors : lightThemeColors 105 | ); 106 | }, [colorTheme, resolvedTheme]); 107 | 108 | return ( 109 | 110 | 111 |
112 |
113 |
114 | {categories.map((category) => ( 115 | 129 | ))} 130 |
131 |
132 |
133 | {isConnected ? ( 134 | <> 135 |
136 |
137 | 138 | ) : ( 139 |
140 | )} 141 |
142 | {isConnected ? "Connected to server" : "Disconnected"} 143 |
144 |
145 |
146 |
147 |
148 | {categories.find((c) => c.id === activeCategory)?.label} 149 |
150 | {activeCategory === "invites" && ( 151 | refetchInvites()} 157 | > 158 | 159 | 160 | )} 161 | {activeCategory === "colours" && ( 162 | { 167 | colorTheme?.resetToDefault(); 168 | }} 169 | > 170 | 171 | 172 | )} 173 |
174 |
175 | {activeCategory === "editor" ? ( 176 |
177 |
178 | 179 | 195 |
196 |
197 |
198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | Toggles code completion language suggestions. 206 | 207 | 208 | 209 |
210 | { 213 | setAutoComplete(checked); 214 | localStorage.setItem("autoComplete", String(checked)); 215 | }} 216 | /> 217 |
218 |
219 |
220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | Controls whether suggestions should be accepted on 228 | Enter, in addition to Tab. 229 |
230 | Helps to avoid ambiguity between inserting new lines 231 | or accepting suggestions. 232 |
233 |
234 |
235 |
236 | { 239 | setAcceptSuggestionOnEnter(checked); 240 | localStorage.setItem( 241 | "acceptSuggestionOnEnter", 242 | String(checked) 243 | ); 244 | }} 245 | /> 246 |
247 |
248 | ) : activeCategory === "shortcuts" ? ( 249 |
250 | {shortcuts.map((item) => ( 251 |
255 | {item.label} 256 | 257 | {item.shortcut} 258 | 259 |
260 | ))} 261 |
262 | ) : activeCategory === "invites" ? ( 263 |
264 | {!invites ? ( 265 |
266 | 267 | Loading... 268 |
269 | ) : invites.length === 0 ? ( 270 |
271 | No pending invites. 272 |
273 | ) : ( 274 | invites.map((invite: InviteWithWorkspace) => { 275 | const hasConflict = workspaces?.workspaces?.some( 276 | (w) => 277 | w.project === invite.workspace.project && 278 | !w.invites.some((i) => i.id === invite.id) 279 | ); 280 | 281 | return ( 282 |
286 |
287 |
288 |
289 | {invite.workspace.project} 290 |
291 |
292 | {invite.workspace.users 293 | .map((u) => u.id) 294 | .join(", ")} 295 |
296 |
297 |
298 | 316 | 329 |
330 |
331 |
332 | ); 333 | }) 334 | )} 335 |
336 | ) : activeCategory === "colours" ? ( 337 | <> 338 |
339 | {themeColors?.map((item, index) => ( 340 |
344 |
{colorNames[index]}
345 | 349 |
350 | ))} 351 |
352 | 353 | 354 | ) : null} 355 |
356 |
357 |
358 | 359 |
360 | ); 361 | } 362 | -------------------------------------------------------------------------------- /frontend/components/editor/tabs.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { X } from "lucide-react"; 3 | import { Tab } from "@/lib/types"; 4 | import { useState } from "react"; 5 | 6 | interface TabsProps { 7 | tabs: Tab[]; 8 | activeTabIndex: number; 9 | onTabClick: (index: number) => void; 10 | onTabClose: (index: number) => void; 11 | } 12 | 13 | export default function Tabs({ 14 | tabs, 15 | activeTabIndex, 16 | onTabClick, 17 | onTabClose, 18 | }: TabsProps) { 19 | return ( 20 |
21 | {tabs.map((tab, index) => ( 22 | 30 | ))} 31 |
32 |
33 | ); 34 | } 35 | 36 | function TabComponent({ 37 | tab, 38 | index, 39 | activeTabIndex, 40 | onTabClick, 41 | onTabClose, 42 | }: { 43 | tab: Tab; 44 | index: number; 45 | activeTabIndex: number; 46 | onTabClick: (index: number) => void; 47 | onTabClose: (index: number) => void; 48 | }) { 49 | const [isHovered, setIsHovered] = useState(false); 50 | 51 | return ( 52 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /frontend/components/editor/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { TooltipButton } from "@/components/ui/tooltip-button"; 2 | import { PanelBottom, PanelLeft, Settings } from "lucide-react"; 3 | 4 | interface ToolbarProps { 5 | loading: boolean; 6 | activeTabIndex: number; 7 | handleAskGeorge: () => void; 8 | toggleExplorer: () => void; 9 | toggleOutput: () => void; 10 | setIsSettingsOpen: (open: boolean) => void; 11 | } 12 | 13 | export default function Toolbar({ 14 | loading, 15 | activeTabIndex, 16 | handleAskGeorge, 17 | toggleExplorer, 18 | toggleOutput, 19 | setIsSettingsOpen, 20 | }: ToolbarProps) { 21 | return ( 22 |
23 |
24 |
Axiom
25 | 32 | {loading ? "Asking George..." : "Ask George"} 33 | 34 |
35 |
36 | 42 | 43 | 44 | 50 | 51 | 52 | setIsSettingsOpen(true)} 57 | > 58 | 59 | 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /frontend/components/editor/upload.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog"; 11 | import { FileUploader } from "@/components/ui/file-uploader"; 12 | import { useState } from "react"; 13 | 14 | export function UploadModal({ 15 | open, 16 | setOpen, 17 | handleUpload, 18 | }: { 19 | open: boolean; 20 | setOpen: (open: boolean) => void; 21 | handleUpload: (files: File[]) => Promise; 22 | }) { 23 | // const [files, setFiles] = useState([]); 24 | 25 | return ( 26 | 27 | 28 | 29 | Upload 30 | 31 | Warning: This will replace the current file contents. 32 | 33 | 34 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /frontend/components/local-block/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FilesResponse } from "@/lib/types"; 4 | import dynamic from "next/dynamic"; 5 | import { useEffect, useState } from "react"; 6 | 7 | const EditorDynamic = dynamic(() => import("@/components/editor"), { 8 | ssr: false, 9 | }); 10 | 11 | export default function LocalBlock({ files }: { files: FilesResponse }) { 12 | const [userId, setUserId] = useState(null); 13 | 14 | useEffect(() => { 15 | const watiam = localStorage.getItem("watiam"); 16 | if (!watiam) { 17 | throw new Error("WatIAM is required to use this application"); 18 | } 19 | setUserId(watiam); 20 | }, []); 21 | 22 | if (!userId) return null; 23 | 24 | return ( 25 | <> 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/components/providers/color-context.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect } from "react"; 2 | import { useTheme } from "next-themes"; 3 | import * as monaco from "monaco-editor"; 4 | import type { editor } from "monaco-editor"; 5 | 6 | // import { darkThemeOld, lightThemeOld } from "@/lib/colors"; 7 | 8 | const ColorThemeContext = createContext(null); 9 | 10 | import { ReactNode } from "react"; 11 | 12 | interface ColorThemeContextType { 13 | updateColor: (token: string, colorHex: string) => void; 14 | resetToDefault: () => void; 15 | darkTheme: editor.IStandaloneThemeData; 16 | lightTheme: editor.IStandaloneThemeData; 17 | } 18 | 19 | interface ColorThemeProviderProps { 20 | children: ReactNode; 21 | } 22 | 23 | export const ColorThemeProvider = ({ children }: ColorThemeProviderProps) => { 24 | const { theme } = useTheme(); 25 | 26 | // Load saved themes from local storage or use default themes 27 | const getInitialDarkTheme = () => { 28 | const savedDarkTheme = localStorage.getItem("darkThemeColors"); 29 | return savedDarkTheme 30 | ? JSON.parse(savedDarkTheme) 31 | : { 32 | rules: [ 33 | { token: "comment", foreground: "666666" }, 34 | { token: "constant.other", foreground: "569CD6" }, 35 | { token: "keyword", foreground: "aeaeeb" }, 36 | { token: "constant.language", foreground: "D99FF1" }, 37 | { token: "constant.numeric", foreground: "aeaeeb" }, 38 | { token: "string", foreground: "9AEFEA" }, 39 | { token: "variable.language", foreground: "9dcafa" }, 40 | ], 41 | }; 42 | }; 43 | 44 | const getInitialLightTheme = () => { 45 | const savedLightTheme = localStorage.getItem("lightThemeColors"); 46 | return savedLightTheme 47 | ? JSON.parse(savedLightTheme) 48 | : { 49 | rules: [ 50 | { token: "comment", foreground: "999999" }, 51 | { token: "constant.other", foreground: "267abf" }, 52 | { token: "keyword", foreground: "6f3fd9" }, 53 | { token: "constant.language", foreground: "b050d9" }, 54 | { token: "constant.numeric", foreground: "6f3fd9" }, 55 | { token: "string", foreground: "04b07c" }, 56 | { token: "variable.language", foreground: "0263cc" }, 57 | ], 58 | }; 59 | }; 60 | 61 | const [darkThemeColors, setDarkThemeColors] = useState(getInitialDarkTheme); 62 | const [lightThemeColors, setLightThemeColors] = 63 | useState(getInitialLightTheme); 64 | 65 | const darkTheme: editor.IStandaloneThemeData = { 66 | base: "vs-dark", 67 | inherit: true, 68 | rules: darkThemeColors.rules, 69 | colors: { 70 | "editor.background": "#0A0A0A", 71 | }, 72 | }; 73 | 74 | const lightTheme: editor.IStandaloneThemeData = { 75 | base: "vs", 76 | inherit: true, 77 | rules: lightThemeColors.rules, 78 | colors: { 79 | "editor.background": "#FFFFFF", 80 | }, 81 | }; 82 | 83 | // Function to update colors for a specific token 84 | const updateColor = (token: string, colorHex: string) => { 85 | if (theme === "dark") { 86 | setDarkThemeColors((prev: any) => { 87 | const updatedTheme = { 88 | ...prev, 89 | rules: prev.rules.map((rule: any) => 90 | rule.token === token 91 | ? { ...rule, foreground: colorHex.slice(1) } 92 | : rule 93 | ), 94 | }; 95 | localStorage.setItem( 96 | "darkThemeColors", 97 | JSON.stringify(updatedTheme) 98 | ); 99 | return updatedTheme; 100 | }); 101 | } else { 102 | setLightThemeColors((prev: any) => { 103 | const updatedTheme = { 104 | ...prev, 105 | rules: prev.rules.map((rule: any) => 106 | rule.token === token 107 | ? { ...rule, foreground: colorHex.slice(1) } 108 | : rule 109 | ), 110 | }; 111 | localStorage.setItem( 112 | "lightThemeColors", 113 | JSON.stringify(updatedTheme) 114 | ); 115 | return updatedTheme; 116 | }); 117 | } 118 | }; 119 | 120 | const resetToDefault = () => { 121 | if (theme === "dark") { 122 | const defaultDarkTheme = { 123 | rules: [ 124 | { token: "comment", foreground: "666666" }, 125 | { token: "constant.other", foreground: "569CD6" }, 126 | { token: "keyword", foreground: "aeaeeb" }, 127 | { token: "constant.language", foreground: "D99FF1" }, 128 | { token: "constant.numeric", foreground: "aeaeeb" }, 129 | { token: "string", foreground: "9AEFEA" }, 130 | { token: "variable.language", foreground: "9dcafa" }, 131 | ], 132 | }; 133 | setDarkThemeColors(defaultDarkTheme); 134 | localStorage.setItem( 135 | "darkThemeColors", 136 | JSON.stringify(defaultDarkTheme) 137 | ); 138 | } else { 139 | const defaultLightTheme = { 140 | rules: [ 141 | { token: "comment", foreground: "999999" }, 142 | { token: "constant.other", foreground: "267abf" }, 143 | { token: "keyword", foreground: "6f3fd9" }, 144 | { token: "constant.language", foreground: "b050d9" }, 145 | { token: "constant.numeric", foreground: "6f3fd9" }, 146 | { token: "string", foreground: "04b07c" }, 147 | { token: "variable.language", foreground: "0263cc" }, 148 | ], 149 | }; 150 | setLightThemeColors(defaultLightTheme); 151 | localStorage.setItem( 152 | "lightThemeColors", 153 | JSON.stringify(defaultLightTheme) 154 | ); 155 | } 156 | }; 157 | 158 | // Update the Monaco theme whenever the colors change 159 | useEffect(() => { 160 | monaco.editor.defineTheme("dark", darkTheme); 161 | monaco.editor.defineTheme("light", lightTheme); 162 | }, [darkThemeColors, lightThemeColors]); 163 | 164 | return ( 165 | 168 | {children} 169 | 170 | ); 171 | }; 172 | 173 | export const useColorTheme = () => useContext(ColorThemeContext); 174 | -------------------------------------------------------------------------------- /frontend/components/providers/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { ThemeProvider } from "@/components/providers/theme-provider"; 5 | import dynamic from "next/dynamic"; 6 | import { Toaster } from "@/components/ui/sonner"; 7 | 8 | const ColorThemeProvider = dynamic( 9 | () => import("./color-context").then((mod) => mod.ColorThemeProvider), 10 | { 11 | ssr: false, 12 | } 13 | ); 14 | 15 | const queryClient = new QueryClient(); 16 | 17 | export default function Providers({ children }: { children: React.ReactNode }) { 18 | return ( 19 | 20 | 26 | 27 | {children} 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/components/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent text-accent-foreground bg-transparent shadow-none", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2", 26 | sm: "h-8 rounded-md px-3 text-xs", 27 | lg: "h-10 rounded-md px-8", 28 | icon: "h-9 w-9", 29 | smIcon: "h-8 w-8", 30 | xsIcon: "h-6 w-6 px-0", 31 | }, 32 | }, 33 | defaultVariants: { 34 | variant: "default", 35 | size: "default", 36 | }, 37 | } 38 | ); 39 | 40 | export interface ButtonProps 41 | extends React.ButtonHTMLAttributes, 42 | VariantProps { 43 | asChild?: boolean; 44 | } 45 | 46 | const Button = React.forwardRef( 47 | ({ className, variant, size, asChild = false, ...props }, ref) => { 48 | const Comp = asChild ? Slot : "button"; 49 | return ( 50 | 55 | ); 56 | } 57 | ); 58 | Button.displayName = "Button"; 59 | 60 | export { Button, buttonVariants }; 61 | -------------------------------------------------------------------------------- /frontend/components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; 5 | import { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | const ContextMenu = ContextMenuPrimitive.Root; 14 | 15 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger; 16 | 17 | const ContextMenuGroup = ContextMenuPrimitive.Group; 18 | 19 | const ContextMenuPortal = ContextMenuPrimitive.Portal; 20 | 21 | const ContextMenuSub = ContextMenuPrimitive.Sub; 22 | 23 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; 24 | 25 | const ContextMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean; 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )); 44 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; 45 | 46 | const ContextMenuSubContent = React.forwardRef< 47 | React.ElementRef, 48 | React.ComponentPropsWithoutRef 49 | >(({ className, ...props }, ref) => ( 50 | 58 | )); 59 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; 60 | 61 | const ContextMenuContent = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, ...props }, ref) => ( 65 | 66 | 74 | 75 | )); 76 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; 77 | 78 | const ContextMenuItem = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef & { 81 | inset?: boolean; 82 | } 83 | >(({ className, inset, ...props }, ref) => ( 84 | 93 | )); 94 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; 95 | 96 | const ContextMenuCheckboxItem = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, children, checked, ...props }, ref) => ( 100 | 109 | 110 | 111 | 112 | 113 | 114 | {children} 115 | 116 | )); 117 | ContextMenuCheckboxItem.displayName = 118 | ContextMenuPrimitive.CheckboxItem.displayName; 119 | 120 | const ContextMenuRadioItem = React.forwardRef< 121 | React.ElementRef, 122 | React.ComponentPropsWithoutRef 123 | >(({ className, children, ...props }, ref) => ( 124 | 132 | 133 | 134 | 135 | 136 | 137 | {children} 138 | 139 | )); 140 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; 141 | 142 | const ContextMenuLabel = React.forwardRef< 143 | React.ElementRef, 144 | React.ComponentPropsWithoutRef & { 145 | inset?: boolean; 146 | } 147 | >(({ className, inset, ...props }, ref) => ( 148 | 157 | )); 158 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; 159 | 160 | const ContextMenuSeparator = React.forwardRef< 161 | React.ElementRef, 162 | React.ComponentPropsWithoutRef 163 | >(({ className, ...props }, ref) => ( 164 | 169 | )); 170 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; 171 | 172 | const ContextMenuShortcut = ({ 173 | className, 174 | ...props 175 | }: React.HTMLAttributes) => { 176 | return ( 177 | 184 | ); 185 | }; 186 | ContextMenuShortcut.displayName = "ContextMenuShortcut"; 187 | 188 | export { 189 | ContextMenu, 190 | ContextMenuTrigger, 191 | ContextMenuContent, 192 | ContextMenuItem, 193 | ContextMenuCheckboxItem, 194 | ContextMenuRadioItem, 195 | ContextMenuLabel, 196 | ContextMenuSeparator, 197 | ContextMenuShortcut, 198 | ContextMenuGroup, 199 | ContextMenuPortal, 200 | ContextMenuSub, 201 | ContextMenuSubContent, 202 | ContextMenuSubTrigger, 203 | ContextMenuRadioGroup, 204 | }; 205 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 5 | import { Cross2Icon } from "@radix-ui/react-icons"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Dialog = DialogPrimitive.Root; 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger; 12 | 13 | const DialogPortal = DialogPrimitive.Portal; 14 | 15 | const DialogClose = DialogPrimitive.Close; 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )); 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )); 54 | DialogContent.displayName = DialogPrimitive.Content.displayName; 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ); 68 | DialogHeader.displayName = "DialogHeader"; 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ); 82 | DialogFooter.displayName = "DialogFooter"; 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 93 | )); 94 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 95 | 96 | const DialogDescription = React.forwardRef< 97 | React.ElementRef, 98 | React.ComponentPropsWithoutRef 99 | >(({ className, ...props }, ref) => ( 100 | 105 | )); 106 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 107 | 108 | export { 109 | Dialog, 110 | DialogPortal, 111 | DialogOverlay, 112 | DialogTrigger, 113 | DialogClose, 114 | DialogContent, 115 | DialogHeader, 116 | DialogFooter, 117 | DialogTitle, 118 | DialogDescription, 119 | }; 120 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 5 | import { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | const DropdownMenu = DropdownMenuPrimitive.Root; 14 | 15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 16 | 17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 18 | 19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 20 | 21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 22 | 23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 24 | 25 | const DropdownMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean; 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )); 44 | DropdownMenuSubTrigger.displayName = 45 | DropdownMenuPrimitive.SubTrigger.displayName; 46 | 47 | const DropdownMenuSubContent = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, ...props }, ref) => ( 51 | 59 | )); 60 | DropdownMenuSubContent.displayName = 61 | DropdownMenuPrimitive.SubContent.displayName; 62 | 63 | const DropdownMenuContent = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, sideOffset = 4, ...props }, ref) => ( 67 | 68 | 78 | 79 | )); 80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 81 | 82 | const DropdownMenuItem = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef & { 85 | inset?: boolean; 86 | } 87 | >(({ className, inset, ...props }, ref) => ( 88 | svg]:size-4 [&>svg]:shrink-0", 92 | inset && "pl-8", 93 | className 94 | )} 95 | {...props} 96 | /> 97 | )); 98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 99 | 100 | const DropdownMenuCheckboxItem = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, children, checked, ...props }, ref) => ( 104 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | )); 121 | DropdownMenuCheckboxItem.displayName = 122 | DropdownMenuPrimitive.CheckboxItem.displayName; 123 | 124 | const DropdownMenuRadioItem = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, children, ...props }, ref) => ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | )); 144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 145 | 146 | const DropdownMenuLabel = React.forwardRef< 147 | React.ElementRef, 148 | React.ComponentPropsWithoutRef & { 149 | inset?: boolean; 150 | } 151 | >(({ className, inset, ...props }, ref) => ( 152 | 161 | )); 162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 163 | 164 | const DropdownMenuSeparator = React.forwardRef< 165 | React.ElementRef, 166 | React.ComponentPropsWithoutRef 167 | >(({ className, ...props }, ref) => ( 168 | 173 | )); 174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 175 | 176 | const DropdownMenuShortcut = ({ 177 | className, 178 | ...props 179 | }: React.HTMLAttributes) => { 180 | return ( 181 | 185 | ); 186 | }; 187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 188 | 189 | export { 190 | DropdownMenu, 191 | DropdownMenuTrigger, 192 | DropdownMenuContent, 193 | DropdownMenuItem, 194 | DropdownMenuCheckboxItem, 195 | DropdownMenuRadioItem, 196 | DropdownMenuLabel, 197 | DropdownMenuSeparator, 198 | DropdownMenuShortcut, 199 | DropdownMenuGroup, 200 | DropdownMenuPortal, 201 | DropdownMenuSub, 202 | DropdownMenuSubContent, 203 | DropdownMenuSubTrigger, 204 | DropdownMenuRadioGroup, 205 | }; 206 | -------------------------------------------------------------------------------- /frontend/components/ui/file-uploader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { Cross2Icon, FileTextIcon, UploadIcon } from "@radix-ui/react-icons"; 5 | import Dropzone, { 6 | type DropzoneProps, 7 | type FileRejection, 8 | } from "react-dropzone"; 9 | import { toast } from "sonner"; 10 | 11 | import { cn, formatBytes } from "@/lib/utils"; 12 | import { Button } from "@/components/ui/button"; 13 | import { Progress } from "@/components/ui/progress"; 14 | import { ScrollArea } from "@/components/ui/scroll-area"; 15 | import { HTMLAttributes, useCallback, useEffect } from "react"; 16 | import { useControllableState } from "@/hooks/use-controllable-state"; 17 | 18 | interface FileUploaderProps extends HTMLAttributes { 19 | /** 20 | * Value of the uploader. 21 | * @type File[] 22 | * @default undefined 23 | * @example value={files} 24 | */ 25 | value?: File[]; 26 | 27 | /** 28 | * Function to be called when the value changes. 29 | * @type (files: File[]) => void 30 | * @default undefined 31 | * @example onValueChange={(files) => setFiles(files)} 32 | */ 33 | onValueChange?: (files: File[]) => void; 34 | 35 | /** 36 | * Function to be called when files are uploaded. 37 | * @type (files: File[]) => Promise 38 | * @default undefined 39 | * @example onUpload={(files) => uploadFiles(files)} 40 | */ 41 | onUpload?: (files: File[]) => Promise; 42 | 43 | /** 44 | * Progress of the uploaded files. 45 | * @type Record | undefined 46 | * @default undefined 47 | * @example progresses={{ "file1.png": 50 }} 48 | */ 49 | progresses?: Record; 50 | 51 | /** 52 | * Accepted file types for the uploader. 53 | * @type { [key: string]: string[]} 54 | * @default 55 | * ```ts 56 | * { "image/*": [] } 57 | * ``` 58 | * @example accept={["image/png", "image/jpeg"]} 59 | */ 60 | accept?: DropzoneProps["accept"]; 61 | 62 | /** 63 | * Maximum file size for the uploader. 64 | * @type number | undefined 65 | * @default 1024 * 1024 * 2 // 2MB 66 | * @example maxSize={1024 * 1024 * 2} // 2MB 67 | */ 68 | maxSize?: DropzoneProps["maxSize"]; 69 | 70 | /** 71 | * Maximum number of files for the uploader. 72 | * @type number | undefined 73 | * @default 1 74 | * @example maxFileCount={4} 75 | */ 76 | maxFileCount?: DropzoneProps["maxFiles"]; 77 | 78 | /** 79 | * Whether the uploader should accept multiple files. 80 | * @type boolean 81 | * @default false 82 | * @example multiple 83 | */ 84 | multiple?: boolean; 85 | 86 | /** 87 | * Whether the uploader is disabled. 88 | * @type boolean 89 | * @default false 90 | * @example disabled 91 | */ 92 | disabled?: boolean; 93 | } 94 | 95 | export function FileUploader(props: FileUploaderProps) { 96 | const { 97 | value: valueProp, 98 | onValueChange, 99 | onUpload, 100 | progresses, 101 | accept = { 102 | "image/*": [], 103 | }, 104 | maxSize = 1024 * 1024 * 2, 105 | maxFileCount = 1, 106 | multiple = false, 107 | disabled = false, 108 | className, 109 | ...dropzoneProps 110 | } = props; 111 | 112 | const [files, setFiles] = useControllableState({ 113 | prop: valueProp, 114 | onChange: onValueChange, 115 | }); 116 | 117 | const onDrop = useCallback( 118 | (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { 119 | if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) { 120 | toast.error("Cannot upload more than 1 file at a time"); 121 | return; 122 | } 123 | 124 | if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) { 125 | toast.error(`Cannot upload more than ${maxFileCount} files`); 126 | return; 127 | } 128 | 129 | const newFiles = acceptedFiles.map((file) => 130 | Object.assign(file, { 131 | preview: URL.createObjectURL(file), 132 | }) 133 | ); 134 | 135 | const updatedFiles = files ? [...files, ...newFiles] : newFiles; 136 | 137 | setFiles(updatedFiles); 138 | 139 | if (rejectedFiles.length > 0) { 140 | rejectedFiles.forEach(({ file }) => { 141 | toast.error(`File ${file.name} was rejected`); 142 | }); 143 | } 144 | 145 | if ( 146 | onUpload && 147 | updatedFiles.length > 0 && 148 | updatedFiles.length <= maxFileCount 149 | ) { 150 | const target = 151 | updatedFiles.length > 1 152 | ? `${updatedFiles.length} files` 153 | : `File ${updatedFiles[0].name}`; 154 | 155 | toast.promise(onUpload(updatedFiles), { 156 | loading: `Uploading ${target}...`, 157 | success: () => { 158 | setFiles([]); 159 | return `${target} uploaded`; 160 | }, 161 | error: `Failed to upload ${target}`, 162 | }); 163 | } 164 | }, 165 | 166 | [files, maxFileCount, multiple, onUpload, setFiles] 167 | ); 168 | 169 | function onRemove(index: number) { 170 | if (!files) return; 171 | const newFiles = files.filter((_, i) => i !== index); 172 | setFiles(newFiles); 173 | onValueChange?.(newFiles); 174 | } 175 | 176 | // Revoke preview url when component unmounts 177 | useEffect(() => { 178 | return () => { 179 | if (!files) return; 180 | files.forEach((file) => { 181 | if (isFileWithPreview(file)) { 182 | URL.revokeObjectURL(file.preview); 183 | } 184 | }); 185 | }; 186 | // eslint-disable-next-line react-hooks/exhaustive-deps 187 | }, []); 188 | 189 | const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount; 190 | 191 | return ( 192 |
193 | 1 || multiple} 199 | disabled={isDisabled} 200 | > 201 | {({ getRootProps, getInputProps, isDragActive }) => ( 202 |
213 | 214 | {isDragActive ? ( 215 |
216 |
219 | ) : ( 220 |
221 |
236 | )} 237 |
238 | )} 239 |
240 | {/* Hide file preview */} 241 | {/* {files?.length ? ( 242 | 243 |
244 | {files?.map((file, index) => ( 245 | onRemove(index)} 249 | progress={progresses?.[file.name]} 250 | /> 251 | ))} 252 |
253 |
254 | ) : null} */} 255 |
256 | ); 257 | } 258 | 259 | interface FileCardProps { 260 | file: File; 261 | onRemove: () => void; 262 | progress?: number; 263 | } 264 | 265 | function FileCard({ file, progress, onRemove }: FileCardProps) { 266 | return ( 267 |
268 |
269 | {isFileWithPreview(file) ? : null} 270 |
271 |
272 |

273 | {file.name} 274 |

275 |

276 | {formatBytes(file.size)} 277 |

278 |
279 | {progress ? : null} 280 |
281 |
282 |
283 | 293 |
294 |
295 | ); 296 | } 297 | 298 | function isFileWithPreview(file: File): file is File & { preview: string } { 299 | return "preview" in file && typeof file.preview === "string"; 300 | } 301 | 302 | interface FilePreviewProps { 303 | file: File & { preview: string }; 304 | } 305 | 306 | function FilePreview({ file }: FilePreviewProps) { 307 | if (file.type.startsWith("image/")) { 308 | return ( 309 | {file.name} 317 | ); 318 | } 319 | 320 | return ( 321 |