mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
feat: full project sync - CI fixes, frontend, workspace API, and all changes
This commit is contained in:
+43
-11
@@ -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 # 支持memory,filesystem, 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
@@ -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
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 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.
|
|
||||||
2. **Copy Frontend**: Ensure the built frontend from `excalidraw/excalidraw-app/build` is copied to the `frontend/` directory in the root.
|
|
||||||
3. **Build Go Backend**:
|
|
||||||
```bash
|
```bash
|
||||||
go build -o excalidraw-complete main.go
|
make build # Full production build (frontend + Go binary)
|
||||||
|
make build-frontend # React build only
|
||||||
|
make build-backend # Go binary only
|
||||||
|
make build-docker # Docker image locally
|
||||||
|
make test # Run Go + frontend tests
|
||||||
|
make test-backend # Go unit tests
|
||||||
|
make test-frontend # Vitest unit tests
|
||||||
|
make test-e2e # Playwright E2E tests
|
||||||
|
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
|
||||||
```
|
```
|
||||||
4. **Run**:
|
|
||||||
```bash
|
|
||||||
# Set environment variables first
|
|
||||||
./excalidraw-complete
|
|
||||||
```
|
|
||||||
---
|
|
||||||
|
|
||||||
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
@@ -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 密钥由后端安全管理,前端只负责调用。
|
|
||||||
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
## 主要特性
|
|
||||||
|
|
||||||
- **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
@@ -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.
|
||||||
@@ -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
|
||||||
Submodule cloudflare-worker deleted from ed0d7f3e1e
+30
-4
@@ -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
@@ -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
|
||||||
+1
-1
Submodule excalidraw updated: b5cca508d4...2e1a529c67
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
Generated
+3879
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useAuth } from './useAuth';
|
||||||
|
export { useDrawings } from './useDrawings';
|
||||||
|
export { useTeams } from './useTeams';
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
@@ -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)}`),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { api } from './api';
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { useAuthStore } from './authStore';
|
||||||
|
export { useTeamStore } from './teamStore';
|
||||||
|
export { useDrawingStore } from './drawingStore';
|
||||||
|
export { useThemeStore } from './themeStore';
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+16
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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{
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Excalidraw",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
+126
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, `"`, """)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 = ©
|
||||||
|
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
Reference in New Issue
Block a user