From 178f5c517d43dac01fba1a02b9d6e485e83232b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 19 Jul 2025 00:55:13 +0200 Subject: [PATCH] document deployment --- Dockerfile.backend | 37 +++++ Dockerfile.frontend | 35 ++++ README.md | 70 +------- deploy.md | 41 +++++ docker-compose.indexeddb.yml | 11 ++ docker-compose.yml | 30 ++++ server/README.md | 30 ++++ server/go.mod | 8 + server/main.go | 266 +++++++++++++++++++++++++++++++ src/App.tsx | 2 + src/components/StorageConfig.tsx | 66 ++++++++ src/db.ts | 118 +++++++++++++- 12 files changed, 643 insertions(+), 71 deletions(-) create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend create mode 100644 deploy.md create mode 100644 docker-compose.indexeddb.yml create mode 100644 docker-compose.yml create mode 100644 server/README.md create mode 100644 server/go.mod create mode 100644 server/main.go create mode 100644 src/components/StorageConfig.tsx diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..995d54e --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,37 @@ +# Backend API server Dockerfile +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Install git +RUN apk add --no-cache git + +# Clone the repository +RUN git clone https://git.raphaelforment.fr/BuboBubo/palace.git . + +# Build the Go application +WORKDIR /app/server +RUN go mod tidy +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o main . + +# Production stage +FROM alpine:latest + +# Install sqlite3 for CGO +RUN apk --no-cache add ca-certificates sqlite + +WORKDIR /root/ + +# Copy the binary +COPY --from=builder /app/server/main . + +# Create data directory for SQLite +RUN mkdir -p /data + +# Expose port +EXPOSE 3001 + +# Set environment variable for database location +ENV DB_PATH=/data/palace.db + +CMD ["./main"] \ No newline at end of file diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..7eb01b3 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,35 @@ +# Frontend-only (IndexedDB) Dockerfile +FROM node:18-alpine AS builder + +WORKDIR /app + +# Clone the repository +RUN apk add --no-cache git +RUN git clone https://git.raphaelforment.fr/BuboBubo/palace.git . + +# Install dependencies +RUN npm install + +# Build the frontend +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config for SPA +RUN echo 'server { \ + listen 80; \ + server_name _; \ + location / { \ + root /usr/share/nginx/html; \ + index index.html; \ + try_files $uri $uri/ /index.html; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md index 7959ce4..012c09e 100644 --- a/README.md +++ b/README.md @@ -1,69 +1 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +# Palace diff --git a/deploy.md b/deploy.md new file mode 100644 index 0000000..e9d890d --- /dev/null +++ b/deploy.md @@ -0,0 +1,41 @@ +# Palace Deployment Guide + +## Option 1: IndexedDB Only (No Backend) + +```bash +# Build and run frontend only +docker-compose -f docker-compose.indexeddb.yml up -d +``` + +## Option 2: Full Stack with Backend + +```bash +# Build and run frontend + backend +docker-compose up -d +``` + +## Production Deployment + +For palace.raphaelforment.fr: + +1. **IndexedDB version:** + ```bash + docker build -f Dockerfile.frontend -t palace-frontend . + docker run -p 80:80 palace-frontend + ``` + +2. **Full stack version:** + ```bash + docker-compose up -d + ``` + +The frontend automatically detects the production domain and connects to the backend API at port 3001. + +## Ports + +- Frontend: 80 +- Backend API: 3001 + +## Data Persistence + +The backend uses SQLite with a persistent volume mounted at `/data/palace.db`. \ No newline at end of file diff --git a/docker-compose.indexeddb.yml b/docker-compose.indexeddb.yml new file mode 100644 index 0000000..fd3d3c2 --- /dev/null +++ b/docker-compose.indexeddb.yml @@ -0,0 +1,11 @@ +version: '3.8' + +services: + # IndexedDB-only deployment + palace-frontend: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "80:80" + restart: unless-stopped \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..83df6cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + # Full stack deployment with backend + palace-backend: + build: + context: . + dockerfile: Dockerfile.backend + ports: + - "3001:3001" + volumes: + - palace-data:/data + environment: + - PORT=3001 + restart: unless-stopped + + palace-frontend-db: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "80:80" + environment: + - API_URL=http://palace-backend:3001 + depends_on: + - palace-backend + restart: unless-stopped + +volumes: + palace-data: \ No newline at end of file diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..2cc1b27 --- /dev/null +++ b/server/README.md @@ -0,0 +1,30 @@ +# Palace Server + +Minimal Go API server with SQLite for Palace collaborative features. + +## Setup + +```bash +cd server +go mod tidy +go run main.go +``` + +Server runs on port 3001 by default (set PORT env var to change). + +## Resource Usage + +- Single binary (~10MB) +- SQLite database file +- Minimal memory footprint +- No external dependencies in production + +## API Endpoints + +- `GET /api/spaces` - List all spaces +- `GET /api/spaces/{id}` - Get space by ID +- `PUT /api/spaces/{id}` - Save/update space +- `DELETE /api/spaces/{id}` - Delete space +- `POST /api/spaces/{id}/notes` - Add note to space +- `PATCH /api/spaces/{spaceId}/notes/{noteId}` - Update note +- `DELETE /api/spaces/{spaceId}/notes/{noteId}` - Delete note \ No newline at end of file diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..6e68b0a --- /dev/null +++ b/server/go.mod @@ -0,0 +1,8 @@ +module palace-server + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.1 + github.com/mattn/go-sqlite3 v1.14.22 +) \ No newline at end of file diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..49d84dd --- /dev/null +++ b/server/main.go @@ -0,0 +1,266 @@ +package main + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "os" + + "github.com/gorilla/mux" + _ "github.com/mattn/go-sqlite3" +) + +type Note struct { + ID string `json:"id"` + X float64 `json:"x"` + Y float64 `json:"y"` + Content string `json:"content"` +} + +type Space struct { + ID string `json:"id"` + Notes []Note `json:"notes"` +} + +var db *sql.DB + +func main() { + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./palace.db" + } + + var err error + db, err = sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := initDB(); err != nil { + log.Fatal(err) + } + + r := mux.NewRouter() + + r.HandleFunc("/api/spaces", getSpaces).Methods("GET") + r.HandleFunc("/api/spaces/{id}", getSpace).Methods("GET") + r.HandleFunc("/api/spaces/{id}", saveSpace).Methods("PUT") + r.HandleFunc("/api/spaces/{id}", deleteSpace).Methods("DELETE") + r.HandleFunc("/api/spaces/{id}/notes", addNote).Methods("POST") + r.HandleFunc("/api/spaces/{spaceId}/notes/{noteId}", updateNote).Methods("PATCH") + r.HandleFunc("/api/spaces/{spaceId}/notes/{noteId}", deleteNote).Methods("DELETE") + + r.Use(corsMiddleware) + + port := os.Getenv("PORT") + if port == "" { + port = "3001" + } + + log.Printf("Server starting on port %s", port) + log.Fatal(http.ListenAndServe(":"+port, r)) +} + +func initDB() error { + query := ` + CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY, + notes TEXT DEFAULT '[]' + );` + _, err := db.Exec(query) + return err +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +func getSpaces(w http.ResponseWriter, r *http.Request) { + rows, err := db.Query("SELECT id FROM spaces") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + var spaces []Space + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + spaces = append(spaces, Space{ID: id, Notes: []Note{}}) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(spaces) +} + +func getSpace(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + var notesJSON string + err := db.QueryRow("SELECT notes FROM spaces WHERE id = ?", id).Scan(¬esJSON) + if err == sql.ErrNoRows { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var notes []Note + if err := json.Unmarshal([]byte(notesJSON), ¬es); err != nil { + notes = []Note{} + } + + space := Space{ID: id, Notes: notes} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(space) +} + +func saveSpace(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + var space Space + if err := json.NewDecoder(r.Body).Decode(&space); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + notesJSON, _ := json.Marshal(space.Notes) + + _, err := db.Exec("INSERT OR REPLACE INTO spaces (id, notes) VALUES (?, ?)", id, string(notesJSON)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func deleteSpace(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + _, err := db.Exec("DELETE FROM spaces WHERE id = ?", id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func addNote(w http.ResponseWriter, r *http.Request) { + spaceId := mux.Vars(r)["id"] + + var note Note + if err := json.NewDecoder(r.Body).Decode(¬e); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var notesJSON string + err := db.QueryRow("SELECT notes FROM spaces WHERE id = ?", spaceId).Scan(¬esJSON) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + var notes []Note + json.Unmarshal([]byte(notesJSON), ¬es) + notes = append(notes, note) + + newNotesJSON, _ := json.Marshal(notes) + _, err = db.Exec("UPDATE spaces SET notes = ? WHERE id = ?", string(newNotesJSON), spaceId) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func updateNote(w http.ResponseWriter, r *http.Request) { + spaceId := mux.Vars(r)["spaceId"] + noteId := mux.Vars(r)["noteId"] + + var updates Note + if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var notesJSON string + err := db.QueryRow("SELECT notes FROM spaces WHERE id = ?", spaceId).Scan(¬esJSON) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + var notes []Note + json.Unmarshal([]byte(notesJSON), ¬es) + + for i, note := range notes { + if note.ID == noteId { + if updates.X != 0 { notes[i].X = updates.X } + if updates.Y != 0 { notes[i].Y = updates.Y } + if updates.Content != "" { notes[i].Content = updates.Content } + break + } + } + + newNotesJSON, _ := json.Marshal(notes) + _, err = db.Exec("UPDATE spaces SET notes = ? WHERE id = ?", string(newNotesJSON), spaceId) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func deleteNote(w http.ResponseWriter, r *http.Request) { + spaceId := mux.Vars(r)["spaceId"] + noteId := mux.Vars(r)["noteId"] + + var notesJSON string + err := db.QueryRow("SELECT notes FROM spaces WHERE id = ?", spaceId).Scan(¬esJSON) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + var notes []Note + json.Unmarshal([]byte(notesJSON), ¬es) + + for i, note := range notes { + if note.ID == noteId { + notes = append(notes[:i], notes[i+1:]...) + break + } + } + + newNotesJSON, _ := json.Marshal(notes) + _, err = db.Exec("UPDATE spaces SET notes = ? WHERE id = ?", string(newNotesJSON), spaceId) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index f7ab1b9..37b8c4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { loadSpace, getSpaceFromUrl } from './store' import { Space } from './components/Space' import { Navigation } from './components/Navigation' +import { StorageConfig } from './components/StorageConfig' import './App.css' function App() { @@ -22,6 +23,7 @@ function App() { <> + ) } diff --git a/src/components/StorageConfig.tsx b/src/components/StorageConfig.tsx new file mode 100644 index 0000000..fa5d363 --- /dev/null +++ b/src/components/StorageConfig.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import { getStorageMode, setStorageMode, type StorageMode } from '../db' + +export function StorageConfig() { + const [currentMode, setCurrentMode] = useState(getStorageMode()) + const [showConfig, setShowConfig] = useState(false) + + const handleModeChange = (mode: StorageMode) => { + setStorageMode(mode) + setCurrentMode(mode) + window.location.reload() + } + + if (!showConfig) { + return ( + + ) + } + + return ( +
+
+

