first commit

This commit is contained in:
M1ngdaXie
2025-12-29 16:29:24 -08:00
commit 37d89b13b9
48 changed files with 7334 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Docker Compose Environment Variables
# Copy this file to .env and fill in your actual values
# WARNING: Never commit the actual .env file to version control
# PostgreSQL Configuration
POSTGRES_USER=collab
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=collaboration

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Environment variables
.env
.env.local
.env.*.local
# AI assistant instructions (keep private)
CLAUDE.md
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# OS-specific files
Thumbs.db
*.log
# Dependencies
node_modules/
# Build artifacts
dist/
dist-ssr/
build/
*.local
# Docker volumes and data
postgres_data/

241
README.md Normal file
View File

@@ -0,0 +1,241 @@
# Real-Time Collaboration Platform
A full-stack real-time collaborative application supporting both text editing and Kanban boards, built with React and Go. The system uses Yjs CRDTs for conflict-free synchronization and WebSockets for real-time updates.
## Features
- **Collaborative Text Editor**: Real-time document editing with TipTap editor
- **Collaborative Kanban Boards**: Drag-and-drop task management
- **Real-Time Synchronization**: Instant updates across all connected clients
- **Offline Support**: IndexedDB persistence for offline editing
- **User Presence**: See who's currently editing with live cursors and awareness
## Tech Stack
### Frontend
- React 19 + TypeScript
- Vite (build tool)
- TipTap (collaborative rich text editor)
- Yjs (CRDT for conflict-free replication)
- y-websocket (WebSocket sync provider)
- y-indexeddb (offline persistence)
### Backend
- Go 1.25
- Gin (web framework)
- Gorilla WebSocket
- PostgreSQL 16 (document storage)
- Redis 7 (future use)
### Infrastructure
- Docker Compose
- PostgreSQL
- Redis
## Getting Started
### Prerequisites
- Node.js 18+ and npm
- Go 1.25+
- Docker and Docker Compose
### Installation
1. **Clone the repository**
```bash
git clone <your-repo-url>
cd realtime-collab
```
2. **Set up environment variables**
Create backend environment file:
```bash
cp backend/.env.example backend/.env
# Edit backend/.env with your configuration
```
Create frontend environment file:
```bash
cp frontend/.env.example frontend/.env
# Edit frontend/.env with your configuration (optional for local development)
```
Create root environment file for Docker:
```bash
cp .env.example .env
# Edit .env with your Docker database credentials
```
3. **Start the infrastructure**
```bash
docker-compose up -d
```
This will start PostgreSQL and Redis containers.
4. **Install and run the backend**
```bash
cd backend
go mod download
go run cmd/server/main.go
```
The backend server will start on `http://localhost:8080`
5. **Install and run the frontend**
```bash
cd frontend
npm install
npm run dev
```
The frontend will start on `http://localhost:5173`
6. **Access the application**
Open your browser and navigate to `http://localhost:5173`
## Environment Variables
### Backend (backend/.env)
```env
PORT=8080
DATABASE_URL=postgres://user:password@localhost:5432/collaboration?sslmode=disable
REDIS_URL=redis://localhost:6379
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
```
### Frontend (frontend/.env)
```env
VITE_API_URL=http://localhost:8080/api
VITE_WS_URL=ws://localhost:8080/ws
```
### Docker Compose (.env)
```env
POSTGRES_USER=collab
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=collaboration
```
## Architecture
### Real-Time Collaboration Flow
1. Client connects to WebSocket endpoint `/ws/:documentId`
2. WebSocket handler creates a client and registers it with the Hub
3. Hub manages rooms (one room per document)
4. Yjs generates binary updates on document changes
5. Client sends updates to WebSocket
6. Hub broadcasts updates to all clients in the same room
7. Yjs applies updates and merges changes automatically (CRDT)
8. IndexedDB persists state locally for offline support
### API Endpoints
#### REST API
- `GET /api/documents` - List all documents
- `POST /api/documents` - Create a new document
- `GET /api/documents/:id` - Get document metadata
- `GET /api/documents/:id/state` - Get document Yjs state
- `PUT /api/documents/:id/state` - Update document Yjs state
- `DELETE /api/documents/:id` - Delete a document
#### WebSocket
- `GET /ws/:roomId` - WebSocket connection for real-time sync
#### Health Check
- `GET /health` - Server health status
## Project Structure
```
realtime-collab/
├── backend/
│ ├── cmd/server/ # Application entry point
│ ├── internal/
│ │ ├── handlers/ # HTTP/WebSocket handlers
│ │ ├── hub/ # WebSocket hub (room management)
│ │ ├── models/ # Domain models
│ │ └── store/ # Database layer
│ └── scripts/ # Database initialization scripts
├── frontend/
│ ├── src/
│ │ ├── api/ # REST API client
│ │ ├── components/ # React components
│ │ ├── lib/ # Yjs integration
│ │ ├── pages/ # Page components
│ │ └── hooks/ # Custom React hooks
│ └── public/ # Static assets
└── docker-compose.yml # Infrastructure setup
```
## Development
### Backend Commands
```bash
cd backend
go run cmd/server/main.go # Run development server
go build -o server cmd/server/main.go # Build binary
go fmt ./... # Format code
```
### Frontend Commands
```bash
cd frontend
npm run dev # Start dev server
npm run build # Production build
npm run preview # Preview production build
npm run lint # Run ESLint
```
### Database
The database schema is automatically initialized on first run using `backend/scripts/init.sql`.
To reset the database:
```bash
docker-compose down -v
docker-compose up -d
```
## How It Works
### Conflict-Free Replication (CRDT)
The application uses Yjs, a CRDT implementation, which allows:
- Multiple users to edit simultaneously without conflicts
- Automatic merging of concurrent changes
- Offline editing with eventual consistency
- No need for operational transformation or locking
### WebSocket Broadcasting
The backend acts as a message broker:
1. Receives binary Yjs updates from clients
2. Broadcasts updates to all clients in the same room
3. Does not interpret or modify the updates
4. Yjs handles all conflict resolution on the client side
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License.
## Acknowledgments
- [Yjs](https://github.com/yjs/yjs) - CRDT framework
- [TipTap](https://tiptap.dev/) - Rich text editor
- [Gin](https://gin-gonic.com/) - Go web framework

13
backend/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Server Configuration
PORT=8080
# Database Configuration
# Format: postgres://username:password@host:port/database?sslmode=disable
DATABASE_URL=postgres://collab:your_password_here@localhost:5432/collaboration?sslmode=disable
# Redis Configuration
REDIS_URL=redis://localhost:6379
# CORS Configuration
# Comma-separated list of allowed origins
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000

31
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Environment variables
.env
.env.local
.env.*.local
# Compiled binaries
server
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binaries
*.test
# Go build artifacts
*.out
/bin/
/pkg/
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS-specific files
.DS_Store
Thumbs.db

43
backend/go.mod Normal file
View File

@@ -0,0 +1,43 @@
module github.com/M1ngdaXie/realtime-collab
go 1.25.3
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/cors v1.7.6 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

95
backend/go.sum Normal file
View File

@@ -0,0 +1,95 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,144 @@
package handlers
import (
"net/http"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/M1ngdaXie/realtime-collab/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type DocumentHandler struct {
store *store.Store
}
func NewDocumentHandler(s *store.Store) *DocumentHandler {
return &DocumentHandler{store: s}
}
// CreateDocument creates a new document
func (h *DocumentHandler) CreateDocument(c *gin.Context) {
var req models.CreateDocumentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate document type
if req.Type != models.DocumentTypeEditor && req.Type != models.DocumentTypeKanban {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document type"})
return
}
doc, err := h.store.CreateDocument(req.Name, req.Type)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, doc)
}
// ListDocuments returns all documents
func (h *DocumentHandler) ListDocuments(c *gin.Context) {
documents, err := h.store.ListDocuments()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if documents == nil {
documents = []models.Document{}
}
c.JSON(http.StatusOK, models.DocumentListResponse{
Documents: documents,
Total: len(documents),
})
}
// GetDocument returns a single document
func (h *DocumentHandler) GetDocument(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
return
}
doc, err := h.store.GetDocument(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
return
}
c.JSON(http.StatusOK, doc)
}
// GetDocumentState returns the Yjs state for a document
func (h *DocumentHandler) GetDocumentState(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
return
}
doc, err := h.store.GetDocument(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
return
}
// Return binary state
if doc.YjsState == nil {
c.Data(http.StatusOK, "application/octet-stream", []byte{})
return
}
c.Data(http.StatusOK, "application/octet-stream", doc.YjsState)
}
// UpdateDocumentState updates the Yjs state for a document
func (h *DocumentHandler) UpdateDocumentState(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
return
}
// Read binary body
state, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request body"})
return
}
err = h.store.UpdateDocumentState(id, state)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "state updated successfully"})
}
// DeleteDocument deletes a document
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document ID"})
return
}
err = h.store.DeleteDocument(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "document deleted successfully"})
}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"log"
"net/http"
"github.com/M1ngdaXie/realtime-collab/internal/hub"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// Allow all origins for development
// TODO: Restrict in production
return true
},
}
type WebSocketHandler struct {
hub *hub.Hub
}
func NewWebSocketHandler(h *hub.Hub) *WebSocketHandler {
return &WebSocketHandler{hub: h}
}
func (wsh *WebSocketHandler) HandleWebSocket(c *gin.Context){
roomID := c.Param("roomId")
if(roomID == ""){
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId is required"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upgrade to WebSocket"})
return
}
// Create a new client
clientID := uuid.New().String()
client := hub.NewClient(clientID, conn, wsh.hub, roomID)
// Register client with hub
wsh.hub.Register <- client
// Start read and write pumps in separate goroutines
go client.WritePump()
go client.ReadPump()
log.Printf("WebSocket connection established for client %s in room %s", clientID, roomID)
}

