feat(web-ui): UI consistency / layout uplifts (#5225)

Co-authored-by: David Lane <42013603+ReenigneArcher@users.noreply.github.com>
This commit is contained in:
Noklef
2026-06-27 09:37:38 +10:00
committed by GitHub
parent cc34eb1410
commit 3266c34172
12 changed files with 920 additions and 248 deletions
+59 -43
View File
@@ -4,17 +4,25 @@ import { onMounted } from 'vue'
import {
CloudMoon,
CloudRain,
Coffee,
Contrast,
Droplet,
Flame,
Flower,
Flower2,
Ghost,
Layers,
Milk,
MonitorSmartphone,
Moon,
Mountain,
Shuffle,
Sparkles,
Sprout,
Sun,
Sunrise,
Sunset,
TreePine,
Trees,
Waves,
} from '@lucide/vue'
@@ -38,96 +46,104 @@ onMounted(() => {
</span>
<span id="bd-theme-text">{{ $t('navbar.toggle_theme') }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme-text">
<li>
<ul class="dropdown-menu dropdown-menu-end theme-menu" aria-labelledby="bd-theme-text">
<li class="theme-menu-full">
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto" aria-pressed="true">
<MonitorSmartphone :size="18" class="theme-icon icon"></MonitorSmartphone>
{{ $t('navbar.theme_auto') }}
</button>
<button type="button" id="bd-theme-random" class="dropdown-item d-flex align-items-center">
<Shuffle :size="18" class="theme-icon icon"></Shuffle>
{{ $t('navbar.theme_random') }}
</button>
</li>
<li><hr class="dropdown-divider"></li>
<!-- Dark Themes -->
<li>
<li class="theme-menu-group">
<h6 class="dropdown-header">{{ $t('navbar.theme_group_dark') }}</h6>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false">
<Moon :size="18" class="theme-icon icon"></Moon>
{{ $t('navbar.theme_dark') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="slate" aria-pressed="false">
<Layers :size="18" class="theme-icon icon"></Layers>
{{ $t('navbar.theme_slate') }}
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dracula" aria-pressed="false">
<Ghost :size="18" class="theme-icon icon"></Ghost>
{{ $t('navbar.theme_dracula') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="moonlight" aria-pressed="false">
<CloudMoon :size="18" class="theme-icon icon"></CloudMoon>
{{ $t('navbar.theme_moonlight') }}
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="mocha" aria-pressed="false">
<Coffee :size="18" class="theme-icon icon"></Coffee>
{{ $t('navbar.theme_mocha') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="midnight" aria-pressed="false">
<CloudRain :size="18" class="theme-icon icon"></CloudRain>
{{ $t('navbar.theme_midnight') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="ember" aria-pressed="false">
<Flame :size="18" class="theme-icon icon"></Flame>
{{ $t('navbar.theme_ember') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="rose-pine" aria-pressed="false">
<TreePine :size="18" class="theme-icon icon"></TreePine>
{{ $t('navbar.theme_rose_pine') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="moonlight" aria-pressed="false">
<CloudMoon :size="18" class="theme-icon icon"></CloudMoon>
{{ $t('navbar.theme_moonlight') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="slate" aria-pressed="false">
<Layers :size="18" class="theme-icon icon"></Layers>
{{ $t('navbar.theme_slate') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="midnight" aria-pressed="false">
<CloudRain :size="18" class="theme-icon icon"></CloudRain>
{{ $t('navbar.theme_midnight') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="nord" aria-pressed="false">
<Mountain :size="18" class="theme-icon icon"></Mountain>
{{ $t('navbar.theme_nord') }}
</button>
</li>
<li><hr class="dropdown-divider"></li>
<!-- Light Themes -->
<li>
<li class="theme-menu-group">
<h6 class="dropdown-header">{{ $t('navbar.theme_group_light') }}</h6>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false">
<Sun :size="18" class="theme-icon icon"></Sun>
{{ $t('navbar.theme_light') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="indigo" aria-pressed="false">
<Sparkles :size="18" class="theme-icon icon"></Sparkles>
{{ $t('navbar.theme_indigo') }}
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="alucard" aria-pressed="false">
<Droplet :size="18" class="theme-icon icon"></Droplet>
{{ $t('navbar.theme_alucard') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="latte" aria-pressed="false">
<Milk :size="18" class="theme-icon icon"></Milk>
{{ $t('navbar.theme_latte') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="ember-light" aria-pressed="false">
<Sunset :size="18" class="theme-icon icon"></Sunset>
{{ $t('navbar.theme_ember_light') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="rose-pine-dawn" aria-pressed="false">
<Sprout :size="18" class="theme-icon icon"></Sprout>
{{ $t('navbar.theme_rose_pine_dawn') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="sunshine" aria-pressed="false">
<Sunrise :size="18" class="theme-icon icon"></Sunrise>
{{ $t('navbar.theme_sunshine') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="indigo" aria-pressed="false">
<Sparkles :size="18" class="theme-icon icon"></Sparkles>
{{ $t('navbar.theme_indigo') }}
</button>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="ocean" aria-pressed="false">
<Waves :size="18" class="theme-icon icon"></Waves>
{{ $t('navbar.theme_ocean') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="forest" aria-pressed="false">
<Trees :size="18" class="theme-icon icon"></Trees>
{{ $t('navbar.theme_forest') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="rose" aria-pressed="false">
<Flower :size="18" class="theme-icon icon"></Flower>
{{ $t('navbar.theme_rose') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="lavender" aria-pressed="false">
<Flower2 :size="18" class="theme-icon icon"></Flower2>
{{ $t('navbar.theme_lavender') }}
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="monochrome" aria-pressed="false">
<Contrast :size="18" class="theme-icon icon"></Contrast>
{{ $t('navbar.theme_monochrome') }}
+20 -13
View File
@@ -7,14 +7,14 @@
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<div id="content" class="container">
<div class="my-4">
<h1>{{ $t('apps.applications_title') }}<span v-if="apps.length"> ({{ appCountLabel }})</span></h1>
<p>{{ $t('apps.applications_desc') }}</p>
</div>
<!-- Actions toolbar -->
<div class="apps-toolbar d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div class="toolbar apps-toolbar d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<!-- Left side actions -->
<div class="d-flex align-items-center gap-2">
<!-- Add new application -->
@@ -63,19 +63,26 @@
<div v-else class="app-poster-placeholder">
<span class="app-initial">{{ app.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="app-poster-overlay">
<div v-if="app.cmd" class="app-overlay-row" :title="app.cmd">
<terminal :size="14" class="icon me-1"></terminal>
<span class="app-detail-text">{{ app.cmd }}</span>
</div>
<div v-if="app['working-dir']" class="app-overlay-row" :title="app['working-dir']">
<folder :size="14" class="icon me-1"></folder>
<span class="app-detail-text">{{ app['working-dir'] }}</span>
</div>
<div class="app-overlay-badges">
<span v-if="app.elevated" class="badge app-flag-badge">{{ $t('apps.badge_admin') }}</span>
<span v-if="app.detached && app.detached.length" class="badge app-flag-badge">{{ $t('apps.badge_detached') }}</span>
<span v-if="app['prep-cmd'] && app['prep-cmd'].length" class="badge app-flag-badge">{{ $t('apps.badge_app_prep') }}</span>
<span v-if="app['exclude-global-prep-cmd']" class="badge app-flag-badge">{{ $t('apps.badge_no_global_prep') }}</span>
<span v-if="app['auto-detach']" class="badge app-flag-badge">{{ $t('apps.badge_auto_detach') }}</span>
</div>
</div>
</div>
<div class="card-body d-flex flex-column">
<h5 class="card-title mb-2">{{ app.name }}</h5>
<div class="app-details text-muted small mb-3">
<div v-if="app.output" class="text-truncate">
<file-text :size="14" class="icon me-1"></file-text>
{{ app.output }}
</div>
<div v-if="app.cmd" class="text-truncate">
<terminal :size="14" class="icon me-1"></terminal>
{{ app.cmd }}
</div>
</div>
<h5 class="card-title mb-3">{{ app.name }}</h5>
<div class="mt-auto d-flex gap-2">
<button class="btn btn-sm btn-primary flex-fill" @click="editApp(index)">
<edit :size="16" class="icon"></edit>
+59 -28
View File
@@ -7,12 +7,15 @@
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<h1 class="my-4">{{ $t('config.configuration') }}</h1>
<div id="content" class="container">
<div class="my-4">
<h1>{{ $t('config.configuration') }}</h1>
<p>{{ $t('config.configuration_desc') }}</p>
</div>
<!-- Search Bar with Autocomplete -->
<div class="mb-3">
<div class="input-group">
<div class="toolbar mb-3 d-flex flex-wrap align-items-center gap-3">
<div class="input-group config-search">
<span class="input-group-text">
<search :size="18" class="icon"></search>
</span>
@@ -30,18 +33,20 @@
{{ option.tab }} - {{ option.label }}
</option>
</datalist>
<div v-if="searchQuery && searchResults.length === 0" class="text-muted small mt-1">
<span v-if="searchQuery && searchResults.length === 0" class="text-muted small flex-shrink-0">
No results found for "{{ searchQuery }}"
</div>
<div v-else-if="searchQuery" class="text-muted small mt-1">
</span>
<span v-else-if="searchQuery" class="text-muted small flex-shrink-0">
Found {{ searchResults.length }} result(s)
</div>
</span>
</div>
<div class="form" v-if="config">
<!-- Header -->
<ul class="nav nav-tabs">
<li class="nav-item" v-for="tab in tabs" :key="tab.id">
<div class="config-layout">
<!-- Sidebar navigation -->
<nav class="config-nav">
<ul class="nav config-nav-list">
<li class="nav-item" v-for="tab in generalTabs" :key="tab.id">
<a class="nav-link" :class="{'active': tab.id === currentTab}" href="#"
@click="currentTab = tab.id">
<component :is="getTabIcon(tab.id)" :size="18" class="icon"></component>
@@ -49,6 +54,39 @@
</a>
</li>
</ul>
<template v-if="encoderTabs.length">
<div class="config-nav-heading">{{ $t('config.encoders') }}</div>
<ul class="nav config-nav-list">
<li class="nav-item" v-for="tab in encoderTabs" :key="tab.id">
<a class="nav-link" :class="{'active': tab.id === currentTab}" href="#"
@click="currentTab = tab.id">
<component :is="getTabIcon(tab.id)" :size="18" class="icon"></component>
{{tab.name}}
</a>
</li>
</ul>
</template>
<div class="config-actions">
<button class="btn btn-primary" @click="save">
<save :size="18" class="icon"></save>
{{ $t('_common.save') }}
</button>
<button class="btn btn-success" @click="apply" v-if="saved && !restarted">
<check :size="18" class="icon"></check>
{{ $t('_common.apply') }}
</button>
</div>
</nav>
<!-- Tab content -->
<div class="config-content">
<div class="alert alert-success mb-4" v-if="saved && !restarted">
<b>{{ $t('_common.success') }}</b> {{ $t('config.apply_note') }}
</div>
<div class="alert alert-success mb-4" v-if="restarted">
<b>{{ $t('_common.success') }}</b> {{ $t('config.restart_note') }}
</div>
<!-- General Tab -->
<general
@@ -99,24 +137,9 @@
:platform="platform">
</container-encoders>
</div>
</div>
</div>
<!-- Save and Apply buttons -->
<div class="alert alert-success my-4" v-if="saved && !restarted">
<b>{{ $t('_common.success') }}</b> {{ $t('config.apply_note') }}
</div>
<div class="alert alert-success my-4" v-if="restarted">
<b>{{ $t('_common.success') }}</b> {{ $t('config.restart_note') }}
</div>
<div class="mb-3 d-flex gap-2 mt-4">
<button class="btn btn-primary" @click="save">
<save :size="18" class="icon"></save>
{{ $t('_common.save') }}
</button>
<button class="btn btn-success" @click="apply" v-if="saved && !restarted">
<check :size="18" class="icon"></check>
{{ $t('_common.apply') }}
</button>
</div>
</div>
</body>
@@ -148,6 +171,8 @@
Volume2,
} from '@lucide/vue'
const ENCODER_TAB_IDS = new Set(["nv", "amd", "qsv", "vaapi", "vt", "vulkan", "sw"]);
const app = createApp({
components: {
Navbar,
@@ -360,6 +385,12 @@
}
},
computed: {
generalTabs() {
return this.tabs.filter(tab => !ENCODER_TAB_IDS.has(tab.id));
},
encoderTabs() {
return this.tabs.filter(tab => ENCODER_TAB_IDS.has(tab.id));
},
allConfigOptions() {
const options = [];
this.tabs.forEach(tab => {
+2 -2
View File
@@ -7,14 +7,14 @@
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<div id="content" class="container">
<div class="my-4">
<h1>{{ $t('featured.title') }}</h1>
<p>{{ $t('featured.description') }}</p>
</div>
<!-- Category Filter -->
<div class="mb-4">
<div class="toolbar mb-4">
<div class="btn-group" role="group" aria-label="Category filter">
<button
type="button"
+5
View File
@@ -1,5 +1,10 @@
import i18n from './locale'
import 'bootstrap/dist/css/bootstrap.min.css'
// Load Sunshine.css after bootstrap to override some of the styles.
// Makes themes load and style correctly.
import './sunshine.css'
// must import even if not implicitly using here
// https://github.com/aurelia/skeleton-navigation/issues/894
// https://discourse.aurelia.io/t/bootstrap-import-bootstrap-breaks-dropdown-menu-in-navbar/641/9
+3
View File
@@ -27,8 +27,11 @@
<script type="module">
import { createApp } from 'vue'
import { initApp } from './init'
import { loadAutoTheme } from './theme'
import { LogIn } from '@lucide/vue'
loadAutoTheme();
const app = createApp({
components: {
LogIn,
+5 -2
View File
@@ -7,8 +7,11 @@
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<h1 class="my-4">{{ $t('password.password_change') }}</h1>
<div id="content" class="container">
<div class="my-4">
<h1>{{ $t('password.password_change') }}</h1>
<p>{{ $t('password.password_change_desc') }}</p>
</div>
<form @submit.prevent="save">
<div class="card">
<div class="card-body">
@@ -42,6 +42,11 @@
"applications_title": "Applications",
"auto_detach": "Continue streaming if the application exits quickly",
"auto_detach_desc": "This will attempt to automatically detect launcher-type apps that close quickly after launching another program or instance of themselves. When a launcher-type app is detected, it is treated as a detached app.",
"badge_admin": "Admin",
"badge_app_prep": "App prep",
"badge_auto_detach": "Auto-detach",
"badge_detached": "Detached",
"badge_no_global_prep": "No global prep",
"cmd": "Command",
"cmd_desc": "The main application to start. If blank, no application will be started.",
"cmd_note": "If the path to the command executable contains spaces, you must enclose it in double quotes.",
@@ -169,6 +174,7 @@
"coder_cabac": "cabac -- context adaptive binary arithmetic coding - higher quality",
"coder_cavlc": "cavlc -- context adaptive variable-length coding - faster decode",
"configuration": "Configuration",
"configuration_desc": "Configure Sunshine's host settings to match your streaming setup.",
"controller": "Enable Gamepad Input",
"controller_desc": "Allows guests to control the host system with a gamepad / controller",
"credentials_file": "Credentials File",
@@ -223,6 +229,7 @@
"encoder": "Force a Specific Encoder",
"encoder_desc": "Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected.",
"encoder_software": "Software",
"encoders": "Encoders",
"external_ip": "External IP",
"external_ip_desc": "If no external IP address is given, Sunshine will automatically detect external IP",
"fec_percentage": "FEC Percentage",
@@ -440,19 +447,29 @@
"logout": "Log Out",
"password": "Change Password",
"pin": "PIN",
"theme_alucard": "Alucard",
"theme_auto": "Auto",
"theme_dark": "Dark",
"theme_dracula": "Dracula",
"theme_ember": "Ember",
"theme_ember_light": "Ember Light",
"theme_forest": "Forest",
"theme_group_dark": "Dark Themes",
"theme_group_light": "Light Themes",
"theme_indigo": "Indigo",
"theme_latte": "Latte",
"theme_lavender": "Lavender",
"theme_light": "Light",
"theme_midnight": "Midnight",
"theme_mocha": "Mocha",
"theme_monochrome": "Monochrome",
"theme_moonlight": "Moonlight",
"theme_nord": "Nord",
"theme_ocean": "Ocean",
"theme_random": "Random",
"theme_rose": "Rose",
"theme_rose_pine": "Rosé Pine",
"theme_rose_pine_dawn": "Rosé Pine Dawn",
"theme_slate": "Slate",
"theme_sunshine": "Sunshine",
"toggle_theme": "Theme",
@@ -469,6 +486,7 @@
"new_creds": "New Credentials",
"new_username_desc": "If not specified, the username will not change",
"password_change": "Password Change",
"password_change_desc": "Update the username and password used to sign in to the Sunshine web UI. You'll need to sign in again after changing them.",
"success_msg": "Password has been changed successfully! This page will reload soon, your browser will ask you for the new credentials."
},
"pin": {
@@ -4,5 +4,3 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sunshine</title>
<link rel="icon" type="image/x-icon" href="./images/sunshine.ico">
<link href="bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="./assets/css/sunshine.css" rel="stylesheet" />
+23 -6
View File
@@ -13,10 +13,13 @@ export const getPreferredTheme = () => {
// Define which themes are dark (for Bootstrap compatibility)
const darkThemes = new Set([
'dark',
'dracula',
'ember',
'midnight',
'mocha',
'moonlight',
'nord',
'rose-pine',
'slate',
])
@@ -78,16 +81,30 @@ export const showActiveTheme = (theme, focus = false) => {
}
}
export function setupThemeToggleListener() {
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
const applyTheme = theme => {
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
}
const pickRandomTheme = () => {
const current = getStoredTheme()
const values = Array.from(document.querySelectorAll('[data-bs-theme-value]'))
.map(el => el.dataset.bsThemeValue)
.filter(value => value !== 'auto' && value !== current)
return values[Math.floor(Math.random() * values.length)] // NOSONAR(javascript:S2245) random not used for cryptography here
}
export function setupThemeToggleListener() {
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => applyTheme(toggle.dataset.bsThemeValue))
})
})
const randomToggle = document.querySelector('#bd-theme-random')
if (randomToggle) {
randomToggle.addEventListener('click', () => applyTheme(pickRandomTheme()))
}
showActiveTheme(getPreferredTheme(), false)
}
@@ -7,7 +7,7 @@
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<div id="content" class="container">
<h1 class="my-4">{{ $t('troubleshooting.troubleshooting') }}</h1>
<!-- ViGEmBus Installation -->
<div class="card my-4" v-if="platform === 'windows' && controllerEnabled">