diff --git a/TODO-css-improvements.md b/TODO-css-improvements.md
index 90de97f..f741250 100644
--- a/TODO-css-improvements.md
+++ b/TODO-css-improvements.md
@@ -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
---
diff --git a/server/src/wled_controller/__init__.py b/server/src/wled_controller/__init__.py
index d40a6a8..36ff477 100644
--- a/server/src/wled_controller/__init__.py
+++ b/server/src/wled_controller/__init__.py
@@ -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
diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py
index c8e11ad..c195882 100644
--- a/server/src/wled_controller/api/routes/system.py
+++ b/server/src/wled_controller/api/routes/system.py
@@ -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),
diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py
index c8260be..75d5f2c 100644
--- a/server/src/wled_controller/api/schemas/system.py
+++ b/server/src/wled_controller/api/schemas/system.py
@@ -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):
diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py
index 52d53e8..b02eba0 100644
--- a/server/src/wled_controller/main.py
+++ b/server/src/wled_controller/main.py
@@ -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():
diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css
index 742fd48..de69aaf 100644
--- a/server/src/wled_controller/static/css/layout.css
+++ b/server/src/wled_controller/static/css/layout.css
@@ -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; }
diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css
index cb397a5..9a58810 100644
--- a/server/src/wled_controller/static/css/modal.css
+++ b/server/src/wled_controller/static/css/modal.css
@@ -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 {
diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts
index 0850306..3fd4d09 100644
--- a/server/src/wled_controller/static/js/app.ts
+++ b/server/src/wled_controller/static/js/app.ts
@@ -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);
diff --git a/server/src/wled_controller/static/js/core/api.ts b/server/src/wled_controller/static/js/core/api.ts
index 44e1e99..5b051f1 100644
--- a/server/src/wled_controller/static/js/core/api.ts
+++ b/server/src/wled_controller/static/js/core/api.ts
@@ -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;
diff --git a/server/src/wled_controller/static/js/core/icon-paths.ts b/server/src/wled_controller/static/js/core/icon-paths.ts
index 0b1706e..1252d30 100644
--- a/server/src/wled_controller/static/js/core/icon-paths.ts
+++ b/server/src/wled_controller/static/js/core/icon-paths.ts
@@ -87,3 +87,5 @@ export const xIcon = '
${t('donation.about_opensource')}
+ ${links ? `${t('donation.about_license')}
+Created by Alexei Dolgolyov • dolgolyov.alexei@gmail.com - • Source Code + • About