document deployment
This commit is contained in:
37
Dockerfile.backend
Normal file
37
Dockerfile.backend
Normal file
@ -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"]
|
||||||
35
Dockerfile.frontend
Normal file
35
Dockerfile.frontend
Normal file
@ -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;"]
|
||||||
70
README.md
70
README.md
@ -1,69 +1 @@
|
|||||||
# React + TypeScript + Vite
|
# Palace
|
||||||
|
|
||||||
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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
41
deploy.md
Normal file
41
deploy.md
Normal file
@ -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`.
|
||||||
11
docker-compose.indexeddb.yml
Normal file
11
docker-compose.indexeddb.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# IndexedDB-only deployment
|
||||||
|
palace-frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
restart: unless-stopped
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@ -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:
|
||||||
30
server/README.md
Normal file
30
server/README.md
Normal file
@ -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
|
||||||
8
server/go.mod
Normal file
8
server/go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
266
server/main.go
Normal file
266
server/main.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useEffect } from 'react'
|
|||||||
import { loadSpace, getSpaceFromUrl } from './store'
|
import { loadSpace, getSpaceFromUrl } from './store'
|
||||||
import { Space } from './components/Space'
|
import { Space } from './components/Space'
|
||||||
import { Navigation } from './components/Navigation'
|
import { Navigation } from './components/Navigation'
|
||||||
|
import { StorageConfig } from './components/StorageConfig'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -22,6 +23,7 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<Space />
|
<Space />
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
<StorageConfig />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/components/StorageConfig.tsx
Normal file
66
src/components/StorageConfig.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { getStorageMode, setStorageMode, type StorageMode } from '../db'
|
||||||
|
|
||||||
|
export function StorageConfig() {
|
||||||
|
const [currentMode, setCurrentMode] = useState<StorageMode>(getStorageMode())
|
||||||
|
const [showConfig, setShowConfig] = useState(false)
|
||||||
|
|
||||||
|
const handleModeChange = (mode: StorageMode) => {
|
||||||
|
setStorageMode(mode)
|
||||||
|
setCurrentMode(mode)
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showConfig) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfig(true)}
|
||||||
|
className="fixed bottom-4 right-4 bg-gray-200 hover:bg-gray-300 px-3 py-2 rounded text-sm"
|
||||||
|
>
|
||||||
|
⚙️ Storage
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 bg-white border rounded-lg shadow-lg p-4 min-w-64">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<h3 className="font-medium">Storage Mode</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfig(false)}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="storage-mode"
|
||||||
|
value="indexeddb"
|
||||||
|
checked={currentMode === 'indexeddb'}
|
||||||
|
onChange={() => handleModeChange('indexeddb')}
|
||||||
|
/>
|
||||||
|
<span>IndexedDB (Local)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="storage-mode"
|
||||||
|
value="database"
|
||||||
|
checked={currentMode === 'database'}
|
||||||
|
onChange={() => handleModeChange('database')}
|
||||||
|
/>
|
||||||
|
<span>Database (Collaborative)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-gray-600">
|
||||||
|
Current: {currentMode === 'indexeddb' ? 'Local storage' : 'Remote database'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/db.ts
118
src/db.ts
@ -10,7 +10,21 @@ type Space = {
|
|||||||
notes: Note[]
|
notes: Note[]
|
||||||
}
|
}
|
||||||
|
|
||||||
class Database {
|
type StorageMode = 'indexeddb' | 'database'
|
||||||
|
|
||||||
|
interface StorageAdapter {
|
||||||
|
init(): Promise<void>
|
||||||
|
getSpace(id: string): Promise<Space | undefined>
|
||||||
|
saveSpace(space: Space): Promise<void>
|
||||||
|
createSpace(id: string): Promise<Space>
|
||||||
|
addNoteToSpace(spaceId: string, note: Note): Promise<void>
|
||||||
|
updateNoteInSpace(spaceId: string, noteId: string, updates: Partial<Note>): Promise<void>
|
||||||
|
deleteNoteFromSpace(spaceId: string, noteId: string): Promise<void>
|
||||||
|
getAllSpaceIds(): Promise<string[]>
|
||||||
|
deleteSpace(id: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
class IndexedDBAdapter implements StorageAdapter {
|
||||||
private db: IDBDatabase | null = null
|
private db: IDBDatabase | null = null
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@ -121,4 +135,104 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = new Database()
|
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<void> {
|
||||||
|
// Database connection is handled per request
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSpace(id: string): Promise<Space | undefined> {
|
||||||
|
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<void> {
|
||||||
|
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<Space> {
|
||||||
|
const space: Space = { id, notes: [] }
|
||||||
|
await this.saveSpace(space)
|
||||||
|
return space
|
||||||
|
}
|
||||||
|
|
||||||
|
async addNoteToSpace(spaceId: string, note: Note): Promise<void> {
|
||||||
|
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<Note>): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]> {
|
||||||
|
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<void> {
|
||||||
|
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 }
|
||||||
Reference in New Issue
Block a user