feat: donation banner, About tab, settings UI improvements
Lint & Test / test (push) Has been cancelled

- Dismissible donation/open-source banner after 3+ sessions (30-day snooze)
- New About tab in Settings: version, repo link, license info
- Centralize project URLs (REPO_URL, DONATE_URL) in __init__.py, served via /health
- Center settings tab bar, reduce tab padding for 6-tab fit
- External URL save button: icon button instead of full-width text button
- Remove redundant settings footer close button
- Footer "Source Code" link replaced with "About" opening settings
- i18n keys for en/ru/zh
This commit is contained in:
2026-03-27 21:09:34 +03:00
parent f61a0206d4
commit f3d07fc47f
18 changed files with 442 additions and 49 deletions
@@ -158,6 +158,58 @@ h2 {
background: var(--border-color);
}
/* ── Donation banner ── */
.donation-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 6px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
color: var(--text-color);
font-size: 0.85rem;
animation: bannerSlideDown 0.3s var(--ease-out);
}
.donation-banner-text {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
}
.donation-banner-text .icon {
width: 14px;
height: 14px;
color: #e25555;
flex-shrink: 0;
}
.donation-banner-action {
padding: 4px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
}
.donation-banner-action:hover {
color: var(--primary-color);
background: var(--border-color);
}
.donation-banner-donate {
color: #e25555;
}
.donation-banner-donate:hover {
color: #ff6b6b;
background: rgba(226, 85, 85, 0.1);
}
@keyframes bannerSlideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
+101 -3
View File
@@ -352,21 +352,23 @@
.settings-tab-bar {
display: flex;
justify-content: center;
gap: 0;
border-bottom: 2px solid var(--border-color);
padding: 0 1.25rem;
padding: 0 0.75rem;
}
.settings-tab-btn {
background: none;
border: none;
padding: 8px 16px;
font-size: 0.9rem;
padding: 8px 12px;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
white-space: nowrap;
transition: color 0.2s ease, border-color 0.25s ease;
}
@@ -388,6 +390,102 @@
animation: tabFadeIn 0.25s ease-out;
}
/* ── About panel ──────────────────────────────────────────── */
.about-section {
text-align: center;
padding: 8px 0 4px;
}
.about-logo {
margin-bottom: 8px;
}
.about-logo .icon {
width: 36px;
height: 36px;
color: var(--primary-color);
}
.about-title {
margin: 0 0 2px;
font-size: 1.1rem;
color: var(--text-color);
}
.about-version {
display: inline-block;
margin-bottom: 8px;
padding: 2px 10px;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 0.8rem;
font-family: var(--font-mono, monospace);
}
.about-text {
margin: 0 0 12px;
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.4;
}
.about-license {
margin: 10px 0 0;
color: var(--text-secondary);
font-size: 0.8rem;
opacity: 0.7;
}
.about-links {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 280px;
margin: 0 auto;
}
.about-link {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-color);
text-decoration: none;
font-size: 0.9rem;
transition: border-color 0.15s, background 0.15s;
}
.about-link:hover {
border-color: var(--primary-color);
background: var(--bg-tertiary);
}
.about-link .icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.about-link .icon:last-child {
width: 14px;
height: 14px;
margin-left: auto;
color: var(--text-secondary);
}
.about-link span {
flex: 1;
text-align: left;
}
.about-link-donate .icon:first-child {
color: #e25555;
}
/* ── Log viewer overlay (full-screen) ──────────────────────── */
.log-overlay {
+13 -1
View File
@@ -8,7 +8,7 @@ import { Modal } from './core/modal.ts';
import { queryEl } from './core/dom-utils.ts';
// Layer 1: api, i18n
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor } from './core/api.ts';
import { loadServerInfo, loadDisplays, configureApiKey, startConnectionMonitor, stopConnectionMonitor, serverRepoUrl, serverDonateUrl } from './core/api.ts';
import { t, initLocale, changeLocale } from './core/i18n.ts';
// Layer 1.5: visual effects
@@ -205,6 +205,9 @@ import {
initUpdateSettingsPanel, applyUpdate,
openReleaseNotes, closeReleaseNotes,
} from './features/update.ts';
import {
initDonationBanner, dismissDonation, snoozeDonation, renderAboutPanel, setProjectUrls,
} from './features/donation.ts';
// ─── Register all HTML onclick / onchange / onfocus globals ───
@@ -576,6 +579,11 @@ Object.assign(window, {
openReleaseNotes,
closeReleaseNotes,
// donation
dismissDonation,
snoozeDonation,
renderAboutPanel,
// appearance
applyStylePreset,
applyBgEffect,
@@ -723,6 +731,10 @@ document.addEventListener('DOMContentLoaded', async () => {
initUpdateListener();
loadUpdateStatus();
// Show donation banner (after a few sessions)
setProjectUrls(serverRepoUrl, serverDonateUrl);
initDonationBanner();
// Show getting-started tutorial on first visit
if (!localStorage.getItem('tour_completed')) {
setTimeout(() => startGettingStartedTutorial(), 600);
@@ -236,6 +236,8 @@ function _setConnectionState(online: boolean) {
}
export let demoMode = false;
export let serverRepoUrl = '';
export let serverDonateUrl = '';
export async function loadServerInfo() {
try {
@@ -259,6 +261,10 @@ export async function loadServerInfo() {
setAuthRequired(authNeeded);
(window as any)._authRequired = authNeeded;
// Project URLs (repo, donate)
if (data.repo_url) serverRepoUrl = data.repo_url;
if (data.donate_url) serverDonateUrl = data.donate_url;
// Demo mode detection
if (data.demo_mode && !demoMode) {
demoMode = true;
@@ -87,3 +87,5 @@ export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
export const fileUp = '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>';
export const fileAudio = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><circle cx="10" cy="16" r="2"/><path d="M12 12v4"/>';
export const packageIcon = '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';
export const heart = '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>';
export const github = '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>';
@@ -189,6 +189,8 @@ export const ICON_X = _svg(P.xIcon);
export const ICON_FILE_UP = _svg(P.fileUp);
export const ICON_FILE_AUDIO = _svg(P.fileAudio);
export const ICON_ASSET = _svg(P.packageIcon);
export const ICON_HEART = _svg(P.heart);
export const ICON_GITHUB = _svg(P.github);
/** Asset type → icon (fallback: file) */
export function getAssetTypeIcon(assetType: string): string {
@@ -0,0 +1,143 @@
/**
* Donation banner — shows a dismissible open-source/donation notice
* after the user has had a few sessions with the app.
*/
import { t } from '../core/i18n.ts';
import { ICON_HEART, ICON_EXTERNAL_LINK, ICON_X, ICON_GITHUB } from '../core/icons.ts';
// ─── Config ─────────────────────────────────────────────────
/** URLs are set from the server /health response via setProjectUrls(). */
let _donateUrl = '';
let _repoUrl = '';
/** Minimum number of app opens before showing the banner. */
const MIN_SESSIONS = 3;
/** "Remind me later" snooze duration (30 days). */
const SNOOZE_MS = 30 * 24 * 60 * 60 * 1000;
// ─── localStorage keys ──────────────────────────────────────
const LS_DISMISSED = 'donation-banner-dismissed';
const LS_SNOOZE = 'donation-banner-snooze-until';
const LS_SESSIONS = 'donation-banner-sessions';
// ─── Public API ─────────────────────────────────────────────
/** Set project URLs from server health response. Call before initDonationBanner. */
export function setProjectUrls(repoUrl: string, donateUrl: string): void {
_repoUrl = repoUrl || '';
_donateUrl = donateUrl || '';
}
/** Call once on app init. Increments session count and shows banner if conditions met. */
export function initDonationBanner(): void {
// No URLs configured — nothing to show
if (!_donateUrl && !_repoUrl) return;
const sessions = (parseInt(localStorage.getItem(LS_SESSIONS) || '0', 10) || 0) + 1;
localStorage.setItem(LS_SESSIONS, String(sessions));
if (sessions < MIN_SESSIONS) return;
if (localStorage.getItem(LS_DISMISSED) === '1') return;
const snoozeUntil = parseInt(localStorage.getItem(LS_SNOOZE) || '0', 10) || 0;
if (snoozeUntil > Date.now()) return;
_showBanner();
}
/** Dismiss forever. */
export function dismissDonation(): void {
localStorage.setItem(LS_DISMISSED, '1');
_hideBanner();
}
/** Snooze for 30 days. */
export function snoozeDonation(): void {
localStorage.setItem(LS_SNOOZE, String(Date.now() + SNOOZE_MS));
_hideBanner();
}
/** Render the About panel content in settings modal. */
export function renderAboutPanel(): void {
const container = document.getElementById('about-panel-content');
if (!container) return;
const version = document.getElementById('version-number')?.textContent || '';
let links = '';
if (_repoUrl) {
links += `<a href="${_repoUrl}" target="_blank" rel="noopener" class="about-link">
${ICON_GITHUB}
<span>${t('donation.view_source')}</span>
${ICON_EXTERNAL_LINK}
</a>`;
}
if (_donateUrl) {
links += `<a href="${_donateUrl}" target="_blank" rel="noopener" class="about-link about-link-donate">
${ICON_HEART}
<span>${t('donation.about_donate')}</span>
${ICON_EXTERNAL_LINK}
</a>`;
}
container.innerHTML = `
<div class="about-section">
<div class="about-logo">${ICON_HEART}</div>
<h3 class="about-title">${t('donation.about_title')}</h3>
${version ? `<span class="about-version">${version}</span>` : ''}
<p class="about-text">${t('donation.about_opensource')}</p>
${links ? `<div class="about-links">${links}</div>` : ''}
<p class="about-license">${t('donation.about_license')}</p>
</div>
`;
}
// ─── Internal ───────────────────────────────────────────────
function _showBanner(): void {
const banner = document.getElementById('donation-banner');
if (!banner) return;
let actions = '';
if (_donateUrl) {
actions += `<a href="${_donateUrl}" target="_blank" rel="noopener"
class="btn btn-icon donation-banner-action donation-banner-donate"
title="${t('donation.support')}">
${ICON_HEART}
</a>`;
}
if (_repoUrl) {
actions += `<a href="${_repoUrl}" target="_blank" rel="noopener"
class="btn btn-icon donation-banner-action"
title="${t('donation.view_source')}">
${ICON_GITHUB}
</a>`;
}
actions += `<button class="btn btn-icon donation-banner-action"
onclick="snoozeDonation()" title="${t('donation.later')}">
${ICON_X}
</button>`;
banner.innerHTML = `
<span class="donation-banner-text">
${ICON_HEART}
${t('donation.message')}
</span>
${actions}
`;
banner.style.display = 'flex';
}
function _hideBanner(): void {
const banner = document.getElementById('donation-banner');
if (banner) banner.style.display = 'none';
}
@@ -81,6 +81,10 @@ export function switchSettingsTab(tabId: string): void {
(window as any).initUpdateSettingsPanel();
(window as any).loadUpdateSettings();
}
// Lazy-render the about panel content
if (tabId === 'about' && typeof (window as any).renderAboutPanel === 'function') {
(window as any).renderAboutPanel();
}
}
// ─── Log Viewer ────────────────────────────────────────────
@@ -1940,6 +1940,7 @@
"appearance.bg.applied": "Background effect applied",
"settings.tab.updates": "Updates",
"settings.tab.about": "About",
"update.status_label": "Update Status",
"update.current_version": "Current version:",
"update.badge_tooltip": "New version available — click for details",
@@ -2009,5 +2010,15 @@
"asset.type.video": "Video",
"asset.type.other": "Other",
"streams.group.assets": "Assets",
"section.empty.assets": "No assets yet. Click + to upload one."
"section.empty.assets": "No assets yet. Click + to upload one.",
"donation.message": "LedGrab is free & open-source. If it's useful to you, consider supporting development.",
"donation.support": "Support the project",
"donation.view_source": "View source code",
"donation.later": "Remind me later",
"donation.dismiss": "Don't show again",
"donation.about_title": "About LedGrab",
"donation.about_opensource": "LedGrab is open-source software, free to use and modify.",
"donation.about_donate": "Support development",
"donation.about_license": "MIT License"
}
@@ -1869,6 +1869,7 @@
"appearance.bg.applied": "Фоновый эффект применён",
"settings.tab.updates": "Обновления",
"settings.tab.about": "О программе",
"update.status_label": "Статус обновления",
"update.current_version": "Текущая версия:",
"update.badge_tooltip": "Доступна новая версия — нажмите для подробностей",
@@ -1938,5 +1939,15 @@
"asset.type.video": "Видео",
"asset.type.other": "Другое",
"streams.group.assets": "Ресурсы",
"section.empty.assets": "Ресурсов пока нет. Нажмите +, чтобы загрузить."
"section.empty.assets": "Ресурсов пока нет. Нажмите +, чтобы загрузить.",
"donation.message": "LedGrab — бесплатный проект с открытым кодом. Если он вам полезен, поддержите разработку.",
"donation.support": "Поддержать проект",
"donation.view_source": "Исходный код",
"donation.later": "Напомнить позже",
"donation.dismiss": "Больше не показывать",
"donation.about_title": "О LedGrab",
"donation.about_opensource": "LedGrab — программа с открытым исходным кодом, бесплатная для использования и модификации.",
"donation.about_donate": "Поддержать разработку",
"donation.about_license": "Лицензия MIT"
}
@@ -1867,6 +1867,7 @@
"appearance.bg.applied": "背景效果已应用",
"settings.tab.updates": "更新",
"settings.tab.about": "关于",
"update.status_label": "更新状态",
"update.current_version": "当前版本:",
"update.badge_tooltip": "有新版本可用 — 点击查看详情",
@@ -1936,5 +1937,15 @@
"asset.type.video": "视频",
"asset.type.other": "其他",
"streams.group.assets": "资源",
"section.empty.assets": "暂无资源。点击 + 上传一个。"
"section.empty.assets": "暂无资源。点击 + 上传一个。",
"donation.message": "LedGrab 是免费开源软件。如果它对您有帮助,请考虑支持开发。",
"donation.support": "支持项目",
"donation.view_source": "查看源代码",
"donation.later": "稍后提醒",
"donation.dismiss": "不再显示",
"donation.about_title": "关于 LedGrab",
"donation.about_opensource": "LedGrab 是开源软件,可免费使用和修改。",
"donation.about_donate": "支持开发",
"donation.about_license": "MIT 许可证"
}