feat: add BASE_PATH support for deployment and update environment variables (#165)

This commit is contained in:
Patipat Chewprecha 2025-06-05 19:09:23 +07:00 committed by GitHub
parent 30ede17f80
commit 2fe35fafcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 355 additions and 122 deletions

View File

@ -1,2 +1,13 @@
# Environment variables for MiniQR
# Crowdin configuration for translations
CROWDIN_PERSONAL_TOKEN="your_personal_token" CROWDIN_PERSONAL_TOKEN="your_personal_token"
CROWDIN_PROJECT_ID="your_project_id" 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

View File

@ -3,16 +3,22 @@
# Build stage # Build stage
FROM node:lts-alpine AS builder FROM node:lts-alpine AS builder
WORKDIR /app WORKDIR /app
# Accept BASE_PATH as build argument
ARG BASE_PATH=/
ENV BASE_PATH=${BASE_PATH}
COPY package*.json ./ COPY package*.json ./
RUN npm install --frozen-lockfile RUN npm install --frozen-lockfile
COPY . . COPY . .
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM nginx:alpine AS production FROM node:lts-alpine AS production
LABEL org.opencontainers.image.source https://github.com/lyqht/mini-qr WORKDIR /app
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public /usr/share/nginx/html/public COPY --from=builder /app/public ./public
COPY --from=builder /app/nginx.conf /etc/nginx/nginx.conf COPY --from=builder /app/package.json ./
RUN npm install -g serve
EXPOSE 8080 EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"] CMD ["serve", "-s", "dist", "-l", "8080"]

View File

@ -142,6 +142,32 @@ docker run -d -p 8081:8080 mini-qr
- The production image uses Nginx for optimal static file serving. - The production image uses Nginx for optimal static file serving.
- The `.dockerignore` file is included for smaller, faster builds. - The `.dockerignore` file is included for smaller, faster builds.
- Set `HIDE_CREDITS=1` to remove the maintainer credit from the footer. - 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 ## Contributing

View File

@ -1,17 +1,37 @@
--- ---
services: services:
mini-qr: mini-qr:
image: ghcr.io/lyqht/mini-qr:latest # image: ghcr.io/lyqht/mini-qr:latest
container_name: mini-qr container_name: mini-qr
ports:
- 8081:8080
restart: unless-stopped restart: unless-stopped
environment: environment:
- VITE_HIDE_CREDITS=${HIDE_CREDITS:-false} - VITE_HIDE_CREDITS=${HIDE_CREDITS:-false}
- BASE_PATH=${BASE_PATH:-/}
# Uncomment the following lines to build locally instead of pulling from ghcr.io # Uncomment the following lines to build locally instead of pulling from ghcr.io
# build: build:
# context: . context: .
# dockerfile: Dockerfile dockerfile: Dockerfile
args:
- BASE_PATH=${BASE_PATH:-/}
# volumes: # volumes:
# - ./public:/usr/share/nginx/html/public:ro # - ./public:/usr/share/nginx/html/public:ro
# - ./nginx.conf:/etc/nginx/nginx.conf: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

1
env.d.ts vendored
View File

@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_HIDE_CREDITS?: string readonly VITE_HIDE_CREDITS?: string
readonly BASE_PATH?: string
} }
interface ImportMeta { interface ImportMeta {

68
nginx-proxy.conf Normal file
View File

@ -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;
}
}
}

View File