164
backend/internal/hub/hub.go Normal file
View File

@@ -0,0 +1,164 @@
package hub
import (
"log"
"sync"
"github.com/gorilla/websocket"
)
type Message struct {
RoomID string
Data []byte
sender *Client
}
type Client struct {
ID string
Conn *websocket.Conn
send chan []byte
hub *Hub
roomID string
}
type Room struct {
ID string
clients map[*Client]bool
mu sync.RWMutex
}
type Hub struct {
rooms map[string]*Room
mu sync.RWMutex
Register chan *Client // Exported
Unregister chan *Client // Exported
Broadcast chan *Message // Exported
}
func NewHub() *Hub {
return &Hub{
rooms: make(map[string]*Room),
Register: make(chan *Client),
Unregister: make(chan *Client),
Broadcast: make(chan *Message),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.Register:
h.registerClient(client)
case client := <-h.Unregister:
h.unregisterClient(client)
case message := <-h.Broadcast:
h.broadcastMessage(message)
}
}
}
func (h *Hub) registerClient(client *Client) {
h.mu.Lock()
defer h.mu.Unlock()
room, exists := h.rooms[client.roomID]
if !exists {
room = &Room{
ID: client.roomID,
clients: make(map[*Client]bool),
}
h.rooms[client.roomID] = room
log.Printf("Created new room with ID: %s", client.roomID)
}
room.mu.Lock()
room.clients[client] = true
room.mu.Unlock()
log.Printf("Client %s joined room %s (total clients: %d)", client.ID, client.roomID, len(room.clients))
}
func (h *Hub) unregisterClient(client *Client) {
h.mu.Lock()
defer h.mu.Unlock()
room, exists := h.rooms[client.roomID]
if !exists {
log.Printf("Room %s does not exist for client %s", client.roomID, client.ID)
return
}
room.mu.Lock()
if _, ok := room.clients[client]; ok {
delete(room.clients, client)
close(client.send)
log.Printf("Client %s disconnected from room %s", client.ID, client.roomID)
}
room.mu.Unlock()
log.Printf("Client %s left room %s (total clients: %d)", client.ID, client.roomID, len(room.clients))
if len(room.clients) == 0 {
delete(h.rooms, client.roomID)
log.Printf("Deleted empty room with ID: %s", client.roomID)
}
}
func (h *Hub) broadcastMessage(message *Message) {
h.mu.RLock()
room, exists := h.rooms[message.RoomID]
h.mu.RUnlock()
if !exists {
log.Printf("Room %s does not exist for broadcasting", message.RoomID)
return
}
room.mu.RLock()
defer room.mu.RUnlock()
for client := range room.clients {
if client != message.sender {
select {
case client.send <- message.Data:
default:
log.Printf("Failed to send to client %s (channel full)", client.ID)
}
}
}
}
func (c *Client) ReadPump() {
defer func() {
c.hub.Unregister <- c
c.Conn.Close()
}()
for {
messageType, message, err := c.Conn.ReadMessage()
if err != nil {
log.Printf("Error reading message from client %s: %v", c.ID, err)
break
}
if messageType == websocket.BinaryMessage {
c.hub.Broadcast <- &Message{
RoomID: c.roomID,
Data: message,
sender: c,
}
}
}
}
func (c *Client) WritePump() {
defer func() {
c.Conn.Close()
}()
for message := range c.send {
err := c.Conn.WriteMessage(websocket.BinaryMessage, message)
if err != nil {
log.Printf("Error writing message to client %s: %v", c.ID, err)
break
}
}
}
func NewClient(id string, conn *websocket.Conn, hub *Hub, roomID string) *Client {
return &Client{
ID: id,
Conn: conn,
send: make(chan []byte, 256),
hub: hub,
roomID: roomID,
}
}

View File

@@ -0,0 +1,38 @@
package models
import (
"time"
"github.com/google/uuid"
)
type DocumentType string
const (
DocumentTypeEditor DocumentType = "editor"
DocumentTypeKanban DocumentType = "kanban"
)
type Document struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Type DocumentType `json:"type"`
YjsState []byte `json:"-"` // Don't expose binary data in JSON
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateDocumentRequest struct {
Name string `json:"name" binding:"required"`
Type DocumentType `json:"type" binding:"required"`
}
type UpdateStateRequest struct {
State []byte `json:"state" binding:"required"`
}
type DocumentListResponse struct {
Documents []Document `json:"documents"`
Total int `json:"total"`
}

View File

