Сделал копирование, почистил репо, перешёл на версию 1.0.0
This commit is contained in:
@@ -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
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
irandom:
|
||||
build: .
|
||||
image: irandom:latest
|
||||
image: irandom:1.0.0
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
|
||||
+1
-1
@@ -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 |
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user