diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh new file mode 100644 index 0000000..c270d2b --- /dev/null +++ b/.docker/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +envsubst \ + '${ANNOTATIONS_FETCH_API_URL} \ + ${MEDIA_ASSET_FETCH_API_URL} \ + ${DEMO_MEDIA_URL} \ + ${BASE_PATH} \ + ${APP_MODE}' \ + < /etc/nginx/runtime-env.template.js \ + > /var/www/runtime-env.js + +exec "$@" diff --git a/.docker/nginx.conf b/.docker/nginx.conf new file mode 100644 index 0000000..c554210 --- /dev/null +++ b/.docker/nginx.conf @@ -0,0 +1,22 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + server { + listen 80; + server_name _; + + root /var/www; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/.docker/runtime-env.template.js b/.docker/runtime-env.template.js new file mode 100644 index 0000000..bb9d742 --- /dev/null +++ b/.docker/runtime-env.template.js @@ -0,0 +1,7 @@ +window.__RUNTIME_CONFIG__ = { + ANNOTATIONS_FETCH_API_URL: '${ANNOTATIONS_FETCH_API_URL}', + MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL}', + DEMO_MEDIA_URL: '${DEMO_MEDIA_URL}', + BASE_PATH: '${BASE_PATH}', + APP_MODE: '${APP_MODE}', +}; diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8118ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.gitignore +Dockerfile +.env* +npm-debug.log \ No newline at end of file diff --git a/.env.example b/.env.example index 79902f9..7a966b6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ -VITE_ANNOTATIONS_FETCH_API_URL=https://api.example.com/annotations -VITE_MEDIA_ASSET_FETCH_API_URL=https://api.example.com/media-assets -VITE_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4 \ No newline at end of file +ANNOTATOR_ANNOTATIONS_FETCH_API_URL=https://api.example.com/annotations +ANNOTATOR_MEDIA_ASSET_FETCH_API_URL=https://api.example.com/media-assets +ANNOTATOR_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4 +ANNOTATOR_BASE_PATH=/annotator \ No newline at end of file diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..cbd8691 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,48 @@ +name: Build & Push Frontend + +on: + push: + branches: + - main + - dev/release-test + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USER }}/media-asset-annotator + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev,enable=${{ startsWith(github.ref, 'refs/heads/dev') }} + type=ref,event=tag + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a8bb0e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +######################################## +# 1. Dependencies +######################################## +FROM node:20-alpine AS deps + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + + +######################################## +# 2. Build +######################################## +FROM node:20-alpine AS build + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + + +######################################## +# 3. Runtime (nginx) +######################################## +FROM nginx:1.25-alpine + +# Copy build output +COPY --from=build /app/dist /var/www + +# Copy custom JS config +COPY .docker/runtime-env.template.js /etc/nginx/runtime-env.template.js + +# Copy entrypoint +COPY .docker/entrypoint.sh /entrypoint.sh + +# Copy nginx configuration +COPY .docker/nginx.conf /etc/nginx/nginx.conf + +RUN chmod +x /entrypoint.sh + +EXPOSE 80 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 7a0e4d7..613139d 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,13 @@ npm run dev Access the application using one of the following URLs: -- http://localhost:5173/ (default demo) -- http://localhost:5173/annotator?id= -- http://localhost:5173/annotator?url= +- http://localhost:5173 (default demo) +- http://localhost:5173/asset?id= +- http://localhost:5173/asset?url= + +### Run Production Server + +In order to run the application in production mode refer to the [deployment instructions](deploy/README.md). ### Build for Production @@ -96,6 +100,6 @@ npm run preview Access the application using one of the following URLs: -- http://localhost:4173/ (default demo) -- http://localhost:4173/annotator?id= -- http://localhost:4173/annotator?url= +- http://localhost:4173 (default demo) +- http://localhost:4173/asset?id= +- http://localhost:4173/asset?url= diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..a119ca4 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,36 @@ +# Container names +# Name of the UI container running the Media Asset Annotator application. +# This name is used by the proxy nginx to route requests via proxy_pass. +ANNOTATOR_CONTAINER_NAME=media-asset-annotator + +# Name of the proxy nginx container that exposes the application to the host. +PROXY_CONTAINER_NAME=media-asset-annotator-proxy + + +# External port +# Host machine port on which the application will be accessible. +# Example: http://localhost:2030// +ANNOTATOR_HOST_PORT=2030 + +# Application mode +# Controls application runtime behavior (e.g. dev, demo, prod). +APP_MODE=prod + + +# Base path +# Base URL path under which the application is served. +# Must start with "/" and must be accessed with a trailing slash. +# Example: http://localhost:2030/annotator/ +ANNOTATOR_BASE_PATH=/annotator + + +# Runtime config for the app +# Default media URL used for initial redirect or demo mode +# when no media URL is explicitly provided. +ANNOTATOR_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2020/08/03/46320-447422988_large.mp4 + +# Backend API endpoint used to fetch annotations. +ANNOTATIONS_FETCH_API_URL=https://api.example.com/annotations + +# Backend API endpoint used to fetch media assets. +MEDIA_ASSET_FETCH_API_URL=https://api.example.com/media diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..de017cb --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,48 @@ +# Production Deployment (Docker Compose) + +This document describes how to deploy **Media Asset Annotator** in **production** using **Docker Compose** with environment-based configuration. + +The setup uses: + +- one **UI container** (React + nginx, SPA fallback) +- one **proxy nginx container** (base-path routing via `proxy_pass`) +- runtime configuration injected via `.env` + +No rebuild is required when changing base paths or API URLs. + +--- + +## Prerequisites + +- Docker 20+ +- Docker Compose v2+ +- Ports you plan to use are free on the host + +--- + +## Deployment Steps + +### 1️⃣ Create `.env` file + +Copy the example file and adjust values for your environment: + +```bash +cp .env.example .env +``` + +Edit .env and fill in all required variables. Descriptions are provided in the file as comments. + +### 2️⃣ Start the stack + +Run Docker Compose with the env file: + +```bash +docker compose --env-file .env up -d +``` + +### 3️⃣ Access the application + +Open the application in your browser: + +- `http://://` + ⚠️ Trailing slash is mandatory diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..d8eee9a --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.9' + +services: + media-asset-annotator: + build: ../ + container_name: ${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator} + expose: + - '80' + environment: + BASE_PATH: '${ANNOTATOR_BASE_PATH:-/annotator}' + DEMO_MEDIA_URL: '${ANNOTATOR_DEMO_MEDIA_URL:-https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4}' + ANNOTATIONS_FETCH_API_URL: '${ANNOTATIONS_FETCH_API_URL:-}' + MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL:-}' + + nginx: + image: nginx:alpine + container_name: ${PROXY_CONTAINER_NAME:-media-asset-annotator-proxy} + ports: + - '${ANNOTATOR_HOST_PORT:-2030}:80' + environment: + BASE_PATH: '${ANNOTATOR_BASE_PATH:-/annotator}' + ANNOTATOR_CONTAINER_NAME: '${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator}' + volumes: + - ./nginx/nginx.conf.template:/etc/nginx/templates/nginx.conf.template:ro + - /dev/null:/etc/nginx/conf.d/default.conf + depends_on: + - media-asset-annotator diff --git a/deploy/nginx/nginx.conf.template b/deploy/nginx/nginx.conf.template new file mode 100644 index 0000000..245fee7 --- /dev/null +++ b/deploy/nginx/nginx.conf.template @@ -0,0 +1,21 @@ +server { + listen 80; + server_name _; + + # redirect /annotator -> /annotator/ + location = ${BASE_PATH} { + return 302 ${BASE_PATH}/; + } + + # proxy UI + location ${BASE_PATH}/ { + proxy_pass http://${ANNOTATOR_CONTAINER_NAME}/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /health { + return 200; + } +} diff --git a/index.html b/index.html index 4b9a546..a9fc5a6 100644 --- a/index.html +++ b/index.html @@ -2,10 +2,9 @@ - - - + + Media Asset Annotator diff --git a/package.json b/package.json index 239e409..6f186a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,15 @@ "name": "media-asset-annotator", "private": true, "version": "0.0.0", - "type": "module", + "keywords": [ + "react", + "media", + "annotation" + ], + "author": { + "name": "Yevgeniy Ulchenkov", + "email": "ulcheyev@fel.cvut.cz" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/public/runtime-env.js b/public/runtime-env.js new file mode 100644 index 0000000..38fdd85 --- /dev/null +++ b/public/runtime-env.js @@ -0,0 +1,2 @@ +// Replace placeholders with actual runtime configuration values +window.__RUNTIME_CONFIG__ = {}; diff --git a/src/api/fetchAnnotations.ts b/src/api/fetchAnnotations.ts index 42c99e4..ed5ba51 100644 --- a/src/api/fetchAnnotations.ts +++ b/src/api/fetchAnnotations.ts @@ -1,20 +1,15 @@ import type { AnnotationData } from '../types/extern/annotationData.ts'; import { mockAnnotations } from './mocks/annotatios'; - -const ANNOTATIONS_FETCH_API_URL = import.meta.env.VITE_ANNOTATIONS_FETCH_API_URL as string; +import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchAnnotations = async (mediaAssetId: string): Promise => { - if (import.meta.env.DEV) { + if (runtimeConfig.USE_MOCK_DATA) { console.warn('[fetchAnnotations] DEV mode – returning mock data'); return mockAnnotations; } try { - if (!ANNOTATIONS_FETCH_API_URL) { - throw new Error('Missing VITE_ANNOTATIONS_FETCH_API_URL'); - } - - const response = await fetch(`${ANNOTATIONS_FETCH_API_URL}/${mediaAssetId}`, { + const response = await fetch(`${runtimeConfig.ANNOTATIONS_FETCH_API_URL}/${mediaAssetId}`, { method: 'GET', headers: { 'Content-Type': 'application/json', diff --git a/src/api/fetchMediaAsset.ts b/src/api/fetchMediaAsset.ts index b9d18e3..eb667cc 100644 --- a/src/api/fetchMediaAsset.ts +++ b/src/api/fetchMediaAsset.ts @@ -1,12 +1,14 @@ import type { MediaAsset } from '../types/intern/media'; import { mockVideoMediaAsset } from './mocks/mediaAsset.ts'; +import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchMediaAsset = async (mediaAssetId: string): Promise => { - if (import.meta.env.DEV) { + if (runtimeConfig.USE_MOCK_DATA) { + console.warn('[fetchAnnotations] DEV mode – returning mock asset data'); return mockVideoMediaAsset; } - const response = await fetch(`${import.meta.env.VITE_MEDIA_ASSET_FETCH_API_URL}/${mediaAssetId}`); + const response = await fetch(`${runtimeConfig.MEDIA_ASSET_FETCH_API_URL}/${mediaAssetId}`); if (!response.ok) { throw new Error('Failed to fetch media asset'); diff --git a/src/api/mocks/annotatios.ts b/src/api/mocks/annotatios.ts index b11455f..d3d3aff 100644 --- a/src/api/mocks/annotatios.ts +++ b/src/api/mocks/annotatios.ts @@ -26,17 +26,4 @@ export const mockAnnotations: AnnotationData[] = [ color: '#00ff00', opacity: 1, }, - { - id: 'a3', - type: 'text', - label: 'My awesome text 3', - points: '0.4875,0.4221105527638191', - timeStart: 0, - timeEnd: 8, - text: 'Hello world', - fontSize: 0.083424, - fontWeight: 200, - color: '#00ff00', - opacity: 1, - }, ]; diff --git a/src/features/annotation/AnnotationsLayer.tsx b/src/features/annotation/AnnotationsLayer.tsx index e647a88..8e551cc 100644 --- a/src/features/annotation/AnnotationsLayer.tsx +++ b/src/features/annotation/AnnotationsLayer.tsx @@ -30,7 +30,7 @@ export const AnnotationsLayer = ({ const visible = annotations.filter((a) => { if (mediaType === Constants.IMAGE_ASSET_TYPE_LABEL) return true; if (a.time.start == null && a.time.end == null) return true; - return a.time.start <= currentTime && a.time.end >= currentTime; + return a.time.start <= currentTime && a.time.end >= currentTime; }); const current = isActive ? visible : annotations; diff --git a/src/features/toolbox/toolsContext/FreehandDrawTool.ts b/src/features/toolbox/toolsContext/FreehandDrawTool.ts index 9a7cbcd..e13c1aa 100644 --- a/src/features/toolbox/toolsContext/FreehandDrawTool.ts +++ b/src/features/toolbox/toolsContext/FreehandDrawTool.ts @@ -21,7 +21,7 @@ export class FreehandDrawTool implements ToolStrategy { points: [point.x, point.y], time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, style: { color: Constants.POLYLINE_DEFAULT_COLOR, diff --git a/src/features/toolbox/toolsContext/PolygonDrawTool.ts b/src/features/toolbox/toolsContext/PolygonDrawTool.ts index cba95bd..21fe1cd 100644 --- a/src/features/toolbox/toolsContext/PolygonDrawTool.ts +++ b/src/features/toolbox/toolsContext/PolygonDrawTool.ts @@ -21,7 +21,7 @@ export class PolygonDrawTool implements ToolStrategy { points: [point.x, point.y], time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, style: { color: Constants.POLYGON_DEFAULT_COLOR, @@ -57,10 +57,8 @@ export class PolygonDrawTool implements ToolStrategy { }); } - -// eslint-disable-next-line @typescript-eslint/no-unused-vars - onPointerUp(_: Point, __: ToolContextInterface) { - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onPointerUp(_: Point, __: ToolContextInterface) {} cancel(ctx: ToolContextInterface) { if (!this.annotationId) return; diff --git a/src/features/toolbox/toolsContext/RectDrawTool.ts b/src/features/toolbox/toolsContext/RectDrawTool.ts index 75f3420..0a04012 100644 --- a/src/features/toolbox/toolsContext/RectDrawTool.ts +++ b/src/features/toolbox/toolsContext/RectDrawTool.ts @@ -26,7 +26,7 @@ export class RectDrawTool implements ToolStrategy { points, time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, style: { color: Constants.POLYLINE_DEFAULT_COLOR, diff --git a/src/features/toolbox/toolsContext/TextDrawTool.ts b/src/features/toolbox/toolsContext/TextDrawTool.ts index 820b235..9cc0779 100644 --- a/src/features/toolbox/toolsContext/TextDrawTool.ts +++ b/src/features/toolbox/toolsContext/TextDrawTool.ts @@ -23,7 +23,7 @@ export class TextDrawTool implements ToolStrategy { y: point.y, time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, fontSize: Constants.TEXT_DEFAULT_FONT_SIZE, fontWeight: Constants.TEXT_DEFAULT_FONT_WEIGHT, diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index a518a7e..94163d3 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,16 +1,21 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { MediaAssetAnnotatorPage } from '../pages/MediaAssetAnnotatorPage.tsx'; +import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const AppRouter = () => { - const DEMO_URL = import.meta.env.VITE_DEMO_MEDIA_URL; return ( - + } + element={ + + } /> - } /> + } /> ); diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 11dd50b..4350790 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -8,7 +8,7 @@ export class Constants { /* ============================================================ * Time * ============================================================ */ - static ANNOTATION_MIN_DURATION = 1; + static ANNOTATION_DEFAULT_DURATION = 5; /* ============================================================ * Annotation Types diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts new file mode 100644 index 0000000..3f9c7ce --- /dev/null +++ b/src/utils/runtimeConfig.ts @@ -0,0 +1,111 @@ +// Prefix for Vite-exposed variables +const VITE_ENV_PREFIX = 'ANNOTATOR_'; +export const APP_MODES = ['demo', 'dev', 'prod'] as const; +export type AppMode = (typeof APP_MODES)[number]; + +/** + * Shape of runtime config injected at runtime (e.g. via runtime-env.js) + */ +export interface RuntimeConfig { + DEMO_MEDIA_URL?: string; + ANNOTATIONS_FETCH_API_URL?: string; + MEDIA_ASSET_FETCH_API_URL?: string; + BASE_PATH?: string; +} + +/** + * Extend Window type safely + */ +declare global { + interface Window { + __RUNTIME_CONFIG__?: RuntimeConfig; + } +} + +/** + * Extract prefixed variables from import.meta.env + */ +function getViteEnv(): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(import.meta.env)) { + if (key.startsWith(VITE_ENV_PREFIX) && typeof value === 'string') { + const strippedKey = key.slice(VITE_ENV_PREFIX.length); + result[strippedKey] = value; + } + } + + return result; +} + +/** + * Merge Vite env and runtime env (runtime takes precedence) + */ +const ENV: Record = { + ...getViteEnv(), + ...(window.__RUNTIME_CONFIG__ ?? {}), +}; + +function env(name: string): string | undefined { + return ENV[name]; +} + +function envOrDefault(name: string, defaultValue: string): string { + const value = env(name); + + if (value === undefined || value.trim() === '') { + return defaultValue; + } + + return value; +} + +function envRequired(name: string): string { + const value = env(name); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function parseAppMode(value?: string): AppMode { + if (value === 'demo' || value === 'dev' || value === 'prod') { + return value; + } + return import.meta.env.DEV ? 'dev' : 'prod'; +} + +// Determine app mode +const APP_MODE: AppMode = parseAppMode(env('APP_MODE')); + +const IS_DEMO = APP_MODE === 'demo'; +const IS_DEV = APP_MODE === 'dev'; +const IS_PROD = APP_MODE === 'prod'; + +// Export runtime config +export const runtimeConfig = { + // mode + APP_MODE, + IS_DEMO, + IS_DEV, + IS_PROD, + + // behavior + USE_MOCK_DATA: IS_DEMO || IS_DEV, + + // always available + BASE_PATH: envOrDefault('BASE_PATH', '/'), + DEMO_MEDIA_URL: envOrDefault( + 'DEMO_MEDIA_URL', + 'https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4', + ), + + // backend (required only in prod) + ANNOTATIONS_FETCH_API_URL: IS_PROD + ? envRequired('ANNOTATIONS_FETCH_API_URL') + : env('ANNOTATIONS_FETCH_API_URL'), + + MEDIA_ASSET_FETCH_API_URL: IS_PROD + ? envRequired('MEDIA_ASSET_FETCH_API_URL') + : env('MEDIA_ASSET_FETCH_API_URL'), +} as const; diff --git a/src/utils/videoTime.utils.ts b/src/utils/videoTime.utils.ts index 5ac196a..65993b2 100644 --- a/src/utils/videoTime.utils.ts +++ b/src/utils/videoTime.utils.ts @@ -1,4 +1,3 @@ -import { Constants } from './Constants.ts'; import type { TimeRange } from '../types/intern/annotation.ts'; export const formatTime = (t: number) => { @@ -30,11 +29,7 @@ export function computeNextInterval( } export const isValidInterval = (interval: TimeRange, duration: number) => { - return ( - interval.start >= 0 && - interval.end <= duration && - interval.end - interval.start >= Constants.ANNOTATION_MIN_DURATION - ); + return interval.start >= 0 && interval.end <= duration; }; export const clamp = (value: number, min: number, max: number) => diff --git a/vite.config.ts b/vite.config.ts index da17daf..40af710 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], + envPrefix: 'ANNOTATOR_', + base: '', + root: '', });