Добавил докер и касторизацию через env
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
docker/entrypoint.sh text eol=lf
|
||||||
@@ -2,5 +2,6 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
__pycache__
|
__pycache__
|
||||||
ideas.md
|
ideas.md
|
||||||
|
|||||||
+21
@@ -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"]
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;'
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const defaultConfig = {
|
||||||
|
title: 'Генератор QR-кодов',
|
||||||
|
bannerBrand: 'iQR',
|
||||||
|
bannerSlogan: '— qr-код без излишеств',
|
||||||
|
bannerUrl: 'https://github.com/IliaMiheev/iQR',
|
||||||
|
accentColor: '#ff4500',
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user