fix(web-ui): modernize UI (#4631)

This commit is contained in:
David Lane
2026-01-29 10:16:37 -05:00
committed by GitHub
parent 76b3a8596f
commit 3ce39b36d0
19 changed files with 3529 additions and 456 deletions

View File

@@ -3,29 +3,50 @@
<head>
<%- header %>
<style>
.config-page {
padding: 1em;
border: 1px solid #dee2e6;
border-top: none;
}
.buttons {
padding: 1em 0;
}
</style>
</head>
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<h1 class="my-4">{{ $t('config.configuration') }}</h1>
<!-- Search Bar with Autocomplete -->
<div class="mb-3">
<div class="input-group">
<span class="input-group-text">
<search :size="18" class="icon"></search>
</span>
<input
type="text"
class="form-control"
v-model="searchQuery"
:placeholder="$t('config.search_options')"
@input="handleSearch"
list="config-options"
/>
</div>
<datalist id="config-options">
<option v-for="option in allConfigOptions" :key="option.key" :value="option.label">
{{ option.tab }} - {{ option.label }}
</option>
</datalist>
<div v-if="searchQuery && searchResults.length === 0" class="text-muted small mt-1">
No results found for "{{ searchQuery }}"
</div>
<div v-else-if="searchQuery" class="text-muted small mt-1">
Found {{ searchResults.length }} result(s)
</div>
</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">
<a class="nav-link" :class="{'active': tab.id === currentTab}" href="#"
@click="currentTab = tab.id">{{tab.name}}</a>
@click="currentTab = tab.id">
<component :is="getTabIcon(tab.id)" :size="18" class="icon"></component>
{{tab.name}}
</a>
</li>
</ul>
@@ -86,9 +107,15 @@
<div class="alert alert-success my-4" v-if="restarted">
<b>{{ $t('_common.success') }}</b> {{ $t('config.restart_note') }}
</div>
<div class="mb-3 buttons">
<button class="btn btn-primary mr-3" @click="save">{{ $t('_common.save') }}</button>
<button class="btn btn-success" @click="apply" v-if="saved && !restarted">{{ $t('_common.apply') }}</button>
<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>
@@ -106,6 +133,19 @@
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 @@
// 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 @@
restarted: false,
config: null,
currentTab: "general",
searchQuery: "",
tabs: [ // TODO: Move the options to each Component instead, encapsulate.
{
id: "general",
@@ -290,9 +343,34 @@
},
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 @@
});
},
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 @@
}
});
},
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