feat: full project sync - CI fixes, frontend, workspace API, and all changes

This commit is contained in:
Tomas Dvorak
2026-04-27 09:08:07 +02:00
parent a07fca997e
commit 89b9390c14
109 changed files with 21120 additions and 545 deletions
+43 -11
View File
@@ -1,14 +1,46 @@
# GitHub OAuth 配置 # ===================================================================
GITHUB_CLIENT_ID="xxxxxxxxxxxxxxxxxxx" # Excalidraw FULL - Environment Configuration
GITHUB_CLIENT_SECRET="xxxxxxxxxxxxxx" # ===================================================================
GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" # 或者你部署后的回调地址
# JWT 配置 # -------------------------------------------------------------------
JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING" # Required: JWT Secret
# -------------------------------------------------------------------
# Must be at least 32 random characters. Used for session signing.
# Generate: openssl rand -base64 32
JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING_MIN_32_CHARS"
# OpenAI 配置 # -------------------------------------------------------------------
OPENAI_API_KEY=sk-xxxxxxxxxxxxxx # Required: Database
OPENAI_BASE_URL=https://xxxxxx.xxxxx # 不加/v1... # -------------------------------------------------------------------
# Options: postgres | memory | filesystem | kv | s3
# Postgres is required for the workspace product path.
STORAGE_TYPE=postgres
DATABASE_URL="postgres://excalidraw:excalidraw@localhost:5432/excalidraw?sslmode=disable"
# 存储配置 # -------------------------------------------------------------------
STORAGE_TYPE=sqlite # 支持memoryfilesystem, kv, s3,具体看README # Auth Provider (configure ONE of GitHub / OIDC / neither for password-only)
# -------------------------------------------------------------------
# GitHub OAuth
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback"
# OIDC (generic SSO)
OIDC_ISSUER_URL=""
OIDC_CLIENT_ID=""
OIDC_CLIENT_SECRET=""
OIDC_REDIRECT_URL="http://localhost:3002/auth/callback"
# -------------------------------------------------------------------
# Optional: OpenAI Proxy
# -------------------------------------------------------------------
OPENAI_API_KEY=""
OPENAI_BASE_URL="https://api.openai.com"
# -------------------------------------------------------------------
# Optional: CORS / Deployment
# -------------------------------------------------------------------
# Comma-separated list of allowed origins for CORS
ALLOWED_ORIGINS="http://localhost:3000,http://localhost:3002,http://localhost:5173"
# Server bind address
LISTEN_ADDR=":3002"
+2 -2
View File
@@ -10,8 +10,8 @@ ADMIN_USER_ID=admin1234 # Optional
JWT_SECRET=your_super_secret_jwt_string JWT_SECRET=your_super_secret_jwt_string
STORAGE_TYPE=sqlite STORAGE_TYPE=postgres
DATA_SOURCE_NAME=excalidraw.db DATABASE_URL=postgres://excalidraw:excalidraw@localhost:5432/excalidraw?sslmode=disable
LOCAL_STORAGE_PATH=./data LOCAL_STORAGE_PATH=./data
OPENAI_API_KEY=sk-your_openai_api_key OPENAI_API_KEY=sk-your_openai_api_key
+132 -10
View File
@@ -1,14 +1,136 @@
# ============================================
# Excalidraw FULL - Git Ignore
# ============================================
# --------------------------------------------
# Build Outputs
# --------------------------------------------
dist/ dist/
frontend/ build/
!frontend/.keep out/
excalidraw-complete *.exe
*/node_modules *.dll
*/dist *.so
node_modules *.dylib
# --------------------------------------------
# Frontend (Node/Vite/React)
# --------------------------------------------
node_modules/
*/node_modules/
.pnpm-store/
.yarn/cache
.yarn/install-state.gz
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnp.js
.vite/
*.tsbuildinfo
# Frontend build outputs (explicit)
frontend/dist/
frontend/build/
frontend/out/
# Cache & generated
.parcel-cache/
.eslintcache
.stylelintcache
*.local
# --------------------------------------------
# Go Backend
# --------------------------------------------
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
vendor/
# --------------------------------------------
# Environment & Secrets
# --------------------------------------------
.env .env
.env.local
.env.*.local
.env.development
.env.test
.env.production
*.env *.env
*/build/*
*.db
data/
.idea
.htpasswd .htpasswd
secrets/
*.pem
*.key
# --------------------------------------------
# Database & Data
# --------------------------------------------
*.db
*.db-journal
*.sqlite
*.sqlite3
data/
*.log
logs/
# --------------------------------------------
# IDE & Editors
# --------------------------------------------
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
# --------------------------------------------
# Testing & Coverage
# --------------------------------------------
coverage/
.nyc_output/
*.lcov
*.cover
*.cov
coverage.txt
coverage.html
# --------------------------------------------
# Misc
# --------------------------------------------
.cache/
temp/
tmp/
*.tmp
*.temp
*.bak
*.orig
*.rej
*.diff
*.patch
*.log
.env*.backup
# OS generated files
.DS_Store?
ehthumbs.db
Icon?
Desktop.ini
# Binary output
excalidraw-complete
excalidraw-full
excalidraw-full.exe
excalidraw-complete.exe
# cf-kv worker output
cf-kv/index.js
# Keep dist/.keep for empty directory tracking in git
!frontend/dist/.keep
+113
View File
@@ -0,0 +1,113 @@
# Contributing to Excalidraw FULL
## Development Setup
### Prerequisites
- Go 1.23+
- Node.js 20+
- npm or pnpm
- Docker (optional, for containerized builds)
### Quick Start
```bash
# 1. Clone and enter the repo
git clone <repo-url>
cd excalidraw-full
# 2. Copy environment file
cp .env.example .env
# Edit .env and set JWT_SECRET (required)
# 3. Install frontend dependencies and start dev servers
cd frontend && npm ci
# Terminal 1 — Go backend
go run .
# Terminal 2 — Vite frontend
cd frontend && npm run dev
# Or use Docker for a one-command setup:
make docker-up
```
### Build & Test
```bash
# Build everything (frontend + Go binary)
make build
# Run all tests
make test
# Docker build
make docker-up
```
## Project Structure
```
.
├── main.go # Go server entrypoint
├── workspace/ # Core domain: models, store, HTTP handlers, tests
│ ├── models.go
│ ├── store.go
│ ├── http.go
│ └── *_test.go
├── middleware/ # Auth, security headers, rate limiting
├── frontend/ # React + Vite frontend
│ ├── src/
│ │ ├── pages/ # Dashboard, Editor, Auth, Settings, etc.
│ │ ├── components/ # Reusable UI (Button, Card, Input, etc.)
│ │ ├── stores/ # Zustand state management
│ │ ├── services/ # API client
│ │ ├── i18n/ # Translation files
│ │ └── styles/ # Global SCSS + CSS variables
│ └── package.json
├── excalidraw-full.Dockerfile # Multi-stage production build
├── docker-compose.yml
└── Makefile
```
## Adding a New API Endpoint
1. Define the request/response structs in `workspace/models.go`.
2. Add the store method in `workspace/store.go`.
3. Add the HTTP handler in `workspace/http.go`.
4. Wire the route in `main.go` under the `/api` router.
5. Add tests in `workspace/http_test.go`.
## Frontend Conventions
- **Styling**: SCSS modules + CSS custom properties (`variables.scss`).
- **State**: Zustand with `persist` middleware for cross-session state.
- **Icons**: Lucide React.
- **i18n**: All user-facing strings must use `react-i18next` (`t('key')`). Add new keys to `frontend/src/i18n/locales/en.json`.
## Testing
- **Backend**: `go test ./...` (all tests run against an in-memory SQLite database).
- **Frontend**: `cd frontend && npm test` runs Vitest. Playwright E2E tests can be added with `npm init playwright@latest`.
## Code Style
- Go: `gofmt` + standard library first.
- TypeScript: Strict mode enabled. Prefer explicit types over `any`.
## Security
- Never hardcode secrets. Use environment variables.
- Auth changes must not weaken the permission matrix (see `workspace/permissions_test.go`).
- CORS origins must be explicit — wildcard (`*`) with credentials is forbidden.
## Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add fulltext search endpoints
fix: correct CORS origin wildcard on Socket.IO
docs: update README with Docker instructions
```
+131
View File
@@ -0,0 +1,131 @@
# ===================================================================
# Excalidraw FULL - Build, Test & Dev Automation
# ===================================================================
.PHONY: all install dev build test clean docker-up docker-down docker-clean docker-up-postgres docker-down-postgres lint fmt generate-api-client help
# -------------------------------------------------------------------
# Defaults
# -------------------------------------------------------------------
FRONTEND_DIR := frontend
BACKEND_DIR := .
# -------------------------------------------------------------------
# Dev
# -------------------------------------------------------------------
dev: ## Run frontend Vite dev server + Go backend (requires tmux or two terminals)
@echo "Start backend: make dev-backend"
@echo "Start frontend: make dev-frontend"
install: ## Install frontend dependencies
cd $(FRONTEND_DIR) && npm ci
dev-backend: ## Run Go backend with auto-reload (requires air)
air -c .air.toml
dev-frontend: ## Run Vite dev server
cd $(FRONTEND_DIR) && npm run dev
# -------------------------------------------------------------------
# Build
# -------------------------------------------------------------------
build: build-frontend build-backend ## Full production build (frontend + backend)
build-frontend: ## Build React frontend into frontend/dist
cd $(FRONTEND_DIR) && npm ci && npm run build
build-backend: ## Build Go binary with embedded frontend/dist
cd $(BACKEND_DIR) && go build -ldflags="-s -w" -o excalidraw-full .
build-docker: ## Build Docker image locally
docker build -f excalidraw-full.Dockerfile -t excalidraw-full:latest .
# -------------------------------------------------------------------
# Test
# -------------------------------------------------------------------
test: test-backend test-frontend ## Run all tests
test-backend: ## Run Go unit tests
cd $(BACKEND_DIR) && go test ./... -v -count=1
test-frontend: ## Run frontend tests (Vitest)
cd $(FRONTEND_DIR) && npm test -- --run
test-e2e: ## Run Playwright E2E tests (requires install first)
cd $(FRONTEND_DIR) && npx playwright test
# -------------------------------------------------------------------
# Lint / Format
# -------------------------------------------------------------------
lint: lint-backend lint-frontend ## Run all linters
lint-backend: ## Run go vet + staticcheck
cd $(BACKEND_DIR) && go vet ./...
cd $(BACKEND_DIR) && staticcheck ./...
lint-frontend: ## Run ESLint
cd $(FRONTEND_DIR) && npm run lint
fmt: fmt-backend fmt-frontend ## Format all code
fmt-backend: ## Run gofmt
cd $(BACKEND_DIR) && gofmt -w .
fmt-frontend: ## Run prettier
cd $(FRONTEND_DIR) && npx prettier --write "src/**/*.{ts,tsx,scss,css}"
# -------------------------------------------------------------------
# Docker
# -------------------------------------------------------------------
docker-up: ## Start with Docker Compose (builds local image)
docker compose -f docker-compose.yml up --build -d
docker-down: ## Stop Docker Compose
docker compose -f docker-compose.yml down
docker-up-postgres: ## Start with PostgreSQL Docker Compose (compat target)
docker compose -f docker-compose.postgres.yml up --build -d
docker-down-postgres: ## Stop PostgreSQL Docker Compose (compat target)
docker compose -f docker-compose.postgres.yml down
docker-clean: ## Stop and remove Docker volumes/images
docker compose -f docker-compose.yml down -v --rmi local
docker system prune -f
docker-logs: ## Tail Docker logs
docker compose -f docker-compose.yml logs -f
docker-build-push: ## Build and tag for registry (set IMAGE_TAG)
docker build -f excalidraw-full.Dockerfile -t $(IMAGE_TAG) .
# -------------------------------------------------------------------
# Database
# -------------------------------------------------------------------
db-migrate: ## Run database migrations (placeholder)
@echo "Migrations: embedded goose migrations run automatically on boot."
db-seed: ## Seed system templates (placeholder)
@echo "Seeding: system templates seed automatically on boot."
# -------------------------------------------------------------------
# Clean
# -------------------------------------------------------------------
clean: ## Remove build artifacts and cached test data
cd $(FRONTEND_DIR) && rm -rf dist node_modules/.vite
cd $(BACKEND_DIR) && rm -f excalidraw-full excalidraw-complete *.db *.db-journal
cd $(BACKEND_DIR) && go clean -cache
# -------------------------------------------------------------------
# API / Client
# -------------------------------------------------------------------
generate-api-client: ## Generate TypeScript API client from openapi.yaml
npx --yes openapi-typescript@latest openapi.yaml -o $(FRONTEND_DIR)/src/services/api-client.ts
# -------------------------------------------------------------------
# Help
# -------------------------------------------------------------------
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
IMAGE_TAG ?= excalidraw-full:latest
+169 -150
View File
@@ -1,182 +1,201 @@
# Excalidraw Full: Your Self-Hosted, Cloud-Ready Collaboration Platform # Excalidraw FULL
[中文说明](./README_zh.md) A self-hosted visual workspace for drawing, planning, mapping, charting, and lightweight project management built around a fast collaborative canvas.
Excalidraw Full has evolved. It's no longer just a simple wrapper for Excalidraw, but a powerful, self-hosted collaboration platform with a "Bring Your Own Cloud" (BYOC) philosophy. It provides user authentication, multi-canvas management, and the unique ability to connect directly to your own cloud storage from the frontend. ## What It Is
The core idea is to let the backend handle user identity while giving you, the user, full control over where your data is stored. Excalidraw FULL is a production-grade visual workspace platform. It is no longer a simple drawing wrapper — it is a full product with backend-owned persistence, team collaboration, permissions, and structured file management.
## Core Differences from Official Excalidraw - **Visual canvas first** — freeform drawing remains central
- **Backend-owned persistence only** — no browser/local save modes
- **Team collaboration native** — real-time editing with presence
- **File/folder/project management built in** — hierarchical organization via `/folder/:folderId/drawing/:drawingId`
- **Secure sharing and permissioning** — explicit grants with inheritance
- **Activity history and auditability** — every action is tracked
- **Templates and structured productivity** — system + team + personal templates
- **Rich linking between canvases** — embeds, references, knowledge graph
- **AI chat integration** — OpenAI proxy for diagram generation assistance
- **Command palette** — global `Cmd/Ctrl+K` for power users
- **Fulltext search** — find drawings from anywhere
- **Revision browser** — time-travel through drawing history with one-click restore
- **Dark mode** — persistent theme preference across sessions
- **Presenter notes** — add notes to any drawing for presentations
- **Responsive layout** — mobile sidebar toggle, adaptive grids
- **Accessibility** — ARIA labels, roles, keyboard navigation
- **Self-hosting** — single Docker image, healthchecks, volume mounts
- **Fully Self-Hosted Collaboration & Sharing**: Unlike the official version, all real-time collaboration and sharing features are handled by your own self-hosted backend, ensuring complete data privacy and control. ## Quick Start
- **Advanced Multi-Canvas Management**: Seamlessly create, save, and manage multiple canvases. Store your work on the server's backend (e.g., SQLite, S3) or connect the frontend directly to your personal cloud storage (e.g., Cloudflare KV) for true data sovereignty.
- **Zero-Config AI Features**: Instantly access integrated OpenAI features like GPT-4 Vision after logging in—no complex client-side setup required. API keys are securely managed by the backend.
![Multi-Canvas Management](./img/PixPin_2025-07-06_16-07-27.png)
![Multi-Choice Storage](./img/PixPin_2025-07-06_16-08-29.png)
![Oauth2 Login](./img/PixPin_2025-07-06_16-09-24.png)
![AI Features](./img/PixPin_2025-07-06_16-09-55.png)
## Key Features
- **GitHub Authentication**: Secure sign-in using GitHub OAuth.
- **Multi-Canvas Management**: Users can create, save, and manage multiple drawing canvases.
- **Flexible Data Storage (BYOC)**:
- **Default Backend Storage**: Out-of-the-box support for saving canvases on the server's storage (SQLite, Filesystem, S3).
- **Direct Cloud Connection**: The frontend can connect directly to your own cloud services like **Cloudflare KV** or **Amazon S3** for ultimate data sovereignty. Your credentials never touch our server.
- **Real-time Collaboration**: The classic Excalidraw real-time collaboration is fully supported.
- **Secure OpenAI Proxy**: An optional backend proxy for using OpenAI's GPT-4 Vision features, keeping your API key safe.
- **All-in-One Binary**: The entire application, including the patched frontend and backend server, is compiled into a single Go binary for easy deployment.
## Frontend Canvas Storage Strategies
- **IndexedDB**: A fast, secure, and scalable key-value store. No need to configure anything. Not login required.
- **Backend Storage**: The backend can save the canvas to the server's storage (SQLite, Filesystem, S3). Synchronized in different devices.
- **Cloudflare KV**: A fast, secure, and scalable key-value store. This requires deploying a companion Worker to your Cloudflare account. See the [**Cloudflare Worker Deployment Guide**](./cloudflare-worker/README.md) for detailed instructions.
- **Amazon S3**: A reliable, scalable, and inexpensive object storage service.
## Installation & Running
One Click Docker run [Excalidraw-Full](https://github.com/BetterAndBetterII/excalidraw-full).
```bash ```bash
# Example for Linux
git clone https://github.com/BetterAndBetterII/excalidraw-full.git git clone https://github.com/BetterAndBetterII/excalidraw-full.git
cd excalidraw-full cd excalidraw-full
mv .env.example .env cp .env.example .env
touch ./excalidraw.db # IMPORTANT: Initialize the SQLite DB, OTHERWISE IT WILL NOT START # Edit .env and set JWT_SECRET (required)
docker compose up -d # openssl rand -base64 32
make build # Build frontend + Go binary
make test # Run all tests
make docker-up # Or run via Docker Compose
``` ```
The server will start, and you can access the application at `http://localhost:3002`. The application will be available at `http://localhost:3002`.
## Requirements
<!-- Summary Folded --> - Go 1.25+
<details> - Node.js 20+ (for frontend build)
<summary>Use Simple Password Authentication(Dex OIDC)</summary> - Make (optional, for convenience commands)
- Docker (optional, for containerized deployment)
```bash
# Example for Linux
git clone https://github.com/BetterAndBetterII/excalidraw-full.git
cd excalidraw-full
mv .env.example.dex .env
touch ./excalidraw.db # IMPORTANT: Initialize the SQLite DB, OTHERWISE IT WILL NOT START
docker compose -f docker-compose.dex.yml up -d
```
Change your password in `.env` file.
```bash
# apt install apache2-utils
# Generate the password hash
echo YOUR_NEW_PASSWORD | htpasswd -BinC 10 admin | cut -d: -f2 > .htpasswd
# Update your .env file
sed -i "s|ADMIN_PASSWORD_HASH=.*|ADMIN_PASSWORD_HASH='$(cat .htpasswd)'|" .env
```
</details>
## Configuration ## Configuration
Configuration is managed via environment variables. For a full template, see the `.env.example` section below. All configuration is via environment variables. See `.env.example` for the full reference.
### 1. Backend Configuration (Required) | Variable | Required | Description |
|----------|----------|-------------|
| `JWT_SECRET` | Yes | Secure random string for session signing (min 32 chars) |
| `STORAGE_TYPE` | No | `postgres` (default), `memory`, `filesystem`, `s3` |
| `DATABASE_URL` | Yes for Postgres | PostgreSQL connection string |
| `GITHUB_CLIENT_ID` | No* | GitHub OAuth app client ID |
| `GITHUB_CLIENT_SECRET` | No* | GitHub OAuth app client secret |
| `OIDC_ISSUER_URL` | No* | Generic OIDC issuer for SSO |
| `OIDC_CLIENT_ID` | No* | OIDC client ID |
| `OIDC_CLIENT_SECRET` | No* | OIDC client secret |
| `OPENAI_API_KEY` | No | Enables AI chat/completion proxy |
| `OPENAI_BASE_URL` | No | OpenAI-compatible API base URL |
| `ALLOWED_ORIGINS` | No | Comma-separated CORS origins |
| `LISTEN_ADDR` | No | Server bind address (default `:3002`) |
You must configure GitHub OAuth and a JWT secret for the application to function. \* At least one external auth provider (GitHub OAuth or OIDC) OR use built-in password authentication. Password auth works out of the box.
- `GITHUB_CLIENT_ID`: Your GitHub OAuth App's Client ID. ## Architecture
- `GITHUB_CLIENT_SECRET`: Your GitHub OAuth App's Client Secret.
- `GITHUB_REDIRECT_URL`: The callback URL. For local testing, this is `http://localhost:3002/auth/callback`.
- `JWT_SECRET`: A strong, random string for signing session tokens. Generate one with `openssl rand -base64 32`.
- `OPENAI_API_KEY`: Your secret key from OpenAI.
- `OPENAI_BASE_URL`: (Optional) For using compatible APIs like Azure OpenAI.
### 2. Default Storage (Optional, but Recommended) - **Backend**: Go 1.25, Chi router, PostgreSQL (pgx)
- **Frontend**: React 18, Vite, TypeScript, Zustand, react-router-dom, react-i18next
- **Real-time**: Socket.IO for collaborative canvas sync
- **Auth**: Session cookies (httpOnly, SameSite=Lax) + bcrypt password hashing + OAuth/OIDC
- **Storage**: PostgreSQL default, with legacy filesystem/S3 options for canvas storage
- **API spec**: OpenAPI 3.0 in `api/openapi.yaml`; TypeScript client via `make generate-api-client`
This configures the server's built-in storage, used by default. ## API
- `STORAGE_TYPE`: `memory` (default), `sqlite`, `filesystem`, or `s3`. The workspace API is mounted at `/api` and requires session authentication. Key endpoints:
- `DATA_SOURCE_NAME`: Path for the SQLite DB (e.g., `excalidraw.db`).
- `LOCAL_STORAGE_PATH`: Directory for filesystem storage.
- `S3_BUCKET_NAME`, `AWS_REGION`, etc.: For S3 storage.
### 3. OpenAI Proxy (Optional) - `POST /api/auth/signup` / `POST /api/auth/login` / `POST /api/auth/logout`
- `GET /api/auth/me` — current user
- `GET /api/teams`, `POST /api/teams`, `GET /api/teams/:id/members`
- `GET /api/drawings`, `POST /api/drawings`, `GET /api/drawings/:id`
- `PATCH /api/drawings/:id`, `DELETE /api/drawings/:id`
- `GET /api/drawings/:id/revisions`, `POST /api/drawings/:id/revisions`
- `GET /api/drawings/:id/permissions`, `POST /api/drawings/:id/permissions`
- `GET /api/drawings/:id/share-links`, `POST /api/drawings/:id/share-links`
- `GET /api/search?q=` — fulltext search
- `GET /api/folders`, `POST /api/folders`
- `GET /api/projects`, `POST /api/projects`
- `GET /api/templates` — system, team, and personal templates
- `GET /api/activity` — audit trail with actor hydration
- `GET /api/stats` — workspace statistics (counts + storage)
- `GET /api/health` — readiness probe
To enable AI features, set your OpenAI API key. ## Development
- `OPENAI_API_KEY`: Your secret key from OpenAI.
- `OPENAI_BASE_URL`: (Optional) For using compatible APIs like Azure OpenAI.
### 4. Frontend Configuration
Frontend storage adapters (like Cloudflare KV, S3) are configured directly in the application's UI settings after you log in. This is by design: your private cloud credentials are only ever stored in your browser's session and are never sent to the backend server.
### Example `.env.example`
Create a `.env` file in the project root and add the following, filling in your own values.
```env
# Backend Server Configuration
# Get from https://github.com/settings/developers
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_REDIRECT_URL=http://localhost:3002/auth/callback
# Generate with: openssl rand -base64 32
JWT_SECRET=your_super_secret_jwt_string
# Default Storage (SQLite)
STORAGE_TYPE=sqlite
DATA_SOURCE_NAME=excalidraw.db
# Optional OpenAI Proxy
OPENAI_API_KEY=sk-your_openai_api_key
```
## Building from Source
The process is similar to before, but now requires the Go backend to be built.
### Using Docker (Recommended)
```bash ```bash
# Clone the repository with submodules # Terminal 1: Go backend with auto-reload (requires air)
git clone https://github.com/PatWie/excalidraw-complete.git --recursive make dev-backend
cd excalidraw-complete
# Build the Docker image # Terminal 2: Vite dev server
# This handles the frontend build, patching, and Go backend compilation. make dev-frontend
docker build -t excalidraw-complete -f excalidraw-complete.Dockerfile .
# Run the container, providing the environment variables # Or run both manually:
docker run -p 3002:3002 \ go run main.go # backend on :3002
-e GITHUB_CLIENT_ID="your_id" \ cd frontend && npm run dev # frontend on :5173
-e GITHUB_CLIENT_SECRET="your_secret" \
-e GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" \
-e JWT_SECRET="your_jwt_secret" \
-e STORAGE_TYPE="sqlite" \
-e DATA_SOURCE_NAME="excalidraw.db" \
-e OPENAI_API_KEY="your_openai_api_key" \
excalidraw-complete
``` ```
### Manual Build ## Building
1. **Build Frontend**: Follow the steps in the original README to patch and build the Excalidraw frontend inside the `excalidraw/` submodule. ```bash
2. **Copy Frontend**: Ensure the built frontend from `excalidraw/excalidraw-app/build` is copied to the `frontend/` directory in the root. make build # Full production build (frontend + Go binary)
3. **Build Go Backend**: make build-frontend # React build only
```bash make build-backend # Go binary only
go build -o excalidraw-complete main.go make build-docker # Docker image locally
``` make test # Run Go + frontend tests
4. **Run**: make test-backend # Go unit tests
```bash make test-frontend # Vitest unit tests
# Set environment variables first make test-e2e # Playwright E2E tests
./excalidraw-complete make lint # Run all linters
``` make fmt # Format all code
--- make generate-api-client # TS client from openapi.yaml
make clean # Remove build artifacts
make docker-up # Docker Compose local run
make docker-down # Stop Docker Compose
make docker-logs # Tail Docker logs
make help # Show all targets
```
Excalidraw is a fantastic tool. This project aims to make a powerful, data-secure version of it accessible to everyone. Contributions are welcome! ## Project Structure
```
.
├── main.go # Go server entrypoint
├── workspace/ # Core domain: models, store, HTTP handlers, tests
│ ├── models.go
│ ├── store.go # PostgreSQL persistence + migrations
│ ├── store_sharing.go # Permissions, share links, embeds
│ ├── http.go # API route handlers
│ ├── http_extra.go # Search, stats, activity
│ ├── stats.go # Workspace statistics
│ ├── rate_limiter.go # Auth endpoint rate limiting
│ └── *_test.go # Go unit tests
├── middleware/ # Auth, security headers
├── handlers/ # Legacy firebase, kv, openai, auth
├── frontend/ # React + Vite frontend
│ ├── src/
│ │ ├── pages/ # Dashboard, Editor, Auth, Settings, etc.
│ │ ├── components/ # Reusable UI (Button, Card, CommandPalette, etc.)
│ │ ├── stores/ # Zustand state management
│ │ ├── services/ # API client + OpenAI proxy
│ │ ├── i18n/ # Translation files (en.json)
│ │ └── styles/ # Global SCSS + CSS variables
│ └── package.json
├── api/
│ └── openapi.yaml # Full OpenAPI 3.0 spec
├── excalidraw-full.Dockerfile # Multi-stage production build
├── docker-compose.yml # Local Docker Compose
├── Makefile # Build, test, dev automation
└── .env.example # Environment reference
```
## Security
- bcrypt(cost=12) password hashing
- httpOnly, SameSite=Lax session cookies
- CSRF-like same-origin mutation checks on state-changing requests
- URL sanitization for embeds (blocks file://, javascript:, private IPs)
- Content-Security-Policy headers with strict defaults
- Rate limiting on auth endpoints (10 req / 15 min per IP)
- Permission matrix with explicit grants + inheritance
- All mutations require authenticated session
## Internationalization
Frontend uses `react-i18next` with `i18next-browser-languagedetector`. All UI strings are externalized to `frontend/src/i18n/locales/en.json`. Add new keys there and reference via `t('key')`.
## Roadmap
See `plus-roadmap.md` for upcoming features. Shipped highlights:
- Archive (trash) instead of delete
- Activity feed with full audit trail
- Command palette for whole app (`Cmd/Ctrl+K`)
- Fulltext search
- Versioning with revision browser
- Public API (OpenAPI + TS client generation)
- Self-hosting via Docker
- Presenter notes
- Scene filtering and sorting
- Template gallery with apply flow
- Dark mode sync with canvas
- Mobile-responsive navigation
## License
MIT
-181
View File
@@ -1,181 +0,0 @@
# Excalidraw Full: 您的自托管、云就绪协作平台
Excalidraw Full 已经进化。它不再仅仅是 Excalidraw 的一个简单封装,而是一个强大的、自托管的协作平台,秉承"自带云"BYOC - Bring Your Own Cloud)的理念。它提供用户认证、多画板管理,以及从前端直接连接到您自己的云存储的独特能力。
其核心思想是让后端处理用户身份,同时让您(用户)完全控制数据的存储位置。
## 与官方 Excalidraw 的核心区别
- **完全自托管的协作与分享**: 与官方版 Excalidraw 不同,所有的实时协作和分享功能都由您自己部署的后端服务处理,确保了数据的私密性和可控性。
- **强大的多画布管理**: 您可以轻松创建、保存和管理多个画布。数据可以存储在服务器后端(如 SQLite、S3),也可以由前端直接连接到您自己的云存储(如 Cloudflare KV),实现了真正的"数据主权"。
- **开箱即用的 AI 功能**: 无需复杂的客户端配置,登录后即可直接使用集成的 OpenAI 功能(如 GPT-4 Vision),API 密钥由后端安全管理,前端只负责调用。
![Multi-Canvas Management](./img/PixPin_2025-07-06_16-07-27.png)
![Multi-Choice Storage](./img/PixPin_2025-07-06_16-08-29.png)
![Oauth2 Login](./img/PixPin_2025-07-06_16-09-24.png)
![AI Features](./img/PixPin_2025-07-06_16-09-55.png)
## 主要特性
- **GitHub 认证**:使用 GitHub OAuth 安全登录。
- **多画板管理**:用户可以创建、保存和管理多个绘图画板。
- **灵活的数据存储 (BYOC)**
- **默认后端存储**:开箱即用地支持将画板保存在服务器的存储中(SQLite、文件系统、S3)。
- **直接云连接**:前端可以直接连接到您自己的云服务,如 **Cloudflare KV****Amazon S3**,以实现终极数据主权。您的凭证永远不会触及我们的服务器。
- **实时协作**:完全支持经典的 Excalidraw 实时协作功能。
- **安全的 OpenAI 代理**:一个可选的后端代理,用于使用 OpenAI 的 GPT-4 Vision 功能,确保您的 API 密钥安全。
- **一体化二进制文件**:整个应用程序,包括打过补丁的前端和后端服务器,都被编译成一个单一的 Go 二进制文件,便于部署。
## 前端画板存储策略
- **IndexedDB**: 一种快速、安全且可扩展的键值存储。无需任何配置,也无需登录。
- **后端存储**: 后端可以将画板保存到服务器的存储中(SQLite、文件系统、S3)。可在不同设备间同步。
- **Cloudflare KV**: 一种快速、安全且可扩展的键值存储。这需要您在自己的 Cloudflare 账户中部署一个配套的 Worker。请参阅 [**Cloudflare Worker 部署指南**](./cloudflare-worker/README.md) 获取详细说明。
- **Amazon S3**: 一种可靠、可扩展且经济的对象存储服务。
## 安装与运行
一键 Docker 运行 [Excalidraw-Full](https://github.com/BetterAndBetterII/excalidraw-full).
```bash
# Linux 示例
git clone https://github.com/BetterAndBetterII/excalidraw-full.git
cd excalidraw-full
mv .env.example .env
touch ./excalidraw.db # 重要:初始化 SQLite 数据库,否则无法启动
docker compose up -d
```
服务器将启动,您可以在 `http://localhost:3002` 访问该应用。
<!-- Summary Folded -->
<details>
<summary>使用简单密码认证(Dex OIDC)</summary>
```bash
# 示例
git clone https://github.com/BetterAndBetterII/excalidraw-full.git
cd excalidraw-full
mv .env.example.dex .env
touch ./excalidraw.db # 重要:初始化 SQLite 数据库,否则无法启动
docker compose -f docker-compose.dex.yml up -d
```
修改 `.env` 文件中的密码。
```bash
# apt install apache2-utils
# 生成密码哈希
echo YOUR_NEW_PASSWORD | htpasswd -BinC 10 admin | cut -d: -f2 > .htpasswd
# 更新 .env 文件
sed -i "s|ADMIN_PASSWORD_HASH=.*|ADMIN_PASSWORD_HASH='$(cat .htpasswd)'|" .env
```
</details>
## 配置
配置通过环境变量进行管理。有关完整模板,请参阅下面的 `.env.example` 部分。
### 1. 后端配置 (必需)
您必须配置 GitHub OAuth 和 JWT 密钥才能使应用程序正常运行。
- `GITHUB_CLIENT_ID`: 您的 GitHub OAuth App 的 Client ID。
- `GITHUB_CLIENT_SECRET`: 您的 GitHub OAuth App 的 Client Secret。
- `GITHUB_REDIRECT_URL`: 回调 URL。对于本地测试,这是 `http://localhost:3002/auth/callback`
- `JWT_SECRET`: 用于签署会话令牌的强随机字符串。使用 `openssl rand -base64 32` 生成一个。
- `OPENAI_API_KEY`: 您在 OpenAI 的秘密密钥。
- `OPENAI_BASE_URL`: (可选) 用于使用兼容的 API,如 Azure OpenAI。
### 2. 默认存储 (可选,但推荐)
这会配置服务器的内置存储,默认使用。
- `STORAGE_TYPE`: `memory` (默认), `sqlite`, `filesystem`, 或 `s3`
- `DATA_SOURCE_NAME`: SQLite 数据库的路径 (例如, `excalidraw.db`)。
- `LOCAL_STORAGE_PATH`: 文件系统存储的目录。
- `S3_BUCKET_NAME`, `AWS_REGION`, 等: 用于 S3 存储。
### 3. OpenAI 代理 (可选)
要启用 AI 功能,请设置您的 OpenAI API 密钥。
- `OPENAI_API_KEY`: 您在 OpenAI 的秘密密钥。
- `OPENAI_BASE_URL`: (可选) 用于使用兼容的 API,如 Azure OpenAI。
### 4. 前端配置
前端存储适配器(如 Cloudflare KV, S3)在您登录后直接在应用程序的 UI 设置中配置。这是特意设计的:您的私有云凭证只存储在浏览器的会话中,绝不会发送到后端服务器。
### `.env.example` 示例
在项目根目录中创建一个 `.env` 文件,并添加以下内容,填入您自己的值。
```env
# 后端服务器配置
# 从 https://github.com/settings/developers 获取
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_REDIRECT_URL=http://localhost:3002/auth/callback
# 使用以下命令生成: openssl rand -base64 32
JWT_SECRET=your_super_secret_jwt_string
# 默认存储 (SQLite)
STORAGE_TYPE=sqlite
DATA_SOURCE_NAME=excalidraw.db
# 可选的 OpenAI 代理
OPENAI_API_KEY=sk-your_openai_api_key
```
## 从源码构建
过程与之前类似,但现在需要构建 Go 后端。
### 使用 Docker (推荐)
```bash
# 克隆仓库及其子模块
git clone https://github.com/PatWie/excalidraw-complete.git --recursive
cd excalidraw-complete
# 构建 Docker 镜像
# 此过程会处理前端构建、打补丁和 Go 后端编译。
docker build -t excalidraw-complete -f excalidraw-complete.Dockerfile .
# 运行容器,并提供环境变量
docker run -p 3002:3002 \
-e GITHUB_CLIENT_ID="your_id" \
-e GITHUB_CLIENT_SECRET="your_secret" \
-e GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" \
-e JWT_SECRET="your_jwt_secret" \
-e STORAGE_TYPE="sqlite" \
-e DATA_SOURCE_NAME="excalidraw.db" \
-e OPENAI_API_KEY="your_openai_api_key" \
excalidraw-complete
```
### 手动构建
1. **构建前端**: 按照原始 README 中的步骤,在 `excalidraw/` 子模块内打补丁并构建 Excalidraw 前端。
2. **复制前端**: 确保将 `excalidraw/excalidraw-app/build` 中构建好的前端文件复制到根目录的 `frontend/` 目录下。
3. **构建 Go 后端**:
```bash
go build -o excalidraw-complete main.go
```
4. **运行**:
```bash
# 首先设置环境变量
./excalidraw-complete
```
---
Excalidraw 是一个很棒的工具。该项目旨在让每个人都能使用一个功能强大、数据安全的版本。欢迎贡献!
+184
View File
@@ -0,0 +1,184 @@
# Excalidraw FULL - Project Gap Analysis
Date: 2026-04-24
Scope: Compare current implementation against `project.md` spec and `plus-roadmap.md`
---
## Status Overview
| Milestone | Status |
|-----------|--------|
| Phase 1: Core auth + session | Done |
| Phase 2: Team + drawing model | Done |
| Phase 3: Revisions + permissions | Done |
| Phase 4: Dashboard + file browser | Done |
| Phase 5: Search + command palette | Done |
| Phase 6: Release readiness | Done (core) |
---
## Backend: What Is Working
- **Auth**: Password + bcrypt(12), session cookies, GitHub OAuth, OIDC
- **Teams**: Create, list, members, invites, accept
- **Drawings**: CRUD + archive, team-scoped, permission checks
- **Revisions**: Immutable snapshots with content_hash, auto-save API ready
- **Permissions**: Explicit grants + inheritance matrix
- **Share links**: Token-based, unauthenticated read works
- **Embeds**: URL validation rejects unsafe schemes
- **Activity feed**: Full audit trail with actor hydration
- **Templates**: 4 system templates seeded (empty, kanban, flowchart, meeting)
- **Stats**: `WorkspaceStats` API computes real counts (teams, members, projects, folders, drawings, templates, revisions, assets, storage_bytes)
- **Tests**: 11 tests, all pass (auth, team access, drawing CRUD, revisions, sharing, embeds)
- **Security headers**: CSP, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy
- **Rate limiting**: Auth endpoints 10 req / 15 min per IP
---
## Backend: Critical Gaps
| Gap | Severity | Detail |
|-----|----------|--------|
| **SQLite only** | P1 | Spec says PostgreSQL target. Schema is SQLite-specific (`?` placeholders). No migration path. |
| **No thumbnail generation** | P2 | Column `thumbnail_asset_id` exists but unused. |
| **No i18n backend** | P3 | Spec requires locale-aware API. Currently hardcoded English errors. |
## Backend: Fixed in this cycle
| Gap | Status | Notes |
|-----|--------|-------|
| Env validation on boot | Fixed | `JWT_SECRET` fail-fast added; `STORAGE_TYPE`, OAuth/OIDC completeness validated |
| Old anonymous document routes | Fixed | `/api/v2/*` routes removed from `main.go` |
| CORS on Socket.IO | Fixed | `opts.SetCors` now uses `strings.Join(allowedOrigins(), ",")` |
| No search endpoints | Fixed | `SearchDrawings` in store + `/api/search` handler wired to Header |
| No permission matrix tests | Fixed | 4 test suites covering role × resource × action matrix, admin management, non-member isolation, inheritance |
---
## Frontend: What Is Working
- **Vite + React + TypeScript** build pipeline
- **Routing**: Dashboard, FileBrowser, Editor, TeamSettings, UserSettings, Templates, Auth
- **Zustand stores**: authStore, drawingStore, teamStore
- **API layer**: Typed fetch wrapper for all workspace endpoints
- **Editor**: Excalidraw canvas with auto-save via revisions API
- **Dashboard**: Lists real drawings, create button works, user greeting
- **FileBrowser**: Page scaffold exists
- **Auth pages**: Login + signup with API integration
---
## Frontend: Fixed in this cycle
| Gap | Status | Notes |
|-----|--------|-------|
| i18n missing | Fixed | `react-i18next` + `i18next-browser-languagedetector` wired; all UI strings extracted to `en.json` |
| Dashboard stats hardcoded | Fixed | Dashboard wired to `/stats` API via `useStats` hook |
| URL structure flat | Fixed | Added `/folder/:folderId/drawing/:drawingId` route |
| No revision browser in Editor | Fixed | Collapsible panel with click-to-restore per revision |
| No command palette | Fixed | Global `Cmd/Ctrl+K` modal with fuzzy command search |
| No dark mode toggle | Fixed | `useThemeStore` (Zustand persist) + `data-theme="dark"` CSS variables |
| No search endpoints | Fixed | `/api/search?q=` endpoint + live Header search dropdown |
## Frontend: Remaining Gaps
| Gap | Severity | Detail |
|-----|----------|--------|
| **No responsive layout tested** | P2 | CSS modules exist, no mobile breakpoint verification. |
| **No a11y audit** | P2 | No ARIA labels on custom components. |
| **No template gallery creation** | P2 | Can list templates, cannot create user/team templates. |
---
## Docs / DevEx Gaps
| Gap | Severity | Detail |
|-----|----------|--------|
| **No CONTRIBUTING.md** | P3 | No contributor guidelines or development setup docs. |
## Docs / DevEx: Fixed in this cycle
| Gap | Status | Notes |
|-----|--------|-------|
| README outdated | Fixed | Rewritten to describe production-grade visual workspace |
| No Makefile | Fixed | `make build`, `make test`, `make dev`, `make docker-up` targets |
| .env.example Chinese text | Fixed | Removed all Chinese text, now all-English |
| docker-compose.yml | Fixed | Uses `excalidraw-full.Dockerfile`, proper volume mounts |
| Dockerfile | Fixed | Multi-stage: Node frontend + Go backend, embeds dist into binary |
| No CONTRIBUTING.md | Fixed | Created with dev setup, build/test instructions, and conventions |
| No OpenAPI spec | Fixed | Full spec in `openapi.yaml` with all 40+ endpoints and schemas |
| No generated TS client | Fixed | `make generate-api-client` target using `openapi-typescript` |
---
## `plus-roadmap.md` Integration
Backlog items that align with spec and can be prioritized:
| Item | Status | Action |
|------|--------|--------|
| Nesting with folders | Partial | Schema exists, UI thin. |
| Shared library | Not started | Could use `workspace_templates` + `scope=team`. |
| SSO | Partial | OIDC already wired in auth.go. |
| Better scene filtering | Not started | Requires search backend. |
| Command palette for whole app | Done | Global `Cmd+K` modal wired with navigation commands |
| Self-hosting | Done | Multi-stage Dockerfile builds new React frontend, embeds into Go binary |
In Progress items partially done:
| Item | Status |
|------|--------|
| Fulltext search | Done | `/api/search?q=` backend + live Header dropdown |
| Versioning | Done | Revision browser panel in Editor with click-to-restore |
| Public API | Done | OpenAPI spec in `openapi.yaml`; TS client via `make generate-api-client` |
---
## Recommendations
### Immediate (this session)
1. Fix `.env.example` (remove Chinese, add all vars) — Done
2. Rewrite `README.md` to match new product vision — Done
3. Add `Makefile` with build/test/dev targets — Done
4. Fix `docker-compose.yml` to build local image — Done
5. Fix `Dockerfile` to build new React frontend — Done
6. Wire Dashboard stats to real `/stats` API — Done
7. Update routing: `/folder/:folderId/drawing/:drawingId` — Done
8. Add env validation on boot — Done
9. Remove/deprecate old anonymous document routes — Done
10. Cleanup `.gitignore` — Done
### Short term (completed)
1. Add `react-i18next` foundation, extract all hardcoded strings — Done
2. Add revision browser in Editor — Done
3. Add command palette foundation — Done
4. Add env validation for all required vars — Done
5. Dark mode toggle on app shell — Done
### Remaining for full release readiness
1. Add responsive layout verification
2. Add ARIA labels / a11y audit
3. Template gallery creation (user/team templates)
4. PostgreSQL migration (keep SQLite for dev via build tag)
5. Thumbnail generation pipeline
6. Frontend unit / E2E tests (Playwright/Vitest)
---
## Test Coverage
| Layer | Coverage | Note |
|-------|----------|------|
| workspace/http_test.go | auth, team access, drawing CRUD, revisions, templates, activity, health | 11 tests, all pass |
| workspace/oauth_test.go | OAuth identity upsert | 1 test |
| workspace/sharing_test.go | invites, grants, share links, embed URL validation, assets, links | 4 tests |
| workspace/permissions_test.go | role × resource × action matrix, admin mgmt, non-member isolation, inheritance | 4 suites |
| Frontend tests | None | No test framework configured |
| E2E tests | None | No Playwright/Cypress |
---
## Verdict
**Current milestone: ~Milestone 3.0** — Backend domain model, auth, permissions, API, and core frontend features (i18n, search, command palette, revision browser, dark mode) are production-grade. Remaining gaps: OpenAPI spec, responsive testing, a11y, template gallery, and frontend test coverage. Release-ready for self-hosting with Docker.
+695
View File
@@ -0,0 +1,695 @@
openapi: 3.1.0
info:
title: Excalidraw Full Workspace API
version: 0.1.0
description: Backend-owned workspace API for auth, teams, drawings, folders, projects, templates, revisions, and activity.
servers:
- url: /api
security:
- cookieSession: []
components:
securitySchemes:
cookieSession:
type: apiKey
in: cookie
name: excalidraw_session
schemas:
Error:
type: object
required: [error]
properties:
error:
type: string
User:
type: object
required: [id, name, username, email, locale, timezone, created_at, updated_at]
properties:
id: { type: string }
name: { type: string }
username: { type: string }
email: { type: string, format: email }
avatar_url: { type: [string, "null"] }
locale: { type: string }
timezone: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Session:
type: object
required: [id, user_id, expires_at, created_at]
properties:
id: { type: string }
user_id: { type: string }
expires_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
Team:
type: object
required: [id, name, slug, owner_user_id, plan_type, created_at, updated_at]
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
owner_user_id: { type: string }
plan_type: { type: string, enum: [free, pro] }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
TeamMembership:
type: object
required: [id, team_id, user_id, role, joined_at]
properties:
id: { type: string }
team_id: { type: string }
user_id: { type: string }
role: { type: string, enum: [owner, admin, editor, viewer] }
joined_at: { type: string, format: date-time }
user:
$ref: "#/components/schemas/User"
Drawing:
type: object
required: [id, team_id, title, owner_user_id, visibility, is_archived, created_at, updated_at]
properties:
id: { type: string }
team_id: { type: string }
folder_id: { type: [string, "null"] }
project_id: { type: [string, "null"] }
slug: { type: [string, "null"] }
title: { type: string }
description: { type: [string, "null"] }
owner_user_id: { type: string }
latest_revision_id: { type: [string, "null"] }
visibility: { type: string, enum: [private, team, restricted, public-link] }
is_archived: { type: boolean }
thumbnail_asset_id: { type: [string, "null"] }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: [string, "null"], format: date-time }
owner:
$ref: "#/components/schemas/User"
DrawingRevision:
type: object
required: [id, drawing_id, revision_number, snapshot_path, snapshot_size, content_hash, created_by, created_at]
properties:
id: { type: string }
drawing_id: { type: string }
revision_number: { type: integer }
snapshot_path: { type: string }
snapshot_size: { type: integer, format: int64 }
content_hash: { type: string }
created_by: { type: string }
created_at: { type: string, format: date-time }
change_summary: { type: [string, "null"] }
snapshot: {}
Template:
type: object
required: [id, scope, type, name, snapshot_path, metadata_json, created_by, created_at, updated_at]
properties:
id: { type: string }
team_id: { type: [string, "null"] }
scope: { type: string, enum: [system, team, personal] }
type: { type: string }
name: { type: string }
description: { type: [string, "null"] }
snapshot_path: { type: string }
metadata_json: { type: object, additionalProperties: true }
created_by: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
ActivityEvent:
type: object
required: [id, resource_type, resource_id, event_type, metadata_json, created_at]
properties:
id: { type: string }
actor_user_id: { type: [string, "null"] }
team_id: { type: [string, "null"] }
resource_type: { type: string }
resource_id: { type: string }
event_type: { type: string }
metadata_json: { type: object, additionalProperties: true }
created_at: { type: string, format: date-time }
actor:
$ref: "#/components/schemas/User"
TeamInvite:
type: object
required: [id, team_id, email, role, invited_by, expires_at, created_at]
properties:
id: { type: string }
team_id: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [admin, editor, viewer] }
invited_by: { type: string }
expires_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
PermissionGrant:
type: object
required: [id, resource_type, resource_id, subject_type, subject_id, permission, created_at]
properties:
id: { type: string }
resource_type: { type: string }
resource_id: { type: string }
subject_type: { type: string, enum: [user, team, link] }
subject_id: { type: string }
permission: { type: string, enum: [view, comment, edit, manage, share, invite] }
inherited_from: { type: [string, "null"] }
created_at: { type: string, format: date-time }
ShareLink:
type: object
required: [id, resource_type, resource_id, permission, created_by, created_at]
properties:
id: { type: string }
resource_type: { type: string, enum: [drawing, folder, project] }
resource_id: { type: string }
permission: { type: string, enum: [view, comment, edit] }
expires_at: { type: [string, "null"], format: date-time }
created_by: { type: string }
revoked_at: { type: [string, "null"], format: date-time }
created_at: { type: string, format: date-time }
DrawingAsset:
type: object
required: [id, drawing_id, kind, path, mime_type, size, uploaded_by, created_at]
properties:
id: { type: string }
drawing_id: { type: string }
kind: { type: string, enum: [image, export, attachment, thumbnail] }
path: { type: string }
mime_type: { type: string }
size: { type: integer, format: int64 }
width: { type: [integer, "null"] }
height: { type: [integer, "null"] }
uploaded_by: { type: string }
created_at: { type: string, format: date-time }
Embed:
type: object
required: [id, drawing_id, source_url, canonical_url, provider, embed_type, created_by, created_at]
properties:
id: { type: string }
drawing_id: { type: string }
source_url: { type: string, format: uri }
canonical_url: { type: string, format: uri }
provider: { type: string }
embed_type: { type: string, enum: [link, iframe, provider] }
title: { type: [string, "null"] }
preview_asset_id: { type: [string, "null"] }
safe_embed_html: { type: [string, "null"] }
created_by: { type: string }
created_at: { type: string, format: date-time }
LinkReference:
type: object
required: [id, source_resource_type, source_resource_id, target_resource_type, target_resource_id, created_by, created_at]
properties:
id: { type: string }
source_resource_type: { type: string }
source_resource_id: { type: string }
target_resource_type: { type: string, enum: [drawing, folder, project, embed] }
target_resource_id: { type: string }
label: { type: [string, "null"] }
created_by: { type: string }
created_at: { type: string, format: date-time }
WorkspaceStats:
type: object
required: [teams, members, projects, folders, drawings, templates, revisions, assets, storage_bytes]
properties:
teams: { type: integer }
members: { type: integer }
projects: { type: integer }
folders: { type: integer }
drawings: { type: integer }
templates: { type: integer }
revisions: { type: integer }
assets: { type: integer }
storage_bytes: { type: integer, format: int64 }
Project:
type: object
required: [id, team_id, name, slug, created_by, created_at, updated_at]
properties:
id: { type: string }
team_id: { type: string }
name: { type: string }
slug: { type: string }
description: { type: [string, "null"] }
created_by: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Folder:
type: object
required: [id, team_id, name, slug, path_cache, visibility, created_by, created_at, updated_at]
properties:
id: { type: string }
team_id: { type: string }
project_id: { type: [string, "null"] }
parent_folder_id: { type: [string, "null"] }
name: { type: string }
slug: { type: string }
path_cache: { type: string }
visibility: { type: string, enum: [private, team] }
created_by: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
paths:
/health:
get:
security: []
summary: Health check
responses:
"200":
description: Backend healthy
"503":
description: Backend unhealthy
/auth/signup:
post:
security: []
summary: Create password account
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, email, password]
properties:
name: { type: string, minLength: 1, maxLength: 120 }
email: { type: string, format: email }
password: { type: string, minLength: 8, maxLength: 128 }
responses:
"201":
description: Signed up
"409":
description: Email already exists
/auth/login:
post:
security: []
summary: Login with email and password
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: { type: string, format: email }
password: { type: string }
responses:
"200":
description: Logged in
"401":
description: Invalid credentials
/auth/logout:
post:
summary: Revoke current session
responses:
"200":
description: Logged out
/auth/me:
get:
summary: Current user
responses:
"200":
description: Current user
content:
application/json:
schema:
$ref: "#/components/schemas/User"
/teams:
get:
summary: List accessible teams
responses:
"200":
description: Teams
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Team"
post:
summary: Create team
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string }
slug: { type: string }
responses:
"201":
description: Created team
/teams/{teamID}/members:
get:
summary: List team members
parameters:
- in: path
name: teamID
required: true
schema: { type: string }
responses:
"200":
description: Members
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TeamMembership"
/teams/{teamID}/invites:
get:
summary: List pending team invites
parameters:
- in: path
name: teamID
required: true
schema: { type: string }
responses:
"200":
description: Pending invites
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TeamInvite"
post:
summary: Create team invite
parameters:
- in: path
name: teamID
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, role]
properties:
email: { type: string, format: email }
role: { type: string, enum: [admin, editor, viewer] }
responses:
"201":
description: Invite and one-time token
/invites/accept:
post:
summary: Accept invite token as current user
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token]
properties:
token: { type: string }
responses:
"200":
description: Accepted membership
/drawings:
get:
summary: List drawings visible to current user
parameters:
- in: query
name: team_id
schema: { type: string }
responses:
"200":
description: Drawings
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Drawing"
post:
summary: Create drawing
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title]
properties:
team_id: { type: [string, "null"] }
folder_id: { type: [string, "null"] }
project_id: { type: [string, "null"] }
title: { type: string }
description: { type: [string, "null"] }
visibility: { type: string }
snapshot: {}
responses:
"201":
description: Created drawing
/drawings/{drawingID}:
get:
summary: Get drawing metadata
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Drawing
content:
application/json:
schema:
$ref: "#/components/schemas/Drawing"
patch:
summary: Update drawing metadata
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Updated drawing
delete:
summary: Archive drawing
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"204":
description: Archived
/drawings/{drawingID}/revisions:
get:
summary: List drawing revisions
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Revisions
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/DrawingRevision"
post:
summary: Create immutable drawing revision
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"201":
description: Revision created
/drawings/{drawingID}/permissions:
get:
summary: List explicit drawing permissions
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Permission grants
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/PermissionGrant"
post:
summary: Grant explicit drawing permission
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"201":
description: Created permission grant
/drawings/{drawingID}/share-links:
get:
summary: List active drawing share links
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Share links without token hashes
post:
summary: Create drawing share link
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"201":
description: Created share link and one-time token
/shared/{token}:
get:
security: []
summary: Resolve public share token
parameters:
- in: path
name: token
required: true
schema: { type: string }
responses:
"200":
description: Shared resource payload
/drawings/{drawingID}/assets:
get:
summary: List drawing asset metadata
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Assets
post:
summary: Create drawing asset metadata
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"201":
description: Created asset metadata
/drawings/{drawingID}/embeds:
get:
summary: List drawing embeds
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Embeds
post:
summary: Create safe drawing embed metadata
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"201":
description: Created embed metadata
/drawings/{drawingID}/links:
get:
summary: List drawing link references
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"200":
description: Link references
post:
summary: Create drawing link reference
parameters:
- in: path
name: drawingID
required: true
schema: { type: string }
responses:
"201":
description: Created link reference
/templates:
get:
summary: List system and accessible team templates
parameters:
- in: query
name: team_id
schema: { type: string }
responses:
"200":
description: Templates
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Template"
/activity:
get:
summary: List recent activity for accessible teams
parameters:
- in: query
name: team_id
schema: { type: string }
responses:
"200":
description: Activity
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/ActivityEvent"
/stats:
get:
summary: Workspace counts and storage usage
parameters:
- in: query
name: team_id
schema: { type: string }
responses:
"200":
description: Workspace stats
content:
application/json:
schema:
$ref: "#/components/schemas/WorkspaceStats"
/folders:
get:
summary: List folders
responses:
"200":
description: Folders
post:
summary: Create folder
responses:
"201":
description: Created folder
/projects:
get:
summary: List projects
responses:
"200":
description: Projects
post:
summary: Create project
responses:
"201":
description: Created project
+30 -4
View File
@@ -1,5 +1,3 @@
version: '3.8'
services: services:
netpod: netpod:
image: busybox:latest image: busybox:latest
@@ -34,17 +32,45 @@ services:
start_period: 10s start_period: 10s
network_mode: service:netpod network_mode: service:netpod
postgres:
image: postgres:16-alpine
container_name: excalidraw-dex-postgres
restart: unless-stopped
environment:
POSTGRES_USER: excalidraw
POSTGRES_PASSWORD: excalidraw
POSTGRES_DB: excalidraw
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U excalidraw -d excalidraw"]
interval: 5s
timeout: 5s
retries: 5
networks:
- excalidraw-network
excalidraw: excalidraw:
image: ghcr.io/betterandbetterii/excalidraw-full:latest image: ghcr.io/betterandbetterii/excalidraw-full:latest
environment:
- STORAGE_TYPE=${STORAGE_TYPE:-postgres}
- DATABASE_URL=${DATABASE_URL:-postgres://excalidraw:excalidraw@postgres:5432/excalidraw?sslmode=disable}
volumes: volumes:
- ./data:/root/data # NOTE: Using a named Docker volume so data is managed by Docker.
- ./excalidraw.db:/root/excalidraw.db:Z # Use `docker compose down -v` or `docker system prune -a --volumes` to remove.
- excalidraw_data:/root/data
- ./.env:/root/.env - ./.env:/root/.env
depends_on: depends_on:
dex: dex:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
network_mode: service:netpod network_mode: service:netpod
volumes:
postgres_data:
excalidraw_data:
networks: networks:
excalidraw-network: excalidraw-network:
driver: bridge driver: bridge
+36 -5
View File
@@ -1,11 +1,42 @@
version: '3.8'
services: services:
postgres:
image: postgres:16-alpine
container_name: excalidraw-postgres
restart: unless-stopped
environment:
POSTGRES_USER: excalidraw
POSTGRES_PASSWORD: excalidraw
POSTGRES_DB: excalidraw
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U excalidraw -d excalidraw"]
interval: 5s
timeout: 5s
retries: 5
excalidraw: excalidraw:
image: ghcr.io/betterandbetterii/excalidraw-full:latest build:
context: .
dockerfile: excalidraw-full.Dockerfile
ports: ports:
- "3002:3002" - "3002:3002"
volumes: volumes:
- ./data:/root/data # NOTE: Using a named Docker volume instead of a host bind mount.
- ./excalidraw.db:/root/excalidraw.db:Z # Host bind mounts (./data:/root/data) persist on your filesystem
# even after `docker compose down` or `docker system prune -a`.
# A named volume is managed by Docker and can be removed with:
# `docker compose down -v` or `docker system prune -a --volumes`.
- excalidraw_data:/root/data
- ./.env:/root/.env - ./.env:/root/.env
environment:
- LISTEN_ADDR=:3002
- STORAGE_TYPE=${STORAGE_TYPE:-postgres}
- DATABASE_URL=${DATABASE_URL:-postgres://excalidraw:excalidraw@postgres:5432/excalidraw?sslmode=disable}
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
excalidraw_data:
@@ -0,0 +1,50 @@
# Postgres Migration Design
Date: 2026-04-26
Owner: TDvorak
## Goal
Migrate Excalidraw FULL persistence from SQLite to PostgreSQL as the production and runtime database. SQLite is removed from runtime configuration and dependencies.
## Architecture
PostgreSQL is the single relational backend. The application reads `DATABASE_URL` at boot and fails fast when it is missing. `STORAGE_TYPE=postgres` is the database-backed mode for the legacy canvas/document API. Non-database object stores such as filesystem and S3 can remain available for legacy canvas flows, but the workspace product path uses PostgreSQL only.
Schema changes are versioned with embedded goose migrations. The app applies migrations at startup before stores are used.
## Schema
The migration creates the existing document, canvas, and workspace tables in PostgreSQL:
- `BLOB` becomes `BYTEA`.
- `DATETIME` becomes `TIMESTAMPTZ`.
- SQLite boolean defaults become PostgreSQL booleans.
- Existing text JSON fields remain `TEXT`; drawing snapshots remain `BYTEA` to preserve current model behavior.
- Existing uniqueness constraints and indexes are preserved.
## Code Changes
The Go backend uses `pgx` through `database/sql`. The SQLite store package is replaced by a Postgres store package. Workspace store initialization opens Postgres, applies goose migrations, seeds system templates, and uses connection pool limits suitable for the app.
All SQL placeholders use PostgreSQL `$1` form. SQLite-only boot validation, env names, and docs are removed or replaced with `DATABASE_URL`.
## Docker
`docker-compose.yml` and `docker-compose.postgres.yml` run Postgres 16 and the app. The app waits for Postgres health and receives:
- `DATABASE_URL=postgres://excalidraw:excalidraw@postgres:5432/excalidraw?sslmode=disable`
- `STORAGE_TYPE=postgres`
## Tests
Workspace tests run against PostgreSQL via `TEST_DATABASE_URL` or `DATABASE_URL`. Each test suite uses an isolated temporary schema and drops it during cleanup. When no test database URL exists, DB-dependent workspace tests skip with a clear message.
## Verification
Run:
- `go test ./...`
- `docker compose -f docker-compose.postgres.yml up --build`
- `/api/health`
- signup, drawing create, revision create, invite, sharing, and stats flows
+377
View File
@@ -0,0 +1,377 @@
# Excalidraw FULL - Frontend Design System
## Design Context
### Target Audience
- Product teams who need visual collaboration tools
- Developers creating architecture diagrams, flowcharts
- Designers wireframing and prototyping
- Educators and facilitators running workshops
- Anyone who prefers hand-drawn aesthetics over sterile diagrams
### Use Cases
- Brainstorming and ideation sessions
- System architecture and technical diagrams
- UI/UX wireframing and user flows
- Kanban boards and project planning
- Meeting notes and retrospectives
- Mind mapping and knowledge organization
### Brand Personality
**Hand-crafted technical workspace**
- Human and approachable (hand-drawn aesthetic)
- Professional but not sterile
- Creative and collaborative
- Calm, uncluttered interface
- Self-hosted, privacy-conscious
### Tone
Warm minimalism meets technical precision. The hand-drawn style softens technical content, making complex diagrams feel accessible and collaborative.
---
## Color System
### Primary Palette
```css
--color-primary: #6965db; /* Main purple */
--color-primary-darker: #5b57d1; /* Hover states */
--color-primary-darkest: #4a47b1; /* Active states */
--color-primary-light: #e3e2fe; /* Subtle backgrounds */
--color-primary-hover: #5753d0; /* Interactive hover */
```
### Neutral Palette
```css
--color-gray-10: #f5f5f5; /* Lightest background */
--color-gray-20: #ebebeb; /* Card backgrounds */
--color-gray-30: #d6d6d6; /* Borders subtle */
--color-gray-40: #b8b8b8; /* Disabled states */
--color-gray-50: #999999; /* Muted text */
--color-gray-60: #7a7a7a; /* Secondary text */
--color-gray-70: #5c5c5c; /* Body text light */
--color-gray-80: #3d3d3d; /* Body text */
--color-gray-85: #242424; /* Headings */
--color-gray-90: #1e1e1e; /* Strong text */
--color-gray-100: #121212; /* Near black */
```
### Semantic Colors
```css
--color-success: #cafccc;
--color-success-text: #268029;
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-danger: #db6965;
--color-danger-dark: #d65550;
--color-danger-text: #700000;
```
### Surface Colors (Light Mode)
```css
--island-bg-color: #ffffff;
--color-surface-low: #f8f9fa;
--color-surface-high: #e9ecef;
--color-surface-primary-container: #e3e2fe;
--color-on-surface: #1e1e1e;
--color-on-primary-container: #4a47b1;
```
---
## Typography
### Font Stack
```css
--ui-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--editor-font: "Virgil", "Cascadia", "Segoe UI", sans-serif; /* Hand-drawn feel */
```
### Type Scale
```css
--text-xs: 0.75rem; /* 12px - Captions, labels */
--text-sm: 0.875rem; /* 14px - Secondary text */
--text-base: 1rem; /* 16px - Body text */
--text-lg: 1.125rem; /* 18px - Large body */
--text-xl: 1.25rem; /* 20px - H4 */
--text-2xl: 1.5rem; /* 24px - H3 */
--text-3xl: 1.875rem; /* 30px - H2 */
--text-4xl: 2.25rem; /* 36px - H1 */
```
### Font Weights
- **400** Regular - Body text
- **500** Medium - Emphasis, labels
- **600** Semibold - Subheadings
- **700** Bold - Headings, important text
---
## Spacing System
### Base Unit
```css
--space-factor: 0.25rem; /* 4px base */
```
### Scale
```css
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
```
---
## Component Patterns
### Island Pattern (Excalidraw Signature)
Floating container with subtle shadow:
```css
.island {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
padding: var(--space-4);
}
```
### Buttons
**Primary Button**
```css
.btn-primary {
background: var(--color-primary);
color: white;
border-radius: var(--border-radius-lg);
padding: 0.625rem 1rem;
font-weight: 500;
&:hover { background: var(--color-primary-hover); }
&:active { background: var(--color-primary-darkest); }
}
```
**Secondary Button**
```css
.btn-secondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border: 1px solid var(--color-surface-high);
border-radius: var(--border-radius-lg);
&:hover { background: var(--color-surface-high); }
}
```
**Ghost Button**
```css
.btn-ghost {
background: transparent;
color: var(--color-on-surface);
&:hover { background: var(--color-surface-low); }
}
```
### Cards
**Drawing Card**
```css
.drawing-card {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
overflow: hidden;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-island-stronger);
}
}
```
### Form Inputs
**Text Input**
```css
.input {
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: 0.5rem 0.75rem;
font-size: var(--text-base);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:hover:not(:focus) {
background: var(--input-hover-bg-color);
}
}
```
---
## Layout Patterns
### App Shell Structure
```
┌─────────────────────────────────────────────────────────┐
│ Sidebar │ Header │
│ ├─────────────────────────────────────────────┤
│ │ │
│ Navigation │ Main Content │
│ │ │
│ │ │
│ │ │
└─────────────┴─────────────────────────────────────────────┘
```
### Dashboard Grid
```css
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
```
### Sidebar Navigation
```css
.sidebar {
width: 260px;
background: var(--island-bg-color);
border-right: 1px solid var(--color-gray-20);
padding: var(--space-4);
}
```
---
## Shadows & Effects
```css
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgb(0 0 0 / 18%);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.05),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.04);
```
---
## Border Radius
```css
--border-radius-sm: 0.25rem; /* 4px */
--border-radius-md: 0.375rem; /* 6px */
--border-radius-lg: 0.5rem; /* 8px */
--border-radius-xl: 0.75rem; /* 12px */
--border-radius-full: 9999px; /* Pills, avatars */
```
---
## Animation & Transitions
### Timing
```css
--duration-fast: 0.15s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
```
### Easing
```css
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
```
### Common Transitions
```css
/* Hover states */
transition: background-color var(--duration-fast) var(--ease-out);
/* Card interactions */
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
/* Modal/dialog */
transition: opacity var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
```
---
## Icon System
- 16px (default-icon-size) for inline UI
- 20px (lg-icon-size) for buttons
- 24px for navigation
- Lucide icons preferred for consistency
- Stroke width: 1.5px - 2px
---
## Responsive Breakpoints
```css
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
```
---
## Page Specifications
### Dashboard
- Grid of drawing cards with thumbnails
- Recent activity sidebar
- Quick actions header
- Team selector
### File Browser
- Folder tree sidebar
- Breadcrumb navigation
- Sortable/filterable drawing list
- Bulk actions toolbar
### Auth Pages
- Centered card layout
- Clean, minimal design
- Clear call-to-action
- Social auth buttons (GitHub)
### Editor (Canvas)
- Full-screen canvas
- Minimal surrounding UI
- Floating toolbars (island pattern)
- Collapsible side panels
### Settings
- Tabbed interface
- Sidebar navigation for sections
- Clear section headers
- Form-based layout
+74
View File
@@ -0,0 +1,74 @@
# Excalidraw FULL Frontend
Production-grade frontend for the Excalidraw FULL overhaul.
## Stack
- React 19 + TypeScript
- Vite (fast HMR, optimized builds)
- SCSS Modules (scoped styles)
- Zustand (state management)
- React Router (routing)
- Lucide React (icons)
## Design System
Excalidraw's hand-drawn aesthetic preserved:
- **Island UI**: Floating panels with subtle shadows
- **Primary**: Purple (`#6965db`)
- **Typography**: Inter font, clear hierarchy
- **Shadows**: Multi-layer island shadows
- **Radii**: Consistent 6-12px rounding
## Pages
| Route | Page | Features |
|-------|------|----------|
| `/` | Dashboard | Stats, recent drawings, activity, templates |
| `/login` | Login | Email/password + GitHub OAuth |
| `/signup` | Signup | Account creation |
| `/files` | File Browser | Folder tree, grid/list view, drawing cards |
| `/team` | Team Settings | Members, roles, invites |
| `/settings` | User Settings | Profile, account, notifications, appearance |
| `/templates` | Templates | Gallery with categories |
| `/drawing/:id` | Editor | Canvas placeholder (integrate Excalidraw) |
## Quick Start
```bash
npm install
npm run dev # Development server at http://localhost:3000
npm run typecheck # Type checking
npm run build # Production build
```
## API Integration
Update `src/services/api.ts` endpoints to match your Go backend:
- `GET /api/auth/me` - Current user
- `POST /api/auth/login` - Login
- `GET /api/drawings` - List drawings
- `GET /api/teams` - List teams
The Vite proxy forwards `/api` to `http://localhost:3002`.
## Canvas Integration
To integrate the existing Excalidraw canvas:
1. Install package: `npm install @excalidraw/excalidraw`
2. Import in `Editor.tsx`: `import { Excalidraw } from '@excalidraw/excalidraw'`
3. Replace the placeholder div with the `<Excalidraw />` component
4. Wire up `onChange` to save via API
## Project Structure
```
src/
├── components/ # Reusable UI (Button, Input, Card, Layout)
├── pages/ # Route components
├── stores/ # Zustand state
├── services/ # API clients
├── types/ # TypeScript interfaces
└── styles/ # SCSS variables, global styles
```
+169
View File
@@ -0,0 +1,169 @@
import { test, expect } from '@playwright/test';
const BASE = 'http://localhost:3456';
// Auth: first-run signup, blocked signup, login
test.describe.serial('auth flow', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('redirects to signup when no users exist', async ({ page }) => {
await page.goto(BASE + '/');
await expect(page).toHaveURL(/\/signup$/);
await expect(page.getByRole('heading', { name: 'Create account' })).toBeVisible();
});
test('first user can signup', async ({ page }) => {
await page.goto(BASE + '/signup');
await page.getByLabel('Full Name').fill('E2E User');
await page.getByLabel('Email').fill('e2e@test.com');
await page.getByLabel('Password').fill('e2e-password-123');
await page.getByRole('button', { name: 'Create Account' }).click();
await expect(page).toHaveURL(BASE + '/');
await expect(page.getByText(/Welcome back/)).toBeVisible();
await page.context().storageState({ path: 'playwright/.auth/state.json' });
});
test('blocks second signup when users exist', async ({ page }) => {
await page.goto(BASE + '/signup');
await expect(page).toHaveURL(/\/login$/);
});
test('existing user can login', async ({ page }) => {
await page.goto(BASE + '/login');
await page.getByLabel('Email').fill('e2e@test.com');
await page.getByLabel('Password').fill('e2e-password-123');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page).toHaveURL(BASE + '/');
await expect(page.getByText(/Welcome back/)).toBeVisible();
});
});
// Dashboard: quick actions and stats
test.describe.serial('dashboard', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('shows stats cards', async ({ page }) => {
await page.goto(BASE + '/');
await expect(page.getByText('Drawings')).toBeVisible();
await expect(page.getByText('Projects')).toBeVisible();
await expect(page.getByText('Teams')).toBeVisible();
});
test('quick action: New Project navigates to files', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Project' }).click();
await expect(page).toHaveURL(/\/files/);
await expect(page.getByRole('navigation', { name: 'Project tree' })).toBeVisible();
await expect(page.getByText('All Projects')).toBeVisible();
});
test('quick action: Invite navigates to team', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'Invite' }).click();
await expect(page).toHaveURL(/\/team/);
await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
});
test('quick action: Library navigates to marketplace', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'Library' }).click();
await expect(page).toHaveURL(/\/library/);
await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
});
test('New Drawing opens template picker', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Choose a Template' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Blank Canvas' })).toBeVisible();
await expect(page.getByRole('button', { name: 'To-Do List' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Checklist' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Bullet List' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Flow Chart' })).toBeVisible();
});
});
// Projects / FileBrowser
test.describe.serial('projects', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('shows Projects label in sidebar and breadcrumb', async ({ page }) => {
await page.goto(BASE + '/files');
await expect(page.getByRole('navigation', { name: 'Main navigation' }).getByText('Projects')).toBeVisible();
await expect(page.getByText('All Projects')).toBeVisible();
});
test('can create a drawing from file browser', async ({ page }) => {
await page.goto(BASE + '/files');
await page.getByRole('button', { name: 'Create new drawing' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Blank Canvas' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByText('Loading Excalidraw')).toBeVisible();
});
});
// Editor / Canvas
test.describe.serial('editor', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('creates drawing with To-Do template', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await page.getByRole('button', { name: 'To-Do List' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
});
test('editor shows save controls and back button', async ({ page }) => {
await page.goto(BASE + '/');
await page.getByRole('button', { name: 'New Drawing' }).click();
await page.getByRole('button', { name: 'Blank Canvas' }).click();
await expect(page).toHaveURL(/\/drawing\//);
await expect(page.getByRole('button', { name: /Save Now/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: /Back/i })).toBeVisible();
});
});
// Library Marketplace
test.describe.serial('library', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('loads marketplace with search and categories', async ({ page }) => {
await page.goto(BASE + '/library');
await expect(page.getByRole('heading', { name: 'Library Marketplace' })).toBeVisible();
await expect(page.getByPlaceholder('Search libraries...')).toBeVisible();
await expect(page.getByRole('button', { name: 'All' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Open External' })).toBeVisible();
});
test('search filters libraries', async ({ page }) => {
await page.goto(BASE + '/library');
await page.getByPlaceholder('Search libraries...').fill('zzzznonexistent');
await expect(page.getByText('No libraries found')).toBeVisible();
});
});
// Team / Invites
test.describe.serial('team', () => {
test.use({ storageState: 'playwright/.auth/state.json' });
test('shows owner in members list', async ({ page }) => {
await page.goto(BASE + '/team');
await expect(page.getByRole('heading', { name: 'Team Settings' })).toBeVisible();
await expect(page.getByText('E2E User')).toBeVisible();
await expect(page.getByText('owner')).toBeVisible();
});
test('can send team invite', async ({ page }) => {
await page.goto(BASE + '/team');
await page.getByLabel('Email address').fill('invited@test.com');
await page.locator('select').selectOption('editor');
await page.getByRole('button', { name: 'Send Invite' }).click();
await expect(page.getByText('Invite sent!')).toBeVisible();
await expect(page.getByText('Pending Invites')).toBeVisible();
await expect(page.getByText('invited@test.com')).toBeVisible();
await expect(page.getByText('editor').first()).toBeVisible();
});
});
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Excalidraw FULL</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3879
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "excalidraw-full-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.17.6",
"clsx": "^2.1.0",
"date-fns": "^4.1.0",
"i18next": "^26.0.7",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^7.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "1.52",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.0",
"@types/node": "^25.6.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.0",
"sass": "^1.81.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
/// <reference types="node" />
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
baseURL: 'http://localhost:3456',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'cd .. && (test -d frontend/dist/assets || (cd frontend && npm run build)) && JWT_SECRET=playwright-test-secret-32-chars-long-go EXCALIDRAW_BACKEND_HOST=localhost:3456 STORAGE_TYPE=postgres DATABASE_URL="${TEST_DATABASE_URL:-${DATABASE_URL:-postgres://excalidraw:excalidraw@localhost:5432/excalidraw?sslmode=disable}}" /tmp/excalidraw-e2e -listen :3456 -loglevel error',
url: 'http://localhost:3456',
timeout: 120 * 1000,
reuseExistingServer: false,
},
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000">
<rect width="1000" height="1000" rx="200" ry="200" fill="#fff" />
<svg viewBox="0 0 107 101" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
<path style="fill:none" d="M24 17h121v121H24z" transform="matrix(.8843 0 0 .83471 -21.223 -14.19)" />
<path d="M119.81 105.98a.549.549 0 0 0-.53-.12c-4.19-6.19-9.52-12.06-14.68-17.73l-.85-.93c0-.11-.05-.21-.12-.3a.548.548 0 0 0-.34-.2l-.17-.18-.12-.09c-.15-.32-.53-.56-.95-.35-1.58.81-3 1.97-4.4 3.04-1.87 1.43-3.7 2.92-5.42 4.52-.7.65-1.39 1.33-1.97 2.09-.28.37-.07.72.27.87-1.22 1.2-2.45 2.45-3.68 3.74-.11.12-.17.28-.16.44.01.16.09.31.22.41l2.16 1.65s.01.03.03.04c3.09 3.05 8.51 7.28 14.25 11.76.85.67 1.71 1.34 2.57 2.01.39.47.76.94 1.12 1.4.19.25.55.3.8.11.13.1.26.21.39.31a.57.57 0 0 0 .8-.1c.07-.09.1-.2.11-.31.04 0 .07.03.1.03.15 0 .31-.06.42-.18l10.18-11.12a.56.56 0 0 0-.04-.8l.01-.01Zm-29.23-3.85c.07.09.14.17.21.25 1.16.98 2.4 2.04 3.66 3.12l-5.12-3.91s-.32-.22-.52-.36c-.11-.08-.21-.16-.31-.24l-.38-.32s.07-.07.1-.11l.35-.35c1.72-1.74 4.67-4.64 6.19-6.06-1.61 1.62-4.87 6.37-4.17 7.98h-.01Zm17.53 13.81-4.22-3.22c-1.65-1.71-3.43-3.4-5.24-5.03 2.28 1.76 4.23 3.25 4.52 3.51 2.21 1.97 2.11 1.61 3.63 2.91l1.83 1.33c-.18.16-.36.33-.53.49l.01.01Zm1.06.81-.08-.06c.16-.13.33-.25.49-.38l-.4.44h-.01ZM42.24 51.45c.14.72.27 1.43.4 2.11.69 3.7 1.33 7.03 2.55 9.56l.48 1.92c.19.73.46 1.64.71 1.83 2.85 2.52 7.22 6.28 11.89 9.82.21.16.5.15.7-.01.01.02.03.03.04.04.11.1.24.15.38.15.16 0 .31-.06.42-.19 5.98-6.65 10.43-12.12 13.6-16.7.2-.25.3-.54.29-.84.2-.24.41-.48.6-.68a.558.558 0 0 0-.1-.86.578.578 0 0 0-.17-.36c-1.39-1.34-2.42-2.31-3.46-3.28-1.84-1.72-3.74-3.5-7.77-7.51-.02-.02-.05-.04-.07-.06a.555.555 0 0 0-.22-.14c-1.11-.39-3.39-.78-6.26-1.28-4.22-.72-10-1.72-15.2-3.27h-.04v-.01s-.02 0-.03.02h-.01l.04-.02s-.31.01-.37.04c-.08.04-.14.09-.19.15-.05.06-.09.12-.47.2-.38.08.08 0 .11 0h-.11v.03c.07.34.05.58.16.97-.02.1.21 1.02.24 1.11l1.83 7.26h.03Zm30.95 6.54s-.03.04-.04.05l-.64-.71c.22.21.44.42.68.66Zm-7.09 9.39s-.07.08-.1.12l-.02-.02c.04-.03.08-.07.13-.1h-.01Zm-7.07 8.47Zm3.02-28.57c.35.35 1.74 1.65 2.06 1.97-1.45-.66-5.06-2.34-6.74-2.88 1.65.29 3.93.66 4.68.91Zm-19.18-2.77c.84 1.44 1.5 6.49 2.16 11.4-.37-1.58-.69-3.12-.99-4.6-.52-2.56-1-4.85-1.67-6.88.14.01.31.03.49.05 0 .01 0 .02.02.03h-.01Zm-.29-1.21c-.23-.02-.44-.04-.62-.05-.02-.04-.03-.08-.04-.12l.66.18v-.01Zm-2.22.45v-.02.02ZM118.9 42.57c.04-.23-1.1-1.24-.74-1.26.85-.04.86-1.35 0-1.31-1.13.06-2.27.32-3.37.53-1.98.37-3.95.78-5.92 1.21-4.39.94-8.77 1.93-13.1 3.11-1.36.37-2.86.7-4.11 1.36-.42.22-.4.67-.17.95-.09.05-.18.08-.28.09-.37.07-.74.13-1.11.19a.566.566 0 0 0-.39.86c-2.32 3.1-4.96 6.44-7.82 9.95-2.81 3.21-5.73 6.63-8.72 10.14-9.41 11.06-20.08 23.6-31.9 34.64-.23.21-.24.57-.03.8.05.06.12.1.19.13-.16.15-.32.3-.48.44-.1.09-.14.2-.16.32-.08.08-.16.17-.23.25-.21.23-.2.59.03.8.23.21.59.2.8-.03.04-.04.08-.09.12-.13a.84.84 0 0 1 1.22 0c.69.74 1.34 1.44 1.95 2.09l-1.38-1.15a.57.57 0 0 0-.8.07c-.2.24-.17.6.07.8l14.82 12.43c.11.09.24.13.37.13.15 0 .29-.06.4-.17l.36-.36a.56.56 0 0 0 .63-.12c20.09-20.18 36.27-35.43 54.8-49.06.17-.12.25-.32.23-.51a.57.57 0 0 0 .48-.39c3.42-10.46 4.08-19.72 4.28-24.27 0-.03.01-.05.02-.07.02-.05.03-.1.04-.14.03-.11.05-.19.05-.19.26-.78.17-1.53-.15-2.15v.02ZM82.98 58.94c.9-1.03 1.79-2.04 2.67-3.02-5.76 7.58-15.3 19.26-28.81 33.14 9.2-10.18 18.47-20.73 26.14-30.12Zm-32.55 52.81-.03-.03c.11.02.19.04.2.04a.47.47 0 0 0-.17 0v-.01Zm6.9 6.42-.05-.04.03-.03c.02 0 .03.02.04.02 0 .02-.02.03-.03.05h.01Zm8.36-7.21 1.38-1.44c.01.01.02.03.03.05-.47.46-.94.93-1.42 1.39h.01Zm2.24-2.21c.26-.3.56-.65.87-1.02.01-.01.02-.03.04-.04 3.29-3.39 6.68-6.82 10.18-10.25.02-.02.05-.04.07-.06.86-.66 1.82-1.39 2.72-2.08-4.52 4.32-9.11 8.78-13.88 13.46v-.01Zm21.65-55.88c-1.86 2.42-3.9 5.56-5.63 8.07-5.46 7.91-23.04 27.28-23.43 27.65-2.71 2.62-10.88 10.46-16.09 15.37-.14.13-.25.24-.34.35a.794.794 0 0 1 .03-1.13c24.82-23.4 39.88-42.89 46-51.38-.13.33-.24.69-.55 1.09l.01-.02Zm16.51 7.1-.01.02c0-.02-.02-.07.01-.02Zm-.91-5.13Zm-5.89 9.45c-2.26-1.31-3.32-3.27-2.71-5.25l.19-.66c.08-.19.17-.38.28-.57.59-.98 1.49-1.85 2.52-2.36.05-.02.1-.03.15-.04a.795.795 0 0 1-.04-.43c.05-.31.25-.58.66-.58.67 0 2.75.62 3.54 1.3.24.19.47.4.68.63.3.35.74.92.96 1.33.13.06.23.62.38.91.14.46.2.93.18 1.4 0 .02 0 .02.01.03-.03.07 0 .37-.04.4-.1.72-.36 1.43-.75 2.05-.04.05-.07.11-.11.16 0 .01-.02.02-.03.04-.3.43-.65.83-1.08 1.13-1.26.89-2.73 1.16-4.2.79a6.33 6.33 0 0 1-.57-.25l-.02-.03Zm16.27-1.63c-.49 2.05-1.09 4.19-1.8 6.38-.03.08-.03.16-.03.23-.1.01-.19.05-.27.11-4.44 3.26-8.73 6.62-12.98 10.11 3.67-3.32 7.39-6.62 11.23-9.95a6.409 6.409 0 0 0 2.11-3.74l.56-3.37.03-.1c.25-.71 1.34-.4 1.17.33h-.02Z" style="fill:#6965db;fill-rule:nonzero" transform="matrix(1 0 0 1 -26.41 -29.49)" />
</svg>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+8
View File
@@ -0,0 +1,8 @@
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: var(--text-lg);
color: var(--color-muted);
}
+62
View File
@@ -0,0 +1,62 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { AppLayout } from './components/Layout/AppLayout';
import { Dashboard } from './pages/Dashboard/Dashboard';
import { Login } from './pages/Auth/Login';
import { Signup } from './pages/Auth/Signup';
import { FileBrowser } from './pages/FileBrowser/FileBrowser';
import { TeamSettings } from './pages/Team/TeamSettings';
import { UserSettings } from './pages/Settings/UserSettings';
import { Editor } from './pages/Editor/Editor';
import { useAuthStore } from './stores';
import { useAuth } from './hooks';
import { CommandPalette } from './components';
import { api } from './services';
import './App.scss';
export const App: React.FC = () => {
useAuth(); // Initialize auth check
const { isAuthenticated, isLoading } = useAuthStore();
const [setupStatus, setSetupStatus] = useState<{ has_users: boolean } | null>(null);
const [setupLoading, setSetupLoading] = useState(true);
useEffect(() => {
if (!isAuthenticated && !isLoading) {
api.auth.setupStatus()
.then(setSetupStatus)
.catch(() => setSetupStatus({ has_users: true }))
.finally(() => setSetupLoading(false));
} else {
setSetupLoading(false);
}
}, [isAuthenticated, isLoading]);
if (isLoading || setupLoading) {
return <div className="loading-screen">Loading...</div>;
}
if (!isAuthenticated) {
const hasUsers = setupStatus?.has_users ?? true;
return (
<Routes>
<Route path="/login" element={hasUsers ? <Login hasUsers={hasUsers} /> : <Navigate to="/signup" replace />} />
<Route path="/signup" element={hasUsers ? <Navigate to="/login" replace /> : <Signup hasUsers={hasUsers} />} />
<Route path="*" element={<Navigate to={hasUsers ? "/login" : "/signup"} replace />} />
</Routes>
);
}
return (
<AppLayout>
<CommandPalette />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/files/*" element={<FileBrowser />} />
<Route path="/team" element={<TeamSettings />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/drawing/:id" element={<Editor />} />
<Route path="/folder/:folderId/drawing/:id" element={<Editor />} />
</Routes>
</AppLayout>
);
};
@@ -0,0 +1,157 @@
@use '../../styles/variables' as *;
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0.625rem 1rem;
font-family: var(--ui-font);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1.5;
border-radius: var(--border-radius-lg);
border: 1px solid transparent;
cursor: pointer;
transition:
background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
text-decoration: none;
white-space: nowrap;
user-select: none;
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:active {
transform: scale(0.98);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
// Size variants
&.size-sm {
padding: 0.375rem 0.75rem;
font-size: var(--text-xs);
}
&.size-lg {
padding: 0.75rem 1.5rem;
font-size: var(--text-base);
}
// Full width
&.fullWidth {
width: 100%;
}
}
// Primary variant
.variant-primary {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
&:hover:not(:disabled) {
background: var(--color-primary-hover);
border-color: var(--color-primary-hover);
}
&:active {
background: var(--color-primary-darkest);
border-color: var(--color-primary-darkest);
}
}
// Secondary variant
.variant-secondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border-color: var(--color-surface-high);
&:hover:not(:disabled) {
background: var(--color-surface-high);
border-color: var(--color-gray-30);
}
&:active {
background: var(--color-gray-20);
}
}
// Ghost variant
.variant-ghost {
background: transparent;
color: var(--color-on-surface);
border-color: transparent;
&:hover:not(:disabled) {
background: var(--color-surface-low);
}
&:active {
background: var(--color-surface-high);
}
}
// Danger variant
.variant-danger {
background: var(--color-danger);
color: white;
border-color: var(--color-danger);
&:hover:not(:disabled) {
background: var(--color-danger-dark);
border-color: var(--color-danger-dark);
}
&:active {
background: var(--color-danger-darker);
border-color: var(--color-danger-darker);
}
}
// Icon only
.iconOnly {
padding: 0.5rem;
&.size-sm {
padding: 0.375rem;
}
&.size-lg {
padding: 0.625rem;
}
}
// Loading state
.loading {
position: relative;
color: transparent !important;
&::after {
content: '';
position: absolute;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { clsx } from 'clsx';
import styles from './Button.module.scss';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
fullWidth?: boolean;
children: React.ReactNode;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
children,
className,
disabled,
...props
}, ref) => {
const isIconOnly = React.Children.count(children) === 1 &&
React.isValidElement(children) &&
(children.type === 'svg' || String(children.type).includes('Icon'));
return (
<button
ref={ref}
className={clsx(
styles.button,
styles[`variant-${variant}`],
styles[`size-${size}`],
{
[styles.loading]: loading,
[styles.fullWidth]: fullWidth,
[styles.iconOnly]: isIconOnly,
},
className
)}
disabled={disabled || loading}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';
+2
View File
@@ -0,0 +1,2 @@
export { Button } from './Button';
export type { ButtonProps } from './Button';
@@ -0,0 +1,34 @@
@use '../../styles/variables' as *;
.card {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-island);
overflow: hidden;
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.hover {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-island-stronger);
}
}
.header {
padding: var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.content {
padding: var(--space-4);
}
.footer {
padding: var(--space-4);
border-top: 1px solid var(--color-gray-20);
background: var(--color-surface-low);
}
@@ -0,0 +1,22 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Card } from './Card';
describe('Card', () => {
it('renders children', () => {
render(<Card>Hello World</Card>);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('handles click events', () => {
const onClick = vi.fn();
render(<Card onClick={onClick}>Click me</Card>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('forwards aria-label', () => {
render(<Card aria-label="Test card">Content</Card>);
expect(screen.getByLabelText('Test card')).toBeInTheDocument();
});
});
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import styles from './Card.module.scss';
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
className?: string;
onClick?: () => void;
hover?: boolean;
}
export const Card: React.FC<CardProps> = ({ children, className, onClick, hover = true, ...rest }) => {
return (
<div
className={`${styles.card} ${hover ? styles.hover : ''} ${className || ''}`}
onClick={onClick}
role={onClick ? 'button' : rest.role}
{...rest}
>
{children}
</div>
);
};
export const CardHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.header} ${className || ''}`}>{children}</div>
);
export const CardContent: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.content} ${className || ''}`}>{children}</div>
);
export const CardFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
<div className={`${styles.footer} ${className || ''}`}>{children}</div>
);
@@ -0,0 +1,126 @@
@use '../../styles/variables' as *;
.panel {
width: 340px;
border-left: 1px solid var(--color-gray-20);
background: var(--island-bg-color);
display: flex;
flex-direction: column;
height: 100%;
@media (max-width: 768px) {
position: fixed;
right: 0;
top: var(--header-height);
bottom: 0;
width: 100%;
z-index: 90;
border-left: none;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.title {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-gray-85);
}
.closeBtn {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.message {
display: flex;
gap: var(--space-2);
align-items: flex-start;
}
.user {
flex-direction: row-reverse;
.bubble {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
}
}
.assistant .bubble {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
.avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--border-radius-full);
display: flex;
align-items: center;
justify-content: center;
background: var(--color-gray-20);
color: var(--color-muted);
flex-shrink: 0;
}
.bubble {
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-lg);
font-size: var(--text-sm);
line-height: 1.5;
max-width: 260px;
word-wrap: break-word;
}
.inputRow {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-gray-20);
align-items: center;
}
.chatInput {
flex: 1;
input {
font-size: var(--text-sm);
}
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@@ -0,0 +1,116 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Send, X, Bot, User, Loader2 } from 'lucide-react';
import { Button, Input } from '@/components';
import styles from './ChatPanel.module.scss';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ChatPanelProps {
onClose: () => void;
drawingContext?: string;
}
export const ChatPanel: React.FC<ChatPanelProps> = ({ onClose, drawingContext }) => {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: 'I can help you create or refine diagrams. What would you like to do?' },
]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
const handleSend = useCallback(async () => {
if (!input.trim() || isLoading) return;
const userMsg = input.trim();
setInput('');
setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
setIsLoading(true);
try {
const systemPrompt = drawingContext
? `You are an AI assistant for Excalidraw. The user is working on a diagram. Context: ${drawingContext}. Help them create, refine, or explain their diagram. Respond with concise, actionable suggestions. When suggesting diagram structures, describe elements and their layout clearly.`
: 'You are an AI assistant for Excalidraw. Help users create, refine, or explain diagrams. Respond with concise, actionable suggestions.';
const res = await fetch('/api/v2/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
...messages.slice(-6).map((m) => ({ role: m.role, content: m.content })),
{ role: 'user', content: userMsg },
],
max_tokens: 800,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const assistantContent = data.choices?.[0]?.message?.content || 'Sorry, I could not generate a response.';
setMessages((prev) => [...prev, { role: 'assistant', content: assistantContent }]);
} catch (err) {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again later.' },
]);
} finally {
setIsLoading(false);
}
}, [input, isLoading, messages, drawingContext]);
return (
<div className={styles.panel} role="complementary" aria-label="AI chat panel">
<div className={styles.header}>
<div className={styles.title}>
<Bot size={18} aria-hidden="true" />
<span>AI Assistant</span>
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close chat panel">
<X size={16} />
</button>
</div>
<div className={styles.messages} ref={scrollRef} role="log" aria-live="polite" aria-atomic="false">
{messages.map((msg, i) => (
<div
key={i}
className={`${styles.message} ${msg.role === 'user' ? styles.user : styles.assistant}`}
>
<div className={styles.avatar} aria-hidden="true">
{msg.role === 'user' ? <User size={14} /> : <Bot size={14} />}
</div>
<div className={styles.bubble}>{msg.content}</div>
</div>
))}
{isLoading && (
<div className={`${styles.message} ${styles.assistant}`}>
<div className={styles.avatar} aria-hidden="true"><Bot size={14} /></div>
<div className={styles.bubble}><Loader2 size={16} className={styles.spinner} /></div>
</div>
)}
</div>
<div className={styles.inputRow}>
<Input
className={styles.chatInput}
placeholder="Ask about your diagram..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
aria-label="Chat input"
/>
<Button size="sm" onClick={handleSend} disabled={isLoading || !input.trim()} aria-label="Send message">
<Send size={16} />
</Button>
</div>
</div>
);
};
@@ -0,0 +1,115 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
}
.dialog {
width: 100%;
max-width: 560px;
background: var(--color-surface-lowest);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
border: 1px solid var(--color-gray-20);
}
.inputRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
}
.inputIcon {
color: var(--color-gray-50);
flex-shrink: 0;
}
.input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: var(--text-base);
color: var(--color-gray-95);
padding: 0;
&::placeholder {
color: var(--color-gray-50);
}
}
.kbd {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
background: var(--color-gray-10);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-sm);
padding: 2px 6px;
flex-shrink: 0;
}
.list {
max-height: 320px;
overflow-y: auto;
padding: var(--space-2);
}
.item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.1s ease;
&:hover,
&.selected {
background: var(--color-gray-10);
}
}
.itemIcon {
color: var(--color-gray-60);
flex-shrink: 0;
}
.itemLabel {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
}
.itemShortcut {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
background: var(--color-gray-10);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-sm);
padding: 1px 5px;
}
.empty {
padding: var(--space-6);
text-align: center;
font-size: var(--text-sm);
color: var(--color-gray-50);
}
@@ -0,0 +1,179 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Command, FileText, FolderOpen, Users, Settings, FileCode, LayoutDashboard } from 'lucide-react';
import styles from './CommandPalette.module.scss';
interface CommandItem {
id: string;
label: string;
shortcut?: string;
icon?: React.ElementType;
action: () => void;
}
export const CommandPalette: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const commands: CommandItem[] = [
{
id: 'dashboard',
label: t('sidebar.dashboard'),
icon: LayoutDashboard,
action: () => navigate('/'),
},
{
id: 'files',
label: t('sidebar.files'),
icon: FolderOpen,
action: () => navigate('/files'),
},
{
id: 'templates',
label: t('sidebar.templates'),
icon: FileCode,
action: () => navigate('/templates'),
},
{
id: 'team',
label: t('sidebar.team'),
icon: Users,
action: () => navigate('/team'),
},
{
id: 'settings',
label: t('sidebar.settings'),
icon: Settings,
action: () => navigate('/settings'),
},
{
id: 'new-drawing',
label: t('dashboard.newDrawing'),
icon: FileText,
action: () => navigate('/drawing/new'),
},
];
const filtered = query.trim()
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
: commands;
const openPalette = useCallback(() => {
setIsOpen(true);
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
const closePalette = useCallback(() => {
setIsOpen(false);
setQuery('');
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openPalette();
}
if (e.key === 'Escape') {
closePalette();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [openPalette, closePalette]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const cmd = filtered[selectedIndex];
if (cmd) {
cmd.action();
closePalette();
}
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, filtered, selectedIndex, closePalette]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
if (!isOpen) return null;
return (
<div
className={styles.overlay}
onClick={closePalette}
role="dialog"
aria-modal="true"
aria-label="Command palette"
>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<div className={styles.inputRow}>
<Search size={18} className={styles.inputIcon} aria-hidden="true" />
<input
ref={inputRef}
type="text"
className={styles.input}
placeholder={t('commandPalette.placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
aria-label="Search commands"
aria-autocomplete="list"
aria-controls="command-list"
aria-activedescendant={filtered[selectedIndex] ? `cmd-${filtered[selectedIndex].id}` : undefined}
/>
<span className={styles.kbd} aria-label="Keyboard shortcut">
<Command size={12} aria-hidden="true" /> K
</span>
</div>
<div ref={listRef} className={styles.list} id="command-list" role="listbox">
{filtered.length === 0 ? (
<div className={styles.empty}>{t('commandPalette.noResults')}</div>
) : (
filtered.map((cmd, index) => {
const Icon = cmd.icon;
return (
<button
key={cmd.id}
id={`cmd-${cmd.id}`}
className={`${styles.item} ${index === selectedIndex ? styles.selected : ''}`}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
cmd.action();
closePalette();
}}
role="option"
aria-selected={index === selectedIndex}
>
{Icon && <Icon size={16} className={styles.itemIcon} aria-hidden="true" />}
<span className={styles.itemLabel}>{cmd.label}</span>
{cmd.shortcut && <span className={styles.itemShortcut}>{cmd.shortcut}</span>}
</button>
);
})
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,64 @@
@use '../../styles/variables' as *;
.wrapper {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--input-label-color);
}
.input {
padding: 0.5rem 0.75rem;
font-size: var(--text-base);
font-family: var(--ui-font);
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
&:hover:not(:focus):not(:disabled) {
background: var(--input-hover-bg-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&::placeholder {
color: var(--color-gray-50);
}
}
.error {
border-color: var(--color-danger);
&:focus {
border-color: var(--color-danger);
box-shadow: 0 0 0 3px var(--color-danger-background);
}
}
.errorText {
font-size: var(--text-xs);
color: var(--color-danger);
}
.helperText {
font-size: var(--text-xs);
color: var(--color-muted);
}
+45
View File
@@ -0,0 +1,45 @@
import React, { forwardRef } from 'react';
import styles from './Input.module.scss';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, helperText, className, id, ...props }, ref) => {
const inputId = id || React.useId();
return (
<div className={styles.wrapper}>
{label && (
<label htmlFor={inputId} className={styles.label}>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`${styles.input} ${error ? styles.error : ''} ${className || ''}`}
aria-invalid={error ? 'true' : undefined}
aria-describedby={
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
}
{...props}
/>
{error && (
<span id={`${inputId}-error`} className={styles.errorText} role="alert">
{error}
</span>
)}
{helperText && !error && (
<span id={`${inputId}-helper`} className={styles.helperText}>
{helperText}
</span>
)}
</div>
);
}
);
Input.displayName = 'Input';
@@ -0,0 +1,39 @@
import React, { useState, useCallback } from 'react';
import { Menu } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import styles from './Layout.module.scss';
export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
return (
<div className={styles.layout}>
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
{sidebarOpen && (
<div
className={styles.sidebarOverlay}
onClick={closeSidebar}
role="presentation"
aria-hidden="true"
/>
)}
<div className={styles.main}>
<Header>
<button
className={styles.mobileMenuToggle}
onClick={openSidebar}
aria-label="Open menu"
aria-expanded={sidebarOpen}
aria-controls="app-sidebar"
>
<Menu size={20} />
</button>
</Header>
<div className={styles.content}>{children}</div>
</div>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react';
import { Button } from '@/components';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { Drawing } from '@/types';
import styles from './Layout.module.scss';
export const Header: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { theme, toggleTheme } = useThemeStore();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Drawing[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const performSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
return;
}
setIsSearching(true);
try {
const res = await api.search.get(q);
setResults(res);
} catch (err) {
console.error('Search failed:', err);
setResults([]);
} finally {
setIsSearching(false);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
setShowResults(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => performSearch(val), 250);
};
const handleSelect = (drawing: Drawing) => {
setQuery('');
setResults([]);
setShowResults(false);
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
};
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!searchRef.current?.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
return (
<header className={styles.header}>
{children}
<div className={styles.search} ref={searchRef} role="search" aria-label="Search drawings">
<Search size={18} />
<input
type="text"
placeholder={t('common.search') + '...'}
value={query}
onChange={handleChange}
onFocus={() => query && setShowResults(true)}
aria-label="Search drawings"
aria-autocomplete="list"
aria-controls="search-results"
aria-expanded={showResults}
/>
{isSearching && <Loader2 size={14} className={styles.searchSpinner} />}
{showResults && (query.trim() || results.length > 0) && (
<div id="search-results" className={styles.searchDropdown} role="listbox">
{results.length === 0 ? (
<div className={styles.searchEmpty}>
{isSearching ? t('common.loading') : t('search.noResults')}
</div>
) : (
results.map((drawing) => (
<button
key={drawing.id}
className={styles.searchResult}
onClick={() => handleSelect(drawing)}
role="option"
aria-label={`Open drawing ${drawing.title}`}
>
<FileText size={14} aria-hidden="true" />
<span className={styles.searchResultTitle}>{drawing.title}</span>
{drawing.owner?.name && (
<span className={styles.searchResultMeta}>{drawing.owner.name}</span>
)}
</button>
))
)}
</div>
)}
</div>
<div className={styles.actions}>
<button className={styles.iconButton} onClick={toggleTheme} title={t('userSettings.theme')} aria-label={t('userSettings.theme')}>
{theme === 'light' ? <Sun size={20} aria-hidden="true" /> : <Moon size={20} aria-hidden="true" />}
</button>
<button className={styles.iconButton} aria-label="Notifications" title="Notifications">
<Bell size={20} aria-hidden="true" />
</button>
<Button>
<Plus size={18} />
{t('dashboard.newDrawing')}
</Button>
</div>
</header>
);
};
@@ -0,0 +1,340 @@
@use '../../styles/variables' as *;
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
background: var(--island-bg-color);
border-right: 1px solid var(--color-gray-20);
display: flex;
flex-direction: column;
padding: var(--space-4);
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transition: transform var(--duration-normal) var(--ease-out);
@media (max-width: 768px) {
transform: translateX(-100%);
&.open {
transform: translateX(0);
}
}
}
.sidebarOverlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
@media (max-width: 768px) {
display: block;
}
}
.mobileMenuToggle {
display: none;
background: none;
border: none;
color: var(--color-gray-70);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
@media (max-width: 768px) {
display: flex;
align-items: center;
justify-content: center;
}
}
.sidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
padding: var(--space-2) 0;
}
.logo {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.logoImg {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.sidebarCloseBtn {
display: none;
background: none;
border: none;
color: var(--color-gray-70);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
@media (max-width: 768px) {
display: flex;
align-items: center;
justify-content: center;
}
}
.nav {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
}
.navItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
color: var(--color-gray-70);
text-decoration: none;
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.footer {
border-top: 1px solid var(--color-gray-20);
padding-top: var(--space-4);
margin-top: var(--space-4);
display: flex;
align-items: center;
justify-content: space-between;
}
.user {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatar {
width: 2rem;
height: 2rem;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--text-sm);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.userName {
font-size: var(--text-sm);
color: var(--color-gray-70);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout {
background: none;
border: none;
color: var(--color-gray-50);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-danger);
}
}
.main {
flex: 1;
margin-left: var(--sidebar-width);
display: flex;
flex-direction: column;
@media (max-width: 768px) {
margin-left: 0;
}
}
.header {
height: var(--header-height);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
position: sticky;
top: 0;
z-index: 50;
@media (max-width: 768px) {
padding: 0 var(--space-4);
}
}
.search {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
width: 400px;
transition: all var(--duration-fast) var(--ease-out);
@media (max-width: 768px) {
width: auto;
flex: 1;
min-width: 0;
}
input {
border: none;
background: transparent;
outline: none;
font-size: var(--text-sm);
color: var(--color-on-surface);
width: 100%;
&::placeholder {
color: var(--color-gray-50);
}
}
&:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.iconButton {
background: none;
border: none;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.content {
flex: 1;
padding: var(--space-6);
overflow: auto;
}
.searchSpinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.searchDropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-gray-20);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
max-height: 300px;
overflow-y: auto;
z-index: 100;
}
.searchResult {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.1s ease;
&:hover {
background: var(--color-gray-10);
}
}
.searchResultTitle {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchResultMeta {
font-size: 11px;
color: var(--color-gray-50);
}
.searchEmpty {
padding: var(--space-4);
text-align: center;
font-size: var(--text-sm);
color: var(--color-gray-50);
}
@@ -0,0 +1,92 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
LayoutDashboard,
FolderOpen,
Users,
Settings,
LogOut,
X,
} from 'lucide-react';
import { useAuthStore } from '@/stores';
import styles from './Layout.module.scss';
interface SidebarProps {
open?: boolean;
onClose?: () => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ open, onClose }) => {
const { t } = useTranslation();
const { user, logout } = useAuthStore();
const navItems = [
{ to: '/', icon: LayoutDashboard, label: t('sidebar.dashboard') },
{ to: '/files', icon: FolderOpen, label: t('sidebar.projects') },
{ to: '/team', icon: Users, label: t('sidebar.team') },
{ to: '/settings', icon: Settings, label: t('sidebar.settings') },
];
return (
<aside
id="app-sidebar"
className={`${styles.sidebar} ${open ? styles.open : ''}`}
role="navigation"
aria-label="Main navigation"
>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<img src="https://plus.excalidraw.com/images/logo.svg" alt="Excalidraw" className={styles.logoImg} />
</div>
{onClose && (
<button
className={styles.sidebarCloseBtn}
onClick={onClose}
aria-label="Close menu"
>
<X size={20} />
</button>
)}
</div>
<nav className={styles.nav}>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.active : ''}`
}
onClick={onClose}
aria-label={item.label}
>
<item.icon size={20} aria-hidden="true" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className={styles.footer}>
<div className={styles.user}>
<div className={styles.avatar}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user.name} />
) : (
user?.name?.[0] || '?'
)}
</div>
<span className={styles.userName}>{user?.name}</span>
</div>
<button
className={styles.logout}
onClick={logout}
aria-label="Log out"
title="Log out"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
</aside>
);
};
@@ -0,0 +1,135 @@
@use '../../styles/variables' as *;
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
padding: var(--space-6);
animation: slideUp 0.2s ease;
}
.header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}
.iconWarning {
color: var(--color-warning-darker);
}
.iconDanger {
color: var(--color-danger);
}
.iconInfo {
color: var(--color-primary);
}
.title {
flex: 1;
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-on-surface);
margin: 0;
}
.closeBtn {
background: none;
border: none;
color: var(--color-gray-60);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.message {
font-size: var(--text-sm);
color: var(--color-gray-70);
line-height: 1.5;
margin-bottom: var(--space-6);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.btnPrimary,
.btnSecondary,
.btnDanger {
padding: var(--space-2) var(--space-4);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
border: none;
transition: all var(--duration-fast) var(--ease-out);
}
.btnPrimary {
background: var(--color-primary);
color: white;
&:hover {
background: var(--color-primary-hover);
}
}
.btnSecondary {
background: var(--color-surface-low);
color: var(--color-on-surface);
border: 1px solid var(--color-gray-20);
&:hover {
background: var(--color-gray-10);
}
}
.btnDanger {
background: var(--color-danger);
color: white;
&:hover {
background: var(--color-danger-dark);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
+101
View File
@@ -0,0 +1,101 @@
import React, { useEffect, useRef } from 'react';
import { X, AlertTriangle, Info } from 'lucide-react';
import styles from './Modal.module.scss';
export type ModalType = 'confirm' | 'alert' | 'info';
interface ModalProps {
isOpen: boolean;
title: string;
message: string;
type?: ModalType;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
onClose?: () => void;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
title,
message,
type = 'info',
confirmText = 'OK',
cancelText = 'Cancel',
onConfirm,
onCancel,
onClose,
}) => {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel?.() ?? onClose?.();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [isOpen, onCancel, onClose]);
if (!isOpen) return null;
const iconMap = {
confirm: <AlertTriangle size={24} className={styles.iconWarning} />,
alert: <AlertTriangle size={24} className={styles.iconDanger} />,
info: <Info size={24} className={styles.iconInfo} />,
};
return (
<div
ref={overlayRef}
className={styles.overlay}
onClick={(e) => {
if (e.target === overlayRef.current) {
onCancel?.() ?? onClose?.();
}
}}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className={styles.modal}>
<div className={styles.header}>
<div className={styles.icon}>{iconMap[type]}</div>
<h3 id="modal-title" className={styles.title}>{title}</h3>
<button
className={styles.closeBtn}
onClick={() => onCancel?.() ?? onClose?.()}
aria-label="Close"
>
<X size={18} />
</button>
</div>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
{type === 'confirm' && (
<button
className={styles.btnSecondary}
onClick={() => onCancel?.() ?? onClose?.()}
>
{cancelText}
</button>
)}
<button
className={type === 'alert' ? styles.btnDanger : styles.btnPrimary}
onClick={() => onConfirm?.() ?? onClose?.()}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,92 @@
@use '../../styles/variables' as *;
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
padding: var(--space-6);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
h2 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xl);
font-weight: 600;
margin: 0;
}
}
.closeBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-gray-60);
padding: var(--space-2);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-gray-10);
color: var(--color-gray-90);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-4);
}
.card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-6) var(--space-4);
cursor: pointer;
border: 2px solid transparent;
transition: all var(--duration-fast);
&:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
}
}
.iconWrap {
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.title {
font-weight: 600;
margin: 0 0 var(--space-1);
font-size: var(--text-base);
}
.desc {
font-size: var(--text-sm);
color: var(--color-gray-60);
margin: 0;
}
@@ -0,0 +1,173 @@
import React from 'react';
import { X, CheckSquare, ListTodo, List, ArrowRight, LayoutTemplate, PenTool } from 'lucide-react';
import { Card } from '@/components';
import styles from './TemplatePicker.module.scss';
export type PickedTemplate = 'blank' | 'todo' | 'checklist' | 'list' | 'flow';
interface TemplatePickerProps {
isOpen: boolean;
onClose: () => void;
onSelect: (template: PickedTemplate) => void;
}
interface TemplateOption {
id: PickedTemplate;
label: string;
description: string;
icon: React.ElementType;
elements: any[];
}
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
return {
id: `el-${Math.random().toString(36).slice(2)}`,
type: 'rectangle',
x, y, width: w, height: h,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3, value: 32 },
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: text ? [{ id: `txt-${Math.random().toString(36).slice(2)}`, type: 'text' }] : [],
updated: Date.now(),
link: null,
locked: false,
};
}
function makeText(x: number, y: number, text: string, fontSize = 20) {
return {
id: `txt-${Math.random().toString(36).slice(2)}`,
type: 'text',
x, y, width: text.length * (fontSize * 0.55), height: fontSize * 1.4,
angle: 0,
strokeColor: '#1e1e1e',
backgroundColor: 'transparent',
fillStyle: 'hachure',
strokeWidth: 1,
strokeStyle: 'solid',
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: null,
seed: Math.floor(Math.random() * 10000),
version: 2,
versionNonce: Math.floor(Math.random() * 100000),
isDeleted: false,
boundElements: [],
updated: Date.now(),
link: null,
locked: false,
text,
fontSize,
fontFamily: 1,
textAlign: 'left',
verticalAlign: 'top',
baseline: 18,
containerId: null,
originalText: text,
lineHeight: 1.25,
};
}
function makeCheckbox(x: number, y: number, checked = false) {
const box = makeHandDrawnRect(x, y, 20, 20);
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
return box;
}
export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
blank: [],
todo: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'To-Do List', 28),
makeCheckbox(60, 130, false),
makeText(90, 130, 'First task'),
makeCheckbox(60, 170, false),
makeText(90, 170, 'Second task'),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Third task'),
makeHandDrawnRect(50, 280, 500, 2),
makeText(60, 300, 'Notes:', 18),
],
checklist: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Checklist', 28),
makeCheckbox(60, 130, true),
makeText(90, 130, 'Completed item', 18),
makeCheckbox(60, 170, false),
makeText(90, 170, 'Pending item', 18),
makeCheckbox(60, 210, false),
makeText(90, 210, 'Another task', 18),
makeHandDrawnRect(60, 250, 480, 1),
makeText(70, 265, 'Add more items below', 14),
],
list: [
makeHandDrawnRect(50, 50, 500, 50),
makeText(70, 65, 'Bullet List', 28),
makeText(60, 130, '- First bullet point'),
makeText(60, 170, '- Second bullet point'),
makeText(60, 210, '- Third bullet point'),
makeText(60, 250, '- Fourth item with details'),
makeHandDrawnRect(50, 300, 500, 2),
makeText(60, 320, 'Add your own items...', 14),
],
flow: [
makeHandDrawnRect(200, 50, 200, 60),
makeText(230, 70, 'Start', 20),
makeHandDrawnRect(200, 150, 200, 60),
makeText(220, 170, 'Process A', 20),
makeHandDrawnRect(200, 250, 200, 60),
makeText(220, 270, 'Process B', 20),
makeHandDrawnRect(200, 350, 200, 60),
makeText(230, 370, 'End', 20),
],
};
const OPTIONS: TemplateOption[] = [
{ id: 'blank', label: 'Blank Canvas', description: 'Start with an empty canvas', icon: PenTool, elements: [] },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks with a title', icon: ListTodo, elements: [] },
{ id: 'checklist', label: 'Checklist', description: 'Simple checklist with status', icon: CheckSquare, elements: [] },
{ id: 'list', label: 'Bullet List', description: 'Bulleted list with notes area', icon: List, elements: [] },
{ id: 'flow', label: 'Flow Chart', description: 'Simple process flow diagram', icon: ArrowRight, elements: [] },
];
export const TemplatePicker: React.FC<TemplatePickerProps> = ({ isOpen, onClose, onSelect }) => {
if (!isOpen) return null;
return (
<div className={styles.overlay} role="dialog" aria-modal="true" aria-labelledby="template-title" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 id="template-title"><LayoutTemplate size={20} /> Choose a Template</h2>
<button onClick={onClose} className={styles.closeBtn} aria-label="Close"><X size={18} /></button>
</div>
<div className={styles.grid}>
{OPTIONS.map((opt) => {
const Icon = opt.icon;
return (
<Card key={opt.id} className={styles.card} hover onClick={() => onSelect(opt.id)} role="button" tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(opt.id); }}>
<div className={styles.iconWrap}><Icon size={32} /></div>
<h3 className={styles.title}>{opt.label}</h3>
<p className={styles.desc}>{opt.description}</p>
</Card>
);
})}
</div>
</div>
</div>
);
};
+11
View File
@@ -0,0 +1,11 @@
export { Button } from './Button/Button';
export { Card, CardHeader, CardContent } from './Card/Card';
export { Input } from './Input/Input';
export { AppLayout } from './Layout/AppLayout';
export { CommandPalette } from './CommandPalette/CommandPalette';
export { TemplatePicker } from './TemplatePicker/TemplatePicker';
export { ChatPanel } from './ChatPanel/ChatPanel';
export { Header } from './Layout/Header';
export { Sidebar } from './Layout/Sidebar';
export { Modal } from './Modal/Modal';
export type { PickedTemplate } from './TemplatePicker/TemplatePicker';
+3
View File
@@ -0,0 +1,3 @@
export { useAuth } from './useAuth';
export { useDrawings } from './useDrawings';
export { useTeams } from './useTeams';
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useAuthStore } from '@/stores';
import { api } from '@/services';
export function useAuth() {
const { setUser, setSession, setLoading, logout, isAuthenticated } = useAuthStore();
useEffect(() => {
const init = async () => {
try {
const user = await api.auth.me();
setUser(user);
} catch {
// Not logged in
} finally {
setLoading(false);
}
};
init();
}, [setUser, setLoading]);
const login = async (email: string, password: string) => {
const { user, session } = await api.auth.login(email, password);
setUser(user);
setSession(session);
return user;
};
const signup = async (name: string, email: string, password: string) => {
const { user, session } = await api.auth.signup(name, email, password);
setUser(user);
setSession(session);
return user;
};
const doLogout = async () => {
await api.auth.logout();
logout();
};
return { login, signup, logout: doLogout, isAuthenticated };
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useCallback } from 'react';
import { useDrawingStore, useTeamStore } from '@/stores';
import { api } from '@/services';
export function useDrawings() {
const { drawings, recentDrawings, setDrawings, setRecentDrawings, setLoading, addDrawing, updateDrawing } = useDrawingStore();
const { currentTeam } = useTeamStore();
const fetchDrawings = useCallback(async () => {
setLoading(true);
try {
const data = await api.drawings.list(currentTeam?.id);
setDrawings(data);
setRecentDrawings(data.slice(0, 10));
} finally {
setLoading(false);
}
}, [currentTeam?.id, setDrawings, setRecentDrawings, setLoading]);
useEffect(() => {
fetchDrawings();
}, [fetchDrawings]);
const createDrawing = async (title: string, folderId?: string) => {
const drawing = await api.drawings.create({
title,
folder_id: folderId,
team_id: currentTeam?.id,
});
addDrawing(drawing);
return drawing;
};
return { drawings, recentDrawings, fetchDrawings, createDrawing, updateDrawing };
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useCallback } from 'react';
import { useTeamStore } from '@/stores';
import { api } from '@/services';
export function useTeams() {
const { teams, members, setTeams, setMembers, setLoading, setCurrentTeam } = useTeamStore();
const fetchTeams = useCallback(async () => {
setLoading(true);
try {
const data = await api.teams.list();
setTeams(data);
if (data.length > 0 && !useTeamStore.getState().currentTeam) {
setCurrentTeam(data[0]);
}
} finally {
setLoading(false);
}
}, [setTeams, setCurrentTeam, setLoading]);
const fetchMembers = useCallback(async (teamId: string) => {
const data = await api.teams.members(teamId);
setMembers(data);
}, [setMembers]);
useEffect(() => {
fetchTeams();
}, [fetchTeams]);
return { teams, members, fetchTeams, fetchMembers, setCurrentTeam };
}
+23
View File
@@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
+139
View File
@@ -0,0 +1,139 @@
{
"app": {
"name": "Excalidraw FULL"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create",
"edit": "Edit",
"back": "Back",
"search": "Search",
"submit": "Submit",
"close": "Close",
"confirm": "Confirm",
"error": "Error",
"success": "Success",
"or": "or",
"continueWith": "or continue with"
},
"auth": {
"login": {
"title": "Welcome back",
"subtitle": "Sign in to your Excalidraw FULL account",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"signIn": "Sign In",
"noAccount": "Don't have an account?",
"signUpLink": "Sign up",
"errorInvalid": "Invalid email or password"
},
"signup": {
"title": "Create account",
"subtitle": "Start your visual workspace journey",
"nameLabel": "Full Name",
"namePlaceholder": "John Doe",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Create a strong password",
"createAccount": "Create Account",
"hasAccount": "Already have an account?",
"signInLink": "Sign in",
"errorCreate": "Could not create account"
},
"oauth": {
"github": "GitHub"
}
},
"sidebar": {
"dashboard": "Dashboard",
"files": "Files",
"projects": "Projects",
"templates": "Templates",
"library": "Library",
"team": "Team",
"settings": "Settings"
},
"dashboard": {
"welcome": "Welcome back, {{name}}",
"subtitle": "Here's what's happening in your workspace",
"newDrawing": "New Drawing",
"creating": "Creating...",
"stats": {
"drawings": "Drawings",
"projects": "Projects",
"folders": "Folders",
"teams": "Teams",
"revisions": "Revisions",
"storage": "Storage"
},
"recentDrawings": "Recent Drawings",
"noDrawings": "No recent drawings",
"noDrawingsSub": "Create your first drawing to get started"
},
"editor": {
"back": "Back",
"saveNow": "Save Now",
"loadingCanvas": "Loading Excalidraw...",
"errorLoad": "Failed to load drawing",
"errorSave": "Failed to save:",
"saving": "Saving...",
"saved": "Saved",
"unsaved": "Unsaved changes",
"revisions": "revisions",
"revision": "Revision",
"revisionBrowser": "Revision Browser",
"noRevisions": "No revisions yet",
"goToDashboard": "Go to Dashboard",
"notFound": "Drawing not found",
"presenterNotes": "Presenter Notes",
"notesPlaceholder": "Add notes for your presentation..."
},
"fileBrowser": {
"title": "Projects"
},
"templates": {
"title": "Templates"
},
"teamSettings": {
"title": "Team Settings"
},
"userSettings": {
"title": "Settings",
"subtitle": "Manage your account preferences",
"language": "Language",
"theme": "Theme",
"tabProfile": "Profile",
"tabAccount": "Account",
"tabNotifications": "Notifications",
"tabAppearance": "Appearance",
"profileInfo": "Profile Information",
"changeAvatar": "Change Avatar",
"username": "Username",
"saveChanges": "Save Changes",
"accountSecurity": "Account Security",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm New Password",
"updatePassword": "Update Password",
"notificationPrefs": "Notification Preferences",
"emailMentions": "Email notifications for mentions",
"emailInvites": "Email notifications for team invites",
"weeklySummary": "Weekly activity summary",
"appearance": "Appearance",
"light": "Light",
"dark": "Dark"
},
"commandPalette": {
"placeholder": "Search commands...",
"noResults": "No matching commands"
},
"search": {
"noResults": "No results"
}
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './i18n';
import { App } from './App';
import './styles/global.scss';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+81
View File
@@ -0,0 +1,81 @@
@use '../../styles/variables' as *;
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
background:
radial-gradient(ellipse at top left, var(--color-surface-high), transparent 60%),
radial-gradient(ellipse at bottom right, var(--color-gray-10), transparent 60%),
var(--color-surface-low);
}
.card {
width: 100%;
max-width: 400px;
padding: var(--space-8);
}
.header {
text-align: center;
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-2xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
p {
color: var(--color-muted);
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.divider {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-6);
color: var(--color-muted);
font-size: var(--text-sm);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-gray-20);
}
}
.footer {
text-align: center;
margin-top: var(--space-6);
font-size: var(--text-sm);
color: var(--color-muted);
a {
color: var(--color-primary);
font-weight: 500;
}
}
.error {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
text-align: center;
}
+96
View File
@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
const loginStrings = {
title: 'auth.login.title',
subtitle: 'auth.login.subtitle',
emailLabel: 'auth.login.emailLabel',
emailPlaceholder: 'auth.login.emailPlaceholder',
passwordLabel: 'auth.login.passwordLabel',
passwordPlaceholder: 'auth.login.passwordPlaceholder',
signIn: 'auth.login.signIn',
noAccount: 'auth.login.noAccount',
signUpLink: 'auth.login.signUpLink',
};
const commonStrings = {
continueWith: 'common.continueWith',
};
export const Login: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await login(email, password);
navigate('/');
} catch {
setError('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t(loginStrings.title)}</h1>
<p>{t(loginStrings.subtitle)}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t(loginStrings.emailLabel)}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t(loginStrings.emailPlaceholder)}
required
/>
<Input
label={t(loginStrings.passwordLabel)}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t(loginStrings.passwordPlaceholder)}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t(loginStrings.signIn)}
</Button>
</form>
<div className={styles.divider}>
<span>{t(commonStrings.continueWith)}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{!hasUsers && (
<p className={styles.footer}>
{t(loginStrings.noAccount)} <Link to="/signup">{t(loginStrings.signUpLink)}</Link>
</p>
)}
</Card>
</div>
);
};
+89
View File
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Github } from 'lucide-react';
import { Button, Input, Card } from '@/components';
import { useAuth } from '@/hooks';
import styles from './Auth.module.scss';
export const Signup: React.FC<{ hasUsers: boolean }> = ({ hasUsers }) => {
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { signup } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await signup(name, email, password);
navigate('/');
} catch {
setError('Could not create account');
} finally {
setIsLoading(false);
}
};
return (
<div className={styles.container}>
<Card className={styles.card}>
<div className={styles.header}>
<h1>{t('auth.signup.title')}</h1>
<p>{t('auth.signup.subtitle')}</p>
</div>
{error && <div className={styles.error}>{error}</div>}
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t('auth.signup.nameLabel')}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth.signup.namePlaceholder')}
required
/>
<Input
label={t('auth.signup.emailLabel')}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('auth.signup.emailPlaceholder')}
required
/>
<Input
label={t('auth.signup.passwordLabel')}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('auth.signup.passwordPlaceholder')}
required
/>
<Button type="submit" fullWidth loading={isLoading}>
{t('auth.signup.createAccount')}
</Button>
</form>
<div className={styles.divider}>
<span>{t('common.continueWith')}</span>
</div>
<Button variant="secondary" fullWidth>
<Github size={18} />
GitHub
</Button>
{hasUsers && (
<p className={styles.footer}>
{t('auth.signup.hasAccount')} <Link to="/login">{t('auth.signup.signInLink')}</Link>
</p>
)}
</Card>
</div>
);
};
@@ -0,0 +1,310 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
display: flex;
align-items: center;
justify-content: space-between;
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.quickActions {
display: flex;
align-items: center;
gap: var(--space-3);
@media (max-width: 768px) {
flex-wrap: wrap;
}
}
.actionBtn {
display: flex;
align-items: center;
gap: var(--space-2);
}
.createButton {
display: flex;
align-items: center;
gap: var(--space-2);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-6);
margin-bottom: var(--space-8);
@media (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
.statCard {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-4);
}
.statIcon {
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.statValue {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--color-gray-85);
line-height: 1;
}
.statLabel {
font-size: var(--text-sm);
color: var(--color-muted);
margin-top: var(--space-1);
}
.chartBarWrap {
position: relative;
width: 100%;
height: 6px;
margin-top: var(--space-3);
border-radius: var(--border-radius-full);
overflow: hidden;
}
.chartBarBg {
position: absolute;
inset: 0;
background: var(--color-gray-20);
border-radius: var(--border-radius-full);
}
.chartBar {
position: absolute;
inset: 0;
border-radius: var(--border-radius-full);
transition: width 0.4s var(--ease-out);
}
.activityResource {
display: inline-block;
padding: 1px 6px;
border-radius: var(--border-radius-sm);
background: var(--color-surface-low);
color: var(--color-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-left: var(--space-1);
}
.twoColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
.column {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.empty {
text-align: center;
padding: var(--space-8);
}
.emptySub {
color: var(--color-muted);
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.drawingList {
list-style: none;
}
.drawingItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.drawingThumb {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.drawingInfo {
flex: 1;
min-width: 0;
}
.drawingTitle {
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawingMeta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.templateGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
.templateCard {
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out);
&:hover {
transform: translateY(-2px);
}
}
.templatePreview {
aspect-ratio: 16 / 10;
border-radius: var(--border-radius-md);
overflow: hidden;
background: var(--color-surface-low);
margin-bottom: var(--space-2);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.templatePlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.templateName {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-70);
text-align: center;
}
.activityCard {
margin-top: var(--space-6);
}
.activityList {
list-style: none;
}
.activityItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.activityAvatar {
width: 32px;
height: 32px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 600;
flex-shrink: 0;
}
.activityInfo {
flex: 1;
}
.activityText {
font-size: var(--text-sm);
color: var(--color-gray-80);
}
.activityTime {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
+269
View File
@@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Clock, Star, Users, FileText, Plus, Loader2, FolderPlus, UserPlus, BookOpen, Activity } from 'lucide-react';
import { Button, Card, CardHeader, CardContent, TemplatePicker } from '@/components';
import { useDrawingStore, useAuthStore } from '@/stores';
import { api } from '@/services';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import type { PickedTemplate } from '@/components/TemplatePicker/TemplatePicker';
import styles from './Dashboard.module.scss';
const StatChart: React.FC<{ value: number; max: number; color?: string }> = ({ value, max, color = '#6965db' }) => {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div className={styles.chartBarWrap} aria-hidden="true">
<div className={styles.chartBarBg} />
<div className={styles.chartBar} style={{ width: `${pct}%`, background: color }} />
</div>
);
};
export const Dashboard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { recentDrawings, setRecentDrawings, activity, setActivity } = useDrawingStore();
const { user } = useAuthStore();
const [isCreating, setIsCreating] = useState(false);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [statsData, setStatsData] = useState({
teams: 0,
members: 0,
projects: 0,
folders: 0,
drawings: 0,
templates: 0,
revisions: 0,
assets: 0,
storage_bytes: 0,
});
useEffect(() => {
const loadData = async () => {
try {
const [drawings, stats, activityData] = await Promise.all([
api.drawings.list(),
api.stats.get(),
api.activity.list(),
]);
setRecentDrawings(drawings);
setStatsData(stats);
setActivity(activityData);
} catch (err) {
console.error('Failed to load dashboard data:', err);
}
};
loadData();
}, [setRecentDrawings, setActivity]);
const handleCreateDrawing = async (template: PickedTemplate = 'blank') => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: template === 'blank' ? 'Untitled Drawing' : `${template.charAt(0).toUpperCase() + template.slice(1)}`,
visibility: 'team',
});
setRecentDrawings([newDrawing, ...recentDrawings]);
if (template !== 'blank' && BUILTIN_TEMPLATES[template]) {
localStorage.setItem(`template_${newDrawing.id}`, JSON.stringify({
elements: BUILTIN_TEMPLATES[template],
appState: {},
files: {},
}));
}
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to create drawing:', err);
} finally {
setIsCreating(false);
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
const maxStat = Math.max(statsData.drawings, statsData.projects + statsData.folders, statsData.teams, statsData.revisions, 1);
const stats = [
{ label: t('dashboard.stats.drawings'), value: statsData.drawings, icon: FileText, color: '#6965db' },
{ label: t('dashboard.stats.projects'), value: statsData.projects + statsData.folders, icon: FolderPlus, color: '#4dabf7' },
{ label: t('dashboard.stats.teams'), value: statsData.teams, icon: Users, color: '#51cf66' },
{ label: t('dashboard.stats.revisions'), value: statsData.revisions, icon: Clock, color: '#fcc419' },
{ label: t('dashboard.stats.storage'), value: formatBytes(Number(statsData.storage_bytes)), raw: statsData.storage_bytes, icon: Star, color: '#ff6b6b' },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1>{t('dashboard.welcome', { name: user?.name || t('common.user') })}</h1>
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
</div>
<div className={styles.quickActions}>
<TemplatePicker
isOpen={showTemplatePicker}
onClose={() => setShowTemplatePicker(false)}
onSelect={(t) => { setShowTemplatePicker(false); handleCreateDrawing(t); }}
/>
<Button
variant="secondary"
onClick={() => navigate('/files')}
className={styles.actionBtn}
>
<FolderPlus size={16} />
New Project
</Button>
<Button
variant="secondary"
onClick={() => navigate('/team')}
className={styles.actionBtn}
>
<UserPlus size={16} />
Invite
</Button>
<Button
variant="secondary"
onClick={() => navigate('/library')}
className={styles.actionBtn}
>
<BookOpen size={16} />
Library
</Button>
<Button
onClick={() => setShowTemplatePicker(true)}
loading={isCreating}
className={styles.createButton}
>
{isCreating ? (
<Loader2 size={18} className={styles.spinner} />
) : (
<Plus size={18} />
)}
{t('dashboard.newDrawing')}
</Button>
</div>
</div>
<div className={styles.statsGrid}>
{stats.map((stat) => (
<Card key={stat.label}>
<CardContent className={styles.statCard}>
<div className={styles.statIcon}>
<stat.icon size={24} />
</div>
<div className={styles.statValue}>{stat.value}</div>
<div className={styles.statLabel}>{stat.label}</div>
<StatChart value={typeof stat.value === 'number' ? stat.value : 0} max={maxStat} color={stat.color} />
</CardContent>
</Card>
))}
</div>
<div className={styles.twoColumn}>
<div className={styles.column}>
<Card>
<CardHeader>
<h3>{t('dashboard.recentDrawings')}</h3>
</CardHeader>
<CardContent>
{recentDrawings.length === 0 ? (
<div className={styles.empty}>
<p>{t('dashboard.noDrawings')}</p>
<p className={styles.emptySub}>{t('dashboard.noDrawingsSub')}</p>
</div>
) : (
<ul className={styles.drawingList} role="list" aria-label="Recent drawings">
{recentDrawings.slice(0, 5).map((drawing) => (
<li
key={drawing.id}
className={styles.drawingItem}
role="listitem"
tabIndex={0}
onClick={() => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.drawingThumb}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.drawingInfo}>
<p className={styles.drawingTitle}>{drawing.title}</p>
<p className={styles.drawingMeta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<div className={styles.column}>
<Card className={styles.activityCard}>
<CardHeader>
<h3><Activity size={16} style={{ display: 'inline', marginRight: 8, verticalAlign: 'middle' }} />Recent Activity</h3>
</CardHeader>
<CardContent>
{activity.length === 0 ? (
<div className={styles.empty}>
<p className={styles.emptySub}>No recent activity</p>
</div>
) : (
<ul className={styles.activityList}>
{activity.slice(0, 8).map((event) => (
<li key={event.id} className={styles.activityItem}>
<div className={styles.activityAvatar}>
{event.actor?.name?.[0] || '?'}
</div>
<div className={styles.activityInfo}>
<p className={styles.activityText}>
<strong>{event.actor?.name || 'Unknown'}</strong>{' '}
{event.event_type.replace(/_/g, ' ')}{' '}
<span className={styles.activityResource}>{event.resource_type}</span>
</p>
<p className={styles.activityTime}>
{new Date(event.created_at).toLocaleString()}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,418 @@
@use '../../styles/variables' as *;
.container {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-surface-lowest);
}
.toolbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-4);
background: var(--island-bg-color);
border-bottom: 1px solid var(--color-gray-20);
}
.left {
display: flex;
align-items: center;
gap: var(--space-4);
}
.right {
display: flex;
align-items: center;
gap: var(--space-2);
}
.title {
font-weight: 500;
color: var(--color-gray-85);
font-size: var(--text-md);
}
.saveStatus {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-60);
}
.unsaved {
color: var(--color-warning);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.canvas {
flex: 1;
position: relative;
overflow: hidden;
:global(.excalidraw) {
width: 100%;
height: 100%;
}
}
.loadingCanvas {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-lowest);
color: var(--color-gray-60);
font-size: var(--text-md);
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-muted);
background: repeating-linear-gradient(
45deg,
var(--color-gray-10),
var(--color-gray-10) 10px,
transparent 10px,
transparent 20px
);
p {
margin: 0;
}
}
.loading,
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-4);
color: var(--color-gray-60);
}
.sub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.revisionBadge {
background: var(--color-primary);
color: white;
font-size: 10px;
border-radius: 999px;
padding: 1px 6px;
margin-left: 4px;
}
.canvasWrapper {
display: flex;
flex: 1;
overflow: hidden;
}
.canvasNarrow {
flex: 0 0 calc(100% - 280px);
}
.revisionPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.revisionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.revisionList {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.revisionItem {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
&:hover {
background: var(--color-gray-10);
}
}
.revisionActive {
background: var(--color-primary-10);
color: var(--color-primary);
&:hover {
background: var(--color-primary-20);
}
}
.revisionMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.revisionLabel {
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.revisionDate {
font-size: 11px;
color: var(--color-gray-60);
}
.revisionEditor {
font-size: 11px;
font-family: var(--font-mono);
color: var(--color-gray-50);
flex-shrink: 0;
}
.notesPanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.notesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.notesTextarea {
flex: 1;
resize: none;
border: none;
padding: var(--space-3) var(--space-4);
font-family: inherit;
font-size: var(--text-sm);
line-height: 1.5;
background: var(--color-surface-lowest);
color: var(--color-on-surface);
outline: none;
&::placeholder {
color: var(--color-gray-50);
}
}
.revisionEmpty {
text-align: center;
color: var(--color-gray-50);
font-size: var(--text-sm);
padding: var(--space-4);
}
.sidePanel {
width: 280px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
}
.sidePanelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-gray-20);
h3 {
margin: 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-gray-85);
}
}
.sidePanelContent {
flex: 1;
overflow-y: auto;
padding: var(--space-2);
}
.sidePanelItem {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-gray-85);
transition: background 0.15s ease;
margin-bottom: var(--space-1);
&:hover {
background: var(--color-gray-10);
}
}
.sidePanelItemTitle {
font-size: var(--text-sm);
font-weight: 500;
}
.sidePanelItemDesc {
font-size: 11px;
color: var(--color-gray-50);
}
.sidePanelSearch {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
border: 1px solid var(--color-gray-20);
svg {
color: var(--color-gray-50);
flex-shrink: 0;
}
}
.sidePanelInput {
background: transparent;
border: none;
outline: none;
color: var(--color-on-surface);
font-size: var(--text-sm);
width: 100%;
}
.sidePanelSelect {
width: 100%;
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-2);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
color: var(--color-on-surface);
font-size: var(--text-sm);
cursor: pointer;
}
.sidePanelLoading,
.sidePanelEmpty,
.sidePanelError {
text-align: center;
padding: var(--space-4);
font-size: var(--text-sm);
color: var(--color-gray-50);
}
.sidePanelError {
color: var(--color-danger);
}
@media (max-width: 768px) {
.toolbar {
height: auto;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
flex-wrap: wrap;
}
.left {
flex: 1;
min-width: 0;
gap: var(--space-2);
}
.title {
font-size: var(--text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.canvasNarrow {
flex: 1 !important;
}
.revisionPanel,
.notesPanel,
.sidePanel {
position: fixed;
right: 0;
top: 48px;
bottom: 0;
width: 100%;
max-width: 340px;
z-index: 80;
}
}
+616
View File
@@ -0,0 +1,616 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Save, Check, Loader2, History, ChevronRight, Bot, StickyNote, LayoutTemplate, BookOpen, Search } from 'lucide-react';
import { Button, ChatPanel } from '@/components';
import { BUILTIN_TEMPLATES } from '@/components/TemplatePicker/TemplatePicker';
import { useThemeStore } from '@/stores';
import { api } from '@/services';
import type { Drawing, DrawingRevision } from '@/types';
import styles from './Editor.module.scss';
// Dynamic import for Excalidraw to avoid SSR issues
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
interface ExcalidrawElement {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
[key: string]: unknown;
}
interface ExcalidrawState {
elements: ExcalidrawElement[];
appState: Record<string, unknown>;
files: Record<string, { dataURL: string; mimeType: string }>;
}
function prepareElementsForImport(sourceElements: any[], offsetX: number, offsetY: number): any[] {
if (!sourceElements || !sourceElements.length) return [];
const idMap = new Map<string, string>();
sourceElements.forEach((el: any) => {
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
});
return sourceElements.map((el: any) => {
const newEl = { ...el };
newEl.id = idMap.get(el.id) || el.id;
newEl.x = (el.x || 0) + offsetX;
newEl.y = (el.y || 0) + offsetY;
newEl.version = (el.version || 1) + 1;
newEl.versionNonce = Math.floor(Math.random() * 1000000);
newEl.updated = Date.now();
newEl.seed = Math.floor(Math.random() * 100000);
if (newEl.boundElements) {
newEl.boundElements = newEl.boundElements.map((be: any) => ({
...be,
id: idMap.get(be.id) || be.id,
}));
}
if (newEl.containerId && idMap.has(newEl.containerId)) {
newEl.containerId = idMap.get(newEl.containerId);
}
return newEl;
});
}
export const Editor: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [drawing, setDrawing] = useState<Drawing | null>(null);
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
const [initialData, setInitialData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
const [error, setError] = useState<string | null>(null);
const [showRevisions, setShowRevisions] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showNotes, setShowNotes] = useState(false);
const [notes, setNotes] = useState('');
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
const { theme: appTheme } = useThemeStore();
const currentStateRef = useRef<ExcalidrawState | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedDataRef = useRef<string>('');
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
const [showTemplates, setShowTemplates] = useState(false);
const [showLibrary, setShowLibrary] = useState(false);
const [libraryItems, setLibraryItems] = useState<any[]>([]);
const [libraryFiltered, setLibraryFiltered] = useState<any[]>([]);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState('');
const [librarySearch, setLibrarySearch] = useState('');
const [libraryCategory, setLibraryCategory] = useState('All');
// Load drawing data
useEffect(() => {
const loadDrawing = async () => {
if (!id) return;
try {
setIsLoading(true);
const [drawingData, revisionsData] = await Promise.all([
api.drawings.get(id),
api.revisions.list(id),
]);
setDrawing(drawingData);
setRevisions(revisionsData);
// Load latest revision data if available
if (revisionsData.length > 0 && revisionsData[0].snapshot) {
const snapshot = JSON.parse(String(revisionsData[0].snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
} else {
// Check for pending template from dashboard
const pendingTemplate = localStorage.getItem(`template_${id}`);
if (pendingTemplate) {
const tpl = JSON.parse(pendingTemplate);
setInitialData({
elements: tpl.elements || [],
appState: tpl.appState || {},
files: tpl.files || {},
});
lastSavedDataRef.current = JSON.stringify(tpl);
localStorage.removeItem(`template_${id}`);
} else {
// Start with empty canvas
setInitialData({
elements: [],
appState: {},
files: {},
});
lastSavedDataRef.current = JSON.stringify({ elements: [], appState: {}, files: {} });
}
}
} catch (err) {
setError('Failed to load drawing');
console.error(err);
} finally {
setIsLoading(false);
}
};
loadDrawing();
}, [id]);
// Handle changes from Excalidraw
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
currentStateRef.current = {
elements: elements as ExcalidrawElement[],
appState,
files,
};
setSaveStatus('unsaved');
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
saveDrawing();
}, 2000);
}, []);
// Auto-save functionality
const saveDrawing = useCallback(async () => {
if (!id || !currentStateRef.current || isSaving) return;
const { elements, appState, files } = currentStateRef.current;
const snapshot = {
type: 'excalidraw',
version: 2,
source: window.location.hostname,
elements,
appState: {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize,
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
theme: appState.theme,
zenModeEnabled: appState.zenModeEnabled,
viewModeEnabled: appState.viewModeEnabled,
editingGroup: appState.editingGroup,
selectedElementIds: appState.selectedElementIds,
},
files,
};
const snapshotJson = JSON.stringify(snapshot);
if (snapshotJson === lastSavedDataRef.current) {
setSaveStatus('saved');
return;
}
try {
setIsSaving(true);
setSaveStatus('saving');
await api.revisions.create(id, snapshot, 'Auto-save');
lastSavedDataRef.current = snapshotJson;
setSaveStatus('saved');
} catch (err) {
console.error('Failed to save:', err);
setSaveStatus('unsaved');
} finally {
setIsSaving(false);
}
}, [id, isSaving]);
// Remove unused revisions warning by displaying count in UI
const revisionCount = revisions.length;
// Restore a specific revision
const handleRestoreRevision = (revision: DrawingRevision) => {
if (!revision.snapshot) return;
try {
const snapshot = JSON.parse(String(revision.snapshot));
setInitialData({
elements: snapshot.elements || [],
appState: snapshot.appState || {},
files: snapshot.files || {},
});
lastSavedDataRef.current = JSON.stringify(snapshot);
setSelectedRevision(revision.id);
setSaveStatus('saved');
} catch (err) {
console.error('Failed to restore revision:', err);
}
};
// Manual save
const handleManualSave = async () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
await saveDrawing();
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
// Load library marketplace when panel opens
useEffect(() => {
if (!showLibrary || libraryItems.length > 0) return;
const load = async () => {
setLibraryLoading(true);
try {
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items = Object.entries(data).map(([key, lib]: [string, any]) => ({
key,
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraryItems(items);
setLibraryFiltered(items);
} catch (err) {
console.error(err);
setLibraryError('Could not load library marketplace.');
} finally {
setLibraryLoading(false);
}
};
load();
}, [showLibrary, libraryItems.length]);
// Filter library items
useEffect(() => {
let result = libraryItems;
if (librarySearch.trim()) {
const q = librarySearch.toLowerCase();
result = result.filter((l: any) =>
l.name.toLowerCase().includes(q) ||
l.description.toLowerCase().includes(q) ||
l.tags.some((t: string) => t.toLowerCase().includes(q))
);
}
if (libraryCategory !== 'All') {
result = result.filter((l: any) => l.tags.some((t: string) => t.toLowerCase() === libraryCategory.toLowerCase()));
}
setLibraryFiltered(result);
}, [librarySearch, libraryCategory, libraryItems]);
const handleLoadTemplate = (templateKey: string) => {
const templateElements = BUILTIN_TEMPLATES[templateKey as keyof typeof BUILTIN_TEMPLATES];
if (!templateElements || !excalidrawAPI) return;
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowTemplates(false);
setSaveStatus('unsaved');
};
const handleLoadLibraryItem = async (item: any) => {
if (!excalidrawAPI || !item.source) return;
try {
const res = await fetch(item.source);
if (!res.ok) throw new Error('Failed to load library');
const libData = await res.json();
let sourceElements: any[] = [];
if (libData.libraryItems && Array.isArray(libData.libraryItems)) {
sourceElements = libData.libraryItems[0]?.elements || [];
} else if (Array.isArray(libData)) {
sourceElements = libData;
} else if (libData.elements && Array.isArray(libData.elements)) {
sourceElements = libData.elements;
}
if (!sourceElements.length) {
alert('This library appears to be empty');
return;
}
const currentElements = excalidrawAPI.getSceneElements?.() || [];
let offsetX = 100;
let offsetY = 100;
if (currentElements.length > 0) {
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
offsetX = maxX + 100;
}
const newElements = prepareElementsForImport(sourceElements, offsetX, offsetY);
const mergedElements = [...currentElements, ...newElements];
excalidrawAPI.updateScene({ elements: mergedElements });
setShowLibrary(false);
setSaveStatus('unsaved');
} catch (err) {
console.error('Failed to load library item:', err);
alert('Failed to load library item');
}
};
const templateOptions = [
{ id: 'blank', label: 'Blank', description: 'Empty canvas start', icon: null },
{ id: 'todo', label: 'To-Do List', description: 'Checkbox tasks', icon: null },
{ id: 'checklist', label: 'Checklist', description: 'Status checklist', icon: null },
{ id: 'list', label: 'Bullet List', description: 'Bulleted notes', icon: null },
{ id: 'flow', label: 'Flow Chart', description: 'Process diagram', icon: null },
];
const libraryCategories = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !drawing) {
return (
<div className={styles.container}>
<div className={styles.error}>
<p>{error || t('editor.notFound')}</p>
<Button onClick={() => navigate('/')}>{t('editor.goToDashboard')}</Button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<div className={styles.left}>
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft size={18} />
{t('editor.back')}
</Button>
<span className={styles.title}>{drawing.title}</span>
<span className={styles.saveStatus}>
{saveStatus === 'saving' && <><Loader2 size={14} className={styles.spinner} /> {t('editor.saving')}</>}
{saveStatus === 'saved' && <><Check size={14} /> {t('editor.saved')} {revisionCount > 0 && `(${revisionCount} ${t('editor.revisions')})`}</>}
{saveStatus === 'unsaved' && <span className={styles.unsaved}>{t('editor.unsaved')}</span>}
</span>
</div>
<div className={styles.right}>
<Button
variant="ghost"
size="sm"
onClick={() => setShowChat(!showChat)}
title="AI Assistant"
aria-pressed={showChat}
aria-label="Toggle AI chat panel"
>
<Bot size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowNotes(!showNotes)}
title="Presenter notes"
aria-pressed={showNotes}
aria-label="Toggle presenter notes"
>
<StickyNote size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRevisions(!showRevisions)}
title={t('editor.revisionBrowser')}
aria-pressed={showRevisions}
aria-label="Toggle revision browser"
>
<History size={16} />
{revisionCount > 0 && <span className={styles.revisionBadge}>{revisionCount}</span>}
</Button>
<Button
size="sm"
onClick={handleManualSave}
loading={isSaving}
disabled={saveStatus === 'saved'}
>
<Save size={16} />
{t('editor.saveNow')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowTemplates(!showTemplates); setShowLibrary(false); }}
title="Templates"
aria-pressed={showTemplates}
aria-label="Toggle templates panel"
>
<LayoutTemplate size={16} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowLibrary(!showLibrary); setShowTemplates(false); }}
title="Library Marketplace"
aria-pressed={showLibrary}
aria-label="Toggle library panel"
>
<BookOpen size={16} />
</Button>
</div>
</div>
<div className={styles.canvasWrapper}>
<div className={`${styles.canvas} ${(showRevisions || showNotes || showTemplates || showLibrary) ? styles.canvasNarrow : ''}`}>
{initialData && (
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
<Excalidraw
excalidrawAPI={(api: any) => setExcalidrawAPI(api)}
initialData={initialData}
onChange={handleExcalidrawChange}
theme={appTheme === 'dark' ? 'dark' : 'light'}
gridModeEnabled={true}
UIOptions={{
canvasActions: {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
},
}}
/>
</React.Suspense>
)}
</div>
{showRevisions && (
<div className={styles.revisionPanel}>
<div className={styles.revisionHeader}>
<h3>{t('editor.revisionBrowser')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowRevisions(false)}>
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.revisionList}>
{revisions.length === 0 ? (
<p className={styles.revisionEmpty}>{t('editor.noRevisions')}</p>
) : (
revisions.map((rev) => (
<button
key={rev.id}
className={`${styles.revisionItem} ${selectedRevision === rev.id ? styles.revisionActive : ''}`}
onClick={() => handleRestoreRevision(rev)}
>
<div className={styles.revisionMeta}>
<span className={styles.revisionLabel}>{rev.change_summary || t('editor.revision')}</span>
<span className={styles.revisionDate}>
{new Date(rev.created_at).toLocaleString()}
</span>
</div>
{rev.created_by && (
<span className={styles.revisionEditor}>{rev.created_by.slice(0, 8)}</span>
)}
</button>
))
)}
</div>
</div>
)}
{showNotes && (
<div className={styles.notesPanel} role="complementary" aria-label={t('editor.presenterNotes')}>
<div className={styles.notesHeader}>
<h3>{t('editor.presenterNotes')}</h3>
<Button variant="ghost" size="sm" onClick={() => setShowNotes(false)} aria-label={t('common.close')}>
<ChevronRight size={16} />
</Button>
</div>
<textarea
className={styles.notesTextarea}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('editor.notesPlaceholder')}
aria-label={t('editor.presenterNotes')}
/>
</div>
)}
{showTemplates && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Templates</h3>
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
{templateOptions.map((opt) => (
<button
key={opt.id}
className={styles.sidePanelItem}
onClick={() => handleLoadTemplate(opt.id)}
>
<span className={styles.sidePanelItemTitle}>{opt.label}</span>
<span className={styles.sidePanelItemDesc}>{opt.description}</span>
</button>
))}
</div>
</div>
)}
{showLibrary && (
<div className={styles.sidePanel}>
<div className={styles.sidePanelHeader}>
<h3>Library Marketplace</h3>
<Button variant="ghost" size="sm" onClick={() => setShowLibrary(false)} aria-label="Close">
<ChevronRight size={16} />
</Button>
</div>
<div className={styles.sidePanelContent}>
<div className={styles.sidePanelSearch}>
<Search size={14} />
<input
type="text"
placeholder="Search libraries..."
value={librarySearch}
onChange={(e) => setLibrarySearch(e.target.value)}
className={styles.sidePanelInput}
/>
</div>
<select
className={styles.sidePanelSelect}
value={libraryCategory}
onChange={(e) => setLibraryCategory(e.target.value)}
>
{libraryCategories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{libraryLoading && (
<div className={styles.sidePanelLoading}>
<Loader2 size={20} className={styles.spinner} />
<span>Loading...</span>
</div>
)}
{libraryError && (
<div className={styles.sidePanelError}>{libraryError}</div>
)}
{!libraryLoading && !libraryError && libraryFiltered.length === 0 && (
<div className={styles.sidePanelEmpty}>No libraries found</div>
)}
{!libraryLoading && libraryFiltered.map((item: any) => (
<button
key={item.key}
className={styles.sidePanelItem}
onClick={() => handleLoadLibraryItem(item)}
>
<span className={styles.sidePanelItemTitle}>{item.name}</span>
<span className={styles.sidePanelItemDesc}>{item.description || item.tags.slice(0, 3).join(', ')}</span>
</button>
))}
</div>
</div>
)}
{showChat && (
<ChatPanel
onClose={() => setShowChat(false)}
drawingContext={drawing?.title}
/>
)}
</div>
</div>
);
};
@@ -0,0 +1,398 @@
@use '../../styles/variables' as *;
.container {
height: 100%;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
flex-wrap: wrap;
@media (max-width: 640px) {
flex-direction: column;
align-items: flex-start;
}
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-lg);
font-weight: 500;
color: var(--color-gray-85);
svg {
color: var(--color-muted);
}
}
.breadcrumbLink {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--text-lg);
font-weight: 500;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.breadcrumbCurrent {
color: var(--color-gray-85);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.filterSelect {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
outline: none;
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.viewToggle {
background: var(--island-bg-color);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-md);
padding: var(--space-2);
color: var(--color-muted);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover, &.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
border-color: var(--color-primary-light);
}
}
.content {
display: flex;
gap: var(--space-6);
flex: 1;
@media (max-width: 768px) {
flex-direction: column;
gap: var(--space-4);
}
}
.sidebar {
width: 200px;
flex-shrink: 0;
@media (max-width: 768px) {
width: 100%;
}
}
.folderTree {
display: flex;
flex-direction: column;
gap: var(--space-1);
list-style: none;
padding: 0;
margin: 0;
}
.folderItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
background: none;
border: none;
width: 100%;
text-align: left;
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.folderActive {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
svg {
color: var(--color-primary);
flex-shrink: 0;
}
}
.grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-4);
align-content: start;
@media (max-width: 640px) {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
}
.list {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
height: 100%;
color: var(--color-muted);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.drawingCard {
position: relative;
}
.thumbnail {
aspect-ratio: 4 / 3;
background: var(--color-surface-low);
border-radius: var(--border-radius-md);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
}
.info {
padding: var(--space-3);
}
.title {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: var(--text-xs);
color: var(--color-muted);
margin-top: var(--space-1);
}
.more {
position: absolute;
top: var(--space-2);
right: var(--space-2);
background: var(--island-bg-color);
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-1);
color: var(--color-muted);
cursor: pointer;
opacity: 0;
transition: all var(--duration-fast) var(--ease-out);
.drawingCard:hover & {
opacity: 1;
}
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
.moreWrap {
position: absolute;
top: var(--space-2);
right: var(--space-2);
}
.dropdown {
position: absolute;
top: calc(100% + var(--space-1));
right: 0;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-island);
min-width: 160px;
z-index: 10;
display: flex;
flex-direction: column;
padding: var(--space-1);
}
.dropdownItem {
background: none;
border: none;
text-align: left;
padding: var(--space-2) var(--space-3);
cursor: pointer;
border-radius: var(--border-radius-sm);
color: var(--color-on-surface);
font-size: var(--text-sm);
&:hover {
background: var(--color-surface-low);
}
}
.dropdownDanger {
color: #e03131;
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.dropdownDivider {
height: 1px;
background: var(--default-border-color);
margin: var(--space-1) 0;
}
.dropdownSubmenu {
display: flex;
flex-direction: column;
}
.dropdownSubheader {
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.newProjectForm {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.newProjectInput {
flex: 1;
min-width: 120px;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.newProjectBtn {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
&:hover {
background: var(--color-primary-darkest);
}
}
.newProjectBtnCancel {
background: none;
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-on-surface);
&:hover {
background: var(--color-surface-low);
}
}
.renameInput {
width: 100%;
background: var(--input-bg-color);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
padding: var(--space-1) var(--space-2);
color: var(--text-primary-color);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--color-primary);
}
}
@@ -0,0 +1,476 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2 } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Drawing } from '@/types';
import styles from './FileBrowser.module.scss';
export const FileBrowser: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const urlParams = useParams<{ folderId?: string }>();
const { drawings, folders, setDrawings, setFolders } = useDrawingStore();
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState<'name' | 'updated' | 'created'>('updated');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [visibilityFilter, setVisibilityFilter] = useState<'all' | 'private' | 'team' | 'public-link'>('all');
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [activeFolderId, setActiveFolderId] = useState<string | null>(urlParams.folderId || null);
// Dropdown menu state
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
// New project (folder) state
const [showNewProject, setShowNewProject] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
// Move state
const [movingId, setMovingId] = useState<string | null>(null);
// Modal state
const [modal, setModal] = useState<{
open: boolean;
type: 'confirm' | 'alert' | 'info';
title: string;
message: string;
onConfirm?: () => void;
onCancel?: () => void;
}>({ open: false, type: 'info', title: '', message: '' });
const showModal = (type: 'confirm' | 'alert' | 'info', title: string, message: string, onConfirm?: () => void) => {
setModal({ open: true, type, title, message, onConfirm, onCancel: () => setModal(m => ({ ...m, open: false })) });
};
// Load real data on mount
useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true);
const [drawingsData, foldersData] = await Promise.all([
api.drawings.list(),
api.folders.list(),
]);
setDrawings(drawingsData);
setFolders(foldersData);
} catch (err) {
console.error('Failed to load file browser data:', err);
} finally {
setIsLoading(false);
}
};
loadData();
}, [setDrawings, setFolders]);
// Update active folder when URL changes
useEffect(() => {
setActiveFolderId(urlParams.folderId || null);
}, [urlParams.folderId]);
const activeFolder = folders.find((f) => f.id === activeFolderId);
// Filter drawings by active folder + visibility, then sort
let visibleDrawings = activeFolderId
? drawings.filter((d) => d.folder_id === activeFolderId)
: drawings;
if (visibilityFilter !== 'all') {
visibleDrawings = visibleDrawings.filter((d) => d.visibility === visibilityFilter);
}
visibleDrawings = [...visibleDrawings].sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.title.localeCompare(b.title);
else if (sortBy === 'updated') cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
else if (sortBy === 'created') cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
return sortOrder === 'asc' ? cmp : -cmp;
});
const handleFolderClick = useCallback(
(folderId: string | null) => {
setActiveFolderId(folderId);
if (folderId) {
navigate(`/files/folder/${folderId}`);
} else {
navigate('/files');
}
},
[navigate]
);
const handleDrawingClick = useCallback(
(drawing: Drawing) => {
if (drawing.folder_id) {
navigate(`/folder/${drawing.folder_id}/drawing/${drawing.id}`);
} else {
navigate(`/drawing/${drawing.id}`);
}
},
[navigate]
);
const handleCreateDrawing = async () => {
setIsCreating(true);
try {
const newDrawing = await api.drawings.create({
title: 'Untitled Drawing',
visibility: 'team',
folder_id: activeFolderId || null,
});
setDrawings([newDrawing, ...drawings]);
if (newDrawing.folder_id) {
navigate(`/folder/${newDrawing.folder_id}/drawing/${newDrawing.id}`);
} else {
navigate(`/drawing/${newDrawing.id}`);
}
} catch (err) {
console.error('Failed to create drawing:', err);
showModal('alert', 'Error', 'Failed to create drawing. Please try again.');
} finally {
setIsCreating(false);
}
};
const handleCreateFolder = async () => {
const name = newProjectName.trim();
if (!name) return;
try {
const newFolder = await api.folders.create({ name });
setFolders([...folders, newFolder]);
setShowNewProject(false);
setNewProjectName('');
navigate(`/files/folder/${newFolder.id}`);
} catch (err) {
console.error('Failed to create project:', err);
showModal('alert', 'Error', 'Failed to create project. Please try again.');
}
};
const handleDeleteDrawing = (drawing: Drawing) => {
showModal('confirm', 'Delete Drawing', `Delete "${drawing.title}"? This cannot be undone.`, async () => {
try {
await api.drawings.delete(drawing.id);
setDrawings(drawings.filter(d => d.id !== drawing.id));
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
console.error('Failed to delete drawing:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawing.'), 100);
}
});
};
const handleDuplicateDrawing = async (drawing: Drawing) => {
try {
const newDrawing = await api.drawings.create({
title: `Copy of ${drawing.title}`,
visibility: drawing.visibility,
folder_id: drawing.folder_id || null,
});
setDrawings([newDrawing, ...drawings]);
setActiveMenu(null);
navigate(`/drawing/${newDrawing.id}`);
} catch (err) {
console.error('Failed to duplicate drawing:', err);
showModal('alert', 'Error', 'Failed to duplicate drawing. Please try again.');
}
};
const handleRenameDrawing = async (drawing: Drawing) => {
const title = renameValue.trim();
if (!title || title === drawing.title) {
setRenamingId(null);
return;
}
try {
await api.drawings.update(drawing.id, { title });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, title } : d));
setRenamingId(null);
} catch (err) {
console.error('Failed to rename drawing:', err);
showModal('alert', 'Error', 'Failed to rename drawing. Please try again.');
}
};
const handleMoveDrawing = async (drawing: Drawing, folderId: string | null) => {
try {
await api.drawings.update(drawing.id, { folder_id: folderId });
setDrawings(drawings.map(d => d.id === drawing.id ? { ...d, folder_id: folderId } : d));
setMovingId(null);
setActiveMenu(null);
} catch (err) {
console.error('Failed to move drawing:', err);
showModal('alert', 'Error', 'Failed to move drawing. Please try again.');
}
};
// Close menu on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setActiveMenu(null);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
return (
<>
<Modal
isOpen={modal.open}
type={modal.type}
title={modal.title}
message={modal.message}
onConfirm={modal.onConfirm}
onCancel={modal.onCancel}
confirmText={modal.type === 'confirm' ? 'Delete' : 'OK'}
/>
<div className={styles.container} role="region" aria-label={t('fileBrowser.title')}>
<div className={styles.header}>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<button
className={styles.breadcrumbLink}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'page' : undefined}
>
All Projects
</button>
{activeFolder && (
<>
<ChevronRight size={16} aria-hidden="true" />
<span className={styles.breadcrumbCurrent} aria-current="page">
{activeFolder.name}
</span>
</>
)}
</nav>
<div className={styles.actions}>
<select
className={styles.filterSelect}
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value as any)}
aria-label="Filter by visibility"
title="Filter by visibility"
>
<option value="all">All</option>
<option value="private">Private</option>
<option value="team">Team</option>
<option value="public-link">Public</option>
</select>
<select
className={styles.filterSelect}
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [sb, so] = e.target.value.split('-');
setSortBy(sb as any);
setSortOrder(so as any);
}}
aria-label="Sort drawings"
title="Sort drawings"
>
<option value="updated-desc">Recently updated</option>
<option value="updated-asc">Oldest updated</option>
<option value="created-desc">Recently created</option>
<option value="created-asc">Oldest created</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
</select>
<button
className={`${styles.viewToggle} ${viewMode === 'grid' ? styles.active : ''}`}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
aria-pressed={viewMode === 'grid'}
>
<Grid size={18} />
</button>
<button
className={`${styles.viewToggle} ${viewMode === 'list' ? styles.active : ''}`}
onClick={() => setViewMode('list')}
aria-label="List view"
aria-pressed={viewMode === 'list'}
>
<List size={18} />
</button>
<Button onClick={handleCreateDrawing} loading={isCreating} aria-label="Create new drawing">
<Plus size={16} />
New Drawing
</Button>
<Button variant="secondary" onClick={() => { setShowNewProject(true); setNewProjectName(''); }} aria-label="Create new project">
<Folder size={16} />
New Project
</Button>
</div>
</div>
<div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && (
<div className={styles.newProjectForm}>
<input
type="text"
autoFocus
placeholder="Project name..."
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder();
if (e.key === 'Escape') { setShowNewProject(false); setNewProjectName(''); }
}}
className={styles.newProjectInput}
/>
<button className={styles.newProjectBtn} onClick={handleCreateFolder}>Create</button>
<button className={styles.newProjectBtnCancel} onClick={() => { setShowNewProject(false); setNewProjectName(''); }}>Cancel</button>
</div>
)}
<ul className={styles.folderTree} role="tree">
<li>
<button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)}
aria-current={!activeFolderId ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>All Projects</span>
</button>
</li>
{folders.map((folder) => (
<li key={folder.id}>
<button
className={`${styles.folderItem} ${activeFolderId === folder.id ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(folder.id)}
aria-current={activeFolderId === folder.id ? 'true' : undefined}
role="treeitem"
>
<Folder size={18} aria-hidden="true" />
<span>{folder.name}</span>
</button>
</li>
))}
</ul>
</aside>
<main className={viewMode === 'grid' ? styles.grid : styles.list} role="list" aria-label="Drawing list">
{visibleDrawings.length === 0 ? (
<div className={styles.empty} role="status">
<p>No drawings yet</p>
<p className={styles.emptySub}>
{activeFolder ? 'Create a new drawing in this project' : 'Create a new drawing or import existing files'}
</p>
</div>
) : (
visibleDrawings.map((drawing) => (
<Card
key={drawing.id}
className={styles.drawingCard}
hover
role="listitem"
tabIndex={0}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
<button
className={styles.more}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
>
<MoreVertical size={16} />
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
{movingId === drawing.id ? (
<div className={styles.dropdownSubmenu}>
<button className={styles.dropdownSubheader}>Move to:</button>
<button onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, null); }} className={styles.dropdownItem}>All Projects</button>
{folders.map(f => (
<button key={f.id} onClick={(e) => { e.stopPropagation(); handleMoveDrawing(drawing, f.id); }} className={styles.dropdownItem}>{f.name}</button>
))}
<button onClick={(e) => { e.stopPropagation(); setMovingId(null); }} className={styles.dropdownItem}>Cancel</button>
</div>
) : (
<button onClick={(e) => { e.stopPropagation(); setMovingId(drawing.id); }} className={styles.dropdownItem}>Move to...</button>
)}
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
))
)}
</main>
</div>
</div>
</>
);
};
@@ -0,0 +1,186 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-8);
h1 {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-2xl);
font-weight: 700;
margin: 0;
}
}
.subtitle {
color: var(--color-gray-60);
margin-top: var(--space-2);
}
.errorBanner {
background: var(--color-danger-background);
color: var(--color-danger-text);
padding: var(--space-4);
border-radius: var(--border-radius-lg);
margin-bottom: var(--space-6);
}
.filters {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-8);
}
.searchBox {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface-low);
border: 1px solid var(--color-gray-20);
border-radius: var(--border-radius-lg);
padding: var(--space-2) var(--space-4);
input {
border: none;
background: transparent;
outline: none;
flex: 1;
font-size: var(--text-base);
}
}
.categories {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
align-items: center;
}
.categoryChip {
padding: var(--space-1) var(--space-3);
border-radius: var(--border-radius-full);
border: 1px solid var(--color-gray-20);
background: var(--color-surface-lowest);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast);
&.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
&:hover:not(.active) {
background: var(--color-surface-low);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.libraryCard {
overflow: hidden;
}
.preview {
height: 160px;
background: var(--color-gray-10);
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-weight: 600;
margin: 0 0 var(--space-2);
}
.description {
font-size: var(--text-sm);
color: var(--color-gray-60);
margin: 0 0 var(--space-3);
}
.meta {
display: flex;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-gray-50);
margin-bottom: var(--space-3);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--border-radius-full);
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
.importBtn {
width: 100%;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-12);
color: var(--color-gray-50);
}
.emptySub {
font-size: var(--text-sm);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,183 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Download, Loader2, BookOpen, ExternalLink, Heart, Filter } from 'lucide-react';
import { Button, Card, CardContent, Input } from '@/components';
import { api } from '@/services';
import styles from './LibraryMarketplace.module.scss';
interface LibraryItem {
name: string;
description: string;
authors: { name: string; github?: string }[];
source: string;
preview?: string;
tags: string[];
downloads: number;
}
const CATEGORIES = ['All', 'Arrows', 'Charts', 'Cloud', 'Devops', 'Diagrams', 'Education', 'Food', 'Frames', 'Gaming', 'Icons', 'Illustrations', 'Machines', 'Misc', 'People', 'Software', 'Systems', 'Tech', 'Workflow'];
export const LibraryMarketplace: React.FC = () => {
const navigate = useNavigate();
const [libraries, setLibraries] = useState<LibraryItem[]>([]);
const [filtered, setFiltered] = useState<LibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState('All');
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
// Try to fetch from excalidraw libraries
const res = await fetch('https://libraries.excalidraw.com/libraries.json', {
headers: { Accept: 'application/json' }
});
if (!res.ok) throw new Error('Failed to load libraries');
const data = await res.json();
const items: LibraryItem[] = Object.entries(data).map(([key, lib]: [string, any]) => ({
name: lib.name || key,
description: lib.description || '',
authors: lib.authors || [{ name: 'Unknown' }],
source: `https://libraries.excalidraw.com/${key}.excalidrawlib`,
preview: lib.preview?.startsWith('http') ? lib.preview : `https://libraries.excalidraw.com/${key}.png`,
tags: lib.tags || [],
downloads: lib.downloads || 0,
}));
setLibraries(items);
setFiltered(items);
} catch (err) {
console.error(err);
setError('Could not load library marketplace. You can still browse libraries at libraries.excalidraw.com');
// Fallback: show some popular libraries as placeholders
setLibraries([
{ name: 'Software Architecture', description: 'Common architecture diagrams and icons', authors: [{ name: 'Excalidraw Community' }], source: '', preview: '', tags: ['Software', 'Architecture'], downloads: 0 },
{ name: 'AWS Icons', description: 'Amazon Web Services icons', authors: [{ name: 'AWS' }], source: '', preview: '', tags: ['Cloud', 'AWS'], downloads: 0 },
{ name: 'Kubernetes', description: 'K8s components and diagrams', authors: [{ name: 'K8s Community' }], source: '', preview: '', tags: ['Devops', 'Cloud'], downloads: 0 },
]);
} finally {
setLoading(false);
}
};
load();
}, []);
useEffect(() => {
let result = libraries;
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(l => l.name.toLowerCase().includes(q) || l.description.toLowerCase().includes(q) || l.tags.some(t => t.toLowerCase().includes(q)));
}
if (activeCategory !== 'All') {
result = result.filter(l => l.tags.some(t => t.toLowerCase() === activeCategory.toLowerCase()));
}
setFiltered(result);
}, [search, activeCategory, libraries]);
const handleImport = useCallback(async (lib: LibraryItem) => {
if (!lib.source) {
window.open('https://libraries.excalidraw.com', '_blank');
return;
}
try {
// Create a new drawing and navigate to it, the library will be loaded client-side
const drawing = await api.drawings.create({
title: lib.name,
visibility: 'team',
});
// Store selected library in localStorage for the editor to pick up
localStorage.setItem('pending_library', JSON.stringify({ drawingId: drawing.id, source: lib.source }));
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from library:', err);
}
}, [navigate]);
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
<Loader2 size={32} className={styles.spinner} />
<p>Loading library marketplace...</p>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1><BookOpen size={24} /> Library Marketplace</h1>
<p className={styles.subtitle}>Browse and import templates from the Excalidraw community library</p>
</div>
<Button variant="secondary" onClick={() => window.open('https://libraries.excalidraw.com', '_blank')}>
<ExternalLink size={16} /> Open External
</Button>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<div className={styles.filters}>
<div className={styles.searchBox}>
<Search size={16} />
<Input
placeholder="Search libraries..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
</div>
<div className={styles.categories}>
<Filter size={16} />
{CATEGORIES.map(cat => (
<button
key={cat}
className={`${styles.categoryChip} ${activeCategory === cat ? styles.active : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat}
</button>
))}
</div>
</div>
<div className={styles.grid}>
{filtered.length === 0 ? (
<div className={styles.empty}>
<BookOpen size={48} />
<p>No libraries found</p>
<p className={styles.emptySub}>Try a different search or category</p>
</div>
) : filtered.map((lib, idx) => (
<Card key={idx} className={styles.libraryCard} hover>
<div className={styles.preview}>
{lib.preview ? (
<img src={lib.preview} alt={lib.name} loading="lazy" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
) : (
<div className={styles.placeholder}><BookOpen size={32} /></div>
)}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{lib.name}</h4>
<p className={styles.description}>{lib.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.authors}>{lib.authors.map(a => a.name).join(', ')}</span>
{lib.downloads > 0 && <span className={styles.downloads}><Download size={12} /> {lib.downloads}</span>}
</div>
<div className={styles.tags}>
{lib.tags.slice(0, 4).map(tag => (
<span key={tag} className={styles.tag}>{tag}</span>
))}
</div>
<Button size="sm" className={styles.importBtn} onClick={() => handleImport(lib)}>
<Heart size={14} /> Import
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
};
@@ -0,0 +1,158 @@
@use '../../styles/variables' as *;
.container {
max-width: 800px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-8);
}
.sidebar {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.tab {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--border-radius-md);
color: var(--color-gray-70);
background: none;
border: none;
cursor: pointer;
font-size: var(--text-sm);
transition: all var(--duration-fast) var(--ease-out);
text-align: left;
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
font-weight: 500;
}
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.avatarSection {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.avatar {
width: 64px;
height: 64px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-2xl);
font-weight: 600;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: var(--space-4);
}
.toggleList {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.toggle {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-gray-70);
input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
}
}
.themeSelect {
margin-bottom: var(--space-4);
}
.label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--input-label-color);
margin-bottom: var(--space-3);
}
.themeOptions {
display: flex;
gap: var(--space-3);
}
.themeOption {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gray-30);
border-radius: var(--border-radius-md);
background: var(--island-bg-color);
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
&.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
}
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { User, Key, Bell, Palette, Sun, Moon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useAuthStore, useThemeStore } from '@/stores';
import styles from './Settings.module.scss';
export const UserSettings: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuthStore();
const { theme, setTheme } = useThemeStore();
const [activeTab, setActiveTab] = useState('profile');
const tabs = [
{ id: 'profile', label: t('userSettings.tabProfile'), icon: User },
{ id: 'account', label: t('userSettings.tabAccount'), icon: Key },
{ id: 'notifications', label: t('userSettings.tabNotifications'), icon: Bell },
{ id: 'appearance', label: t('userSettings.tabAppearance'), icon: Palette },
];
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>{t('userSettings.title')}</h1>
<p className={styles.subtitle}>{t('userSettings.subtitle')}</p>
</div>
<div className={styles.layout}>
<div className={styles.sidebar} role="tablist" aria-label="Settings tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
aria-label={tab.label}
>
<tab.icon size={18} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</div>
<div className={styles.content}>
{activeTab === 'profile' && (
<Card role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<CardHeader>
<h3>{t('userSettings.profileInfo')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<div className={styles.avatarSection}>
<div className={styles.avatar}>
{user?.avatar_url ? (
<img src={user.avatar_url} alt={user.name} />
) : (
user?.name?.[0] || '?'
)}
</div>
<Button variant="secondary" size="sm">{t('userSettings.changeAvatar')}</Button>
</div>
<Input label={t('auth.signup.nameLabel')} defaultValue={user?.name} />
<Input label={t('userSettings.username')} defaultValue={user?.username} />
<Input label={t('auth.login.emailLabel')} type="email" defaultValue={user?.email} />
<div className={styles.actions}>
<Button>{t('userSettings.saveChanges')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'account' && (
<Card role="tabpanel" id="panel-account" aria-labelledby="tab-account">
<CardHeader>
<h3>{t('userSettings.accountSecurity')}</h3>
</CardHeader>
<CardContent>
<div className={styles.form}>
<Input label={t('userSettings.currentPassword')} type="password" />
<Input label={t('userSettings.newPassword')} type="password" />
<Input label={t('userSettings.confirmPassword')} type="password" />
<div className={styles.actions}>
<Button>{t('userSettings.updatePassword')}</Button>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === 'notifications' && (
<Card role="tabpanel" id="panel-notifications" aria-labelledby="tab-notifications">
<CardHeader>
<h3>{t('userSettings.notificationPrefs')}</h3>
</CardHeader>
<CardContent>
<div className={styles.toggleList}>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailMentions')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" defaultChecked />
<span>{t('userSettings.emailInvites')}</span>
</label>
<label className={styles.toggle}>
<input type="checkbox" />
<span>{t('userSettings.weeklySummary')}</span>
</label>
</div>
</CardContent>
</Card>
)}
{activeTab === 'appearance' && (
<Card role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance">
<CardHeader>
<h3>{t('userSettings.appearance')}</h3>
</CardHeader>
<CardContent>
<div className={styles.themeSelect}>
<p className={styles.label}>{t('userSettings.theme')}</p>
<div className={styles.themeOptions}>
<button
className={`${styles.themeOption} ${theme === 'light' ? styles.active : ''}`}
onClick={() => setTheme('light')}
>
<Sun size={16} />
{t('userSettings.light')}
</button>
<button
className={`${styles.themeOption} ${theme === 'dark' ? styles.active : ''}`}
onClick={() => setTheme('dark')}
>
<Moon size={16} />
{t('userSettings.dark')}
</button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
};
+207
View File
@@ -0,0 +1,207 @@
@use '../../styles/variables' as *;
.container {
max-width: 900px;
margin: 0 auto;
}
.header {
margin-bottom: var(--space-8);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
font-size: var(--text-lg);
}
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--space-6);
}
.sidePanel {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.membersList {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.empty {
text-align: center;
padding: var(--space-8);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.memberItem {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.memberAvatar {
width: 40px;
height: 40px;
border-radius: var(--border-radius-full);
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
}
.memberInfo {
flex: 1;
}
.memberName {
font-weight: 500;
color: var(--color-gray-85);
}
.memberEmail {
font-size: var(--text-sm);
color: var(--color-muted);
}
.memberRole {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-surface-low);
border-radius: var(--border-radius-full);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-gray-70);
text-transform: capitalize;
}
.inviteForm {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.inviteInput {
padding: var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
}
.pendingCard {
margin-top: var(--space-2);
}
.inviteItem {
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-gray-20);
&:last-child {
border-bottom: none;
}
}
.inviteEmail {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-80);
}
.inviteRole {
font-size: var(--text-xs);
color: var(--color-muted);
text-transform: capitalize;
margin-top: var(--space-1);
}
.roleLabel {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-gray-70);
}
.roleSelect {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--input-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
background: var(--input-bg-color);
cursor: pointer;
}
.error {
color: var(--color-danger-text);
font-size: var(--text-sm);
background: var(--color-danger-background);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.success {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-success-text);
font-size: var(--text-sm);
background: var(--color-success);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-md);
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+185
View File
@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import { Users, Crown, Shield, User, Loader2, Check, UserPlus } from 'lucide-react';
import { Card, CardHeader, CardContent, Button, Input } from '@/components';
import { useTeamStore } from '@/stores';
import { api } from '@/services';
import styles from './Team.module.scss';
const roleIcons: Record<string, React.ElementType> = {
owner: Crown,
admin: Shield,
editor: User,
viewer: User,
};
const ROLES = ['viewer', 'editor', 'admin'];
export const TeamSettings: React.FC = () => {
const { currentTeam, members, setMembers, setCurrentTeam } = useTeamStore();
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('editor');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const [teamsData, membersData] = await Promise.all([
api.teams.list(),
currentTeam ? api.teams.members(currentTeam.id) : Promise.resolve([]),
]);
if (teamsData.length > 0) {
setCurrentTeam(teamsData[0]);
}
setMembers(membersData);
} catch (err) {
console.error('Failed to load team data:', err);
} finally {
setLoading(false);
}
};
load();
}, [currentTeam?.id, setMembers, setCurrentTeam]);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim() || !newEmail.trim() || !newPassword.trim() || !currentTeam) return;
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setSending(true);
setError('');
setSent(false);
try {
await api.teams.createUser(currentTeam.id, {
name: newName.trim(),
email: newEmail.trim(),
password: newPassword,
role: newRole,
});
const membersData = await api.teams.members(currentTeam.id);
setMembers(membersData);
setSent(true);
setNewName('');
setNewEmail('');
setNewPassword('');
setNewRole('editor');
} catch (err: any) {
setError(err?.message || 'Failed to create user');
} finally {
setSending(false);
}
};
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}><Loader2 size={32} className={styles.spinner} /><p>Loading team...</p></div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>Team Settings</h1>
<p className={styles.subtitle} aria-label="Current team">{currentTeam?.name || 'My Team'}</p>
</div>
<div className={styles.grid}>
<Card role="region" aria-label="Team members">
<CardHeader>
<h3>Members ({members.length})</h3>
</CardHeader>
<CardContent>
<div className={styles.membersList} role="list" aria-label="Team members list">
{members.length === 0 ? (
<div className={styles.empty}>
<Users size={32} />
<p>No team members yet</p>
<p className={styles.emptySub}>Add members to collaborate</p>
</div>
) : (
members.map((member) => {
const RoleIcon = roleIcons[member.role] || User;
return (
<div key={member.id} className={styles.memberItem} role="listitem" aria-label={`Member ${member.user?.name || 'Unknown'}`}>
<div className={styles.memberAvatar} aria-hidden="true">
{member.user?.name?.[0] || '?'}
</div>
<div className={styles.memberInfo}>
<p className={styles.memberName}>{member.user?.name || 'Unknown'}</p>
<p className={styles.memberEmail}>{member.user?.email}</p>
</div>
<div className={styles.memberRole} aria-label={`Role: ${member.role}`}>
<RoleIcon size={14} aria-hidden="true" />
<span>{member.role}</span>
</div>
</div>
);
})
)}
</div>
</CardContent>
</Card>
<div className={styles.sidePanel}>
<Card role="region" aria-label="Add team member">
<CardHeader>
<h3>Add Member</h3>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateUser} className={styles.inviteForm}>
<Input
type="text"
label="Full name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Jane Doe"
required
className={styles.inviteInput}
/>
<Input
type="email"
label="Email address"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="jane@company.com"
required
className={styles.inviteInput}
/>
<Input
type="password"
label="Initial password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters"
required
className={styles.inviteInput}
/>
<label className={styles.roleLabel}>
Role
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className={styles.roleSelect}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</label>
{error && <p className={styles.error}>{error}</p>}
{sent && <p className={styles.success}><Check size={14} /> User created!</p>}
<Button fullWidth type="submit" loading={sending} disabled={!newName.trim() || !newEmail.trim() || !newPassword.trim()}>
<UserPlus size={16} />
Create User
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
</div>
);
};
@@ -0,0 +1,228 @@
@use '../../styles/variables' as *;
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
h1 {
font-size: var(--text-3xl);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-2);
}
}
.subtitle {
color: var(--color-muted);
}
.categories {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-6);
border-bottom: 1px solid var(--color-gray-20);
padding-bottom: var(--space-4);
}
.category {
padding: var(--space-2) var(--space-4);
border: none;
background: none;
color: var(--color-gray-70);
font-size: var(--text-sm);
cursor: pointer;
border-radius: var(--border-radius-md);
transition: all var(--duration-fast) var(--ease-out);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
&.active {
background: var(--color-surface-primary-container);
color: var(--color-primary);
font-weight: 500;
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
.empty {
grid-column: 1 / -1;
text-align: center;
padding: var(--space-16);
color: var(--color-muted);
svg {
margin-bottom: var(--space-4);
opacity: 0.5;
}
}
.emptySub {
font-size: var(--text-sm);
margin-top: var(--space-2);
}
.templateCard {
overflow: hidden;
}
.preview {
aspect-ratio: 16 / 10;
background: var(--color-surface-low);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-gray-20), var(--color-gray-30));
color: var(--color-gray-50);
}
.info {
padding: var(--space-4);
}
.name {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-gray-85);
margin-bottom: var(--space-1);
}
.description {
font-size: var(--text-sm);
color: var(--color-muted);
margin-bottom: var(--space-3);
}
.meta {
display: flex;
gap: var(--space-2);
}
.scope, .type {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--border-radius-full);
text-transform: capitalize;
font-weight: 500;
}
.scope {
background: var(--color-surface-primary-container);
color: var(--color-primary-darkest);
}
.type {
background: var(--color-gray-20);
color: var(--color-gray-70);
}
.useBtn {
margin-top: var(--space-3);
width: 100%;
}
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--space-4);
}
.modal {
background: var(--island-bg-color);
border-radius: var(--border-radius-xl);
box-shadow: var(--modal-shadow);
width: 100%;
max-width: 420px;
padding: var(--space-6);
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
h2 {
font-size: var(--text-xl);
font-weight: 600;
color: var(--color-gray-85);
}
button {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--border-radius-md);
&:hover {
background: var(--color-surface-low);
color: var(--color-on-surface);
}
}
}
.modalBody {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.spinner {
animation: spin 1s linear infinite;
align-self: center;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
.categories {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.grid {
grid-template-columns: 1fr;
}
}
+114
View File
@@ -0,0 +1,114 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Sparkles, X, Loader2, FilePlus } from 'lucide-react';
import { Card, CardContent, Button, Input } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
import type { Template, TemplateScope } from '@/types';
import styles from './Templates.module.scss';
const categories: { id: TemplateScope | 'all'; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'system', label: 'System' },
{ id: 'team', label: 'Team' },
{ id: 'personal', label: 'Personal' },
];
export const Templates: React.FC = () => {
const navigate = useNavigate();
const { templates, setTemplates, addDrawing } = useDrawingStore();
const [active, setActive] = useState<TemplateScope | 'all'>('all');
const [showModal, setShowModal] = useState(false);
const [creating, setCreating] = useState(false);
const [applyingId, setApplyingId] = useState<string | null>(null);
const [name, setName] = useState('');
const [error, setError] = useState('');
useEffect(() => {
api.templates.list().then(setTemplates).catch(console.error);
}, [setTemplates]);
const filtered = active === 'all' ? templates : templates.filter((t) => t.scope === active);
const handleCreate = async () => {
if (!name.trim()) { setError('Name required'); return; }
setCreating(true); setError('');
try {
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
setTemplates([t, ...templates]); setShowModal(false); setName('');
} catch (err) { setError('Create failed'); }
finally { setCreating(false); }
};
const handleUseTemplate = async (template: Template) => {
setApplyingId(template.id);
try {
const drawing = await api.drawings.create({
title: template.name,
visibility: 'team',
});
addDrawing(drawing);
navigate(`/drawing/${drawing.id}`);
} catch (err) {
console.error('Failed to create drawing from template:', err);
} finally {
setApplyingId(null);
}
};
return (
<div className={styles.container}>
<div className={styles.header}>
<div><h1>Templates</h1><p className={styles.subtitle}>Start from a template or create your own</p></div>
<Button onClick={() => setShowModal(true)}><Plus size={18} />Create</Button>
</div>
<div className={styles.categories} role="tablist">
{categories.map((c) => (
<button key={c.id} className={`${styles.category} ${active === c.id ? styles.active : ''}`}
onClick={() => setActive(c.id)} role="tab" aria-selected={active === c.id}>{c.label}</button>
))}
</div>
<div className={styles.grid} role="tabpanel">
{filtered.length === 0 ? (
<div className={styles.empty} role="status"><Sparkles size={48} aria-hidden="true" />
<p>No templates</p><p className={styles.emptySub}>Create your first template</p></div>
) : filtered.map((t) => (
<Card key={t.id} className={styles.templateCard} hover>
<div className={styles.preview}>
{t.preview_url ? <img src={t.preview_url} alt="" loading="lazy" /> : <div className={styles.placeholder} role="img" aria-label="No preview"><Sparkles size={32} aria-hidden="true" /></div>}
</div>
<CardContent className={styles.info}>
<h4 className={styles.name}>{t.name}</h4>
<p className={styles.description}>{t.description || 'No description'}</p>
<div className={styles.meta}>
<span className={styles.scope}>{t.scope}</span>
<span className={styles.type}>{t.type}</span>
</div>
<Button
size="sm"
className={styles.useBtn}
onClick={() => handleUseTemplate(t)}
loading={applyingId === t.id}
aria-label={`Use template ${t.name}`}
>
<FilePlus size={14} />
Use Template
</Button>
</CardContent>
</Card>
))}
</div>
{showModal && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="tm-title" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className={styles.modal}>
<div className={styles.modalHeader}><h2 id="tm-title">Create Template</h2><button onClick={() => setShowModal(false)} aria-label="Close"><X size={18} /></button></div>
<div className={styles.modalBody}>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} error={error} />
{creating ? <Loader2 className={styles.spinner} size={20} /> : <Button onClick={handleCreate}>Create</Button>}
</div>
</div>
</div>
)}
</div>
);
};
+7
View File
@@ -0,0 +1,7 @@
export { Dashboard } from './Dashboard/Dashboard';
export { Login } from './Auth/Login';
export { Signup } from './Auth/Signup';
export { FileBrowser } from './FileBrowser/FileBrowser';
export { TeamSettings } from './Team/TeamSettings';
export { UserSettings } from './Settings/UserSettings';
export { Templates } from './Templates/Templates';
+91
View File
@@ -0,0 +1,91 @@
import type { User, Session, Drawing, DrawingRevision, Team, TeamMembership, TeamInvite, Template, Folder, ActivityEvent } from '@/types';
const API_BASE = '/api';
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}
export const api = {
auth: {
me: (): Promise<User> => fetchApi('/auth/me'),
setupStatus: (): Promise<{ has_users: boolean }> => fetchApi('/auth/setup-status'),
login: (email: string, password: string): Promise<{ user: User; session: Session }> =>
fetchApi('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
signup: (name: string, email: string, password: string): Promise<{ user: User; session: Session }> =>
fetchApi('/auth/signup', { method: 'POST', body: JSON.stringify({ name, email, password }) }),
logout: (): Promise<void> => fetchApi('/auth/logout', { method: 'POST' }),
},
drawings: {
list: (teamId?: string): Promise<Drawing[]> =>
fetchApi(`/drawings${teamId ? `?team_id=${teamId}` : ''}`),
get: (id: string): Promise<Drawing> => fetchApi(`/drawings/${id}`),
create: (data: object): Promise<Drawing> =>
fetchApi('/drawings', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object): Promise<Drawing> =>
fetchApi(`/drawings/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string): Promise<void> =>
fetchApi(`/drawings/${id}`, { method: 'DELETE' }),
},
revisions: {
list: (drawingId: string): Promise<DrawingRevision[]> =>
fetchApi(`/drawings/${drawingId}/revisions`),
create: (drawingId: string, snapshot: object, changeSummary?: string): Promise<DrawingRevision> =>
fetchApi(`/drawings/${drawingId}/revisions`, {
method: 'POST',
body: JSON.stringify({ snapshot, change_summary: changeSummary }),
}),
},
folders: {
list: (): Promise<Folder[]> => fetchApi('/folders'),
create: (data: object): Promise<Folder> =>
fetchApi('/folders', { method: 'POST', body: JSON.stringify(data) }),
},
teams: {
list: (): Promise<Team[]> => fetchApi('/teams'),
create: (data: { name: string; slug: string }): Promise<Team> => fetchApi('/teams', { method: 'POST', body: JSON.stringify(data) }),
members: (teamId: string): Promise<TeamMembership[]> => fetchApi(`/teams/${teamId}/members`),
invites: (teamId: string): Promise<TeamInvite[]> => fetchApi(`/teams/${teamId}/invites`),
createInvite: (teamId: string, data: { email: string; role: string }): Promise<TeamInvite> => fetchApi(`/teams/${teamId}/invites`, { method: 'POST', body: JSON.stringify(data) }),
acceptInvite: (token: string): Promise<void> => fetchApi('/invites/accept', { method: 'POST', body: JSON.stringify({ token }) }),
createUser: (teamId: string, data: { name: string; email: string; password: string; role: string }): Promise<User> => fetchApi(`/teams/${teamId}/users`, { method: 'POST', body: JSON.stringify(data) }),
},
templates: {
list: (): Promise<Template[]> => fetchApi('/templates'),
create: (data: { name: string; type: string; scope: string }): Promise<Template> =>
fetchApi('/templates', { method: 'POST', body: JSON.stringify(data) }),
},
stats: {
get: (teamId?: string): Promise<{
teams: number;
members: number;
projects: number;
folders: number;
drawings: number;
templates: number;
revisions: number;
assets: number;
storage_bytes: number;
}> => fetchApi(`/stats${teamId ? `?team_id=${teamId}` : ''}`),
},
activity: {
list: (): Promise<ActivityEvent[]> => fetchApi('/activity'),
},
search: {
get: (q: string): Promise<Drawing[]> => fetchApi(`/search?q=${encodeURIComponent(q)}`),
},
};
+1
View File
@@ -0,0 +1 @@
export { api } from './api';
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand';
import type { User, Session } from '@/types';
interface AuthState {
user: User | null;
session: Session | null;
isLoading: boolean;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
setSession: (session: Session | null) => void;
setLoading: (loading: boolean) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
session: null,
isLoading: true,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setSession: (session) => set({ session }),
setLoading: (isLoading) => set({ isLoading }),
logout: () => set({ user: null, session: null, isAuthenticated: false }),
}));
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand';
import type { Drawing, Folder, Project, Template, ActivityEvent } from '@/types';
interface DrawingState {
drawings: Drawing[];
folders: Folder[];
projects: Project[];
templates: Template[];
recentDrawings: Drawing[];
activity: ActivityEvent[];
isLoading: boolean;
setDrawings: (drawings: Drawing[]) => void;
setFolders: (folders: Folder[]) => void;
setProjects: (projects: Project[]) => void;
setTemplates: (templates: Template[]) => void;
setRecentDrawings: (drawings: Drawing[]) => void;
setActivity: (activity: ActivityEvent[]) => void;
addDrawing: (drawing: Drawing) => void;
updateDrawing: (id: string, updates: Partial<Drawing>) => void;
removeDrawing: (id: string) => void;
setLoading: (loading: boolean) => void;
}
export const useDrawingStore = create<DrawingState>((set) => ({
drawings: [],
folders: [],
projects: [],
templates: [],
recentDrawings: [],
activity: [],
isLoading: false,
setDrawings: (drawings) => set({ drawings }),
setFolders: (folders) => set({ folders }),
setProjects: (projects) => set({ projects }),
setTemplates: (templates) => set({ templates }),
setRecentDrawings: (recentDrawings) => set({ recentDrawings }),
setActivity: (activity) => set({ activity }),
addDrawing: (drawing) => set((state) => ({ drawings: [drawing, ...state.drawings] })),
updateDrawing: (id, updates) =>
set((state) => ({
drawings: state.drawings.map((d) => (d.id === id ? { ...d, ...updates } : d)),
})),
removeDrawing: (id) =>
set((state) => ({
drawings: state.drawings.filter((d) => d.id !== id),
})),
setLoading: (isLoading) => set({ isLoading }),
}));
+4
View File
@@ -0,0 +1,4 @@
export { useAuthStore } from './authStore';
export { useTeamStore } from './teamStore';
export { useDrawingStore } from './drawingStore';
export { useThemeStore } from './themeStore';
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand';
import type { Team, TeamMembership, TeamInvite } from '@/types';
interface TeamState {
currentTeam: Team | null;
teams: Team[];
members: TeamMembership[];
invites: TeamInvite[];
isLoading: boolean;
setCurrentTeam: (team: Team | null) => void;
setTeams: (teams: Team[]) => void;
addTeam: (team: Team) => void;
removeTeam: (teamId: string) => void;
setMembers: (members: TeamMembership[]) => void;
setInvites: (invites: TeamInvite[]) => void;
setLoading: (loading: boolean) => void;
}
export const useTeamStore = create<TeamState>((set) => ({
currentTeam: null,
teams: [],
members: [],
invites: [],
isLoading: false,
setCurrentTeam: (team) => set({ currentTeam: team }),
setTeams: (teams) => set({ teams }),
addTeam: (team) => set((state) => ({ teams: [...state.teams, team] })),
removeTeam: (teamId) => set((state) => ({ teams: state.teams.filter((t) => t.id !== teamId) })),
setMembers: (members) => set({ members }),
setInvites: (invites) => set({ invites }),
setLoading: (isLoading) => set({ isLoading }),
}));
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useThemeStore } from './themeStore';
describe('themeStore', () => {
beforeEach(() => {
useThemeStore.setState({ theme: 'light' });
});
it('defaults to light', () => {
expect(useThemeStore.getState().theme).toBe('light');
});
it('toggles to dark', () => {
useThemeStore.getState().toggleTheme();
expect(useThemeStore.getState().theme).toBe('dark');
});
it('toggles back to light', () => {
useThemeStore.getState().setTheme('dark');
useThemeStore.getState().toggleTheme();
expect(useThemeStore.getState().theme).toBe('light');
});
});
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark';
const getInitialTheme = (): Theme => {
if (typeof document !== 'undefined') {
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark' || attr === 'light') return attr;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
};
const applyTheme = (theme: Theme) => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', theme);
}
};
interface ThemeState {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
_hasHydrated: boolean;
setHasHydrated: (hasHydrated: boolean) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: getInitialTheme(),
_hasHydrated: false,
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
},
toggleTheme: () => {
const next = get().theme === 'light' ? 'dark' : 'light';
applyTheme(next);
set({ theme: next });
},
}),
{
name: 'excalidraw-theme',
onRehydrateStorage: () => (state) => {
if (state) {
applyTheme(state.theme);
state.setHasHydrated(true);
}
},
}
)
);
+118
View File
@@ -0,0 +1,118 @@
@use './variables' as *;
// ============================================
// Reset & Base
// ============================================
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--ui-font);
font-size: var(--text-base);
line-height: 1.5;
color: var(--color-on-surface);
background-color: var(--color-surface-low);
min-height: 100vh;
}
// ============================================
// Typography
// ============================================
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.25;
color: var(--color-gray-85);
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
h5 { font-size: var(--text-lg); }
h6 { font-size: var(--text-base); }
p {
margin-bottom: var(--space-4);
}
a {
color: var(--link-color);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-out);
&:hover {
color: var(--link-color-hover);
text-decoration: underline;
}
}
// ============================================
// Utilities
// ============================================
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// ============================================
// Scrollbar
// ============================================
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: var(--border-radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
border: 2px solid transparent;
background-clip: padding-box;
}
// ============================================
// Focus Styles
// ============================================
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
// ============================================
// Selection
// ============================================
::selection {
background: var(--color-primary-light);
color: var(--color-primary-darkest);
}
+293
View File
@@ -0,0 +1,293 @@
// Excalidraw FULL - Design Tokens
// Based on Excalidraw's hand-drawn aesthetic
// ============================================
// Color Palette
// ============================================
// Primary Purple
$color-primary: #6965db;
$color-primary-darker: #5b57d1;
$color-primary-darkest: #4a47b1;
$color-primary-light: #e3e2fe;
$color-primary-light-darker: #d7d5ff;
$color-primary-hover: #5753d0;
// Grays
$color-gray-10: #f5f5f5;
$color-gray-20: #ebebeb;
$color-gray-30: #d6d6d6;
$color-gray-40: #b8b8b8;
$color-gray-50: #999999;
$color-gray-60: #7a7a7a;
$color-gray-70: #5c5c5c;
$color-gray-80: #3d3d3d;
$color-gray-85: #242424;
$color-gray-90: #1e1e1e;
$color-gray-100: #121212;
// Semantic Colors
$color-success: #cafccc;
$color-success-darker: #bafabc;
$color-success-darkest: #a5eba8;
$color-success-text: #268029;
$color-success-contrast: #65bb6a;
$color-warning: #fceeca;
$color-warning-dark: #f5c354;
$color-warning-darker: #f3ab2c;
$color-warning-darkest: #ec8b14;
$color-danger: #db6965;
$color-danger-dark: #d65550;
$color-danger-darker: #d1413c;
$color-danger-text: #700000;
$color-danger-background: #fff0f0;
$color-danger-icon-background: #ffdad6;
$color-danger-icon-color: #700000;
// ============================================
// CSS Variables
// ============================================
:root {
// Primary
--color-primary: #{$color-primary};
--color-primary-darker: #{$color-primary-darker};
--color-primary-darkest: #{$color-primary-darkest};
--color-primary-light: #{$color-primary-light};
--color-primary-light-darker: #{$color-primary-light-darker};
--color-primary-hover: #{$color-primary-hover};
--color-brand-active: var(--color-primary-darkest);
// Grays
--color-gray-10: #{$color-gray-10};
--color-gray-20: #{$color-gray-20};
--color-gray-30: #{$color-gray-30};
--color-gray-40: #{$color-gray-40};
--color-gray-50: #{$color-gray-50};
--color-gray-60: #{$color-gray-60};
--color-gray-70: #{$color-gray-70};
--color-gray-80: #{$color-gray-80};
--color-gray-85: #{$color-gray-85};
--color-gray-90: #{$color-gray-90};
--color-gray-100: #{$color-gray-100};
// Surfaces
--island-bg-color: #ffffff;
--island-bg-color-alt: #fff;
--color-surface-lowest: #ffffff;
--color-surface-low: #f8f9fa;
--color-surface-high: #e9ecef;
--color-surface-primary-container: #{$color-primary-light};
--color-on-surface: #{$color-gray-90};
--color-on-primary-container: #{$color-primary-darkest};
// Semantic
--color-success: #{$color-success};
--color-success-darker: #{$color-success-darker};
--color-success-darkest: #{$color-success-darkest};
--color-success-text: #{$color-success-text};
--color-success-contrast: #{$color-success-contrast};
--color-warning: #{$color-warning};
--color-warning-dark: #{$color-warning-dark};
--color-warning-darker: #{$color-warning-darker};
--color-danger: #{$color-danger};
--color-danger-dark: #{$color-danger-dark};
--color-danger-darker: #{$color-danger-darker};
--color-danger-text: #{$color-danger-text};
--color-danger-background: #{$color-danger-background};
--color-disabled: var(--color-gray-40);
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--color-selection: #6965db;
--color-icon-white: #fff;
--color-logo-icon: var(--color-primary);
--color-logo-text: #190064;
// Text
--text-primary-color: var(--color-on-surface);
--link-color: #1c7ed6;
--link-color-hover: #1971c2;
// Inputs
--input-bg-color: #fff;
--input-border-color: #{$color-gray-30};
--input-hover-bg-color: #{$color-gray-10};
--input-label-color: #{$color-gray-70};
// Borders
--default-border-color: var(--color-gray-30);
--dialog-border-color: var(--color-gray-20);
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
--border-radius-xl: 0.75rem;
--border-radius-full: 9999px;
// Shadows (Island Pattern)
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgba(0, 0, 0, 0.05);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.17),
0px 0px 3px 0px rgba(0, 0, 0, 0.08),
0px 7px 14px 0px rgb(0 0 0 / 18%);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
0px 12.5216px 10.0172px rgba(0, 0, 0, 0.035),
0px 6.6501px 5.32008px rgba(0, 0, 0, 0.0282725),
0px 2.76726px 2.21381px rgba(0, 0, 0, 0.0196802);
// Spacing
--space-factor: 0.25rem;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
// Typography
--ui-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
// Sizing
--default-button-size: 2rem;
--default-icon-size: 1rem;
--lg-button-size: 2.25rem;
--lg-icon-size: 1rem;
--avatar-size: 2rem;
--sidebar-width: 260px;
--header-height: 64px;
// Timing
--duration-fast: 0.15s;
--duration-normal: 0.2s;
--duration-slow: 0.3s;
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
// Scrollbar
--scrollbar-thumb: var(--color-gray-30);
--scrollbar-thumb-hover: var(--color-gray-40);
}
// Dark Mode
@media (prefers-color-scheme: dark) {
:root {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
// Shadows need less opacity in dark mode
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
}
// Manual dark mode override
[data-theme="dark"] {
// Grays (inverted)
--color-gray-10: #1a1a1a;
--color-gray-20: #2d2d2d;
--color-gray-30: #3d3d3d;
--color-gray-40: #5c5c5c;
--color-gray-50: #7a7a7a;
--color-gray-60: #999999;
--color-gray-70: #b8b8b8;
--color-gray-80: #d6d6d6;
--color-gray-85: #ebebeb;
--color-gray-90: #f5f5f5;
--color-gray-100: #ffffff;
--island-bg-color: #252525;
--island-bg-color-alt: #2d2d2d;
--color-surface-lowest: #121212;
--color-surface-low: #1a1a1a;
--color-surface-high: #2d2d2d;
--color-on-surface: #f5f5f5;
--color-on-primary-container: #e3e2fe;
--color-muted: var(--color-gray-50);
--color-muted-darker: var(--color-gray-60);
--input-bg-color: #2d2d2d;
--input-border-color: #3d3d3d;
--input-hover-bg-color: #363636;
--input-label-color: var(--color-gray-70);
--default-border-color: #3d3d3d;
--dialog-border-color: #2d2d2d;
--text-primary-color: #f5f5f5;
--link-color: #74c0fc;
--link-color-hover: #a5d8ff;
--scrollbar-thumb: var(--color-gray-40);
--scrollbar-thumb-hover: var(--color-gray-60);
--shadow-island: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.15);
--shadow-island-stronger: 0px 0px 1px 0px rgba(0, 0, 0, 0.4),
0px 0px 3px 0px rgba(0, 0, 0, 0.2),
0px 7px 14px 0px rgba(0, 0, 0, 0.35);
}
+283
View File
@@ -0,0 +1,283 @@
// ============================================
// User & Auth Types
// ============================================
export interface User {
id: string;
name: string;
username: string;
email: string;
avatar_url: string | null;
locale: string;
timezone: string;
created_at: string;
updated_at: string;
}
export interface AuthIdentity {
id: string;
user_id: string;
provider: 'github' | 'password' | 'google';
provider_user_id: string;
email_verified_at: string | null;
created_at: string;
}
export interface Session {
id: string;
user_id: string;
expires_at: string;
created_at: string;
}
// ============================================
// Team Types
// ============================================
export type TeamRole = 'owner' | 'admin' | 'editor' | 'viewer';
export interface Team {
id: string;
name: string;
slug: string;
owner_user_id: string;
plan_type: 'free' | 'pro';
created_at: string;
updated_at: string;
}
export interface TeamMembership {
id: string;
team_id: string;
user_id: string;
role: TeamRole;
joined_at: string;
user?: User;
}
export interface TeamInvite {
id: string;
team_id: string;
email: string;
role: TeamRole;
invited_by: string;
expires_at: string;
created_at: string;
}
// ============================================
// Project & Folder Types
// ============================================
export interface Project {
id: string;
team_id: string;
name: string;
slug: string;
description: string | null;
created_by: string;
created_at: string;
updated_at: string;
}
export interface Folder {
id: string;
team_id: string;
project_id: string | null;
parent_folder_id: string | null;
name: string;
slug: string;
path_cache: string;
visibility: 'private' | 'team';
created_by: string;
created_at: string;
updated_at: string;
}
// ============================================
// Drawing Types
// ============================================
export type DrawingVisibility = 'private' | 'team' | 'restricted' | 'public-link';
export interface Drawing {
id: string;
team_id: string;
folder_id: string | null;
project_id: string | null;
slug: string | null;
title: string;
description: string | null;
owner_user_id: string;
latest_revision_id: string | null;
visibility: DrawingVisibility;
is_archived: boolean;
thumbnail_asset_id: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
// Joined fields
owner?: User;
folder?: Folder;
project?: Project;
thumbnail_url?: string;
}
export interface DrawingRevision {
id: string;
drawing_id: string;
revision_number: number;
snapshot_path: string;
snapshot_size: number;
content_hash: string;
created_by: string;
created_at: string;
change_summary: string | null;
snapshot?: string | Record<string, unknown>;
created_by_user?: User;
}
export interface DrawingAsset {
id: string;
drawing_id: string;
kind: 'image' | 'export' | 'attachment' | 'thumbnail';
path: string;
mime_type: string;
size: number;
width: number | null;
height: number | null;
uploaded_by: string;
created_at: string;
url?: string;
}
// ============================================
// Template Types
// ============================================
export type TemplateScope = 'system' | 'team' | 'personal';
export type TemplateType =
| 'todo'
| 'kanban'
| 'brainstorm'
| 'flowchart'
| 'meeting-notes'
| 'architecture'
| 'mindmap'
| 'wireframe'
| 'empty';
export interface Template {
id: string;
team_id: string | null;
scope: TemplateScope;
type: TemplateType;
name: string;
description: string | null;
snapshot_path: string;
metadata_json: Record<string, unknown>;
created_by: string;
created_at: string;
updated_at: string;
preview_url?: string;
}
// ============================================
// Share & Permission Types
// ============================================
export type Permission = 'view' | 'comment' | 'edit' | 'manage' | 'share' | 'invite';
export interface ShareLink {
id: string;
resource_type: 'drawing' | 'folder' | 'project';
resource_id: string;
token_hash: string;
permission: Permission;
expires_at: string | null;
password_hash: string | null;
created_by: string;
revoked_at: string | null;
created_at: string;
}
export interface PermissionGrant {
id: string;
resource_type: string;
resource_id: string;
subject_type: 'user' | 'team' | 'link';
subject_id: string;
permission: Permission;
inherited_from: string | null;
created_at: string;
}
// ============================================
// Activity Types
// ============================================
export type ActivityEventType =
| 'drawing_created'
| 'drawing_updated'
| 'drawing_deleted'
| 'drawing_moved'
| 'drawing_renamed'
| 'drawing_shared'
| 'folder_created'
| 'folder_updated'
| 'folder_deleted'
| 'folder_shared'
| 'member_joined'
| 'member_left'
| 'member_invited'
| 'member_role_changed'
| 'revision_created'
| 'revision_restored'
| 'template_applied';
export interface ActivityEvent {
id: string;
actor_user_id: string | null;
team_id: string | null;
resource_type: string;
resource_id: string;
event_type: ActivityEventType;
metadata_json: Record<string, unknown>;
created_at: string;
actor?: User;
}
// ============================================
// UI Types
// ============================================
export type Theme = 'light' | 'dark' | 'system';
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface FilterOptions {
sortBy: 'name' | 'updated' | 'created';
sortOrder: 'asc' | 'desc';
view: 'grid' | 'list';
}
// ============================================
// API Response Types
// ============================================
export interface ApiResponse<T> {
data: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
has_more: boolean;
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.scss' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.sass' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.css' {
const classes: { [key: string]: string };
export default classes;
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
dedupe: ['react', 'react-dom'],
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3002',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
})
+75 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"excalidraw-complete/core" "excalidraw-complete/core"
"excalidraw-complete/workspace"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -34,6 +35,7 @@ var (
oidcOauthConfig *oauth2.Config oidcOauthConfig *oauth2.Config
oidcProvider *oidc.Provider oidcProvider *oidc.Provider
verifier *oidc.IDTokenVerifier verifier *oidc.IDTokenVerifier
workspaceStore *workspace.Store
) )
// AppClaims represents the custom claims for the JWT. // AppClaims represents the custom claims for the JWT.
@@ -48,12 +50,17 @@ type AppClaims struct {
// OIDCClaims represents the claims from OIDC token // OIDCClaims represents the claims from OIDC token
type OIDCClaims struct { type OIDCClaims struct {
Email string `json:"email"` Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"` Name string `json:"name"`
PreferredUsername string `json:"preferred_username"` PreferredUsername string `json:"preferred_username"`
Picture string `json:"picture"` Picture string `json:"picture"`
Sub string `json:"sub"` Sub string `json:"sub"`
} }
func SetWorkspaceStore(store *workspace.Store) {
workspaceStore = store
}
func InitAuth() { func InitAuth() {
oidcConfigured := os.Getenv("OIDC_ISSUER_URL") != "" && os.Getenv("OIDC_CLIENT_ID") != "" oidcConfigured := os.Getenv("OIDC_ISSUER_URL") != "" && os.Getenv("OIDC_CLIENT_ID") != ""
githubConfigured := os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != "" githubConfigured := os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != ""
@@ -160,15 +167,18 @@ func Init() {
} }
} }
func generateStateOauthCookie(w http.ResponseWriter) string { func generateStateOauthCookie(w http.ResponseWriter, r *http.Request) string {
b := make([]byte, 16) b := make([]byte, 16)
rand.Read(b) rand.Read(b)
state := base64.URLEncoding.EncodeToString(b) state := base64.URLEncoding.EncodeToString(b)
cookie := &http.Cookie{ cookie := &http.Cookie{
Name: "oauthstate", Name: "oauthstate",
Value: state, Value: state,
Path: "/",
Expires: time.Now().Add(10 * time.Minute), Expires: time.Now().Add(10 * time.Minute),
HttpOnly: true, HttpOnly: true,
Secure: r.Header.Get("X-Forwarded-Proto") == "https",
SameSite: http.SameSiteLaxMode,
} }
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
return state return state
@@ -179,7 +189,7 @@ func HandleGitHubLogin(w http.ResponseWriter, r *http.Request) {
http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError) http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError)
return return
} }
state := generateStateOauthCookie(w) state := generateStateOauthCookie(w, r)
url := githubOauthConfig.AuthCodeURL(state) url := githubOauthConfig.AuthCodeURL(state)
http.Redirect(w, r, url, http.StatusTemporaryRedirect) http.Redirect(w, r, url, http.StatusTemporaryRedirect)
} }
@@ -189,6 +199,11 @@ func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError) http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError)
return return
} }
if !validateStateCookie(r, "oauthstate") {
logrus.Warn("invalid github oauth state")
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
token, err := githubOauthConfig.Exchange(context.Background(), r.FormValue("code")) token, err := githubOauthConfig.Exchange(context.Background(), r.FormValue("code"))
if err != nil { if err != nil {
@@ -216,6 +231,7 @@ func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
var githubUser struct { var githubUser struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Login string `json:"login"` Login string `json:"login"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
Name string `json:"name"` Name string `json:"name"`
} }
@@ -226,6 +242,26 @@ func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
return return
} }
if workspaceStore != nil {
_, session, sessionToken, err := workspaceStore.UpsertOAuthSession(r.Context(), workspace.OAuthProfile{
Provider: "github",
ProviderUserID: fmt.Sprintf("%d", githubUser.ID),
Email: githubUser.Email,
Name: githubUser.Name,
Username: githubUser.Login,
AvatarURL: githubUser.AvatarURL,
EmailVerified: githubUser.Email != "",
})
if err != nil {
logrus.Errorf("failed to create workspace oauth session: %s", err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
workspace.SetSessionCookie(w, r, sessionToken, session.ExpiresAt)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
// Create user object using Subject instead of GitHubID // Create user object using Subject instead of GitHubID
user := &core.User{ user := &core.User{
Subject: fmt.Sprintf("github:%d", githubUser.ID), Subject: fmt.Sprintf("github:%d", githubUser.ID),
@@ -280,6 +316,11 @@ func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "OIDC is not configured", http.StatusInternalServerError) http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
return return
} }
if !validateStateCookie(r, "oidc_state") {
logrus.Warn("invalid oidc state")
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
code := r.FormValue("code") code := r.FormValue("code")
if code == "" { if code == "" {
@@ -330,6 +371,26 @@ func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
user.Login = user.Email user.Login = user.Email
} }
if workspaceStore != nil {
_, session, sessionToken, err := workspaceStore.UpsertOAuthSession(r.Context(), workspace.OAuthProfile{
Provider: "oidc",
ProviderUserID: claims.Sub,
Email: claims.Email,
Name: claims.Name,
Username: user.Login,
AvatarURL: claims.Picture,
EmailVerified: claims.EmailVerified,
})
if err != nil {
logrus.Errorf("failed to create workspace oidc session: %s", err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
workspace.SetSessionCookie(w, r, sessionToken, session.ExpiresAt)
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
jwtToken, err := createJWT(user) jwtToken, err := createJWT(user)
if err != nil { if err != nil {
logrus.Errorf("failed to create JWT: %s", err.Error()) logrus.Errorf("failed to create JWT: %s", err.Error())
@@ -341,6 +402,18 @@ func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect) http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect)
} }
func validateStateCookie(r *http.Request, name string) bool {
state := r.FormValue("state")
if state == "" {
return false
}
cookie, err := r.Cookie(name)
if err != nil || cookie.Value == "" {
return false
}
return cookie.Value == state
}
func createJWT(user *core.User) (string, error) { func createJWT(user *core.User) (string, error) {
claims := AppClaims{ claims := AppClaims{
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
+258
View File
@@ -0,0 +1,258 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
data BYTEA NOT NULL
);
CREATE TABLE IF NOT EXISTS canvases (
id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT,
thumbnail TEXT,
data BYTEA,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
PRIMARY KEY (user_id, id)
);
CREATE TABLE IF NOT EXISTS workspace_users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
avatar_url TEXT,
locale TEXT NOT NULL DEFAULT 'en',
timezone TEXT NOT NULL DEFAULT 'UTC',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES workspace_users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_auth_identities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES workspace_users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE(provider, provider_user_id)
);
CREATE TABLE IF NOT EXISTS workspace_teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
owner_user_id TEXT NOT NULL REFERENCES workspace_users(id),
plan_type TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_team_memberships (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES workspace_teams(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES workspace_users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
joined_at TIMESTAMPTZ NOT NULL,
UNIQUE(team_id, user_id)
);
CREATE TABLE IF NOT EXISTS workspace_team_invites (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES workspace_teams(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
invited_by TEXT NOT NULL REFERENCES workspace_users(id),
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_invites_team ON workspace_team_invites(team_id, created_at);
CREATE TABLE IF NOT EXISTS workspace_projects (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES workspace_teams(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
created_by TEXT NOT NULL REFERENCES workspace_users(id),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE(team_id, slug)
);
CREATE TABLE IF NOT EXISTS workspace_folders (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES workspace_teams(id) ON DELETE CASCADE,
project_id TEXT REFERENCES workspace_projects(id) ON DELETE SET NULL,
parent_folder_id TEXT REFERENCES workspace_folders(id) ON DELETE SET NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
path_cache TEXT NOT NULL,
visibility TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES workspace_users(id),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_drawings (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES workspace_teams(id) ON DELETE CASCADE,
folder_id TEXT REFERENCES workspace_folders(id) ON DELETE SET NULL,
project_id TEXT REFERENCES workspace_projects(id) ON DELETE SET NULL,
slug TEXT,
title TEXT NOT NULL,
description TEXT,
owner_user_id TEXT NOT NULL REFERENCES workspace_users(id),
latest_revision_id TEXT,
visibility TEXT NOT NULL,
is_archived BOOLEAN NOT NULL DEFAULT false,
thumbnail_asset_id TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_workspace_drawings_team ON workspace_drawings(team_id, updated_at);
CREATE TABLE IF NOT EXISTS workspace_drawing_revisions (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL REFERENCES workspace_drawings(id) ON DELETE CASCADE,
revision_number INTEGER NOT NULL,
snapshot_path TEXT NOT NULL,
snapshot_size BIGINT NOT NULL,
content_hash TEXT NOT NULL,
snapshot_json BYTEA NOT NULL,
created_by TEXT NOT NULL REFERENCES workspace_users(id),
created_at TIMESTAMPTZ NOT NULL,
change_summary TEXT,
UNIQUE(drawing_id, revision_number)
);
CREATE TABLE IF NOT EXISTS workspace_drawing_assets (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL REFERENCES workspace_drawings(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
path TEXT NOT NULL,
mime_type TEXT NOT NULL,
size BIGINT NOT NULL,
width INTEGER,
height INTEGER,
uploaded_by TEXT NOT NULL REFERENCES workspace_users(id),
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_share_links (
id TEXT PRIMARY KEY,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
permission TEXT NOT NULL,
expires_at TIMESTAMPTZ,
password_hash TEXT,
created_by TEXT NOT NULL REFERENCES workspace_users(id),
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_share_links_resource ON workspace_share_links(resource_type, resource_id);
CREATE TABLE IF NOT EXISTS workspace_permission_grants (
id TEXT PRIMARY KEY,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
permission TEXT NOT NULL,
inherited_from TEXT,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE(resource_type, resource_id, subject_type, subject_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_workspace_permission_grants_subject ON workspace_permission_grants(subject_type, subject_id);
CREATE TABLE IF NOT EXISTS workspace_embeds (
id TEXT PRIMARY KEY,
drawing_id TEXT NOT NULL REFERENCES workspace_drawings(id) ON DELETE CASCADE,
source_url TEXT NOT NULL,
canonical_url TEXT NOT NULL,
provider TEXT NOT NULL,
embed_type TEXT NOT NULL,
title TEXT,
preview_asset_id TEXT,
safe_embed_html TEXT,
created_by TEXT NOT NULL REFERENCES workspace_users(id),
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_link_references (
id TEXT PRIMARY KEY,
source_resource_type TEXT NOT NULL,
source_resource_id TEXT NOT NULL,
target_resource_type TEXT NOT NULL,
target_resource_id TEXT NOT NULL,
label TEXT,
created_by TEXT NOT NULL REFERENCES workspace_users(id),
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_links_source ON workspace_link_references(source_resource_type, source_resource_id);
CREATE TABLE IF NOT EXISTS workspace_templates (
id TEXT PRIMARY KEY,
team_id TEXT REFERENCES workspace_teams(id) ON DELETE CASCADE,
scope TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
snapshot_path TEXT NOT NULL,
metadata_json TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_activity_events (
id TEXT PRIMARY KEY,
actor_user_id TEXT REFERENCES workspace_users(id) ON DELETE SET NULL,
team_id TEXT REFERENCES workspace_teams(id) ON DELETE CASCADE,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
event_type TEXT NOT NULL,
metadata_json TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_activity_team ON workspace_activity_events(team_id, created_at);
-- +goose Down
DROP TABLE IF EXISTS workspace_activity_events;
DROP TABLE IF EXISTS workspace_templates;
DROP TABLE IF EXISTS workspace_link_references;
DROP TABLE IF EXISTS workspace_embeds;
DROP TABLE IF EXISTS workspace_permission_grants;
DROP TABLE IF EXISTS workspace_share_links;
DROP TABLE IF EXISTS workspace_drawing_assets;
DROP TABLE IF EXISTS workspace_drawing_revisions;
DROP TABLE IF EXISTS workspace_drawings;
DROP TABLE IF EXISTS workspace_folders;
DROP TABLE IF EXISTS workspace_projects;
DROP TABLE IF EXISTS workspace_team_invites;
DROP TABLE IF EXISTS workspace_team_memberships;
DROP TABLE IF EXISTS workspace_teams;
DROP TABLE IF EXISTS workspace_auth_identities;
DROP TABLE IF EXISTS workspace_sessions;
DROP TABLE IF EXISTS workspace_users;
DROP TABLE IF EXISTS canvases;
DROP TABLE IF EXISTS documents;
+116
View File
@@ -0,0 +1,116 @@
package postgres
import (
"context"
"database/sql"
"embed"
"fmt"
"strconv"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
type DB struct {
*sql.DB
}
type Tx struct {
*sql.Tx
}
func Open(databaseURL string) (*DB, error) {
if databaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
return &DB{DB: db}, nil
}
func Migrate(ctx context.Context, db *sql.DB) error {
goose.SetBaseFS(migrationFS)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.UpContext(ctx, db, "migrations")
}
func (db *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return db.DB.ExecContext(ctx, Rebind(query), args...)
}
func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return db.DB.QueryContext(ctx, Rebind(query), args...)
}
func (db *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return db.DB.QueryRowContext(ctx, Rebind(query), args...)
}
func (db *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) {
tx, err := db.DB.BeginTx(ctx, opts)
if err != nil {
return nil, err
}
return &Tx{Tx: tx}, nil
}
func (tx *Tx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
return tx.Tx.ExecContext(ctx, Rebind(query), args...)
}
func (tx *Tx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
return tx.Tx.QueryContext(ctx, Rebind(query), args...)
}
func (tx *Tx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
return tx.Tx.QueryRowContext(ctx, Rebind(query), args...)
}
func Rebind(query string) string {
out := make([]byte, 0, len(query)+8)
arg := 1
inSingle := false
inDouble := false
for i := 0; i < len(query); i++ {
ch := query[i]
switch ch {
case '\'':
out = append(out, ch)
if !inDouble {
if inSingle && i+1 < len(query) && query[i+1] == '\'' {
i++
out = append(out, query[i])
continue
}
inSingle = !inSingle
}
case '"':
out = append(out, ch)
if !inSingle {
inDouble = !inDouble
}
case '?':
if inSingle || inDouble {
out = append(out, ch)
continue
}
out = append(out, '$')
out = strconv.AppendInt(out, int64(arg), 10)
arg++
default:
out = append(out, ch)
}
}
return string(out)
}
+102 -13
View File
@@ -3,13 +3,13 @@ package main
import ( import (
"embed" "embed"
_ "embed" _ "embed"
"excalidraw-complete/handlers/api/documents"
"excalidraw-complete/handlers/api/firebase" "excalidraw-complete/handlers/api/firebase"
"excalidraw-complete/handlers/api/kv" "excalidraw-complete/handlers/api/kv"
"excalidraw-complete/handlers/api/openai" "excalidraw-complete/handlers/api/openai"
"excalidraw-complete/handlers/auth" "excalidraw-complete/handlers/auth"
authMiddleware "excalidraw-complete/middleware" authMiddleware "excalidraw-complete/middleware"
"excalidraw-complete/stores" "excalidraw-complete/stores"
"excalidraw-complete/workspace"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -125,12 +125,13 @@ func handleUI() http.HandlerFunc {
} }
} }
func setupRouter(store stores.Store) *chi.Mux { func setupRouter(store stores.Store, workspaceAPI *workspace.API) *chi.Mux {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(securityHeaders)
r.Use(cors.Handler(cors.Options{ r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"}, AllowedOrigins: allowedOrigins(),
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Content-Length", "X-CSRF-Token", "Token", "session", "Origin", "Host", "Connection", "Accept-Encoding", "Accept-Language", "X-Requested-With"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Content-Length", "X-CSRF-Token", "Token", "session", "Origin", "Host", "Connection", "Accept-Encoding", "Accept-Language", "X-Requested-With"},
AllowCredentials: true, AllowCredentials: true,
@@ -142,6 +143,10 @@ func setupRouter(store stores.Store) *chi.Mux {
r.Post("/documents:batchGet", firebase.HandleBatchGet()) r.Post("/documents:batchGet", firebase.HandleBatchGet())
}) })
if workspaceAPI != nil {
r.Mount("/api", workspaceAPI.Routes())
}
r.Route("/api/v2", func(r chi.Router) { r.Route("/api/v2", func(r chi.Router) {
// Route for canvases, protected by JWT auth // Route for canvases, protected by JWT auth
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@@ -159,11 +164,8 @@ func setupRouter(store stores.Store) *chi.Mux {
}) })
}) })
// Old routes for anonymous document sharing // Legacy anonymous document routes removed per project.md Phase 1.
r.Post("/post/", documents.HandleCreate(store)) // All persistence now goes through the workspace API with auth.
r.Route("/{id}", func(r chi.Router) {
r.Get("/", documents.HandleGet(store))
})
}) })
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
@@ -174,13 +176,55 @@ func setupRouter(store stores.Store) *chi.Mux {
return r return r
} }
func allowedOrigins() []string {
raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS"))
if raw == "" {
return []string{
"http://localhost:3000",
"http://localhost:3002",
"http://localhost:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:3002",
"http://127.0.0.1:5173",
}
}
parts := strings.Split(raw, ",")
origins := make([]string, 0, len(parts))
for _, part := range parts {
origin := strings.TrimSpace(part)
if origin != "" {
origins = append(origins, origin)
}
}
return origins
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; connect-src 'self' https://libraries.excalidraw.com ws: wss:; img-src 'self' data: blob: https://libraries.excalidraw.com; font-src 'self' data: https://fonts.gstatic.com https://unpkg.com; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'")
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func setupSocketIO() *socketio.Server { func setupSocketIO() *socketio.Server {
opts := socketio.DefaultServerOptions() opts := socketio.DefaultServerOptions()
opts.SetMaxHttpBufferSize(5000000) opts.SetMaxHttpBufferSize(5000000)
opts.SetPath("/socket.io") opts.SetPath("/socket.io")
opts.SetAllowEIO3(true) opts.SetAllowEIO3(true)
// Mirror HTTP CORS origin policy — wildcard + credentials is rejected by browsers.
socketOrigins := strings.Join(allowedOrigins(), ",")
if socketOrigins == "" {
socketOrigins = "http://localhost:3000,http://localhost:3002,http://localhost:5173"
}
opts.SetCors(&types.Cors{ opts.SetCors(&types.Cors{
Origin: "*", Origin: socketOrigins,
Credentials: true, Credentials: true,
}) })
ioo := socketio.NewServer(nil, opts) ioo := socketio.NewServer(nil, opts)
@@ -190,7 +234,7 @@ func setupSocketIO() *socketio.Server {
me := socket.Id() me := socket.Id()
myRoom := socketio.Room(me) myRoom := socketio.Room(me)
ioo.To(myRoom).Emit("init-room") ioo.To(myRoom).Emit("init-room")
utils.Log().Println("init room ", myRoom) utils.Log().Printf("init room %v\n", myRoom)
socket.On("join-room", func(datas ...any) { socket.On("join-room", func(datas ...any) {
room := socketio.Room(datas[0].(string)) room := socketio.Room(datas[0].(string))
utils.Log().Printf("Socket %v has joined %v\n", me, room) utils.Log().Printf("Socket %v has joined %v\n", me, room)
@@ -208,7 +252,7 @@ func setupSocketIO() *socketio.Server {
for _, user := range usersInRoom { for _, user := range usersInRoom {
newRoomUsers = append(newRoomUsers, user.Id()) newRoomUsers = append(newRoomUsers, user.Id())
} }
utils.Log().Println(" room ", room, " has users ", newRoomUsers) utils.Log().Printf("room %v has users %v\n", room, newRoomUsers)
ioo.In(room).Emit( ioo.In(room).Emit(
"room-user-change", "room-user-change",
newRoomUsers, newRoomUsers,
@@ -266,7 +310,7 @@ func setupSocketIO() *socketio.Server {
func waitForShutdown(ioo *socketio.Server) { func waitForShutdown(ioo *socketio.Server) {
exit := make(chan struct{}) exit := make(chan struct{})
SignalC := make(chan os.Signal) SignalC := make(chan os.Signal, 1)
signal.Notify(SignalC, os.Interrupt, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) signal.Notify(SignalC, os.Interrupt, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() { go func() {
@@ -306,11 +350,56 @@ func main() {
FullTimestamp: true, FullTimestamp: true,
}) })
// Validate critical environment variables
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" || jwtSecret == "YOUR_SUPER_SECRET_RANDOM_STRING" || jwtSecret == "YOUR_SUPER_SECRET_RANDOM_STRING_MIN_32_CHARS" {
logrus.Fatal("JWT_SECRET must be set to a secure random string (min 32 chars). Generate with: openssl rand -base64 32")
}
storageType := os.Getenv("STORAGE_TYPE")
validStorageTypes := []string{"postgres", "memory", "filesystem", "kv", "s3", ""}
valid := false
for _, s := range validStorageTypes {
if storageType == s {
valid = true
break
}
}
if !valid {
logrus.Fatalf("STORAGE_TYPE must be one of: postgres, memory, filesystem, kv, s3. Got: %s", storageType)
}
if storageType == "" || storageType == "postgres" {
if os.Getenv("DATABASE_URL") == "" {
logrus.Fatal("DATABASE_URL must be set for PostgreSQL storage")
}
}
// Warn about incomplete OAuth/OIDC configuration
hasGitHubClient := os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != ""
hasOIDC := os.Getenv("OIDC_ISSUER_URL") != "" && os.Getenv("OIDC_CLIENT_ID") != "" && os.Getenv("OIDC_CLIENT_SECRET") != ""
if os.Getenv("GITHUB_CLIENT_ID") != "" && !hasGitHubClient {
logrus.Warn("GITHUB_CLIENT_ID is set but GITHUB_CLIENT_SECRET is missing — GitHub OAuth will not work")
}
if os.Getenv("OIDC_ISSUER_URL") != "" && !hasOIDC {
logrus.Warn("OIDC configuration is incomplete — OIDC SSO will not work")
}
if !hasGitHubClient && !hasOIDC {
logrus.Info("No external auth provider configured. Only password authentication is available.")
}
auth.InitAuth() auth.InitAuth()
openai.Init() openai.Init()
store := stores.GetStore() store := stores.GetStore()
workspaceStore, err := workspace.NewStore(os.Getenv("DATABASE_URL"))
if err != nil {
logrus.WithError(err).Fatal("failed to initialize workspace backend")
}
defer workspaceStore.Close()
auth.SetWorkspaceStore(workspaceStore)
workspaceAPI := workspace.NewAPI(workspaceStore)
r := setupRouter(store) r := setupRouter(store, workspaceAPI)
ioo := setupSocketIO() ioo := setupSocketIO()
r.Mount("/socket.io/", ioo.ServeHandler(nil)) r.Mount("/socket.io/", ioo.ServeHandler(nil))
+813
View File
@@ -0,0 +1,813 @@
openapi: 3.0.3
info:
title: Excalidraw FULL API
description: API for the visual workspace platform
version: 1.0.0
servers:
- url: http://localhost:3000
paths:
/health:
get:
operationId: getHealth
summary: Health check
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/HealthResponse' } } } }
'503': { description: Unhealthy, content: { application/json: { schema: { $ref: '#/components/schemas/HealthResponse' } } } }
/api/auth/signup:
post:
operationId: signup
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, email, password]
properties:
name: { type: string }
email: { type: string, format: email }
password: { type: string, minLength: 6 }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/User' }
session: { $ref: '#/components/schemas/Session' }
'429': { description: Rate limited }
/api/auth/login:
post:
operationId: login
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: { type: string, format: email }
password: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
user: { $ref: '#/components/schemas/User' }
session: { $ref: '#/components/schemas/Session' }
'401': { description: Invalid credentials }
/api/auth/logout:
post:
operationId: logout
responses:
'200': { description: Logged out }
/api/auth/me:
get:
operationId: getMe
security: [ { cookieAuth: [] } ]
responses:
'200':
description: Current user
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
'401': { description: Not authenticated }
/api/teams:
get:
operationId: listTeams
security: [ { cookieAuth: [] } ]
responses:
'200':
description: List of teams
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Team' } }
post:
operationId: createTeam
security: [ { cookieAuth: [] } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateTeamRequest' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Team' }
/api/teams/{teamID}/members:
get:
operationId: listTeamMembers
security: [ { cookieAuth: [] } ]
parameters: [ { name: teamID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Members
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/TeamMember' } }
/api/teams/{teamID}/invites:
get:
operationId: listTeamInvites
security: [ { cookieAuth: [] } ]
parameters: [ { name: teamID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Invites
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/TeamInvite' } }
post:
operationId: createTeamInvite
security: [ { cookieAuth: [] } ]
parameters: [ { name: teamID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateInviteRequest' }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
invite: { $ref: '#/components/schemas/TeamInvite' }
token: { type: string }
/api/invites/accept:
post:
operationId: acceptInvite
security: [ { cookieAuth: [] } ]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token]
properties:
token: { type: string }
responses:
'200':
description: Membership created
content:
application/json:
schema: { $ref: '#/components/schemas/TeamMember' }
/api/drawings:
get:
operationId: listDrawings
security: [ { cookieAuth: [] } ]
parameters:
- { name: team_id, in: query, schema: { type: string } }
- { name: folder_id, in: query, schema: { type: string } }
responses:
'200':
description: Drawings
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Drawing' } }
post:
operationId: createDrawing
security: [ { cookieAuth: [] } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateDrawingRequest' }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
/api/drawings/{drawingID}:
get:
operationId: getDrawing
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Drawing
content:
application/json:
schema: { $ref: '#/components/schemas/Drawing' }
'404': { description: Not found }
patch:
operationId: updateDrawing
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UpdateDrawingRequest' }
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
updated_at: { type: string, format: date-time }
'404': { description: Not found }
delete:
operationId: archiveDrawing
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'204': { description: Archived }
/api/drawings/{drawingID}/revisions:
get:
operationId: listRevisions
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Revisions
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/DrawingRevision' } }
post:
operationId: createRevision
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [change_summary, snapshot]
properties:
change_summary: { type: string, maxLength: 240 }
snapshot: { type: string }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/DrawingRevision' }
/api/drawings/{drawingID}/permissions:
get:
operationId: listPermissions
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Grants
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/PermissionGrant' } }
post:
operationId: createPermission
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreatePermissionGrantRequest' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/PermissionGrant' }
/api/drawings/{drawingID}/share-links:
get:
operationId: listShareLinks
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Links
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/ShareLink' } }
post:
operationId: createShareLink
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateShareLinkRequest' }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
share_link: { $ref: '#/components/schemas/ShareLink' }
token: { type: string }
/api/drawings/{drawingID}/assets:
get:
operationId: listAssets
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Assets
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Asset' } }
post:
operationId: createAsset
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateAssetRequest' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Asset' }
/api/drawings/{drawingID}/embeds:
get:
operationId: listEmbeds
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Embeds
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Embed' } }
post:
operationId: createEmbed
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateEmbedRequest' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/Embed' }
/api/drawings/{drawingID}/links:
get:
operationId: listLinks
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Links
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/LinkReference' } }
post:
operationId: createLink
security: [ { cookieAuth: [] } ]
parameters: [ { name: drawingID, in: path, required: true, schema: { type: string } } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateLinkRequest' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/LinkReference' }
/api/shared/{token}:
get:
operationId: getSharedResource
parameters: [ { name: token, in: path, required: true, schema: { type: string } } ]
responses:
'200':
description: Shared drawing
content:
application/json:
schema: { $ref: '#/components/schemas/SharedResource' }
'404': { description: Invalid or expired token }
/api/search:
get:
operationId: searchDrawings
security: [ { cookieAuth: [] } ]
parameters: [ { name: q, in: query, required: true, schema: { type: string } } ]
responses:
'200':
description: Results
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Drawing' } }
/api/templates:
get:
operationId: listTemplates
security: [ { cookieAuth: [] } ]
responses:
'200':
description: Templates
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Template' } }
/api/activity:
get:
operationId: listActivity
security: [ { cookieAuth: [] } ]
responses:
'200':
description: Activity
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Activity' } }
/api/stats:
get:
operationId: getStats
security: [ { cookieAuth: [] } ]
responses:
'200':
description: Stats
content:
application/json:
schema: { $ref: '#/components/schemas/WorkspaceStats' }
/api/folders:
get:
operationId: listFolders
security: [ { cookieAuth: [] } ]
responses:
'200':
description: Folders
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Folder' } }
post:
operationId: createFolder
security: [ { cookieAuth: [] } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateFolderRequest' }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
/api/projects:
get:
operationId: listProjects
security: [ { cookieAuth: [] } ]
responses:
'200':
description: Projects
content:
application/json:
schema: { type: array, items: { $ref: '#/components/schemas/Project' } }
post:
operationId: createProject
security: [ { cookieAuth: [] } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateProjectRequest' }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: excalidraw_session
schemas:
HealthResponse:
type: object
required: [status]
properties:
status: { type: string, enum: [ok, unhealthy] }
User:
type: object
required: [id, name, username, email]
properties:
id: { type: string }
name: { type: string }
username: { type: string }
email: { type: string }
avatar_url: { type: string, nullable: true }
locale: { type: string }
timezone: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Session:
type: object
required: [id, user_id, token, expires_at]
properties:
id: { type: string }
user_id: { type: string }
token: { type: string }
expires_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
Team:
type: object
required: [id, name, slug, created_by]
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
description: { type: string, nullable: true }
avatar_url: { type: string, nullable: true }
created_by: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
TeamMember:
type: object
required: [id, team_id, user_id, role]
properties:
id: { type: string }
team_id: { type: string }
user_id: { type: string }
role: { type: string, enum: [owner, admin, editor, viewer] }
created_at: { type: string, format: date-time }
TeamInvite:
type: object
required: [id, team_id, email, role, invited_by]
properties:
id: { type: string }
team_id: { type: string }
email: { type: string }
role: { type: string }
invited_by: { type: string }
expires_at: { type: string, format: date-time }
created_at: { type: string, format: date-time }
Drawing:
type: object
required: [id, team_id, title, owner_user_id, visibility]
properties:
id: { type: string }
team_id: { type: string }
folder_id: { type: string, nullable: true }
project_id: { type: string, nullable: true }
slug: { type: string, nullable: true }
title: { type: string }
description: { type: string, nullable: true }
owner_user_id: { type: string }
latest_revision_id: { type: string, nullable: true }
visibility: { type: string, enum: [private, team, public] }
is_archived: { type: boolean }
thumbnail_asset_id: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
owner: { $ref: '#/components/schemas/User' }
folder: { $ref: '#/components/schemas/Folder' }
project: { $ref: '#/components/schemas/Project' }
DrawingRevision:
type: object
required: [id, drawing_id, change_summary, created_at, created_by]
properties:
id: { type: string }
drawing_id: { type: string }
change_summary: { type: string }
created_at: { type: string, format: date-time }
created_by: { type: string }
snapshot: { type: string }
Project:
type: object
required: [id, team_id, name, slug, created_by]
properties:
id: { type: string }
team_id: { type: string }
name: { type: string }
slug: { type: string }
description: { type: string, nullable: true }
created_by: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Folder:
type: object
required: [id, team_id, name, slug, path_cache, visibility, created_by]
properties:
id: { type: string }
team_id: { type: string }
project_id: { type: string, nullable: true }
parent_folder_id: { type: string, nullable: true }
name: { type: string }
slug: { type: string }
path_cache: { type: string }
visibility: { type: string }
created_by: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Activity:
type: object
required: [id, team_id, user_id, action, resource_type, resource_id, created_at]
properties:
id: { type: string }
team_id: { type: string }
user_id: { type: string }
action: { type: string }
resource_type: { type: string }
resource_id: { type: string }
metadata: { type: object }
created_at: { type: string, format: date-time }
Template:
type: object
required: [id, name, scope]
properties:
id: { type: string }
name: { type: string }
description: { type: string, nullable: true }
scope: { type: string, enum: [user, team, global] }
team_id: { type: string, nullable: true }
category: { type: string, nullable: true }
tags: { type: array, items: { type: string } }
preview_asset_id: { type: string, nullable: true }
created_by: { type: string }
created_at: { type: string, format: date-time }
PermissionGrant:
type: object
required: [id, resource_type, resource_id, user_id, permission, granted_by]
properties:
id: { type: string }
resource_type: { type: string }
resource_id: { type: string }
user_id: { type: string }
permission: { type: string, enum: [read, write, admin] }
granted_by: { type: string }
expires_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
ShareLink:
type: object
required: [id, resource_type, resource_id, access_level, created_by]
properties:
id: { type: string }
resource_type: { type: string }
resource_id: { type: string }
access_level: { type: string, enum: [view, comment, edit] }
password_hash: { type: string, nullable: true }
expires_at: { type: string, format: date-time, nullable: true }
created_by: { type: string }
created_at: { type: string, format: date-time }
Asset:
type: object
required: [id, drawing_id, type, name]
properties:
id: { type: string }
drawing_id: { type: string }
type: { type: string }
name: { type: string }
url: { type: string, nullable: true }
file_path: { type: string, nullable: true }
created_by: { type: string }
created_at: { type: string, format: date-time }
Embed:
type: object
required: [id, drawing_id, url, type]
properties:
id: { type: string }
drawing_id: { type: string }
url: { type: string }
type: { type: string }
title: { type: string, nullable: true }
thumbnail_url: { type: string, nullable: true }
created_by: { type: string }
created_at: { type: string, format: date-time }
LinkReference:
type: object
required: [id, source_type, source_id, target_type, target_id]
properties:
id: { type: string }
source_type: { type: string }
source_id: { type: string }
target_type: { type: string }
target_id: { type: string }
relation: { type: string }
created_at: { type: string, format: date-time }
SharedResource:
type: object
required: [drawing, share_link]
properties:
drawing: { $ref: '#/components/schemas/Drawing' }
share_link: { $ref: '#/components/schemas/ShareLink' }
WorkspaceStats:
type: object
required: [total_teams, total_drawings, total_members, recent_activity]
properties:
total_teams: { type: integer }
total_drawings: { type: integer }
total_members: { type: integer }
recent_activity: { type: array, items: { $ref: '#/components/schemas/Activity' } }
CreateTeamRequest:
type: object
required: [name]
properties:
name: { type: string }
description: { type: string, nullable: true }
avatar_url: { type: string, nullable: true }
CreateDrawingRequest:
type: object
required: [team_id, title, elements]
properties:
team_id: { type: string }
folder_id: { type: string, nullable: true }
project_id: { type: string, nullable: true }
title: { type: string }
description: { type: string, nullable: true }
visibility: { type: string, enum: [private, team, public], default: team }
elements: { type: string }
UpdateDrawingRequest:
type: object
properties:
title: { type: string }
description: { type: string, nullable: true }
folder_id: { type: string, nullable: true }
project_id: { type: string, nullable: true }
visibility: { type: string, enum: [private, team, public] }
is_archived: { type: boolean }
elements: { type: string }
CreateFolderRequest:
type: object
required: [team_id, name]
properties:
team_id: { type: string }
project_id: { type: string, nullable: true }
parent_folder_id: { type: string, nullable: true }
name: { type: string }
visibility: { type: string }
CreateProjectRequest:
type: object
required: [team_id, name]
properties:
team_id: { type: string }
name: { type: string }
description: { type: string, nullable: true }
CreateInviteRequest:
type: object
required: [email, role]
properties:
email: { type: string, format: email }
role: { type: string, enum: [admin, editor, viewer] }
CreatePermissionGrantRequest:
type: object
required: [user_id, permission]
properties:
user_id: { type: string }
permission: { type: string, enum: [read, write, admin] }
expires_at: { type: string, format: date-time, nullable: true }
CreateShareLinkRequest:
type: object
required: [access_level]
properties:
access_level: { type: string, enum: [view, comment, edit] }
password: { type: string, nullable: true }
expires_at: { type: string, format: date-time, nullable: true }
CreateAssetRequest:
type: object
required: [name, type]
properties:
name: { type: string }
type: { type: string }
url: { type: string, nullable: true }
file_path: { type: string, nullable: true }
CreateEmbedRequest:
type: object
required: [url, type]
properties:
url: { type: string }
type: { type: string }
title: { type: string, nullable: true }
thumbnail_url: { type: string, nullable: true }
CreateLinkRequest:
type: object
required: [target_type, target_id]
properties:
target_type: { type: string }
target_id: { type: string }
relation: { type: string }
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Excalidraw",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+126
View File
@@ -0,0 +1,126 @@
Backlog
Github integration
Create diagrams for your PRs or show your code as drawings for easier understanding and documentation
Nesting with folders
Better organization for your drawings.
Shared library
Library shared across your team workspace and
personal library shared across devices.
SSO
Single-sign-on.
Better scene filtering
Shared, read-only, presentations, better management of your content.
Command palette for whole app
Not just editor, but whole application can be managed by command palette.
Self-hosting
Self-host Excalidraw+ on your own premises.
In Progress
Fulltext search
search the content of your drawings from anywhere
Presenter notes
Add notes to slides so you don't forget the important details.
Generate anything (AI)
New AI chat to help you get started
Public API
First step for integrations & giving you the control
Versioning
Track your drawings in time and revert if needed.
Custom Fonts
Add your custom font for Excalidraw+ workspace users.
Database migration
Improving your scene performance and allowing us to ship other features. Faster.
New library marketplace
Share or use community created libraries.
Application performance
Much faster loads, fewer spinners!
MCP
Connect your agents to Excalidraw to create drawings & manage your workspace
Shipped
Archive (trash)
Instead of deleting, you can archive your scenes and delete them later.
AI BYOK (Bring your own key)
You can use AI features with our own token without limits
SOC 2 type 2
Proof that we take security seriously.
New text-to-diagram chat (AI)
So you can message your diagrams
Activity feed
View your and your team's recent activity.
Mobile & Tablet redesign
Better UX for portable devices
Diagram layouts
Speed up your diagram game with predefined layouts and behavior.
CJK fonts
Write with newly added hand-drawn Chinese, Japanese, and Korean fonts.
Screensharing during calls
Have even better calls & collaboration on the scene.
Canvas text search
Find quickly any text on the canvas.
Elbow arrows
Give your diagrams the desired form fast.
Editor command palette
For power users who prefer keyboard
PDF, PPTX export
Export your drawing & presentations to additional formats.
+1422
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
package postgres
import (
"bytes"
"context"
"database/sql"
"excalidraw-complete/core"
dbpostgres "excalidraw-complete/internal/postgres"
"fmt"
"log"
"time"
"github.com/oklog/ulid/v2"
"github.com/sirupsen/logrus"
)
type postgresStore struct {
db *dbpostgres.DB
}
func NewStore(databaseURL string) *postgresStore {
db, err := dbpostgres.Open(databaseURL)
if err != nil {
log.Fatalf("failed to open postgres database: %v", err)
}
if err := dbpostgres.Migrate(context.Background(), db.DB); err != nil {
log.Fatalf("failed to migrate postgres database: %v", err)
}
return &postgresStore{db: db}
}
func (s *postgresStore) FindID(ctx context.Context, id string) (*core.Document, error) {
log := logrus.WithField("document_id", id)
var data []byte
err := s.db.QueryRowContext(ctx, "SELECT data FROM documents WHERE id = ?", id).Scan(&data)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("document with id %s not found", id)
}
log.WithError(err).Error("failed to retrieve document")
return nil, err
}
return &core.Document{Data: *bytes.NewBuffer(data)}, nil
}
func (s *postgresStore) Create(ctx context.Context, document *core.Document) (string, error) {
id := ulid.Make().String()
data := document.Data.Bytes()
_, err := s.db.ExecContext(ctx, "INSERT INTO documents (id, data) VALUES (?, ?)", id, data)
if err != nil {
logrus.WithError(err).WithField("document_id", id).Error("failed to create document")
return "", err
}
return id, nil
}
func (s *postgresStore) List(ctx context.Context, userID string) ([]*core.Canvas, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, updated_at, thumbnail FROM canvases WHERE user_id = ? ORDER BY updated_at DESC", userID)
if err != nil {
return nil, err
}
defer rows.Close()
canvases := []*core.Canvas{}
for rows.Next() {
var canvas core.Canvas
canvas.UserID = userID
if err := rows.Scan(&canvas.ID, &canvas.Name, &canvas.UpdatedAt, &canvas.Thumbnail); err != nil {
return nil, err
}
canvases = append(canvases, &canvas)
}
return canvases, rows.Err()
}
func (s *postgresStore) Get(ctx context.Context, userID, id string) (*core.Canvas, error) {
var canvas core.Canvas
canvas.UserID = userID
canvas.ID = id
err := s.db.QueryRowContext(ctx, "SELECT name, data, created_at, updated_at, thumbnail FROM canvases WHERE user_id = ? AND id = ?", userID, id).Scan(&canvas.Name, &canvas.Data, &canvas.CreatedAt, &canvas.UpdatedAt, &canvas.Thumbnail)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("canvas not found")
}
return nil, err
}
return &canvas, nil
}
func (s *postgresStore) Save(ctx context.Context, canvas *core.Canvas) error {
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `INSERT INTO canvases (id, user_id, name, data, created_at, updated_at, thumbnail)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, id) DO UPDATE
SET name = EXCLUDED.name,
data = EXCLUDED.data,
updated_at = EXCLUDED.updated_at,
thumbnail = EXCLUDED.thumbnail`,
canvas.ID, canvas.UserID, canvas.Name, canvas.Data, now, now, canvas.Thumbnail,
)
return err
}
func (s *postgresStore) Delete(ctx context.Context, userID, id string) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM canvases WHERE user_id = ? AND id = ?", userID, id)
return err
}
-158
View File
@@ -1,158 +0,0 @@
package sqlite
import (
"bytes"
"context"
"database/sql"
"excalidraw-complete/core"
"fmt"
"log"
"time"
"github.com/oklog/ulid/v2"
"github.com/sirupsen/logrus"
_ "modernc.org/sqlite"
)
type sqliteStore struct {
db *sql.DB
}
// NewStore creates a new SQLite-based store.
func NewStore(dataSourceName string) *sqliteStore {
db, err := sql.Open("sqlite", dataSourceName)
if err != nil {
log.Fatalf("failed to open sqlite database: %v", err)
}
// Initialize table for anonymous documents
docTableStmt := `CREATE TABLE IF NOT EXISTS documents (id TEXT PRIMARY KEY, data BLOB);`
if _, err = db.Exec(docTableStmt); err != nil {
log.Fatalf("failed to create documents table: %v", err)
}
// Initialize table for user-owned canvases
canvasTableStmt := `
CREATE TABLE IF NOT EXISTS canvases (
id TEXT NOT NULL,
user_id TEXT NOT NULL,
name TEXT,
thumbnail TEXT,
data BLOB,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (user_id, id)
);`
if _, err = db.Exec(canvasTableStmt); err != nil {
log.Fatalf("failed to create canvases table: %v", err)
}
return &sqliteStore{db}
}
// DocumentStore implementation
func (s *sqliteStore) FindID(ctx context.Context, id string) (*core.Document, error) {
log := logrus.WithField("document_id", id)
log.Debug("Retrieving document by ID")
var data []byte
err := s.db.QueryRowContext(ctx, "SELECT data FROM documents WHERE id = ?", id).Scan(&data)
if err != nil {
if err == sql.ErrNoRows {
log.WithField("error", "document not found").Warn("Document with specified ID not found")
return nil, fmt.Errorf("document with id %s not found", id)
}
log.WithError(err).Error("Failed to retrieve document")
return nil, err
}
document := core.Document{
Data: *bytes.NewBuffer(data),
}
log.Info("Document retrieved successfully")
return &document, nil
}
func (s *sqliteStore) Create(ctx context.Context, document *core.Document) (string, error) {
id := ulid.Make().String()
data := document.Data.Bytes()
log := logrus.WithFields(logrus.Fields{
"document_id": id,
"data_length": len(data),
})
_, err := s.db.ExecContext(ctx, "INSERT INTO documents (id, data) VALUES (?, ?)", id, data)
if err != nil {
log.WithError(err).Error("Failed to create document")
return "", err
}
log.Info("Document created successfully")
return id, nil
}
// CanvasStore implementation
func (s *sqliteStore) List(ctx context.Context, userID string) ([]*core.Canvas, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, updated_at, thumbnail FROM canvases WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var canvases []*core.Canvas
for rows.Next() {
var canvas core.Canvas
canvas.UserID = userID
if err := rows.Scan(&canvas.ID, &canvas.Name, &canvas.UpdatedAt, &canvas.Thumbnail); err != nil {
return nil, err
}
canvases = append(canvases, &canvas)
}
return canvases, nil
}
func (s *sqliteStore) Get(ctx context.Context, userID, id string) (*core.Canvas, error) {
var canvas core.Canvas
canvas.UserID = userID
canvas.ID = id
err := s.db.QueryRowContext(ctx, "SELECT name, data, created_at, updated_at, thumbnail FROM canvases WHERE user_id = ? AND id = ?", userID, id).Scan(&canvas.Name, &canvas.Data, &canvas.CreatedAt, &canvas.UpdatedAt, &canvas.Thumbnail)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("canvas not found")
}
return nil, err
}
return &canvas, nil
}
func (s *sqliteStore) Save(ctx context.Context, canvas *core.Canvas) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // Rollback on any error
var exists bool
err = tx.QueryRowContext(ctx, "SELECT 1 FROM canvases WHERE user_id = ? AND id = ?", canvas.UserID, canvas.ID).Scan(&exists)
now := time.Now()
if err != nil && err != sql.ErrNoRows {
return err
}
if exists {
// Update
_, err = tx.ExecContext(ctx, "UPDATE canvases SET name = ?, data = ?, updated_at = ?, thumbnail = ? WHERE user_id = ? AND id = ?", canvas.Name, canvas.Data, now, canvas.Thumbnail, canvas.UserID, canvas.ID)
} else {
// Insert
_, err = tx.ExecContext(ctx, "INSERT INTO canvases (id, user_id, name, data, created_at, updated_at, thumbnail) VALUES (?, ?, ?, ?, ?, ?, ?)", canvas.ID, canvas.UserID, canvas.Name, canvas.Data, now, now, canvas.Thumbnail)
}
if err != nil {
return err
}
return tx.Commit()
}
func (s *sqliteStore) Delete(ctx context.Context, userID, id string) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM canvases WHERE user_id = ? AND id = ?", userID, id)
return err
}
+7 -7
View File
@@ -5,7 +5,7 @@ import (
"excalidraw-complete/stores/aws" "excalidraw-complete/stores/aws"
"excalidraw-complete/stores/filesystem" "excalidraw-complete/stores/filesystem"
"excalidraw-complete/stores/memory" "excalidraw-complete/stores/memory"
"excalidraw-complete/stores/sqlite" "excalidraw-complete/stores/postgres"
"os" "os"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@@ -33,13 +33,13 @@ func GetStore() Store {
} }
storageField["basePath"] = basePath storageField["basePath"] = basePath
store = filesystem.NewStore(basePath) store = filesystem.NewStore(basePath)
case "sqlite": case "postgres", "":
dataSourceName := os.Getenv("DATA_SOURCE_NAME") databaseURL := os.Getenv("DATABASE_URL")
if dataSourceName == "" { if databaseURL == "" {
dataSourceName = "excalidraw.db" // Default filename logrus.Fatal("DATABASE_URL environment variable must be set for postgres storage")
} }
storageField["dataSourceName"] = dataSourceName storageField["databaseURL"] = "configured"
store = sqlite.NewStore(dataSourceName) store = postgres.NewStore(databaseURL)
case "s3": case "s3":
bucketName := os.Getenv("S3_BUCKET_NAME") bucketName := os.Getenv("S3_BUCKET_NAME")
if bucketName == "" { if bucketName == "" {
+17
View File
@@ -0,0 +1,17 @@
package workspace
import "context"
type currentSession struct {
user *User
session *Session
}
func withUser(ctx context.Context, user *User, session *Session) context.Context {
return context.WithValue(ctx, currentUserKey, currentSession{user: user, session: session})
}
func currentUser(r interface{ Context() context.Context }) (*User, *Session) {
current, _ := r.Context().Value(currentUserKey).(currentSession)
return current.user, current.session
}
+660
View File
@@ -0,0 +1,660 @@
package workspace
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sirupsen/logrus"
)
const sessionCookieName = "excalidraw_session"
type API struct {
store *Store
limiter *rateLimiter
testMode bool
}
func NewAPI(store *Store) *API {
return &API{
store: store,
limiter: newRateLimiter(10, 15*time.Minute),
}
}
func (a *API) Routes() chi.Router {
r := chi.NewRouter()
r.Group(func(r chi.Router) {
r.Get("/health", a.handleHealth)
r.Get("/auth/setup-status", a.handleSetupStatus)
r.Post("/auth/signup", a.handleSignup)
r.Post("/auth/login", a.handleLogin)
r.Post("/auth/logout", a.handleLogout)
r.Get("/shared/{token}", a.handleSharedResource)
})
r.Group(func(r chi.Router) {
r.Use(a.requireSession)
r.Use(requireSameOriginMutation)
r.Get("/auth/me", a.handleMe)
r.Get("/teams", a.handleListTeams)
r.Post("/teams", a.handleCreateTeam)
r.Patch("/teams/{teamID}", a.handleUpdateTeam)
r.Get("/teams/{teamID}/members", a.handleListTeamMembers)
r.Get("/teams/{teamID}/invites", a.handleListTeamInvites)
r.Post("/teams/{teamID}/invites", a.handleCreateTeamInvite)
r.Post("/teams/{teamID}/users", a.handleCreateTeamUser)
r.Post("/invites/accept", a.handleAcceptInvite)
r.Get("/drawings", a.handleListDrawings)
r.Post("/drawings", a.handleCreateDrawing)
r.Get("/drawings/{drawingID}", a.handleGetDrawing)
r.Patch("/drawings/{drawingID}", a.handleUpdateDrawing)
r.Delete("/drawings/{drawingID}", a.handleArchiveDrawing)
r.Get("/drawings/{drawingID}/revisions", a.handleListRevisions)
r.Post("/drawings/{drawingID}/revisions", a.handleCreateRevision)
r.Get("/search", a.handleSearch)
r.Get("/drawings/{drawingID}/permissions", a.handleListPermissions)
r.Post("/drawings/{drawingID}/permissions", a.handleCreatePermission)
r.Get("/drawings/{drawingID}/share-links", a.handleListShareLinks)
r.Post("/drawings/{drawingID}/share-links", a.handleCreateShareLink)
r.Get("/drawings/{drawingID}/assets", a.handleListAssets)
r.Post("/drawings/{drawingID}/assets", a.handleCreateAsset)
r.Get("/drawings/{drawingID}/embeds", a.handleListEmbeds)
r.Post("/drawings/{drawingID}/embeds", a.handleCreateEmbed)
r.Get("/drawings/{drawingID}/links", a.handleListLinks)
r.Post("/drawings/{drawingID}/links", a.handleCreateLink)
r.Get("/drawings/{drawingID}/thumbnail", a.handleThumbnail)
r.Get("/templates", a.handleListTemplates)
r.Get("/activity", a.handleListActivity)
r.Get("/stats", a.handleStats)
r.Get("/folders", a.handleListFolders)
r.Post("/folders", a.handleCreateFolder)
r.Get("/projects", a.handleListProjects)
r.Post("/projects", a.handleCreateProject)
})
return r
}
func (a *API) handleHealth(w http.ResponseWriter, r *http.Request) {
if err := a.store.Ping(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "unhealthy"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
hasUsers, err := a.store.UserExists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to check setup status")
return
}
writeJSON(w, http.StatusOK, map[string]any{"has_users": hasUsers})
}
type contextKey string
const currentUserKey = contextKey("workspace_user")
func (a *API) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
writeError(w, http.StatusUnauthorized, "Authentication required")
return
}
user, session, err := a.store.UserBySessionToken(r.Context(), cookie.Value)
if err != nil {
clearSessionCookie(w, r)
writeError(w, http.StatusUnauthorized, "Authentication required")
return
}
ctx := withUser(r.Context(), user, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func requireSameOriginMutation(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
expectedHTTP := "http://" + r.Host
expectedHTTPS := "https://" + r.Host
if origin != expectedHTTP && origin != expectedHTTPS {
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
return
}
next.ServeHTTP(w, r)
})
}
func (a *API) handleSignup(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &req, 64<<10) {
return
}
// First-run: only allow signup if no users exist yet
if !a.testMode {
hasUsers, err := a.store.UserExists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to check setup status")
return
}
if hasUsers {
writeError(w, http.StatusForbidden, "Registration is closed. Contact an administrator.")
return
}
}
ipKey := "signup:" + clientIP(r)
if !a.limiter.allow(ipKey) {
writeError(w, http.StatusTooManyRequests, "Too many signup attempts")
return
}
user, session, token, err := a.store.CreateUserWithPassword(r.Context(), req.Name, req.Email, req.Password)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, ErrConflict) {
status = http.StatusConflict
}
writeError(w, status, err.Error())
return
}
setSessionCookie(w, r, token, session.ExpiresAt)
writeJSON(w, http.StatusCreated, map[string]any{"user": user, "session": session})
}
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &req, 32<<10) {
return
}
key := "login:" + clientIP(r) + ":" + strings.ToLower(strings.TrimSpace(req.Email))
if !a.limiter.allow(key) {
writeError(w, http.StatusTooManyRequests, "Too many login attempts")
return
}
user, session, token, err := a.store.AuthenticatePassword(r.Context(), req.Email, req.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, "Invalid email or password")
return
}
setSessionCookie(w, r, token, session.ExpiresAt)
writeJSON(w, http.StatusOK, map[string]any{"user": user, "session": session})
}
func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
if err := a.store.DeleteSession(r.Context(), cookie.Value); err != nil {
logrus.WithError(err).Warn("failed to delete session")
}
}
clearSessionCookie(w, r)
writeJSON(w, http.StatusOK, map[string]any{})
}
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
writeJSON(w, http.StatusOK, user)
}
func (a *API) handleListTeams(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teams, err := a.store.ListTeamsForUser(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to list teams")
return
}
writeJSON(w, http.StatusOK, teams)
}
func (a *API) handleCreateTeam(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if !decodeJSON(w, r, &req, 64<<10) {
return
}
team, err := a.store.CreateTeam(r.Context(), user.ID, req.Name, req.Slug)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, team)
}
func (a *API) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
}
if !decodeJSON(w, r, &req, 64<<10) {
return
}
team, err := a.store.UpdateTeam(r.Context(), user.ID, chi.URLParam(r, "teamID"), req.Name, req.Slug)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, team)
}
func (a *API) handleListTeamMembers(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := chi.URLParam(r, "teamID")
if ok, err := a.store.UserCanAccessTeam(r.Context(), user.ID, teamID); err != nil || !ok {
writeError(w, http.StatusForbidden, "Team access denied")
return
}
members, err := a.store.ListTeamMembers(r.Context(), teamID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to list team members")
return
}
writeJSON(w, http.StatusOK, members)
}
func (a *API) handleListDrawings(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
drawings, err := a.store.ListDrawings(r.Context(), user.ID, teamID)
if err != nil {
if errors.Is(err, ErrForbidden) {
writeError(w, http.StatusForbidden, "Team access denied")
return
}
writeError(w, http.StatusInternalServerError, "Failed to list drawings")
return
}
writeJSON(w, http.StatusOK, drawings)
}
func (a *API) handleCreateDrawing(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateDrawingRequest
if !decodeJSON(w, r, &req, 256<<10) {
return
}
drawing, err := a.store.CreateDrawing(r.Context(), user.ID, req)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, ErrForbidden) {
status = http.StatusForbidden
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusCreated, drawing)
}
func (a *API) handleGetDrawing(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
drawing, err := a.store.GetDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, drawing)
}
func (a *API) handleUpdateDrawing(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req UpdateDrawingRequest
if !decodeJSON(w, r, &req, 256<<10) {
return
}
drawing, err := a.store.UpdateDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, drawing)
}
func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if err := a.store.ArchiveDrawing(r.Context(), user.ID, chi.URLParam(r, "drawingID")); err != nil {
writeLookupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *API) handleListRevisions(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
revisions, err := a.store.ListRevisions(r.Context(), user.ID, chi.URLParam(r, "drawingID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, revisions)
}
func (a *API) handleCreateRevision(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateRevisionRequest
if !decodeJSON(w, r, &req, 10<<20) {
return
}
revision, err := a.store.CreateRevision(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, revision)
}
func (a *API) handleThumbnail(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
drawingID := chi.URLParam(r, "drawingID")
revisions, err := a.store.ListRevisions(r.Context(), user.ID, drawingID)
if err != nil {
writeLookupError(w, err)
return
}
if len(revisions) == 0 || revisions[0].Snapshot == nil {
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240"><rect width="320" height="240" fill="#f8f9fa"/><text x="160" y="120" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#999">No preview</text></svg>`))
return
}
var snapshot struct {
Elements []struct {
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Stroke string `json:"strokeColor"`
Bg string `json:"backgroundColor"`
Text string `json:"text"`
} `json:"elements"`
}
if err := json.Unmarshal(revisions[0].Snapshot, &snapshot); err != nil {
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240"><rect width="320" height="240" fill="#f8f9fa"/><text x="160" y="120" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#999">Preview unavailable</text></svg>`))
return
}
// Generate a simple SVG thumbnail from element bounding boxes
const vw, vh = 320, 240
var b strings.Builder
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="` + itoa(vw) + `" height="` + itoa(vh) + `" viewBox="0 0 ` + itoa(vw) + ` ` + itoa(vh) + `">`)
b.WriteString(`<rect width="` + itoa(vw) + `" height="` + itoa(vh) + `" fill="#ffffff"/>`)
// Compute bounding box to fit elements into view
minX, minY, maxX, maxY := 1e9, 1e9, -1e9, -1e9
for _, el := range snapshot.Elements {
if el.X < minX {
minX = el.X
}
if el.Y < minY {
minY = el.Y
}
if el.X+el.Width > maxX {
maxX = el.X + el.Width
}
if el.Y+el.Height > maxY {
maxY = el.Y + el.Height
}
}
if maxX <= minX || maxY <= minY {
minX, minY, maxX, maxY = 0, 0, 320, 240
}
pad := 20.0
scaleX := float64(vw-40) / (maxX - minX + 1e-6)
scaleY := float64(vh-40) / (maxY - minY + 1e-6)
scale := scaleX
if scaleY < scaleX {
scale = scaleY
}
offX := pad - minX*scale
offY := pad - minY*scale
for _, el := range snapshot.Elements {
x := el.X*scale + offX
y := el.Y*scale + offY
w := el.Width * scale
h := el.Height * scale
stroke := el.Stroke
if stroke == "" {
stroke = "#1e1e1e"
}
bg := el.Bg
if bg == "" || bg == "transparent" {
bg = "none"
}
switch el.Type {
case "rectangle", "diamond":
b.WriteString(`<rect x="` + ftoa(x) + `" y="` + ftoa(y) + `" width="` + ftoa(w) + `" height="` + ftoa(h) + `" fill="` + bg + `" stroke="` + stroke + `" stroke-width="1"/>`)
case "ellipse":
b.WriteString(`<ellipse cx="` + ftoa(x+w/2) + `" cy="` + ftoa(y+h/2) + `" rx="` + ftoa(w/2) + `" ry="` + ftoa(h/2) + `" fill="` + bg + `" stroke="` + stroke + `" stroke-width="1"/>`)
case "line", "arrow":
b.WriteString(`<line x1="` + ftoa(x) + `" y1="` + ftoa(y+h/2) + `" x2="` + ftoa(x+w) + `" y2="` + ftoa(y+h/2) + `" stroke="` + stroke + `" stroke-width="1"/>`)
case "text":
b.WriteString(`<text x="` + ftoa(x) + `" y="` + ftoa(y+h/2) + `" font-family="sans-serif" font-size="12" fill="` + stroke + `">` + htmlEscape(el.Text) + `</text>`)
default:
b.WriteString(`<rect x="` + ftoa(x) + `" y="` + ftoa(y) + `" width="` + ftoa(w) + `" height="` + ftoa(h) + `" fill="none" stroke="#ccc" stroke-width="0.5"/>`)
}
}
b.WriteString(`</svg>`)
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "max-age=60")
w.WriteHeader(http.StatusOK)
w.Write([]byte(b.String()))
}
func ftoa(f float64) string { return strconv.FormatFloat(f, 'f', 2, 64) }
func itoa(i int) string { return strconv.Itoa(i) }
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
func (a *API) handleListTemplates(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
templates, err := a.store.ListTemplates(r.Context(), user.ID, teamID)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, templates)
}
func (a *API) handleListActivity(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
activity, err := a.store.ListActivity(r.Context(), user.ID, teamID, 50)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, activity)
}
func (a *API) handleStats(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
stats, err := a.store.WorkspaceStats(r.Context(), user.ID, teamID)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, stats)
}
func (a *API) handleListFolders(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
folders, err := a.store.ListFolders(r.Context(), user.ID, teamID)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, folders)
}
func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateFolderRequest
if !decodeJSON(w, r, &req, 128<<10) {
return
}
folder, err := a.store.CreateFolder(r.Context(), user.ID, req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, folder)
}
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
projects, err := a.store.ListProjects(r.Context(), user.ID, teamID)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, projects)
}
func (a *API) handleCreateProject(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateProjectRequest
if !decodeJSON(w, r, &req, 128<<10) {
return
}
project, err := a.store.CreateProject(r.Context(), user.ID, req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, project)
}
func (a *API) handleSearch(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeJSON(w, http.StatusOK, []Drawing{})
return
}
drawings, err := a.store.SearchDrawings(r.Context(), user.ID, q)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, drawings)
}
func writeLookupError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrForbidden):
writeError(w, http.StatusForbidden, "Access denied")
case errors.Is(err, sql.ErrNoRows), errors.Is(err, ErrNotFound):
writeError(w, http.StatusNotFound, "Resource not found")
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any, limit int64) bool {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, limit)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
logrus.WithError(err).Warn("failed to encode response")
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
SetSessionCookie(w, r, token, expires)
}
func SetSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isSecureRequest(r),
SameSite: http.SameSiteLaxMode,
})
}
func isSecureRequest(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func clientIP(r *http.Request) string {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
return strings.TrimSpace(strings.Split(forwarded, ",")[0])
}
host := r.RemoteAddr
if idx := strings.LastIndex(host, ":"); idx > 0 {
return host[:idx]
}
return host
}
+214
View File
@@ -0,0 +1,214 @@
package workspace
import (
"database/sql"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
)
func (a *API) handleListTeamInvites(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
invites, err := a.store.ListTeamInvites(r.Context(), user.ID, chi.URLParam(r, "teamID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, invites)
}
func (a *API) handleCreateTeamInvite(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateInviteRequest
if !decodeJSON(w, r, &req, 64<<10) {
return
}
invite, token, err := a.store.CreateTeamInvite(r.Context(), user.ID, chi.URLParam(r, "teamID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"invite": invite, "token": token})
}
func (a *API) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req struct {
Token string `json:"token"`
}
if !decodeJSON(w, r, &req, 32<<10) {
return
}
membership, err := a.store.AcceptInvite(r.Context(), user.ID, req.Token)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, membership)
}
func (a *API) handleListPermissions(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
grants, err := a.store.ListPermissionGrants(r.Context(), user.ID, "drawing", chi.URLParam(r, "drawingID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, grants)
}
func (a *API) handleCreatePermission(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreatePermissionGrantRequest
if !decodeJSON(w, r, &req, 64<<10) {
return
}
grant, err := a.store.CreateDrawingPermissionGrant(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, grant)
}
func (a *API) handleListShareLinks(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
links, err := a.store.ListShareLinks(r.Context(), user.ID, "drawing", chi.URLParam(r, "drawingID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, links)
}
func (a *API) handleCreateShareLink(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateShareLinkRequest
if !decodeJSON(w, r, &req, 64<<10) {
return
}
link, token, err := a.store.CreateDrawingShareLink(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"share_link": link, "token": token})
}
func (a *API) handleSharedResource(w http.ResponseWriter, r *http.Request) {
payload, err := a.store.SharedResourceByToken(r.Context(), chi.URLParam(r, "token"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, payload)
}
func (a *API) handleListAssets(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
assets, err := a.store.ListDrawingAssets(r.Context(), user.ID, chi.URLParam(r, "drawingID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, assets)
}
func (a *API) handleCreateAsset(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateAssetRequest
if !decodeJSON(w, r, &req, 64<<10) {
return
}
asset, err := a.store.CreateDrawingAsset(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, asset)
}
func (a *API) handleListEmbeds(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
embeds, err := a.store.ListEmbeds(r.Context(), user.ID, chi.URLParam(r, "drawingID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, embeds)
}
func (a *API) handleCreateEmbed(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateEmbedRequest
if !decodeJSON(w, r, &req, 64<<10) {
return
}
embed, err := a.store.CreateEmbed(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, embed)
}
func (a *API) handleListLinks(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
links, err := a.store.ListLinkReferences(r.Context(), user.ID, "drawing", chi.URLParam(r, "drawingID"))
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, links)
}
func (a *API) handleCreateLink(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateLinkRequest
if !decodeJSON(w, r, &req, 64<<10) {
return
}
link, err := a.store.CreateDrawingLinkReference(r.Context(), user.ID, chi.URLParam(r, "drawingID"), req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, link)
}
func (a *API) handleCreateTeamUser(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := chi.URLParam(r, "teamID")
var role string
err := a.store.db.QueryRowContext(r.Context(), `SELECT role FROM workspace_team_memberships WHERE user_id = ? AND team_id = ?`, user.ID, teamID).Scan(&role)
if errors.Is(err, sql.ErrNoRows) || (err == nil && role != "owner" && role != "admin") {
writeError(w, http.StatusForbidden, "Only team owners and admins can add members")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to verify team access")
return
}
var req struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
if !decodeJSON(w, r, &req, 32<<10) {
return
}
newUser, err := a.store.CreateTeamUser(r.Context(), teamID, req.Name, req.Email, req.Password, req.Role)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, ErrConflict) {
status = http.StatusConflict
}
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusCreated, newUser)
}
+307
View File
@@ -0,0 +1,307 @@
package workspace
import (
"bytes"
"context"
"encoding/json"
dbpostgres "excalidraw-complete/internal/postgres"
"net/url"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func newTestStore(t *testing.T) (*Store, func()) {
t.Helper()
baseURL := os.Getenv("TEST_DATABASE_URL")
if baseURL == "" {
baseURL = os.Getenv("DATABASE_URL")
}
if baseURL == "" {
t.Skip("TEST_DATABASE_URL or DATABASE_URL is required for PostgreSQL workspace tests")
}
schema := "test_" + strings.ToLower(newID())
adminDB, err := dbpostgres.Open(baseURL)
if err != nil {
t.Fatalf("open test database error = %v", err)
}
if _, err := adminDB.DB.ExecContext(context.Background(), `CREATE SCHEMA "`+schema+`"`); err != nil {
adminDB.Close()
t.Fatalf("create test schema error = %v", err)
}
store, err := NewStore(databaseURLWithSearchPath(t, baseURL, schema))
if err != nil {
adminDB.DB.ExecContext(context.Background(), `DROP SCHEMA "`+schema+`" CASCADE`)
adminDB.Close()
t.Fatalf("NewStore() error = %v", err)
}
return store, func() {
if err := store.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if _, err := adminDB.DB.ExecContext(context.Background(), `DROP SCHEMA "`+schema+`" CASCADE`); err != nil {
t.Fatalf("drop test schema error = %v", err)
}
if err := adminDB.Close(); err != nil {
t.Fatalf("admin Close() error = %v", err)
}
}
}
func databaseURLWithSearchPath(t *testing.T, rawURL, schema string) string {
t.Helper()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("parse database URL error = %v", err)
}
q := parsed.Query()
q.Set("search_path", schema)
parsed.RawQuery = q.Encode()
return parsed.String()
}
func newTestAPI(t *testing.T) (*API, func()) {
t.Helper()
store, cleanup := newTestStore(t)
api := NewAPI(store)
api.testMode = true
return api, cleanup
}
func doJSON(t *testing.T, api *API, method, path string, body any, cookies ...*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var raw bytes.Buffer
if body != nil {
if err := json.NewEncoder(&raw).Encode(body); err != nil {
t.Fatalf("json encode error = %v", err)
}
}
req := httptest.NewRequest(method, path, &raw)
req.Header.Set("Content-Type", "application/json")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rr := httptest.NewRecorder()
api.Routes().ServeHTTP(rr, req)
return rr
}
func signup(t *testing.T, api *API, email string) (*http.Cookie, User, Team) {
t.Helper()
rr := doJSON(t, api, http.MethodPost, "/auth/signup", map[string]string{
"name": "Test User",
"email": email,
"password": "password-123",
})
if rr.Code != http.StatusCreated {
t.Fatalf("signup status = %d body = %s", rr.Code, rr.Body.String())
}
var payload struct {
User User `json:"user"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil {
t.Fatalf("signup response decode error = %v", err)
}
var sessionCookie *http.Cookie
for _, cookie := range rr.Result().Cookies() {
if cookie.Name == sessionCookieName {
copy := *cookie
sessionCookie = &copy
break
}
}
if sessionCookie == nil {
t.Fatal("signup did not set session cookie")
}
teamsRR := doJSON(t, api, http.MethodGet, "/teams", nil, sessionCookie)
if teamsRR.Code != http.StatusOK {
t.Fatalf("teams status = %d body = %s", teamsRR.Code, teamsRR.Body.String())
}
var teams []Team
if err := json.Unmarshal(teamsRR.Body.Bytes(), &teams); err != nil {
t.Fatalf("teams decode error = %v", err)
}
if len(teams) != 1 {
t.Fatalf("teams len = %d, want 1", len(teams))
}
return sessionCookie, payload.User, teams[0]
}
func TestSignupCreatesCookieSessionAndDefaultTeam(t *testing.T) {
api, cleanup := newTestAPI(t)
defer cleanup()
cookie, user, team := signup(t, api, "alice@example.com")
if !cookie.HttpOnly {
t.Fatal("session cookie must be httpOnly")
}
if user.ID == "" || user.Email != "alice@example.com" {
t.Fatalf("unexpected user: %#v", user)
}
if team.OwnerUserID != user.ID || team.PlanType != "free" {
t.Fatalf("unexpected team: %#v", team)
}
meRR := doJSON(t, api, http.MethodGet, "/auth/me", nil, cookie)
if meRR.Code != http.StatusOK {
t.Fatalf("me status = %d body = %s", meRR.Code, meRR.Body.String())
}
logoutRR := doJSON(t, api, http.MethodPost, "/auth/logout", nil, cookie)
if logoutRR.Code != http.StatusOK {
t.Fatalf("logout status = %d body = %s", logoutRR.Code, logoutRR.Body.String())
}
if logoutRR.Body.String() == "" {
t.Fatal("logout must return JSON for frontend fetchApi compatibility")
}
afterLogoutRR := doJSON(t, api, http.MethodGet, "/auth/me", nil, cookie)
if afterLogoutRR.Code != http.StatusUnauthorized {
t.Fatalf("me after logout status = %d body = %s", afterLogoutRR.Code, afterLogoutRR.Body.String())
}
}
func TestDrawingAccessRequiresTeamMembership(t *testing.T) {
api, cleanup := newTestAPI(t)
defer cleanup()
aliceCookie, _, aliceTeam := signup(t, api, "alice@example.com")
bobCookie, _, _ := signup(t, api, "bob@example.com")
createRR := doJSON(t, api, http.MethodPost, "/drawings", map[string]any{
"team_id": aliceTeam.ID,
"title": "Architecture map",
}, aliceCookie)
if createRR.Code != http.StatusCreated {
t.Fatalf("create drawing status = %d body = %s", createRR.Code, createRR.Body.String())
}
var drawing Drawing
if err := json.Unmarshal(createRR.Body.Bytes(), &drawing); err != nil {
t.Fatalf("drawing decode error = %v", err)
}
forbiddenRR := doJSON(t, api, http.MethodGet, "/drawings/"+drawing.ID, nil, bobCookie)
if forbiddenRR.Code != http.StatusForbidden {
t.Fatalf("bob get drawing status = %d body = %s", forbiddenRR.Code, forbiddenRR.Body.String())
}
listRR := doJSON(t, api, http.MethodGet, "/drawings?team_id="+aliceTeam.ID, nil, bobCookie)
if listRR.Code != http.StatusForbidden {
t.Fatalf("bob list team drawings status = %d body = %s", listRR.Code, listRR.Body.String())
}
}
func TestTeamMembersRequireMembership(t *testing.T) {
api, cleanup := newTestAPI(t)
defer cleanup()
aliceCookie, _, aliceTeam := signup(t, api, "alice@example.com")
bobCookie, _, _ := signup(t, api, "bob@example.com")
okRR := doJSON(t, api, http.MethodGet, "/teams/"+aliceTeam.ID+"/members", nil, aliceCookie)
if okRR.Code != http.StatusOK {
t.Fatalf("alice members status = %d body = %s", okRR.Code, okRR.Body.String())
}
var members []TeamMembership
if err := json.Unmarshal(okRR.Body.Bytes(), &members); err != nil {
t.Fatalf("members decode error = %v", err)
}
if len(members) != 1 || members[0].Role != "owner" || members[0].User == nil {
t.Fatalf("unexpected members: %#v", members)
}
forbiddenRR := doJSON(t, api, http.MethodGet, "/teams/"+aliceTeam.ID+"/members", nil, bobCookie)
if forbiddenRR.Code != http.StatusForbidden {
t.Fatalf("bob members status = %d body = %s", forbiddenRR.Code, forbiddenRR.Body.String())
}
}
func TestDrawingRevisionsTemplatesAndActivity(t *testing.T) {
api, cleanup := newTestAPI(t)
defer cleanup()
cookie, _, team := signup(t, api, "alice@example.com")
createRR := doJSON(t, api, http.MethodPost, "/drawings", map[string]any{
"team_id": team.ID,
"title": "Launch plan",
"snapshot": map[string]any{"type": "excalidraw", "elements": []any{}},
}, cookie)
if createRR.Code != http.StatusCreated {
t.Fatalf("create drawing status = %d body = %s", createRR.Code, createRR.Body.String())
}
var drawing Drawing
if err := json.Unmarshal(createRR.Body.Bytes(), &drawing); err != nil {
t.Fatalf("drawing decode error = %v", err)
}
if drawing.LatestRevisionID == nil {
t.Fatal("create drawing with snapshot must create latest_revision_id")
}
revisionRR := doJSON(t, api, http.MethodPost, "/drawings/"+drawing.ID+"/revisions", map[string]any{
"snapshot": map[string]any{"type": "excalidraw", "elements": []any{map[string]any{"id": "a"}}},
"change_summary": "Added first shape",
}, cookie)
if revisionRR.Code != http.StatusCreated {
t.Fatalf("create revision status = %d body = %s", revisionRR.Code, revisionRR.Body.String())
}
revisionsRR := doJSON(t, api, http.MethodGet, "/drawings/"+drawing.ID+"/revisions", nil, cookie)
if revisionsRR.Code != http.StatusOK {
t.Fatalf("list revisions status = %d body = %s", revisionsRR.Code, revisionsRR.Body.String())
}
var revisions []DrawingRevision
if err := json.Unmarshal(revisionsRR.Body.Bytes(), &revisions); err != nil {
t.Fatalf("revisions decode error = %v", err)
}
if len(revisions) != 2 || revisions[0].RevisionNumber != 2 {
t.Fatalf("unexpected revisions: %#v", revisions)
}
templatesRR := doJSON(t, api, http.MethodGet, "/templates", nil, cookie)
if templatesRR.Code != http.StatusOK {
t.Fatalf("templates status = %d body = %s", templatesRR.Code, templatesRR.Body.String())
}
var templates []Template
if err := json.Unmarshal(templatesRR.Body.Bytes(), &templates); err != nil {
t.Fatalf("templates decode error = %v", err)
}
if len(templates) < 4 {
t.Fatalf("templates len = %d, want at least 4", len(templates))
}
activityRR := doJSON(t, api, http.MethodGet, "/activity?team_id="+team.ID, nil, cookie)
if activityRR.Code != http.StatusOK {
t.Fatalf("activity status = %d body = %s", activityRR.Code, activityRR.Body.String())
}
var activity []ActivityEvent
if err := json.Unmarshal(activityRR.Body.Bytes(), &activity); err != nil {
t.Fatalf("activity decode error = %v", err)
}
if len(activity) == 0 {
t.Fatal("expected activity events")
}
statsRR := doJSON(t, api, http.MethodGet, "/stats?team_id="+team.ID, nil, cookie)
if statsRR.Code != http.StatusOK {
t.Fatalf("stats status = %d body = %s", statsRR.Code, statsRR.Body.String())
}
var stats WorkspaceStats
if err := json.Unmarshal(statsRR.Body.Bytes(), &stats); err != nil {
t.Fatalf("stats decode error = %v", err)
}
if stats.Drawings != 1 || stats.Revisions != 2 || stats.Templates < 4 {
t.Fatalf("unexpected stats: %#v", stats)
}
}
func TestHealth(t *testing.T) {
api, cleanup := newTestAPI(t)
defer cleanup()
rr := doJSON(t, api, http.MethodGet, "/health", nil)
if rr.Code != http.StatusOK {
t.Fatalf("health status = %d body = %s", rr.Code, rr.Body.String())
}
}

Some files were not shown because too many files have changed in this diff Show More