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 {Promise} Parsed JSON response. + * @throws {Error} On non-OK responses. + */ +export async function apiFetch(path, options = {}) { + const url = `${API_BASE}${path}`; + + const config = { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }; + + if (config.body && typeof config.body === 'object' && !(config.body instanceof FormData)) { + config.body = JSON.stringify(config.body); + } + + const response = await fetch(url, config); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `HTTP ${response.status}`); + } + + return response.json(); +} diff --git a/frontend/src/lib/services/objects.js b/frontend/src/lib/services/objects.js new file mode 100644 index 0000000..7e3d0fe --- /dev/null +++ b/frontend/src/lib/services/objects.js @@ -0,0 +1,69 @@ +import { apiFetch } from './api.js'; + +/** + * @typedef {Object} ObjectRecord + * @property {string} objectId + * @property {string} userId + * @property {string} objectData + */ + +/** + * Create a new object. + * + * @param {string} objectId + * @param {string} userId + * @param {string} objectData + * @returns {Promise<{status: string}>} + */ +export function createObject(objectId, userId, objectData) { + return apiFetch('/objects', { + method: 'POST', + body: { objectId, userId, objectData } + }); +} + +/** + * Fetch an object by ID. + * + * @param {string} objectId + * @returns {Promise} + */ +export function getObject(objectId) { + return apiFetch(`/objects/${objectId}`); +} + +/** + * Fetch all objects belonging to a user. + * + * @param {string} userId + * @returns {Promise} + */ +export function getObjectsByUser(userId) { + return apiFetch(`/users/${userId}/objects`); +} + +/** + * Update an object's data. + * + * @param {string} objectId + * @param {string} objectData + * @returns {Promise<{status: string}>} + */ +export function updateObject(objectId, objectData) { + return apiFetch(`/objects/${objectId}`, { + method: 'PUT', + body: { objectData } + }); +} + +/** + * Delete an object. + * + * @param {string} objectId + * @returns {Promise<{status: string}>} + */ +export function deleteObject(objectId) { + return apiFetch(`/objects/${objectId}`, { + method: 'DELETE' + }); +} diff --git a/frontend/src/lib/services/users.js b/frontend/src/lib/services/users.js new file mode 100644 index 0000000..41f639f --- /dev/null +++ b/frontend/src/lib/services/users.js @@ -0,0 +1,57 @@ +import { apiFetch } from './api.js'; + +/** + * @typedef {Object} User + * @property {string} userId + * @property {string} rootObjectId + */ + +/** + * Create a new user. + * + * @param {string} userId + * @param {string} rootObjectId + * @returns {Promise<{status: string}>} + */ +export function createUser(userId, rootObjectId) { + return apiFetch('/users', { + method: 'POST', + body: { userId, rootObjectId } + }); +} + +/** + * Fetch a user by ID. + * + * @param {string} userId + * @returns {Promise} + */ +export function getUser(userId) { + return apiFetch(`/users/${userId}`); +} + +/** + * Update a user's root object. + * + * @param {string} userId + * @param {string} rootObjectId + * @returns {Promise<{status: string}>} + */ +export function updateUser(userId, rootObjectId) { + return apiFetch(`/users/${userId}`, { + method: 'PUT', + body: { rootObjectId } + }); +} + +/** + * Delete a user. + * + * @param {string} userId + * @returns {Promise<{status: string}>} + */ +export function deleteUser(userId) { + return apiFetch(`/users/${userId}`, { + method: 'DELETE' + }); +} diff --git a/frontend/src/lib/stores/auth.js b/frontend/src/lib/stores/auth.js new file mode 100644 index 0000000..f8808e1 --- /dev/null +++ b/frontend/src/lib/stores/auth.js @@ -0,0 +1,16 @@ +import { writable } from 'svelte/store'; + +/** + * Simple auth store. Replace with real authentication logic as needed. + */ +function createAuthStore() { + const { subscribe, set } = writable(null); + + return { + subscribe, + login: (/** @type {string} */ userId) => set(userId), + logout: () => set(null) + }; +} + +export const auth = createAuthStore(); diff --git a/frontend/src/routes/+layout.js b/frontend/src/routes/+layout.js new file mode 100644 index 0000000..83addb7 --- /dev/null +++ b/frontend/src/routes/+layout.js @@ -0,0 +1,2 @@ +export const ssr = false; +export const prerender = false; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index cc88df0..25ebcc0 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,2 +1,25 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + +

OwOrganizer

+ +

Enter your user ID to open the app.

+ +
{ e.preventDefault(); enterApp(); }}> + + +
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} +

Dashboard

+

Welcome, {userId}.

+ +

Your Objects