Storage Mode

+ +
+ +
+ + + +
+ +
+ Current: {currentMode === 'indexeddb' ? 'Local storage' : 'Remote database'} +
+
+ ) +} \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index 978d903..90d3c81 100644 --- a/src/db.ts +++ b/src/db.ts @@ -10,7 +10,21 @@ type Space = { notes: Note[] } -class Database { +type StorageMode = 'indexeddb' | 'database' + +interface StorageAdapter { + init(): Promise + getSpace(id: string): Promise + saveSpace(space: Space): Promise + createSpace(id: string): Promise + addNoteToSpace(spaceId: string, note: Note): Promise + updateNoteInSpace(spaceId: string, noteId: string, updates: Partial): Promise + deleteNoteFromSpace(spaceId: string, noteId: string): Promise + getAllSpaceIds(): Promise + deleteSpace(id: string): Promise +} + +class IndexedDBAdapter implements StorageAdapter { private db: IDBDatabase | null = null async init() { @@ -121,4 +135,104 @@ class Database { } } -export const db = new Database() \ No newline at end of file +class DatabaseAdapter implements StorageAdapter { + private apiUrl: string + + constructor(apiUrl?: string) { + this.apiUrl = apiUrl || this.getApiUrl() + } + + private getApiUrl(): string { + if (typeof window !== 'undefined' && window.location.hostname === 'palace.raphaelforment.fr') { + return 'https://palace.raphaelforment.fr:3001/api' + } + return '/api' + } + + async init(): Promise { + // Database connection is handled per request + } + + async getSpace(id: string): Promise { + const response = await fetch(`${this.apiUrl}/spaces/${id}`) + if (response.status === 404) return undefined + if (!response.ok) throw new Error(`Failed to get space: ${response.statusText}`) + return await response.json() + } + + async saveSpace(space: Space): Promise { + const response = await fetch(`${this.apiUrl}/spaces/${space.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(space) + }) + if (!response.ok) throw new Error(`Failed to save space: ${response.statusText}`) + } + + async createSpace(id: string): Promise { + const space: Space = { id, notes: [] } + await this.saveSpace(space) + return space + } + + async addNoteToSpace(spaceId: string, note: Note): Promise { + const response = await fetch(`${this.apiUrl}/spaces/${spaceId}/notes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(note) + }) + if (!response.ok) throw new Error(`Failed to add note: ${response.statusText}`) + } + + async updateNoteInSpace(spaceId: string, noteId: string, updates: Partial): Promise { + const response = await fetch(`${this.apiUrl}/spaces/${spaceId}/notes/${noteId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }) + if (!response.ok) throw new Error(`Failed to update note: ${response.statusText}`) + } + + async deleteNoteFromSpace(spaceId: string, noteId: string): Promise { + const response = await fetch(`${this.apiUrl}/spaces/${spaceId}/notes/${noteId}`, { + method: 'DELETE' + }) + if (!response.ok) throw new Error(`Failed to delete note: ${response.statusText}`) + } + + async getAllSpaceIds(): Promise { + const response = await fetch(`${this.apiUrl}/spaces`) + if (!response.ok) throw new Error(`Failed to get spaces: ${response.statusText}`) + const spaces = await response.json() + return spaces.map((space: Space) => space.id) + } + + async deleteSpace(id: string): Promise { + const response = await fetch(`${this.apiUrl}/spaces/${id}`, { + method: 'DELETE' + }) + if (!response.ok) throw new Error(`Failed to delete space: ${response.statusText}`) + } +} + +function getStorageMode(): StorageMode { + return (localStorage.getItem('palace-storage-mode') as StorageMode) || 'indexeddb' +} + +function setStorageMode(mode: StorageMode): void { + localStorage.setItem('palace-storage-mode', mode) +} + +function createStorageAdapter(): StorageAdapter { + const mode = getStorageMode() + switch (mode) { + case 'database': + return new DatabaseAdapter() + case 'indexeddb': + default: + return new IndexedDBAdapter() + } +} + +export const db = createStorageAdapter() +export { type Note, type Space, type StorageMode, getStorageMode, setStorageMode } \ No newline at end of file