diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..02e1547f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,68 @@ +# Dependencies +node_modules +**/node_modules +packages/*/node_modules +.pnpm-store + +# Build outputs +dist +**/dist +packages/*/dist +build +**/build + +# Development files +.env +.env.local +.env.*.local +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Git +.git +.gitignore +.gitattributes + +# IDE +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Testing +coverage +.nyc_output +**/*.test.ts +**/*.test.js +**/*.spec.ts +**/*.spec.js +test +**/test + +# Documentation +README.md +*.md +docs + +# CI/CD +.github +.gitlab-ci.yml +.travis.yml + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# Misc +.husky +.prettierrc +.prettierignore +.eslintrc* +.eslintignore +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/README.md b/README.md index 7876f210..91ac21c5 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,107 @@ -# Docker Desktop Download +## Prerequisites -[Docker Desktop Download](https://www.docker.com/products/docker-desktop/) +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running +- pnpm 8.9.0+ (included via corepack in Docker) +- MongoDB Compass Application -## To Run the Program +## Quick Installation Workflow: -### Install Node Modules +- Install Docker Desktop, make an account, and run +- Install MongoDB Compass, make an account, and add cluster connection string (from .env file) +- Clone Repository locally +- Run in terminal: +- `pnpm install` +- `pnpm dev:build` +- `pnpm dev` +- `pnpm log` (to perform logging of application while running) +- Open the application frontend at http://localhost:5173 +- Create an account/log in + +## Setup + +**Install dependencies (optional for local development):** ```bash -pnpm install + pnpm install ``` -### Boot the Infrastructure (Redis Server) +## Development + +Start all services in development mode with hot-reload: ```bash -pnpm run dev:infra +pnpm dev ``` -### Run the Program +**Services and Ports:** + +- Frontend: http://localhost:5173 +- API: http://localhost:4000 +- Redis: localhost:6379 + +**View logs:** ```bash -pnpm run dev +pnpm logs # All services +pnpm logs:api # API only +pnpm logs:frontend # Frontend only ``` -### Cleanup Docker Infrastructure +## Production + +Test the production build locally: ```bash -pnpm run dev:infra:stop +pnpm prod:build ``` -### To see if you have a Docker container currently running +**Check health:** ```bash -docker ps +pnpm health ``` + +## Useful Commands + +```bash +pnpm down # Stop all services +pnpm clean # Remove containers, volumes, and cleanup +pnpm redis:cli # Access Redis CLI +pnpm shell:api # Shell into API container +pnpm shell:frontend # Shell into frontend container +``` + +## Code Quality + +```bash +pnpm lint # Check for linting errors +pnpm format # Format code and fix linting issues +``` + +## Project Structure + +``` +seitz/ +├── packages/ +│ ├── api/ # Express backend +│ ├── ui/ # Vue.js frontend +│ └── shared/ # Shared types/utilities +├── docker-compose.yml # Production configuration +├── docker-compose.dev.yml # Development configuration +└── package.json # Root package scripts +``` + +## Troubleshooting + +**Containers won't start:** + +```bash +pnpm clean +pnpm dev:build +``` + +**Port conflicts:** +Make sure ports 4000, 5173, and 6379 are not in use by other applications. + +**Environment variables not loading:** +Ensure `.env` file exists in the root directory with all required variables. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..78ddeb24 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,74 @@ +services: + api: + build: + context: . + dockerfile: ./packages/api/Dockerfile + target: development + environment: + - NODE_ENV=development + - DEBUG=* + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_URL=redis://redis:6379 + - MONGO_URL=${MONGO_URL} + - SENDGRID_API_KEY=${SENDGRID_API_KEY} + - JWT_SECRET=${JWT_SECRET} + - SESSION_SECRET=${SESSION_SECRET} + volumes: + - ./packages/api/src:/app/packages/api/src + - /app/packages/api/node_modules + - ./packages/api/tsconfig.json:/app/packages/api/tsconfig.json + - ./packages/api/package.json:/app/packages/api/package.json + ports: + - "4000:4000" + - "9229:9229" + depends_on: + redis: + condition: service_healthy + networks: + - seitz-network + frontend: + build: + context: . + dockerfile: ./packages/ui/Dockerfile + target: development + environment: + - NODE_ENV=development + - VITE_API_URL=http://localhost:4000 + volumes: + - ./packages/ui/src:/app/packages/ui/src + - ./packages/ui/public:/app/packages/ui/public + - ./packages/ui/index.html:/app/packages/ui/index.html + - ./packages/ui/vite.config.ts:/app/packages/ui/vite.config.ts + - ./packages/ui/tailwind.config.js:/app/packages/ui/tailwind.config.js + - ./packages/ui/postcss.config.js:/app/packages/ui/postcss.config.js + - ./packages/ui/tsconfig.json:/app/packages/ui/tsconfig.json + - ./packages/ui/package.json:/app/packages/ui/package.json + - /app/packages/ui/node_modules + ports: + - "5173:5173" + depends_on: + - api + networks: + - seitz-network + redis: + image: redis:7-alpine + container_name: seitz-redis-dev + ports: + - "6379:6379" + volumes: + - redis_data_dev:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + networks: + - seitz-network +volumes: + redis_data_dev: +networks: + seitz-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index fe30a4a3..96692e20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,95 @@ +# This file is generally speaking a placeholder for once the project is deployed on the cloud +# The productin containers and images should be placed on the cloud using this configuration, with +# the locally hosted URLS replaced with the registered ones, as well as added configurations for +# cookies and authentication (locally, we work with http and not https) services: redis: image: redis:7-alpine + container_name: seitz-redis ports: - "6379:6379" volumes: - redis_data:/data - command: redis-server --appendonly yes + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + networks: + - seitz-network + + api: + build: + context: . + dockerfile: ./packages/api/Dockerfile + target: production + container_name: seitz-api + restart: unless-stopped + ports: + - "4000:4000" + depends_on: + redis: + condition: service_healthy + environment: + - NODE_ENV=production + - PORT=4000 + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_URL=redis://redis:6379 + - MONGO_URL=${MONGO_URL} + - SENDGRID_API_KEY=${SENDGRID_API_KEY} + - JWT_SECRET=${JWT_SECRET} + - SESSION_SECRET=${SESSION_SECRET} + networks: + - seitz-network + healthcheck: + test: + [ + "CMD-SHELL", + 'node -e "require(''http'').get(''http://localhost:4000/health/ready'', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); });"', + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + frontend: + build: + context: . + dockerfile: ./packages/ui/Dockerfile + target: production + args: + - API_URL=${API_URL:-http://localhost:4000} + container_name: seitz-frontend + restart: unless-stopped + ports: + - "5173:5173" + depends_on: + api: + condition: service_healthy + networks: + - seitz-network + healthcheck: + test: [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:5173", # needs to be updated to DN once registered + ] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s volumes: redis_data: + driver: local + +networks: + seitz-network: + driver: bridge diff --git a/package.json b/package.json index cd97897e..f51e08b1 100644 --- a/package.json +++ b/package.json @@ -23,14 +23,25 @@ "prettier:fix": "prettier . --write", "format": "pnpm prettier:fix && pnpm lint:fix", "prepare": "husky install", - "dev": "pnpm --stream -r dev", - "dev:infra": "docker-compose up -d redis", - "dev:infra:stop": "docker-compose down redis" + "prod": "docker compose up -d", + "prod:build": "docker compose up --build -d", + "dev": "docker compose -f docker-compose.dev.yml up -d", + "dev:build": "docker compose -f docker-compose.dev.yml up --build -d", + "down": "docker compose down", + "logs": "docker compose logs -f", + "logs:api": "docker compose logs -f api", + "logs:frontend": "docker compose logs -f frontend", + "clean": "docker compose down -v && docker system prune -f", + "restart": "docker compose restart", + "health": "curl -s http://localhost:4000/health | jq '.' && curl -s http://localhost:5173 > /dev/null && echo 'Frontend is healthy' || echo 'Health check failed'", + "redis:cli": "docker compose exec redis redis-cli", + "shell:api": "docker compose exec api sh", + "shell:frontend": "docker compose exec frontend sh", + "shell:redis": "docker compose exec redis sh" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", "@sendgrid/mail": "^8.1.1", - "@types/redis": "^4.0.10", "mongoose": "^7.5.2", "prettier": "3.0.3", "redis": "^5.8.2", diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile new file mode 100644 index 00000000..4df753ba --- /dev/null +++ b/packages/api/Dockerfile @@ -0,0 +1,43 @@ +FROM node:20-alpine AS development +WORKDIR /app +ENV HUSKY=0 +RUN apk add --no-cache python3 make g++ +RUN corepack enable && corepack prepare pnpm@latest --activate +COPY package.json* pnpm-lock.yaml* pnpm-workspace.yaml* ./ +COPY packages/api/package.json ./packages/api/ +COPY packages/shared/package.json ./packages/shared/ +RUN pnpm install --frozen-lockfile +COPY packages/shared ./packages/shared +COPY packages/api ./packages/api +RUN pnpm --filter @seitz/shared build +EXPOSE 4000 +CMD ["pnpm", "--filter", "@seitz/api", "dev"] +FROM node:20-alpine AS builder +WORKDIR /app +ENV HUSKY=0 +RUN apk add --no-cache python3 make g++ +RUN corepack enable && corepack prepare pnpm@latest --activate +COPY package.json* pnpm-lock.yaml* pnpm-workspace.yaml* ./ +COPY packages/api/package.json ./packages/api/ +COPY packages/shared/package.json ./packages/shared/ +RUN pnpm install +COPY packages/shared ./packages/shared +COPY packages/api ./packages/api +RUN pnpm --filter @seitz/shared build +RUN pnpm --filter @seitz/api build +WORKDIR /app +RUN pnpm deploy --filter=api --prod /prod/api +FROM node:20-alpine AS production +WORKDIR /app +RUN apk add --no-cache dumb-init python3 make g++ && \ + addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 +COPY --chown=nodejs:nodejs --from=builder /prod/api ./ +COPY --chown=nodejs:nodejs --from=builder /app/packages/shared/dist ./node_modules/@seitz/shared/dist +COPY --chown=nodejs:nodejs --from=builder /app/packages/shared/package.json ./node_modules/@seitz/shared/package.json +USER nodejs +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD pgrep node || exit 1 +EXPOSE 4000 +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "dist/index.js"] \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index ec0a0fbd..33a25c93 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "nodemon ./src/index.ts", "start": "tsc && node dist/index.js", - "test": "jest --runInBand --watchAll" + "test": "jest --runInBand --watchAll", + "build": "tsc" }, "dependencies": { "@seitz/shared": "workspace:*", @@ -17,7 +18,8 @@ "express": "^4.18.2", "express-session": "^1.17.3", "passport": "^0.6.0", - "passport-local": "^1.0.0" + "passport-local": "^1.0.0", + "redis": "^5.8.2" }, "devDependencies": { "@types/bcrypt": "^5.0.1", @@ -34,6 +36,7 @@ "nodemon": "^3.0.1", "supertest": "^6.3.3", "ts-jest": "^29.1.1", - "ts-node": "^10.9.1" + "ts-node": "^10.9.1", + "tsc-alias": "^1.8.16" } } diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 6d0a72ce..8c6abe65 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -8,6 +8,7 @@ import { adminRoutes, authRoutes, exampleRoutes, + healthRoutes, studiesRoutes, tasksRoutes, } from "./routes"; @@ -19,7 +20,7 @@ const app = express(); app.use(bodyParser.json()); app.use( cors({ - origin: [process.env.CLIENT_URL ?? "http://localhost:5173"], + origin: process.env.CLIENT_URL ?? "http://localhost:5173", credentials: true, }) ); @@ -42,6 +43,7 @@ app.use(passport.initialize()); app.use(passport.session()); // Routes +app.use("/", healthRoutes); app.use("/admin", adminRoutes); app.use("/example/", exampleRoutes); app.use("/studies/", studiesRoutes); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a5fcb415..1f639e14 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,11 +1,11 @@ import mongoose from "mongoose"; import dotenv from "dotenv"; - import app from "./app"; +import redisClient from "./redis"; dotenv.config(); -import "./redis"; +app.locals.redisClient = redisClient; const port = process.env.PORT || 4000; diff --git a/packages/api/src/routes/health.route.ts b/packages/api/src/routes/health.route.ts new file mode 100644 index 00000000..cd83647c --- /dev/null +++ b/packages/api/src/routes/health.route.ts @@ -0,0 +1,75 @@ +import { Router, Request, Response } from "express"; +import mongoose from "mongoose"; + +const router = Router(); + +router.get("/health", async (req: Request, res: Response) => { + const healthcheck = { + uptime: process.uptime(), + message: "OK", + timestamp: Date.now(), + environment: process.env.NODE_ENV, + checks: { + api: "healthy" as string, + database: "unknown" as string, + redis: "unknown" as string, + }, + }; + + try { + if (mongoose.connection.readyState === 1) { + healthcheck.checks.database = "healthy"; + } else { + healthcheck.checks.database = "unhealthy"; + } + + const redisClient = req.app.locals.redisClient; + if (redisClient?.isReady) { + await redisClient.ping(); + healthcheck.checks.redis = "healthy"; + } else { + healthcheck.checks.redis = "unhealthy"; + } + } catch (error) { + console.error("Health check error:", error); + healthcheck.checks.api = "unhealthy"; + if (error instanceof Error) { + healthcheck.message = error.message; + } + } + + const allHealthy = Object.values(healthcheck.checks).every( + (status) => status === "healthy" + ); + + res.status(allHealthy ? 200 : 503).json(healthcheck); +}); + +router.get("/health/live", (req: Request, res: Response) => { + res.status(200).json({ status: "alive" }); +}); + +router.get("/health/ready", async (req: Request, res: Response) => { + try { + const mongoReady = mongoose.connection.readyState === 1; + const redisClient = req.app.locals.redisClient; + const redisReady = redisClient?.isReady; + + if (mongoReady && redisReady) { + res.status(200).json({ status: "ready" }); + } else { + res.status(503).json({ + status: "not ready", + mongo: mongoReady, + redis: redisReady, + }); + } + } catch (error) { + res.status(503).json({ + status: "not ready", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +export default router; diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index f3b83ed5..b28ec551 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -1,5 +1,6 @@ export { default as adminRoutes } from "./admin.route"; export { default as authRoutes } from "./auth.route"; export { default as exampleRoutes } from "./example.route"; +export { default as healthRoutes } from "./health.route"; export { default as studiesRoutes } from "./studies.route"; export { default as tasksRoutes } from "./tasks.route"; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index caada9e2..1cbf8067 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -7,10 +7,13 @@ "strict": true, "skipLibCheck": true, "outDir": "dist", + "rootDir": "src", "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@seitz/shared": ["../shared/src"], + "@seitz/shared/*": ["../shared/src/*"] } }, - "include": ["src", "test"] + "include": ["src"] } diff --git a/packages/shared/package.json b/packages/shared/package.json index 275ff69b..676b8a21 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,4 +1,27 @@ { "name": "@seitz/shared", - "private": true + "private": true, + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.0.0", + "@types/mongoose": "^5.11.97" + }, + "peerDependencies": { + "mongoose": "^8.0.0" + } } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..847e1ee4 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "types", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["types/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/shared/types/index.ts b/packages/shared/types/index.ts index e5f658b7..ef908713 100644 --- a/packages/shared/types/index.ts +++ b/packages/shared/types/index.ts @@ -1,3 +1,3 @@ -export * from "./api"; -export * from "./models"; +export * from "./api/"; +export * from "./models/"; export * from "./util"; diff --git a/packages/ui/Dockerfile b/packages/ui/Dockerfile new file mode 100644 index 00000000..142a1731 --- /dev/null +++ b/packages/ui/Dockerfile @@ -0,0 +1,68 @@ +FROM node:20-alpine AS development + +WORKDIR /app + +ENV HUSKY=0 + +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY package.json* pnpm-lock.yaml* pnpm-workspace.yaml* ./ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/shared/package.json ./packages/shared/ + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --ignore-scripts + +COPY packages/shared ./packages/shared +COPY packages/ui ./packages/ui + +EXPOSE 5173 + +CMD ["pnpm", "--filter", "ui", "dev", "--host", "0.0.0.0"] + +FROM node:20-alpine AS builder + +WORKDIR /app + +ENV HUSKY=0 + +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY package.json* pnpm-lock.yaml* pnpm-workspace.yaml* ./ +COPY packages/ui/package.json ./packages/ui/ +COPY packages/shared/package.json ./packages/shared/ + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ + pnpm install --ignore-scripts + +COPY packages/shared ./packages/shared +COPY packages/ui ./packages/ui + +ARG API_URL=http://localhost:4000 +ENV VITE_API_URL=$API_URL + +WORKDIR /app/packages/shared +RUN pnpm build + +WORKDIR /app/packages/ui +RUN pnpm build + +# Placeholder production. Should be updated to include the registered URL once the project is +# deployed on the cloud. +FROM nginxinc/nginx-unprivileged:1.27-alpine AS production + +USER root +RUN apk upgrade --no-cache && \ + apk add --no-cache curl + +USER nginx + +COPY packages/ui/nginx.conf /etc/nginx/nginx.conf +COPY --from=builder --chown=nginx:nginx /app/packages/ui/dist /usr/share/nginx/html + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:5173 || exit 1 + +EXPOSE 5173 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/packages/ui/nginx.conf b/packages/ui/nginx.conf new file mode 100644 index 00000000..e1a7699a --- /dev/null +++ b/packages/ui/nginx.conf @@ -0,0 +1,108 @@ +worker_processes auto; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; + multi_accept on; + use epoll; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + charset utf-8; + + access_log off; + error_log /dev/stderr warn; + + sendfile on; + tcp_nodelay on; + tcp_nopush on; + keepalive_timeout 65; + keepalive_requests 1000; + reset_timedout_connection on; + client_body_timeout 10; + send_timeout 10; + + gzip on; + gzip_comp_level 6; + gzip_proxied any; + gzip_min_length 256; + gzip_vary on; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/rss+xml + application/atom+xml + image/svg+xml + font/opentype + font/truetype + font/eot + font/woff + font/woff2 + application/vnd.ms-fontobject + application/x-font-ttf + application/x-web-app-manifest+json + application/xhtml+xml; + + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + server { + listen 5173; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # API proxy + location /api { + proxy_pass http://api:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass $http_upgrade; + proxy_buffering off; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Vue.js SPA routing + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # Static assets + location ~* \.(?:ico|css|js|gif|jpe?g|png|svg|woff2?|eot|ttf|otf|webp|avif|map)$ { + expires 1y; + access_log off; + add_header Cache-Control "public, immutable"; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + error_page 404 /index.html; + } +} \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index 08480e6a..1adde162 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,15 +5,16 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc && vite build", + "build-check": "vue-tsc && vite build", + "build": "vite build", "preview": "vite preview" }, "dependencies": { - "@seitz/shared": "workspace:*", "@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/vue-fontawesome": "^3.0.5", + "@seitz/shared": "workspace:*", "@tanstack/vue-query": "^4.36.2", "@vueuse/core": "^10.5.0", "axios": "^1.5.1", @@ -26,9 +27,9 @@ "devDependencies": { "@types/node": "^20.6.2", "@vitejs/plugin-vue": "^4.2.3", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.31", - "tailwindcss": "^3.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", "vite": "^4.4.5", "vue-tsc": "^1.8.5" } diff --git a/packages/ui/postcss.config.cjs b/packages/ui/postcss.config.js similarity index 77% rename from packages/ui/postcss.config.cjs rename to packages/ui/postcss.config.js index 12a703d9..2aa7205d 100644 --- a/packages/ui/postcss.config.cjs +++ b/packages/ui/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { plugins: { tailwindcss: {}, autoprefixer: {}, diff --git a/packages/ui/src/pages/MyStudiesPage/components/StudyDetailsSideBar.vue b/packages/ui/src/pages/MyStudiesPage/components/StudyDetailsSideBar.vue index 25a1920c..56aca85a 100644 --- a/packages/ui/src/pages/MyStudiesPage/components/StudyDetailsSideBar.vue +++ b/packages/ui/src/pages/MyStudiesPage/components/StudyDetailsSideBar.vue @@ -2,7 +2,33 @@ import { ref, watch, onMounted, onUnmounted } from "vue"; import AppButton from "@/components/ui/AppButton.vue"; import studiesAPI from "@/api/studies"; -import type { GETStudy } from "@seitz/shared"; + +interface Task { + battery?: { name?: string }; + name?: string; +} + +interface TaskInstance { + _id?: string | unknown; + task?: Task; +} + +interface Session { + _id?: string | unknown; + tasks?: TaskInstance[]; +} + +interface Variant { + _id?: string | unknown; + sessions?: Session[]; +} + +interface Study { + _id?: string; + name?: string; + description?: string; + variants?: Variant[]; +} const props = defineProps<{ studyId: string | null; @@ -11,7 +37,7 @@ const props = defineProps<{ const emit = defineEmits(["close"]); -const study = ref(null); +const study = ref(null); const isLoading = ref(false); const error = ref(null); const expandedVariants = ref>(new Set([0])); @@ -25,7 +51,7 @@ const fetchStudy = async () => { error.value = null; try { const data = await studiesAPI.getStudyPreview(props.studyId); - study.value = data as unknown as GETStudy; + study.value = data as unknown as Study; } catch (err) { if (err instanceof Error) { error.value = err.message; @@ -88,19 +114,37 @@ const toggleSession = (variantIndex: number, sessionIndex: number) => { expandedSessions.value = newSet; }; -const getTaskName = (taskInstance: unknown) => { - const task = taskInstance as { - task?: { battery?: { name?: string }; name?: string }; - }; - if (task.task?.battery?.name) { - return task.task.battery.name; +const getTaskName = (taskInstance: TaskInstance): string => { + if (taskInstance.task?.battery?.name) { + return taskInstance.task.battery.name; } - if (task.task?.name) { - return task.task.name; + if (taskInstance.task?.name) { + return taskInstance.task.name; } return "Session Element"; }; +const getVariantKey = (variant: Variant, index: number): string => { + if (variant._id) { + return String(variant._id); + } + return `variant-${index}`; +}; + +const getSessionKey = (session: Session, index: number): string => { + if (session._id) { + return String(session._id); + } + return `session-${index}`; +}; + +const getTaskKey = (taskInstance: TaskInstance, index: number): string => { + if (taskInstance._id) { + return String(taskInstance._id); + } + return `task-${index}`; +}; + const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape" && props.show) { emit("close"); @@ -156,7 +200,7 @@ onUnmounted(() => {