@ -10,20 +10,45 @@ pid /var/run/nginx.pid;
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream; sendfile on;
sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
server { server {
listen 8080; listen 8080;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location /public/ { location /public/ {
alias /usr/share/nginx/html/public/; alias /usr/share/nginx/html/public/;
} }
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
root /usr/share/nginx/html; root /usr/share/nginx/html;

View File

@ -2,6 +2,7 @@
import { marked } from 'marked' import { marked } from 'marked'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { fetchWithBasePath } from '@/utils/basePath'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -17,15 +18,13 @@ const { t } = useI18n()
const version = ref('...') const version = ref('...')
const changelogContent = ref<string | null>(null) const changelogContent = ref<string | null>(null)
const isLoading = ref(true) const isLoading = ref(true)
const hideCredits = ['1', 'true'].includes( const hideCredits = ['1', 'true'].includes((import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase())
(import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase()
)
async function fetchAndProcessChangelog() { async function fetchAndProcessChangelog() {
if (changelogContent.value === null) { if (changelogContent.value === null) {
isLoading.value = true isLoading.value = true
try { try {
const response = await fetch('/CHANGELOG.md') const response = await fetchWithBasePath('/CHANGELOG.md')
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)
} }
@ -69,33 +68,33 @@ onMounted(() => {
> >
<span>|</span> <span>|</span>
<Dialog> <Dialog>
<DialogTrigger as-child> <DialogTrigger as-child>
<button class="secondary-button" :aria-label="t('View changelog')" :disabled="isLoading"> <button class="secondary-button" :aria-label="t('View changelog')" :disabled="isLoading">
{{ isLoading ? '...' : version }} {{ isLoading ? '...' : version }}
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent class="flex max-h-[80vh] flex-col sm:max-w-md" @open-auto-focus.prevent> <DialogContent class="flex max-h-[80vh] flex-col sm:max-w-md" @open-auto-focus.prevent>
<DialogHeader> <DialogHeader>
<DialogTitle>{{ t('Changelog') }}</DialogTitle> <DialogTitle>{{ t('Changelog') }}</DialogTitle>
<DialogClose <DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground" class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
> >
<X class="size-4" /> <X class="size-4" />
<span class="sr-only">{{ t('Close') }}</span> <span class="sr-only">{{ t('Close') }}</span>
</DialogClose> </DialogClose>
</DialogHeader> </DialogHeader>
<div class="flex-1 overflow-y-auto pr-2"> <div class="flex-1 overflow-y-auto pr-2">
<DialogDescription <DialogDescription
as="div" as="div"
class="prose prose-sm max-w-none text-start dark:prose-invert prose-li:my-1" class="prose prose-sm max-w-none text-start dark:prose-invert prose-li:my-1"
> >
<div v-if="isLoading">Loading...</div> <div v-if="isLoading">Loading...</div>
<div v-else-if="changelogContent" v-html="changelogContent"></div> <div v-else-if="changelogContent" v-html="changelogContent"></div>
<div v-else>{{ t('Failed to load changelog') }}</div> <div v-else>{{ t('Failed to load changelog') }}</div>
</DialogDescription> </DialogDescription>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</footer> </footer>

View File

@ -4,6 +4,7 @@ import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'
import LanguageSelector from '@/components/LanguageSelector.vue' import LanguageSelector from '@/components/LanguageSelector.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { marked } from 'marked' import { marked } from 'marked'
import { fetchWithBasePath } from '@/utils/basePath'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -33,15 +34,13 @@ const floating = ref<HTMLElement | null>(null)
const version = ref('...') const version = ref('...')
const changelogContent = ref<string | null>(null) const changelogContent = ref<string | null>(null)
const isLoadingChangelog = ref(true) const isLoadingChangelog = ref(true)
const hideCredits = ['1', 'true'].includes( const hideCredits = ['1', 'true'].includes((import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase())
(import.meta.env.VITE_HIDE_CREDITS ?? '').toLowerCase()
)
async function fetchAndProcessChangelog() { async function fetchAndProcessChangelog() {
if (changelogContent.value === null) { if (changelogContent.value === null) {
isLoadingChangelog.value = true isLoadingChangelog.value = true
try { try {
const response = await fetch('/CHANGELOG.md') const response = await fetchWithBasePath('/CHANGELOG.md')
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)
} }

40
src/utils/basePath.ts Normal file
View File

@ -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<Response>
*/
export function fetchWithBasePath(path: string, options?: RequestInit): Promise<Response> {
const resolvedPath = resolvePath(path)
return fetch(resolvedPath, options)
}

View File

@ -1,82 +1,120 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [ // Load environment variables
vue(), const env = loadEnv(mode, '.', '')
vueJsx(), // Get BASE_PATH from environment variable, default to '/'
VitePWA({ // Ensure base path ends with slash for proper URL construction
registerType: 'autoUpdate', let base = env.BASE_PATH || '/'
includeAssets: [ if (base !== '/' && !base.endsWith('/')) {
'app_icons/web/favicon.ico', base = base + '/'
'app_icons/web/splash-750x1334@2x.png', }
'app_icons/web/splash-1170x2532@3x.png',
'app_icons/web/splash-1290x2796@3x.png', return {
'app_icons/web/splash-2048x2732@2x.png' base,
], define: {
manifest: { // Make BASE_PATH available to client-side code through import.meta.env
name: 'MiniQR', 'import.meta.env.BASE_PATH': JSON.stringify(base)
short_name: 'MiniQR', },
description: 'A minimal QR code generator and scanner', plugins: [
theme_color: '#ffffff', vue(),
background_color: '#ffffff', vueJsx(),
display: 'standalone', VitePWA({
orientation: 'portrait', registerType: 'autoUpdate',
icons: [ base: base, // Make sure PWA respects the base path
{ includeAssets: [
src: 'app_icons/web/icon-192.png', 'app_icons/web/favicon.ico',
sizes: '192x192', 'app_icons/web/splash-750x1334@2x.png',
type: 'image/png' 'app_icons/web/splash-1170x2532@3x.png',
}, 'app_icons/web/splash-1290x2796@3x.png',
{ 'app_icons/web/splash-2048x2732@2x.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: [ manifest: {
{ name: 'MiniQR',
src: 'app_icons/web/screenshot-narrow.png', short_name: 'MiniQR',
sizes: '3510x7596', description: 'A minimal QR code generator and scanner',
type: 'image/png', theme_color: '#ffffff',
form_factor: 'narrow' background_color: '#ffffff',
}, display: 'standalone',
{ orientation: 'portrait',
src: 'app_icons/web/screenshot-wide.png', start_url: base, // Use the base path as start URL
sizes: '7596x3510', icons: [
type: 'image/png', {
form_factor: 'wide' src: 'app_icons/web/icon-192.png',
} sizes: '192x192',
] type: 'image/png'
}, },
workbox: { {
navigateFallback: 'index.html' src: 'app_icons/web/icon-192-maskable.png',
}, sizes: '192x192',
devOptions: { type: 'image/png',
// enabled: true, purpose: 'maskable'
type: 'module' },
{
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))
} }
} }
}) })