first commit
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal 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
31
.gitignore
vendored
Normal 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
241
README.md
Normal 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
13
backend/.env.example
Normal 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
31
backend/.gitignore
vendored
Normal 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
43
backend/go.mod
Normal 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
95
backend/go.sum
Normal 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=
|
||||
144
backend/internal/handlers/document.go
Normal file
144
backend/internal/handlers/document.go
Normal 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"})
|
||||
}
|
||||
59
backend/internal/handlers/websocket.go
Normal file
59
backend/internal/handlers/websocket.go
Normal 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
164
backend/internal/hub/hub.go
Normal 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,
|
||||
}
|
||||
}
|
||||
38
backend/internal/models/document.go
Normal file
38
backend/internal/models/document.go
Normal 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"`
|
||||
}
|
||||
|
||||
164
backend/internal/store/postgres.go
Normal file
164
backend/internal/store/postgres.go
Normal 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
32
backend/scripts/init.sql
Normal 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
36
docker-compose.yml
Normal 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
7
frontend/.env.example
Normal 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
26
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
4163
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/package.json
Normal file
39
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
215
frontend/public/icons/pixel-sprites.svg
Normal file
215
frontend/public/icons/pixel-sprites.svg
Normal 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 |
70
frontend/public/pixel-patterns.svg
Normal file
70
frontend/public/pixel-patterns.svg
Normal 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
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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
18
frontend/src/App.tsx
Normal 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;
|
||||
71
frontend/src/api/document.ts
Normal file
71
frontend/src/api/document.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
58
frontend/src/components/Editor/Editor.tsx
Normal file
58
frontend/src/components/Editor/Editor.tsx
Normal 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;
|
||||
52
frontend/src/components/Editor/Toolbar.tsx
Normal file
52
frontend/src/components/Editor/Toolbar.tsx
Normal 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;
|
||||
16
frontend/src/components/Kanban/Card.tsx
Normal file
16
frontend/src/components/Kanban/Card.tsx
Normal 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;
|
||||
60
frontend/src/components/Kanban/Column.tsx
Normal file
60
frontend/src/components/Kanban/Column.tsx
Normal 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;
|
||||
112
frontend/src/components/Kanban/KanbanBoard.tsx
Normal file
112
frontend/src/components/Kanban/KanbanBoard.tsx
Normal 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;
|
||||
31
frontend/src/components/PixelIcon/PixelIcon.tsx
Normal file
31
frontend/src/components/PixelIcon/PixelIcon.tsx
Normal 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;
|
||||
32
frontend/src/components/PixelSprites/FloatingGem.tsx
Normal file
32
frontend/src/components/PixelSprites/FloatingGem.tsx
Normal 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;
|
||||
60
frontend/src/components/Presence/UserList.tsx
Normal file
60
frontend/src/components/Presence/UserList.tsx
Normal 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;
|
||||
48
frontend/src/hooks/useAutoSave.ts
Normal file
48
frontend/src/hooks/useAutoSave.ts
Normal 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]);
|
||||
};
|
||||
63
frontend/src/hooks/useYjsDocument.ts
Normal file
63
frontend/src/hooks/useYjsDocument.ts
Normal 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
604
frontend/src/index.css
Normal 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
75
frontend/src/lib/yjs.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
36
frontend/src/pages/EditorPage.tsx
Normal file
36
frontend/src/pages/EditorPage.tsx
Normal 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
112
frontend/src/pages/Home.tsx
Normal 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;
|
||||
36
frontend/src/pages/KanbanPage.tsx
Normal file
36
frontend/src/pages/KanbanPage.tsx
Normal 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;
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user