From f20c7e2f6327a71114d826a05dadf4ac91fe450a Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Thu, 2 Jul 2026 00:21:22 +0800 Subject: [PATCH] Turn Publisher into multi-page app with license manager v1.1.0. Hash router with publish, licenses, generate, stats, and settings pages backed by license server admin API. Co-authored-by: Cursor --- package.json | 4 +- src/index.html | 184 +---------- src/license/api.mjs | 78 +++++ src/license/models.mjs | 74 +++++ src/license/settings.mjs | 24 ++ src/main.js | 73 ++++- src/preload.cjs | 27 ++ src/renderer.js | 78 ----- src/renderer/app.mjs | 14 + src/renderer/pages/generateKey.mjs | 246 ++++++++++++++ src/renderer/pages/licenseList.mjs | 144 ++++++++ src/renderer/pages/publish.mjs | 110 +++++++ src/renderer/pages/settings.mjs | 67 ++++ src/renderer/pages/stats.mjs | 111 +++++++ src/renderer/router.mjs | 73 +++++ src/styles/app.css | 507 +++++++++++++++++++++++++++++ 16 files changed, 1562 insertions(+), 252 deletions(-) create mode 100644 src/license/api.mjs create mode 100644 src/license/models.mjs create mode 100644 src/license/settings.mjs delete mode 100644 src/renderer.js create mode 100644 src/renderer/app.mjs create mode 100644 src/renderer/pages/generateKey.mjs create mode 100644 src/renderer/pages/licenseList.mjs create mode 100644 src/renderer/pages/publish.mjs create mode 100644 src/renderer/pages/settings.mjs create mode 100644 src/renderer/pages/stats.mjs create mode 100644 src/renderer/router.mjs create mode 100644 src/styles/app.css diff --git a/package.json b/package.json index 8139e8a..83ec13c 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "ttrpg-player-publisher", "private": true, - "version": "1.0.0", - "description": "One-click TTRPG Player release publisher (Mac files in folder → build Win/Linux → upload)", + "version": "1.1.0", + "description": "TTRPG Player release publisher and license manager", "type": "module", "main": "src/main.js", "scripts": { diff --git a/src/index.html b/src/index.html index a2092da..ee67722 100644 --- a/src/index.html +++ b/src/index.html @@ -4,177 +4,25 @@ TTRPG Player Publisher - + -
-

TTRPG Player Publisher

-

- Положите Mac-файлы в папку релиза, нажмите кнопку — приложение заберёт версию из git, - соберёт Windows и Linux и загрузит всё на сервер. -

-
-
-
Папка релиза (Mac-файлы):
-
-
Версия в git:
+
+
+ + +
- + diff --git a/src/license/api.mjs b/src/license/api.mjs new file mode 100644 index 0000000..6e71b07 --- /dev/null +++ b/src/license/api.mjs @@ -0,0 +1,78 @@ +import { SERVER_URL } from './models.mjs'; + +export class LicenseApiError extends Error { + /** @param {string} message @param {number} [status] */ + constructor(message, status = 0) { + super(message); + this.name = 'LicenseApiError'; + this.status = status; + } +} + +/** @param {string} adminToken @param {string} apiPath @param {RequestInit} [options] */ +async function request(adminToken, apiPath, options = {}) { + if (!adminToken?.trim()) { + throw new LicenseApiError('Укажите admin token в настройках'); + } + + const url = new URL(apiPath.replace(/^\//, ''), SERVER_URL); + const res = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${adminToken.trim()}`, + 'Content-Type': 'application/json', + ...(options.headers ?? {}), + }, + }); + + const text = await res.text(); + /** @type {Record | null} */ + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = { raw: text }; + } + } + + if (!res.ok) { + const msg = String(body?.error ?? body?.message ?? `HTTP ${res.status}`); + throw new LicenseApiError(msg, res.status); + } + + return body; +} + +/** @param {string} adminToken */ +export async function listLicenses(adminToken) { + const data = await request(adminToken, 'v1/admin/licenses'); + return /** @type {import('./models.mjs').LicenseEntry[]} */ (data?.licenses ?? []); +} + +/** + * @param {string} adminToken + * @param {Record} body + */ +export async function createProductKeys(adminToken, body) { + return request(adminToken, 'v1/admin/product-keys', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +/** @param {string} adminToken @param {string} key */ +export async function deleteProductKey(adminToken, key) { + return request(adminToken, 'v1/admin/product-keys/delete', { + method: 'POST', + body: JSON.stringify({ key }), + }); +} + +/** @param {string} adminToken @param {string | null} [month] YYYY-MM */ +export async function getDownloadStats(adminToken, month) { + const path = month + ? `v1/admin/stats/downloads?month=${encodeURIComponent(month)}` + : 'v1/admin/stats/downloads'; + return request(adminToken, path); +} diff --git a/src/license/models.mjs b/src/license/models.mjs new file mode 100644 index 0000000..4a2ef5a --- /dev/null +++ b/src/license/models.mjs @@ -0,0 +1,74 @@ +/** @typedef {Object} LicenseEntry + * @property {string} key + * @property {string} sub + * @property {string} pid + * @property {number} maxDevices + * @property {string | null} [expiryMode] + * @property {number | null} [expiresAtSec] + * @property {number | null} [validDays] + * @property {number | null} [activatedAtSec] + * @property {number | null} [licenseExpSec] + * @property {number | null} [effectiveExpSec] + * @property {boolean} revoked + * @property {string[]} activatedDevices + * @property {number} activatedCount + */ + +/** @typedef {Object} ProductKeyResponse + * @property {string} key + * @property {string} sub + * @property {string} pid + * @property {number} maxDevices + * @property {number | null} [expiresAtSec] + * @property {number | null} [validDays] + */ + +/** @typedef {Object} LicenseTierPreset + * @property {string} label + * @property {number} validDays + * @property {number} maxDevices + */ + +export const SERVER_URL = 'https://license.ttrpgplayer.ru/'; + +/** @type {LicenseTierPreset[]} */ +export const LICENSE_TIER_PRESETS = [ + { label: 'Basic (1 год)', validDays: 365, maxDevices: 3 }, + { label: 'Master (2 года)', validDays: 730, maxDevices: 5 }, + { label: 'Legend (~99 лет)', validDays: 36_135, maxDevices: 15 }, +]; + +/** @param {number} sec */ +export function formatEpochSec(sec) { + return new Date(sec * 1000).toISOString().slice(0, 10); +} + +/** @param {LicenseEntry} license */ +export function formatLicenseExpiry(license) { + const mode = license.expiryMode ?? (license.validDays != null ? 'period' : 'fixed'); + if (mode === 'period') { + const days = license.validDays; + if (days != null && license.licenseExpSec != null) { + return `до ${formatEpochSec(license.licenseExpSec)} (${days} дн.)`; + } + if (days != null) return `${days} дн. · не активирован`; + return '—'; + } + const sec = license.effectiveExpSec ?? license.expiresAtSec; + return sec != null ? formatEpochSec(sec) : '—'; +} + +/** @param {ProductKeyResponse} created */ +export function formatCreatedKeyExpiry(created) { + if (created.validDays != null) return `${created.validDays} дн. с момента активации`; + if (created.expiresAtSec != null) return `до ${formatEpochSec(created.expiresAtSec)}`; + return '—'; +} + +/** @returns {string} YYYY-MM */ +export function currentMonthKey() { + const now = new Date(); + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, '0'); + return `${y}-${m}`; +} diff --git a/src/license/settings.mjs b/src/license/settings.mjs new file mode 100644 index 0000000..2e9e2d0 --- /dev/null +++ b/src/license/settings.mjs @@ -0,0 +1,24 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const FILE_NAME = 'license-settings.json'; + +/** @typedef {{ adminToken: string }} LicenseSettings */ + +/** @param {string} userDataDir @returns {LicenseSettings} */ +export function loadLicenseSettings(userDataDir) { + const filePath = path.join(userDataDir, FILE_NAME); + if (!fs.existsSync(filePath)) { + return { adminToken: '' }; + } + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); + return { adminToken: String(raw.adminToken ?? '').trim() }; +} + +/** @param {string} userDataDir @param {LicenseSettings} settings */ +export function saveLicenseSettings(userDataDir, settings) { + fs.mkdirSync(userDataDir, { recursive: true }); + const filePath = path.join(userDataDir, FILE_NAME); + const data = { adminToken: String(settings.adminToken ?? '').trim() }; + fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +} diff --git a/src/main.js b/src/main.js index b15285e..a0aab7d 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,14 @@ import { fileURLToPath } from 'node:url'; import { app, BrowserWindow, ipcMain } from 'electron'; +import { + createProductKeys, + deleteProductKey, + getDownloadStats, + listLicenses, + LicenseApiError, +} from './license/api.mjs'; +import { loadLicenseSettings, saveLicenseSettings } from './license/settings.mjs'; import { loadConfig, readProjectVersion } from './release/config.mjs'; import { runFullRelease } from './release/runRelease.mjs'; @@ -17,12 +25,20 @@ function send(channel, payload) { } } +function licenseError(err) { + if (err instanceof LicenseApiError) { + return { ok: false, error: err.message, status: err.status }; + } + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: message }; +} + function createWindow() { mainWindow = new BrowserWindow({ - width: 720, - height: 520, - minWidth: 560, - minHeight: 420, + width: 960, + height: 640, + minWidth: 720, + minHeight: 480, backgroundColor: '#0f1419', title: 'TTRPG Player Publisher', webPreferences: { @@ -63,6 +79,55 @@ app.whenReady().then(() => { return { ok: false, error: message }; } }); + + ipcMain.handle('license.getSettings', async () => { + return loadLicenseSettings(app.getPath('userData')); + }); + + ipcMain.handle('license.saveSettings', async (_e, settings) => { + saveLicenseSettings(app.getPath('userData'), settings ?? {}); + return { ok: true }; + }); + + ipcMain.handle('license.listLicenses', async () => { + try { + const { adminToken } = loadLicenseSettings(app.getPath('userData')); + const licenses = await listLicenses(adminToken); + return { ok: true, licenses }; + } catch (err) { + return licenseError(err); + } + }); + + ipcMain.handle('license.createProductKeys', async (_e, body) => { + try { + const { adminToken } = loadLicenseSettings(app.getPath('userData')); + const result = await createProductKeys(adminToken, body ?? {}); + return { ok: true, result }; + } catch (err) { + return licenseError(err); + } + }); + + ipcMain.handle('license.deleteProductKey', async (_e, key) => { + try { + const { adminToken } = loadLicenseSettings(app.getPath('userData')); + await deleteProductKey(adminToken, String(key ?? '')); + return { ok: true }; + } catch (err) { + return licenseError(err); + } + }); + + ipcMain.handle('license.getDownloadStats', async (_e, month) => { + try { + const { adminToken } = loadLicenseSettings(app.getPath('userData')); + const stats = await getDownloadStats(adminToken, month ?? null); + return { ok: true, stats }; + } catch (err) { + return licenseError(err); + } + }); }); app.on('window-all-closed', () => { diff --git a/src/preload.cjs b/src/preload.cjs index 39c67a8..d5ffb7c 100644 --- a/src/preload.cjs +++ b/src/preload.cjs @@ -1,5 +1,12 @@ const { contextBridge, ipcRenderer } = require('electron'); +function unwrapLicense(result) { + if (!result?.ok) { + throw new Error(result?.error ?? 'Неизвестная ошибка'); + } + return result; +} + contextBridge.exposeInMainWorld('publisher', { getInfo: () => ipcRenderer.invoke('publisher.getInfo'), publish: () => ipcRenderer.invoke('publisher.publish'), @@ -8,3 +15,23 @@ contextBridge.exposeInMainWorld('publisher', { ipcRenderer.on('publisher.progress', (_e, payload) => cb(payload)); }, }); + +contextBridge.exposeInMainWorld('license', { + getSettings: () => ipcRenderer.invoke('license.getSettings'), + saveSettings: (settings) => ipcRenderer.invoke('license.saveSettings', settings), + listLicenses: async () => { + const res = unwrapLicense(await ipcRenderer.invoke('license.listLicenses')); + return res.licenses ?? []; + }, + createProductKeys: async (body) => { + const res = unwrapLicense(await ipcRenderer.invoke('license.createProductKeys', body)); + return res.result; + }, + deleteProductKey: async (key) => { + unwrapLicense(await ipcRenderer.invoke('license.deleteProductKey', key)); + }, + getDownloadStats: async (month) => { + const res = unwrapLicense(await ipcRenderer.invoke('license.getDownloadStats', month)); + return res.stats; + }, +}); diff --git a/src/renderer.js b/src/renderer.js deleted file mode 100644 index 7b42bb5..0000000 --- a/src/renderer.js +++ /dev/null @@ -1,78 +0,0 @@ -const $publishBtn = document.getElementById('publishBtn'); -const $releaseDir = document.getElementById('releaseDir'); -const $version = document.getElementById('version'); -const $progressLabel = document.getElementById('progressLabel'); -const $progressBar = document.getElementById('progressBar'); -const $percent = document.getElementById('percent'); -const $result = document.getElementById('result'); -const $log = document.getElementById('log'); - -let running = false; - -function setProgress(percent, label) { - const p = Math.max(0, Math.min(100, percent)); - $progressBar.style.width = `${p}%`; - $percent.textContent = `${p}%`; - if (label) $progressLabel.textContent = label; -} - -function showResult(ok, text) { - $result.className = `result ${ok ? 'ok' : 'err'}`; - $result.textContent = text; -} - -function appendLog(line) { - if (!line) return; - $log.classList.add('visible'); - $log.textContent += `${line}\n`; - $log.scrollTop = $log.scrollHeight; -} - -async function loadInfo() { - if (!window.publisher) { - showResult(false, 'Ошибка: preload не подключился. Перезапустите приложение.'); - $publishBtn.disabled = true; - return; - } - const info = await window.publisher.getInfo(); - $releaseDir.textContent = info.config.releaseDir; - if (info.version) { - $version.textContent = `v${info.version}`; - } else { - $version.textContent = info.versionError ?? 'не удалось прочитать'; - } -} - -window.publisher?.onProgress((ev) => { - setProgress(ev.percent ?? 0, ev.label ?? ''); - if (ev.logLine) appendLog(ev.logLine); -}); - -$publishBtn.addEventListener('click', async () => { - if (running || !window.publisher) return; - running = true; - $publishBtn.disabled = true; - $result.className = 'result'; - $result.textContent = ''; - $log.textContent = ''; - $log.classList.remove('visible'); - setProgress(0, 'Запуск…'); - - const res = await window.publisher.publish(); - running = false; - $publishBtn.disabled = false; - - if (res.ok) { - setProgress(100, 'Готово!'); - showResult( - true, - `Версия v${res.version} успешно опубликована на updates.ttrpgplayer.ru`, - ); - void loadInfo(); - } else { - setProgress(0, 'Ошибка'); - showResult(false, res.error ?? 'Неизвестная ошибка'); - } -}); - -void loadInfo(); diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs new file mode 100644 index 0000000..bfe5025 --- /dev/null +++ b/src/renderer/app.mjs @@ -0,0 +1,14 @@ +import { registerRoute, startRouter } from './router.mjs'; +import { renderPublishPage } from './pages/publish.mjs'; +import { renderLicenseListPage } from './pages/licenseList.mjs'; +import { renderGenerateKeyPage } from './pages/generateKey.mjs'; +import { renderStatsPage } from './pages/stats.mjs'; +import { renderSettingsPage } from './pages/settings.mjs'; + +registerRoute({ path: '/publish', title: 'Публикация', render: renderPublishPage }); +registerRoute({ path: '/licenses', title: 'Лицензии', render: renderLicenseListPage }); +registerRoute({ path: '/generate', title: 'Новый ключ', render: renderGenerateKeyPage }); +registerRoute({ path: '/stats', title: 'Статистика', render: renderStatsPage }); +registerRoute({ path: '/settings', title: 'Настройки', render: renderSettingsPage }); + +startRouter(); diff --git a/src/renderer/pages/generateKey.mjs b/src/renderer/pages/generateKey.mjs new file mode 100644 index 0000000..df4cb24 --- /dev/null +++ b/src/renderer/pages/generateKey.mjs @@ -0,0 +1,246 @@ +import { + LICENSE_TIER_PRESETS, + formatCreatedKeyExpiry, +} from '../../license/models.mjs'; + +/** @param {HTMLElement} root */ +export function renderGenerateKeyPage(root) { + let expiryMode = 'period'; + let pid = 'dnd_player'; + let maxDevices = '3'; + let validDays = '365'; + let expiryDate = '2027-12-31'; + let count = '1'; + let submitting = false; + /** @type {unknown} */ + let lastCreated = null; + /** @type {string} */ + let lastError = ''; + + function render() { + const periodFields = + expiryMode === 'period' + ? ` +
+ + +
Отсчёт начинается при первой активации в TTRPG Player
+
+
+ +
+ ${LICENSE_TIER_PRESETS.map( + (p) => + ``, + ).join('')} +
+
+ ` + : ` +
+ + +
Дата фиксируется при создании ключа (старый формат)
+
+ `; + + root.innerHTML = ` +

Новый ключ

+

+ Ключ будет добавлен в data.json на сервере. Передайте его пользователю для активации в TTRPG Player. +

+
+
+ + +
+
+ + +
+
+ + +
От 1 до 100
+
+
+ +
+ + +
+
+ ${periodFields} + + +
+
+ `; + + bindEvents(); + if (lastError) showError(lastError); + if (lastCreated) showCreated(lastCreated); + } + + /** @param {string} msg */ + function showError(msg) { + const el = root.querySelector('#genError'); + if (!el) return; + if (!msg) { + el.style.display = 'none'; + el.textContent = ''; + return; + } + el.style.display = 'block'; + el.textContent = msg; + } + + /** @param {unknown} result */ + function showCreated(result) { + const el = root.querySelector('#createdResult'); + if (!el) return; + + /** @type {import('../../license/models.mjs').ProductKeyResponse[]} */ + let keys = []; + if (result && typeof result === 'object' && 'keys' in result && Array.isArray(result.keys)) { + keys = result.keys; + } else if (result && typeof result === 'object' && 'key' in result) { + keys = [/** @type {import('../../license/models.mjs').ProductKeyResponse} */ (result)]; + } + + if (keys.length === 0) { + el.innerHTML = ''; + return; + } + + const items = keys + .map( + (k) => ` +
+
${k.key}
+
sub: ${k.sub} · ${formatCreatedKeyExpiry(k)}
+
+ `, + ) + .join(''); + + el.innerHTML = ` +
+ Создано ключей: ${keys.length} + ${items} + +
+ `; + + el.querySelector('#copyKeys')?.addEventListener('click', () => { + const text = keys.map((k) => k.key).join('\n'); + void navigator.clipboard.writeText(text); + }); + } + + function bindEvents() { + root.querySelector('#pid')?.addEventListener('input', (e) => { + pid = /** @type {HTMLInputElement} */ (e.target).value; + }); + root.querySelector('#maxDevices')?.addEventListener('input', (e) => { + maxDevices = /** @type {HTMLInputElement} */ (e.target).value.replace(/\D/g, '').slice(0, 2); + /** @type {HTMLInputElement} */ (e.target).value = maxDevices; + }); + root.querySelector('#count')?.addEventListener('input', (e) => { + count = /** @type {HTMLInputElement} */ (e.target).value.replace(/\D/g, '').slice(0, 3); + /** @type {HTMLInputElement} */ (e.target).value = count; + }); + root.querySelector('#validDays')?.addEventListener('input', (e) => { + validDays = /** @type {HTMLInputElement} */ (e.target).value.replace(/\D/g, '').slice(0, 5); + /** @type {HTMLInputElement} */ (e.target).value = validDays; + }); + root.querySelector('#expiryDate')?.addEventListener('change', (e) => { + expiryDate = /** @type {HTMLInputElement} */ (e.target).value; + }); + + root.querySelectorAll('#expiryMode button').forEach((btn) => { + btn.addEventListener('click', () => { + expiryMode = btn.getAttribute('data-mode') ?? 'period'; + render(); + }); + }); + + root.querySelectorAll('#tierChips .chip').forEach((chip) => { + chip.addEventListener('click', () => { + validDays = chip.getAttribute('data-days') ?? validDays; + maxDevices = chip.getAttribute('data-devices') ?? maxDevices; + expiryMode = 'period'; + render(); + }); + }); + + root.querySelector('#generateBtn')?.addEventListener('click', () => void submit()); + } + + async function submit() { + lastError = ''; + const devices = Number(maxDevices); + const keyCount = Number(count); + if (!Number.isInteger(devices) || devices < 1) { + lastError = 'Укажите число устройств (минимум 1)'; + render(); + return; + } + if (!pid.trim()) { + lastError = 'Укажите product id'; + render(); + return; + } + if (!Number.isInteger(keyCount) || keyCount < 1 || keyCount > 100) { + lastError = 'Количество ключей: от 1 до 100'; + render(); + return; + } + + /** @type {Record} */ + const body = { + pid: pid.trim(), + maxDevices: devices, + count: keyCount, + }; + + if (expiryMode === 'period') { + const days = Number(validDays); + if (!Number.isInteger(days) || days < 1) { + lastError = 'Укажите период в днях (минимум 1)'; + render(); + return; + } + body.validDays = days; + } else { + const [y, m, d] = expiryDate.split('-').map(Number); + const end = Date.UTC(y, m - 1, d + 1) / 1000 - 1; + body.expiresAtSec = Math.floor(end); + } + + if (!window.license) { + lastError = 'Ошибка: preload не подключился.'; + render(); + return; + } + + submitting = true; + lastCreated = null; + lastError = ''; + render(); + try { + lastCreated = await window.license.createProductKeys(body); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } finally { + submitting = false; + render(); + } + } + + render(); +} diff --git a/src/renderer/pages/licenseList.mjs b/src/renderer/pages/licenseList.mjs new file mode 100644 index 0000000..a992a03 --- /dev/null +++ b/src/renderer/pages/licenseList.mjs @@ -0,0 +1,144 @@ +import { formatLicenseExpiry } from '../../license/models.mjs'; + +/** @param {HTMLElement} root */ +export function renderLicenseListPage(root) { + root.innerHTML = ` +

Лицензии

+

Список продуктовых ключей на сервере (новые сверху).

+
+ + +
+ +
+ `; + + const $refreshBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#refreshBtn')); + const $listError = /** @type {HTMLElement} */ (root.querySelector('#listError')); + const $listContent = /** @type {HTMLElement} */ (root.querySelector('#listContent')); + + /** @type {import('../../license/models.mjs').LicenseEntry | null} */ + let confirmDelete = null; + + function showError(msg) { + if (!msg) { + $listError.style.display = 'none'; + $listError.textContent = ''; + return; + } + $listError.style.display = 'block'; + $listError.textContent = msg; + } + + function renderModal() { + const existing = document.getElementById('delete-modal'); + if (existing) existing.remove(); + if (!confirmDelete) return; + + const backdrop = document.createElement('div'); + backdrop.id = 'delete-modal'; + backdrop.className = 'modal-backdrop'; + backdrop.innerHTML = ` + + `; + document.body.appendChild(backdrop); + + backdrop.querySelector('#cancelDelete')?.addEventListener('click', () => { + confirmDelete = null; + backdrop.remove(); + }); + + backdrop.querySelector('#confirmDelete')?.addEventListener('click', async () => { + const key = confirmDelete?.key; + if (!key || !window.license) return; + backdrop.remove(); + confirmDelete = null; + $refreshBtn.disabled = true; + try { + await window.license.deleteProductKey(key); + await loadLicenses(); + } catch (err) { + showError(err instanceof Error ? err.message : String(err)); + } finally { + $refreshBtn.disabled = false; + } + }); + } + + /** @param {import('../../license/models.mjs').LicenseEntry[]} licenses */ + function renderList(licenses) { + if (licenses.length === 0) { + $listContent.innerHTML = '
Лицензий пока нет
'; + return; + } + + const html = licenses + .map((lic) => { + const devices = lic.activatedDevices?.length + ? `
deviceId: ${lic.activatedDevices.join(', ')}
` + : ''; + const deleteBtn = !lic.revoked + ? `
` + : ''; + return ` +
+
${lic.key}
+
sub: ${lic.sub}
+
pid: ${lic.pid}
+
Устройства: ${lic.activatedCount} / ${lic.maxDevices}
+
Срок: ${formatLicenseExpiry(lic)}
+
Статус: ${lic.revoked ? 'Отозвана' : 'Активна'}
+ ${devices} + ${deleteBtn} +
+ `; + }) + .join(''); + + $listContent.innerHTML = `
${html}
`; + + $listContent.querySelectorAll('[data-delete]').forEach((btn) => { + btn.addEventListener('click', () => { + const key = btn.getAttribute('data-delete'); + const lic = licenses.find((l) => l.key === key); + if (lic) { + confirmDelete = lic; + renderModal(); + } + }); + }); + } + + async function loadLicenses() { + if (!window.license) { + showError('Ошибка: preload не подключился.'); + return; + } + showError(''); + $listContent.innerHTML = '
Загрузка…
'; + $refreshBtn.disabled = true; + try { + const licenses = await window.license.listLicenses(); + renderList(licenses); + } catch (err) { + $listContent.innerHTML = ''; + showError(err instanceof Error ? err.message : String(err)); + } finally { + $refreshBtn.disabled = false; + } + } + + $refreshBtn.addEventListener('click', () => void loadLicenses()); + void loadLicenses(); + + return () => { + document.getElementById('delete-modal')?.remove(); + }; +} diff --git a/src/renderer/pages/publish.mjs b/src/renderer/pages/publish.mjs new file mode 100644 index 0000000..eb6b48a --- /dev/null +++ b/src/renderer/pages/publish.mjs @@ -0,0 +1,110 @@ +let running = false; + +/** @param {HTMLElement} root */ +export function renderPublishPage(root) { + root.innerHTML = ` +

Публикация

+

+ Положите Mac-файлы в папку релиза, нажмите кнопку — приложение заберёт версию из git, + соберёт Windows и Linux и загрузит всё на сервер. +

+
+
+
Папка релиза (Mac-файлы):
+
+
Версия в git:
+
+ +
+
Готов к публикации
+
+
+
+
0%
+
+
+
+
+ `; + + const $publishBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#publishBtn')); + const $releaseDir = root.querySelector('#releaseDir'); + const $version = root.querySelector('#version'); + const $progressLabel = root.querySelector('#progressLabel'); + const $progressBar = /** @type {HTMLElement} */ (root.querySelector('#progressBar')); + const $percent = root.querySelector('#percent'); + const $result = /** @type {HTMLElement} */ (root.querySelector('#result')); + const $log = /** @type {HTMLElement} */ (root.querySelector('#log')); + + /** @param {number} percent @param {string} [label] */ + function setProgress(percent, label) { + const p = Math.max(0, Math.min(100, percent)); + $progressBar.style.width = `${p}%`; + $percent.textContent = `${p}%`; + if (label) $progressLabel.textContent = label; + } + + /** @param {boolean} ok @param {string} text */ + function showResult(ok, text) { + $result.className = `result ${ok ? 'ok' : 'err'}`; + $result.textContent = text; + } + + /** @param {string} [line] */ + function appendLog(line) { + if (!line) return; + $log.classList.add('visible'); + $log.textContent += `${line}\n`; + $log.scrollTop = $log.scrollHeight; + } + + async function loadInfo() { + if (!window.publisher) { + showResult(false, 'Ошибка: preload не подключился. Перезапустите приложение.'); + $publishBtn.disabled = true; + return; + } + const info = await window.publisher.getInfo(); + if ($releaseDir) $releaseDir.textContent = info.config.releaseDir; + if ($version) { + $version.textContent = info.version ? `v${info.version}` : (info.versionError ?? 'не удалось прочитать'); + } + } + + const onProgress = (/** @type {{ percent?: number; label?: string; logLine?: string }} */ ev) => { + setProgress(ev.percent ?? 0, ev.label ?? ''); + if (ev.logLine) appendLog(ev.logLine); + }; + + window.publisher?.onProgress(onProgress); + + $publishBtn.addEventListener('click', async () => { + if (running || !window.publisher) return; + running = true; + $publishBtn.disabled = true; + $result.className = 'result'; + $result.textContent = ''; + $log.textContent = ''; + $log.classList.remove('visible'); + setProgress(0, 'Запуск…'); + + const res = await window.publisher.publish(); + running = false; + $publishBtn.disabled = false; + + if (res.ok) { + setProgress(100, 'Готово!'); + showResult(true, `Версия v${res.version} успешно опубликована на updates.ttrpgplayer.ru`); + void loadInfo(); + } else { + setProgress(0, 'Ошибка'); + showResult(false, res.error ?? 'Неизвестная ошибка'); + } + }); + + void loadInfo(); + + return () => { + window.publisher?.onProgress(() => {}); + }; +} diff --git a/src/renderer/pages/settings.mjs b/src/renderer/pages/settings.mjs new file mode 100644 index 0000000..0173dad --- /dev/null +++ b/src/renderer/pages/settings.mjs @@ -0,0 +1,67 @@ +import { SERVER_URL } from '../../license/models.mjs'; + +/** @param {HTMLElement} root */ +export function renderSettingsPage(root) { + let adminToken = ''; + let savedMessage = ''; + + async function load() { + if (!window.license) return; + const settings = await window.license.getSettings(); + adminToken = settings.adminToken ?? ''; + render(); + } + + function render() { + root.innerHTML = ` +

Настройки

+

Сервер: ${SERVER_URL}

+
+

+ Токен администратора (LICENSE_ADMIN_TOKEN) — см. инструкцию в README или спросите у администратора сервера. +

+
+ + +
+ ${savedMessage ? `
${savedMessage}
` : ''} + + +
+ `; + + root.querySelector('#adminToken')?.addEventListener('input', (e) => { + adminToken = /** @type {HTMLInputElement} */ (e.target).value; + savedMessage = ''; + }); + + root.querySelector('#saveSettings')?.addEventListener('click', () => void save()); + } + + async function save() { + const errEl = root.querySelector('#settingsError'); + if (!window.license) { + if (errEl) { + errEl.style.display = 'block'; + errEl.textContent = 'Ошибка: preload не подключился.'; + } + return; + } + try { + await window.license.saveSettings({ adminToken }); + savedMessage = 'Сохранено'; + if (errEl) { + errEl.style.display = 'none'; + errEl.textContent = ''; + } + render(); + } catch (err) { + if (errEl) { + errEl.style.display = 'block'; + errEl.textContent = err instanceof Error ? err.message : String(err); + } + } + } + + void load(); +} diff --git a/src/renderer/pages/stats.mjs b/src/renderer/pages/stats.mjs new file mode 100644 index 0000000..9f9cf2d --- /dev/null +++ b/src/renderer/pages/stats.mjs @@ -0,0 +1,111 @@ +import { currentMonthKey } from '../../license/models.mjs'; + +/** @param {HTMLElement} root */ +export function renderStatsPage(root) { + let month = currentMonthKey(); + /** @type {string[]} */ + let availableMonths = []; + /** @type {{ month?: string; windows?: number; macos?: number; linux?: number; total?: number } | null} */ + let statsData = null; + let loading = false; + + function render() { + const monthOptions = + availableMonths.length > 0 + ? availableMonths + .map((m) => ``) + .join('') + : ``; + + const statsHtml = loading + ? '
Загрузка…
' + : statsData + ? ` +
+
+
${statsData.windows ?? 0}
+
Windows
+
+
+
${statsData.macos ?? 0}
+
macOS
+
+
+
${statsData.linux ?? 0}
+
Linux
+
+
+
${statsData.total ?? 0}
+
Всего за ${statsData.month ?? month}
+
+
+ ` + : ''; + + root.innerHTML = ` +

Статистика загрузок

+

Количество скачиваний установщиков по платформам за месяц.

+
+
+ + +
+ +
+ +
${statsHtml}
+ `; + + root.querySelector('#monthSelect')?.addEventListener('change', (e) => { + month = /** @type {HTMLSelectElement} */ (e.target).value; + void loadStats(); + }); + root.querySelector('#refreshStats')?.addEventListener('click', () => void loadStats()); + } + + /** @param {string} msg */ + function showError(msg) { + const el = root.querySelector('#statsError'); + if (!el) return; + if (!msg) { + el.style.display = 'none'; + el.textContent = ''; + return; + } + el.style.display = 'block'; + el.textContent = msg; + } + + async function loadStats() { + if (!window.license) { + showError('Ошибка: preload не подключился.'); + return; + } + loading = true; + statsData = null; + showError(''); + render(); + + try { + const overview = await window.license.getDownloadStats(null); + if (Array.isArray(overview?.months)) { + availableMonths = overview.months; + if (!availableMonths.includes(month) && overview.currentMonth?.month) { + month = overview.currentMonth.month; + } + } + + statsData = await window.license.getDownloadStats(month); + } catch (err) { + statsData = null; + showError(err instanceof Error ? err.message : String(err)); + } finally { + loading = false; + render(); + if (statsData) showError(''); + } + } + + render(); + void loadStats(); +} diff --git a/src/renderer/router.mjs b/src/renderer/router.mjs new file mode 100644 index 0000000..77c4359 --- /dev/null +++ b/src/renderer/router.mjs @@ -0,0 +1,73 @@ +/** @typedef {{ path: string; title: string; render: (root: HTMLElement) => void | (() => void) }} Route */ + +/** @type {Route[]} */ +const routes = []; + +/** @type {(() => void) | null} */ +let cleanup = null; + +/** @param {Route} route */ +export function registerRoute(route) { + routes.push(route); +} + +/** @param {string} hashPath */ +export function navigate(hashPath) { + const path = hashPath.startsWith('#') ? hashPath : `#${hashPath}`; + if (location.hash !== path) { + location.hash = path; + } else { + renderCurrent(); + } +} + +function currentPath() { + const hash = location.hash.replace(/^#/, '') || '/publish'; + return hash.startsWith('/') ? hash : `/${hash}`; +} + +function findRoute() { + const path = currentPath(); + return routes.find((r) => r.path === path) ?? routes.find((r) => r.path === '/publish'); +} + +function updateNav() { + const path = currentPath(); + document.querySelectorAll('.nav a').forEach((a) => { + const href = a.getAttribute('href') ?? ''; + a.classList.toggle('active', href === `#${path}`); + }); +} + +function renderCurrent() { + const route = findRoute(); + const root = document.getElementById('page-root'); + if (!root || !route) return; + + if (cleanup) { + cleanup(); + cleanup = null; + } + + root.innerHTML = ''; + document.title = `${route.title} — TTRPG Player Publisher`; + updateNav(); + + const result = route.render(root); + if (typeof result === 'function') { + cleanup = result; + } +} + +export function startRouter() { + window.addEventListener('hashchange', renderCurrent); + if (!location.hash) { + location.hash = '#/publish'; + } else { + renderCurrent(); + } +} + +export function getCurrentPath() { + return currentPath(); +} diff --git a/src/styles/app.css b/src/styles/app.css new file mode 100644 index 0000000..5685967 --- /dev/null +++ b/src/styles/app.css @@ -0,0 +1,507 @@ +:root { + color-scheme: dark; + --bg: #0f1419; + --card: rgba(255, 255, 255, 0.04); + --border: rgba(255, 255, 255, 0.1); + --text: #e8eaed; + --muted: rgba(232, 234, 237, 0.72); + --accent: #7c9cff; + --accent-bg: rgba(124, 156, 255, 0.18); + --ok: #6ee7a0; + --err: #ff8a8a; + --warn-bg: rgba(255, 193, 7, 0.12); + --warn-border: rgba(255, 193, 7, 0.35); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.app-shell { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 200px; + flex-shrink: 0; + border-right: 1px solid var(--border); + background: rgba(0, 0, 0, 0.2); + padding: 20px 0; + display: flex; + flex-direction: column; +} + +.brand { + padding: 0 18px 20px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; +} + +.brand h1 { + margin: 0; + font-size: 15px; + font-weight: 650; + line-height: 1.3; +} + +.brand p { + margin: 4px 0 0; + font-size: 11px; + color: var(--muted); +} + +.nav { + display: flex; + flex-direction: column; + gap: 2px; + padding: 0 8px; +} + +.nav a { + display: block; + padding: 10px 12px; + border-radius: 8px; + color: var(--muted); + text-decoration: none; + font-size: 13px; + font-weight: 500; + transition: background 0.12s, color 0.12s; +} + +.nav a:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text); +} + +.nav a.active { + background: var(--accent-bg); + color: var(--text); + border: 1px solid rgba(124, 156, 255, 0.35); +} + +.main { + flex: 1; + overflow: auto; + padding: 24px 28px 32px; + max-width: 760px; +} + +.page-title { + margin: 0 0 6px; + font-size: 22px; + font-weight: 650; +} + +.page-subtitle { + margin: 0 0 22px; + color: var(--muted); + font-size: 14px; + line-height: 1.45; +} + +.card { + border: 1px solid var(--border); + background: var(--card); + border-radius: 14px; + padding: 20px; +} + +.card + .card { + margin-top: 12px; +} + +.meta { + font-size: 13px; + color: var(--muted); + margin-bottom: 18px; + line-height: 1.5; +} + +.meta strong { + color: var(--text); + font-weight: 600; +} + +.path { + font-family: Consolas, 'Courier New', monospace; + font-size: 12px; + word-break: break-all; +} + +button.primary, +button.publish { + appearance: none; + border: 1px solid rgba(124, 156, 255, 0.45); + background: var(--accent-bg); + color: var(--text); + font-size: 15px; + font-weight: 650; + padding: 12px 18px; + border-radius: 12px; + cursor: pointer; + transition: background 0.15s; +} + +button.primary:hover:not(:disabled), +button.publish:hover:not(:disabled) { + background: rgba(124, 156, 255, 0.28); +} + +button.primary:disabled, +button.publish:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +button.publish { + width: 100%; + font-size: 16px; + padding: 14px 18px; +} + +button.secondary { + appearance: none; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + font-size: 13px; + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; +} + +button.secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); +} + +button.danger { + border-color: rgba(255, 138, 138, 0.45); + color: var(--err); +} + +button.danger:hover:not(:disabled) { + background: rgba(255, 138, 138, 0.12); +} + +.progress-wrap { + margin-top: 20px; +} + +.progress-label { + font-size: 13px; + color: var(--muted); + margin-bottom: 8px; + min-height: 1.4em; +} + +.progress-track { + height: 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.progress-bar { + height: 100%; + width: 0%; + border-radius: 999px; + background: linear-gradient(90deg, #5b7cfa, #7c9cff); + transition: width 0.25s ease; +} + +.percent { + margin-top: 6px; + font-size: 12px; + color: var(--muted); + text-align: right; +} + +.result { + margin-top: 16px; + padding: 12px 14px; + border-radius: 10px; + font-size: 14px; + line-height: 1.45; + display: none; +} + +.result.ok { + display: block; + background: rgba(110, 231, 160, 0.12); + border: 1px solid rgba(110, 231, 160, 0.35); + color: var(--ok); +} + +.result.err { + display: block; + background: rgba(255, 138, 138, 0.1); + border: 1px solid rgba(255, 138, 138, 0.35); + color: var(--err); + white-space: pre-wrap; +} + +.log { + margin-top: 14px; + max-height: 140px; + overflow: auto; + font-family: Consolas, monospace; + font-size: 11px; + color: var(--muted); + background: rgba(0, 0, 0, 0.25); + border-radius: 8px; + padding: 8px 10px; + display: none; +} + +.log.visible { + display: block; +} + +.field { + margin-bottom: 14px; +} + +.field label { + display: block; + font-size: 13px; + color: var(--muted); + margin-bottom: 6px; +} + +.field input, +.field select { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(0, 0, 0, 0.25); + color: var(--text); + font-size: 14px; + font-family: inherit; +} + +.field input:focus, +.field select:focus { + outline: none; + border-color: rgba(124, 156, 255, 0.55); +} + +.field .hint { + margin-top: 4px; + font-size: 12px; + color: var(--muted); +} + +.segmented { + display: flex; + gap: 0; + margin-bottom: 14px; +} + +.segmented button { + flex: 1; + appearance: none; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + padding: 10px 12px; + font-size: 13px; + cursor: pointer; +} + +.segmented button:first-child { + border-radius: 8px 0 0 8px; +} + +.segmented button:last-child { + border-radius: 0 8px 8px 0; +} + +.segmented button + button { + border-left: none; +} + +.segmented button.active { + background: var(--accent-bg); + color: var(--text); + border-color: rgba(124, 156, 255, 0.45); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.chip { + appearance: none; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + cursor: pointer; +} + +.chip.active, +.chip:hover { + background: var(--accent-bg); + color: var(--text); + border-color: rgba(124, 156, 255, 0.45); +} + +.toolbar { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 16px; +} + +.toolbar .spacer { + flex: 1; +} + +.license-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.license-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + background: var(--card); +} + +.license-card.revoked { + border-color: rgba(255, 138, 138, 0.35); + background: rgba(255, 138, 138, 0.08); +} + +.license-key { + font-family: Consolas, 'Courier New', monospace; + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + word-break: break-all; +} + +.license-detail { + font-size: 13px; + color: var(--muted); + line-height: 1.5; +} + +.license-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; +} + +.empty, +.loading { + text-align: center; + color: var(--muted); + padding: 40px 20px; + font-size: 14px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.stat-box { + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + text-align: center; +} + +.stat-box .value { + font-size: 28px; + font-weight: 700; + color: var(--accent); +} + +.stat-box .label { + font-size: 12px; + color: var(--muted); + margin-top: 4px; +} + +.stat-total { + grid-column: 1 / -1; +} + +.created-keys { + margin-top: 16px; +} + +.created-key-item { + font-family: Consolas, monospace; + font-size: 13px; + padding: 8px 0; + border-bottom: 1px solid var(--border); + word-break: break-all; +} + +.created-key-item:last-child { + border-bottom: none; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal { + background: #1a2129; + border: 1px solid var(--border); + border-radius: 14px; + padding: 24px; + max-width: 420px; + width: calc(100% - 40px); +} + +.modal h2 { + margin: 0 0 10px; + font-size: 18px; +} + +.modal p { + margin: 0 0 20px; + color: var(--muted); + font-size: 14px; + line-height: 1.45; +} + +.modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.saved-msg { + color: var(--ok); + font-size: 13px; + margin-bottom: 10px; +}