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_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
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;"]
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 `.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

View File

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

1
env.d.ts vendored
View File

@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_HIDE_CREDITS?: string
readonly BASE_PATH?: string
}
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 {
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;

View File

@ -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<string | null>(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(() => {
>
<span>|</span>
<Dialog>
<DialogTrigger as-child>
<button class="secondary-button" :aria-label="t('View changelog')" :disabled="isLoading">
{{ isLoading ? '...' : version }}
</button>
</DialogTrigger>
<DialogContent class="flex max-h-[80vh] flex-col sm:max-w-md" @open-auto-focus.prevent>
<DialogHeader>
<DialogTitle>{{ t('Changelog') }}</DialogTitle>
<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"
>
<X class="size-4" />
<span class="sr-only">{{ t('Close') }}</span>
</DialogClose>
</DialogHeader>
<DialogTrigger as-child>
<button class="secondary-button" :aria-label="t('View changelog')" :disabled="isLoading">
{{ isLoading ? '...' : version }}
</button>
</DialogTrigger>
<DialogContent class="flex max-h-[80vh] flex-col sm:max-w-md" @open-auto-focus.prevent>
<DialogHeader>
<DialogTitle>{{ t('Changelog') }}</DialogTitle>
<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"
>
<X class="size-4" />
<span class="sr-only">{{ t('Close') }}</span>
</DialogClose>
</DialogHeader>
<div class="flex-1 overflow-y-auto pr-2">
<DialogDescription
as="div"
class="prose prose-sm max-w-none text-start dark:prose-invert prose-li:my-1"
>
<div v-if="isLoading">Loading...</div>
<div v-else-if="changelogContent" v-html="changelogContent"></div>
<div v-else>{{ t('Failed to load changelog') }}</div>
</DialogDescription>
</div>
</DialogContent>
<div class="flex-1 overflow-y-auto pr-2">
<DialogDescription
as="div"
class="prose prose-sm max-w-none text-start dark:prose-invert prose-li:my-1"
>
<div v-if="isLoading">Loading...</div>
<div v-else-if="changelogContent" v-html="changelogContent"></div>
<div v-else>{{ t('Failed to load changelog') }}</div>
</DialogDescription>
</div>
</DialogContent>
</Dialog>
</div>
</footer>

View File

@ -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<HTMLElement | null>(null)
const version = ref('...')
const changelogContent = ref<string | null>(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}`)
}

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 { 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))
}
}
})