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:
Ivan Fontosh
2026-07-02 00:21:22 +08:00
parent dd052f1585
commit f20c7e2f63
16 changed files with 1562 additions and 252 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "ttrpg-player-publisher", "name": "ttrpg-player-publisher",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0",
"description": "One-click TTRPG Player release publisher (Mac files in folder → build Win/Linux → upload)", "description": "TTRPG Player release publisher and license manager",
"type": "module", "type": "module",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
+16 -168
View File
@@ -4,177 +4,25 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TTRPG Player Publisher</title> <title>TTRPG Player Publisher</title>
<style> <link rel="stylesheet" href="./styles/app.css" />
: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>
</head> </head>
<body> <body>
<div class="wrap"> <div class="app-shell">
<h1>TTRPG Player Publisher</h1> <aside class="sidebar">
<p class="subtitle"> <div class="brand">
Положите Mac-файлы в папку релиза, нажмите кнопку — приложение заберёт версию из git, <h1>TTRPG Player Publisher</h1>
соберёт Windows и Linux и загрузит всё на сервер. <p>Релизы и лицензии</p>
</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> </div>
<button id="publishBtn" class="publish" type="button">Опубликовать новую версию</button> <nav class="nav">
<div class="progress-wrap"> <a href="#/publish">Публикация</a>
<div id="progressLabel" class="progress-label">Готов к публикации</div> <a href="#/licenses">Лицензии</a>
<div class="progress-track"> <a href="#/generate">Новый ключ</a>
<div id="progressBar" class="progress-bar"></div> <a href="#/stats">Статистика</a>
</div> <a href="#/settings">Настройки</a>
<div id="percent" class="percent">0%</div> </nav>
</div> </aside>
<div id="result" class="result"></div> <main class="main" id="page-root"></main>
<div id="log" class="log"></div>
</div>
</div> </div>
<script type="module" src="./renderer.js"></script> <script type="module" src="./renderer/app.mjs"></script>
</body> </body>
</html> </html>
+78
View File
@@ -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);
}
+74
View File
@@ -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}`;
}
+24
View File
@@ -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
View File
@@ -3,6 +3,14 @@ import { fileURLToPath } from 'node:url';
import { app, BrowserWindow, ipcMain } from 'electron'; 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 { loadConfig, readProjectVersion } from './release/config.mjs';
import { runFullRelease } from './release/runRelease.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() { function createWindow() {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 720, width: 960,
height: 520, height: 640,
minWidth: 560, minWidth: 720,
minHeight: 420, minHeight: 480,
backgroundColor: '#0f1419', backgroundColor: '#0f1419',
title: 'TTRPG Player Publisher', title: 'TTRPG Player Publisher',
webPreferences: { webPreferences: {
@@ -63,6 +79,55 @@ app.whenReady().then(() => {
return { ok: false, error: message }; 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', () => { app.on('window-all-closed', () => {
+27
View File
@@ -1,5 +1,12 @@
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
function unwrapLicense(result) {
if (!result?.ok) {
throw new Error(result?.error ?? 'Неизвестная ошибка');
}
return result;
}
contextBridge.exposeInMainWorld('publisher', { contextBridge.exposeInMainWorld('publisher', {
getInfo: () => ipcRenderer.invoke('publisher.getInfo'), getInfo: () => ipcRenderer.invoke('publisher.getInfo'),
publish: () => ipcRenderer.invoke('publisher.publish'), publish: () => ipcRenderer.invoke('publisher.publish'),
@@ -8,3 +15,23 @@ contextBridge.exposeInMainWorld('publisher', {
ipcRenderer.on('publisher.progress', (_e, payload) => cb(payload)); 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;
},
});
-78
View File
@@ -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();
+14
View File
@@ -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();
+246
View File
@@ -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();
}
+144
View File
@@ -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();
};
}
+110
View File
@@ -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(() => {});
};
}
+67
View File
@@ -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, '&quot;')}" 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();
}
+111
View File
@@ -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();
}
+73
View File
@@ -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();
}
+507
View File
@@ -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;
}