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

This commit is contained in:
David Lane 2026-01-29 10:16:37 -05:00 committed by GitHub
commit 3ce39b36d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 3529 additions and 456 deletions

View file

@ -4,7 +4,7 @@ Read our contribution guide in our organization level
## Recommended Tools ## Recommended Tools
| Tool | Description | | Tool | Description |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| <a href="https://www.jetbrains.com/clion/"><img src="https://resources.jetbrains.com/storage/products/company/brand/logos/CLion_icon.svg" width="30" height="30"></a><br>CLion | Recommended IDE for C and C++ development. Free for non-commercial use. | | <a href="https://www.jetbrains.com/clion/"><img src="https://resources.jetbrains.com/storage/products/company/brand/logos/CLion_icon.svg" width="30" height="30"></a><br>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 * [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`). (check `template_header.html` and `template_header_main.html`).
* The Style System is provided by [Bootstrap](https://getbootstrap.com). * 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). * The JS framework used by the more interactive pages is [Vus.js](https://vuejs.org).
#### Building #### Building

View file

@ -10,9 +10,12 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@lizardbyte/shared-web": "2025.922.181114", "@lizardbyte/shared-web": "2025.922.181114",
"date-fns": "4.1.0",
"lucide-vue-next": "0.563.0",
"marked": "17.0.1", "marked": "17.0.1",
"vue": "3.5.27", "vue": "3.5.27",
"vue-i18n": "11.2.8" "vue-i18n": "11.2.8",
"vue3-simple-icons": "15.6.0"
}, },
"devDependencies": { "devDependencies": {
"@codecov/vite-plugin": "1.9.1", "@codecov/vite-plugin": "1.9.1",

View file

@ -387,6 +387,26 @@ namespace confighttp {
response->write(content, headers); 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. * @brief Get the password page.
* @param response The HTTP response object. * @param response The HTTP response object.
@ -955,9 +975,6 @@ namespace confighttp {
* @api_examples{/api/covers/9999 | GET| null} * @api_examples{/api/covers/9999 | GET| null}
*/ */
void getCover(resp_https_t response, req_https_t request) { void getCover(resp_https_t response, req_https_t request) {
if (!check_content_type(response, request, "application/json")) {
return;
}
if (!authenticate(response, request)) { if (!authenticate(response, request)) {
return; return;
} }
@ -986,6 +1003,13 @@ namespace confighttp {
// This handles extension validation, PNG signature validation, and path resolution // This handles extension validation, PNG signature validation, and path resolution
std::string validated_path = proc::validate_app_image_path(app_image_path); 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 // Open and stream the validated file
std::ifstream in(validated_path, std::ios::binary); std::ifstream in(validated_path, std::ios::binary);
if (!in) { if (!in) {
@ -1410,6 +1434,7 @@ namespace confighttp {
server.resource["^/apps/?$"]["GET"] = getAppsPage; server.resource["^/apps/?$"]["GET"] = getAppsPage;
server.resource["^/clients/?$"]["GET"] = getClientsPage; server.resource["^/clients/?$"]["GET"] = getClientsPage;
server.resource["^/config/?$"]["GET"] = getConfigPage; server.resource["^/config/?$"]["GET"] = getConfigPage;
server.resource["^/featured/?$"]["GET"] = getFeaturedPage;
server.resource["^/password/?$"]["GET"] = getPasswordPage; server.resource["^/password/?$"]["GET"] = getPasswordPage;
server.resource["^/welcome/?$"]["GET"] = getWelcomePage; server.resource["^/welcome/?$"]["GET"] = getWelcomePage;
server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage; server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage;

View file

@ -1,5 +1,5 @@
<template> <template>
<nav class="navbar navbar-light navbar-expand-lg navbar-background header"> <nav class="navbar navbar-expand-lg navbar-sunshine">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="./" title="Sunshine"> <a class="navbar-brand" href="./" title="Sunshine">
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine"> <img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
@ -11,22 +11,46 @@
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="./"><i class="fas fa-fw fa-home"></i> {{ $t('navbar.home') }}</a> <a class="nav-link" href="./">
<Home :size="18" class="icon"></Home>
{{ $t('navbar.home') }}
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="./pin"><i class="fas fa-fw fa-unlock"></i> {{ $t('navbar.pin') }}</a> <a class="nav-link" href="./pin">
<Lock :size="18" class="icon"></Lock>
{{ $t('navbar.pin') }}
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="./apps"><i class="fas fa-fw fa-stream"></i> {{ $t('navbar.applications') }}</a> <a class="nav-link" href="./apps">
<Layers :size="18" class="icon"></Layers>
{{ $t('navbar.applications') }}
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="./config"><i class="fas fa-fw fa-cog"></i> {{ $t('navbar.configuration') }}</a> <a class="nav-link" href="./featured">
<Star :size="18" class="icon"></Star>
{{ $t('navbar.featured') }}
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="./password"><i class="fas fa-fw fa-user-shield"></i> {{ $t('navbar.password') }}</a> <a class="nav-link" href="./config">
<Settings :size="18" class="icon"></Settings>
{{ $t('navbar.configuration') }}
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="./troubleshooting"><i class="fas fa-fw fa-info"></i> {{ $t('navbar.troubleshoot') }}</a> <a class="nav-link" href="./password">
<Shield :size="18" class="icon"></Shield>
{{ $t('navbar.password') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="./troubleshooting">
<Info :size="18" class="icon"></Info>
{{ $t('navbar.troubleshoot') }}
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<ThemeToggle/> <ThemeToggle/>
@ -38,10 +62,20 @@
</template> </template>
<script> <script>
import { Home, Lock, Layers, Star, Settings, Shield, Info } from 'lucide-vue-next'
import ThemeToggle from './ThemeToggle.vue' import ThemeToggle from './ThemeToggle.vue'
export default { export default {
components: { ThemeToggle }, components: {
ThemeToggle,
Home,
Lock,
Layers,
Star,
Settings,
Shield,
Info
},
created() { created() {
console.log("Header mounted!") console.log("Header mounted!")
}, },
@ -53,34 +87,8 @@ export default {
</script> </script>
<style> <style>
.navbar-background { /* Navbar toggler icon for dark text on light background */
background-color: #ffc400 .navbar-sunshine .navbar-toggler-icon {
} --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.9%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important;
.header .nav-link {
color: rgba(0, 0, 0, .65) !important;
}
.header .nav-link.active {
color: rgb(0, 0, 0) !important;
font-weight: 500;
}
.header .nav-link:hover {
color: rgb(0, 0, 0) !important;
font-weight: 500;
}
.header .navbar-toggler {
color: rgba(var(--bs-dark-rgb), .65) !important;
border: var(--bs-border-width) solid rgba(var(--bs-dark-rgb), 0.15) !important;
}
.header .navbar-toggler-icon {
--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") !important;
}
.form-control::placeholder {
opacity: 0.5;
} }
</style> </style>

View file

@ -1,33 +1,59 @@
<template> <template>
<div class="card p-2"> <div class="card">
<div class="card-body"> <div class="card-body">
<h2>{{ $t('resource_card.resources') }}</h2> <h2>{{ $t('resource_card.resources') }}</h2>
<br>
<p>{{ $t('resource_card.resources_desc') }}</p> <p>{{ $t('resource_card.resources_desc') }}</p>
<div class="card-group p-4 align-items-center"> <div class="d-flex flex-wrap gap-2 mt-4">
<a class="btn btn-success m-1" href="https://app.lizardbyte.dev" target="_blank"> <a class="btn btn-success" href="https://app.lizardbyte.dev" target="_blank">
{{ $t('resource_card.lizardbyte_website') }}</a> <Globe :size="18" class="icon"></Globe>
<a class="btn btn-primary m-1" href="https://app.lizardbyte.dev/discord" target="_blank"> {{ $t('resource_card.lizardbyte_website') }}
<i class="fab fa-fw fa-discord"></i> Discord</a> </a>
<a class="btn btn-secondary m-1" href="https://github.com/orgs/LizardByte/discussions" target="_blank"> <a class="btn btn-primary" href="https://app.lizardbyte.dev/discord" target="_blank">
<i class="fab fa-fw fa-github"></i> {{ $t('resource_card.github_discussions') }}</a> <SimpleIcon icon="Discord" :size="18" class="icon"></SimpleIcon>
Discord
</a>
<a class="btn btn-secondary" href="https://github.com/orgs/LizardByte/discussions" target="_blank">
<SimpleIcon icon="GitHub" :size="18" class="icon"></SimpleIcon>
{{ $t('resource_card.github_discussions') }}
</a>
</div> </div>
</div> </div>
</div> </div>
<!-- Legal --> <!-- Legal -->
<div class="card p-2 mt-4"> <div class="card mt-4">
<div class="card-body"> <div class="card-body">
<h2>{{ $t('resource_card.legal') }}</h2> <h2>{{ $t('resource_card.legal') }}</h2>
<br>
<p>{{ $t('resource_card.legal_desc') }}</p> <p>{{ $t('resource_card.legal_desc') }}</p>
<div class="card-group p-4 align-items-center"> <div class="d-flex flex-wrap gap-2 mt-4">
<a class="btn btn-danger m-1" href="https://github.com/LizardByte/Sunshine/blob/master/LICENSE" <a class="btn btn-danger" href="https://github.com/LizardByte/Sunshine/blob/master/LICENSE"
target="_blank"> target="_blank">
<i class="fas fa-fw fa-file-alt"></i> {{ $t('resource_card.license') }}</a> <FileText :size="18" class="icon"></FileText>
<a class="btn btn-danger m-1" href="https://github.com/LizardByte/Sunshine/blob/master/NOTICE" {{ $t('resource_card.license') }}
</a>
<a class="btn btn-danger" href="https://github.com/LizardByte/Sunshine/blob/master/NOTICE"
target="_blank"> target="_blank">
<i class="fas fa-fw fa-exclamation"></i> {{ $t('resource_card.third_party_notice') }}</a> <AlertCircle :size="18" class="icon"></AlertCircle>
{{ $t('resource_card.third_party_notice') }}
</a>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script>
import {
AlertCircle,
FileText,
Globe,
} from 'lucide-vue-next'
import SimpleIcon from './SimpleIcon.vue'
export default {
components: {
SimpleIcon,
AlertCircle,
FileText,
Globe,
}
}
</script>

View file

@ -0,0 +1,44 @@
<template>
<component
v-if="iconComponent"
:is="iconComponent"
:size="size"
:class="className"
/>
</template>
<script setup>
import { computed } from 'vue'
import { GitHubIcon, DiscordIcon } from 'vue3-simple-icons'
const props = defineProps({
icon: {
type: String,
required: true,
default: 'GitHub'
},
size: {
type: [Number, String],
default: 24
},
className: {
type: String,
default: ''
}
})
// Map icon names to actual components
const iconMap = {
'GitHub': GitHubIcon,
'Discord': DiscordIcon,
}
const iconComponent = computed(() => {
const component = iconMap[props.icon]
if (!component) {
console.error(`Icon "${props.icon}" not found in SimpleIcon mapping`)
return null
}
return component
})
</script>

View file

@ -1,6 +1,23 @@
<script setup> <script setup>
import { loadAutoTheme, setupThemeToggleListener } from './theme' import { loadAutoTheme, setupThemeToggleListener } from './theme'
import { onMounted } from 'vue' import { onMounted } from 'vue'
import {
CloudMoon,
CloudRain,
Contrast,
Flame,
Flower,
Flower2,
Layers,
MonitorSmartphone,
Moon,
Mountain,
Sparkles,
Sun,
Sunrise,
Trees,
Waves,
} from 'lucide-vue-next'
onMounted(() => { onMounted(() => {
loadAutoTheme() loadAutoTheme()
@ -10,32 +27,110 @@ onMounted(() => {
<template> <template>
<div class="dropdown bd-mode-toggle"> <div class="dropdown bd-mode-toggle">
<a class="nav-link dropdown-toggle align-items-center" <a class="nav-link dropdown-toggle d-flex align-items-center"
id="bd-theme" id="bd-theme"
type="button" type="button"
aria-expanded="false" aria-expanded="false"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-label="{{ $t('navbar.toggle_theme') }} ({{ $t('navbar.theme_auto') }})"> aria-label="{{ $t('navbar.toggle_theme') }} ({{ $t('navbar.theme_auto') }})">
<span class="bi my-1 theme-icon-active"><i class="fa-solid fa-circle-half-stroke"></i></span> <span class="theme-icon-active">
<MonitorSmartphone :size="18" class="icon"></MonitorSmartphone>
</span>
<span id="bd-theme-text">{{ $t('navbar.toggle_theme') }}</span> <span id="bd-theme-text">{{ $t('navbar.toggle_theme') }}</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme-text"> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme-text">
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false"> <button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto" aria-pressed="true">
<i class="bi me-2 theme-icon fas fa-fw fa-solid fa-sun"></i> <MonitorSmartphone :size="18" class="theme-icon icon"></MonitorSmartphone>
{{ $t('navbar.theme_light') }} {{ $t('navbar.theme_auto') }}
</button> </button>
</li> </li>
<li><hr class="dropdown-divider"></li>
<!-- Dark Themes -->
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false"> <button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false">
<i class="bi me-2 theme-icon fas fa-fw fa-solid fa-moon"></i> <Moon :size="18" class="theme-icon icon"></Moon>
{{ $t('navbar.theme_dark') }} {{ $t('navbar.theme_dark') }}
</button> </button>
</li> </li>
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto" aria-pressed="true"> <button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="slate" aria-pressed="false">
<i class="bi me-2 theme-icon fas fa-fw fa-solid fa-circle-half-stroke"></i> <Layers :size="18" class="theme-icon icon"></Layers>
{{ $t('navbar.theme_auto') }} {{ $t('navbar.theme_slate') }}
</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>
</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="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>
<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>
</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="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') }}
</button> </button>
</li> </li>
</ul> </ul>

View file

@ -3,71 +3,6 @@
<head> <head>
<%- header %> <%- header %>
<style>
.precmd-head {
width: 200px;
}
.monospace {
font-family: monospace;
}
.cover-finder {}
.cover-finder .cover-results {
max-height: 400px;
overflow-x: hidden;
overflow-y: auto;
}
.cover-finder .cover-results.busy * {
cursor: wait !important;
pointer-events: none;
}
.cover-container {
padding-top: 133.33%;
position: relative;
}
.cover-container.result {
cursor: pointer;
}
.spinner-border {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
margin: auto;
}
.cover-container img {
display: block;
position: absolute;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.config-page {
padding: 1em;
border: 1px solid #dee2e6;
border-top: none;
}
td {
padding: 0 0.5em;
}
.env-table td {
padding: 0.25em;
border-bottom: rgba(0, 0, 0, 0.25) 1px solid;
vertical-align: top;
}
</style>
</head> </head>
<body id="app" v-cloak> <body id="app" v-cloak>
@ -75,33 +10,60 @@
<div class="container"> <div class="container">
<div class="my-4"> <div class="my-4">
<h1>{{ $t('apps.applications_title') }}</h1> <h1>{{ $t('apps.applications_title') }}</h1>
<div>{{ $t('apps.applications_desc') }}</div> <p>{{ $t('apps.applications_desc') }}</p>
</div> </div>
<div class="card p-4">
<table class="table"> <!-- Apps Grid -->
<thead> <div class="row g-3" v-if="apps && apps.length > 0">
<tr> <div class="col-12 col-sm-6 col-md-4 col-lg-3" v-for="(app,i) in apps" :key="i">
<th scope="col">{{ $t('apps.name') }}</th> <div class="card app-card h-100">
<th scope="col">{{ $t('apps.actions') }}</th> <div class="app-poster-container">
</tr> <img
</thead> v-if="app['image-path']"
<tbody> :src="'/api/covers/' + i"
<tr v-for="(app,i) in apps" :key="i"> class="app-poster"
<td>{{app.name}}</td> :alt="app.name"
<td> @error="handleImageError"
<button class="btn btn-primary mx-1" @click="editApp(i)"> />
<i class="fas fa-edit"></i> {{ $t('apps.edit') }} <div v-else class="app-poster-placeholder">
<span class="app-initial">{{ app.name.charAt(0).toUpperCase() }}</span>
</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>
<div class="mt-auto d-flex gap-2">
<button class="btn btn-sm btn-primary flex-fill" @click="editApp(i)">
<edit :size="16" class="icon"></edit>
{{ $t('apps.edit') }}
</button> </button>
<button class="btn btn-danger mx-1" @click="showDeleteForm(i)"> <button class="btn btn-sm btn-danger" @click="showDeleteForm(i)">
<i class="fas fa-trash"></i> {{ $t('apps.delete') }} <trash-2 :size="16" class="icon"></trash-2>
</button> </button>
</td> </div>
</tr> </div>
</tbody> </div>
</table> </div>
</div> </div>
<div class="edit-form card mt-2" v-if="showEditForm">
<div class="p-4"> <!-- Empty State -->
<div v-else class="card">
<div class="card-body text-center py-5">
<p class="text-muted">{{ $t('apps.no_applications') }}</p>
</div>
</div>
<div class="edit-form card mt-4" v-if="showEditForm">
<div class="card-body">
<!-- Application Name --> <!-- Application Name -->
<div class="mb-3"> <div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.app_name') }}</label> <label for="appName" class="form-label">{{ $t('apps.app_name') }}</label>
@ -129,16 +91,24 @@
<div class="form-text">{{ $t('apps.cmd_prep_desc') }}</div> <div class="form-text">{{ $t('apps.cmd_prep_desc') }}</div>
<div class="d-flex justify-content-start mb-3 mt-3" v-if="editForm['prep-cmd'].length === 0"> <div class="d-flex justify-content-start mb-3 mt-3" v-if="editForm['prep-cmd'].length === 0">
<button class="btn btn-success" @click="addPrepCmd"> <button class="btn btn-success" @click="addPrepCmd">
<i class="fas fa-plus mr-1"></i> {{ $t('apps.add_cmds') }} <plus :size="18" class="icon"></plus>
{{ $t('apps.add_cmds') }}
</button> </button>
</div> </div>
<table class="table" v-if="editForm['prep-cmd'].length > 0"> <table class="table" v-if="editForm['prep-cmd'].length > 0">
<thead> <thead>
<tr> <tr>
<th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th> <th scope="col">
<th scope="col"><i class="fas fa-undo"></i> {{ $t('_common.undo_cmd') }}</th> <play :size="18" class="icon"></play>
{{ $t('_common.do_cmd') }}
</th>
<th scope="col">
<rotate-ccw :size="18" class="icon"></rotate-ccw>
{{ $t('_common.undo_cmd') }}
</th>
<th scope="col" v-if="platform === 'windows'"> <th scope="col" v-if="platform === 'windows'">
<i class="fas fa-shield-alt"></i> {{ $t('_common.run_as') }} <shield :size="18" class="icon"></shield>
{{ $t('_common.run_as') }}
</th> </th>
<th scope="col"></th> <th scope="col"></th>
</tr> </tr>
@ -158,12 +128,12 @@
v-model="c.elevated" v-model="c.elevated"
></Checkbox> ></Checkbox>
</td> </td>
<td> <td class="align-middle">
<button class="btn btn-danger" @click="editForm['prep-cmd'].splice(i,1)"> <button class="btn btn-danger btn-sm ms-2" @click="deletePrepCmd(i)">
<i class="fas fa-trash"></i> <trash-2 :size="16" class="icon"></trash-2>
</button> </button>
<button class="btn btn-success" @click="addPrepCmd"> <button class="btn btn-success btn-sm ms-2" @click="addPrepCmd">
<i class="fas fa-plus"></i> <plus :size="16" class="icon"></plus>
</button> </button>
</td> </td>
</tr> </tr>
@ -173,15 +143,19 @@
<!-- detached --> <!-- detached -->
<div class="mb-3"> <div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.detached_cmds') }}</label> <label for="appName" class="form-label">{{ $t('apps.detached_cmds') }}</label>
<div v-for="(c,i) in editForm.detached" class="d-flex justify-content-between my-2"> <div v-for="(c,i) in editForm.detached" class="d-flex justify-content-between align-items-center my-2">
<input type="text" v-model="editForm.detached[i]" class="form-control monospace"> <input type="text" v-model="editForm.detached[i]" class="form-control monospace">
<button class="btn btn-danger mx-2" @click="editForm.detached.splice(i,1)"> <button class="btn btn-danger btn-sm ms-2" @click="editForm.detached.splice(i,1)">
&times; <trash-2 :size="16" class="icon"></trash-2>
</button>
<button class="btn btn-success btn-sm ms-2" @click="addDetached">
<plus :size="16" class="icon"></plus>
</button> </button>
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-start mb-3 mt-3" v-if="editForm.detached.length === 0">
<button class="btn btn-success" @click="editForm.detached.push('');"> <button class="btn btn-success" @click="addDetached">
<i class="fas fa-plus mr-1"></i> {{ $t('apps.detached_cmds_add') }} <plus :size="18" class="icon"></plus>
{{ $t('apps.detached_cmds_add') }}
</button> </button>
</div> </div>
<div class="form-text"> <div class="form-text">
@ -240,42 +214,79 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="appImagePath" class="form-label">{{ $t('apps.image') }}</label> <label for="appImagePath" class="form-label">{{ $t('apps.image') }}</label>
<div class="input-group dropup"> <div class="input-group">
<input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp" <input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp"
v-model="editForm['image-path']" /> v-model="editForm['image-path']" />
<button class="btn btn-secondary dropdown-toggle" type="button" id="findCoverToggle" <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#coverFinderModal"
aria-expanded="false" @click="showCoverFinder" ref="coverFinderDropdown"> @click="showCoverFinder">
<search :size="18" class="icon"></search>
{{ $t('apps.find_cover') }} {{ $t('apps.find_cover') }}
</button> </button>
<div class="dropdown-menu dropdown-menu-end w-50 cover-finder overflow-hidden" </div>
aria-labelledby="findCoverToggle"> <div id="appImagePathHelp" class="form-text">{{ $t('apps.image_desc') }}</div>
<div class="modal-header px-2"> </div>
<h4 class="modal-title">{{ $t('apps.covers_found') }}</h4>
<button type="button" class="btn-close mr-2" aria-label="Close" @click="closeCoverFinder"></button> <!-- Cover Finder Modal -->
<div class="modal fade" id="coverFinderModal" tabindex="-1" aria-labelledby="coverFinderModalLabel" aria-hidden="true" ref="coverFinderModal">
<div class="modal-dialog modal-xl modal-dialog-scrollable modal-fullscreen-md-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="coverFinderModalLabel">
<span v-if="coverSearching">{{ $t('apps.searching_covers') }}</span>
<span v-else-if="coverCandidates.length > 0">{{ $t('apps.covers_found') }} ({{ coverCandidates.length }})</span>
<span v-else>{{ $t('apps.no_covers_found') }}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body cover-results px-3 pt-3" :class="{ busy: coverFinderBusy }"> <div class="modal-body">
<div class="row"> <div class="mb-3">
<div v-if="coverSearching" class="col-12 col-sm-6 col-lg-4 mb-3"> <div class="input-group">
<div class="cover-container"> <input
<div class="spinner-border" role="status"> type="text"
<span class="visually-hidden">{{ $t('apps.loading') }}</span> class="form-control"
v-model="coverSearchQuery"
:placeholder="editForm.name"
@keyup.enter="performCoverSearch"
/>
<button class="btn btn-primary" type="button" @click="performCoverSearch">
<search :size="18" class="icon"></search>
{{ $t('_common.search') }}
</button>
</div>
<div class="form-text mt-2">
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cover_search_hint') }}
<a href="https://www.igdb.com/" target="_blank" rel="noopener noreferrer">IGDB</a>
</div>
</div>
<div class="cover-results" :class="{ busy: coverFinderBusy }">
<div class="row">
<div v-if="coverSearching" class="col-12 col-sm-6 col-lg-4 mb-3">
<div class="cover-container">
<div class="spinner-border" role="status">
<span class="visually-hidden">{{ $t('apps.loading') }}</span>
</div>
</div> </div>
</div> </div>
</div> <div v-for="(cover,i) in coverCandidates" :key="'i'" class="col-12 col-sm-6 col-lg-3 mb-3"
<div v-for="(cover,i) in coverCandidates" :key="'i'" class="col-12 col-sm-6 col-lg-4 mb-3" @click="useCover(cover)">
@click="useCover(cover)"> <div class="cover-container result">
<div class="cover-container result"> <img class="rounded" :src="cover.url" />
<img class="rounded" :src="cover.url" /> </div>
<label class="d-block text-nowrap text-center text-truncate">
{{cover.name}}
</label>
</div> </div>
<label class="d-block text-nowrap text-center text-truncate">
{{cover.name}}
</label>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<x :size="18" class="icon"></x>
{{ $t('_common.cancel') }}
</button>
</div>
</div> </div>
</div> </div>
<div id="appImagePathHelp" class="form-text">{{ $t('apps.image_desc') }}</div>
</div> </div>
<div class="env-hint alert alert-info"> <div class="env-hint alert alert-info">
<div class="form-text"> <div class="form-text">
@ -344,15 +355,20 @@
<!-- Save buttons --> <!-- Save buttons -->
<div class="d-flex"> <div class="d-flex">
<button @click="showEditForm = false" class="btn btn-secondary m-2"> <button @click="showEditForm = false" class="btn btn-secondary m-2">
<x :size="18" class="icon"></x>
{{ $t('_common.cancel') }} {{ $t('_common.cancel') }}
</button> </button>
<button class="btn btn-primary m-2" @click="save">{{ $t('_common.save') }}</button> <button class="btn btn-primary m-2" @click="save">
<save :size="18" class="icon"></save>
{{ $t('_common.save') }}
</button>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-2" v-else> <div class="mt-4" v-else>
<button class="btn btn-primary" @click="newApp"> <button class="btn btn-primary" @click="newApp">
<i class="fas fa-plus"></i> {{ $t('apps.add_new') }} <layers-plus :size="18" class="icon"></layers-plus>
{{ $t('apps.add_new') }}
</button> </button>
</div> </div>
</div> </div>
@ -362,12 +378,38 @@
import { initApp } from './init' import { initApp } from './init'
import Navbar from './Navbar.vue' import Navbar from './Navbar.vue'
import Checkbox from './Checkbox.vue' import Checkbox from './Checkbox.vue'
import { Dropdown } from 'bootstrap/dist/js/bootstrap' import { Modal } from 'bootstrap/dist/js/bootstrap'
import {
Edit,
FileText,
LayersPlus,
Play,
Plus,
RotateCcw,
Save,
Search,
Shield,
Terminal,
Trash2,
X,
} from 'lucide-vue-next'
const app = createApp({ const app = createApp({
components: { components: {
Navbar, Navbar,
Checkbox Checkbox,
Edit,
FileText,
LayersPlus,
Play,
Plus,
RotateCcw,
Save,
Search,
Shield,
Terminal,
Trash2,
X,
}, },
data() { data() {
return { return {
@ -378,6 +420,7 @@
coverSearching: false, coverSearching: false,
coverFinderBusy: false, coverFinderBusy: false,
coverCandidates: [], coverCandidates: [],
coverSearchQuery: "",
platform: "", platform: "",
}; };
}, },
@ -398,7 +441,7 @@
this.editForm = { this.editForm = {
name: "", name: "",
output: "", output: "",
cmd: [], cmd: "",
index: -1, index: -1,
"exclude-global-prep-cmd": false, "exclude-global-prep-cmd": false,
elevated: false, elevated: false,
@ -462,22 +505,27 @@
this.editForm["prep-cmd"].push(template); this.editForm["prep-cmd"].push(template);
}, },
showCoverFinder($event) { deletePrepCmd(index) {
this.editForm["prep-cmd"].splice(index, 1);
},
addDetached() {
this.editForm.detached.push("");
},
showCoverFinder() {
// Reset search state
this.coverCandidates = []; this.coverCandidates = [];
this.coverSearchQuery = "";
// Perform initial search with app name
this.performCoverSearch();
},
performCoverSearch() {
this.coverSearching = true; this.coverSearching = true;
const ref = this.$refs.coverFinderDropdown; this.coverCandidates = [];
if (!ref) {
console.error("Ref not found!"); // Use search query if provided, otherwise fall back to app name
return; const searchTerm = this.coverSearchQuery.trim() || this.editForm["name"].toString();
}
this.coverFinderDropdown = Dropdown.getInstance(ref);
if (!this.coverFinderDropdown) {
this.coverFinderDropdown = new Dropdown(ref);
if (!this.coverFinderDropdown) {
return;
}
}
this.coverFinderDropdown.show();
function getSearchBucket(name) { function getSearchBucket(name) {
let bucket = name.substring(0, Math.min(name.length, 2)).toLowerCase().replaceAll(/[^a-z\d]/g, ''); let bucket = name.substring(0, Math.min(name.length, 2)).toLowerCase().replaceAll(/[^a-z\d]/g, '');
if (!bucket) { if (!bucket) {
@ -526,21 +574,10 @@
}).filter(item => item)); }).filter(item => item));
} }
searchCovers(this.editForm["name"].toString()) searchCovers(searchTerm)
.then(list => this.coverCandidates = list) .then(list => this.coverCandidates = list)
.finally(() => this.coverSearching = false); .finally(() => this.coverSearching = false);
}, },
closeCoverFinder() {
const ref = this.$refs.coverFinderDropdown;
if (!ref) {
return;
}
const dropdown = this.coverFinderDropdown = Dropdown.getInstance(ref);
if (!dropdown) {
return;
}
dropdown.hide();
},
useCover(cover) { useCover(cover) {
this.coverFinderBusy = true; this.coverFinderBusy = true;
fetch("./api/covers/upload", { fetch("./api/covers/upload", {
@ -555,8 +592,17 @@
}).then(r => { }).then(r => {
if (!r.ok) throw new Error("Failed to download covers"); if (!r.ok) throw new Error("Failed to download covers");
return r.json(); return r.json();
}).then(body => this.editForm["image-path"] = body.path) }).then(body => {
.then(() => this.closeCoverFinder()) this.editForm["image-path"] = body.path;
// Close the modal
const modalEl = this.$refs.coverFinderModal;
if (modalEl) {
const modal = Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
}
})
.finally(() => this.coverFinderBusy = false); .finally(() => this.coverFinderBusy = false);
}, },
save() { save() {
@ -571,14 +617,17 @@
if (r.status === 200) document.location.reload(); if (r.status === 200) document.location.reload();
}); });
}, },
handleImageError(event) {
// Hide the broken image and show placeholder instead
event.target.style.display = 'none';
const placeholder = event.target.nextElementSibling;
if (placeholder && placeholder.classList.contains('app-poster-placeholder')) {
placeholder.style.display = 'flex';
}
}
}, },
}); });
app.directive('dropdown-show', {
mounted: function (el, binding) {
el.addEventListener('show.bs.dropdown', binding.value);
}
});
initApp(app); initApp(app);
</script> </script>

View file

@ -3,29 +3,50 @@
<head> <head>
<%- header %> <%- header %>
<style>
.config-page {
padding: 1em;
border: 1px solid #dee2e6;
border-top: none;
}
.buttons {
padding: 1em 0;
}
</style>
</head> </head>
<body id="app" v-cloak> <body id="app" v-cloak>
<Navbar></Navbar> <Navbar></Navbar>
<div class="container"> <div class="container">
<h1 class="my-4">{{ $t('config.configuration') }}</h1> <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"> <div class="form" v-if="config">
<!-- Header --> <!-- Header -->
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li class="nav-item" v-for="tab in tabs" :key="tab.id"> <li class="nav-item" v-for="tab in tabs" :key="tab.id">
<a class="nav-link" :class="{'active': tab.id === currentTab}" href="#" <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> </li>
</ul> </ul>
@ -86,9 +107,15 @@
<div class="alert alert-success my-4" v-if="restarted"> <div class="alert alert-success my-4" v-if="restarted">
<b>{{ $t('_common.success') }}</b> {{ $t('config.restart_note') }} <b>{{ $t('_common.success') }}</b> {{ $t('config.restart_note') }}
</div> </div>
<div class="mb-3 buttons"> <div class="mb-3 d-flex gap-2 mt-4">
<button class="btn btn-primary mr-3" @click="save">{{ $t('_common.save') }}</button> <button class="btn btn-primary" @click="save">
<button class="btn btn-success" @click="apply" v-if="saved && !restarted">{{ $t('_common.apply') }}</button> <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>
</div> </div>
</body> </body>
@ -106,6 +133,19 @@
import AudioVideo from './configs/tabs/AudioVideo.vue' import AudioVideo from './configs/tabs/AudioVideo.vue'
import ContainerEncoders from './configs/tabs/ContainerEncoders.vue' import ContainerEncoders from './configs/tabs/ContainerEncoders.vue'
import {$tp, usePlatformI18n} from './platform-i18n' 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({ const app = createApp({
components: { components: {
@ -118,6 +158,18 @@
// They will be accessible via audio-video, container-encoders only. // They will be accessible via audio-video, container-encoders only.
AudioVideo, AudioVideo,
ContainerEncoders, ContainerEncoders,
// icons
Cpu,
Check,
FileCog,
Gamepad2,
Gpu,
NetworkIcon,
Save,
Search,
Settings,
Sliders,
Volume2,
}, },
data() { data() {
return { return {
@ -126,6 +178,7 @@
restarted: false, restarted: false,
config: null, config: null,
currentTab: "general", currentTab: "general",
searchQuery: "",
tabs: [ // TODO: Move the options to each Component instead, encapsulate. tabs: [ // TODO: Move the options to each Component instead, encapsulate.
{ {
id: "general", id: "general",
@ -290,9 +343,34 @@
}, },
provide() { provide() {
return { 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() { created() {
fetch("./api/config") fetch("./api/config")
.then((r) => r.json()) .then((r) => r.json())
@ -344,6 +422,23 @@
}); });
}, },
methods: { 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() { forceUpdate() {
this.$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() { mounted() {
// Handle hashchange events // Handle hashchange events

View file

@ -0,0 +1,436 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<head>
<%- header %>
</head>
<body id="app" v-cloak>
<Navbar></Navbar>
<div 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="btn-group" role="group" aria-label="Category filter">
<button
type="button"
class="btn btn-outline-primary"
:class="{ active: selectedCategory === null }"
@click="selectedCategory = null">
{{ $t('_common.all') }}
</button>
<button
v-for="category in categories"
:key="category.id"
type="button"
class="btn btn-outline-primary"
:class="{ active: selectedCategory === category.id }"
@click="selectedCategory = category.id">
{{ $t(`featured.categories.${category.originalId}`) }}
</button>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">{{ $t('_common.loading') }}</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-danger" role="alert">
<h4 class="alert-heading">{{ $t('_common.error') }}</h4>
<p>{{ error }}</p>
</div>
<!-- Apps Grid -->
<div v-else class="row g-4">
<div
v-for="app in filteredApps"
:key="app.id"
class="col-12 col-md-6 col-lg-4">
<div class="card h-100 featured-app-card">
<div class="card-body">
<div class="d-flex align-items-start mb-3">
<div class="featured-app-icon me-3">
<img
v-if="app.icon"
:src="app.icon + '?size=64'"
:alt="app.name"
class="rounded"
@error="handleIconError($event)"
/>
<div v-else class="featured-app-icon-placeholder">
<box :size="32" class="icon"></box>
</div>
</div>
<div class="flex-grow-1 min-w-0">
<h5 class="card-title mb-1">{{ app.name }}</h5>
<p class="text-muted small mb-0" v-if="app.tagline">{{ app.tagline }}</p>
</div>
<span v-if="app.official" class="badge bg-primary flex-shrink-0 ms-2">{{ $t('featured.official') }}</span>
</div>
<p class="card-text text-muted small mb-3">{{ app.description }}</p>
<!-- GitHub Metadata -->
<div v-if="app.github" class="github-stats mb-3 d-flex gap-3 text-muted small">
<span v-if="app.github.stars !== undefined" :title="$t('featured.github_stars')">
<star :size="14" class="icon" fill="currentColor"></star>
{{ formatNumber(app.github.stars) }}
</span>
<span v-if="app.github.forks !== undefined" :title="$t('featured.github_forks')">
<git-fork :size="14" class="icon"></git-fork>
{{ formatNumber(app.github.forks) }}
</span>
<span v-if="app.github.openIssues !== undefined" :title="$t('featured.github_issues')">
<circle-dot :size="14" class="icon"></circle-dot>
{{ formatNumber(app.github.openIssues) }}
</span>
</div>
<!-- Last Updated -->
<div v-if="app.github && app.github.lastUpdated" class="text-muted small mb-3">
<span :title="formatDate(app.github.lastUpdated).absolute">
{{ $t('featured.last_updated') }}: {{ formatDate(app.github.lastUpdated).relative }}
</span>
</div>
<!-- Screenshots Section -->
<div v-if="app.screenshots && app.screenshots.length > 0" class="screenshots-container mb-3">
<div class="screenshots-scroll">
<img
v-for="(screenshot, index) in app.screenshots"
:key="index"
:src="screenshot"
:alt="app.name + ' screenshot ' + (index + 1)"
class="screenshot-thumbnail"
@click="openScreenshot(screenshot, app.screenshots)"
@error="handleScreenshotError"
/>
</div>
</div>
<!-- Platform Icons -->
<div class="mb-3">
<span class="badge bg-secondary me-1" v-for="platform in app.platforms" :key="platform">
<monitor v-if="platform === 'windows'" :size="14" class="icon"></monitor>
<monitor v-else-if="platform === 'macos'" :size="14" class="icon"></monitor>
<monitor v-else-if="platform === 'linux'" :size="14" class="icon"></monitor>
<smartphone v-else-if="platform === 'android'" :size="14" class="icon"></smartphone>
<smartphone v-else-if="platform === 'ios'" :size="14" class="icon"></smartphone>
<globe v-else-if="platform === 'web'" :size="14" class="icon"></globe>
{{ platformName(platform) }}
</span>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2 flex-wrap">
<a
v-if="app.links && app.links.download"
:href="app.links.download"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary btn-sm flex-fill">
<arrow-down-circle :size="16" class="icon"></arrow-down-circle>
{{ $t('featured.get') }}
</a>
<a
v-if="app.links && app.links.documentation"
:href="app.links.documentation"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary btn-sm"
:title="$t('featured.documentation')">
<external-link :size="16" class="icon"></external-link>
{{ $t('featured.docs') }}
</a>
<a
v-if="app.links && app.links.website"
:href="app.links.website"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-secondary btn-sm"
:title="$t('featured.website')">
<external-link :size="16" class="icon"></external-link>
</a>
<a
v-if="app.links && app.links.github"
:href="app.links.github"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-secondary btn-sm"
:title="$t('featured.github')">
<simple-icon icon="GitHub" :size="16" class="icon"></simple-icon>
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!loading && !error && filteredApps.length === 0" class="text-center py-5">
<p class="text-muted">{{ $t('featured.no_apps') }}</p>
</div>
<!-- Screenshot Modal -->
<div
v-if="selectedScreenshot"
class="screenshot-modal"
@click="closeScreenshot"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd">
<div class="screenshot-modal-content">
<button
type="button"
class="btn-close btn-close-white screenshot-close"
@click="closeScreenshot"
aria-label="Close"></button>
<!-- Previous Button -->
<button
v-if="currentAppScreenshots.length > 1"
type="button"
class="screenshot-nav screenshot-nav-prev"
@click.stop="prevScreenshot"
aria-label="Previous screenshot">
<chevron-left :size="32"></chevron-left>
</button>
<!-- Next Button -->
<button
v-if="currentAppScreenshots.length > 1"
type="button"
class="screenshot-nav screenshot-nav-next"
@click.stop="nextScreenshot"
aria-label="Next screenshot">
<chevron-right :size="32"></chevron-right>
</button>
<!-- Screenshot Counter -->
<div v-if="currentAppScreenshots.length > 1" class="screenshot-counter">
{{ selectedScreenshotIndex + 1 }} / {{ currentAppScreenshots.length }}
</div>
<img :src="selectedScreenshot" alt="Screenshot" @click.stop />
</div>
</div>
</div>
</body>
<script type="module">
import { createApp } from 'vue'
import { initApp } from './init'
import Navbar from './Navbar.vue'
import SimpleIcon from './SimpleIcon.vue'
import { formatDistanceToNow, format } from 'date-fns'
import {
ArrowDownCircle,
Box,
ChevronLeft,
ChevronRight,
CircleDot,
ExternalLink,
GitFork,
Globe,
Monitor,
Smartphone,
Star,
} from 'lucide-vue-next'
const app = createApp({
components: {
Navbar,
SimpleIcon,
ArrowDownCircle,
Box,
ChevronLeft,
ChevronRight,
CircleDot,
ExternalLink,
GitFork,
Globe,
Monitor,
Smartphone,
Star,
},
data() {
return {
apps: [],
categories: [],
selectedCategory: null,
loading: true,
error: null,
selectedScreenshot: null,
selectedScreenshotIndex: -1,
currentAppScreenshots: [],
touchStartX: 0,
touchEndX: 0,
};
},
computed: {
filteredApps() {
let filtered = this.selectedCategory
? this.apps.filter(app => app.category === this.selectedCategory)
: this.apps;
// Sort by official status first, then by GitHub stars
return filtered.slice().sort((a, b) => {
// Official apps first
if (a.official && !b.official) return -1;
if (!a.official && b.official) return 1;
// Then sort by GitHub stars (descending)
const aStars = a.github?.stars || 0;
const bStars = b.github?.stars || 0;
return bStars - aStars;
});
}
},
created() {
this.loadFeaturedApps();
},
mounted() {
window.addEventListener('keydown', this.handleKeydown);
},
beforeUnmount() {
window.removeEventListener('keydown', this.handleKeydown);
},
methods: {
async loadFeaturedApps() {
try {
this.loading = true;
this.error = null;
// Fetch the app directory for Sunshine
const indexUrl = 'https://app.lizardbyte.dev/app-directory/sunshine.json';
const response = await fetch(indexUrl);
if (!response.ok) {
throw new Error('Failed to load featured apps');
}
const data = await response.json();
this.apps = data.apps || [];
this.categories = data.categories || [];
} catch (err) {
console.error('Error loading featured apps:', err);
this.error = err.message;
} finally {
this.loading = false;
}
},
platformName(platform) {
const names = {
'windows': 'Windows',
'macos': 'macOS',
'linux': 'Linux',
'android': 'Android',
'ios': 'iOS',
'web': 'Web'
};
return names[platform] || platform;
},
handleIconError(event) {
// Hide broken icon and show placeholder
event.target.style.display = 'none';
},
openScreenshot(url, screenshots) {
this.currentAppScreenshots = screenshots || [];
this.selectedScreenshotIndex = this.currentAppScreenshots.indexOf(url);
this.selectedScreenshot = url;
},
closeScreenshot() {
this.selectedScreenshot = null;
this.selectedScreenshotIndex = -1;
this.currentAppScreenshots = [];
},
nextScreenshot() {
if (this.currentAppScreenshots.length === 0) return;
this.selectedScreenshotIndex = (this.selectedScreenshotIndex + 1) % this.currentAppScreenshots.length;
this.selectedScreenshot = this.currentAppScreenshots[this.selectedScreenshotIndex];
},
prevScreenshot() {
if (this.currentAppScreenshots.length === 0) return;
this.selectedScreenshotIndex = (this.selectedScreenshotIndex - 1 + this.currentAppScreenshots.length) % this.currentAppScreenshots.length;
this.selectedScreenshot = this.currentAppScreenshots[this.selectedScreenshotIndex];
},
handleKeydown(event) {
if (!this.selectedScreenshot) return;
if (event.key === 'ArrowRight') {
event.preventDefault();
this.nextScreenshot();
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
this.prevScreenshot();
} else if (event.key === 'Escape') {
event.preventDefault();
this.closeScreenshot();
}
},
handleTouchStart(event) {
this.touchStartX = event.changedTouches[0].screenX;
},
handleTouchEnd(event) {
this.touchEndX = event.changedTouches[0].screenX;
this.handleSwipe();
},
handleSwipe() {
const swipeThreshold = 50;
const diff = this.touchStartX - this.touchEndX;
if (Math.abs(diff) > swipeThreshold) {
if (diff > 0) {
// Swiped left, show next
this.nextScreenshot();
} else {
// Swiped right, show previous
this.prevScreenshot();
}
}
},
handleScreenshotError(event) {
event.target.style.display = 'none';
},
formatNumber(num) {
if (num === undefined || num === null) return '0';
const absNum = Math.abs(num);
if (absNum >= 1000000000) {
return (num / 1000000000).toFixed(1) + 'B';
} else if (absNum >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (absNum >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
},
formatDate(dateString) {
if (!dateString) return { relative: '', absolute: '' };
const date = new Date(dateString);
// Format absolute date like GitHub: "Jan 28, 2026, 1:52 PM UTC"
const absolute = format(date, 'MMM d, yyyy, h:mm a zzz');
// Use date-fns to get relative time like "2 days ago"
const relative = formatDistanceToNow(date, { addSuffix: true });
return { relative, absolute };
}
},
});
initApp(app);
</script>
</html>

View file

@ -10,77 +10,115 @@
<div id="content" class="container"> <div id="content" class="container">
<h1 class="my-4">{{ $t('index.welcome') }}</h1> <h1 class="my-4">{{ $t('index.welcome') }}</h1>
<p>{{ $t('index.description') }}</p> <p>{{ $t('index.description') }}</p>
<div class="alert alert-danger" v-if="fancyLogs.find(x => x.level === 'Fatal')">
<div style="line-height: 32px;"> <!-- Fatal Errors Alert -->
<i class="fas fa-circle-exclamation" style="font-size: 32px;margin-right: 0.25em;"></i> <div class="alert alert-danger my-4" v-if="fancyLogs.find(x => x.level === 'Fatal')">
<p v-html="$t('index.startup_errors')"></p> <div>
<br> <div class="d-flex align-items-center mb-3">
<alert-circle :size="32" class="icon-lg me-3"></alert-circle>
<div v-html="$t('index.startup_errors')"></div>
</div>
<ul class="mb-3">
<li v-for="v in fancyLogs.filter(x => x.level === 'Fatal')">{{v.value}}</li>
</ul>
<a class="btn btn-danger" href="./troubleshooting#logs">
<file-text :size="18" class="icon"></file-text>
View Logs
</a>
</div> </div>
<ul>
<li v-for="v in fancyLogs.filter(x => x.level === 'Fatal')">{{v.value}}</li>
</ul>
<a class="btn btn-danger" href="./troubleshooting#logs">View Logs</a>
</div> </div>
<!-- ViGEmBus Warning --> <!-- ViGEmBus Warning -->
<div class="alert alert-warning" v-if="platform === 'windows' && controllerEnabled && vigembus && (!vigembus.installed || !vigembus.version_compatible)"> <div class="alert alert-warning my-4" v-if="platform === 'windows' && controllerEnabled && vigembus && (!vigembus.installed || !vigembus.version_compatible)">
<div style="line-height: 32px;"> <div>
<i class="fas fa-triangle-exclamation" style="font-size: 32px;margin-right: 0.25em;"></i> <div class="d-flex align-items-center mb-3">
<div v-if="!vigembus.installed"> <alert-triangle :size="32" class="icon-lg me-3"></alert-triangle>
<p><strong>{{ $t('index.vigembus_not_installed_title') }}</strong></p> <div>
<p>{{ $t('index.vigembus_not_installed_desc') }}</p> <div v-if="!vigembus.installed">
<p class="mb-1"><strong>{{ $t('index.vigembus_not_installed_title') }}</strong></p>
<p class="mb-0">{{ $t('index.vigembus_not_installed_desc') }}</p>
</div>
<div v-else-if="!vigembus.version_compatible">
<p class="mb-1"><strong>{{ $t('index.vigembus_outdated_title') }}</strong></p>
<p class="mb-0">{{ $t('index.vigembus_outdated_desc', { version: vigembus.version }) }}</p>
</div>
</div>
</div> </div>
<div v-else-if="!vigembus.version_compatible"> <a class="btn btn-warning" href="./troubleshooting#vigembus">
<p><strong>{{ $t('index.vigembus_outdated_title') }}</strong></p> <wrench :size="18" class="icon"></wrench>
<p>{{ $t('index.vigembus_outdated_desc', { version: vigembus.version }) }}</p> {{ $t('index.fix_now') }}
</div> </a>
<a class="btn btn-warning" href="./troubleshooting#vigembus">{{ $t('index.fix_now') }}</a>
</div> </div>
</div> </div>
<!-- Version --> <!-- Version -->
<div class="card p-2 my-4"> <div class="card my-4">
<div class="card-body" v-if="version"> <div class="card-body" v-if="version">
<h2>Version {{version.version}}</h2> <h2>Version {{version.version}}</h2>
<br>
<div v-if="loading"> <div v-if="loading" class="my-3">
{{ $t('index.loading_latest') }} {{ $t('index.loading_latest') }}
</div> </div>
<div class="alert alert-success" v-if="buildVersionIsDirty">
<div class="alert alert-success my-3" v-if="buildVersionIsDirty">
<package :size="18" class="icon"></package>
{{ $t('index.version_dirty') }} 🌇 {{ $t('index.version_dirty') }} 🌇
</div> </div>
<div class="alert alert-info" v-if="installedVersionNotStable">
<div class="alert alert-info my-3" v-if="installedVersionNotStable">
<info :size="18" class="icon"></info>
{{ $t('index.installed_version_not_stable') }} {{ $t('index.installed_version_not_stable') }}
</div> </div>
<div v-else-if="(!preReleaseBuildAvailable || !notifyPreReleases) && !stableBuildAvailable && !buildVersionIsDirty"> <div v-else-if="(!preReleaseBuildAvailable || !notifyPreReleases) && !stableBuildAvailable && !buildVersionIsDirty">
<div class="alert alert-success"> <div class="alert alert-success my-3">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('index.version_latest') }} {{ $t('index.version_latest') }}
</div> </div>
</div> </div>
<div v-if="notifyPreReleases && preReleaseBuildAvailable"> <div v-if="notifyPreReleases && preReleaseBuildAvailable">
<div class="alert alert-warning"> <div class="alert alert-warning my-3">
<div class="d-flex justify-content-between"> <!-- header row -->
<div class="my-2"> <div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
<p>{{ $t('index.new_pre_release') }}</p> <div class="d-flex align-items-center gap-3 flex-wrap">
<alert-circle :size="18" class="icon"></alert-circle>
<span>{{ $t('index.new_pre_release') }}</span>
<h5 class="mb-0">{{ preReleaseVersion.release.name }}</h5>
</div> </div>
<a class="btn btn-success m-1" :href="preReleaseVersion.release.html_url" target="_blank">{{ $t('index.download') }}</a> <a class="btn btn-success flex-shrink-0" :href="preReleaseVersion.release.html_url" target="_blank">
<download :size="18" class="icon"></download>
{{ $t('index.download') }}
</a>
</div> </div>
<h3>{{preReleaseVersion.release.name}}</h3>
<div class="markdown-body" v-html="convertMarkdownToHtml(preReleaseVersion.release.body)"></div> <!-- body row (full width) -->
<div class="markdown-body release-notes" v-html="convertMarkdownToHtml(preReleaseVersion.release.body)"></div>
</div> </div>
</div> </div>
<div v-if="stableBuildAvailable"> <div v-if="stableBuildAvailable">
<div class="alert alert-warning"> <div class="alert alert-warning my-3">
<div class="d-flex justify-content-between"> <!-- header row -->
<div class="my-2"> <div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
<p>{{ $t('index.new_stable') }}</p> <div class="d-flex align-items-center gap-3 flex-wrap">
<alert-circle :size="18" class="icon"></alert-circle>
<span>{{ $t('index.new_stable') }}</span>
<h5 class="mb-0">{{ githubVersion.release.name }}</h5>
</div> </div>
<a class="btn btn-success m-1" :href="githubVersion.release.html_url" target="_blank">{{ $t('index.download') }}</a> <a class="btn btn-success flex-shrink-0" :href="githubVersion.release.html_url" target="_blank">
<download :size="18" class="icon"></download>
{{ $t('index.download') }}
</a>
</div> </div>
<h3>{{githubVersion.release.name}}</h3>
<div class="markdown-body" v-html="convertMarkdownToHtml(githubVersion.release.body)"></div> <!-- body row (full width) -->
<div class="markdown-body release-notes" v-html="convertMarkdownToHtml(githubVersion.release.body)"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Resources --> <!-- Resources -->
<div class="my-4"> <div class="my-4">
<Resource-Card></Resource-Card> <Resource-Card></Resource-Card>
@ -95,6 +133,16 @@
import Navbar from './Navbar.vue' import Navbar from './Navbar.vue'
import ResourceCard from './ResourceCard.vue' import ResourceCard from './ResourceCard.vue'
import SunshineVersion from './sunshine_version' import SunshineVersion from './sunshine_version'
import {
AlertCircle,
AlertTriangle,
FileText,
Wrench,
Package,
Info,
CheckCircle,
Download
} from 'lucide-vue-next'
// Configure marked to allow HTML // Configure marked to allow HTML
marked.setOptions({ marked.setOptions({
@ -109,7 +157,15 @@
let app = createApp({ let app = createApp({
components: { components: {
Navbar, Navbar,
ResourceCard ResourceCard,
AlertCircle,
AlertTriangle,
FileText,
Wrench,
Package,
Info,
CheckCircle,
Download
}, },
data() { data() {
return { return {
@ -200,3 +256,4 @@
initApp(app); initApp(app);
</script> </script>
</html>

View file

@ -3,17 +3,6 @@
<head> <head>
<%- header %> <%- header %>
<style>
.config-page {
padding: 1em;
border: 1px solid #dee2e6;
border-top: none;
}
.buttons {
padding: 1em 0;
}
</style>
</head> </head>
<body id="app" v-cloak> <body id="app" v-cloak>
@ -21,46 +10,52 @@
<div class="container"> <div class="container">
<h1 class="my-4">{{ $t('password.password_change') }}</h1> <h1 class="my-4">{{ $t('password.password_change') }}</h1>
<form @submit.prevent="save"> <form @submit.prevent="save">
<div class="card d-flex p-4 flex-row"> <div class="card">
<div class="col-md-6 px-4"> <div class="card-body">
<h4>{{ $t('password.current_creds') }}</h4> <div class="row">
<div class="mb-3"> <div class="col-md-6">
<label for="currentUsername" class="form-label">{{ $t('_common.username') }}</label> <h4>{{ $t('password.current_creds') }}</h4>
<input required type="text" class="form-control" id="currentUsername" <div class="mb-3">
v-model="passwordData.currentUsername" /> <label for="currentUsername" class="form-label">{{ $t('_common.username') }}</label>
<div class="form-text">&nbsp;</div> <input required type="text" class="form-control" id="currentUsername"
</div> v-model="passwordData.currentUsername" />
<div class="mb-3"> </div>
<label for="currentPassword" class="form-label">{{ $t('_common.password') }}</label> <div class="mb-3">
<input autocomplete="current-password" type="password" class="form-control" id="currentPassword" <label for="currentPassword" class="form-label">{{ $t('_common.password') }}</label>
v-model="passwordData.currentPassword" /> <input autocomplete="current-password" type="password" class="form-control" id="currentPassword"
</div> v-model="passwordData.currentPassword" />
</div> </div>
<div class="col-md-6 px-4"> </div>
<h4>{{ $t('password.new_creds') }}</h4> <div class="col-md-6">
<div class="mb-3"> <h4>{{ $t('password.new_creds') }}</h4>
<label for="newUsername" class="form-label">{{ $t('_common.username') }}</label> <div class="mb-3">
<input type="text" class="form-control" id="newUsername" v-model="passwordData.newUsername" /> <label for="newUsername" class="form-label">{{ $t('_common.username') }}</label>
<div class="form-text">{{ $t('password.new_username_desc') }}</div> <input type="text" class="form-control" id="newUsername" v-model="passwordData.newUsername" />
</div> <div class="form-text">{{ $t('password.new_username_desc') }}</div>
<div class="mb-3"> </div>
<label for="newPassword" class="form-label">{{ $t('_common.password') }}</label> <div class="mb-3">
<input autocomplete="new-password" required type="password" class="form-control" id="newPassword" <label for="newPassword" class="form-label">{{ $t('_common.password') }}</label>
v-model="passwordData.newPassword" /> <input autocomplete="new-password" required type="password" class="form-control" id="newPassword"
</div> v-model="passwordData.newPassword" />
<div class="mb-3"> </div>
<label for="confirmNewPassword" class="form-label">{{ $t('password.confirm_password') }}</label> <div class="mb-3">
<input autocomplete="new-password" required type="password" class="form-control" id="confirmNewPassword" <label for="confirmNewPassword" class="form-label">{{ $t('password.confirm_password') }}</label>
v-model="passwordData.confirmNewPassword" /> <input autocomplete="new-password" required type="password" class="form-control" id="confirmNewPassword"
v-model="passwordData.confirmNewPassword" />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="alert alert-danger" v-if="error"><b>Error: </b>{{error}}</div> <div class="alert alert-danger my-3" v-if="error"><b>Error: </b>{{error}}</div>
<div class="alert alert-success" v-if="success"> <div class="alert alert-success my-3" v-if="success">
<b>{{ $t('_common.success') }}</b> {{ $t('password.success_msg') }} <b>{{ $t('_common.success') }}</b> {{ $t('password.success_msg') }}
</div> </div>
<div class="mb-3 buttons"> <div class="mb-3 mt-4">
<button class="btn btn-primary">{{ $t('_common.save') }}</button> <button class="btn btn-primary">
<save :size="18" class="icon"></save>
{{ $t('_common.save') }}
</button>
</div> </div>
</form> </form>
</div> </div>
@ -69,10 +64,12 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { initApp } from './init' import { initApp } from './init'
import Navbar from './Navbar.vue' import Navbar from './Navbar.vue'
import { Save } from 'lucide-vue-next'
const app = createApp({ const app = createApp({
components: { components: {
Navbar Navbar,
Save,
}, },
data() { data() {
return { return {

View file

@ -11,9 +11,22 @@
<h1 class="my-4 text-center">{{ $t('pin.pin_pairing') }}</h1> <h1 class="my-4 text-center">{{ $t('pin.pin_pairing') }}</h1>
<form class="form d-flex flex-column align-items-center" id="form" @submit.prevent="registerDevice"> <form class="form d-flex flex-column align-items-center" id="form" @submit.prevent="registerDevice">
<div class="card flex-column d-flex p-4 mb-4"> <div class="card flex-column d-flex p-4 mb-4">
<input type="text" pattern="\d*" :placeholder="`${$t('navbar.pin')}`" autofocus id="pin-input" class="form-control mt-2" required /> <div class="input-group mt-2">
<input type="text" :placeholder="`${$t('pin.device_name')}`" id="name-input" class="form-control my-4" required /> <span class="input-group-text">
<button class="btn btn-primary">{{ $t('pin.send') }}</button> <hash :size="18" class="icon"></hash>
</span>
<input type="text" pattern="\d*" :placeholder="`${$t('navbar.pin')}`" autofocus id="pin-input" class="form-control" required />
</div>
<div class="input-group my-4">
<span class="input-group-text">
<monitor :size="18" class="icon"></monitor>
</span>
<input type="text" :placeholder="`${$t('pin.device_name')}`" id="name-input" class="form-control" required />
</div>
<button class="btn btn-primary">
<forward :size="18" class="icon"></forward>
{{ $t('pin.send') }}
</button>
</div> </div>
<div class="alert alert-warning"> <div class="alert alert-warning">
<b>{{ $t('_common.warning') }}</b> {{ $t('pin.warning_msg') }} <b>{{ $t('_common.warning') }}</b> {{ $t('pin.warning_msg') }}
@ -27,10 +40,18 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { initApp } from './init' import { initApp } from './init'
import Navbar from './Navbar.vue' import Navbar from './Navbar.vue'
import {
Forward,
Hash,
Monitor,
} from 'lucide-vue-next'
let app = createApp({ let app = createApp({
components: { components: {
Navbar Navbar,
Forward,
Hash,
Monitor,
}, },
inject: ['i18n'], inject: ['i18n'],
methods: { methods: {
@ -40,10 +61,10 @@
document.querySelector("#status").innerHTML = ""; document.querySelector("#status").innerHTML = "";
let b = JSON.stringify({pin: pin, name: name}); let b = JSON.stringify({pin: pin, name: name});
fetch("./api/pin", { fetch("./api/pin", {
method: "POST", method: "POST",
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: b body: b
}) })
.then((response) => response.json()) .then((response) => response.json())

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,12 @@
{ {
"_common": { "_common": {
"all": "All",
"apply": "Apply", "apply": "Apply",
"auto": "Automatic", "auto": "Automatic",
"autodetect": "Autodetect (recommended)", "autodetect": "Autodetect (recommended)",
"beta": "(beta)", "beta": "(beta)",
"cancel": "Cancel", "cancel": "Cancel",
"close": "Close",
"disabled": "Disabled", "disabled": "Disabled",
"disabled_def": "Disabled (default)", "disabled_def": "Disabled (default)",
"disabled_def_cbox": "Default: unchecked", "disabled_def_cbox": "Default: unchecked",
@ -15,10 +17,12 @@
"enabled_def": "Enabled (default)", "enabled_def": "Enabled (default)",
"enabled_def_cbox": "Default: checked", "enabled_def_cbox": "Default: checked",
"error": "Error!", "error": "Error!",
"loading": "Loading...",
"note": "Note:", "note": "Note:",
"password": "Password", "password": "Password",
"run_as": "Run as Admin", "run_as": "Run as Admin",
"save": "Save", "save": "Save",
"search": "Search...",
"see_more": "See More", "see_more": "See More",
"success": "Success!", "success": "Success!",
"undo_cmd": "Undo Command", "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_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", "cmd_prep_name": "Command Preparations",
"covers_found": "Covers Found", "covers_found": "Covers Found",
"cover_search_hint": "Search names should match IGDB naming conventions.",
"delete": "Delete", "delete": "Delete",
"detached_cmds": "Detached Commands", "detached_cmds": "Detached Commands",
"detached_cmds_add": "Add Detached Command", "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.", "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...", "loading": "Loading...",
"name": "Name", "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_desc": "The file where the output of the command is stored, if it is not specified, the output is ignored",
"output_name": "Output", "output_name": "Output",
"run_as_desc": "This can be necessary for some applications that require administrator permissions to run properly.", "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": "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.", "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", "working_dir": "Working Directory",
@ -334,6 +341,7 @@
"qsv_slow_hevc": "Allow Slow HEVC Encoding", "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.", "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.", "restart_note": "Sunshine is restarting to apply changes.",
"search_options": "Search configuration options...",
"stream_audio": "Stream Audio", "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.", "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", "sunshine_name": "Sunshine Name",
@ -398,12 +406,25 @@
"navbar": { "navbar": {
"applications": "Applications", "applications": "Applications",
"configuration": "Configuration", "configuration": "Configuration",
"featured": "Featured Apps",
"home": "Home", "home": "Home",
"password": "Change Password", "password": "Change Password",
"pin": "PIN", "pin": "PIN",
"theme_auto": "Auto", "theme_auto": "Auto",
"theme_dark": "Dark", "theme_dark": "Dark",
"theme_ember": "Ember",
"theme_forest": "Forest",
"theme_indigo": "Indigo",
"theme_lavender": "Lavender",
"theme_light": "Light", "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", "toggle_theme": "Theme",
"troubleshoot": "Troubleshooting" "troubleshoot": "Troubleshooting"
}, },
@ -468,6 +489,25 @@
"vigembus_force_reinstall_button": "Force Reinstall ViGEmBus v{version}", "vigembus_force_reinstall_button": "Force Reinstall ViGEmBus v{version}",
"vigembus_not_installed": "ViGEmBus is not installed." "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": { "welcome": {
"confirm_password": "Confirm password", "confirm_password": "Confirm password",
"create_creds": "Before Getting Started, we need you to make a new username and password for accessing the Web UI.", "create_creds": "Before Getting Started, we need you to make a new username and password for accessing the Web UI.",

View file

@ -4,6 +4,5 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sunshine</title> <title>Sunshine</title>
<link rel="icon" type="image/x-icon" href="./images/sunshine.ico"> <link rel="icon" type="image/x-icon" href="./images/sunshine.ico">
<link href="@fortawesome/fontawesome-free/css/all.min.css" rel="stylesheet">
<link href="bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="./assets/css/sunshine.css" rel="stylesheet" /> <link href="./assets/css/sunshine.css" rel="stylesheet" />

View file

@ -10,14 +10,30 @@ export const getPreferredTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 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 => { const setTheme = theme => {
if (theme === 'auto') { if (theme === 'auto') {
document.documentElement.setAttribute( const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
'data-bs-theme', document.documentElement.dataset.bsTheme = preferredTheme
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') document.documentElement.dataset.theme = preferredTheme
) console.log(`Theme set to auto (resolved to: ${preferredTheme})`)
} else { } 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 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 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 => { document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active') element.classList.remove('active')
@ -40,8 +65,11 @@ export const showActiveTheme = (theme, focus = false) => {
btnToActive.classList.add('active') btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true') 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()})` const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.textContent.trim()})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel) themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
@ -72,7 +100,8 @@ export function loadAutoTheme() {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme() const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') { // Only auto-switch if theme is set to 'auto'
if (storedTheme === 'auto' || !storedTheme) {
setTheme(getPreferredTheme()) setTheme(getPreferredTheme())
} }
}) })

