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 <cursoragent@cursor.com>
This commit is contained in:
+2
-2
@@ -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": {
|
||||
|
||||
+16
-168
@@ -4,177 +4,25 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TTRPG Player Publisher</title>
|
||||
<style>
|
||||
: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;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 32px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0 0 24px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
.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.publish {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: 1px solid rgba(124, 156, 255, 0.45);
|
||||
background: var(--accent-bg);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button.publish:hover:not(:disabled) {
|
||||
background: rgba(124, 156, 255, 0.28);
|
||||
}
|
||||
button.publish:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="./styles/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>TTRPG Player Publisher</h1>
|
||||
<p class="subtitle">
|
||||
Положите Mac-файлы в папку релиза, нажмите кнопку — приложение заберёт версию из git,
|
||||
соберёт Windows и Linux и загрузит всё на сервер.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="meta">
|
||||
<div><strong>Папка релиза (Mac-файлы):</strong></div>
|
||||
<div id="releaseDir" class="path">—</div>
|
||||
<div style="margin-top: 10px"><strong>Версия в git:</strong> <span id="version">—</span></div>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<h1>TTRPG Player Publisher</h1>
|
||||
<p>Релизы и лицензии</p>
|
||||
</div>
|
||||
<button id="publishBtn" class="publish" type="button">Опубликовать новую версию</button>
|
||||
<div class="progress-wrap">
|
||||
<div id="progressLabel" class="progress-label">Готов к публикации</div>
|
||||
<div class="progress-track">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
<div id="percent" class="percent">0%</div>
|
||||
</div>
|
||||
<div id="result" class="result"></div>
|
||||
<div id="log" class="log"></div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a href="#/publish">Публикация</a>
|
||||
<a href="#/licenses">Лицензии</a>
|
||||
<a href="#/generate">Новый ключ</a>
|
||||
<a href="#/stats">Статистика</a>
|
||||
<a href="#/settings">Настройки</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="main" id="page-root"></main>
|
||||
</div>
|
||||
<script type="module" src="./renderer.js"></script>
|
||||
<script type="module" src="./renderer/app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>} 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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
+69
-4
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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'
|
||||
? `
|
||||
<div class="field">
|
||||
<label for="validDays">Период (дней)</label>
|
||||
<input id="validDays" type="text" inputmode="numeric" value="${validDays}" />
|
||||
<div class="hint">Отсчёт начинается при первой активации в TTRPG Player</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Тарифы</label>
|
||||
<div class="chips" id="tierChips">
|
||||
${LICENSE_TIER_PRESETS.map(
|
||||
(p) =>
|
||||
`<button type="button" class="chip${
|
||||
validDays === String(p.validDays) && maxDevices === String(p.maxDevices) ? ' active' : ''
|
||||
}" data-days="${p.validDays}" data-devices="${p.maxDevices}">${p.label}</button>`,
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div class="field">
|
||||
<label for="expiryDate">Срок действия</label>
|
||||
<input id="expiryDate" type="date" value="${expiryDate}" />
|
||||
<div class="hint">Дата фиксируется при создании ключа (старый формат)</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
root.innerHTML = `
|
||||
<h2 class="page-title">Новый ключ</h2>
|
||||
<p class="page-subtitle">
|
||||
Ключ будет добавлен в data.json на сервере. Передайте его пользователю для активации в TTRPG Player.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label for="pid">Product ID (pid)</label>
|
||||
<input id="pid" type="text" value="${pid}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="maxDevices">Макс. устройств</label>
|
||||
<input id="maxDevices" type="text" inputmode="numeric" value="${maxDevices}" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="count">Количество ключей</label>
|
||||
<input id="count" type="text" inputmode="numeric" value="${count}" />
|
||||
<div class="hint">От 1 до 100</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Тип срока</label>
|
||||
<div class="segmented" id="expiryMode">
|
||||
<button type="button" class="${expiryMode === 'period' ? 'active' : ''}" data-mode="period">Период (дней)</button>
|
||||
<button type="button" class="${expiryMode === 'fixed' ? 'active' : ''}" data-mode="fixed">Фикс. дата</button>
|
||||
</div>
|
||||
</div>
|
||||
${periodFields}
|
||||
<div id="genError" class="result err" style="display:none"></div>
|
||||
<button id="generateBtn" class="primary" type="button" style="width:100%" ${submitting ? 'disabled' : ''}>
|
||||
${submitting ? 'Создание…' : 'Сгенерировать'}
|
||||
</button>
|
||||
<div id="createdResult"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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) => `
|
||||
<div class="created-key-item">
|
||||
<div>${k.key}</div>
|
||||
<div class="license-detail">sub: ${k.sub} · ${formatCreatedKeyExpiry(k)}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="card created-keys" style="margin-top:16px;background:rgba(110,231,160,0.08);border-color:rgba(110,231,160,0.35)">
|
||||
<strong>Создано ключей: ${keys.length}</strong>
|
||||
${items}
|
||||
<button type="button" class="secondary" id="copyKeys" style="margin-top:12px">Копировать все ключи</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<string, unknown>} */
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { formatLicenseExpiry } from '../../license/models.mjs';
|
||||
|
||||
/** @param {HTMLElement} root */
|
||||
export function renderLicenseListPage(root) {
|
||||
root.innerHTML = `
|
||||
<h2 class="page-title">Лицензии</h2>
|
||||
<p class="page-subtitle">Список продуктовых ключей на сервере (новые сверху).</p>
|
||||
<div class="toolbar">
|
||||
<button id="refreshBtn" class="secondary" type="button">Обновить</button>
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
<div id="listError" class="result err" style="display:none"></div>
|
||||
<div id="listContent"></div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="modal">
|
||||
<h2>Удалить лицензию?</h2>
|
||||
<p>Ключ <strong>${confirmDelete.key}</strong> будет удалён с сервера безвозвратно.</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" id="cancelDelete">Отмена</button>
|
||||
<button type="button" class="secondary danger" id="confirmDelete">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 = '<div class="empty">Лицензий пока нет</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = licenses
|
||||
.map((lic) => {
|
||||
const devices = lic.activatedDevices?.length
|
||||
? `<div class="license-detail">deviceId: ${lic.activatedDevices.join(', ')}</div>`
|
||||
: '';
|
||||
const deleteBtn = !lic.revoked
|
||||
? `<div class="license-actions"><button type="button" class="secondary danger" data-delete="${lic.key}">Удалить</button></div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="license-card${lic.revoked ? ' revoked' : ''}">
|
||||
<div class="license-key">${lic.key}</div>
|
||||
<div class="license-detail">sub: ${lic.sub}</div>
|
||||
<div class="license-detail">pid: ${lic.pid}</div>
|
||||
<div class="license-detail">Устройства: ${lic.activatedCount} / ${lic.maxDevices}</div>
|
||||
<div class="license-detail">Срок: ${formatLicenseExpiry(lic)}</div>
|
||||
<div class="license-detail">Статус: ${lic.revoked ? 'Отозвана' : 'Активна'}</div>
|
||||
${devices}
|
||||
${deleteBtn}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
$listContent.innerHTML = `<div class="license-list">${html}</div>`;
|
||||
|
||||
$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 = '<div class="loading">Загрузка…</div>';
|
||||
$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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
let running = false;
|
||||
|
||||
/** @param {HTMLElement} root */
|
||||
export function renderPublishPage(root) {
|
||||
root.innerHTML = `
|
||||
<h2 class="page-title">Публикация</h2>
|
||||
<p class="page-subtitle">
|
||||
Положите Mac-файлы в папку релиза, нажмите кнопку — приложение заберёт версию из git,
|
||||
соберёт Windows и Linux и загрузит всё на сервер.
|
||||
</p>
|
||||
<div class="card">
|
||||
<div class="meta">
|
||||
<div><strong>Папка релиза (Mac-файлы):</strong></div>
|
||||
<div id="releaseDir" class="path">—</div>
|
||||
<div style="margin-top: 10px"><strong>Версия в git:</strong> <span id="version">—</span></div>
|
||||
</div>
|
||||
<button id="publishBtn" class="publish" type="button">Опубликовать новую версию</button>
|
||||
<div class="progress-wrap">
|
||||
<div id="progressLabel" class="progress-label">Готов к публикации</div>
|
||||
<div class="progress-track">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
<div id="percent" class="percent">0%</div>
|
||||
</div>
|
||||
<div id="result" class="result"></div>
|
||||
<div id="log" class="log"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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(() => {});
|
||||
};
|
||||
}
|
||||
@@ -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 = `
|
||||
<h2 class="page-title">Настройки</h2>
|
||||
<p class="page-subtitle">Сервер: ${SERVER_URL}</p>
|
||||
<div class="card">
|
||||
<p class="meta">
|
||||
Токен администратора (LICENSE_ADMIN_TOKEN) — см. инструкцию в README или спросите у администратора сервера.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="adminToken">Admin token</label>
|
||||
<input id="adminToken" type="password" value="${adminToken.replace(/"/g, '"')}" autocomplete="off" />
|
||||
</div>
|
||||
${savedMessage ? `<div class="saved-msg">${savedMessage}</div>` : ''}
|
||||
<div id="settingsError" class="result err" style="display:none"></div>
|
||||
<button id="saveSettings" class="primary" type="button" style="width:100%">Сохранить</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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) => `<option value="${m}"${m === month ? ' selected' : ''}>${m}</option>`)
|
||||
.join('')
|
||||
: `<option value="${month}" selected>${month}</option>`;
|
||||
|
||||
const statsHtml = loading
|
||||
? '<div class="loading">Загрузка…</div>'
|
||||
: statsData
|
||||
? `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box">
|
||||
<div class="value">${statsData.windows ?? 0}</div>
|
||||
<div class="label">Windows</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">${statsData.macos ?? 0}</div>
|
||||
<div class="label">macOS</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="value">${statsData.linux ?? 0}</div>
|
||||
<div class="label">Linux</div>
|
||||
</div>
|
||||
<div class="stat-box stat-total">
|
||||
<div class="value">${statsData.total ?? 0}</div>
|
||||
<div class="label">Всего за ${statsData.month ?? month}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
root.innerHTML = `
|
||||
<h2 class="page-title">Статистика загрузок</h2>
|
||||
<p class="page-subtitle">Количество скачиваний установщиков по платформам за месяц.</p>
|
||||
<div class="toolbar">
|
||||
<div class="field" style="margin:0;min-width:160px">
|
||||
<label for="monthSelect">Месяц</label>
|
||||
<select id="monthSelect">${monthOptions}</select>
|
||||
</div>
|
||||
<button id="refreshStats" class="secondary" type="button" ${loading ? 'disabled' : ''}>Обновить</button>
|
||||
</div>
|
||||
<div id="statsError" class="result err" style="display:none"></div>
|
||||
<div id="statsContent">${statsHtml}</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user