diff --git a/.env.example b/.env.example index 8c9d2c9..acf8d39 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,13 @@ +# Environment variables for MiniQR + +# Crowdin configuration for translations CROWDIN_PERSONAL_TOKEN="your_personal_token" -CROWDIN_PROJECT_ID="your_project_id" \ No newline at end of file +CROWDIN_PROJECT_ID="your_project_id" + +# Base path for deployment (e.g., /mini-qr for deploying at domain.com/mini-qr) +# Default: / +BASE_PATH=/ + +# Hide credits in the footer +# Default: false +VITE_HIDE_CREDITS=false \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 76e86a8..5ad06c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,16 +3,22 @@ # Build stage FROM node:lts-alpine AS builder WORKDIR /app + +# Accept BASE_PATH as build argument +ARG BASE_PATH=/ +ENV BASE_PATH=${BASE_PATH} + COPY package*.json ./ RUN npm install --frozen-lockfile COPY . . RUN npm run build # Production stage -FROM nginx:alpine AS production -LABEL org.opencontainers.image.source https://github.com/lyqht/mini-qr -COPY --from=builder /app/dist /usr/share/nginx/html -COPY --from=builder /app/public /usr/share/nginx/html/public -COPY --from=builder /app/nginx.conf /etc/nginx/nginx.conf +FROM node:lts-alpine AS production +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/public ./public +COPY --from=builder /app/package.json ./ +RUN npm install -g serve EXPOSE 8080 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["serve", "-s", "dist", "-l", "8080"] \ No newline at end of file diff --git a/README.md b/README.md index af0edfe..93ae9b7 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,32 @@ docker run -d -p 8081:8080 mini-qr - The production image uses Nginx for optimal static file serving. - The `.dockerignore` file is included for smaller, faster builds. - Set `HIDE_CREDITS=1` to remove the maintainer credit from the footer. +- Set `BASE_PATH=/your-path` to deploy the app under a subdirectory (e.g., for hosting at `domain.com/your-path`). + +#### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `HIDE_CREDITS` | Hide credits in the footer | `false` | +| `BASE_PATH` | Base path for deployment | `/` | + +#### Examples + +Deploy at root path (default): +```bash +docker compose up -d +``` + +Deploy at subdirectory `/mini-qr`: +```bash +BASE_PATH=/mini-qr docker compose up -d +``` + +For custom builds with specific BASE_PATH: +```bash +docker build --build-arg BASE_PATH=/mini-qr -t mini-qr . +docker run -d -p 8081:8080 mini-qr +``` ## Contributing diff --git a/docker-compose.yml b/docker-compose.yml index b0a5f2b..4dbbe42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,37 @@ --- services: mini-qr: - image: ghcr.io/lyqht/mini-qr:latest + # image: ghcr.io/lyqht/mini-qr:latest container_name: mini-qr - ports: - - 8081:8080 restart: unless-stopped environment: - VITE_HIDE_CREDITS=${HIDE_CREDITS:-false} + - BASE_PATH=${BASE_PATH:-/} # Uncomment the following lines to build locally instead of pulling from ghcr.io - # build: - # context: . - # dockerfile: Dockerfile + build: + context: . + dockerfile: Dockerfile + args: + - BASE_PATH=${BASE_PATH:-/} # volumes: # - ./public:/usr/share/nginx/html/public:ro # - ./nginx.conf:/etc/nginx/nginx.conf:ro + networks: + - mini-qr-network + + nginx-proxy: + image: nginx:alpine + container_name: nginx-proxy + ports: + - 80:80 + restart: unless-stopped + volumes: + - ./nginx-proxy.conf:/etc/nginx/nginx.conf:ro + depends_on: + - mini-qr + networks: + - mini-qr-network + +networks: + mini-qr-network: + driver: bridge diff --git a/env.d.ts b/env.d.ts index 9099180..994c6fe 100644 --- a/env.d.ts +++ b/env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_HIDE_CREDITS?: string + readonly BASE_PATH?: string } interface ImportMeta { diff --git a/nginx-proxy.conf b/nginx-proxy.conf new file mode 100644 index 0000000..830e54a --- /dev/null +++ b/nginx-proxy.conf @@ -0,0 +1,68 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + + # Upstream for mini-qr service + upstream mini-qr-backend { + server mini-qr:8080; + } + + server { + listen 80; + server_name localhost; + + # Root path - redirect to mini-qr + location = / { + return 301 /mini-qr/; + } + + # Mini QR application with subpath + location /mini-qr/ { + # Remove /mini-qr prefix before forwarding to backend + rewrite ^/mini-qr/(.*) /$1 break; + + proxy_pass http://mini-qr-backend; + 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_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Handle assets specifically + location ~* ^/mini-qr/assets/ { + rewrite ^/mini-qr/(.*) /$1 break; + proxy_pass http://mini-qr-backend; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Handle static files + location ~* ^/mini-qr/.*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + rewrite ^/mini-qr/(.*) /$1 break; + proxy_pass http://mini-qr-backend; + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Error pages + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} diff --git a/nginx.conf b/nginx.conf index 543aec4..0111bad 100644 --- a/nginx.conf +++ b/nginx.conf @@ -10,20 +10,45 @@ pid /var/run/nginx.pid; http { include /etc/nginx/mime.types; - default_type application/octet-stream; - sendfile on; + default_type application/octet-stream; sendfile on; keepalive_timeout 65; - server { + server { listen 8080; server_name localhost; root /usr/share/nginx/html; index index.html; + + # Handle JavaScript modules with correct MIME type for any base path + location ~* ^/.*/assets/.*\.(js|mjs)$ { + add_header Content-Type application/javascript; + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Handle CSS files with correct MIME type for any base path + location ~* ^/.*/assets/.*\.css$ { + add_header Content-Type text/css; + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Handle other static assets + location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + location / { try_files $uri $uri/ /index.html; } + location /public/ { alias /usr/share/nginx/html/public/; } + error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; diff --git a/src/components/AppFooter.vue b/src/components/AppFooter.vue index 4261b65..e51c83f 100644 --- a/src/components/AppFooter.vue +++ b/src/components/AppFooter.vue @@ -2,6 +2,7 @@ import { marked } from 'marked' import { ref, onMounted } from 'vue' import { useI18n } from 'vue-i18n' +import { fetchWithBasePath } from '@/utils/basePath' import { Dialog, DialogContent, @@ -17,15 +18,13 @@ const { t } = useI18n() const version = ref('...') const changelogContent = ref(null) const isLoading = ref(true) -const hideCredits = ['1', 'true'].includes( - (import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase() -) +const hideCredits = ['1', 'true'].includes((import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase()) async function fetchAndProcessChangelog() { if (changelogContent.value === null) { isLoading.value = true try { - const response = await fetch('/CHANGELOG.md') + const response = await fetchWithBasePath('/CHANGELOG.md') if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } @@ -69,33 +68,33 @@ onMounted(() => { > | - - - - - - {{ t('Changelog') }} - - - {{ t('Close') }} - - + + + + + + {{ t('Changelog') }} + + + {{ t('Close') }} + + -
- -
Loading...
-
-
{{ t('Failed to load changelog') }}
-
-
-
+
+ +
Loading...
+
+
{{ t('Failed to load changelog') }}
+
+
+
diff --git a/src/components/MobileMenu.vue b/src/components/MobileMenu.vue index 99ab426..2b9bb1a 100644 --- a/src/components/MobileMenu.vue +++ b/src/components/MobileMenu.vue @@ -4,6 +4,7 @@ import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue' import LanguageSelector from '@/components/LanguageSelector.vue' import { useI18n } from 'vue-i18n' import { marked } from 'marked' +import { fetchWithBasePath } from '@/utils/basePath' import { Dialog, DialogContent, @@ -33,15 +34,13 @@ const floating = ref(null) const version = ref('...') const changelogContent = ref(null) const isLoadingChangelog = ref(true) -const hideCredits = ['1', 'true'].includes( - (import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase() -) +const hideCredits = ['1', 'true'].includes((import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase()) async function fetchAndProcessChangelog() { if (changelogContent.value === null) { isLoadingChangelog.value = true try { - const response = await fetch('/CHANGELOG.md') + const response = await fetchWithBasePath('/CHANGELOG.md') if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } diff --git a/src/utils/basePath.ts b/src/utils/basePath.ts new file mode 100644 index 0000000..fc9c98e --- /dev/null +++ b/src/utils/basePath.ts @@ -0,0 +1,40 @@ +// Base path utility for handling runtime paths +// Uses Vite's environment variable system to get BASE_PATH at build time + +/** + * Get the base path for the application + * This reads from Vite's environment variables which are set during build time + */ +export function getBasePath(): string { + // Vite makes BASE_PATH available at build time through import.meta.env + // If BASE_PATH is not set, default to '/' + return import.meta.env.BASE_PATH || '/' +} + +/** + * Resolve a relative path with the application base path + * @param path - The path to resolve (should start with /) + * @returns The resolved path with base path prepended + */ +export function resolvePath(path: string): string { + const basePath = getBasePath() + + // Remove trailing slash from base path (except for root) + const cleanBasePath = basePath === '/' ? '' : basePath.replace(/\/$/, '') + + // Ensure path starts with / + const cleanPath = path.startsWith('/') ? path : `/${path}` + + return `${cleanBasePath}${cleanPath}` +} + +/** + * Create a fetch function that respects the base path + * @param path - The path to fetch (should start with /) + * @param options - Fetch options + * @returns Promise + */ +export function fetchWithBasePath(path: string, options?: RequestInit): Promise { + const resolvedPath = resolvePath(path) + return fetch(resolvedPath, options) +} diff --git a/vite.config.js b/vite.config.js index fbd5c49..edd094f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,82 +1,120 @@ import { fileURLToPath, URL } from 'node:url' - -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' import { VitePWA } from 'vite-plugin-pwa' -export default defineConfig({ - plugins: [ - vue(), - vueJsx(), - VitePWA({ - registerType: 'autoUpdate', - includeAssets: [ - 'app_icons/web/favicon.ico', - 'app_icons/web/splash-750x1334@2x.png', - 'app_icons/web/splash-1170x2532@3x.png', - 'app_icons/web/splash-1290x2796@3x.png', - 'app_icons/web/splash-2048x2732@2x.png' - ], - manifest: { - name: 'MiniQR', - short_name: 'MiniQR', - description: 'A minimal QR code generator and scanner', - theme_color: '#ffffff', - background_color: '#ffffff', - display: 'standalone', - orientation: 'portrait', - icons: [ - { - src: 'app_icons/web/icon-192.png', - sizes: '192x192', - type: 'image/png' - }, - { - src: 'app_icons/web/icon-192-maskable.png', - sizes: '192x192', - type: 'image/png', - purpose: 'maskable' - }, - { - src: 'app_icons/web/icon-512.png', - sizes: '512x512', - type: 'image/png' - }, - { - src: 'app_icons/web/icon-512-maskable.png', - sizes: '512x512', - type: 'image/png', - purpose: 'maskable' - } +export default defineConfig(({ mode }) => { + // Load environment variables + const env = loadEnv(mode, '.', '') + // Get BASE_PATH from environment variable, default to '/' + // Ensure base path ends with slash for proper URL construction + let base = env.BASE_PATH || '/' + if (base !== '/' && !base.endsWith('/')) { + base = base + '/' + } + + return { + base, + define: { + // Make BASE_PATH available to client-side code through import.meta.env + 'import.meta.env.BASE_PATH': JSON.stringify(base) + }, + plugins: [ + vue(), + vueJsx(), + VitePWA({ + registerType: 'autoUpdate', + base: base, // Make sure PWA respects the base path + includeAssets: [ + 'app_icons/web/favicon.ico', + 'app_icons/web/splash-750x1334@2x.png', + 'app_icons/web/splash-1170x2532@3x.png', + 'app_icons/web/splash-1290x2796@3x.png', + 'app_icons/web/splash-2048x2732@2x.png' ], - screenshots: [ - { - src: 'app_icons/web/screenshot-narrow.png', - sizes: '3510x7596', - type: 'image/png', - form_factor: 'narrow' - }, - { - src: 'app_icons/web/screenshot-wide.png', - sizes: '7596x3510', - type: 'image/png', - form_factor: 'wide' - } - ] - }, - workbox: { - navigateFallback: 'index.html' - }, - devOptions: { - // enabled: true, - type: 'module' + manifest: { + name: 'MiniQR', + short_name: 'MiniQR', + description: 'A minimal QR code generator and scanner', + theme_color: '#ffffff', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: base, // Use the base path as start URL + icons: [ + { + src: 'app_icons/web/icon-192.png', + sizes: '192x192', + type: 'image/png' + }, + { + src: 'app_icons/web/icon-192-maskable.png', + sizes: '192x192', + type: 'image/png', + purpose: 'maskable' + }, + { + src: 'app_icons/web/icon-512.png', + sizes: '512x512', + type: 'image/png' + }, + { + src: 'app_icons/web/icon-512-maskable.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable' + } + ], + screenshots: [ + { + src: 'app_icons/web/screenshot-narrow.png', + sizes: '3510x7596', + type: 'image/png', + form_factor: 'narrow' + }, + { + src: 'app_icons/web/screenshot-wide.png', + sizes: '7596x3510', + type: 'image/png', + form_factor: 'wide' + } + ] + }, + workbox: { + globPatterns: ['**/*.{js,css,svg,png,jpg,jpeg,gif,ico,woff,woff2}'], // Removed html from patterns + // Exclude large files from precaching and HTML files to avoid base path issues + globIgnores: ['**/app_preview.*', '**/presets/*.svg', '**/*.html'], + maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB limit + // Don't precache index.html to avoid base path issues + dontCacheBustURLsMatching: /\.\w{8}\./, + navigateFallback: null, // Disable navigate fallback to avoid issues + navigateFallbackDenylist: [/^\/_/, /\/[^/?]+\.[^/]+$/], + // Remove modifyURLPrefix as it's causing conflicts with the base path + runtimeCaching: [ + { + urlPattern: ({ request }) => request.destination === 'document', + handler: 'NetworkFirst', + options: { + cacheName: 'pages', + expiration: { + maxEntries: 10, + maxAgeSeconds: 86400 // 1 day + } + } + } + ] + }, + devOptions: { + // enabled: true, + type: 'module' + } + }) + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) } - }) - ], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) } } })