+ {#if loading} + + {:else if error} +

Error: {error}

+ {:else if objects.length === 0} +

No objects found.

+ {:else} +
    + {#each objects as obj (obj.objectId)} +
  • + {obj.objectId}: {obj.objectData} +
  • + {/each} +
+ {/if} +{/if} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..a047afd --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,25 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + kit: { + adapter: adapter({ + // Output static files to the build directory. + // This is the default, but we make it explicit. + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: true + }) + } +}; + +export default config; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index cb76b81..bbf8c7d 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,20 +1,6 @@ -import adapter from '@sveltejs/adapter-auto'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [ - sveltekit({ - compilerOptions: { - // Force runes mode for the project, except for libraries. Can be removed in svelte 6. - runes: ({ filename }) => - filename.split(/[/\\]/).includes('node_modules') ? undefined : true - }, - - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() - }) - ] + plugins: [sveltekit()] }); diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..9b340b9 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Dev startup script for OwOrganizer. +# Runs the database in Docker and the backend/frontend natively. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +DB_CONTAINER_NAME="oworganizer-dev-db" +DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-top-secret}" +DB_PASSWORD="${DB_PASSWORD:-secret}" +DB_USER="oworganizer" +DB_NAME="oworganizer" +DB_PORT="${DB_PORT:-3306}" + +# Colors for output +RESET='\033[0m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' + +log() { + echo -e "${GREEN}[dev]${RESET} $1" +} + +warn() { + echo -e "${YELLOW}[dev]${RESET} $1" +} + +cleanup() { + echo + warn "Shutting down dev environment..." + + if [[ -n "${BACKEND_PID:-}" ]] && kill -0 "$BACKEND_PID" 2>/dev/null; then + log "Stopping backend (PID $BACKEND_PID)..." + kill "$BACKEND_PID" 2>/dev/null || true + wait "$BACKEND_PID" 2>/dev/null || true + fi + + if [[ -n "${FRONTEND_PID:-}" ]] && kill -0 "$FRONTEND_PID" 2>/dev/null; then + log "Stopping frontend (PID $FRONTEND_PID)..." + kill "$FRONTEND_PID" 2>/dev/null || true + wait "$FRONTEND_PID" 2>/dev/null || true + fi + + if [[ "${STOP_DB_ON_EXIT:-true}" == "true" ]]; then + log "Stopping database container..." + docker stop "$DB_CONTAINER_NAME" >/dev/null 2>&1 || true + else + warn "Leaving database container '$DB_CONTAINER_NAME' running." + fi + + log "Dev environment stopped." +} + +trap cleanup EXIT INT TERM + +# Ensure required tools are available +command -v docker >/dev/null 2>&1 || { echo -e "${RED}docker is required but not installed.${RESET}"; exit 1; } +command -v go >/dev/null 2>&1 || { echo -e "${RED}go is required but not installed.${RESET}"; exit 1; } +command -v npm >/dev/null 2>&1 || { echo -e "${RED}npm is required but not installed.${RESET}"; exit 1; } + +# Start the database container if it's not already running +if docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER_NAME}$"; then + warn "Database container '$DB_CONTAINER_NAME' is already running. Reusing it." +elif docker ps -a --format '{{.Names}}' | grep -q "^${DB_CONTAINER_NAME}$"; then + log "Starting existing database container '$DB_CONTAINER_NAME'..." + docker start "$DB_CONTAINER_NAME" >/dev/null +else + log "Creating and starting database container '$DB_CONTAINER_NAME'..." + docker run -d \ + --name "$DB_CONTAINER_NAME" \ + -e MYSQL_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \ + -e MYSQL_USER="$DB_USER" \ + -e MYSQL_PASSWORD="$DB_PASSWORD" \ + -e MYSQL_DATABASE="$DB_NAME" \ + -p "${DB_PORT}:3306" \ + mariadb:10 \ + --character-set-server=utf8mb4 \ + --collation-server=utf8mb4_unicode_ci \ + >/dev/null +fi + +# Wait for MariaDB to be ready +log "Waiting for database to be ready..." +for i in {1..60}; do + if docker exec "$DB_CONTAINER_NAME" mysqladmin ping \ + -h localhost -u "$DB_USER" --password="$DB_PASSWORD" \ + >/dev/null 2>&1; then + # mysqladmin ping can return true before the server accepts SQL connections, + # so wait a little longer for the server to finish initializing. + sleep 2 + log "Database is ready." + break + fi + if [[ "$i" -eq 60 ]]; then + echo -e "${RED}Database did not become ready in time.${RESET}" + exit 1 + fi + sleep 1 +done + +# Start the backend (with retries in case the DB isn't fully accepting connections yet) +log "Starting backend..." +cd "$PROJECT_DIR/backend" + +BACKEND_START_ATTEMPTS="${BACKEND_START_ATTEMPTS:-3}" +for attempt in $(seq 1 "$BACKEND_START_ATTEMPTS"); do + DB_HOST=localhost DB_PASSWORD="$DB_PASSWORD" go run . & + BACKEND_PID=$! + + # Wait for backend to be ready + log "Waiting for backend to be ready (attempt $attempt/$BACKEND_START_ATTEMPTS)..." + ready=false + for i in {1..30}; do + if curl -s http://localhost:8080/ping >/dev/null 2>&1; then + log "Backend is ready at http://localhost:8080" + ready=true + break + fi + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then + warn "Backend exited early, possibly waiting for DB." + break + fi + sleep 1 + done + + if [[ "$ready" == "true" ]]; then + break + fi + + if [[ "$attempt" -eq "$BACKEND_START_ATTEMPTS" ]]; then + echo -e "${RED}Backend did not become ready after $BACKEND_START_ATTEMPTS attempts.${RESET}" + exit 1 + fi + + warn "Retrying backend startup..." + sleep 2 +done + +# Start the frontend +log "Starting frontend..." +cd "$PROJECT_DIR/frontend" +if [[ ! -f .env ]]; then + warn "No .env file found in frontend/. Creating from .env.example..." + cp .env.example .env +fi +npm run dev & +FRONTEND_PID=$! + +log "Frontend starting. It will be available at http://localhost:5173" +log "${BLUE}Press Ctrl-C to stop all services.${RESET}" + +# Wait for either process to exit +wait -n "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null || true