feat: donation banner, About tab, settings UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
Some checks failed
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:
@@ -13,10 +13,11 @@
|
||||
|
||||
## Donation / Open-Source Banner
|
||||
|
||||
- [ ] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
|
||||
- [ ] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
||||
- [ ] Remember dismissal in localStorage so it doesn't reappear every session
|
||||
- [ ] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
|
||||
- [x] Add a persistent but dismissible banner or notification in the dashboard UI informing users that the project is open-source and under active development, and that donations are highly appreciated
|
||||
- [x] Include a link to the donation page (GitHub Sponsors, Ko-fi, or similar — decide on platform)
|
||||
- [x] Remember dismissal in localStorage so it doesn't reappear every session
|
||||
- [x] Add i18n keys for the banner text (`en.json`, `ru.json`, `zh.json`)
|
||||
- [ ] Configure `DONATE_URL` and `REPO_URL` constants in `donation.ts` once platform is chosen
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -10,3 +10,9 @@ except PackageNotFoundError:
|
||||
|
||||
__author__ = "Alexei Dolgolyov"
|
||||
__email__ = "dolgolyov.alexei@gmail.com"
|
||||
|
||||
# ─── Project links ───────────────────────────────────────────
|
||||
GITEA_BASE_URL = "https://git.dolgolyov-family.by"
|
||||
GITEA_REPO = "alexei.dolgolyov/wled-screen-controller-mixed"
|
||||
REPO_URL = f"{GITEA_BASE_URL}/{GITEA_REPO}"
|
||||
DONATE_URL = "" # TODO: set once donation platform is chosen
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Optional
|
||||
import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller import __version__, REPO_URL, DONATE_URL
|
||||
from wled_controller.api.auth import AuthRequired, is_auth_enabled
|
||||
from wled_controller.api.dependencies import (
|
||||
get_audio_source_store,
|
||||
@@ -54,7 +54,11 @@ logger = get_logger(__name__)
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle # noqa: E402
|
||||
from wled_controller.utils.gpu import ( # noqa: E402
|
||||
nvml_available as _nvml_available,
|
||||
nvml as _nvml,
|
||||
nvml_handle as _nvml_handle,
|
||||
)
|
||||
|
||||
|
||||
def _get_cpu_name() -> str | None:
|
||||
@@ -77,9 +81,7 @@ def _get_cpu_name() -> str | None:
|
||||
return line.split(":")[1].strip()
|
||||
elif platform.system() == "Darwin":
|
||||
return (
|
||||
subprocess.check_output(
|
||||
["sysctl", "-n", "machdep.cpu.brand_string"]
|
||||
)
|
||||
subprocess.check_output(["sysctl", "-n", "machdep.cpu.brand_string"])
|
||||
.decode()
|
||||
.strip()
|
||||
)
|
||||
@@ -107,6 +109,8 @@ async def health_check():
|
||||
version=__version__,
|
||||
demo_mode=get_config().demo,
|
||||
auth_required=is_auth_enabled(),
|
||||
repo_url=REPO_URL,
|
||||
donate_url=DONATE_URL,
|
||||
)
|
||||
|
||||
|
||||
@@ -131,12 +135,22 @@ async def list_all_tags(_: AuthRequired):
|
||||
"""Get all tags used across all entities."""
|
||||
all_tags: set[str] = set()
|
||||
from wled_controller.api.dependencies import get_asset_store
|
||||
|
||||
store_getters = [
|
||||
get_device_store, get_output_target_store, get_color_strip_store,
|
||||
get_picture_source_store, get_audio_source_store, get_value_source_store,
|
||||
get_sync_clock_store, get_automation_store, get_scene_preset_store,
|
||||
get_template_store, get_audio_template_store, get_pp_template_store,
|
||||
get_pattern_template_store, get_asset_store,
|
||||
get_device_store,
|
||||
get_output_target_store,
|
||||
get_color_strip_store,
|
||||
get_picture_source_store,
|
||||
get_audio_source_store,
|
||||
get_value_source_store,
|
||||
get_sync_clock_store,
|
||||
get_automation_store,
|
||||
get_scene_preset_store,
|
||||
get_template_store,
|
||||
get_audio_template_store,
|
||||
get_pp_template_store,
|
||||
get_pattern_template_store,
|
||||
get_asset_store,
|
||||
]
|
||||
for getter in store_getters:
|
||||
try:
|
||||
@@ -209,15 +223,11 @@ async def get_displays(
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get displays: %s", e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Internal server error"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
|
||||
@@ -235,10 +245,7 @@ async def get_running_processes(_: AuthRequired):
|
||||
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get processes: %s", e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Internal server error"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -260,9 +267,7 @@ def get_system_performance(_: AuthRequired):
|
||||
try:
|
||||
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
|
||||
mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle)
|
||||
temp = _nvml.nvmlDeviceGetTemperature(
|
||||
_nvml_handle, _nvml.NVML_TEMPERATURE_GPU
|
||||
)
|
||||
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
|
||||
gpu = GpuInfo(
|
||||
name=_nvml.nvmlDeviceGetName(_nvml_handle),
|
||||
utilization=float(util.gpu),
|
||||
|
||||
@@ -13,7 +13,11 @@ class HealthResponse(BaseModel):
|
||||
timestamp: datetime = Field(description="Current server time")
|
||||
version: str = Field(description="Application version")
|
||||
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||
auth_required: bool = Field(default=True, description="Whether API key authentication is required")
|
||||
auth_required: bool = Field(
|
||||
default=True, description="Whether API key authentication is required"
|
||||
)
|
||||
repo_url: str = Field(default="", description="Source code repository URL")
|
||||
donate_url: str = Field(default="", description="Donation page URL")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
@@ -84,6 +88,7 @@ class RestoreResponse(BaseModel):
|
||||
|
||||
# ─── Auto-backup schemas ──────────────────────────────────────
|
||||
|
||||
|
||||
class AutoBackupSettings(BaseModel):
|
||||
"""Settings for automatic backup."""
|
||||
|
||||
@@ -119,6 +124,7 @@ class BackupListResponse(BaseModel):
|
||||
|
||||
# ─── MQTT schemas ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class MQTTSettingsResponse(BaseModel):
|
||||
"""MQTT broker settings response (password is masked)."""
|
||||
|
||||
@@ -138,17 +144,22 @@ class MQTTSettingsRequest(BaseModel):
|
||||
broker_host: str = Field(description="MQTT broker hostname or IP")
|
||||
broker_port: int = Field(ge=1, le=65535, description="MQTT broker port")
|
||||
username: str = Field(default="", description="MQTT username (empty = anonymous)")
|
||||
password: str = Field(default="", description="MQTT password (empty = keep existing if omitted)")
|
||||
password: str = Field(
|
||||
default="", description="MQTT password (empty = keep existing if omitted)"
|
||||
)
|
||||
client_id: str = Field(default="ledgrab", description="MQTT client ID")
|
||||
base_topic: str = Field(default="ledgrab", description="Base topic prefix")
|
||||
|
||||
|
||||
# ─── External URL schema ───────────────────────────────────────
|
||||
|
||||
|
||||
class ExternalUrlResponse(BaseModel):
|
||||
"""External URL setting response."""
|
||||
|
||||
external_url: str = Field(description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL.")
|
||||
external_url: str = Field(
|
||||
description="External base URL (e.g. https://myserver.example.com:8080). Empty = use auto-detected URL."
|
||||
)
|
||||
|
||||
|
||||
class ExternalUrlRequest(BaseModel):
|
||||
@@ -159,10 +170,13 @@ class ExternalUrlRequest(BaseModel):
|
||||
|
||||
# ─── Log level schemas ─────────────────────────────────────────
|
||||
|
||||
|
||||
class LogLevelResponse(BaseModel):
|
||||
"""Current log level response."""
|
||||
|
||||
level: str = Field(description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
||||
level: str = Field(
|
||||
description="Current effective log level name (e.g. DEBUG, INFO, WARNING, ERROR, CRITICAL)"
|
||||
)
|
||||
|
||||
|
||||
class LogLevelRequest(BaseModel):
|
||||
|
||||
@@ -12,11 +12,14 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.requests import Request
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller import __version__, GITEA_BASE_URL, GITEA_REPO
|
||||
from wled_controller.api import router
|
||||
from wled_controller.api.dependencies import init_dependencies
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.processing.processor_manager import ProcessorDependencies, ProcessorManager
|
||||
from wled_controller.core.processing.processor_manager import (
|
||||
ProcessorDependencies,
|
||||
ProcessorManager,
|
||||
)
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
@@ -31,7 +34,9 @@ from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_processing_template_store import (
|
||||
ColorStripProcessingTemplateStore,
|
||||
)
|
||||
from wled_controller.storage.gradient_store import GradientStore
|
||||
from wled_controller.storage.weather_source_store import WeatherSourceStore
|
||||
from wled_controller.storage.asset_store import AssetStore
|
||||
@@ -61,6 +66,7 @@ db = Database(config.storage.database_file)
|
||||
# Seed demo data after DB is ready (first-run only)
|
||||
if config.demo:
|
||||
from wled_controller.core.demo_seed import seed_demo_data
|
||||
|
||||
seed_demo_data(db)
|
||||
|
||||
# Initialize storage and processing
|
||||
@@ -148,7 +154,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Create automation engine (needs processor_manager + mqtt_service + stores for scene activation)
|
||||
automation_engine = AutomationEngine(
|
||||
automation_store, processor_manager,
|
||||
automation_store,
|
||||
processor_manager,
|
||||
mqtt_service=mqtt_service,
|
||||
scene_preset_store=scene_preset_store,
|
||||
target_store=output_target_store,
|
||||
@@ -165,8 +172,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Create update service (checks for new releases)
|
||||
_release_provider = GiteaReleaseProvider(
|
||||
base_url="https://git.dolgolyov-family.by",
|
||||
repo="alexei.dolgolyov/wled-screen-controller-mixed",
|
||||
base_url=GITEA_BASE_URL,
|
||||
repo=GITEA_REPO,
|
||||
)
|
||||
update_service = UpdateService(
|
||||
provider=_release_provider,
|
||||
@@ -177,7 +184,9 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Initialize API dependencies
|
||||
init_dependencies(
|
||||
device_store, template_store, processor_manager,
|
||||
device_store,
|
||||
template_store,
|
||||
processor_manager,
|
||||
database=db,
|
||||
pp_template_store=pp_template_store,
|
||||
pattern_template_store=pattern_template_store,
|
||||
@@ -309,6 +318,7 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping MQTT service: {e}")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="LED Grab",
|
||||
@@ -363,6 +373,7 @@ async def _no_cache_static(request: Request, call_next):
|
||||
return response
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Mount static files
|
||||
static_path = Path(__file__).parent / "static"
|
||||
if static_path.exists():
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
143
server/src/wled_controller/static/js/features/donation.ts
Normal file
143
server/src/wled_controller/static/js/features/donation.ts
Normal file
@@ -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 许可证"
|
||||
}
|
||||
@@ -105,6 +105,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<div id="update-banner" class="update-banner" style="display:none"></div>
|
||||
<div id="donation-banner" class="donation-banner" style="display:none"></div>
|
||||
<div class="container">
|
||||
<div class="tabs">
|
||||
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
||||
@@ -170,7 +171,7 @@
|
||||
<p>
|
||||
Created by <strong>Alexei Dolgolyov</strong>
|
||||
• <a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
• <a href="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" target="_blank" rel="noopener">Source Code</a>
|
||||
• <a href="#" onclick="openSettingsModal();switchSettingsTab('about');return false" data-i18n="donation.about_title">About</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
|
||||
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
|
||||
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" data-i18n="settings.tab.updates">Updates</button>
|
||||
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" data-i18n="settings.tab.about">About</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
@@ -37,7 +38,7 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.external_url.hint">If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080</small>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<input type="text" id="settings-external-url" placeholder="https://myserver.example.com:8080" style="flex:1" data-i18n-placeholder="settings.external_url.placeholder">
|
||||
<button class="btn btn-primary" onclick="saveExternalUrl()" data-i18n="settings.external_url.save">Save</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveExternalUrl()" title="Save" data-i18n-title="settings.external_url.save"><svg class="icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -282,11 +283,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ About tab ═══ -->
|
||||
<div id="settings-panel-about" class="settings-panel">
|
||||
<div id="about-panel-content"></div>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="error-message" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeSettingsModal()" title="Close" data-i18n-title="settings.button.close" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user