@@ -0,0 +1,164 @@
package store
import (
"database/sql"
"fmt"
"time"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/google/uuid"
_ "github.com/lib/pq" // PostgreSQL driver
)
type Store struct{
db *sql.DB
}
func NewStore(databaseUrl string) (*Store, error) {
db, error := sql.Open("postgres", databaseUrl)
if error != nil {
return nil, error
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) CreateDocument(name string, docType models.DocumentType) (*models.Document, error) {
doc := &models.Document{
ID: uuid.New(),
Name: name,
Type: docType,
YjsState: []byte{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
query := `
INSERT INTO documents (id, name, type, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, type, created_at, updated_at
`
err := s.db.QueryRow(query,
doc.ID,
doc.Name,
doc.Type,
doc.CreatedAt,
doc.UpdatedAt,
).Scan(&doc.ID, &doc.Name, &doc.Type, &doc.CreatedAt, &doc.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to create document: %w", err)
}
return doc, nil
}
// GetDocument retrieves a document by ID
func (s *Store) GetDocument(id uuid.UUID) (*models.Document, error) {
doc := &models.Document{}
query := `
SELECT id, name, type, yjs_state, created_at, updated_at
FROM documents
WHERE id = $1
`
err := s.db.QueryRow(query, id).Scan(
&doc.ID,
&doc.Name,
&doc.Type,
&doc.YjsState,
&doc.CreatedAt,
&doc.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("document not found")
}
if err != nil {
return nil, fmt.Errorf("failed to get document: %w", err)
}
return doc, nil
}
// ListDocuments retrieves all documents
func (s *Store) ListDocuments() ([]models.Document, error) {
query := `
SELECT id, name, type, created_at, updated_at
FROM documents
ORDER BY created_at DESC
`
rows, err := s.db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to list documents: %w", err)
}
defer rows.Close()
var documents []models.Document
for rows.Next() {
var doc models.Document
err := rows.Scan(&doc.ID, &doc.Name, &doc.Type, &doc.CreatedAt, &doc.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan document: %w", err)
}
documents = append(documents, doc)
}
return documents, nil
}
func (s *Store) UpdateDocumentState(id uuid.UUID, state []byte) error {
query := `
UPDATE documents
SET yjs_state = $1, updated_at = $2
WHERE id = $3
`
result, err := s.db.Exec(query, state, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to update document state: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("document not found")
}
return nil
}
func (s *Store) DeleteDocument(id uuid.UUID) error {
query := `DELETE FROM documents WHERE id = $1`
result, err := s.db.Exec(query, id)
if err != nil {
return fmt.Errorf("failed to delete document: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("document not found")
}
return nil
}

32
backend/scripts/init.sql Normal file
View File

@@ -0,0 +1,32 @@
-- Initialize database schema for realtime collaboration
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL CHECK (type IN ('editor', 'kanban')),
yjs_state BYTEA,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_documents_type ON documents(type);
CREATE INDEX idx_documents_created_at ON documents(created_at DESC);
-- Optional: Table for storing incremental updates (for history)
CREATE TABLE IF NOT EXISTS document_updates (
id SERIAL PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
update BYTEA NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_updates_document_id ON document_updates(document_id);
CREATE INDEX idx_updates_created_at ON document_updates(created_at DESC);
-- Insert some sample documents for testing
INSERT INTO documents (id, name, type) VALUES
('00000000-0000-0000-0000-000000000001', 'Welcome Document', 'editor'),
('00000000-0000-0000-0000-000000000002', 'Project Kanban', 'kanban')
ON CONFLICT DO NOTHING;

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: realtime-collab-db
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backend/scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: realtime-collab-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
postgres_data:

7
frontend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# API Configuration
# Backend API base URL
VITE_API_URL=http://localhost:8080/api
# WebSocket Configuration
# WebSocket server URL for real-time collaboration
VITE_WS_URL=ws://localhost:8080/ws

26
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.local

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4163
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-collaboration": "^2.27.1",
"@tiptap/extension-collaboration-cursor": "^2.26.2",
"@tiptap/pm": "^2.27.1",
"@tiptap/react": "^2.27.1",
"@tiptap/starter-kit": "^2.27.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.11.0",
"y-indexeddb": "^9.0.12",
"y-websocket": "^3.0.0",
"yjs": "^13.6.28"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,215 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<defs>
<!-- Document Icons -->
<symbol id="icon-document" viewBox="0 0 24 24">
<path fill="currentColor" d="M6 2h8l4 4v14H6V2z"/>
<path fill="currentColor" opacity="0.3" d="M14 2v4h4l-4-4z"/>
<rect x="8" y="10" width="8" height="2" fill="currentColor" opacity="0.6"/>
<rect x="8" y="14" width="6" height="2" fill="currentColor" opacity="0.6"/>
</symbol>
<symbol id="icon-kanban" viewBox="0 0 24 24">
<rect x="2" y="4" width="6" height="16" fill="currentColor"/>
<rect x="9" y="4" width="6" height="10" fill="currentColor" opacity="0.7"/>
<rect x="16" y="4" width="6" height="14" fill="currentColor" opacity="0.5"/>
</symbol>
<symbol id="icon-plus" viewBox="0 0 24 24">
<rect x="10" y="4" width="4" height="16" fill="currentColor"/>
<rect x="4" y="10" width="16" height="4" fill="currentColor"/>
</symbol>
<symbol id="icon-trash" viewBox="0 0 24 24">
<rect x="7" y="6" width="10" height="2" fill="currentColor"/>
<rect x="8" y="8" width="8" height="12" fill="currentColor"/>
<rect x="9" y="4" width="6" height="2" fill="currentColor"/>
<rect x="10" y="10" width="2" height="8" fill="currentColor" opacity="0.3"/>
<rect x="14" y="10" width="2" height="8" fill="currentColor" opacity="0.3"/>
</symbol>
<symbol id="icon-back-arrow" viewBox="0 0 24 24">
<rect x="8" y="11" width="12" height="2" fill="currentColor"/>
<rect x="6" y="9" width="2" height="2" fill="currentColor"/>
<rect x="6" y="13" width="2" height="2" fill="currentColor"/>
<rect x="4" y="11" width="2" height="2" fill="currentColor"/>
</symbol>
<symbol id="icon-home" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 4l-8 6v10h6v-6h4v6h6V10l-8-6z"/>
</symbol>
<!-- Text Editor Icons -->
<symbol id="icon-bold" viewBox="0 0 24 24">
<path fill="currentColor" d="M6 4h8c2 0 4 1.5 4 3.5s-1 3-3 3.5c2.5 0.5 4 2 4 4s-2 4-5 4H6V4z"/>
<rect x="8" y="7" width="6" height="2" fill="currentColor" opacity="0.3"/>
<rect x="8" y="13" width="7" height="2" fill="currentColor" opacity="0.3"/>
</symbol>
<symbol id="icon-italic" viewBox="0 0 24 24">
<path fill="currentColor" d="M10 4h8v3h-2.5l-3 10H15v3H7v-3h2.5l3-10H10V4z"/>
</symbol>
<symbol id="icon-h1" viewBox="0 0 24 24">
<rect x="4" y="6" width="2" height="12" fill="currentColor"/>
<rect x="4" y="11" width="6" height="2" fill="currentColor"/>
<rect x="8" y="6" width="2" height="12" fill="currentColor"/>
<rect x="15" y="8" width="2" height="10" fill="currentColor"/>
<rect x="13" y="8" width="2" height="3" fill="currentColor"/>
</symbol>
<symbol id="icon-h2" viewBox="0 0 24 24">
<rect x="4" y="6" width="2" height="12" fill="currentColor"/>
<rect x="4" y="11" width="6" height="2" fill="currentColor"/>
<rect x="8" y="6" width="2" height="12" fill="currentColor"/>
<path fill="currentColor" d="M14 8h4v2h-2v2h2v2h-4v2h6v-2h-2v-2h2v-2h-2V8h2V6h-6v2z"/>
</symbol>
<symbol id="icon-bullet-list" viewBox="0 0 24 24">
<rect x="4" y="6" width="3" height="3" fill="currentColor"/>
<rect x="9" y="7" width="11" height="2" fill="currentColor"/>
<rect x="4" y="11" width="3" height="3" fill="currentColor"/>
<rect x="9" y="12" width="11" height="2" fill="currentColor"/>
<rect x="4" y="16" width="3" height="3" fill="currentColor"/>
<rect x="9" y="17" width="11" height="2" fill="currentColor"/>
</symbol>
<symbol id="icon-numbered-list" viewBox="0 0 24 24">
<text x="4" y="9" font-size="8" font-weight="bold" fill="currentColor">1</text>
<rect x="9" y="7" width="11" height="2" fill="currentColor"/>
<text x="4" y="14" font-size="8" font-weight="bold" fill="currentColor">2</text>
<rect x="9" y="12" width="11" height="2" fill="currentColor"/>
<text x="4" y="19" font-size="8" font-weight="bold" fill="currentColor">3</text>
<rect x="9" y="17" width="11" height="2" fill="currentColor"/>
</symbol>
<symbol id="icon-undo" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 8V4l-6 6 6 6v-4h6c2 0 4 2 4 4v2h2v-2c0-3.3-2.7-6-6-6H8z"/>
</symbol>
<symbol id="icon-redo" viewBox="0 0 24 24">
<path fill="currentColor" d="M16 8V4l6 6-6 6v-4h-6c-3.3 0-6 2.7-6 6v2H2v-2c0-4.4 3.6-8 8-8h6z"/>
</symbol>
<!-- Status Icons -->
<symbol id="icon-checkmark" viewBox="0 0 24 24">
<rect x="4" y="12" width="2" height="2" fill="currentColor"/>
<rect x="6" y="14" width="2" height="2" fill="currentColor"/>
<rect x="8" y="16" width="2" height="2" fill="currentColor"/>
<rect x="10" y="14" width="2" height="2" fill="currentColor"/>
<rect x="12" y="12" width="2" height="2" fill="currentColor"/>
<rect x="14" y="10" width="2" height="2" fill="currentColor"/>
<rect x="16" y="8" width="2" height="2" fill="currentColor"/>
<rect x="18" y="6" width="2" height="2" fill="currentColor"/>
</symbol>
<symbol id="icon-sync-arrows" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 4V2l-4 4 4 4V8c3.3 0 6 2.7 6 6h2c0-4.4-3.6-8-8-8z"/>
<path fill="currentColor" d="M12 20v2l4-4-4-4v2c-3.3 0-6-2.7-6-6H4c0 4.4 3.6 8 8 8z"/>
</symbol>
<symbol id="icon-spinner" viewBox="0 0 24 24">
<rect x="11" y="2" width="2" height="6" fill="currentColor"/>
<rect x="11" y="16" width="2" height="6" fill="currentColor" opacity="0.3"/>
<rect x="2" y="11" width="6" height="2" fill="currentColor" opacity="0.5"/>
<rect x="16" y="11" width="6" height="2" fill="currentColor" opacity="0.7"/>
<rect x="5" y="5" width="3" height="3" fill="currentColor" opacity="0.8"/>
<rect x="16" y="16" width="3" height="3" fill="currentColor" opacity="0.4"/>
<rect x="5" y="16" width="3" height="3" fill="currentColor" opacity="0.6"/>
<rect x="16" y="5" width="3" height="3" fill="currentColor" opacity="0.9"/>
</symbol>
<symbol id="icon-connected" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8" fill="currentColor"/>
<circle cx="12" cy="12" r="4" fill="currentColor" opacity="0.5"/>
</symbol>
<symbol id="icon-disconnected" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8" fill="currentColor" opacity="0.3"/>
<rect x="6" y="11" width="12" height="2" fill="currentColor" transform="rotate(45 12 12)"/>
</symbol>
<!-- Decorative Sprites -->
<symbol id="icon-sparkle" viewBox="0 0 24 24">
<rect x="11" y="4" width="2" height="6" fill="currentColor"/>
<rect x="11" y="14" width="2" height="6" fill="currentColor"/>
<rect x="4" y="11" width="6" height="2" fill="currentColor"/>
<rect x="14" y="11" width="6" height="2" fill="currentColor"/>
<rect x="7" y="7" width="2" height="2" fill="currentColor"/>
<rect x="15" y="7" width="2" height="2" fill="currentColor"/>
<rect x="7" y="15" width="2" height="2" fill="currentColor"/>
<rect x="15" y="15" width="2" height="2" fill="currentColor"/>
</symbol>
<symbol id="icon-gem" viewBox="0 0 24 24">
<path fill="#FF6EC7" d="M12 4l-8 6 8 10 8-10-8-6z"/>
<path fill="#FFD23F" opacity="0.7" d="M12 4l-4 3 4 5 4-5-4-3z"/>
<path fill="#00D9FF" opacity="0.5" d="M8 10l4 10v-8l-4-2z"/>
<path fill="#8B4FB9" opacity="0.5" d="M16 10l-4 10v-8l4-2z"/>
</symbol>
<symbol id="icon-orb" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8" fill="#8B4FB9"/>
<circle cx="10" cy="10" r="3" fill="#FF6EC7" opacity="0.6"/>
<circle cx="14" cy="14" r="2" fill="#00D9FF" opacity="0.4"/>
</symbol>
<symbol id="icon-wand" viewBox="0 0 24 24">
<rect x="3" y="17" width="14" height="3" fill="currentColor" transform="rotate(-45 10 18.5)"/>
<rect x="16" y="4" width="2" height="2" fill="#FFD23F"/>
<rect x="18" y="6" width="2" height="2" fill="#FF6EC7"/>
<rect x="14" y="6" width="2" height="2" fill="#00D9FF"/>
<rect x="16" y="8" width="2" height="2" fill="#8EF048"/>
</symbol>
<symbol id="icon-scroll" viewBox="0 0 24 24">
<path fill="currentColor" d="M6 4h12v2h-2v12h2v2H6v-2h2V6H6V4z"/>
<rect x="10" y="8" width="6" height="2" fill="currentColor" opacity="0.3"/>
<rect x="10" y="12" width="4" height="2" fill="currentColor" opacity="0.3"/>
</symbol>
<symbol id="icon-chest" viewBox="0 0 24 24">
<rect x="4" y="10" width="16" height="10" fill="#FF8E3C"/>
<rect x="4" y="8" width="16" height="2" fill="#FFD23F"/>
<rect x="11" y="13" width="2" height="4" fill="#FFD23F"/>
<circle cx="12" cy="15" r="1" fill="#4A1B6F"/>
</symbol>
<symbol id="icon-potion" viewBox="0 0 24 24">
<rect x="10" y="4" width="4" height="3" fill="currentColor"/>
<path fill="#FF6EC7" d="M8 7h8v2h-1v9H9V9H8V7z"/>
<rect x="9" y="11" width="6" height="5" fill="#00D9FF" opacity="0.6"/>
</symbol>
<symbol id="icon-shield" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2l-8 4v6c0 5 3.6 9.2 8 10 4.4-0.8 8-5 8-10V6l-8-4z"/>
<path fill="currentColor" opacity="0.3" d="M12 4l-6 3v5c0 3.7 2.7 6.9 6 7.5V4z"/>
</symbol>
<symbol id="icon-sword" viewBox="0 0 24 24">
<rect x="3" y="15" width="4" height="4" fill="currentColor"/>
<rect x="7" y="11" width="2" height="2" fill="currentColor"/>
<rect x="9" y="9" width="2" height="2" fill="currentColor"/>
<rect x="11" y="7" width="10" height="2" fill="currentColor"/>
<rect x="19" y="5" width="2" height="2" fill="currentColor"/>
<rect x="19" y="9" width="2" height="2" fill="currentColor"/>
</symbol>
<symbol id="icon-crown" viewBox="0 0 24 24">
<path fill="#FFD23F" d="M4 16h16v2H4v-2z"/>
<path fill="#FFD23F" d="M4 10l4 4 4-6 4 6 4-4v6H4v-6z"/>
<circle cx="6" cy="8" r="2" fill="#FF6EC7"/>
<circle cx="12" cy="4" r="2" fill="#FF6EC7"/>
<circle cx="18" cy="8" r="2" fill="#FF6EC7"/>
</symbol>
<symbol id="icon-user" viewBox="0 0 24 24">
<rect x="8" y="4" width="8" height="8" fill="currentColor"/>
<path fill="currentColor" d="M6 14h12l2 6H4l2-6z"/>
</symbol>
<symbol id="icon-online-dot" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="6" fill="#8EF048"/>
<circle cx="10" cy="10" r="2" fill="#FFFFFF" opacity="0.6"/>
</symbol>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,70 @@
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position: absolute;">
<defs>
<!-- Diagonal Dither Pattern (8x8 tile) -->
<pattern id="pixel-dither-diagonal" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<rect fill="#4A3B5C" width="8" height="8"/>
<path d="M0,0 L2,0 L2,2 L0,2 Z M4,0 L6,0 L6,2 L4,2 Z
M2,2 L4,2 L4,4 L2,4 Z M6,2 L8,2 L8,4 L6,4 Z
M0,4 L2,4 L2,6 L0,6 Z M4,4 L6,4 L6,6 L4,6 Z
M2,6 L4,6 L4,8 L2,8 Z M6,6 L8,6 L8,8 L6,8 Z"
fill="#8B4FB9" opacity="0.15"/>
</pattern>
<!-- Subtle Checkerboard Pattern (4x4 tile) -->
<pattern id="pixel-checker-subtle" x="0" y="0" width="4" height="4" patternUnits="userSpaceOnUse">
<rect fill="transparent" width="4" height="4"/>
<rect fill="rgba(75, 27, 111, 0.08)" x="0" y="0" width="2" height="2"/>
<rect fill="rgba(75, 27, 111, 0.08)" x="2" y="2" width="2" height="2"/>
</pattern>
<!-- Dense Pixel Dots (for backgrounds) -->
<pattern id="pixel-dots" x="0" y="0" width="6" height="6" patternUnits="userSpaceOnUse">
<rect fill="transparent" width="6" height="6"/>
<circle cx="3" cy="3" r="1" fill="rgba(139, 79, 185, 0.1)"/>
</pattern>
<!-- Gradient Dither (vertical) -->
<pattern id="pixel-gradient-dither" x="0" y="0" width="16" height="64" patternUnits="userSpaceOnUse">
<rect fill="transparent" width="16" height="64"/>
<!-- Top section - dense dots -->
<g fill="rgba(75, 27, 111, 0.12)">
<rect x="0" y="0" width="2" height="2"/>
<rect x="4" y="0" width="2" height="2"/>
<rect x="8" y="0" width="2" height="2"/>
<rect x="12" y="0" width="2" height="2"/>
<rect x="2" y="2" width="2" height="2"/>
<rect x="6" y="2" width="2" height="2"/>
<rect x="10" y="2" width="2" height="2"/>
<rect x="14" y="2" width="2" height="2"/>
</g>
<!-- Middle section - medium -->
<g fill="rgba(75, 27, 111, 0.08)">
<rect x="0" y="16" width="2" height="2"/>
<rect x="8" y="16" width="2" height="2"/>
<rect x="4" y="18" width="2" height="2"/>
<rect x="12" y="18" width="2" height="2"/>
</g>
<!-- Bottom section - sparse -->
<g fill="rgba(75, 27, 111, 0.04)">
<rect x="2" y="48" width="2" height="2"/>
<rect x="10" y="50" width="2" height="2"/>
</g>
</pattern>
<!-- Scanlines Pattern (CRT effect) -->
<pattern id="pixel-scanlines" x="0" y="0" width="4" height="4" patternUnits="userSpaceOnUse">
<rect fill="transparent" width="4" height="4"/>
<line x1="0" y1="0" x2="4" y2="0" stroke="rgba(0, 0, 0, 0.03)" stroke-width="1"/>
<line x1="0" y1="2" x2="4" y2="2" stroke="rgba(0, 0, 0, 0.03)" stroke-width="1"/>
</pattern>
<!-- Panel Texture (for light backgrounds) -->
<pattern id="pixel-panel-texture" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<rect fill="transparent" width="8" height="8"/>
<rect x="0" y="0" width="1" height="1" fill="rgba(139, 79, 185, 0.06)"/>
<rect x="4" y="4" width="1" height="1" fill="rgba(139, 79, 185, 0.06)"/>
<rect x="2" y="6" width="1" height="1" fill="rgba(255, 110, 199, 0.04)"/>
<rect x="6" y="2" width="1" height="1" fill="rgba(0, 217, 255, 0.04)"/>
</pattern>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

18
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import EditorPage from "./pages/EditorPage.tsx";
import Home from "./pages/Home.tsx";
import KanbanPage from "./pages/KanbanPage.tsx";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/editor/:id" element={<EditorPage />} />
<Route path="/kanban/:id" element={<KanbanPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,71 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api";
export type DocumentType = {
id: string;
name: string;
type: "editor" | "kanban";
created_at: string;
updated_at: string;
}
export type CreateDocumentRequest = {
name: string;
type: "editor" | "kanban";
}
export const documentsApi = {
// List all documents
list: async (): Promise<{ documents: DocumentType[]; total: number }> => {
const response = await fetch(`${API_BASE_URL}/documents`);
if (!response.ok) throw new Error("Failed to fetch documents");
return response.json();
},
// Get a single document
get: async (id: string): Promise<DocumentType> => {
const response = await fetch(`${API_BASE_URL}/documents/${id}`);
if (!response.ok) throw new Error("Failed to fetch document");
return response.json();
},
// Create a new document
create: async (data: CreateDocumentRequest): Promise<DocumentType> => {
const response = await fetch(`${API_BASE_URL}/documents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to create document");
return response.json();
},
// Delete a document
delete: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/documents/${id}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete document");
},
// Get document Yjs state
getState: async (id: string): Promise<Uint8Array> => {
const response = await fetch(`${API_BASE_URL}/documents/${id}/state`);
if (!response.ok) throw new Error("Failed to fetch document state");
const arrayBuffer = await response.arrayBuffer();
return new Uint8Array(arrayBuffer);
},
// Update document Yjs state
updateState: async (id: string, state: Uint8Array): Promise<void> => {
// Create a new ArrayBuffer copy to ensure compatibility
const buffer = new ArrayBuffer(state.byteLength);
new Uint8Array(buffer).set(state);
const response = await fetch(`${API_BASE_URL}/documents/${id}/state`, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body: buffer,
});
if (!response.ok) throw new Error("Failed to update document state");
},
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,58 @@
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect } from "react";
import type { YjsProviders } from "../../lib/yjs";
import Toolbar from "./Toolbar.tsx";
interface EditorProps {
providers: YjsProviders;
}
const Editor = ({ providers }: EditorProps) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
history: false, // Use Yjs history instead
}),
Collaboration.configure({
document: providers.ydoc,
}),
CollaborationCursor.configure({
provider: providers.websocketProvider,
user: providers.awareness.getLocalState()?.user || {
name: "Anonymous",
color: "#000000",
},
}),
],
content: "",
});
useEffect(() => {
if (editor && providers.awareness) {
const user = providers.awareness.getLocalState()?.user;
if (user) {
editor.extensionManager.extensions.forEach((extension: any) => {
if (extension.name === "collaborationCursor") {
extension.options.user = user;
}
});
}
}
}, [editor, providers.awareness]);
if (!editor) {
return <div>Loading editor...</div>;
}
return (
<div className="editor-container">
<Toolbar editor={editor} />
<EditorContent editor={editor} className="editor-content" />
</div>
);
};
export default Editor;

View File

@@ -0,0 +1,52 @@
import { Editor } from "@tiptap/react";
interface ToolbarProps {
editor: Editor;
}
const Toolbar = ({ editor }: ToolbarProps) => {
return (
<div className="toolbar">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "active" : ""}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive("italic") ? "active" : ""}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive("heading", { level: 1 }) ? "active" : ""}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive("heading", { level: 2 }) ? "active" : ""}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "active" : ""}
>
Bullet List
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "active" : ""}
>
Ordered List
</button>
<button onClick={() => editor.chain().focus().undo().run()}>Undo</button>
<button onClick={() => editor.chain().focus().redo().run()}>Redo</button>
</div>
);
};
export default Toolbar;

View File

@@ -0,0 +1,16 @@
import type { Task } from "./KanbanBoard.tsx";
interface CardProps {
task: Task;
}
const Card = ({ task }: CardProps) => {
return (
<div className="kanban-card">
<h4>{task.title}</h4>
{task.description && <p>{task.description}</p>}
</div>
);
};
export default Card;

View File

@@ -0,0 +1,60 @@
import { useState } from "react";
import Card from "./Card.tsx";
import type { KanbanColumn, Task } from "./KanbanBoard.tsx";
interface ColumnProps {
column: KanbanColumn;
onAddTask: (task: Task) => void;
onMoveTask: (taskId: string, toColumnId: string) => void;
}
const Column = ({ column, onAddTask }: ColumnProps) => {
const [isAdding, setIsAdding] = useState(false);
const [newTaskTitle, setNewTaskTitle] = useState("");
const handleAddTask = () => {
if (newTaskTitle.trim()) {
onAddTask({
id: `task-${Date.now()}`,
title: newTaskTitle,
description: "",
});
setNewTaskTitle("");
setIsAdding(false);
}
};
return (
<div className="kanban-column">
<h3 className="column-title">{column.title}</h3>
<div className="column-content">
{column.tasks.map((task) => (
<Card key={task.id} task={task} />
))}
{isAdding ? (
<div className="add-task-form">
<input
type="text"
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
placeholder="Task title..."
autoFocus
onKeyPress={(e) => e.key === "Enter" && handleAddTask()}
/>
<div className="form-actions">
<button onClick={handleAddTask}>Add</button>
<button onClick={() => setIsAdding(false)}>Cancel</button>
</div>
</div>
) : (
<button className="add-task-btn" onClick={() => setIsAdding(true)}>
+ Add Task
</button>
)}
</div>
</div>
);
};
export default Column;

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import type { YjsProviders } from "../../lib/yjs";
import Column from "./Column.tsx";
interface KanbanBoardProps {
providers: YjsProviders;
}
export interface Task {
id: string;
title: string;
description: string;
}
export interface KanbanColumn {
id: string;
title: string;
tasks: Task[];
}
const KanbanBoard = ({ providers }: KanbanBoardProps) => {
const [columns, setColumns] = useState<KanbanColumn[]>([]);
useEffect(() => {
const yarray = providers.ydoc.getArray<any>("kanban-columns");
// Initialize with default columns if empty
if (yarray.length === 0) {
providers.ydoc.transact(() => {
yarray.push([
{ id: "todo", title: "To Do", tasks: [] },
{ id: "in-progress", title: "In Progress", tasks: [] },
{ id: "done", title: "Done", tasks: [] },
]);
});
}
// Update state when Yjs array changes
const updateColumns = () => {
setColumns(yarray.toArray());
};
updateColumns();
yarray.observe(updateColumns);
return () => {
yarray.unobserve(updateColumns);
};
}, [providers.ydoc]);
const addTask = (columnId: string, task: Task) => {
const yarray = providers.ydoc.getArray("kanban-columns");
const cols = yarray.toArray();
const columnIndex = cols.findIndex((col: any) => col.id === columnId);
if (columnIndex !== -1) {
providers.ydoc.transact(() => {
const column = cols[columnIndex];
column.tasks.push(task);
yarray.delete(columnIndex, 1);
yarray.insert(columnIndex, [column]);
});
}
};
const moveTask = (
fromColumnId: string,
toColumnId: string,
taskId: string
) => {
const yarray = providers.ydoc.getArray("kanban-columns");
const cols = yarray.toArray();
const fromIndex = cols.findIndex((col: any) => col.id === fromColumnId);
const toIndex = cols.findIndex((col: any) => col.id === toColumnId);
if (fromIndex !== -1 && toIndex !== -1) {
providers.ydoc.transact(() => {
const fromCol = { ...cols[fromIndex] };
const toCol = { ...cols[toIndex] };
const taskIndex = fromCol.tasks.findIndex((t: Task) => t.id === taskId);
if (taskIndex !== -1) {
const [task] = fromCol.tasks.splice(taskIndex, 1);
toCol.tasks.push(task);
yarray.delete(fromIndex, 1);
yarray.insert(fromIndex, [fromCol]);
yarray.delete(toIndex, 1);
yarray.insert(toIndex, [toCol]);
}
});
}
};
return (
<div className="kanban-board">
{columns.map((column) => (
<Column
key={column.id}
column={column}
onAddTask={(task) => addTask(column.id, task)}
onMoveTask={(taskId, toColumnId) =>
moveTask(column.id, toColumnId, taskId)
}
/>
))}
</div>
);
};
export default KanbanBoard;

View File

@@ -0,0 +1,31 @@
interface PixelIconProps {
name: string;
size?: number;
color?: string;
animated?: boolean;
className?: string;
style?: React.CSSProperties;
}
const PixelIcon = ({
name,
size = 24,
color,
animated = false,
className = '',
style = {}
}: PixelIconProps) => {
return (
<svg
width={size}
height={size}
className={`pixel-icon ${animated ? 'pixel-icon-animated' : ''} ${className}`}
style={{ color, ...style }}
aria-hidden="true"
>
<use href={`/icons/pixel-sprites.svg#icon-${name}`} />
</svg>
);
};
export default PixelIcon;

View File

@@ -0,0 +1,32 @@
import { CSSProperties } from 'react';
interface FloatingGemProps {
position?: { top?: string; right?: string; bottom?: string; left?: string };
delay?: number;
size?: number;
}
const FloatingGem = ({ position = {}, delay = 0, size = 32 }: FloatingGemProps) => {
const style: CSSProperties = {
position: 'absolute',
...position,
animation: `pixel-float 4s ease-in-out infinite`,
animationDelay: `${delay}s`,
pointerEvents: 'none',
zIndex: 10,
};
return (
<svg
width={size}
height={size}
style={style}
viewBox="0 0 24 24"
aria-hidden="true"
>
<use href="/icons/pixel-sprites.svg#icon-gem" />
</svg>
);
};
export default FloatingGem;

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
interface UserListProps {
awareness: any;
}
interface User {
clientId: number;
name: string;
color: string;
}
const UserList = ({ awareness }: UserListProps) => {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
const updateUsers = () => {
const states = awareness.getStates();
const userList: User[] = [];
states.forEach((state: any, clientId: number) => {
if (state.user) {
userList.push({
clientId,
name: state.user.name,
color: state.user.color,
});
}
});
setUsers(userList);
};
updateUsers();
awareness.on("change", updateUsers);
return () => {
awareness.off("change", updateUsers);
};
}, [awareness]);
return (
<div className="user-list">
<h4>Online Users ({users.length})</h4>
<div className="users">
{users.map((user) => (
<div key={user.clientId} className="user">
<span
className="user-color"
style={{ backgroundColor: user.color }}
></span>
<span className="user-name">{user.name}</span>
</div>
))}
</div>
</div>
);
};
export default UserList;

View File

@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import * as Y from 'yjs';
import { documentsApi } from '../api/document';
export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => {
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isSavingRef = useRef(false);
useEffect(() => {
if (!ydoc) return;
const handleUpdate = (update: Uint8Array, origin: any) => {
// Ignore updates from initial sync or remote sources
if (origin === 'init' || origin === 'remote') return;
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Set new timeout: save 2 seconds after last edit
saveTimeoutRef.current = setTimeout(async () => {
if (isSavingRef.current) return; // Prevent concurrent saves
isSavingRef.current = true;
try {
const state = Y.encodeStateAsUpdate(ydoc);
await documentsApi.updateState(documentId, state);
console.log('✓ Document saved to database');
} catch (error) {
console.error('Failed to save document:', error);
} finally {
isSavingRef.current = false;
}
}, 2000); // 2 second debounce
};
ydoc.on('update', handleUpdate);
// Cleanup
return () => {
ydoc.off('update', handleUpdate);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [documentId, ydoc]);
};

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import {
createYjsDocument,
destroyYjsDocument,
getRandomColor,
getRandomName,
type YjsProviders,
} from "../lib/yjs";
import { useAutoSave } from "./useAutoSave";
export const useYjsDocument = (documentId: string) => {
const [providers, setProviders] = useState<YjsProviders | null>(null);
const [synced, setSynced] = useState(false);
// Enable auto-save when providers are ready
useAutoSave(documentId, providers?.ydoc || null);
useEffect(() => {
let mounted = true;
let currentProviders: YjsProviders | null = null;
// Create Yjs document and providers
const initializeDocument = async () => {
const yjsProviders = await createYjsDocument(documentId);
currentProviders = yjsProviders;
if (!mounted) {
destroyYjsDocument(yjsProviders);
return;
}
// Set user info for awareness
yjsProviders.awareness.setLocalStateField("user", {
name: getRandomName(),
color: getRandomColor(),
});
// Listen for sync status
yjsProviders.indexeddbProvider.on("synced", () => {
console.log("IndexedDB synced");
setSynced(true);
});
yjsProviders.websocketProvider.on("status", (event: { status: string }) => {
console.log("WebSocket status:", event.status);
});
setProviders(yjsProviders);
};
initializeDocument();
// Cleanup on unmount
return () => {
mounted = false;
if (currentProviders) {
destroyYjsDocument(currentProviders);
}
};
}, [documentId]);
return { providers, synced };
};

604
frontend/src/index.css Normal file
View File

@@ -0,0 +1,604 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
/* Vibrant Fantasy Color Palette */
--pixel-purple-deep: #4A1B6F;
--pixel-purple-bright: #8B4FB9;
--pixel-pink-vibrant: #FF6EC7;
--pixel-cyan-bright: #00D9FF;
--pixel-orange-warm: #FF8E3C;
--pixel-yellow-gold: #FFD23F;
--pixel-green-lime: #8EF048;
--pixel-green-forest: #3FA54D;
/* UI Backgrounds & Neutrals */
--pixel-bg-dark: #2B1B38;
--pixel-bg-medium: #4A3B5C;
--pixel-bg-light: #E8D9F3;
--pixel-panel: #F5F0FF;
--pixel-white: #FFFFFF;
/* Shadows & Outlines */
--pixel-shadow-dark: #1A0E28;
--pixel-outline: #2B1B38;
/* Text Colors */
--pixel-text-primary: #2B1B38;
--pixel-text-secondary: #4A3B5C;
--pixel-text-muted: #8B7B9C;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--pixel-bg-light);
background-image: url('/pixel-patterns.svg#pixel-checker-subtle');
}
#root {
min-height: 100vh;
}
/* Pixel Art Utility Classes */
.pixel-border {
border: 3px solid var(--pixel-outline);
box-shadow:
4px 4px 0 var(--pixel-shadow-dark),
4px 4px 0 3px var(--pixel-outline);
}
.pixel-card {
border: 3px solid var(--pixel-outline);
box-shadow:
0 0 0 3px var(--pixel-outline),
6px 6px 0 var(--pixel-shadow-dark),
6px 6px 0 3px var(--pixel-outline);
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.pixel-card:hover {
transform: translate(-2px, -2px);
box-shadow:
0 0 0 3px var(--pixel-outline),
8px 8px 0 var(--pixel-shadow-dark),
8px 8px 0 3px var(--pixel-outline);
}
.pixel-button {
border: 3px solid var(--pixel-outline);
box-shadow:
4px 4px 0 var(--pixel-shadow-dark);
transition: transform 0.05s ease, box-shadow 0.05s ease;
cursor: pointer;
}
.pixel-button:hover {
transform: translate(-1px, -1px);
box-shadow:
5px 5px 0 var(--pixel-shadow-dark);
}
.pixel-button:active {
transform: translate(2px, 2px);
box-shadow:
2px 2px 0 var(--pixel-shadow-dark);
}
/* Focus states for accessibility */
button:focus-visible,
input:focus-visible {
outline: 3px solid var(--pixel-yellow-gold);
outline-offset: 2px;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Pixel Icon Styles */
.pixel-icon {
display: inline-block;
vertical-align: middle;
}
.pixel-icon-animated {
animation: pixel-spin 1s steps(8) infinite;
}
/* Keyframe Animations */
@keyframes pixel-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pixel-float {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-12px) rotate(2deg); }
50% { transform: translateY(-8px) rotate(0deg); }
75% { transform: translateY(-12px) rotate(-2deg); }
}
@keyframes pixel-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
@keyframes pixel-sparkle {
0% { transform: scale(0) rotate(0deg); opacity: 1; }
50% { transform: scale(1.5) rotate(180deg); opacity: 0.6; }
100% { transform: scale(0) rotate(360deg); opacity: 0; }
}
@keyframes pixel-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.1); }
}
/* Home Page */
.home-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.home-page h1 {
margin-bottom: 2rem;
}
.create-buttons {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.create-buttons button {
padding: 0.75rem 1.5rem;
background: var(--pixel-purple-bright);
color: white;
border: 3px solid var(--pixel-outline);
border-radius: 0;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
box-shadow: 4px 4px 0 var(--pixel-shadow-dark);
transition: transform 0.05s ease, box-shadow 0.05s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.create-buttons button:hover {
background: var(--pixel-purple-deep);
transform: translate(-1px, -1px);
box-shadow: 5px 5px 0 var(--pixel-shadow-dark);
}
.create-buttons button:active {
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 var(--pixel-shadow-dark);
}
.create-buttons button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.document-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.document-card {
background: var(--pixel-white);
padding: 1.5rem;
border-radius: 0;
border: 3px solid var(--pixel-outline);
box-shadow:
6px 6px 0 var(--pixel-shadow-dark),
6px 6px 0 3px var(--pixel-outline);
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.document-card:hover {
transform: translate(-2px, -2px);
box-shadow:
8px 8px 0 var(--pixel-shadow-dark),
8px 8px 0 3px var(--pixel-outline);
}
.doc-info h3 {
margin-bottom: 0.5rem;
}
.doc-type {
color: var(--pixel-text-secondary);
text-transform: capitalize;
margin-bottom: 0.5rem;
}
.doc-date {
color: var(--pixel-text-muted);
font-size: 0.875rem;
}
.doc-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.doc-actions button {
padding: 0.5rem 1rem;
border: 3px solid var(--pixel-outline);
background: var(--pixel-white);
border-radius: 0;
cursor: pointer;
min-width: 44px;
min-height: 44px;
box-shadow: 3px 3px 0 var(--pixel-shadow-dark);
transition: transform 0.05s ease, box-shadow 0.05s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.doc-actions button:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--pixel-shadow-dark);
}
.doc-actions button:active {
transform: translate(1px, 1px);
box-shadow: 2px 2px 0 var(--pixel-shadow-dark);
}
.doc-actions button:first-child {
background: var(--pixel-cyan-bright);
color: white;
border-color: var(--pixel-outline);
}
.doc-actions button:first-child:hover {
background: var(--pixel-purple-bright);
}
/* Editor Page */
.editor-page, .kanban-page {
display: flex;
flex-direction: column;
height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--pixel-white);
border-bottom: 3px solid var(--pixel-outline);
}
.page-header button {
padding: 0.5rem 1rem;
background: var(--pixel-panel);
border: 3px solid var(--pixel-outline);
border-radius: 0;
cursor: pointer;
min-width: 44px;
min-height: 44px;
box-shadow: 3px 3px 0 var(--pixel-shadow-dark);
transition: transform 0.05s ease, box-shadow 0.05s ease;
}
.page-header button:hover {
background: var(--pixel-bg-light);
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--pixel-shadow-dark);
}
.page-header button:active {
transform: translate(1px, 1px);
box-shadow: 2px 2px 0 var(--pixel-shadow-dark);
}
.sync-status {
color: var(--pixel-text-secondary);
font-size: 0.875rem;
}
.page-content {
display: flex;
flex: 1;
overflow: hidden;
}
.main-area {
flex: 1;
overflow: auto;
padding: 2rem;
}
.sidebar {
width: 250px;
background: var(--pixel-white);
background-image: url('/pixel-patterns.svg#pixel-scanlines');
border-left: 3px solid var(--pixel-outline);
padding: 1rem;
}
/* Editor */
.editor-container {
background: var(--pixel-white);
border-radius: 0;
border: 3px solid var(--pixel-outline);
box-shadow:
6px 6px 0 var(--pixel-shadow-dark),
6px 6px 0 3px var(--pixel-outline);
overflow: hidden;
}
.toolbar {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
background: var(--pixel-panel);
background-image: url('/pixel-patterns.svg#pixel-panel-texture');
border-bottom: 3px solid var(--pixel-outline);
flex-wrap: wrap;
}
.toolbar button {
padding: 0.5rem 0.75rem;
background: var(--pixel-white);
border: 3px solid var(--pixel-outline);
border-radius: 0;
cursor: pointer;
min-width: 44px;
min-height: 44px;
box-shadow: 3px 3px 0 var(--pixel-shadow-dark);
transition: transform 0.05s ease, box-shadow 0.05s ease;
}
.toolbar button:hover {
background: var(--pixel-bg-light);
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--pixel-shadow-dark);
}
.toolbar button:active {
transform: translate(1px, 1px);
box-shadow: 2px 2px 0 var(--pixel-shadow-dark);
}
.toolbar button.active {
background: var(--pixel-cyan-bright);
color: white;
border-color: var(--pixel-outline);
box-shadow: 2px 2px 0 var(--pixel-shadow-dark);
transform: translate(1px, 1px);
}
.editor-content {
padding: 2rem;
min-height: 500px;
}
.ProseMirror {
outline: none;
}
.ProseMirror h1 {
font-size: 2rem;
margin: 1rem 0;
}
.ProseMirror h2 {
font-size: 1.5rem;
margin: 1rem 0;
}
.ProseMirror p {
margin: 0.5rem 0;
}
.ProseMirror ul, .ProseMirror ol {
margin-left: 1.5rem;
}
/* Collaborative Cursors */
.collaboration-cursor__caret {
position: absolute;
border-left: 2px solid;
border-right: none;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
word-break: normal;
}
.collaboration-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: normal;
user-select: none;
color: #fff;
padding: 2px 6px;
border-radius: 0;
white-space: nowrap;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3);
}
/* Kanban Board */
.kanban-board {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 1rem;
}
.kanban-column {
background: var(--pixel-panel);
background-image: url('/pixel-patterns.svg#pixel-dither-diagonal');
border-radius: 0;
border: 3px solid var(--pixel-outline);
padding: 1rem;
min-width: 300px;
max-width: 300px;
}
.column-title {
margin-bottom: 1rem;
font-size: 1.125rem;
}
.column-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.kanban-card {
background: var(--pixel-white);
padding: 1rem;
border-radius: 0;
border: 3px solid var(--pixel-outline);
box-shadow:
4px 4px 0 var(--pixel-shadow-dark),
4px 4px 0 3px var(--pixel-outline);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.kanban-card:hover {
transform: translate(-1px, -1px);
box-shadow:
5px 5px 0 var(--pixel-shadow-dark),
5px 5px 0 3px var(--pixel-outline);
}
.kanban-card h4 {
margin-bottom: 0.5rem;
}
.kanban-card p {
color: var(--pixel-text-secondary);
font-size: 0.875rem;
}
.add-task-btn {
padding: 0.75rem;
background: var(--pixel-white);
border: 3px dashed var(--pixel-outline);
border-radius: 0;
cursor: pointer;
color: var(--pixel-text-secondary);
}
.add-task-btn:hover {
border-color: var(--pixel-purple-bright);
color: var(--pixel-text-primary);
background: var(--pixel-bg-light);
}
.add-task-form input {
width: 100%;
padding: 0.75rem;
border: 2px solid var(--pixel-outline);
border-radius: 0;
margin-bottom: 0.5rem;
background: var(--pixel-white);
}
.add-task-form input:focus {
outline: none;
border-color: var(--pixel-cyan-bright);
box-shadow: 0 0 0 2px var(--pixel-cyan-bright);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.form-actions button {
padding: 0.5rem 1rem;
border: 3px solid var(--pixel-outline);
border-radius: 0;
cursor: pointer;
background: var(--pixel-white);
box-shadow: 3px 3px 0 var(--pixel-shadow-dark);
transition: transform 0.05s ease, box-shadow 0.05s ease;
}
.form-actions button:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--pixel-shadow-dark);
}
.form-actions button:active {
transform: translate(1px, 1px);
box-shadow: 2px 2px 0 var(--pixel-shadow-dark);
}
.form-actions button:first-child {
background: var(--pixel-green-lime);
color: var(--pixel-text-primary);
border-color: var(--pixel-outline);
}
/* User List */
.user-list h4 {
margin-bottom: 1rem;
}
.users {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.user {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-color {
width: 12px;
height: 12px;
border-radius: 0;
border: 1px solid var(--pixel-outline);
}
.user-name {
font-size: 0.875rem;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.25rem;
color: var(--pixel-text-secondary);
}

75
frontend/src/lib/yjs.ts Normal file
View File

@@ -0,0 +1,75 @@
import { IndexeddbPersistence } from "y-indexeddb";
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";
import { documentsApi } from "../api/document";
const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws";
export interface YjsProviders {
ydoc: Y.Doc;
websocketProvider: WebsocketProvider;
indexeddbProvider: IndexeddbPersistence;
awareness: any;
}
export const createYjsDocument = async (documentId: string): Promise<YjsProviders> => {
// Create Yjs document
const ydoc = new Y.Doc();
// Load initial state from database BEFORE connecting providers
try {
const state = await documentsApi.getState(documentId);
if (state && state.length > 0) {
Y.applyUpdate(ydoc, state);
console.log('✓ Loaded document state from database');
}
} catch {
console.log('No existing state in database (new document)');
}
// IndexedDB persistence (offline support)
const indexeddbProvider = new IndexeddbPersistence(documentId, ydoc);
// WebSocket provider (real-time sync)
const websocketProvider = new WebsocketProvider(WS_URL, documentId, ydoc);
// Awareness for cursors and presence
const awareness = websocketProvider.awareness;
return {
ydoc,
websocketProvider,
indexeddbProvider,
awareness,
};
};
export const destroyYjsDocument = (providers: YjsProviders) => {
providers.websocketProvider.destroy();
providers.indexeddbProvider.destroy();
providers.ydoc.destroy();
};
// Random color generator for users
export const getRandomColor = () => {
const colors = [
"#FF6B6B",
"#4ECDC4",
"#45B7D1",
"#FFA07A",
"#98D8C8",
"#F7DC6F",
"#BB8FCE",
"#85C1E2",
];
return colors[Math.floor(Math.random() * colors.length)];
};
// Random name generator
export const getRandomName = () => {
const adjectives = ["Happy", "Clever", "Brave", "Swift", "Kind"];
const animals = ["Panda", "Fox", "Wolf", "Bear", "Eagle"];
return `${adjectives[Math.floor(Math.random() * adjectives.length)]} ${
animals[Math.floor(Math.random() * animals.length)]
}`;
};

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,36 @@
import { useNavigate, useParams } from "react-router-dom";
import Editor from "../components/Editor/Editor.tsx";
import UserList from "../components/Presence/UserList.tsx";
import { useYjsDocument } from "../hooks/useYjsDocument.ts";
const EditorPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { providers, synced } = useYjsDocument(id!);
if (!providers) {
return <div className="loading">Connecting...</div>;
}
return (
<div className="editor-page">
<div className="page-header">
<button onClick={() => navigate("/")}> Back to Home</button>
<div className="sync-status">
{synced ? "✓ Synced" : "⟳ Syncing..."}
</div>
</div>
<div className="page-content">
<div className="main-area">
<Editor providers={providers} />
</div>
<div className="sidebar">
<UserList awareness={providers.awareness} />
</div>
</div>
</div>
);
};
export default EditorPage;

112
frontend/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { DocumentType } from "../api/document.ts";
import { documentsApi } from "../api/document.ts";
import PixelIcon from "../components/PixelIcon/PixelIcon.tsx";
import FloatingGem from "../components/PixelSprites/FloatingGem.tsx";
const Home = () => {
const [documents, setDocuments] = useState<DocumentType[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const navigate = useNavigate();
const loadDocuments = async () => {
try {
const { documents } = await documentsApi.list();
setDocuments(documents);
} catch (error) {
console.error("Failed to load documents:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDocuments();
}, []);
const createDocument = async (type: "editor" | "kanban") => {
setCreating(true);
try {
const doc = await documentsApi.create({
name: `New ${type === "editor" ? "Document" : "Kanban Board"}`,
type,
});
navigate(`/${type}/${doc.id}`);
} catch (error) {
console.error("Failed to create document:", error);
} finally {
setCreating(false);
}
};
const deleteDocument = async (id: string) => {
if (!confirm("Are you sure you want to delete this document?")) return;
try {
await documentsApi.delete(id);
loadDocuments();
} catch (error) {
console.error("Failed to delete document:", error);
}
};
if (loading) {
return <div className="loading">Loading documents...</div>;
}
return (
<div className="home-page" style={{ position: 'relative' }}>
<FloatingGem position={{ top: '20px', right: '40px' }} delay={0} size={40} />
<FloatingGem position={{ top: '60px', left: '60px' }} delay={1.5} size={32} />
<FloatingGem position={{ bottom: '100px', right: '100px' }} delay={3} size={36} />
<h1>My Documents</h1>
<div className="create-buttons">
<button onClick={() => createDocument("editor")} disabled={creating}>
<PixelIcon name="plus" size={20} />
<span style={{ marginLeft: '8px' }}>New Text Document</span>
</button>
<button onClick={() => createDocument("kanban")} disabled={creating}>
<PixelIcon name="plus" size={20} />
<span style={{ marginLeft: '8px' }}>New Kanban Board</span>
</button>
</div>
<div className="document-list">
{documents.length === 0 ? (
<p>No documents yet. Create one to get started!</p>
) : (
documents.map((doc) => (
<div key={doc.id} className="document-card">
<div className="doc-info">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<PixelIcon name={doc.type === 'editor' ? 'document' : 'kanban'} size={24} color="var(--pixel-purple-bright)" />
<h3 style={{ margin: 0 }}>{doc.name}</h3>
</div>
<p className="doc-type">{doc.type}</p>
<p className="doc-date">
Created: {new Date(doc.created_at).toLocaleDateString()}
</p>
</div>
<div className="doc-actions">
<button onClick={() => navigate(`/${doc.type}/${doc.id}`)} aria-label={`Open ${doc.name}`}>
<PixelIcon name="back-arrow" size={16} style={{ transform: 'rotate(180deg)' }} />
<span style={{ marginLeft: '6px' }}>Open</span>
</button>
<button onClick={() => deleteDocument(doc.id)} aria-label={`Delete ${doc.name}`}>
<PixelIcon name="trash" size={16} />
<span style={{ marginLeft: '6px' }}>Delete</span>
</button>
</div>
</div>
))
)}
</div>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,36 @@
import { useNavigate, useParams } from "react-router-dom";
import KanbanBoard from "../components/Kanban/KanbanBoard.tsx";
import UserList from "../components/Presence/UserList.tsx";
import { useYjsDocument } from "../hooks/useYjsDocument.ts";
const KanbanPage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { providers, synced } = useYjsDocument(id!);
if (!providers) {
return <div className="loading">Connecting...</div>;
}
return (
<div className="kanban-page">
<div className="page-header">
<button onClick={() => navigate("/")}> Back to Home</button>
<div className="sync-status">
{synced ? "✓ Synced" : "⟳ Syncing..."}
</div>
</div>
<div className="page-content">
<div className="main-area">
<KanbanBoard providers={providers} />
</div>
<div className="sidebar">
<UserList awareness={providers.awareness} />
</div>
</div>
</div>
);
};
export default KanbanPage;

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})