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