Сделал копирование, почистил репо, перешёл на версию 1.0.0

This commit is contained in:
Ilia Miheev
2026-08-02 21:19:21 +03:00
parent 54b912c900
commit d475ed775b
15 changed files with 277 additions and 64 deletions
+14 -43
View File
@@ -1,5 +1,10 @@
# irandom
Сервис генерации случайных значений: числа из диапазона, выбор из списка, орёл/решка.
Vue 3 + Vite + Element Plus. Тема и брендинг настраиваются через env / Docker.
**Версия:** `1.0.0`
## Setup
```
npm install
@@ -9,18 +14,10 @@ npm install
```
npm run serve
```
or
```
npm run dev
```
### Production build
```
npm run build
```
### Preview production build
```
npm run preview
```
@@ -29,43 +26,17 @@ npm run preview
npm run lint
```
### Customization
Copy `.env.example` to `.env` and set branding / theme variables.
`npm run serve` / `npm run build` regenerates `public/config.js` automatically.
### Docker
Build and run (config via environment, same keys as `.env.example`):
## Customization
Copy `.env.example` to `.env` and set `IRANDOM_*` variables
(name, title, logo URL, colors, default locale).
`npm run serve` / `npm run build` regenerates `public/config.js`.
## Docker
```
docker compose up --build
```
→ http://localhost:8080
Open http://localhost:8080
Change branding without rebuilding the image — only restart with new env:
```
docker compose up -d
```
Or:
```
docker build -t irandom .
docker run --rm -p 8080:80 ^
-e IRANDOM_NAME=LuckyPenguin ^
-e IRANDOM_COLOR_PRIMARY=#3DDC97 ^
irandom
```
### Offline (PWA)
Production build registers a service worker and caches the app shell.
After opening the site once online:
```
npm run build
npm run preview
```
you can reload without network — generators keep working.
`config.js` uses NetworkFirst (fresh when online, cached when offline).
## Offline (PWA)
After one online visit to a production build (`build` + `preview` or Docker),
the app shell works offline. Runtime branding updates title, theme-color, icons and manifest.
+1 -1
View File
@@ -1,7 +1,7 @@
services:
irandom:
build: .
image: irandom:latest
image: irandom:1.0.0
ports:
- "8080:80"
environment:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "irandom",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"generate-config": "node scripts/generate-config.js",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+48
View File
@@ -0,0 +1,48 @@
<template>
<div class="generator-actions">
<el-button type="primary" native-type="submit" size="large">
<el-icon class="btn-icon"><RefreshRight /></el-icon>
{{ t('common.generate') }}
</el-button>
<el-button size="large" :disabled="!hasResult" @click.prevent="$emit('copy')">
<el-icon class="btn-icon"><DocumentCopy /></el-icon>
{{ t('common.copy') }}
</el-button>
</div>
</template>
<script>
import { RefreshRight, DocumentCopy } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
export default {
name: 'GeneratorActions',
components: {
RefreshRight,
DocumentCopy
},
props: {
hasResult: {
type: Boolean,
default: false
}
},
emits: ['copy'],
setup() {
const { t } = useI18n()
return { t }
}
}
</script>
<style scoped lang="scss">
.generator-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.btn-icon {
margin-right: 0.35rem;
}
</style>
+27 -4
View File
@@ -4,9 +4,7 @@
<el-input-number v-model="count" :min="1" :max="100" :controls="true" />
</el-form-item>
<p class="hint">{{ t('coin.hint') }}</p>
<el-button type="primary" native-type="submit" size="large">
{{ t('coin.generate') }}
</el-button>
<GeneratorActions :has-result="hasResult" @copy="onCopy" />
</el-form>
</template>
@@ -15,6 +13,8 @@ import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { flipCoin } from '@/utils/random'
import { loadFormState, saveFormState } from '@/utils/persistForm'
import { copyText, formatResultForCopy } from '@/utils/copyResult'
import GeneratorActions from '@/components/GeneratorActions.vue'
const STORAGE_KEY = 'coin'
const DEFAULTS = {
@@ -23,6 +23,13 @@ const DEFAULTS = {
export default {
name: 'CoinGenerator',
components: { GeneratorActions },
props: {
result: {
type: [Number, String, Array],
default: null
}
},
emits: ['result'],
setup() {
const { t } = useI18n()
@@ -31,6 +38,14 @@ export default {
data() {
return loadFormState(STORAGE_KEY, DEFAULTS)
},
computed: {
hasResult() {
if (this.result === null || this.result === undefined || this.result === '') {
return false
}
return Array.isArray(this.result) ? this.result.length > 0 : true
}
},
watch: {
count: 'persist'
},
@@ -47,6 +62,14 @@ export default {
}
const flips = flipCoin(this.count).map((side) => this.t(`coin.${side}`))
this.$emit('result', this.count === 1 ? flips[0] : flips)
},
async onCopy() {
try {
await copyText(formatResultForCopy(this.result))
ElMessage.success(this.t('common.copied'))
} catch {
ElMessage.error(this.t('common.copyFailed'))
}
}
}
}
@@ -54,7 +77,7 @@ export default {
<style scoped lang="scss">
.generator-form {
max-width: 420px;
max-width: 480px;
}
.hint {
+24 -3
View File
@@ -14,9 +14,7 @@
<el-form-item>
<el-checkbox v-model="unique">{{ t('list.unique') }}</el-checkbox>
</el-form-item>
<el-button type="primary" native-type="submit" size="large">
{{ t('list.generate') }}
</el-button>
<GeneratorActions :has-result="hasResult" @copy="onCopy" />
</el-form>
</template>
@@ -25,6 +23,8 @@ import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { generateFromList } from '@/utils/random'
import { loadFormState, saveFormState } from '@/utils/persistForm'
import { copyText, formatResultForCopy } from '@/utils/copyResult'
import GeneratorActions from '@/components/GeneratorActions.vue'
const STORAGE_KEY = 'list'
const DEFAULTS = {
@@ -35,6 +35,13 @@ const DEFAULTS = {
export default {
name: 'ListGenerator',
components: { GeneratorActions },
props: {
result: {
type: [Number, String, Array],
default: null
}
},
emits: ['result'],
setup() {
const { t } = useI18n()
@@ -49,6 +56,12 @@ export default {
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
},
hasResult() {
if (this.result === null || this.result === undefined || this.result === '') {
return false
}
return Array.isArray(this.result) ? this.result.length > 0 : true
}
},
watch: {
@@ -84,6 +97,14 @@ export default {
: 'list.errCount'
ElMessage.error(this.t(key))
}
},
async onCopy() {
try {
await copyText(formatResultForCopy(this.result))
ElMessage.success(this.t('common.copied'))
} catch {
ElMessage.error(this.t('common.copyFailed'))
}
}
}
}
+27 -4
View File
@@ -12,9 +12,7 @@
<el-form-item>
<el-checkbox v-model="unique">{{ t('range.unique') }}</el-checkbox>
</el-form-item>
<el-button type="primary" native-type="submit" size="large">
{{ t('range.generate') }}
</el-button>
<GeneratorActions :has-result="hasResult" @copy="onCopy" />
</el-form>
</template>
@@ -23,6 +21,8 @@ import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { generateRange } from '@/utils/random'
import { loadFormState, saveFormState } from '@/utils/persistForm'
import { copyText, formatResultForCopy } from '@/utils/copyResult'
import GeneratorActions from '@/components/GeneratorActions.vue'
const STORAGE_KEY = 'range'
const DEFAULTS = {
@@ -34,6 +34,13 @@ const DEFAULTS = {
export default {
name: 'RangeGenerator',
components: { GeneratorActions },
props: {
result: {
type: [Number, String, Array],
default: null
}
},
emits: ['result'],
setup() {
const { t } = useI18n()
@@ -42,6 +49,14 @@ export default {
data() {
return loadFormState(STORAGE_KEY, DEFAULTS)
},
computed: {
hasResult() {
if (this.result === null || this.result === undefined || this.result === '') {
return false
}
return Array.isArray(this.result) ? this.result.length > 0 : true
}
},
watch: {
min: 'persist',
max: 'persist',
@@ -72,6 +87,14 @@ export default {
const key = err.code === 'RANGE_UNIQUE_TOO_LARGE' ? 'range.errUniqueRange' : 'range.errCount'
ElMessage.error(this.t(key))
}
},
async onCopy() {
try {
await copyText(formatResultForCopy(this.result))
ElMessage.success(this.t('common.copied'))
} catch {
ElMessage.error(this.t('common.copyFailed'))
}
}
}
}
@@ -79,7 +102,7 @@ export default {
<style scoped lang="scss">
.generator-form {
max-width: 420px;
max-width: 480px;
}
:deep(.el-input-number) {
+83
View File
@@ -0,0 +1,83 @@
import { getAppConfig } from './appConfig'
function setMeta(name, content) {
if (!content) {
return
}
let el = document.querySelector(`meta[name="${name}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute('name', name)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
function setLink(rel, href) {
if (!href) {
return
}
let el = document.querySelector(`link[rel="${rel}"]`)
if (!el) {
el = document.createElement('link')
el.setAttribute('rel', rel)
document.head.appendChild(el)
}
el.setAttribute('href', href)
}
function applyDynamicManifest(config) {
const manifest = {
name: config.name,
short_name: config.name,
description: config.title || config.name,
theme_color: config.colors.bg,
background_color: config.colors.bg,
display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
lang: config.defaultLocale || 'ru',
icons: [
{
src: config.logoUrl,
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: config.logoUrl,
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
}
const blob = new Blob([JSON.stringify(manifest)], {
type: 'application/manifest+json'
})
const url = URL.createObjectURL(blob)
let link = document.querySelector('link[rel="manifest"]')
if (!link) {
link = document.createElement('link')
link.rel = 'manifest'
document.head.appendChild(link)
} else if (link.dataset.irandomBlob) {
URL.revokeObjectURL(link.href)
}
link.href = url
link.dataset.irandomBlob = '1'
}
/**
* Sync document title, theme-color, icons and web manifest with runtime config.
*/
export function applyBrandingMeta(config = getAppConfig()) {
document.title = config.title || config.name
setMeta('theme-color', config.colors.bg)
setMeta('description', config.title || config.name)
setLink('icon', config.logoUrl)
setLink('apple-touch-icon', config.logoUrl)
applyDynamicManifest(config)
}
+6 -3
View File
@@ -12,6 +12,12 @@ export default {
ru: 'Русский',
en: 'English'
},
common: {
generate: 'Generate',
copy: 'Copy',
copied: 'Copied',
copyFailed: 'Failed to copy'
},
range: {
title: 'Numbers from a range',
description: 'Generate several random integers within a chosen range.',
@@ -19,7 +25,6 @@ export default {
max: 'Maximum',
count: 'Count',
unique: 'Unique values only',
generate: 'Generate',
errMinMax: 'Minimum cannot be greater than maximum',
errCount: 'Count must be at least 1',
errUniqueRange: 'Unique count cannot exceed the size of the range'
@@ -31,7 +36,6 @@ export default {
placeholder: 'apple\nbanana\ncherry',
count: 'Count',
unique: 'Unique values only',
generate: 'Generate',
errEmpty: 'The list is empty — add at least one value',
errCount: 'Count must be at least 1',
errUniqueList: 'Unique count cannot exceed the list length'
@@ -41,7 +45,6 @@ export default {
description: 'Flip a coin once or several times.',
flips: 'Number of flips',
hint: 'Each flip is either heads or tails.',
generate: 'Generate',
errCount: 'Count must be at least 1',
heads: 'heads',
tails: 'tails'
+6 -3
View File
@@ -12,6 +12,12 @@ export default {
ru: 'Русский',
en: 'English'
},
common: {
generate: 'Сгенерировать',
copy: 'Копировать',
copied: 'Скопировано',
copyFailed: 'Не удалось скопировать'
},
range: {
title: 'Числа из диапазона',
description: 'Сгенерируйте несколько случайных чисел в заданном диапазоне.',
@@ -19,7 +25,6 @@ export default {
max: 'Максимум',
count: 'Количество',
unique: 'Только уникальные значения',
generate: 'Сгенерировать',
errMinMax: 'Минимум не может быть больше максимума',
errCount: 'Количество должно быть не меньше 1',
errUniqueRange: 'Количество уникальных значений не может превышать размер диапазона'
@@ -31,7 +36,6 @@ export default {
placeholder: 'яблоко\nбанан\nвишня',
count: 'Количество',
unique: 'Только уникальные значения',
generate: 'Сгенерировать',
errEmpty: 'Список пуст — добавьте хотя бы одно значение',
errCount: 'Количество должно быть не меньше 1',
errUniqueList: 'Количество уникальных значений не может превышать длину списка'
@@ -41,7 +45,6 @@ export default {
description: 'Подбросьте монетку один или несколько раз.',
flips: 'Количество бросков',
hint: 'Каждый бросок даёт «орёл» или «решку».',
generate: 'Сгенерировать',
errCount: 'Количество должно быть не меньше 1',
heads: 'орёл',
tails: 'решка'
+2 -1
View File
@@ -7,12 +7,13 @@ import router from './router'
import i18n from './i18n'
import { getAppConfig } from './config/appConfig'
import { applyTheme } from './config/applyTheme'
import { applyBrandingMeta } from './config/applyBrandingMeta'
import { registerSW } from 'virtual:pwa-register'
registerSW({ immediate: true })
const appConfig = getAppConfig()
applyTheme(appConfig)
document.title = appConfig.title
applyBrandingMeta(appConfig)
createApp(App).use(router).use(i18n).use(ElementPlus).mount('#app')
+32
View File
@@ -0,0 +1,32 @@
export function formatResultForCopy(result) {
if (result === null || result === undefined) {
return ''
}
if (Array.isArray(result)) {
return result.join(', ')
}
return String(result)
}
export async function copyText(text) {
if (!text) {
throw new Error('EMPTY')
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
const area = document.createElement('textarea')
area.value = text
area.setAttribute('readonly', '')
area.style.position = 'fixed'
area.style.opacity = '0'
document.body.appendChild(area)
area.select()
const ok = document.execCommand('copy')
document.body.removeChild(area)
if (!ok) {
throw new Error('COPY_FAILED')
}
}
+5 -1
View File
@@ -3,7 +3,11 @@
<h1 class="page-title">{{ title }}</h1>
<p class="page-desc">{{ description }}</p>
<component :is="generatorComponent" @result="onResult" />
<component
:is="generatorComponent"
:result="result"
@result="onResult"
/>
<ResultDisplay :result="result" />
</div>
</template>
+1
View File
@@ -38,6 +38,7 @@ export default defineConfig({
}
]
},
// Runtime branding (name / theme / logo) is applied in applyBrandingMeta.js
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}'],
navigateFallback: '/index.html',