View file

@ -3,37 +3,6 @@
<head> <head>
<%- header %> <%- header %>
<style>
.troubleshooting-logs {
white-space: pre;
font-family: monospace;
overflow: auto;
max-height: 500px;
min-height: 500px;
font-size: 16px;
position: relative;
}
.copy-icon {
position: absolute;
top: 8px;
right: 8px;
padding: 8px;
cursor: pointer;
color: rgba(0, 0, 0, 1);
appearance: none;
border: none;
background: none;
}
.copy-icon:hover {
color: rgba(0, 0, 0, 0.75);
}
.copy-icon:active {
color: rgba(0, 0, 0, 1);
}
</style>
</head> </head>
<body id="app" v-cloak> <body id="app" v-cloak>
@ -41,27 +10,31 @@
<div class="container"> <div class="container">
<h1 class="my-4">{{ $t('troubleshooting.troubleshooting') }}</h1> <h1 class="my-4">{{ $t('troubleshooting.troubleshooting') }}</h1>
<!-- ViGEmBus Installation --> <!-- ViGEmBus Installation -->
<div class="card p-2 my-4" v-if="platform === 'windows' && controllerEnabled"> <div class="card my-4" v-if="platform === 'windows' && controllerEnabled">
<div class="card-body"> <div class="card-body">
<h2 id="vigembus">{{ $t('troubleshooting.vigembus_install') }}</h2> <h2 id="vigembus">{{ $t('troubleshooting.vigembus_install') }}</h2>
<br>
<p style="white-space: pre-line">{{ $t('troubleshooting.vigembus_desc') }}</p> <p style="white-space: pre-line">{{ $t('troubleshooting.vigembus_desc') }}</p>
<div v-if="vigembus.installed && vigembus.version"> <div v-if="vigembus.installed && vigembus.version">
<p><strong>{{ $t('troubleshooting.vigembus_current_version') }}:</strong> {{ vigembus.version }}</p> <p><strong>{{ $t('troubleshooting.vigembus_current_version') }}:</strong> {{ vigembus.version }}</p>
<div class="alert alert-success" v-if="vigembus.version_compatible"> <div class="alert alert-success" v-if="vigembus.version_compatible">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.vigembus_compatible') }} {{ $t('troubleshooting.vigembus_compatible') }}
</div> </div>
<div class="alert alert-danger" v-if="!vigembus.version_compatible"> <div class="alert alert-danger" v-if="!vigembus.version_compatible">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.vigembus_incompatible') }} {{ $t('troubleshooting.vigembus_incompatible') }}
</div> </div>
</div> </div>
<div class="alert alert-warning" v-else-if="!vigembus.installed"> <div class="alert alert-warning" v-else-if="!vigembus.installed">
<alert-triangle :size="18" class="icon"></alert-triangle>
{{ $t('troubleshooting.vigembus_not_installed') }} {{ $t('troubleshooting.vigembus_not_installed') }}
</div> </div>
<div class="alert alert-success" v-if="vigemBusInstallStatus === true"> <div class="alert alert-success" v-if="vigemBusInstallStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.vigembus_install_success') }} {{ $t('troubleshooting.vigembus_install_success') }}
</div> </div>
<div class="alert alert-danger" v-if="vigemBusInstallStatus === false"> <div class="alert alert-danger" v-if="vigemBusInstallStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ vigemBusInstallError || $t('troubleshooting.vigembus_install_error') }} {{ vigemBusInstallError || $t('troubleshooting.vigembus_install_error') }}
</div> </div>
<div> <div>
@ -69,65 +42,67 @@
:class="vigembus.installed && vigembus.version === vigembus.packaged_version ? 'btn btn-danger' : 'btn btn-primary'" :class="vigembus.installed && vigembus.version === vigembus.packaged_version ? 'btn btn-danger' : 'btn btn-primary'"
:disabled="vigemBusInstallPressed" :disabled="vigemBusInstallPressed"
@click="installViGEmBus"> @click="installViGEmBus">
<template v-if="vigembus.installed && vigembus.version === vigembus.packaged_version"> <download :size="18" class="icon" v-if="!(vigembus.installed && vigembus.version === vigembus.packaged_version)"></download>
{{ $t('troubleshooting.vigembus_force_reinstall_button', { version: vigembus.packaged_version }) }} <refresh-cw :size="18" class="icon" v-else></refresh-cw>
</template> {{ 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 }) }}
<template v-else>
{{ $t('troubleshooting.vigembus_install_button', { version: vigembus.packaged_version }) }}
</template>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Force Close App --> <!-- Force Close App -->
<div class="card p-2 my-4"> <div class="card my-4">
<div class="card-body"> <div class="card-body">
<h2 id="close_apps">{{ $t('troubleshooting.force_close') }}</h2> <h2 id="close_apps">{{ $t('troubleshooting.force_close') }}</h2>
<br>
<p>{{ $t('troubleshooting.force_close_desc') }}</p> <p>{{ $t('troubleshooting.force_close_desc') }}</p>
<div class="alert alert-success" v-if="closeAppStatus === true"> <div class="alert alert-success" v-if="closeAppStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.force_close_success') }} {{ $t('troubleshooting.force_close_success') }}
</div> </div>
<div class="alert alert-danger" v-if="closeAppStatus === false"> <div class="alert alert-danger" v-if="closeAppStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.force_close_error') }} {{ $t('troubleshooting.force_close_error') }}
</div> </div>
<div> <div>
<button class="btn btn-warning" :disabled="closeAppPressed" @click="closeApp"> <button class="btn btn-warning" :disabled="closeAppPressed" @click="closeApp">
<x-circle :size="18" class="icon"></x-circle>
{{ $t('troubleshooting.force_close') }} {{ $t('troubleshooting.force_close') }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Restart Sunshine --> <!-- Restart Sunshine -->
<div class="card p-2 my-4"> <div class="card my-4">
<div class="card-body"> <div class="card-body">
<h2 id="restart">{{ $t('troubleshooting.restart_sunshine') }}</h2> <h2 id="restart">{{ $t('troubleshooting.restart_sunshine') }}</h2>
<br>
<p>{{ $t('troubleshooting.restart_sunshine_desc') }}</p> <p>{{ $t('troubleshooting.restart_sunshine_desc') }}</p>
<div class="alert alert-success" v-if="restartPressed === true"> <div class="alert alert-success" v-if="restartPressed === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.restart_sunshine_success') }} {{ $t('troubleshooting.restart_sunshine_success') }}
</div> </div>
<div> <div>
<button class="btn btn-warning" :disabled="restartPressed" @click="restart"> <button class="btn btn-warning" :disabled="restartPressed" @click="restart">
<refresh-cw :size="18" class="icon"></refresh-cw>
{{ $t('troubleshooting.restart_sunshine') }} {{ $t('troubleshooting.restart_sunshine') }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Reset persistent display device settings --> <!-- Reset persistent display device settings -->
<div class="card p-2 my-4" v-if="platform === 'windows'"> <div class="card my-4" v-if="platform === 'windows'">
<div class="card-body"> <div class="card-body">
<h2 id="dd_reset">{{ $t('troubleshooting.dd_reset') }}</h2> <h2 id="dd_reset">{{ $t('troubleshooting.dd_reset') }}</h2>
<br>
<p style="white-space: pre-line">{{ $t('troubleshooting.dd_reset_desc') }}</p> <p style="white-space: pre-line">{{ $t('troubleshooting.dd_reset_desc') }}</p>
<div class="alert alert-success" v-if="ddResetStatus === true"> <div class="alert alert-success" v-if="ddResetStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.dd_reset_success') }} {{ $t('troubleshooting.dd_reset_success') }}
</div> </div>
<div class="alert alert-danger" v-if="ddResetStatus === false"> <div class="alert alert-danger" v-if="ddResetStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.dd_reset_error') }} {{ $t('troubleshooting.dd_reset_error') }}
</div> </div>
<div> <div>
<button class="btn btn-warning" :disabled="ddResetPressed" @click="ddResetPersistence"> <button class="btn btn-warning" :disabled="ddResetPressed" @click="ddResetPersistence">
<rotate-ccw :size="18" class="icon"></rotate-ccw>
{{ $t('troubleshooting.dd_reset') }} {{ $t('troubleshooting.dd_reset') }}
</button> </button>
</div> </div>
@ -136,50 +111,77 @@
<!-- Unpair Clients --> <!-- Unpair Clients -->
<div class="card my-4"> <div class="card my-4">
<div class="card-body"> <div class="card-body">
<div class="p-2"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex justify-content-end align-items-center"> <h2 id="unpair" class="mb-0">{{ $t('troubleshooting.unpair_title') }}</h2>
<h2 id="unpair" class="text-center me-auto">{{ $t('troubleshooting.unpair_title') }}</h2> <button class="btn btn-danger" :disabled="unpairAllPressed" @click="unpairAll">
<button class="btn btn-danger" :disabled="unpairAllPressed" @click="unpairAll"> <trash-2 :size="18" class="icon"></trash-2>
{{ $t('troubleshooting.unpair_all') }} {{ $t('troubleshooting.unpair_all') }}
</button> </button>
</div> </div>
<br /> <p>{{ $t('troubleshooting.unpair_desc') }}</p>
<p class="mb-0">{{ $t('troubleshooting.unpair_desc') }}</p> <div class="alert alert-success d-flex align-items-center" v-if="showApplyMessage">
<div id="apply-alert" class="alert alert-success d-flex align-items-center mt-3" :style="{ 'display': (showApplyMessage ? 'flex !important': 'none !important') }"> <check-circle :size="18" class="icon"></check-circle>
<div class="me-2"><b>{{ $t('_common.success') }}</b> {{ $t('troubleshooting.unpair_single_success') }}</div> <div><b>{{ $t('_common.success') }}</b> {{ $t('troubleshooting.unpair_single_success') }}</div>
<button class="btn btn-success ms-auto apply" @click="clickedApplyBanner">{{ $t('_common.dismiss') }}</button> <button class="btn btn-success ms-auto" @click="clickedApplyBanner">{{ $t('_common.dismiss') }}</button>
</div> </div>
<div class="alert alert-success mt-3" v-if="unpairAllStatus === true"> <div class="alert alert-success" v-if="unpairAllStatus === true">
{{ $t('troubleshooting.unpair_all_success') }} <check-circle :size="18" class="icon"></check-circle>
</div> {{ $t('troubleshooting.unpair_all_success') }}
<div class="alert alert-danger mt-3" v-if="unpairAllStatus === false"> </div>
{{ $t('troubleshooting.unpair_all_error') }} <div class="alert alert-danger" v-if="unpairAllStatus === false">
</div> <alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.unpair_all_error') }}
</div> </div>
</div> </div>
<ul id="client-list" class="list-group list-group-flush list-group-item-light" v-if="clients && clients.length > 0"> <ul class="list-group list-group-flush" v-if="clients && clients.length > 0">
<div v-for="client in clients" class="list-group-item d-flex"> <li v-for="client in clients" :key="client.uuid" class="list-group-item d-flex align-items-center">
<div class="p-2 flex-grow-1">{{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}</div> <div class="flex-grow-1">{{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}</div>
<div class="me-2 ms-auto btn btn-danger" @click="unpairSingle(client.uuid)"><i class="fas fa-trash"></i></div> <button class="btn btn-danger ms-auto" @click="unpairSingle(client.uuid)">
</div> <trash-2 :size="18" class="icon"></trash-2>
</button>
</li>
</ul> </ul>
<ul v-else class="list-group list-group-flush list-group-item-light"> <ul v-else class="list-group list-group-flush">
<div class="list-group-item p-3 text-center"><em>{{ $t('troubleshooting.unpair_single_no_devices') }}</em></div> <li class="list-group-item p-3 text-center">
<em>{{ $t('troubleshooting.unpair_single_no_devices') }}</em>
</li>
</ul> </ul>
</div> </div>
<!-- Logs --> <!-- Logs -->
<div class="card p-2 my-4"> <div class="card my-4">
<div class="card-body"> <div class="card-body">
<h2 id="logs">{{ $t('troubleshooting.logs') }}</h2> <h2 id="logs">{{ $t('troubleshooting.logs') }}</h2>
<br>
<div class="d-flex justify-content-between align-items-baseline py-2"> <div class="d-flex justify-content-between align-items-baseline py-2">
<p>{{ $t('troubleshooting.logs_desc') }}</p> <p>{{ $t('troubleshooting.logs_desc') }}</p>
<input type="text" class="form-control" v-model="logFilter" :placeholder="$t('troubleshooting.logs_find')" style="width: 300px"> <div class="input-group" style="max-width: 300px">
<span class="input-group-text">
<search :size="18" class="icon"></search>
</span>
<input type="text" class="form-control" v-model="logFilter" :placeholder="$t('troubleshooting.logs_find')" />
</div>
</div> </div>
<div> <div>
<div class="troubleshooting-logs"> <div class="troubleshooting-logs" ref="logsContainer">
<button class="copy-icon"><i class="fas fa-copy " @click="copyLogs"></i></button>{{actualLogs}} <div class="log-nav-overlay">
<div class="log-nav-controls">
<button class="log-nav-btn" @click="scrollLogsTo('top')" title="Jump to Top">
<chevrons-up :size="18" class="icon"></chevrons-up>
</button>
<button class="log-nav-btn" @click="navigateToLog('prev')" :disabled="!hasPrevLog" title="Previous Warning/Error">
<chevron-up :size="18" class="icon"></chevron-up>
</button>
<button class="log-nav-btn" @click="navigateToLog('next')" :disabled="!hasNextLog" title="Next Warning/Error">
<chevron-down :size="18" class="icon"></chevron-down>
</button>
<button class="log-nav-btn" @click="scrollLogsTo('bottom')" title="Jump to Bottom">
<chevrons-down :size="18" class="icon"></chevrons-down>
</button>
<button class="log-nav-btn" @click="copyLogs" title="Copy Logs">
<copy :size="18" class="icon"></copy>
</button>
</div>
</div>
<pre class="mb-0" v-html="highlightedLogs"></pre>
</div> </div>
</div> </div>
</div> </div>
@ -190,10 +192,40 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { initApp } from './init' import { initApp } from './init'
import Navbar from './Navbar.vue' 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({ const app = createApp({
components: { components: {
Navbar Navbar,
AlertCircle,
AlertTriangle,
CheckCircle,
ChevronDown,
ChevronUp,
ChevronsDown,
ChevronsUp,
Copy,
Download,
RefreshCw,
RotateCcw,
Search,
Trash2,
XCircle,
}, },
data() { data() {
return { return {
@ -220,14 +252,126 @@
vigemBusInstallPressed: false, vigemBusInstallPressed: false,
vigemBusInstallStatus: null, vigemBusInstallStatus: null,
vigemBusInstallError: null, vigemBusInstallError: null,
currentLogIndex: -1,
logLines: [],
}; };
}, },
computed: { computed: {
actualLogs() { actualLogs() {
if (!this.logFilter) return this.logs; if (!this.logFilter) return this.logs;
let lines = this.logs.split("\n"); const filterLower = this.logFilter.toLowerCase();
lines = lines.filter(x => x.indexOf(this.logFilter) !== -1); return this.logs
return lines.join("\n"); .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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
return this.parsedLogEntries
.map((entry) => {
const safe = escapeHtml(entry.raw);
const isSelected = entry.index === this.currentLogIndex;
const selectedClass = isSelected ? ' log-entry-selected' : '';
return `<span data-entry-index="${entry.index}" data-log-level="${entry.cssClass}" class="${entry.cssClass}${selectedClass}">${safe}</span>`;
})
// 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() { created() {
@ -323,6 +467,7 @@
this.showApplyMessage = false; this.showApplyMessage = false;
}, },
copyLogs() { copyLogs() {
// Copy the filtered view if a filter is active.
navigator.clipboard.writeText(this.actualLogs); navigator.clipboard.writeText(this.actualLogs);
}, },
restart() { restart() {
@ -403,6 +548,62 @@
}, 10000); }, 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' });
}
},
}, },
}); });

View file

@ -68,6 +68,7 @@ export default defineConfig({
input: { input: {
apps: resolve(assetsSrcPath, 'apps.html'), apps: resolve(assetsSrcPath, 'apps.html'),
config: resolve(assetsSrcPath, 'config.html'), config: resolve(assetsSrcPath, 'config.html'),
featured: resolve(assetsSrcPath, 'featured.html'),
index: resolve(assetsSrcPath, 'index.html'), index: resolve(assetsSrcPath, 'index.html'),
password: resolve(assetsSrcPath, 'password.html'), password: resolve(assetsSrcPath, 'password.html'),
pin: resolve(assetsSrcPath, 'pin.html'), pin: resolve(assetsSrcPath, 'pin.html'),