Добавил докер и касторизацию через env

This commit is contained in:
Ilia Miheev
2026-06-20 21:53:03 +03:00
parent 709eaacadb
commit c9347b8d20
16 changed files with 243 additions and 30 deletions
+10
View File
@@ -0,0 +1,10 @@
# Заголовок на главной карточке
VITE_APP_TITLE=Генератор QR-кодов
# Плашка внизу: бренд, слоган и ссылка
VITE_BANNER_BRAND=iQR
VITE_BANNER_SLOGAN=— qr-код без излишеств
VITE_BANNER_URL=https://github.com/IliaMiheev/iQR
# Акцентный цвет (hex, rgb или css-имя)
VITE_ACCENT_COLOR=#ff4500
+2
View File
@@ -0,0 +1,2 @@
*.sh text eol=lf
docker/entrypoint.sh text eol=lf
+1
View File
@@ -2,5 +2,6 @@ node_modules
dist dist
.DS_Store .DS_Store
*.local *.local
.env
__pycache__ __pycache__
ideas.md ideas.md
+21
View File
@@ -0,0 +1,21 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/entrypoint.sh /entrypoint.sh
COPY --from=build /app/dist /usr/share/nginx/html
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/bin/sh", "/entrypoint.sh"]
+49 -9
View File
@@ -2,7 +2,7 @@
Вводишь текст и генерируешь. Всё просто. Вводишь текст и генерируешь. Всё просто.
Проект переписан на **Vue 3 + Vite**. Проект на **Vue 3 + Vite**. Подходит для деплоя на поддомен (например `qr.example.com`) с кастомизацией брендинга.
### Запуск локально ### Запуск локально
@@ -11,26 +11,66 @@ npm install
npm run dev npm run dev
``` ```
Приложение откроется по адресу `http://localhost:5173/iQR/`. Приложение откроется по адресу `http://localhost:5173/`.
### Сборка для GitHub Pages Файл `.env` **не обязателен** — без него работают значения по умолчанию. Нужен только если хотите кастомизировать интерфейс при локальной разработке (`npm run dev` / `npm run build`).
### Настройка через `.env` (только локально)
Скопируйте `.env.example` в `.env` и измените значения:
| Переменная | Описание | По умолчанию |
|---|---|---|
| `VITE_APP_TITLE` | Заголовок | `Генератор QR-кодов` |
| `VITE_BANNER_BRAND` | Бренд в плашке | `iQR` |
| `VITE_BANNER_SLOGAN` | Слоган в плашке | `— qr-код без излишеств` |
| `VITE_BANNER_URL` | Ссылка плашки | репозиторий iQR |
| `VITE_ACCENT_COLOR` | Акцентный цвет | `#ff4500` |
После изменения `.env` перезапустите `npm run dev`.
### Docker
Для Docker **`.env` не нужен** — настройки передаются переменными окружения в `docker-compose.yml`.
```bash
docker compose up --build
```
Сайт будет на `http://localhost:8080`.
```yaml
environment:
APP_TITLE: "QR на моём сайте"
BANNER_BRAND: "MySite"
BANNER_SLOGAN: "— быстрые qr-коды"
BANNER_URL: "https://example.com"
ACCENT_COLOR: "#7c3aed"
```
При старте контейнера генерируется `config.json`, поэтому пересборка образа для смены текстов не нужна.
### Сборка
```bash ```bash
npm run build npm run build
``` ```
Содержимое папки `dist/` нужно задеплоить на GitHub Pages (корень сайта — `dist`, base path уже настроен как `/iQR/`). Результат в папке `dist/`.
### Структура ### Структура
``` ```
src/ src/
App.vue — главный компонент App.vue
config/ — загрузка настроек
composables/ composables/
useTheme.js — тёмная/светлая тема useTheme.js
useQrCode.js — генерация, скачивание, масштаб useQrCode.js
assets/style.css — стили useAppConfig.js
public/img/ — иконки, курсоры, favicon assets/style.css
public/img/
docker/ — nginx и entrypoint
``` ```
<div style="text-align: center"> <div style="text-align: center">
+12
View File
@@ -0,0 +1,12 @@
services:
iqr:
build: .
ports:
- "8080:80"
environment:
APP_TITLE: "Генератор QR-кодов"
BANNER_BRAND: "iQR"
BANNER_SLOGAN: "— qr-код без излишеств"
BANNER_URL: "https://github.com/IliaMiheev/iQR"
ACCENT_COLOR: "#ff4500"
restart: unless-stopped
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
APP_TITLE="${APP_TITLE:-Генератор QR-кодов}"
BANNER_BRAND="${BANNER_BRAND:-iQR}"
BANNER_SLOGAN="${BANNER_SLOGAN:-— qr-код без излишеств}"
BANNER_URL="${BANNER_URL:-https://github.com/IliaMiheev/iQR}"
ACCENT_COLOR="${ACCENT_COLOR:-#ff4500}"
cat > /usr/share/nginx/html/config.json <<EOF
{
"title": "$(printf '%s' "$APP_TITLE" | sed 's/"/\\"/g')",
"bannerBrand": "$(printf '%s' "$BANNER_BRAND" | sed 's/"/\\"/g')",
"bannerSlogan": "$(printf '%s' "$BANNER_SLOGAN" | sed 's/"/\\"/g')",
"bannerUrl": "$(printf '%s' "$BANNER_URL" | sed 's/"/\\"/g')",
"accentColor": "$(printf '%s' "$ACCENT_COLOR" | sed 's/"/\\"/g')"
}
EOF
exec nginx -g 'daemon off;'
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /config.json {
add_header Cache-Control "no-store";
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="revisit-after" content="8 days"> <meta name="revisit-after" content="8 days">
<meta name="author" content="Ilia Miheev"> <meta name="author" content="Ilia Miheev">
<meta name="theme-color" content="orangered"> <meta name="theme-color" content="#ff4500">
<meta property="og:title" content="Генератор QR кодов"> <meta property="og:title" content="Генератор QR кодов">
<meta property="og:description" content="Простой в использовании генератор QR кодов. Сделано программистом с любовью к пользователям"> <meta property="og:description" content="Простой в использовании генератор QR кодов. Сделано программистом с любовью к пользователям">
+8 -5
View File
@@ -2,7 +2,9 @@
import { computed, onMounted, onUnmounted, watch } from 'vue' import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useTheme } from './composables/useTheme' import { useTheme } from './composables/useTheme'
import { useQrCode } from './composables/useQrCode' import { useQrCode } from './composables/useQrCode'
import { useAppConfig } from './composables/useAppConfig'
const config = useAppConfig()
const { isLightTheme, toggleTheme } = useTheme() const { isLightTheme, toggleTheme } = useTheme()
const { const {
@@ -51,7 +53,7 @@ onUnmounted(() => {
<main class="app-shell"> <main class="app-shell">
<div class="app-card"> <div class="app-card">
<header class="logo"> <header class="logo">
<h1>Генератор QR-кодов</h1> <h1>{{ config.title }}</h1>
<a <a
href="https://github.com/IliaMiheev/iQR" href="https://github.com/IliaMiheev/iQR"
target="_blank" target="_blank"
@@ -105,17 +107,18 @@ onUnmounted(() => {
<p v-if="error" :key="error" class="status" :class="statusClass">{{ error }}</p> <p v-if="error" :key="error" class="status" :class="statusClass">{{ error }}</p>
</Transition> </Transition>
</div> </div>
</div> </div>
<a <a
href="https://github.com/IliaMiheev/iQR" :href="config.bannerUrl"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="site-banner" class="site-banner"
title="Репозиторий на GitHub" :title="config.bannerBrand"
> >
<span class="site-banner__brand">iQR</span> <span class="site-banner__brand">{{ config.bannerBrand }}</span>
<span class="site-banner__slogan"> qr-код без излишеств</span> <span class="site-banner__slogan">{{ config.bannerSlogan }}</span>
</a> </a>
</main> </main>
+14 -14
View File
@@ -1,5 +1,5 @@
::selection { ::selection {
background-color: orangered; background-color: var(--accent);
color: white; color: white;
} }
@@ -10,7 +10,6 @@ html {
html.dark { html.dark {
--bg: #1a1a1e; --bg: #1a1a1e;
--bg-accent: rgba(255, 69, 0, 0.06);
--surface: rgba(38, 38, 42, 0.72); --surface: rgba(38, 38, 42, 0.72);
--surface-hover: rgba(52, 52, 58, 0.88); --surface-hover: rgba(52, 52, 58, 0.88);
--surface-solid: rgba(30, 30, 34, 0.85); --surface-solid: rgba(30, 30, 34, 0.85);
@@ -20,20 +19,23 @@ html.dark {
--placeholder: rgba(255, 255, 255, 0.35); --placeholder: rgba(255, 255, 255, 0.35);
--shadow: 0 8px 32px rgba(0, 0, 0, 0.35); --shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.25); --shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.25);
--accent: orangered; --accent: #ff4500;
--accent-glow: rgba(255, 69, 0, 0.35); --accent-glow: color-mix(in srgb, var(--accent) 35%, transparent);
--bg-accent: color-mix(in srgb, var(--accent) 6%, transparent);
--canvas-bg: #fff; --canvas-bg: #fff;
--canvas-border: rgba(255, 255, 255, 0.15); --canvas-border: rgba(255, 255, 255, 0.15);
background-color: var(--bg); background-color: var(--bg);
background-image: background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, var(--bg-accent), transparent), radial-gradient(ellipse 80% 50% at 50% -10%, var(--bg-accent), transparent),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(255, 69, 0, 0.04), transparent); radial-gradient(ellipse 60% 40% at 100% 100%, color-mix(in srgb, var(--accent) 4%, transparent), transparent);
} }
html.light { html.light {
--bg: #f4f4f6; --bg: #f4f4f6;
--bg-accent: rgba(255, 69, 0, 0.08); --accent: #ff4500;
--accent-glow: color-mix(in srgb, var(--accent) 25%, transparent);
--bg-accent: color-mix(in srgb, var(--accent) 8%, transparent);
--surface: rgba(255, 255, 255, 0.78); --surface: rgba(255, 255, 255, 0.78);
--surface-hover: rgba(255, 255, 255, 0.95); --surface-hover: rgba(255, 255, 255, 0.95);
--surface-solid: rgba(255, 255, 255, 0.92); --surface-solid: rgba(255, 255, 255, 0.92);
@@ -43,15 +45,13 @@ html.light {
--placeholder: rgba(0, 0, 0, 0.35); --placeholder: rgba(0, 0, 0, 0.35);
--shadow: 0 8px 32px rgba(0, 0, 0, 0.1); --shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
--shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.06); --shadow-sm: 0 4px 16px rgba(0, 0, 0, 0.06);
--accent: orangered;
--accent-glow: rgba(255, 69, 0, 0.25);
--canvas-bg: #fff; --canvas-bg: #fff;
--canvas-border: rgba(0, 0, 0, 0.1); --canvas-border: rgba(0, 0, 0, 0.1);
background-color: var(--bg); background-color: var(--bg);
background-image: background-image:
radial-gradient(ellipse 80% 50% at 50% -10%, var(--bg-accent), transparent), radial-gradient(ellipse 80% 50% at 50% -10%, var(--bg-accent), transparent),
radial-gradient(ellipse 60% 40% at 0% 100%, rgba(255, 69, 0, 0.05), transparent); radial-gradient(ellipse 60% 40% at 0% 100%, color-mix(in srgb, var(--accent) 5%, transparent), transparent);
} }
body { body {
@@ -186,7 +186,7 @@ textarea:focus {
} }
textarea::selection { textarea::selection {
background-color: orangered; background-color: var(--accent);
color: white; color: white;
} }
@@ -301,14 +301,14 @@ button {
} }
.btn:hover { .btn:hover {
background-color: #ff5722; background-color: color-mix(in srgb, var(--accent) 88%, white);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 6px 20px var(--accent-glow); box-shadow: 0 6px 20px var(--accent-glow);
} }
.btn:active { .btn:active {
transform: translateY(0); transform: translateY(0);
background-color: rgb(200, 55, 0); background-color: color-mix(in srgb, var(--accent) 80%, black);
} }
.btn:disabled { .btn:disabled {
@@ -349,7 +349,7 @@ button {
} }
button::selection { button::selection {
background-color: rgb(172, 46, 0); background-color: color-mix(in srgb, var(--accent) 70%, black);
} }
.status-slot { .status-slot {
@@ -705,7 +705,7 @@ html.light .site-banner:hover {
.site-banner__brand { .site-banner__brand {
font-weight: 700; font-weight: 700;
color: orangered; color: var(--accent);
} }
.site-banner__slogan { .site-banner__slogan {
+11
View File
@@ -0,0 +1,11 @@
import { inject } from 'vue'
export function useAppConfig() {
const config = inject('appConfig')
if (!config) {
throw new Error('appConfig не найден. Проверьте инициализацию в main.js.')
}
return config
}
+12
View File
@@ -0,0 +1,12 @@
export function applyAppConfig(config) {
const root = document.documentElement
root.style.setProperty('--accent', config.accentColor)
const themeMeta = document.querySelector('meta[name="theme-color"]')
if (themeMeta) {
themeMeta.setAttribute('content', config.accentColor)
}
document.title = config.title
}
+7
View File
@@ -0,0 +1,7 @@
export const defaultConfig = {
title: 'Генератор QR-кодов',
bannerBrand: 'iQR',
bannerSlogan: '— qr-код без излишеств',
bannerUrl: 'https://github.com/IliaMiheev/iQR',
accentColor: '#ff4500',
}
+48
View File
@@ -0,0 +1,48 @@
import { defaultConfig } from './defaults'
function fromBuildEnv() {
const env = import.meta.env
return pickDefined({
title: env.VITE_APP_TITLE,
bannerBrand: env.VITE_BANNER_BRAND,
bannerSlogan: env.VITE_BANNER_SLOGAN,
bannerUrl: env.VITE_BANNER_URL,
accentColor: env.VITE_ACCENT_COLOR,
})
}
function pickDefined(source) {
return Object.fromEntries(
Object.entries(source).filter(([, value]) => value !== undefined && value !== ''),
)
}
export async function loadConfig() {
let runtime = {}
try {
const response = await fetch(`${import.meta.env.BASE_URL}config.json`, {
cache: 'no-store',
})
if (response.ok) {
runtime = await response.json()
}
} catch {
// Локально config.json может отсутствовать — это нормально.
}
const config = {
...defaultConfig,
...fromBuildEnv(),
}
for (const key of Object.keys(defaultConfig)) {
if (key in runtime) {
config[key] = runtime[key]
}
}
return config
}
+12 -1
View File
@@ -1,5 +1,16 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './assets/style.css' import './assets/style.css'
import { loadConfig } from './config/loadConfig'
import { applyAppConfig } from './config/applyTheme'
createApp(App).mount('#app') async function bootstrap() {
const config = await loadConfig()
applyAppConfig(config)
const app = createApp(App)
app.provide('appConfig', config)
app.mount('#app')
}
bootstrap()