diff --git a/backend/database.go b/backend/database.go index d351a55..5eb8df4 100644 --- a/backend/database.go +++ b/backend/database.go @@ -20,8 +20,14 @@ func InitDB() (*sql.DB, error) { password = "secret" } - // Build the DSN. The host "db" matches the service name in docker-compose.yml. - dsn := fmt.Sprintf("oworganizer:%s@tcp(db:3306)/?parseTime=true", password) + host := os.Getenv("DB_HOST") + if host == "" { + // Default to the Docker Compose service name. + host = "db" + } + + // Build the DSN. The default host "db" matches the service name in docker-compose.yml. + dsn := fmt.Sprintf("oworganizer:%s@tcp(%s:3306)/?parseTime=true", password, host) db, err := sql.Open("mysql", dsn) if err != nil { diff --git a/backend/go.mod b/backend/go.mod index 2c3df0f..62db692 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,6 +14,7 @@ require ( github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/cors v1.7.7 // indirect github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0df137d..af7f476 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= +github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= diff --git a/backend/main.go b/backend/main.go index 1b2910b..fd44c72 100644 --- a/backend/main.go +++ b/backend/main.go @@ -3,7 +3,10 @@ package main import ( "log" "net/http" + "os" + "strings" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) @@ -16,6 +19,20 @@ func main() { router := gin.Default() + // Configure CORS. In production, set CORS_ALLOWED_ORIGINS to your public domain. + allowedOrigins := []string{"http://localhost:5173"} + if envOrigins := os.Getenv("CORS_ALLOWED_ORIGINS"); envOrigins != "" { + allowedOrigins = strings.Split(envOrigins, ",") + } + router.Use(cors.New(cors.Config{ + AllowOrigins: allowedOrigins, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * 60 * 60, + })) + router.GET("/ping", func(c *gin.Context) { c.JSON(200, gin.H{ "message": "pong", diff --git a/docker-compose.yml b/docker-compose.yml index 8813b52..fc224a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,19 @@ services: + frontend: + build: ./frontend + container_name: ${SERVICE_NAME}-oworganizer-frontend + ports: + - "3000:80" + depends_on: + - backend + restart: unless-stopped + backend: build: ./backend container_name: ${SERVICE_NAME}-oworganizer-backend environment: DB_PASSWORD: ${DB_PASSWORD} # volumes: - ports: - - "8080:8080" restart: unless-stopped db: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..755acc8 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# Development API base URL. +# In production, the app uses the relative /api path and nginx proxies it to the backend. +VITE_API_BASE_URL=http://localhost:8080 + +# Vite dev server port. The dev script expects the frontend at http://localhost:5173. +# PORT=5173 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..aadcba1 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy dependency files first for better layer caching +COPY package*.json ./ +RUN npm ci + +# Copy source code and build the static SPA +COPY . . +RUN npm run build + +# Runtime stage +FROM nginx:alpine + +# Copy the built static files to nginx's default document root +COPY --from=builder /app/build /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose the port nginx listens on +EXPOSE 80 + +# Run nginx in the foreground +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..22ba95c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression for static assets + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|otf)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Proxy API requests to the backend container + location /api/ { + proxy_pass http://backend:8080/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Serve the SPA and fall back to index.html for client-side routes + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f31058..2d164e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "frontend", "version": "0.0.1", "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "svelte": "^5.56.1", @@ -434,10 +434,10 @@ "acorn": "^8.9.0" } }, - "node_modules/@sveltejs/adapter-auto": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", - "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz", + "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/frontend/package.json b/frontend/package.json index a1e5fc5..0701ef1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "prepare": "svelte-kit sync || echo ''" }, "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "svelte": "^5.56.1", diff --git a/frontend/src/lib/components/Status.svelte b/frontend/src/lib/components/Status.svelte new file mode 100644 index 0000000..e2b9bb6 --- /dev/null +++ b/frontend/src/lib/components/Status.svelte @@ -0,0 +1,5 @@ + + +
{message}
diff --git a/frontend/src/lib/models/note.js b/frontend/src/lib/models/note.js new file mode 100644 index 0000000..d8e7a65 --- /dev/null +++ b/frontend/src/lib/models/note.js @@ -0,0 +1,19 @@ +/** + * @typedef {Object} Note + * @property {string} objectId + * @property {'note'} type + * @property {string} text + * @property {string[]} childIds + */ + +/** + * Create a Note object. + * + * @param {string} objectId + * @param {string} text + * @param {string[]} [childIds=[]] + * @returns {Note} + */ +export function createNoteModel(objectId, text, childIds = []) { + return { objectId, type: 'note', text, childIds }; +} diff --git a/frontend/src/lib/models/object.js b/frontend/src/lib/models/object.js new file mode 100644 index 0000000..656e141 --- /dev/null +++ b/frontend/src/lib/models/object.js @@ -0,0 +1,18 @@ +/** + * @typedef {Object} ObjectRecord + * @property {string} objectId + * @property {string} userId + * @property {string} objectData + */ + +/** + * Create an ObjectRecord object. + * + * @param {string} objectId + * @param {string} userId + * @param {string} objectData + * @returns {ObjectRecord} + */ +export function createObjectModel(objectId, userId, objectData) { + return { objectId, userId, objectData }; +} diff --git a/frontend/src/lib/models/project.js b/frontend/src/lib/models/project.js new file mode 100644 index 0000000..9e8fe3e --- /dev/null +++ b/frontend/src/lib/models/project.js @@ -0,0 +1,19 @@ +/** + * @typedef {Object} Project + * @property {string} objectId + * @property {'project'} type + * @property {string} name + * @property {string[]} childIds + */ + +/** + * Create a Project object. + * + * @param {string} objectId + * @param {string} name + * @param {string[]} [childIds=[]] + * @returns {Project} + */ +export function createProjectModel(objectId, name, childIds = []) { + return { objectId, type: 'project', name, childIds }; +} diff --git a/frontend/src/lib/models/root.js b/frontend/src/lib/models/root.js new file mode 100644 index 0000000..6f0e710 --- /dev/null +++ b/frontend/src/lib/models/root.js @@ -0,0 +1,17 @@ +/** + * @typedef {Object} Root + * @property {string} objectId + * @property {'root'} type + * @property {string[]} childIds + */ + +/** + * Create a Root object. + * + * @param {string} objectId + * @param {string[]} [childIds=[]] + * @returns {Root} + */ +export function createRootModel(objectId, childIds = []) { + return { objectId, type: 'root', childIds }; +} diff --git a/frontend/src/lib/models/task.js b/frontend/src/lib/models/task.js new file mode 100644 index 0000000..79f1a3f --- /dev/null +++ b/frontend/src/lib/models/task.js @@ -0,0 +1,25 @@ +/** + * @typedef {'not-started' | 'in-progress' | 'completed' | 'cancelled'} TaskStatus + */ + +/** + * @typedef {Object} Task + * @property {string} objectId + * @property {'task'} type + * @property {string} text + * @property {TaskStatus} status + * @property {string[]} childIds + */ + +/** + * Create a Task object. + * + * @param {string} objectId + * @param {string} text + * @param {TaskStatus} [status='not-started'] + * @param {string[]} [childIds=[]] + * @returns {Task} + */ +export function createTaskModel(objectId, text, status = 'not-started', childIds = []) { + return { objectId, type: 'task', text, status, childIds }; +} diff --git a/frontend/src/lib/models/user.js b/frontend/src/lib/models/user.js new file mode 100644 index 0000000..d31f266 --- /dev/null +++ b/frontend/src/lib/models/user.js @@ -0,0 +1,16 @@ +/** + * @typedef {Object} User + * @property {string} userId + * @property {string} rootObjectId + */ + +/** + * Create a User object. + * + * @param {string} userId + * @param {string} rootObjectId + * @returns {User} + */ +export function createUserModel(userId, rootObjectId) { + return { userId, rootObjectId }; +} diff --git a/frontend/src/lib/services/api.js b/frontend/src/lib/services/api.js new file mode 100644 index 0000000..ee8fcdd --- /dev/null +++ b/frontend/src/lib/services/api.js @@ -0,0 +1,39 @@ +/** + * Base URL for API requests. + * In development, point to the Go backend directly (e.g., http://localhost:8080). + * In production, use the relative /api path so nginx proxies to the backend. + */ +const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; + +/** + * Wrapper around fetch for calling the backend API. + * + * @param {string} path - API path, e.g., `/users` or `/objects/123`. + * @param {RequestInit} [options={}] - fetch options. + * @returns {PromiseVisit svelte.dev/docs/kit to read the documentation
+ + +Enter your user ID to open the app.
+ + diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..1a49d26 --- /dev/null +++ b/frontend/src/routes/dashboard/+page.svelte @@ -0,0 +1,49 @@ + + +{#if !userId} +You are not logged in. Go home
+{:else} +Welcome, {userId}.
+ +Error: {error}
+ {:else if objects.length === 0} +No objects found.
+ {:else} +