diff --git a/backend/src/controllers/AdminController.cpp b/backend/src/controllers/AdminController.cpp index eaa4305..0c1fa02 100644 --- a/backend/src/controllers/AdminController.cpp +++ b/backend/src/controllers/AdminController.cpp @@ -2336,6 +2336,41 @@ void AdminController::updateSiteSettings(const HttpRequestPtr &req, }; } + // Update announcement_enabled if provided + if (json->isMember("announcement_enabled")) { + bool enabled = (*json)["announcement_enabled"].asBool(); + std::string value = enabled ? "true" : "false"; + *dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('announcement_enabled', $1) " + "ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP" + << value + >> [](const Result&) { + LOG_INFO << "Announcement enabled setting updated successfully"; + } + >> [](const DrogonDbException& e) { + LOG_ERROR << "Failed to update announcement_enabled: " << e.base().what(); + }; + } + + // Update announcement_text if provided + if (json->isMember("announcement_text")) { + std::string text = (*json)["announcement_text"].asString(); + // Limit to 500 characters + if (text.length() > 500) { + text = text.substr(0, 500); + } + // Sanitize to prevent XSS + text = htmlEscape(text); + *dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('announcement_text', $1) " + "ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP" + << text + >> [](const Result&) { + LOG_INFO << "Announcement text updated successfully"; + } + >> [](const DrogonDbException& e) { + LOG_ERROR << "Failed to update announcement_text: " << e.base().what(); + }; + } + // Update censored_words if provided (comma-separated list) if (json->isMember("censored_words")) { // Rate limit: 10 updates per minute per admin @@ -2464,7 +2499,8 @@ void AdminController::getPublicSiteSettings(const HttpRequestPtr &, >> [callback](const Result& r) { // Whitelist of publicly-safe settings static const std::unordered_set publicKeys = { - "site_title", "logo_path", "logo_display_mode" + "site_title", "logo_path", "logo_display_mode", + "announcement_enabled", "announcement_text" }; Json::Value resp; diff --git a/database/init.sql b/database/init.sql index 3875dba..9bb3864 100644 --- a/database/init.sql +++ b/database/init.sql @@ -555,7 +555,9 @@ CREATE TABLE IF NOT EXISTS site_settings ( INSERT INTO site_settings (setting_key, setting_value) VALUES ('site_title', 'Stream'), ('logo_path', ''), - ('logo_display_mode', 'text') -- 'text', 'image', 'both' + ('logo_display_mode', 'text'), -- 'text', 'image', 'both' + ('announcement_enabled', 'false'), + ('announcement_text', '') ON CONFLICT (setting_key) DO NOTHING; -- Trigger for site_settings updated_at diff --git a/frontend/src/lib/stores/siteSettings.js b/frontend/src/lib/stores/siteSettings.js index 4dd91f4..4053420 100644 --- a/frontend/src/lib/stores/siteSettings.js +++ b/frontend/src/lib/stores/siteSettings.js @@ -3,5 +3,7 @@ import { writable } from 'svelte/store'; export const siteSettings = writable({ site_title: 'Stream', logo_path: '', - logo_display_mode: 'text' + logo_display_mode: 'text', + announcement_enabled: false, + announcement_text: '' }); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ad7da15..34a4b4b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -30,7 +30,9 @@ siteSettings.set({ site_title: settings.site_title || 'Stream', logo_path: settings.logo_path || '', - logo_display_mode: settings.logo_display_mode || 'text' + logo_display_mode: settings.logo_display_mode || 'text', + announcement_enabled: settings.announcement_enabled === 'true', + announcement_text: settings.announcement_text || '' }); } } catch (e) { @@ -413,6 +415,16 @@ .dropdown-item.logout:hover :global(svg) { color: var(--error); } + + .announcement-banner { + background: linear-gradient(135deg, #8b5cf6, #a855f7); + color: white; + padding: 0.75rem 1rem; + text-align: center; + font-size: 0.95rem; + font-weight: 500; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } {#if !isPopoutPage} @@ -559,6 +571,12 @@ +{#if $siteSettings.announcement_enabled && $siteSettings.announcement_text} +
+ {$siteSettings.announcement_text} +
+{/if} + {#if browser} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 34978f1..0836134 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -57,6 +57,11 @@ lastRenewalError: null, autoRenewalEnabled: true }; + + // Announcement Settings + let announcementEnabled = false; + let announcementText = ''; + let announcementSaving = false; let sslLoading = false; let sslSaving = false; let sslRequesting = false; @@ -1142,6 +1147,8 @@ logo_display_mode: settings.logo_display_mode || 'text' }; censoredWords = settings.censored_words || ''; + announcementEnabled = settings.announcement_enabled === 'true'; + announcementText = settings.announcement_text || ''; } else { console.error('Failed to load site settings'); } @@ -1221,6 +1228,35 @@ setTimeout(() => { message = ''; error = ''; }, 3000); } + async function saveAnnouncementSettings() { + announcementSaving = true; + try { + const response = await fetch('/api/admin/settings/site', { + method: 'PUT', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + announcement_enabled: announcementEnabled, + announcement_text: announcementText + }) + }); + + if (response.ok) { + message = 'Announcement settings updated successfully'; + } else { + error = 'Failed to update announcement settings'; + } + } catch (e) { + error = 'Error updating announcement settings'; + console.error(e); + } + announcementSaving = false; + + setTimeout(() => { message = ''; error = ''; }, 3000); + } + async function loadDefaultAvatars() { try { const response = await fetch('/api/admin/default-avatars', { @@ -3603,6 +3639,57 @@ {/if} + +
+

Site Announcement

+

+ Display a site-wide announcement banner at the top of all pages +

+ +
+
+ + + When enabled, the announcement will be displayed at the top of all pages + +
+ +
+ + + + {announcementText.length}/500 characters. This message will be displayed to all visitors. + +
+ +
+ +
+
+
+ {:else if activeTab === 'botkeys'}

Bot API Keys