diff --git a/docs/contributing.md b/docs/contributing.md index e7c19d5d1f7..bce3a628913 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -4,7 +4,7 @@ Read our contribution guide in our organization level ## Recommended Tools -| Tool | Description | +| Tool | Description | |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| |
CLion | Recommended IDE for C and C++ development. Free for non-commercial use. | @@ -16,6 +16,7 @@ Read our contribution guide in our organization level * [EJS](https://www.npmjs.com/package/vite-plugin-ejs) is used as a templating system for the pages (check `template_header.html` and `template_header_main.html`). * The Style System is provided by [Bootstrap](https://getbootstrap.com). +* Icons are provided by [Lucide](https://lucide.dev) and [Simple Icons](https://simpleicons.org). * The JS framework used by the more interactive pages is [Vus.js](https://vuejs.org). #### Building diff --git a/package.json b/package.json index 24f1d895cd8..5c1bfb0ec0f 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "type": "module", "dependencies": { "@lizardbyte/shared-web": "2025.922.181114", + "date-fns": "4.1.0", + "lucide-vue-next": "0.563.0", "marked": "17.0.1", "vue": "3.5.27", - "vue-i18n": "11.2.8" + "vue-i18n": "11.2.8", + "vue3-simple-icons": "15.6.0" }, "devDependencies": { "@codecov/vite-plugin": "1.9.1", diff --git a/src/confighttp.cpp b/src/confighttp.cpp index ada6916832b..85d66077e49 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -387,6 +387,26 @@ namespace confighttp { response->write(content, headers); } + /** + * @brief Get the featured apps page. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void getFeaturedPage(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::string content = file_handler::read_file(WEB_DIR "featured.html"); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "text/html; charset=utf-8"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + response->write(content, headers); + } + /** * @brief Get the password page. * @param response The HTTP response object. @@ -955,9 +975,6 @@ namespace confighttp { * @api_examples{/api/covers/9999 | GET| null} */ void getCover(resp_https_t response, req_https_t request) { - if (!check_content_type(response, request, "application/json")) { - return; - } if (!authenticate(response, request)) { return; } @@ -986,6 +1003,13 @@ namespace confighttp { // This handles extension validation, PNG signature validation, and path resolution std::string validated_path = proc::validate_app_image_path(app_image_path); + // Check if we got the default image path (means validation failed or no image configured) + if (validated_path == DEFAULT_APP_IMAGE_PATH) { + BOOST_LOG(debug) << "Application at index " << index << " does not have a valid cover image"; + not_found(response, request, "Cover image not found"); + return; + } + // Open and stream the validated file std::ifstream in(validated_path, std::ios::binary); if (!in) { @@ -1410,6 +1434,7 @@ namespace confighttp { server.resource["^/apps/?$"]["GET"] = getAppsPage; server.resource["^/clients/?$"]["GET"] = getClientsPage; server.resource["^/config/?$"]["GET"] = getConfigPage; + server.resource["^/featured/?$"]["GET"] = getFeaturedPage; server.resource["^/password/?$"]["GET"] = getPasswordPage; server.resource["^/welcome/?$"]["GET"] = getWelcomePage; server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage; diff --git a/src_assets/common/assets/web/Navbar.vue b/src_assets/common/assets/web/Navbar.vue index 75503a7312e..43be98448d0 100644 --- a/src_assets/common/assets/web/Navbar.vue +++ b/src_assets/common/assets/web/Navbar.vue @@ -1,5 +1,5 @@ diff --git a/src_assets/common/assets/web/ResourceCard.vue b/src_assets/common/assets/web/ResourceCard.vue index 43dc3479112..72818e07326 100644 --- a/src_assets/common/assets/web/ResourceCard.vue +++ b/src_assets/common/assets/web/ResourceCard.vue @@ -1,33 +1,59 @@ + + diff --git a/src_assets/common/assets/web/SimpleIcon.vue b/src_assets/common/assets/web/SimpleIcon.vue new file mode 100644 index 00000000000..62d49e80bbb --- /dev/null +++ b/src_assets/common/assets/web/SimpleIcon.vue @@ -0,0 +1,44 @@ + + + diff --git a/src_assets/common/assets/web/ThemeToggle.vue b/src_assets/common/assets/web/ThemeToggle.vue index 7c34916adc9..459cf88f006 100644 --- a/src_assets/common/assets/web/ThemeToggle.vue +++ b/src_assets/common/assets/web/ThemeToggle.vue @@ -1,6 +1,23 @@ diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index c85192b87bd..222fba0eccd 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -3,29 +3,50 @@ <%- header %> -

{{ $t('config.configuration') }}

+ + +
+
+ + + + +
+ + + +
+ No results found for "{{ searchQuery }}" +
+
+ Found {{ searchResults.length }} result(s) +
+
+
@@ -86,9 +107,15 @@

{{ $t('config.configuration') }}

{{ $t('_common.success') }} {{ $t('config.restart_note') }}
-
- - +
+ +
@@ -106,6 +133,19 @@

{{ $t('config.configuration') }}

import AudioVideo from './configs/tabs/AudioVideo.vue' import ContainerEncoders from './configs/tabs/ContainerEncoders.vue' import {$tp, usePlatformI18n} from './platform-i18n' + import { + Check, + Cpu, + FileCog, + Gamepad2, + Gpu, + Network as NetworkIcon, + Save, + Search, + Settings, + Sliders, + Volume2, + } from 'lucide-vue-next' const app = createApp({ components: { @@ -118,6 +158,18 @@

{{ $t('config.configuration') }}

// They will be accessible via audio-video, container-encoders only. AudioVideo, ContainerEncoders, + // icons + Cpu, + Check, + FileCog, + Gamepad2, + Gpu, + NetworkIcon, + Save, + Search, + Settings, + Sliders, + Volume2, }, data() { return { @@ -126,6 +178,7 @@

{{ $t('config.configuration') }}

restarted: false, config: null, currentTab: "general", + searchQuery: "", tabs: [ // TODO: Move the options to each Component instead, encapsulate. { id: "general", @@ -290,9 +343,34 @@

{{ $t('config.configuration') }}

}, provide() { return { - platform: computed(() => this.platform) + platform: computed(() => this.platform), + searchQuery: computed(() => this.searchQuery), } }, + computed: { + allConfigOptions() { + const options = []; + this.tabs.forEach(tab => { + Object.keys(tab.options).forEach(key => { + options.push({ + key: key, + label: key.replaceAll('_', ' ').replaceAll(/\b\w/g, l => l.toUpperCase()), + tab: tab.name, + tabId: tab.id + }); + }); + }); + return options; + }, + searchResults() { + if (!this.searchQuery) return []; + const query = this.searchQuery.toLowerCase(); + return this.allConfigOptions.filter(option => + option.key.toLowerCase().includes(query) || + option.label.toLowerCase().includes(query) + ); + } + }, created() { fetch("./api/config") .then((r) => r.json()) @@ -344,6 +422,23 @@

{{ $t('config.configuration') }}

}); }, methods: { + getTabIcon(tabId) { + const iconMap = { + 'general': 'Settings', + 'input': 'Gamepad2', + 'av': 'Volume2', + 'network': 'NetworkIcon', + 'files': 'FileCog', + 'advanced': 'Sliders', + 'nv': 'Gpu', + 'amd': 'Gpu', + 'qsv': 'Gpu', + 'vaapi': 'Gpu', + 'vt': 'Gpu', + 'sw': 'Cpu', + }; + return iconMap[tabId] || 'Settings'; + }, forceUpdate() { this.$forceUpdate() }, @@ -408,6 +503,67 @@

{{ $t('config.configuration') }}

} }); }, + handleSearch() { + // Clear all highlighting + document.querySelectorAll('.config-search-highlight').forEach(el => { + el.classList.remove('config-search-highlight'); + }); + + if (!this.searchQuery) { + // Show all form groups when search is cleared + document.querySelectorAll('.mb-3').forEach(el => { + el.style.display = ''; + }); + return; + } + + const results = this.searchResults; + + if (results.length === 0) { + return; + } + + // Switch to the tab of the first result + if (results.length > 0 && results[0].tabId !== this.currentTab) { + this.currentTab = results[0].tabId; + } + + // Wait for tab content to render + this.$nextTick(() => { + // Hide all form groups first + document.querySelectorAll('.config-page .mb-3').forEach(el => { + el.style.display = 'none'; + }); + + // Show only matching elements + results.forEach(result => { + const element = document.getElementById(result.key); + + if (element) { + // Show the element's container + const container = element.closest('.mb-3'); + if (container) { + container.style.display = ''; + } + } + }); + + // Scroll to and highlight the first result + if (results.length > 0) { + const firstElement = document.getElementById(results[0].key); + if (firstElement) { + const container = firstElement.closest('.mb-3'); + if (container) { + container.scrollIntoView({ behavior: 'smooth', block: 'center' }); + container.classList.add('config-search-highlight'); + setTimeout(() => { + container.classList.remove('config-search-highlight'); + }, 3000); + } + } + } + }); + }, }, mounted() { // Handle hashchange events diff --git a/src_assets/common/assets/web/featured.html b/src_assets/common/assets/web/featured.html new file mode 100644 index 00000000000..562d61c6a8b --- /dev/null +++ b/src_assets/common/assets/web/featured.html @@ -0,0 +1,436 @@ + + + + + <%- header %> + + + + +
+
+

{{ $t('featured.title') }}

+

{{ $t('featured.description') }}

+
+ + +
+
+ + +
+
+ + +
+
+ {{ $t('_common.loading') }} +
+
+ + + + + +
+
+ +
+
+ + +
+

{{ $t('featured.no_apps') }}

+
+ + +
+
+ + + + + + + + + +
+ {{ selectedScreenshotIndex + 1 }} / {{ currentAppScreenshots.length }} +
+ + Screenshot +
+
+
+ + + + + diff --git a/src_assets/common/assets/web/index.html b/src_assets/common/assets/web/index.html index 55f62e43557..7a02b34fc36 100644 --- a/src_assets/common/assets/web/index.html +++ b/src_assets/common/assets/web/index.html @@ -10,77 +10,115 @@

{{ $t('index.welcome') }}

{{ $t('index.description') }}

-
-
- -

-
+ + +
+
+
+ +
+
+
    +
  • {{v.value}}
  • +
+ + + View Logs +
-
    -
  • {{v.value}}
  • -
- View Logs
+ -
-
- -
-

{{ $t('index.vigembus_not_installed_title') }}

-

{{ $t('index.vigembus_not_installed_desc') }}

-
-
-

{{ $t('index.vigembus_outdated_title') }}

-

{{ $t('index.vigembus_outdated_desc', { version: vigembus.version }) }}

+
+
+
+ +
+
+

{{ $t('index.vigembus_not_installed_title') }}

+

{{ $t('index.vigembus_not_installed_desc') }}

+
+
+

{{ $t('index.vigembus_outdated_title') }}

+

{{ $t('index.vigembus_outdated_desc', { version: vigembus.version }) }}

+
+
- {{ $t('index.fix_now') }} + + + {{ $t('index.fix_now') }} +
+ -
+

Version {{version.version}}

-
-
+ +
{{ $t('index.loading_latest') }}
-
+ +
+ {{ $t('index.version_dirty') }} 🌇
-
+ +
+ {{ $t('index.installed_version_not_stable') }}
+
-
+
+ {{ $t('index.version_latest') }}
+
-
-
-
-

{{ $t('index.new_pre_release') }}

+
+ +
+
+ + {{ $t('index.new_pre_release') }} +
{{ preReleaseVersion.release.name }}
- {{ $t('index.download') }} + + + {{ $t('index.download') }} +
-

{{preReleaseVersion.release.name}}

-
+ + +
+
-
-
-
-

{{ $t('index.new_stable') }}

+
+ +
+
+ + {{ $t('index.new_stable') }} +
{{ githubVersion.release.name }}
- {{ $t('index.download') }} + + + {{ $t('index.download') }} +
-

{{githubVersion.release.name}}

-
+ + +
+
@@ -95,6 +133,16 @@

{{githubVersion.release.name}}

import Navbar from './Navbar.vue' import ResourceCard from './ResourceCard.vue' import SunshineVersion from './sunshine_version' + import { + AlertCircle, + AlertTriangle, + FileText, + Wrench, + Package, + Info, + CheckCircle, + Download + } from 'lucide-vue-next' // Configure marked to allow HTML marked.setOptions({ @@ -109,7 +157,15 @@

{{githubVersion.release.name}}

let app = createApp({ components: { Navbar, - ResourceCard + ResourceCard, + AlertCircle, + AlertTriangle, + FileText, + Wrench, + Package, + Info, + CheckCircle, + Download }, data() { return { @@ -200,3 +256,4 @@

{{githubVersion.release.name}}

initApp(app); + diff --git a/src_assets/common/assets/web/password.html b/src_assets/common/assets/web/password.html index 9f1e7194a79..57c9e7f8f28 100644 --- a/src_assets/common/assets/web/password.html +++ b/src_assets/common/assets/web/password.html @@ -3,17 +3,6 @@ <%- header %> - @@ -21,46 +10,52 @@

{{ $t('password.password_change') }}

-
-
-

{{ $t('password.current_creds') }}

-
- - -
 
-
-
- - -
-
-
-

{{ $t('password.new_creds') }}

-
- - -
{{ $t('password.new_username_desc') }}
-
-
- - -
-
- - +
+
+
+
+

{{ $t('password.current_creds') }}

+
+ + +
+
+ + +
+
+
+

{{ $t('password.new_creds') }}

+
+ + +
{{ $t('password.new_username_desc') }}
+
+
+ + +
+
+ + +
+
-
Error: {{error}}
-
+
Error: {{error}}
+
{{ $t('_common.success') }} {{ $t('password.success_msg') }}
-
- +
+
@@ -69,10 +64,12 @@

{{ $t('password.new_creds') }}

import { createApp } from 'vue' import { initApp } from './init' import Navbar from './Navbar.vue' + import { Save } from 'lucide-vue-next' const app = createApp({ components: { - Navbar + Navbar, + Save, }, data() { return { diff --git a/src_assets/common/assets/web/pin.html b/src_assets/common/assets/web/pin.html index d16a5de156e..9f0cc11c830 100644 --- a/src_assets/common/assets/web/pin.html +++ b/src_assets/common/assets/web/pin.html @@ -11,9 +11,22 @@

{{ $t('pin.pin_pairing') }}

- - - +
+ + + + +
+
+ + + + +
+
{{ $t('_common.warning') }} {{ $t('pin.warning_msg') }} @@ -27,10 +40,18 @@

{{ $t('pin.pin_pairing') }}

import { createApp } from 'vue' import { initApp } from './init' import Navbar from './Navbar.vue' + import { + Forward, + Hash, + Monitor, + } from 'lucide-vue-next' let app = createApp({ components: { - Navbar + Navbar, + Forward, + Hash, + Monitor, }, inject: ['i18n'], methods: { @@ -40,10 +61,10 @@

{{ $t('pin.pin_pairing') }}

document.querySelector("#status").innerHTML = ""; let b = JSON.stringify({pin: pin, name: name}); fetch("./api/pin", { - method: "POST", + method: "POST", headers: { 'Content-Type': 'application/json' - }, + }, body: b }) .then((response) => response.json()) diff --git a/src_assets/common/assets/web/public/assets/css/sunshine.css b/src_assets/common/assets/web/public/assets/css/sunshine.css index 37b08be25f3..970f2e2e976 100644 --- a/src_assets/common/assets/web/public/assets/css/sunshine.css +++ b/src_assets/common/assets/web/public/assets/css/sunshine.css @@ -1,24 +1,1441 @@ +/* ========================================================================== + Sunshine UI - Modern Design System + ========================================================================== */ + /* Hide pages while localization is loading */ [v-cloak] { display: none; } -[data-bs-theme=dark] .element { - color: var(--bs-primary-text-emphasis); - background-color: var(--bs-primary-bg-subtle); +/* ========================================================================== + CSS Custom Properties - Design Tokens + ========================================================================== */ + +:root { + /* Spacing Scale */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + --spacing-3xl: 4rem; + + /* Border Radius */ + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + + /* Shadows - Light theme defaults */ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +/* ========================================================================== + Dark Themes + ========================================================================== */ + +/* Dark Theme - Simple clean dark design */ +[data-theme="dark"] { + --color-primary: #0d6efd; + --color-primary-hover: #3d8bfd; + --color-primary-light: #031633; + --color-accent: #fd7e14; + --color-accent-hover: #fd9843; + --color-accent-light: #331e08; + --color-success: #198754; + --color-success-hover: #20c997; + --color-success-light: #051b11; + --color-danger: #dc3545; + --color-danger-hover: #e35d6a; + --color-danger-light: #2c0b0e; + --color-warning: #ffc107; + --color-warning-hover: #ffcd39; + --color-warning-light: #332701; + --color-info: #0dcaf0; + --color-info-hover: #3dd5f3; + --color-info-light: #032830; + + --color-bg-base: #212529; + --color-bg-subtle: #2c3034; + --color-bg-muted: #383d41; + --color-surface: #2c3034; + --color-surface-raised: #383d41; + + --color-border: #495057; + --color-border-strong: #6c757d; + + --color-text-base: #f8f9fa; + --color-text-muted: #adb5bd; + --color-text-subtle: #6c757d; + + --navbar-bg: #ffc400; + --navbar-text: #594400; + --navbar-text-muted: #7f6100; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3); +} + +/* Ember Theme - Warm dark with orange/red accents */ +[data-theme="ember"] { + --color-primary: #F97316; + --color-primary-hover: #FB923C; + --color-primary-light: #7C2D12; + --color-accent: #EF4444; + --color-accent-hover: #F87171; + --color-accent-light: #7F1D1D; + --color-success: #34D399; + --color-success-hover: #6EE7B7; + --color-success-light: #064E3B; + --color-danger: #DC2626; + --color-danger-hover: #EF4444; + --color-danger-light: #7F1D1D; + --color-warning: #F59E0B; + --color-warning-hover: #FBBF24; + --color-warning-light: #78350F; + --color-info: #60A5FA; + --color-info-hover: #93C5FD; + --color-info-light: #1E3A8A; + + --color-bg-base: #1C1917; + --color-bg-subtle: #292524; + --color-bg-muted: #44403C; + --color-surface: #292524; + --color-surface-raised: #44403C; + + --color-border: #57534E; + --color-border-strong: #78716C; + + --color-text-base: #FEF3C7; + --color-text-muted: #FDE68A; + --color-text-subtle: #FCD34D; + + --navbar-bg: linear-gradient(135deg, #7C2D12 0%, #9A3412 50%, #C2410C 100%); + --navbar-text: #FEF3C7; + --navbar-text-muted: rgba(254, 243, 199, 0.8); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -2px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5); +} + +/* Midnight Theme - Deep dark blue */ +[data-theme="midnight"] { + --color-primary: #3B82F6; + --color-primary-hover: #60A5FA; + --color-primary-light: #1E3A8A; + --color-accent: #06B6D4; + --color-accent-hover: #22D3EE; + --color-accent-light: #164E63; + --color-success: #34D399; + --color-success-hover: #6EE7B7; + --color-success-light: #064E3B; + --color-danger: #F87171; + --color-danger-hover: #FCA5A5; + --color-danger-light: #7F1D1D; + --color-warning: #FBBF24; + --color-warning-hover: #FCD34D; + --color-warning-light: #78350F; + --color-info: #60A5FA; + --color-info-hover: #93C5FD; + --color-info-light: #1E3A8A; + + --color-bg-base: #020617; + --color-bg-subtle: #0F172A; + --color-bg-muted: #1E293B; + --color-surface: #0F172A; + --color-surface-raised: #1E293B; + + --color-border: #1E293B; + --color-border-strong: #334155; + + --color-text-base: #F1F5F9; + --color-text-muted: #CBD5E1; + --color-text-subtle: #94A3B8; + + --navbar-bg: linear-gradient(135deg, #0C4A6E 0%, #075985 100%); + --navbar-text: #F1F5F9; + --navbar-text-muted: rgba(241, 245, 249, 0.8); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -2px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5); +} + +/* Moonlight Theme - Cool and serene inspired by moonlight */ +[data-theme="moonlight"] { + --color-primary: #818CF8; + --color-primary-hover: #A5B4FC; + --color-primary-light: #312E81; + --color-accent: #A78BFA; + --color-accent-hover: #C4B5FD; + --color-accent-light: #4C1D95; + --color-success: #34D399; + --color-success-hover: #6EE7B7; + --color-success-light: #064E3B; + --color-danger: #F87171; + --color-danger-hover: #FCA5A5; + --color-danger-light: #7F1D1D; + --color-warning: #FBBF24; + --color-warning-hover: #FCD34D; + --color-warning-light: #78350F; + --color-info: #60A5FA; + --color-info-hover: #93C5FD; + --color-info-light: #1E3A8A; + + --color-bg-base: #0F0F23; + --color-bg-subtle: #1A1A2E; + --color-bg-muted: #252540; + --color-surface: #1A1A2E; + --color-surface-raised: #252540; + + --color-border: #3F3F5F; + --color-border-strong: #4F4F6F; + + --color-text-base: #E0E7FF; + --color-text-muted: #C7D2FE; + --color-text-subtle: #A5B4FC; + + --navbar-bg: linear-gradient(135deg, #1E1B4B 0%, #312E81 50%, #4C1D95 100%); + --navbar-text: #E0E7FF; + --navbar-text-muted: rgba(224, 231, 255, 0.8); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -2px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.5); +} + +/* Nord Theme - Popular Nordic color palette */ +[data-theme="nord"] { + --color-primary: #5E81AC; + --color-primary-hover: #81A1C1; + --color-primary-light: #D8DEE9; + --color-accent: #88C0D0; + --color-accent-hover: #8FBCBB; + --color-accent-light: #ECEFF4; + --color-success: #A3BE8C; + --color-success-hover: #B8D4A3; + --color-success-light: #E5E9F0; + --color-danger: #BF616A; + --color-danger-hover: #D08770; + --color-danger-light: #E5E9F0; + --color-warning: #EBCB8B; + --color-warning-hover: #F0D9A6; + --color-warning-light: #ECEFF4; + --color-info: #5E81AC; + --color-info-hover: #81A1C1; + --color-info-light: #D8DEE9; + + --color-bg-base: #2E3440; + --color-bg-subtle: #3B4252; + --color-bg-muted: #434C5E; + --color-surface: #3B4252; + --color-surface-raised: #434C5E; + + --color-border: #4C566A; + --color-border-strong: #5E6B82; + + --color-text-base: #ECEFF4; + --color-text-muted: #D8DEE9; + --color-text-subtle: #E5E9F0; + + --navbar-bg: linear-gradient(135deg, #2E3440 0%, #3B4252 100%); + --navbar-text: #ECEFF4; + --navbar-text-muted: rgba(236, 239, 244, 0.8); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4); +} + +/* Slate Theme - Modern slate gray design */ +[data-theme="slate"] { + --color-primary: #6366F1; + --color-primary-hover: #818CF8; + --color-primary-light: #312E81; + --color-accent: #FBBF24; + --color-accent-hover: #FCD34D; + --color-accent-light: #78350F; + --color-success: #34D399; + --color-success-hover: #6EE7B7; + --color-success-light: #064E3B; + --color-danger: #F87171; + --color-danger-hover: #FCA5A5; + --color-danger-light: #7F1D1D; + --color-warning: #FBBF24; + --color-warning-hover: #FCD34D; + --color-warning-light: #78350F; + --color-info: #60A5FA; + --color-info-hover: #93C5FD; + --color-info-light: #1E3A8A; + + --color-bg-base: #0F172A; + --color-bg-subtle: #1E293B; + --color-bg-muted: #334155; + --color-surface: #1E293B; + --color-surface-raised: #334155; + + --color-border: #334155; + --color-border-strong: #475569; + + --color-text-base: #F1F5F9; + --color-text-muted: #CBD5E1; + --color-text-subtle: #94A3B8; + + --navbar-bg: linear-gradient(135deg, #1E293B 0%, #334155 100%); + --navbar-text: #F1F5F9; + --navbar-text-muted: rgba(241, 245, 249, 0.8); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3); +} + +/* ========================================================================== + Light Themes + ========================================================================== */ + +/* Light Theme - Simple clean light design */ +[data-theme="light"] { + --color-primary: #0d6efd; + --color-primary-hover: #0b5ed7; + --color-primary-light: #cfe2ff; + --color-accent: #fd7e14; + --color-accent-hover: #e76c0c; + --color-accent-light: #ffe5d0; + --color-success: #198754; + --color-success-hover: #157347; + --color-success-light: #d1e7dd; + --color-danger: #dc3545; + --color-danger-hover: #bb2d3b; + --color-danger-light: #f8d7da; + --color-warning: #ffc107; + --color-warning-hover: #ffca2c; + --color-warning-light: #fff3cd; + --color-info: #0dcaf0; + --color-info-hover: #31d2f2; + --color-info-light: #cff4fc; + + --color-bg-base: #ffffff; + --color-bg-subtle: #f8f9fa; + --color-bg-muted: #e9ecef; + --color-surface: #ffffff; + --color-surface-raised: #ffffff; + + --color-border: #dee2e6; + --color-border-strong: #adb5bd; + + --color-text-base: #212529; + --color-text-muted: #6c757d; + --color-text-subtle: #adb5bd; + + --navbar-bg: #ffc400; + --navbar-text: #594400; + --navbar-text-muted: #7f6100; +} + +/* Forest Theme - Green nature tones */ +[data-theme="forest"] { + --color-primary: #10B981; + --color-primary-hover: #059669; + --color-primary-light: #D1FAE5; + --color-accent: #14B8A6; + --color-accent-hover: #0D9488; + --color-accent-light: #CCFBF1; + --color-success: #22C55E; + --color-success-hover: #16A34A; + --color-success-light: #DCFCE7; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-danger-light: #FEE2E2; + --color-warning: #F59E0B; + --color-warning-hover: #D97706; + --color-warning-light: #FEF3C7; + --color-info: #3B82F6; + --color-info-hover: #2563EB; + --color-info-light: #DBEAFE; + + --color-bg-base: #F0FDF4; + --color-bg-subtle: #DCFCE7; + --color-bg-muted: #BBF7D0; + --color-surface: #FFFFFF; + --color-surface-raised: #F0FDF4; + + --color-border: #86EFAC; + --color-border-strong: #4ADE80; + + --color-text-base: #14532D; + --color-text-muted: #166534; + --color-text-subtle: #15803D; + + --navbar-bg: linear-gradient(135deg, #10B981 0%, #14B8A6 100%); + --navbar-text: #FFFFFF; + --navbar-text-muted: rgba(255, 255, 255, 0.9); +} + +/* Indigo Theme - Modern indigo/purple design */ +[data-theme="indigo"] { + --color-primary: #4F46E5; + --color-primary-hover: #4338CA; + --color-primary-light: #EEF2FF; + --color-accent: #F59E0B; + --color-accent-hover: #D97706; + --color-accent-light: #FEF3C7; + --color-success: #10B981; + --color-success-hover: #059669; + --color-success-light: #D1FAE5; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-danger-light: #FEE2E2; + --color-warning: #F59E0B; + --color-warning-hover: #D97706; + --color-warning-light: #FEF3C7; + --color-info: #3B82F6; + --color-info-hover: #2563EB; + --color-info-light: #DBEAFE; + + --color-bg-base: #FFFFFF; + --color-bg-subtle: #F9FAFB; + --color-bg-muted: #F3F4F6; + --color-surface: #FFFFFF; + --color-surface-raised: #FFFFFF; + + --color-border: #E5E7EB; + --color-border-strong: #D1D5DB; + + --color-text-base: #111827; + --color-text-muted: #6B7280; + --color-text-subtle: #9CA3AF; + + --navbar-bg: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%); + --navbar-text: #FFFFFF; + --navbar-text-muted: rgba(255, 255, 255, 0.8); +} + +/* Lavender Theme - Soft purple tones */ +[data-theme="lavender"] { + --color-primary: #A78BFA; + --color-primary-hover: #8B5CF6; + --color-primary-light: #EDE9FE; + --color-accent: #C4B5FD; + --color-accent-hover: #A78BFA; + --color-accent-light: #DDD6FE; + --color-success: #10B981; + --color-success-hover: #059669; + --color-success-light: #D1FAE5; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-danger-light: #FEE2E2; + --color-warning: #F59E0B; + --color-warning-hover: #D97706; + --color-warning-light: #FEF3C7; + --color-info: #3B82F6; + --color-info-hover: #2563EB; + --color-info-light: #DBEAFE; + + --color-bg-base: #FAF5FF; + --color-bg-subtle: #F3E8FF; + --color-bg-muted: #E9D5FF; + --color-surface: #FFFFFF; + --color-surface-raised: #FAF5FF; + + --color-border: #D8B4FE; + --color-border-strong: #C084FC; + + --color-text-base: #4C1D95; + --color-text-muted: #5B21B6; + --color-text-subtle: #6D28D9; + + --navbar-bg: linear-gradient(135deg, #8B5CF6 0%, #A78BFA 100%); + --navbar-text: #FFFFFF; + --navbar-text-muted: rgba(255, 255, 255, 0.9); +} + +/* Monochrome Theme - Pure black and white */ +[data-theme="monochrome"] { + --color-primary: #18181B; + --color-primary-hover: #3F3F46; + --color-primary-light: #F4F4F5; + --color-accent: #52525B; + --color-accent-hover: #71717A; + --color-accent-light: #E4E4E7; + --color-success: #10B981; + --color-success-hover: #059669; + --color-success-light: #D1FAE5; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-danger-light: #FEE2E2; + --color-warning: #F59E0B; + --color-warning-hover: #D97706; + --color-warning-light: #FEF3C7; + --color-info: #3B82F6; + --color-info-hover: #2563EB; + --color-info-light: #DBEAFE; + + --color-bg-base: #FFFFFF; + --color-bg-subtle: #FAFAFA; + --color-bg-muted: #F4F4F5; + --color-surface: #FFFFFF; + --color-surface-raised: #FAFAFA; + + --color-border: #E4E4E7; + --color-border-strong: #D4D4D8; + + --color-text-base: #18181B; + --color-text-muted: #52525B; + --color-text-subtle: #71717A; + + --navbar-bg: linear-gradient(135deg, #18181B 0%, #27272A 100%); + --navbar-text: #FFFFFF; + --navbar-text-muted: rgba(255, 255, 255, 0.9); +} + +/* Ocean Theme - Cool blue tones */ +[data-theme="ocean"] { + --color-primary: #0EA5E9; + --color-primary-hover: #0284C7; + --color-primary-light: #E0F2FE; + --color-accent: #06B6D4; + --color-accent-hover: #0891B2; + --color-accent-light: #CFFAFE; + --color-success: #10B981; + --color-success-hover: #059669; + --color-success-light: #D1FAE5; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-danger-light: #FEE2E2; + --color-warning: #F59E0B; + --color-warning-hover: #D97706; + --color-warning-light: #FEF3C7; + --color-info: #3B82F6; + --color-info-hover: #2563EB; + --color-info-light: #DBEAFE; + + --color-bg-base: #F0F9FF; + --color-bg-subtle: #E0F2FE; + --color-bg-muted: #BAE6FD; + --color-surface: #FFFFFF; + --color-surface-raised: #F0F9FF; + + --color-border: #7DD3FC; + --color-border-strong: #38BDF8; + + --color-text-base: #0C4A6E; + --color-text-muted: #075985; + --color-text-subtle: #0369A1; + + --navbar-bg: linear-gradient(135deg, #0EA5E9 0%, #06B6D4 100%); + --navbar-text: #FFFFFF; + --navbar-text-muted: rgba(255, 255, 255, 0.9); +} + +/* Rose Theme - Elegant pink tones */ +[data-theme="rose"] { + --color-primary: #EC4899; + --color-primary-hover: #DB2777; + --color-primary-light: #FCE7F3; + --color-accent: #F472B6; + --color-accent-hover: #EC4899; + --color-accent-light: #FBCFE8; + --color-success: #10B981; + --color-success-hover: #059669; + --color-success-light: #D1FAE5; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-danger-light: #FEE2E2; + --color-warning: #F59E0B; + --color-warning-hover: #D97706; + --color-warning-light: #FEF3C7; + --color-info: #3B82F6; + --color-info-hover: #2563EB; + --color-info-light: #DBEAFE; + + --color-bg-base: #FFF1F2; + --color-bg-subtle: #FFE4E6; + --color-bg-muted: #FECDD3; + --color-surface: #FFFFFF; + --color-surface-raised: #FFF1F2; + + --color-border: #FBCFE8; + --color-border-strong: #F9A8D4; + + --color-text-base: #881337; + --color-text-muted: #9F1239; + --color-text-subtle: #BE123C; + + --navbar-bg: linear-gradient(135deg, #EC4899 0%, #F472B6 100%); + --navbar-text: #FFFFFF; + --navbar-text-muted: rgba(255, 255, 255, 0.9); +} + +/* Sunshine Theme - Warm and bright inspired by sunlight */ +[data-theme="sunshine"] { + --color-primary: #F59E0B; + --color-primary-hover: #D97706; + --color-primary-light: #FEF3C7; + --color-accent: #FB923C; + --color-accent-hover: #F97316; + --color-accent-light: #FFEDD5; + --color-success: #10B981; + --color-success-hover: #059669; + --color-success-light: #D1FAE5; + --color-danger: #EF4444; + --color-danger-hover: #DC2626; + --color-danger-light: #FEE2E2; + --color-warning: #F59E0B; + --color-warning-hover: #D97706; + --color-warning-light: #FEF3C7; + --color-info: #3B82F6; + --color-info-hover: #2563EB; + --color-info-light: #DBEAFE; + + --color-bg-base: #FFFBEB; + --color-bg-subtle: #FEF3C7; + --color-bg-muted: #FDE68A; + --color-surface: #FFFFFF; + --color-surface-raised: #FFFBEB; + + --color-border: #FDE047; + --color-border-strong: #FACC15; + + --color-text-base: #78350F; + --color-text-muted: #92400E; + --color-text-subtle: #B45309; + + --navbar-bg: linear-gradient(135deg, #F59E0B 0%, #FB923C 50%, #FBBF24 100%); + --navbar-text: #FFFFFF; + --navbar-text-muted: rgba(255, 255, 255, 0.9); +} + +/* ========================================================================== + Global Styles + ========================================================================== */ + +html { + background-color: var(--color-bg-base); + min-height: 100%; +} + +body { + background-color: var(--color-bg-base) !important; + color: var(--color-text-base) !important; + transition: background-color var(--transition-slow), color var(--transition-slow); + min-height: 100vh; +} + +/* Ensure containers inherit theme background */ +.container, +.container-fluid { + background-color: transparent; +} + +/* ========================================================================== + Bootstrap Component Overrides + ========================================================================== */ + +/* Force Bootstrap components to use our theme variables */ +.card { + background-color: var(--color-surface) !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + color: var(--color-text-base) !important; + transition: all var(--transition-base); +} + +.card .card-body { + padding: var(--spacing-xl); +} + +.card-header { + background-color: var(--color-bg-subtle) !important; + border-color: var(--color-border) !important; + color: var(--color-text-base) !important; +} + +.card-footer { + background-color: var(--color-bg-subtle) !important; + border-color: var(--color-border) !important; +} + +.modal-content { + background-color: var(--color-surface) !important; + border-color: var(--color-border) !important; +} + +.modal-header, +.modal-footer { + border-color: var(--color-border) !important; +} + +.list-group-item { + background-color: var(--color-surface) !important; + border-color: var(--color-border) !important; + color: var(--color-text-base) !important; +} + +.list-group-item:hover { + background-color: var(--color-bg-subtle) !important; +} + +.accordion-item { + background-color: var(--color-surface) !important; + border-color: var(--color-border) !important; +} + +.accordion-button { + background-color: var(--color-bg-subtle) !important; + color: var(--color-text-base) !important; +} + +.accordion-button:not(.collapsed) { + background-color: var(--color-bg-muted) !important; + color: var(--color-text-base) !important; +} + +.offcanvas { + background-color: var(--color-surface) !important; +} + +.offcanvas-header, +.offcanvas-body { + color: var(--color-text-base) !important; +} + +.toast { + background-color: var(--color-surface) !important; + border-color: var(--color-border) !important; +} + +.toast-header { + background-color: var(--color-bg-subtle) !important; + border-color: var(--color-border) !important; + color: var(--color-text-base) !important; +} + +.toast-body { + color: var(--color-text-base) !important; +} + +.popover { + background-color: var(--color-surface) !important; + border-color: var(--color-border) !important; +} + +.popover-header { + background-color: var(--color-bg-subtle) !important; + border-color: var(--color-border) !important; + color: var(--color-text-base) !important; +} + +.popover-body { + color: var(--color-text-base) !important; +} + +.tooltip-inner { + background-color: var(--color-bg-muted) !important; + color: var(--color-text-base) !important; +} + +.breadcrumb { + background-color: var(--color-bg-subtle) !important; +} + +.breadcrumb-item a { + color: var(--color-primary) !important; +} + +.breadcrumb-item.active { + color: var(--color-text-muted) !important; +} + +.pagination { + --bs-pagination-bg: var(--color-surface); + --bs-pagination-border-color: var(--color-border); + --bs-pagination-hover-bg: var(--color-bg-subtle); + --bs-pagination-hover-border-color: var(--color-border); + --bs-pagination-focus-bg: var(--color-bg-subtle); + --bs-pagination-disabled-bg: var(--color-bg-muted); + --bs-pagination-disabled-border-color: var(--color-border); +} + +.page-link { + color: var(--color-text-base) !important; +} + +/* ========================================================================== + Navbar Styles + ========================================================================== */ + +.navbar-sunshine { + background: var(--navbar-bg) !important; + box-shadow: var(--shadow-md); + border: none; + transition: all var(--transition-base); +} + +.navbar-sunshine .navbar-brand { + transition: transform var(--transition-fast); +} + +.navbar-sunshine .navbar-brand:hover { + transform: scale(1.05); +} + +.navbar-sunshine .nav-link { + color: var(--navbar-text-muted) !important; + font-weight: 500; + padding: 0.5rem 1rem !important; + border-radius: var(--radius-md); + transition: all var(--transition-fast); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.navbar-sunshine .nav-link:hover { + color: var(--navbar-text) !important; + background-color: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); +} + +.navbar-sunshine .nav-link.active { + color: var(--navbar-text) !important; + background-color: rgba(255, 255, 255, 0.15); + font-weight: 600; +} + +.navbar-sunshine .navbar-toggler { + color: var(--navbar-text) !important; + border-color: rgba(255, 255, 255, 0.2) !important; +} + +.navbar-sunshine .navbar-toggler:focus { + box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.15); +} + +/* ========================================================================== + Typography + ========================================================================== */ + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + letter-spacing: -0.025em; + color: var(--color-text-base); +} + +h1 { + font-size: 2.25rem; + line-height: 2.5rem; + margin-bottom: var(--spacing-lg); +} + +h2 { + font-size: 1.875rem; + line-height: 2.25rem; + margin-bottom: var(--spacing-md); +} + +h3 { + font-size: 1.5rem; + line-height: 2rem; + margin-bottom: var(--spacing-md); +} + +p { + line-height: 1.75; + color: var(--color-text-muted); +} + +/* ========================================================================== + Card Styles + ========================================================================== */ + +.card-sunshine { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); + overflow: hidden; +} + +.card-sunshine:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.card-sunshine .card-body { + padding: var(--spacing-xl); +} + +.card-sunshine .card-header { + background-color: var(--color-bg-subtle); + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-lg) var(--spacing-xl); + font-weight: 600; +} + +/* ========================================================================== + Alert Styles + ========================================================================== */ + +.alert { + border-radius: var(--radius-lg); + border: 1px solid; + padding: var(--spacing-lg); + /* Alerts are containers; keep them as block so their children stack naturally. */ + display: block; +} + +/* Use this helper when an alert needs icon + content aligned horizontally. */ +.alert-inline { + display: flex; + gap: var(--spacing-md); + align-items: flex-start; +} + +.alert-danger { + background-color: var(--color-danger-light); + border-color: var(--color-danger); + color: var(--color-danger); +} + +.alert-warning { + background-color: var(--color-warning-light); + border-color: var(--color-warning); + color: var(--color-warning); +} + +.alert-success { + background-color: var(--color-success-light); + border-color: var(--color-success); + color: var(--color-success); +} + +.alert-info { + background-color: var(--color-info-light); + border-color: var(--color-info); + color: var(--color-info); +} + +/* Apply readable alert colors to all dark themes */ +[data-bs-theme="dark"] .alert-danger, +[data-bs-theme="dark"] .alert-warning, +[data-bs-theme="dark"] .alert-success, +[data-bs-theme="dark"] .alert-info { + color: var(--color-text-base); +} + +/* ========================================================================== + Button Styles + ========================================================================== */ + +.btn { + font-weight: 600; + padding: 0.625rem 1.25rem; + border-radius: var(--radius-md); + border: none; + transition: all var(--transition-fast); + display: inline-flex; + align-items: center; + gap: 0.5rem; + box-shadow: var(--shadow-sm); +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.btn:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + +.btn-primary { + background-color: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--color-primary-hover); + color: white; +} + +.btn-success { + background-color: var(--color-success); + color: white; +} + +.btn-success:hover { + background-color: var(--color-success-hover); + color: white; +} + +.btn-danger { + background-color: var(--color-danger); + color: white; +} + +.btn-danger:hover { + background-color: var(--color-danger-hover); + color: white; +} + +.btn-warning { + background-color: var(--color-warning); + color: white; +} + +.btn-warning:hover { + background-color: var(--color-warning-hover); + color: white; +} + +.btn-secondary { + background-color: var(--color-bg-muted); + color: var(--color-text-base); +} + +.btn-secondary:hover { + background-color: var(--color-border-strong); + color: var(--color-text-base); +} + +/* Align SVG icons inside buttons. */ +.btn .icon, +.btn svg { + vertical-align: text-bottom; +} + +/* ========================================================================== + Form Styles + ========================================================================== */ + +.form-control, +.form-select { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.625rem 0.875rem; + color: var(--color-text-base); + transition: all var(--transition-fast); +} + +.form-control:focus, +.form-select:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background-color: var(--color-surface); + color: var(--color-text-base); +} + +.form-control::placeholder { + color: var(--color-text-subtle); + opacity: 0.7; +} + +.form-label { + font-weight: 600; + color: var(--color-text-base); + margin-bottom: var(--spacing-sm); +} + +.form-check-input { + border: 2px solid var(--color-border-strong); + transition: all var(--transition-fast); +} + +.form-check-input:checked { + background-color: var(--color-primary); + border-color: var(--color-primary); +} + +.form-check-input:focus { + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +/* ========================================================================== + Table Styles + ========================================================================== */ + +.table { + color: var(--color-text-base); +} + +.table thead th { + background-color: var(--color-bg-subtle); + border-bottom: 2px solid var(--color-border-strong); + font-weight: 700; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; + color: var(--color-text-muted); + padding: var(--spacing-md) var(--spacing-lg); } -@media (prefers-color-scheme: dark) { - .element { - color: var(--bs-primary-text-emphasis); - background-color: var(--bs-primary-bg-subtle); - } +.table tbody tr { + border-bottom: 1px solid var(--color-border); + transition: background-color var(--transition-fast); +} + +.table tbody tr:hover { + background-color: var(--color-bg-subtle); +} + +.table tbody td { + padding: var(--spacing-md) var(--spacing-lg); + vertical-align: middle; +} + +/* ========================================================================== + Navigation Tabs + ========================================================================== */ + +.nav-tabs { + border-bottom: 2px solid var(--color-border); + gap: 0.5rem; +} + +.nav-tabs .nav-link { + border: none; + border-bottom: 2px solid transparent; + color: var(--color-text-muted); + font-weight: 600; + padding: var(--spacing-md) var(--spacing-lg); + transition: all var(--transition-fast); + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.nav-tabs .nav-link:hover { + color: var(--color-text-base); + background-color: var(--color-bg-subtle); + border-bottom-color: var(--color-border-strong); +} + +.nav-tabs .nav-link.active { + color: var(--color-primary); + background-color: transparent; + border-bottom-color: var(--color-primary); +} + +/* ========================================================================== + Container & Layout + ========================================================================== */ + +#content { + padding-top: var(--spacing-xl); + padding-bottom: var(--spacing-3xl); +} + +.config-page { + padding: var(--spacing-xl); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-top: none; + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + +/* ========================================================================== + Utility Classes + ========================================================================== */ + +.section-spacing { + margin-top: var(--spacing-xl); + margin-bottom: var(--spacing-xl); +} + +.icon { + width: 1.25rem; + height: 1.25rem; + stroke-width: 2; +} + +.icon-lg { + width: 2rem; + height: 2rem; +} + +/* ========================================================================== + Specific Component Styles + ========================================================================== */ + +/* Troubleshooting Logs */ +.troubleshooting-logs { + white-space: pre; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Courier New', monospace; + overflow: auto; + max-height: 500px; + min-height: 500px; + font-size: 0.875rem; + position: relative; + background-color: var(--color-bg-muted); + padding: var(--spacing-lg); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); +} + +.troubleshooting-logs pre { + margin: 0; +} + +/* Log level highlighting */ +.log-line-info { + color: var(--color-text-base); +} + +.log-line-debug { + color: var(--color-text-muted); +} + +.log-line-warning { + color: #F59E0B; + font-weight: 500; +} + +.log-line-error, +.log-line-fatal { + color: #EF4444; + font-weight: 600; +} + +/* Log line colors for dark themes */ +[data-bs-theme="dark"] .log-line-info { + color: var(--color-text-base); +} + +[data-bs-theme="dark"] .log-line-debug { + color: var(--color-text-muted); +} + +[data-bs-theme="dark"] .log-line-warning { + color: #FBBF24; +} + +[data-bs-theme="dark"] .log-line-error, +[data-bs-theme="dark"] .log-line-fatal { + color: #F87171; +} + +/* Highlight for the currently selected error/warning entry */ +/* Warning level - yellow/amber alert style */ +.log-line-warning.log-entry-selected { + background-color: rgba(245, 158, 11, 0.2); + border-left: 4px solid #F59E0B; + padding-left: 0.75rem; + margin-left: -0.75rem; + box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3); +} + +/* Error/Fatal level - red alert style */ +.log-line-error.log-entry-selected, +.log-line-fatal.log-entry-selected { + background-color: rgba(239, 68, 68, 0.2); + border-left: 4px solid #EF4444; + padding-left: 0.75rem; + margin-left: -0.75rem; + box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.3); +} + +/* Dark theme variants */ +[data-bs-theme="dark"] .log-line-warning.log-entry-selected { + background-color: rgba(251, 191, 36, 0.25); + border-left-color: #FBBF24; + box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.4); +} + +[data-bs-theme="dark"] .log-line-error.log-entry-selected, +[data-bs-theme="dark"] .log-line-fatal.log-entry-selected { + background-color: rgba(248, 113, 113, 0.25); + border-left-color: #F87171; + box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.4); +} + +/* Overlay wrapper keeps controls out of normal flow so log text starts at the top. */ +.log-nav-overlay { + position: sticky; + top: 0; + z-index: 10; + + /* Don't push the log text down */ + height: 0; + + /* Let log text remain selectable/clickable under the overlay */ + pointer-events: none; } -/* Markdown body styling for release notes */ +.log-nav-overlay .log-nav-controls { + pointer-events: auto; + + /* Overlay the buttons in the top-right corner */ + position: absolute; + top: 0; + right: 0; + + display: inline-flex; + gap: var(--spacing-xs); + background: transparent; +} + +.log-nav-btn { + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + color: var(--color-text-muted); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + display: flex; + align-items: center; + gap: var(--spacing-xs); + font-size: 0.875rem; + white-space: nowrap; + font-weight: 500; +} + +.log-nav-btn svg { + flex-shrink: 0; +} + +.log-nav-btn:hover:not(:disabled) { + color: var(--color-text-base); + background-color: var(--color-bg-subtle); + transform: scale(1.05); +} + +.log-nav-btn:active:not(:disabled) { + transform: scale(0.95); +} + +.log-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.copy-icon { + position: absolute; + top: var(--spacing-md); + right: var(--spacing-md); + padding: var(--spacing-sm); + cursor: pointer; + color: var(--color-text-muted); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.copy-icon:hover { + color: var(--color-text-base); + background-color: var(--color-bg-subtle); + transform: scale(1.05); +} + +.copy-icon:active { + transform: scale(0.95); +} + +/* Cover Finder */ +.cover-finder .cover-results { + max-height: 400px; + overflow-x: hidden; + overflow-y: auto; + padding: var(--spacing-md); + background-color: var(--color-bg-subtle); + border-radius: var(--radius-md); +} + +.cover-finder .cover-results.busy * { + cursor: wait !important; + pointer-events: none; +} + +.cover-container { + padding-top: 133.33%; + position: relative; + border-radius: var(--radius-md); + overflow: hidden; + background-color: var(--color-bg-muted); +} + +.cover-container.result { + cursor: pointer; + transition: transform var(--transition-fast); +} + +.cover-container.result:hover { + transform: scale(1.02); +} + +.cover-container img { + display: block; + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.spinner-border { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + margin: auto; +} + +/* Environment Table */ +.env-table td { + padding: var(--spacing-sm); + border-bottom: 1px solid var(--color-border); + vertical-align: top; +} + +/* Monospace Text */ +.monospace { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Courier New', monospace; + font-size: 0.875rem; +} + +/* ========================================================================== + Markdown Body (for release notes) + ========================================================================== */ + .markdown-body { font-size: 1rem; line-height: 1.6; + color: var(--color-text-base); +} + +.markdown-body.release-notes { + width: 100%; + max-width: 100%; + column-count: 1 !important; + display: block !important; } .markdown-body h1, @@ -30,11 +1447,12 @@ margin-top: 1.5rem; margin-bottom: 1rem; font-weight: 600; + color: var(--color-text-base); } .markdown-body h2 { font-size: 1.5rem; - border-bottom: 1px solid rgba(var(--bs-body-color-rgb), 0.1); + border-bottom: 1px solid var(--color-border); padding-bottom: 0.3rem; } @@ -53,11 +1471,13 @@ } .markdown-body a { - color: var(--bs-link-color); + color: var(--color-primary); text-decoration: none; + transition: color var(--transition-fast); } .markdown-body a:hover { + color: var(--color-primary-hover); text-decoration: underline; } @@ -71,24 +1491,26 @@ padding: 0.2em 0.4em; margin: 0; font-size: 85%; - background-color: rgba(var(--bs-body-color-rgb), 0.05); - border-radius: 6px; + background-color: var(--color-bg-muted); + border-radius: var(--radius-sm); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Courier New', monospace; } .markdown-body pre { - padding: 1rem; + padding: var(--spacing-lg); overflow: auto; font-size: 85%; line-height: 1.45; - background-color: rgba(var(--bs-body-color-rgb), 0.05); - border-radius: 6px; + background-color: var(--color-bg-muted); + border-radius: var(--radius-md); margin-bottom: 1rem; + border: 1px solid var(--color-border); } .markdown-body blockquote { - padding: 0 1rem; - color: rgba(var(--bs-body-color-rgb), 0.65); - border-left: 0.25rem solid rgba(var(--bs-body-color-rgb), 0.15); + padding: 0 var(--spacing-lg); + color: var(--color-text-muted); + border-left: 0.25rem solid var(--color-primary); margin-bottom: 1rem; } @@ -96,22 +1518,485 @@ border-spacing: 0; border-collapse: collapse; margin-bottom: 1rem; + width: 100%; } .markdown-body table th, .markdown-body table td { padding: 6px 13px; - border: 1px solid rgba(var(--bs-body-color-rgb), 0.15); + border: 1px solid var(--color-border); +} + +.markdown-body table th { + background-color: var(--color-bg-subtle); + font-weight: 600; } .markdown-body table tr:nth-child(2n) { - background-color: rgba(var(--bs-body-color-rgb), 0.03); + background-color: rgba(0, 0, 0, 0.02); +} + +[data-bs-theme="dark"] .markdown-body table tr:nth-child(2n) { + background-color: rgba(255, 255, 255, 0.02); } .markdown-body hr { height: 0.25rem; padding: 0; margin: 1.5rem 0; - background-color: rgba(var(--bs-body-color-rgb), 0.1); + background-color: var(--color-border); border: 0; } + +/* ========================================================================== + Dropdown Styles + ========================================================================== */ + +.dropdown-menu { + background-color: var(--color-surface) !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: var(--spacing-sm); +} + +.dropdown-item { + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-sm); + color: var(--color-text-base) !important; + transition: all var(--transition-fast); +} + +.dropdown-item:hover { + background-color: var(--color-bg-subtle) !important; + color: var(--color-text-base) !important; +} + +.dropdown-item.active { + background-color: var(--color-primary-light); + color: var(--color-primary); +} + +[data-bs-theme="dark"] .dropdown-item.active { + background-color: var(--color-primary); + color: white; +} + +.dropdown-divider { + border-color: var(--color-border) !important; +} + +/* Add spacing between icons and text in dropdown items */ +.dropdown-item .icon, +.dropdown-item svg { + margin-right: 0.5rem; +} + +/* ========================================================================== + Responsive Adjustments + ========================================================================== */ + +@media (max-width: 768px) { + h1 { + font-size: 1.875rem; + line-height: 2.25rem; + } + + h2 { + font-size: 1.5rem; + line-height: 2rem; + } + + .card-sunshine .card-body, + .card .card-body { + padding: var(--spacing-lg); + } + + .btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + } +} + +/* ========================================================================== + Config Search Highlighting + ========================================================================== */ + +.config-search-highlight { + animation: highlightPulse 5s ease-in-out; + border-radius: var(--radius-md); +} + +@keyframes highlightPulse { + 0%, 100% { + background-color: transparent; + box-shadow: none; + } + 20%, 80% { + background-color: rgba(255, 215, 0, 0.4); + box-shadow: 0 0 0 4px rgba(255, 215, 0, 0.6); + } +} + +[data-bs-theme="dark"] .config-search-highlight { + animation: highlightPulseDark 5s ease-in-out; +} + +@keyframes highlightPulseDark { + 0%, 100% { + background-color: transparent; + box-shadow: none; + } + 20%, 80% { + background-color: rgba(255, 215, 0, 0.3); + box-shadow: 0 0 0 4px rgba(255, 215, 0, 0.5); + } +} + +/* ========================================================================== + App Cards + ========================================================================== */ + +.app-card { + transition: all var(--transition-base); + overflow: hidden; +} + +.app-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.app-poster-container { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + overflow: hidden; + background: var(--color-bg-muted); +} + +.app-poster { + width: 100%; + height: 100%; + object-fit: cover; +} + +.app-poster-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--color-primary-light), var(--color-accent-light)); +} + +[data-bs-theme="dark"] .app-poster-placeholder { + background: linear-gradient(135deg, rgba(79, 70, 229, 0.2), rgba(245, 158, 11, 0.2)); +} + +.app-initial { + font-size: 3rem; + font-weight: 700; + color: var(--color-primary); + opacity: 0.5; +} + +.app-details { + flex-grow: 1; +} + +.app-details > div { + margin-bottom: 0.25rem; + display: flex; + align-items: center; +} + +/* ========================================================================== + Featured Apps Page Styles + ========================================================================== */ + +.featured-app-card { + transition: transform var(--transition-base), box-shadow var(--transition-base); + overflow: hidden; +} + +.featured-app-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.featured-app-card .card-body { + overflow: hidden; +} + +.featured-app-icon { + width: 64px; + height: 64px; + min-width: 64px; + min-height: 64px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: var(--radius-md); +} + +.featured-app-icon img { + max-width: 64px !important; + max-height: 64px !important; + width: auto; + height: auto; + object-fit: contain; +} + +.featured-app-icon-placeholder { + width: 64px; + height: 64px; + background: var(--color-bg-muted); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); +} + +.featured-app-card .card-title { + font-weight: 600; + color: var(--color-text-base); +} + +.featured-app-card .text-muted.small { + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.min-w-0 { + min-width: 0; +} + +/* GitHub stats */ +.github-stats { + font-size: 0.875rem; + display: flex; + flex-wrap: wrap; + gap: var(--spacing-md); +} + +.github-stats span { + display: flex; + align-items: center; + gap: var(--spacing-xs); + white-space: nowrap; +} + +.github-stats svg { + flex-shrink: 0; +} + +/* Screenshot gallery */ +.screenshots-container { + overflow: hidden; + width: 100%; +} + +.screenshots-scroll { + display: flex; + gap: var(--spacing-sm); + overflow-x: auto; + scroll-behavior: smooth; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; + padding-bottom: var(--spacing-xs); +} + +.screenshots-scroll::-webkit-scrollbar { + height: 6px; +} + +.screenshots-scroll::-webkit-scrollbar-track { + background: var(--color-bg-muted); + border-radius: 3px; +} + +.screenshots-scroll::-webkit-scrollbar-thumb { + background: var(--color-border-strong); + border-radius: 3px; +} + +.screenshots-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-text-subtle); +} + +.screenshot-thumbnail { + height: 120px !important; + width: auto; + max-width: 200px !important; + min-width: 100px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: transform var(--transition-base), box-shadow var(--transition-base); + flex-shrink: 0; + object-fit: cover; + border: 1px solid var(--color-border); +} + +.screenshot-thumbnail:hover { + transform: scale(1.05); + box-shadow: var(--shadow-md); +} + +/* Screenshot modal */ +.screenshot-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.95); + display: flex; + align-items: center; + justify-content: center; + z-index: 1060; + cursor: pointer; + animation: fadeIn var(--transition-fast); + user-select: none; + -webkit-user-select: none; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.screenshot-modal-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + animation: zoomIn var(--transition-base); +} + +@keyframes zoomIn { + from { + transform: scale(0.9); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +.screenshot-modal-content img { + max-width: calc(100vw - 160px); + max-height: calc(100vh - 100px); + width: auto; + height: auto; + object-fit: contain; + cursor: default; + border-radius: var(--radius-md); +} + +.screenshot-close { + position: fixed; + top: var(--spacing-lg); + right: var(--spacing-lg); + z-index: 3; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + padding: var(--spacing-sm); + transition: background var(--transition-fast), transform var(--transition-fast); +} + +.screenshot-close:hover { + background: rgba(0, 0, 0, 0.8); + transform: scale(1.1); +} + +/* Screenshot navigation buttons */ +.screenshot-nav { + position: fixed; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.5); + border: none; + color: white; + padding: var(--spacing-lg); + cursor: pointer; + border-radius: var(--radius-md); + transition: background var(--transition-fast), transform var(--transition-fast); + z-index: 3; + display: flex; + align-items: center; + justify-content: center; +} + +.screenshot-nav:hover { + background: rgba(0, 0, 0, 0.8); + transform: translateY(-50%) scale(1.1); +} + +.screenshot-nav:active { + transform: translateY(-50%) scale(0.95); +} + +.screenshot-nav-prev { + left: var(--spacing-xl); +} + +.screenshot-nav-next { + right: var(--spacing-xl); +} + +/* Screenshot counter */ +.screenshot-counter { + position: fixed; + bottom: var(--spacing-xl); + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + color: white; + padding: var(--spacing-sm) var(--spacing-lg); + border-radius: var(--radius-md); + font-size: 0.875rem; + white-space: nowrap; + z-index: 3; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .screenshot-modal-content img { + max-width: calc(100vw - 80px); + max-height: calc(100vh - 80px); + } + + .screenshot-nav { + padding: var(--spacing-sm); + } + + .screenshot-nav-prev { + left: var(--spacing-sm); + } + + .screenshot-nav-next { + right: var(--spacing-sm); + } + + .screenshot-close { + top: var(--spacing-sm); + right: var(--spacing-sm); + } + + .screenshot-counter { + bottom: var(--spacing-sm); + } +} diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 18b72a49f41..7c611e6614c 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -1,10 +1,12 @@ { "_common": { + "all": "All", "apply": "Apply", "auto": "Automatic", "autodetect": "Autodetect (recommended)", "beta": "(beta)", "cancel": "Cancel", + "close": "Close", "disabled": "Disabled", "disabled_def": "Disabled (default)", "disabled_def_cbox": "Default: unchecked", @@ -15,10 +17,12 @@ "enabled_def": "Enabled (default)", "enabled_def_cbox": "Default: checked", "error": "Error!", + "loading": "Loading...", "note": "Note:", "password": "Password", "run_as": "Run as Admin", "save": "Save", + "search": "Search...", "see_more": "See More", "success": "Success!", "undo_cmd": "Undo Command", @@ -41,6 +45,7 @@ "cmd_prep_desc": "A list of commands to be run before/after this application. If any of the prep-commands fail, starting the application is aborted.", "cmd_prep_name": "Command Preparations", "covers_found": "Covers Found", + "cover_search_hint": "Search names should match IGDB naming conventions.", "delete": "Delete", "detached_cmds": "Detached Commands", "detached_cmds_add": "Add Detached Command", @@ -73,9 +78,11 @@ "image_desc": "Application icon/picture/image path that will be sent to client. Image must be a PNG file. If not set, Sunshine will send default box image.", "loading": "Loading...", "name": "Name", + "no_covers_found": "No covers found", "output_desc": "The file where the output of the command is stored, if it is not specified, the output is ignored", "output_name": "Output", "run_as_desc": "This can be necessary for some applications that require administrator permissions to run properly.", + "searching_covers": "Searching for covers...", "wait_all": "Continue streaming until all app processes exit", "wait_all_desc": "This will continue streaming until all processes started by the app have terminated. When unchecked, streaming will stop when the initial app process exits, even if other app processes are still running.", "working_dir": "Working Directory", @@ -334,6 +341,7 @@ "qsv_slow_hevc": "Allow Slow HEVC Encoding", "qsv_slow_hevc_desc": "This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU usage and worse performance.", "restart_note": "Sunshine is restarting to apply changes.", + "search_options": "Search configuration options...", "stream_audio": "Stream Audio", "stream_audio_desc": "Whether to stream audio or not. Disabling this can be useful for streaming headless displays as second monitors.", "sunshine_name": "Sunshine Name", @@ -398,12 +406,25 @@ "navbar": { "applications": "Applications", "configuration": "Configuration", + "featured": "Featured Apps", "home": "Home", "password": "Change Password", "pin": "PIN", "theme_auto": "Auto", "theme_dark": "Dark", + "theme_ember": "Ember", + "theme_forest": "Forest", + "theme_indigo": "Indigo", + "theme_lavender": "Lavender", "theme_light": "Light", + "theme_midnight": "Midnight", + "theme_monochrome": "Monochrome", + "theme_moonlight": "Moonlight", + "theme_nord": "Nord", + "theme_ocean": "Ocean", + "theme_rose": "Rose", + "theme_slate": "Slate", + "theme_sunshine": "Sunshine", "toggle_theme": "Theme", "troubleshoot": "Troubleshooting" }, @@ -468,6 +489,25 @@ "vigembus_force_reinstall_button": "Force Reinstall ViGEmBus v{version}", "vigembus_not_installed": "ViGEmBus is not installed." }, + "featured": { + "categories": { + "client": "Clients", + "tool": "Tools" + }, + "description": "Discover clients, tools, and integrations that enhance your Sunshine streaming experience.", + "docs": "Docs", + "documentation": "Documentation", + "get": "Get", + "github": "GitHub Repository", + "github_forks": "Forks", + "github_issues": "Open Issues", + "github_stars": "Stars", + "last_updated": "Last Updated", + "no_apps": "No apps found in this category.", + "official": "Official", + "title": "Featured Apps", + "website": "Website" + }, "welcome": { "confirm_password": "Confirm password", "create_creds": "Before Getting Started, we need you to make a new username and password for accessing the Web UI.", diff --git a/src_assets/common/assets/web/template_header.html b/src_assets/common/assets/web/template_header.html index 1a27f925404..a41642fc3e7 100644 --- a/src_assets/common/assets/web/template_header.html +++ b/src_assets/common/assets/web/template_header.html @@ -4,6 +4,5 @@ Sunshine - diff --git a/src_assets/common/assets/web/theme.js b/src_assets/common/assets/web/theme.js index a1f497802fa..a04c3e7c90d 100644 --- a/src_assets/common/assets/web/theme.js +++ b/src_assets/common/assets/web/theme.js @@ -10,14 +10,30 @@ export const getPreferredTheme = () => { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } +// Define which themes are dark (for Bootstrap compatibility) +const darkThemes = new Set([ + 'dark', + 'ember', + 'midnight', + 'moonlight', + 'nord', + 'slate', +]) + const setTheme = theme => { if (theme === 'auto') { - document.documentElement.setAttribute( - 'data-bs-theme', - (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') - ) + const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + document.documentElement.dataset.bsTheme = preferredTheme + document.documentElement.dataset.theme = preferredTheme + console.log(`Theme set to auto (resolved to: ${preferredTheme})`) } else { - document.documentElement.setAttribute('data-bs-theme', theme) + // Set Bootstrap's data-bs-theme to 'light' or 'dark' for Bootstrap's own styles + const bsTheme = darkThemes.has(theme) ? 'dark' : 'light' + document.documentElement.dataset.bsTheme = bsTheme + + // Set our custom data-theme attribute for our color schemes + document.documentElement.dataset.theme = theme + console.log(`Theme set to: ${theme} (Bootstrap: ${bsTheme})`) } } @@ -29,9 +45,18 @@ export const showActiveTheme = (theme, focus = false) => { } const themeSwitcherText = document.querySelector('#bd-theme-text') - const activeThemeIcon = document.querySelector('.theme-icon-active i') + const activeThemeIcon = document.querySelector('.theme-icon-active svg') const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`) - const classListOfActiveBtn = btnToActive.querySelector('i').classList + + if (!btnToActive) { + return + } + + const btnIcon = btnToActive.querySelector('svg') + + if (!activeThemeIcon || !btnIcon) { + return + } document.querySelectorAll('[data-bs-theme-value]').forEach(element => { element.classList.remove('active') @@ -40,8 +65,11 @@ export const showActiveTheme = (theme, focus = false) => { btnToActive.classList.add('active') btnToActive.setAttribute('aria-pressed', 'true') - activeThemeIcon.classList.remove(...activeThemeIcon.classList.values()) - activeThemeIcon.classList.add(...classListOfActiveBtn) + + // Clone the SVG icon from the active button to the theme switcher + const clonedIcon = btnIcon.cloneNode(true) + activeThemeIcon.parentNode.replaceChild(clonedIcon, activeThemeIcon) + const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.textContent.trim()})` themeSwitcher.setAttribute('aria-label', themeSwitcherLabel) @@ -72,7 +100,8 @@ export function loadAutoTheme() { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { const storedTheme = getStoredTheme() - if (storedTheme !== 'light' && storedTheme !== 'dark') { + // Only auto-switch if theme is set to 'auto' + if (storedTheme === 'auto' || !storedTheme) { setTheme(getPreferredTheme()) } }) diff --git a/src_assets/common/assets/web/troubleshooting.html b/src_assets/common/assets/web/troubleshooting.html index 0b23a45511f..c33b3db24bc 100644 --- a/src_assets/common/assets/web/troubleshooting.html +++ b/src_assets/common/assets/web/troubleshooting.html @@ -3,37 +3,6 @@ <%- header %> - @@ -41,27 +10,31 @@

{{ $t('troubleshooting.troubleshooting') }}

-
+

{{ $t('troubleshooting.vigembus_install') }}

-

{{ $t('troubleshooting.vigembus_desc') }}

{{ $t('troubleshooting.vigembus_current_version') }}: {{ vigembus.version }}

+ {{ $t('troubleshooting.vigembus_compatible') }}
+ {{ $t('troubleshooting.vigembus_incompatible') }}
+ {{ $t('troubleshooting.vigembus_not_installed') }}
+ {{ $t('troubleshooting.vigembus_install_success') }}
+ {{ vigemBusInstallError || $t('troubleshooting.vigembus_install_error') }}
@@ -69,65 +42,67 @@

{{ $t('troubleshooting.vigembus_install') }}

:class="vigembus.installed && vigembus.version === vigembus.packaged_version ? 'btn btn-danger' : 'btn btn-primary'" :disabled="vigemBusInstallPressed" @click="installViGEmBus"> - - + + + {{ vigembus.installed && vigembus.version === vigembus.packaged_version ? $t('troubleshooting.vigembus_force_reinstall_button', { version: vigembus.packaged_version }) : $t('troubleshooting.vigembus_install_button', { version: vigembus.packaged_version }) }}
-
+

{{ $t('troubleshooting.force_close') }}

-

{{ $t('troubleshooting.force_close_desc') }}

+ {{ $t('troubleshooting.force_close_success') }}
+ {{ $t('troubleshooting.force_close_error') }}
-
+

{{ $t('troubleshooting.restart_sunshine') }}

-

{{ $t('troubleshooting.restart_sunshine_desc') }}

+ {{ $t('troubleshooting.restart_sunshine_success') }}
-
+

{{ $t('troubleshooting.dd_reset') }}

-

{{ $t('troubleshooting.dd_reset_desc') }}

+ {{ $t('troubleshooting.dd_reset_success') }}
+ {{ $t('troubleshooting.dd_reset_error') }}
@@ -136,50 +111,77 @@

{{ $t('troubleshooting.dd_reset') }}

-
-
-

{{ $t('troubleshooting.unpair_title') }}

- -
-
-

{{ $t('troubleshooting.unpair_desc') }}

-
-
{{ $t('_common.success') }} {{ $t('troubleshooting.unpair_single_success') }}
- -
-
- {{ $t('troubleshooting.unpair_all_success') }} -
-
- {{ $t('troubleshooting.unpair_all_error') }} -
+
+

{{ $t('troubleshooting.unpair_title') }}

+
-
-
    -
    -
    {{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}
    -
    +

    {{ $t('troubleshooting.unpair_desc') }}

    +
    + +
    {{ $t('_common.success') }} {{ $t('troubleshooting.unpair_single_success') }}
    +
    +
    + + {{ $t('troubleshooting.unpair_all_success') }} +
    +
    + + {{ $t('troubleshooting.unpair_all_error') }} +
    +
    +
      +
    • +
      {{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}
      + +
    -
      -
      {{ $t('troubleshooting.unpair_single_no_devices') }}
      +
        +
      • + {{ $t('troubleshooting.unpair_single_no_devices') }} +
      -
-
+

{{ $t('troubleshooting.logs') }}

-

{{ $t('troubleshooting.logs_desc') }}

- +
+ + + + +
-
- {{actualLogs}} +
+
+
+ + + + + +
+
+

           
@@ -190,10 +192,40 @@

{{ $t('troubleshooting.logs') }}

import { createApp } from 'vue' import { initApp } from './init' import Navbar from './Navbar.vue' + import { + AlertCircle, + AlertTriangle, + CheckCircle, + ChevronDown, + ChevronUp, + ChevronsDown, + ChevronsUp, + Copy, + Download, + RefreshCw, + RotateCcw, + Search, + Trash2, + XCircle, + } from 'lucide-vue-next' const app = createApp({ components: { - Navbar + Navbar, + AlertCircle, + AlertTriangle, + CheckCircle, + ChevronDown, + ChevronUp, + ChevronsDown, + ChevronsUp, + Copy, + Download, + RefreshCw, + RotateCcw, + Search, + Trash2, + XCircle, }, data() { return { @@ -220,14 +252,126 @@

{{ $t('troubleshooting.logs') }}

vigemBusInstallPressed: false, vigemBusInstallStatus: null, vigemBusInstallError: null, + currentLogIndex: -1, + logLines: [], }; }, computed: { actualLogs() { if (!this.logFilter) return this.logs; - let lines = this.logs.split("\n"); - lines = lines.filter(x => x.indexOf(this.logFilter) !== -1); - return lines.join("\n"); + const filterLower = this.logFilter.toLowerCase(); + return this.logs + .split("\n") + .filter((x) => x.toLowerCase().includes(filterLower)) + .join("\n"); + }, + + /** + * Parse the (possibly multi-line) log output into timestamp-prefixed entries. + * Each entry starts with: [YYYY-MM-DD HH:MM:SS.mmm]: + */ + parsedLogEntries() { + const text = this.actualLogs || ''; + + // Match on timestamp tokens, but keep everything between them as the entry body. + // Using a global exec loop lets us split without losing delimiters and works + // even when entries span multiple lines. + const tsRegex = /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]:/g; + + const entries = []; + const matches = Array.from(text.matchAll(tsRegex)); + + // If no timestamps are found, treat everything as a single entry. + if (matches.length === 0) { + const raw = text.trimEnd(); + if (!raw) return []; + return [ + { + index: 0, + raw, + level: 'Info', + cssClass: 'log-line-info', + }, + ]; + } + + for (let i = 0; i < matches.length; i++) { + const start = matches[i].index; + const end = i + 1 < matches.length ? matches[i + 1].index : text.length; + const raw = text.slice(start, end).trimEnd(); + if (!raw) continue; + + // Determine level based on the *first* level token in the entry. + // Sunshine logs are typically: "[ts]: Level: message". + // Some messages may contain additional embedded timestamps, but we treat + // those as part of the entry content. + let level = 'Info'; + let cssClass = 'log-line-info'; + + if (/\]:\s*Fatal:/i.test(raw)) { + level = 'Fatal'; + cssClass = 'log-line-fatal'; + } else if (/\]:\s*(Error|Critical):/i.test(raw)) { + level = 'Error'; + cssClass = 'log-line-error'; + } else if (/\]:\s*Warning:/i.test(raw)) { + level = 'Warning'; + cssClass = 'log-line-warning'; + } else if (/\]:\s*Debug:/i.test(raw)) { + level = 'Debug'; + cssClass = 'log-line-debug'; + } + + entries.push({ + index: entries.length, + raw, + level, + cssClass, + }); + } + + return entries; + }, + + highlightedLogs() { + const escapeHtml = (s) => + s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + + return this.parsedLogEntries + .map((entry) => { + const safe = escapeHtml(entry.raw); + const isSelected = entry.index === this.currentLogIndex; + const selectedClass = isSelected ? ' log-entry-selected' : ''; + return `${safe}`; + }) + // Separate entries visually with a newline. + .join("\n"); + }, + + errorWarningEntries() { + // Only navigate between warnings/errors/fatal/critical + return this.parsedLogEntries + .filter((e) => e.level === 'Warning' || e.level === 'Error' || e.level === 'Fatal') + .map((e) => e.index); + }, + + hasNextLog() { + const indices = this.errorWarningEntries; + if (indices.length === 0) return false; + if (this.currentLogIndex === -1) return true; + return indices.some((i) => i > this.currentLogIndex); + }, + + hasPrevLog() { + const indices = this.errorWarningEntries; + if (indices.length === 0) return false; + if (this.currentLogIndex === -1) return false; + return indices.some((i) => i < this.currentLogIndex); } }, created() { @@ -323,6 +467,7 @@

{{ $t('troubleshooting.logs') }}

this.showApplyMessage = false; }, copyLogs() { + // Copy the filtered view if a filter is active. navigator.clipboard.writeText(this.actualLogs); }, restart() { @@ -403,6 +548,62 @@

{{ $t('troubleshooting.logs') }}

}, 10000); }); }, + navigateToLog(direction) { + const indices = this.errorWarningEntries; + if (indices.length === 0) return; + + let targetIndex; + + if (direction === 'next') { + if (this.currentLogIndex === -1) { + targetIndex = indices[0]; + } else { + const nextIndices = indices.filter((i) => i > this.currentLogIndex); + if (nextIndices.length === 0) return; + targetIndex = nextIndices[0]; + } + } else if (direction === 'prev') { + if (this.currentLogIndex === -1) return; + const prevIndices = indices.filter((i) => i < this.currentLogIndex); + if (prevIndices.length === 0) return; + targetIndex = prevIndices[prevIndices.length - 1]; + } else { + return; + } + + this.currentLogIndex = targetIndex; + + this.$nextTick(() => { + const container = this.$refs.logsContainer; + if (!container) return; + + const el = container.querySelector(`[data-entry-index="${targetIndex}"]`); + if (!el) return; + + // Ensure it's visible even for tall multi-line entries. + const containerRect = container.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const relativeTop = elRect.top - containerRect.top; + + container.scrollTop = container.scrollTop + relativeTop - containerRect.height * 0.15; + }); + }, + scrollLogsTo(where) { + const container = this.$refs.logsContainer; + if (!container) return; + + // Reset the selected error/warning index when jumping to top or bottom + this.currentLogIndex = -1; + + if (where === 'top') { + container.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + + if (where === 'bottom') { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + } + }, }, }); diff --git a/vite.config.js b/vite.config.js index 7bfc0cf85cb..c430a437134 100644 --- a/vite.config.js +++ b/vite.config.js @@ -68,6 +68,7 @@ export default defineConfig({ input: { apps: resolve(assetsSrcPath, 'apps.html'), config: resolve(assetsSrcPath, 'config.html'), + featured: resolve(assetsSrcPath, 'featured.html'), index: resolve(assetsSrcPath, 'index.html'), password: resolve(assetsSrcPath, 'password.html'), pin: resolve(assetsSrcPath, 'pin.html'),