fix(web-ui): modernize UI (#4631)
This commit is contained in:
parent
76b3a8596f
commit
3ce39b36d0
19 changed files with 3529 additions and 456 deletions
|
|
@ -4,7 +4,7 @@ Read our contribution guide in our organization level
|
|||
|
||||
## 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. |
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ Read our contribution guide in our organization level
|
|||
* [EJS](https://www.npmjs.com/package/vite-plugin-ejs) is used as a templating system for the pages
|
||||
(check `template_header.html` and `template_header_main.html`).
|
||||
* The Style System is provided by [Bootstrap](https://getbootstrap.com).
|
||||
* Icons are provided by [Lucide](https://lucide.dev) and [Simple Icons](https://simpleicons.org).
|
||||
* The JS framework used by the more interactive pages is [Vus.js](https://vuejs.org).
|
||||
|
||||
#### Building
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"@lizardbyte/shared-web": "2025.922.181114",
|
||||
"date-fns": "4.1.0",
|
||||
"lucide-vue-next": "0.563.0",
|
||||
"marked": "17.0.1",
|
||||
"vue": "3.5.27",
|
||||
"vue-i18n": "11.2.8"
|
||||
"vue-i18n": "11.2.8",
|
||||
"vue3-simple-icons": "15.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codecov/vite-plugin": "1.9.1",
|
||||
|
|
|
|||
|
|
@ -387,6 +387,26 @@ namespace confighttp {
|
|||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the featured apps page.
|
||||
* @param response The HTTP response object.
|
||||
* @param request The HTTP request object.
|
||||
*/
|
||||
void getFeaturedPage(resp_https_t response, req_https_t request) {
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
print_req(request);
|
||||
|
||||
std::string content = file_handler::read_file(WEB_DIR "featured.html");
|
||||
SimpleWeb::CaseInsensitiveMultimap headers;
|
||||
headers.emplace("Content-Type", "text/html; charset=utf-8");
|
||||
headers.emplace("X-Frame-Options", "DENY");
|
||||
headers.emplace("Content-Security-Policy", "frame-ancestors 'none';");
|
||||
response->write(content, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get the password page.
|
||||
* @param response The HTTP response object.
|
||||
|
|
@ -955,9 +975,6 @@ namespace confighttp {
|
|||
* @api_examples{/api/covers/9999 | GET| null}
|
||||
*/
|
||||
void getCover(resp_https_t response, req_https_t request) {
|
||||
if (!check_content_type(response, request, "application/json")) {
|
||||
return;
|
||||
}
|
||||
if (!authenticate(response, request)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -986,6 +1003,13 @@ namespace confighttp {
|
|||
// This handles extension validation, PNG signature validation, and path resolution
|
||||
std::string validated_path = proc::validate_app_image_path(app_image_path);
|
||||
|
||||
// Check if we got the default image path (means validation failed or no image configured)
|
||||
if (validated_path == DEFAULT_APP_IMAGE_PATH) {
|
||||
BOOST_LOG(debug) << "Application at index " << index << " does not have a valid cover image";
|
||||
not_found(response, request, "Cover image not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Open and stream the validated file
|
||||
std::ifstream in(validated_path, std::ios::binary);
|
||||
if (!in) {
|
||||
|
|
@ -1410,6 +1434,7 @@ namespace confighttp {
|
|||
server.resource["^/apps/?$"]["GET"] = getAppsPage;
|
||||
server.resource["^/clients/?$"]["GET"] = getClientsPage;
|
||||
server.resource["^/config/?$"]["GET"] = getConfigPage;
|
||||
server.resource["^/featured/?$"]["GET"] = getFeaturedPage;
|
||||
server.resource["^/password/?$"]["GET"] = getPasswordPage;
|
||||
server.resource["^/welcome/?$"]["GET"] = getWelcomePage;
|
||||
server.resource["^/troubleshooting/?$"]["GET"] = getTroubleshootingPage;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<nav class="navbar navbar-light navbar-expand-lg navbar-background header">
|
||||
<nav class="navbar navbar-expand-lg navbar-sunshine">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="./" title="Sunshine">
|
||||
<img src="/images/logo-sunshine-45.png" height="45" alt="Sunshine">
|
||||
|
|
@ -11,22 +11,46 @@
|
|||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<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 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 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 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 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 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 class="nav-item">
|
||||
<ThemeToggle/>
|
||||
|
|
@ -38,10 +62,20 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { Home, Lock, Layers, Star, Settings, Shield, Info } from 'lucide-vue-next'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
export default {
|
||||
components: { ThemeToggle },
|
||||
components: {
|
||||
ThemeToggle,
|
||||
Home,
|
||||
Lock,
|
||||
Layers,
|
||||
Star,
|
||||
Settings,
|
||||
Shield,
|
||||
Info
|
||||
},
|
||||
created() {
|
||||
console.log("Header mounted!")
|
||||
},
|
||||
|
|
@ -53,34 +87,8 @@ export default {
|
|||
</script>
|
||||
|
||||
<style>
|
||||
.navbar-background {
|
||||
background-color: #ffc400
|
||||
}
|
||||
|
||||
.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;
|
||||
/* Navbar toggler icon for dark text on light background */
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,59 @@
|
|||
<template>
|
||||
<div class="card p-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2>{{ $t('resource_card.resources') }}</h2>
|
||||
<br>
|
||||
<p>{{ $t('resource_card.resources_desc') }}</p>
|
||||
<div class="card-group p-4 align-items-center">
|
||||
<a class="btn btn-success m-1" href="https://app.lizardbyte.dev" target="_blank">
|
||||
{{ $t('resource_card.lizardbyte_website') }}</a>
|
||||
<a class="btn btn-primary m-1" href="https://app.lizardbyte.dev/discord" target="_blank">
|
||||
<i class="fab fa-fw fa-discord"></i> Discord</a>
|
||||
<a class="btn btn-secondary m-1" href="https://github.com/orgs/LizardByte/discussions" target="_blank">
|
||||
<i class="fab fa-fw fa-github"></i> {{ $t('resource_card.github_discussions') }}</a>
|
||||
<div class="d-flex flex-wrap gap-2 mt-4">
|
||||
<a class="btn btn-success" href="https://app.lizardbyte.dev" target="_blank">
|
||||
<Globe :size="18" class="icon"></Globe>
|
||||
{{ $t('resource_card.lizardbyte_website') }}
|
||||
</a>
|
||||
<a class="btn btn-primary" href="https://app.lizardbyte.dev/discord" target="_blank">
|
||||
<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>
|
||||
<!-- Legal -->
|
||||
<div class="card p-2 mt-4">
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h2>{{ $t('resource_card.legal') }}</h2>
|
||||
<br>
|
||||
<p>{{ $t('resource_card.legal_desc') }}</p>
|
||||
<div class="card-group p-4 align-items-center">
|
||||
<a class="btn btn-danger m-1" href="https://github.com/LizardByte/Sunshine/blob/master/LICENSE"
|
||||
<div class="d-flex flex-wrap gap-2 mt-4">
|
||||
<a class="btn btn-danger" href="https://github.com/LizardByte/Sunshine/blob/master/LICENSE"
|
||||
target="_blank">
|
||||
<i class="fas fa-fw fa-file-alt"></i> {{ $t('resource_card.license') }}</a>
|
||||
<a class="btn btn-danger m-1" href="https://github.com/LizardByte/Sunshine/blob/master/NOTICE"
|
||||
<FileText :size="18" class="icon"></FileText>
|
||||
{{ $t('resource_card.license') }}
|
||||
</a>
|
||||
<a class="btn btn-danger" href="https://github.com/LizardByte/Sunshine/blob/master/NOTICE"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Globe,
|
||||
} from 'lucide-vue-next'
|
||||
import SimpleIcon from './SimpleIcon.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SimpleIcon,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Globe,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
44
src_assets/common/assets/web/SimpleIcon.vue
Normal file
44
src_assets/common/assets/web/SimpleIcon.vue
Normal 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>
|
||||
|
|
@ -1,6 +1,23 @@
|
|||
<script setup>
|
||||
import { loadAutoTheme, setupThemeToggleListener } from './theme'
|
||||
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(() => {
|
||||
loadAutoTheme()
|
||||
|
|
@ -10,32 +27,110 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<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"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
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>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme-text">
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false">
|
||||
<i class="bi me-2 theme-icon fas fa-fw fa-solid fa-sun"></i>
|
||||
{{ $t('navbar.theme_light') }}
|
||||
<button type="button" class="dropdown-item d-flex align-items-center active" data-bs-theme-value="auto" aria-pressed="true">
|
||||
<MonitorSmartphone :size="18" class="theme-icon icon"></MonitorSmartphone>
|
||||
{{ $t('navbar.theme_auto') }}
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<!-- Dark Themes -->
|
||||
<li>
|
||||
<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') }}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<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-circle-half-stroke"></i>
|
||||
{{ $t('navbar.theme_auto') }}
|
||||
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="slate" aria-pressed="false">
|
||||
<Layers :size="18" class="theme-icon icon"></Layers>
|
||||
{{ $t('navbar.theme_slate') }}
|
||||
</button>
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -3,71 +3,6 @@
|
|||
|
||||
<head>
|
||||
<%- 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>
|
||||
|
||||
<body id="app" v-cloak>
|
||||
|
|
@ -75,33 +10,60 @@
|
|||
<div class="container">
|
||||
<div class="my-4">
|
||||
<h1>{{ $t('apps.applications_title') }}</h1>
|
||||
<div>{{ $t('apps.applications_desc') }}</div>
|
||||
<p>{{ $t('apps.applications_desc') }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ $t('apps.name') }}</th>
|
||||
<th scope="col">{{ $t('apps.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(app,i) in apps" :key="i">
|
||||
<td>{{app.name}}</td>
|
||||
<td>
|
||||
<button class="btn btn-primary mx-1" @click="editApp(i)">
|
||||
<i class="fas fa-edit"></i> {{ $t('apps.edit') }}
|
||||
|
||||
<!-- Apps Grid -->
|
||||
<div class="row g-3" v-if="apps && apps.length > 0">
|
||||
<div class="col-12 col-sm-6 col-md-4 col-lg-3" v-for="(app,i) in apps" :key="i">
|
||||
<div class="card app-card h-100">
|
||||
<div class="app-poster-container">
|
||||
<img
|
||||
v-if="app['image-path']"
|
||||
:src="'/api/covers/' + i"
|
||||
class="app-poster"
|
||||
:alt="app.name"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<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 class="btn btn-danger mx-1" @click="showDeleteForm(i)">
|
||||
<i class="fas fa-trash"></i> {{ $t('apps.delete') }}
|
||||
<button class="btn btn-sm btn-danger" @click="showDeleteForm(i)">
|
||||
<trash-2 :size="16" class="icon"></trash-2>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="mb-3">
|
||||
<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="d-flex justify-content-start mb-3 mt-3" v-if="editForm['prep-cmd'].length === 0">
|
||||
<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>
|
||||
</div>
|
||||
<table class="table" v-if="editForm['prep-cmd'].length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><i class="fas fa-play"></i> {{ $t('_common.do_cmd') }}</th>
|
||||
<th scope="col"><i class="fas fa-undo"></i> {{ $t('_common.undo_cmd') }}</th>
|
||||
<th scope="col">
|
||||
<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'">
|
||||
<i class="fas fa-shield-alt"></i> {{ $t('_common.run_as') }}
|
||||
<shield :size="18" class="icon"></shield>
|
||||
{{ $t('_common.run_as') }}
|
||||
</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
|
|
@ -158,12 +128,12 @@
|
|||
v-model="c.elevated"
|
||||
></Checkbox>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger" @click="editForm['prep-cmd'].splice(i,1)">
|
||||
<i class="fas fa-trash"></i>
|
||||
<td class="align-middle">
|
||||
<button class="btn btn-danger btn-sm ms-2" @click="deletePrepCmd(i)">
|
||||
<trash-2 :size="16" class="icon"></trash-2>
|
||||
</button>
|
||||
<button class="btn btn-success" @click="addPrepCmd">
|
||||
<i class="fas fa-plus"></i>
|
||||
<button class="btn btn-success btn-sm ms-2" @click="addPrepCmd">
|
||||
<plus :size="16" class="icon"></plus>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -173,15 +143,19 @@
|
|||
<!-- detached -->
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
<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)">
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<button class="btn btn-success" @click="editForm.detached.push('');">
|
||||
<i class="fas fa-plus mr-1"></i> {{ $t('apps.detached_cmds_add') }}
|
||||
<div class="d-flex justify-content-start mb-3 mt-3" v-if="editForm.detached.length === 0">
|
||||
<button class="btn btn-success" @click="addDetached">
|
||||
<plus :size="18" class="icon"></plus>
|
||||
{{ $t('apps.detached_cmds_add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
|
|
@ -240,42 +214,79 @@
|
|||
</div>
|
||||
<div class="mb-3">
|
||||
<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"
|
||||
v-model="editForm['image-path']" />
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="findCoverToggle"
|
||||
aria-expanded="false" @click="showCoverFinder" ref="coverFinderDropdown">
|
||||
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#coverFinderModal"
|
||||
@click="showCoverFinder">
|
||||
<search :size="18" class="icon"></search>
|
||||
{{ $t('apps.find_cover') }}
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end w-50 cover-finder overflow-hidden"
|
||||
aria-labelledby="findCoverToggle">
|
||||
<div class="modal-header px-2">
|
||||
<h4 class="modal-title">{{ $t('apps.covers_found') }}</h4>
|
||||
<button type="button" class="btn-close mr-2" aria-label="Close" @click="closeCoverFinder"></button>
|
||||
</div>
|
||||
<div id="appImagePathHelp" class="form-text">{{ $t('apps.image_desc') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 class="modal-body cover-results px-3 pt-3" :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 class="modal-body">
|
||||
<div class="mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
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 v-for="(cover,i) in coverCandidates" :key="'i'" class="col-12 col-sm-6 col-lg-4 mb-3"
|
||||
@click="useCover(cover)">
|
||||
<div class="cover-container result">
|
||||
<img class="rounded" :src="cover.url" />
|
||||
<div v-for="(cover,i) in coverCandidates" :key="'i'" class="col-12 col-sm-6 col-lg-3 mb-3"
|
||||
@click="useCover(cover)">
|
||||
<div class="cover-container result">
|
||||
<img class="rounded" :src="cover.url" />
|
||||
</div>
|
||||
<label class="d-block text-nowrap text-center text-truncate">
|
||||
{{cover.name}}
|
||||
</label>
|
||||
</div>
|
||||
<label class="d-block text-nowrap text-center text-truncate">
|
||||
{{cover.name}}
|
||||
</label>
|
||||
</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 id="appImagePathHelp" class="form-text">{{ $t('apps.image_desc') }}</div>
|
||||
</div>
|
||||
<div class="env-hint alert alert-info">
|
||||
<div class="form-text">
|
||||
|
|
@ -344,15 +355,20 @@
|
|||
<!-- Save buttons -->
|
||||
<div class="d-flex">
|
||||
<button @click="showEditForm = false" class="btn btn-secondary m-2">
|
||||
<x :size="18" class="icon"></x>
|
||||
{{ $t('_common.cancel') }}
|
||||
</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 class="mt-2" v-else>
|
||||
<div class="mt-4" v-else>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -362,12 +378,38 @@
|
|||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.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({
|
||||
components: {
|
||||
Navbar,
|
||||
Checkbox
|
||||
Checkbox,
|
||||
Edit,
|
||||
FileText,
|
||||
LayersPlus,
|
||||
Play,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Shield,
|
||||
Terminal,
|
||||
Trash2,
|
||||
X,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -378,6 +420,7 @@
|
|||
coverSearching: false,
|
||||
coverFinderBusy: false,
|
||||
coverCandidates: [],
|
||||
coverSearchQuery: "",
|
||||
platform: "",
|
||||
};
|
||||
},
|
||||
|
|
@ -398,7 +441,7 @@
|
|||
this.editForm = {
|
||||
name: "",
|
||||
output: "",
|
||||
cmd: [],
|
||||
cmd: "",
|
||||
index: -1,
|
||||
"exclude-global-prep-cmd": false,
|
||||
elevated: false,
|
||||
|
|
@ -462,22 +505,27 @@
|
|||
|
||||
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.coverSearchQuery = "";
|
||||
|
||||
// Perform initial search with app name
|
||||
this.performCoverSearch();
|
||||
},
|
||||
performCoverSearch() {
|
||||
this.coverSearching = true;
|
||||
const ref = this.$refs.coverFinderDropdown;
|
||||
if (!ref) {
|
||||
console.error("Ref not found!");
|
||||
return;
|
||||
}
|
||||
this.coverFinderDropdown = Dropdown.getInstance(ref);
|
||||
if (!this.coverFinderDropdown) {
|
||||
this.coverFinderDropdown = new Dropdown(ref);
|
||||
if (!this.coverFinderDropdown) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.coverFinderDropdown.show();
|
||||
this.coverCandidates = [];
|
||||
|
||||
// Use search query if provided, otherwise fall back to app name
|
||||
const searchTerm = this.coverSearchQuery.trim() || this.editForm["name"].toString();
|
||||
|
||||
function getSearchBucket(name) {
|
||||
let bucket = name.substring(0, Math.min(name.length, 2)).toLowerCase().replaceAll(/[^a-z\d]/g, '');
|
||||
if (!bucket) {
|
||||
|
|
@ -526,21 +574,10 @@
|
|||
}).filter(item => item));
|
||||
}
|
||||
|
||||
searchCovers(this.editForm["name"].toString())
|
||||
searchCovers(searchTerm)
|
||||
.then(list => this.coverCandidates = list)
|
||||
.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) {
|
||||
this.coverFinderBusy = true;
|
||||
fetch("./api/covers/upload", {
|
||||
|
|
@ -555,8 +592,17 @@
|
|||
}).then(r => {
|
||||
if (!r.ok) throw new Error("Failed to download covers");
|
||||
return r.json();
|
||||
}).then(body => this.editForm["image-path"] = body.path)
|
||||
.then(() => this.closeCoverFinder())
|
||||
}).then(body => {
|
||||
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);
|
||||
},
|
||||
save() {
|
||||
|
|
@ -571,14 +617,17 @@
|
|||
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);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,29 +3,50 @@
|
|||
|
||||
<head>
|
||||
<%- header %>
|
||||
<style>
|
||||
.config-page {
|
||||
padding: 1em;
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
padding: 1em 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body id="app" v-cloak>
|
||||
<Navbar></Navbar>
|
||||
<div class="container">
|
||||
<h1 class="my-4">{{ $t('config.configuration') }}</h1>
|
||||
|
||||
<!-- Search Bar with Autocomplete -->
|
||||
<div class="mb-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<search :size="18" class="icon"></search>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('config.search_options')"
|
||||
@input="handleSearch"
|
||||
list="config-options"
|
||||
/>
|
||||
</div>
|
||||
<datalist id="config-options">
|
||||
<option v-for="option in allConfigOptions" :key="option.key" :value="option.label">
|
||||
{{ option.tab }} - {{ option.label }}
|
||||
</option>
|
||||
</datalist>
|
||||
<div v-if="searchQuery && searchResults.length === 0" class="text-muted small mt-1">
|
||||
No results found for "{{ searchQuery }}"
|
||||
</div>
|
||||
<div v-else-if="searchQuery" class="text-muted small mt-1">
|
||||
Found {{ searchResults.length }} result(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form" v-if="config">
|
||||
<!-- Header -->
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item" v-for="tab in tabs" :key="tab.id">
|
||||
<a class="nav-link" :class="{'active': tab.id === currentTab}" href="#"
|
||||
@click="currentTab = tab.id">{{tab.name}}</a>
|
||||
@click="currentTab = tab.id">
|
||||
<component :is="getTabIcon(tab.id)" :size="18" class="icon"></component>
|
||||
{{tab.name}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -86,9 +107,15 @@
|
|||
<div class="alert alert-success my-4" v-if="restarted">
|
||||
<b>{{ $t('_common.success') }}</b> {{ $t('config.restart_note') }}
|
||||
</div>
|
||||
<div class="mb-3 buttons">
|
||||
<button class="btn btn-primary mr-3" @click="save">{{ $t('_common.save') }}</button>
|
||||
<button class="btn btn-success" @click="apply" v-if="saved && !restarted">{{ $t('_common.apply') }}</button>
|
||||
<div class="mb-3 d-flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="save">
|
||||
<save :size="18" class="icon"></save>
|
||||
{{ $t('_common.save') }}
|
||||
</button>
|
||||
<button class="btn btn-success" @click="apply" v-if="saved && !restarted">
|
||||
<check :size="18" class="icon"></check>
|
||||
{{ $t('_common.apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
@ -106,6 +133,19 @@
|
|||
import AudioVideo from './configs/tabs/AudioVideo.vue'
|
||||
import ContainerEncoders from './configs/tabs/ContainerEncoders.vue'
|
||||
import {$tp, usePlatformI18n} from './platform-i18n'
|
||||
import {
|
||||
Check,
|
||||
Cpu,
|
||||
FileCog,
|
||||
Gamepad2,
|
||||
Gpu,
|
||||
Network as NetworkIcon,
|
||||
Save,
|
||||
Search,
|
||||
Settings,
|
||||
Sliders,
|
||||
Volume2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const app = createApp({
|
||||
components: {
|
||||
|
|
@ -118,6 +158,18 @@
|
|||
// They will be accessible via audio-video, container-encoders only.
|
||||
AudioVideo,
|
||||
ContainerEncoders,
|
||||
// icons
|
||||
Cpu,
|
||||
Check,
|
||||
FileCog,
|
||||
Gamepad2,
|
||||
Gpu,
|
||||
NetworkIcon,
|
||||
Save,
|
||||
Search,
|
||||
Settings,
|
||||
Sliders,
|
||||
Volume2,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -126,6 +178,7 @@
|
|||
restarted: false,
|
||||
config: null,
|
||||
currentTab: "general",
|
||||
searchQuery: "",
|
||||
tabs: [ // TODO: Move the options to each Component instead, encapsulate.
|
||||
{
|
||||
id: "general",
|
||||
|
|
@ -290,9 +343,34 @@
|
|||
},
|
||||
provide() {
|
||||
return {
|
||||
platform: computed(() => this.platform)
|
||||
platform: computed(() => this.platform),
|
||||
searchQuery: computed(() => this.searchQuery),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allConfigOptions() {
|
||||
const options = [];
|
||||
this.tabs.forEach(tab => {
|
||||
Object.keys(tab.options).forEach(key => {
|
||||
options.push({
|
||||
key: key,
|
||||
label: key.replaceAll('_', ' ').replaceAll(/\b\w/g, l => l.toUpperCase()),
|
||||
tab: tab.name,
|
||||
tabId: tab.id
|
||||
});
|
||||
});
|
||||
});
|
||||
return options;
|
||||
},
|
||||
searchResults() {
|
||||
if (!this.searchQuery) return [];
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
return this.allConfigOptions.filter(option =>
|
||||
option.key.toLowerCase().includes(query) ||
|
||||
option.label.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
},
|
||||
created() {
|
||||
fetch("./api/config")
|
||||
.then((r) => r.json())
|
||||
|
|
@ -344,6 +422,23 @@
|
|||
});
|
||||
},
|
||||
methods: {
|
||||
getTabIcon(tabId) {
|
||||
const iconMap = {
|
||||
'general': 'Settings',
|
||||
'input': 'Gamepad2',
|
||||
'av': 'Volume2',
|
||||
'network': 'NetworkIcon',
|
||||
'files': 'FileCog',
|
||||
'advanced': 'Sliders',
|
||||
'nv': 'Gpu',
|
||||
'amd': 'Gpu',
|
||||
'qsv': 'Gpu',
|
||||
'vaapi': 'Gpu',
|
||||
'vt': 'Gpu',
|
||||
'sw': 'Cpu',
|
||||
};
|
||||
return iconMap[tabId] || 'Settings';
|
||||
},
|
||||
forceUpdate() {
|
||||
this.$forceUpdate()
|
||||
},
|
||||
|
|
@ -408,6 +503,67 @@
|
|||
}
|
||||
});
|
||||
},
|
||||
handleSearch() {
|
||||
// Clear all highlighting
|
||||
document.querySelectorAll('.config-search-highlight').forEach(el => {
|
||||
el.classList.remove('config-search-highlight');
|
||||
});
|
||||
|
||||
if (!this.searchQuery) {
|
||||
// Show all form groups when search is cleared
|
||||
document.querySelectorAll('.mb-3').forEach(el => {
|
||||
el.style.display = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const results = this.searchResults;
|
||||
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the tab of the first result
|
||||
if (results.length > 0 && results[0].tabId !== this.currentTab) {
|
||||
this.currentTab = results[0].tabId;
|
||||
}
|
||||
|
||||
// Wait for tab content to render
|
||||
this.$nextTick(() => {
|
||||
// Hide all form groups first
|
||||
document.querySelectorAll('.config-page .mb-3').forEach(el => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show only matching elements
|
||||
results.forEach(result => {
|
||||
const element = document.getElementById(result.key);
|
||||
|
||||
if (element) {
|
||||
// Show the element's container
|
||||
const container = element.closest('.mb-3');
|
||||
if (container) {
|
||||
container.style.display = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to and highlight the first result
|
||||
if (results.length > 0) {
|
||||
const firstElement = document.getElementById(results[0].key);
|
||||
if (firstElement) {
|
||||
const container = firstElement.closest('.mb-3');
|
||||
if (container) {
|
||||
container.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
container.classList.add('config-search-highlight');
|
||||
setTimeout(() => {
|
||||
container.classList.remove('config-search-highlight');
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Handle hashchange events
|
||||
|
|
|
|||
436
src_assets/common/assets/web/featured.html
Normal file
436
src_assets/common/assets/web/featured.html
Normal 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>
|
||||
|
|
@ -10,77 +10,115 @@
|
|||
<div id="content" class="container">
|
||||
<h1 class="my-4">{{ $t('index.welcome') }}</h1>
|
||||
<p>{{ $t('index.description') }}</p>
|
||||
<div class="alert alert-danger" v-if="fancyLogs.find(x => x.level === 'Fatal')">
|
||||
<div style="line-height: 32px;">
|
||||
<i class="fas fa-circle-exclamation" style="font-size: 32px;margin-right: 0.25em;"></i>
|
||||
<p v-html="$t('index.startup_errors')"></p>
|
||||
<br>
|
||||
|
||||
<!-- Fatal Errors Alert -->
|
||||
<div class="alert alert-danger my-4" v-if="fancyLogs.find(x => x.level === 'Fatal')">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- ViGEmBus Warning -->
|
||||
<div class="alert alert-warning" v-if="platform === 'windows' && controllerEnabled && vigembus && (!vigembus.installed || !vigembus.version_compatible)">
|
||||
<div style="line-height: 32px;">
|
||||
<i class="fas fa-triangle-exclamation" style="font-size: 32px;margin-right: 0.25em;"></i>
|
||||
<div v-if="!vigembus.installed">
|
||||
<p><strong>{{ $t('index.vigembus_not_installed_title') }}</strong></p>
|
||||
<p>{{ $t('index.vigembus_not_installed_desc') }}</p>
|
||||
<div class="alert alert-warning my-4" v-if="platform === 'windows' && controllerEnabled && vigembus && (!vigembus.installed || !vigembus.version_compatible)">
|
||||
<div>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<alert-triangle :size="32" class="icon-lg me-3"></alert-triangle>
|
||||
<div>
|
||||
<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 v-else-if="!vigembus.version_compatible">
|
||||
<p><strong>{{ $t('index.vigembus_outdated_title') }}</strong></p>
|
||||
<p>{{ $t('index.vigembus_outdated_desc', { version: vigembus.version }) }}</p>
|
||||
</div>
|
||||
<a class="btn btn-warning" href="./troubleshooting#vigembus">{{ $t('index.fix_now') }}</a>
|
||||
<a class="btn btn-warning" href="./troubleshooting#vigembus">
|
||||
<wrench :size="18" class="icon"></wrench>
|
||||
{{ $t('index.fix_now') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version -->
|
||||
<div class="card p-2 my-4">
|
||||
<div class="card my-4">
|
||||
<div class="card-body" v-if="version">
|
||||
<h2>Version {{version.version}}</h2>
|
||||
<br>
|
||||
<div v-if="loading">
|
||||
|
||||
<div v-if="loading" class="my-3">
|
||||
{{ $t('index.loading_latest') }}
|
||||
</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') }} 🌇
|
||||
</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') }}
|
||||
</div>
|
||||
|
||||
<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') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="notifyPreReleases && preReleaseBuildAvailable">
|
||||
<div class="alert alert-warning">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="my-2">
|
||||
<p>{{ $t('index.new_pre_release') }}</p>
|
||||
<div class="alert alert-warning my-3">
|
||||
<!-- header row -->
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
|
||||
<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>
|
||||
<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>
|
||||
<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 v-if="stableBuildAvailable">
|
||||
<div class="alert alert-warning">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div class="my-2">
|
||||
<p>{{ $t('index.new_stable') }}</p>
|
||||
<div class="alert alert-warning my-3">
|
||||
<!-- header row -->
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Resources -->
|
||||
<div class="my-4">
|
||||
<Resource-Card></Resource-Card>
|
||||
|
|
@ -95,6 +133,16 @@
|
|||
import Navbar from './Navbar.vue'
|
||||
import ResourceCard from './ResourceCard.vue'
|
||||
import SunshineVersion from './sunshine_version'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Wrench,
|
||||
Package,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Download
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
// Configure marked to allow HTML
|
||||
marked.setOptions({
|
||||
|
|
@ -109,7 +157,15 @@
|
|||
let app = createApp({
|
||||
components: {
|
||||
Navbar,
|
||||
ResourceCard
|
||||
ResourceCard,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
Wrench,
|
||||
Package,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Download
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -200,3 +256,4 @@
|
|||
|
||||
initApp(app);
|
||||
</script>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,17 +3,6 @@
|
|||
|
||||
<head>
|
||||
<%- header %>
|
||||
<style>
|
||||
.config-page {
|
||||
padding: 1em;
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
padding: 1em 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body id="app" v-cloak>
|
||||
|
|
@ -21,46 +10,52 @@
|
|||
<div class="container">
|
||||
<h1 class="my-4">{{ $t('password.password_change') }}</h1>
|
||||
<form @submit.prevent="save">
|
||||
<div class="card d-flex p-4 flex-row">
|
||||
<div class="col-md-6 px-4">
|
||||
<h4>{{ $t('password.current_creds') }}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="currentUsername" class="form-label">{{ $t('_common.username') }}</label>
|
||||
<input required type="text" class="form-control" id="currentUsername"
|
||||
v-model="passwordData.currentUsername" />
|
||||
<div class="form-text"> </div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="currentPassword" class="form-label">{{ $t('_common.password') }}</label>
|
||||
<input autocomplete="current-password" type="password" class="form-control" id="currentPassword"
|
||||
v-model="passwordData.currentPassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 px-4">
|
||||
<h4>{{ $t('password.new_creds') }}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="newUsername" class="form-label">{{ $t('_common.username') }}</label>
|
||||
<input type="text" class="form-control" id="newUsername" v-model="passwordData.newUsername" />
|
||||
<div class="form-text">{{ $t('password.new_username_desc') }}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newPassword" class="form-label">{{ $t('_common.password') }}</label>
|
||||
<input autocomplete="new-password" required type="password" class="form-control" id="newPassword"
|
||||
v-model="passwordData.newPassword" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmNewPassword" class="form-label">{{ $t('password.confirm_password') }}</label>
|
||||
<input autocomplete="new-password" required type="password" class="form-control" id="confirmNewPassword"
|
||||
v-model="passwordData.confirmNewPassword" />
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h4>{{ $t('password.current_creds') }}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="currentUsername" class="form-label">{{ $t('_common.username') }}</label>
|
||||
<input required type="text" class="form-control" id="currentUsername"
|
||||
v-model="passwordData.currentUsername" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="currentPassword" class="form-label">{{ $t('_common.password') }}</label>
|
||||
<input autocomplete="current-password" type="password" class="form-control" id="currentPassword"
|
||||
v-model="passwordData.currentPassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h4>{{ $t('password.new_creds') }}</h4>
|
||||
<div class="mb-3">
|
||||
<label for="newUsername" class="form-label">{{ $t('_common.username') }}</label>
|
||||
<input type="text" class="form-control" id="newUsername" v-model="passwordData.newUsername" />
|
||||
<div class="form-text">{{ $t('password.new_username_desc') }}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newPassword" class="form-label">{{ $t('_common.password') }}</label>
|
||||
<input autocomplete="new-password" required type="password" class="form-control" id="newPassword"
|
||||
v-model="passwordData.newPassword" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmNewPassword" class="form-label">{{ $t('password.confirm_password') }}</label>
|
||||
<input autocomplete="new-password" required type="password" class="form-control" id="confirmNewPassword"
|
||||
v-model="passwordData.confirmNewPassword" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="error"><b>Error: </b>{{error}}</div>
|
||||
<div class="alert alert-success" v-if="success">
|
||||
<div class="alert alert-danger my-3" v-if="error"><b>Error: </b>{{error}}</div>
|
||||
<div class="alert alert-success my-3" v-if="success">
|
||||
<b>{{ $t('_common.success') }}</b> {{ $t('password.success_msg') }}
|
||||
</div>
|
||||
<div class="mb-3 buttons">
|
||||
<button class="btn btn-primary">{{ $t('_common.save') }}</button>
|
||||
<div class="mb-3 mt-4">
|
||||
<button class="btn btn-primary">
|
||||
<save :size="18" class="icon"></save>
|
||||
{{ $t('_common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -69,10 +64,12 @@
|
|||
import { createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import { Save } from 'lucide-vue-next'
|
||||
|
||||
const app = createApp({
|
||||
components: {
|
||||
Navbar
|
||||
Navbar,
|
||||
Save,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,22 @@
|
|||
<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">
|
||||
<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 />
|
||||
<input type="text" :placeholder="`${$t('pin.device_name')}`" id="name-input" class="form-control my-4" required />
|
||||
<button class="btn btn-primary">{{ $t('pin.send') }}</button>
|
||||
<div class="input-group mt-2">
|
||||
<span class="input-group-text">
|
||||
<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 class="alert alert-warning">
|
||||
<b>{{ $t('_common.warning') }}</b> {{ $t('pin.warning_msg') }}
|
||||
|
|
@ -27,10 +40,18 @@
|
|||
import { createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import {
|
||||
Forward,
|
||||
Hash,
|
||||
Monitor,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
let app = createApp({
|
||||
components: {
|
||||
Navbar
|
||||
Navbar,
|
||||
Forward,
|
||||
Hash,
|
||||
Monitor,
|
||||
},
|
||||
inject: ['i18n'],
|
||||
methods: {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,12 @@
|
|||
{
|
||||
"_common": {
|
||||
"all": "All",
|
||||
"apply": "Apply",
|
||||
"auto": "Automatic",
|
||||
"autodetect": "Autodetect (recommended)",
|
||||
"beta": "(beta)",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"disabled": "Disabled",
|
||||
"disabled_def": "Disabled (default)",
|
||||
"disabled_def_cbox": "Default: unchecked",
|
||||
|
|
@ -15,10 +17,12 @@
|
|||
"enabled_def": "Enabled (default)",
|
||||
"enabled_def_cbox": "Default: checked",
|
||||
"error": "Error!",
|
||||
"loading": "Loading...",
|
||||
"note": "Note:",
|
||||
"password": "Password",
|
||||
"run_as": "Run as Admin",
|
||||
"save": "Save",
|
||||
"search": "Search...",
|
||||
"see_more": "See More",
|
||||
"success": "Success!",
|
||||
"undo_cmd": "Undo Command",
|
||||
|
|
@ -41,6 +45,7 @@
|
|||
"cmd_prep_desc": "A list of commands to be run before/after this application. If any of the prep-commands fail, starting the application is aborted.",
|
||||
"cmd_prep_name": "Command Preparations",
|
||||
"covers_found": "Covers Found",
|
||||
"cover_search_hint": "Search names should match IGDB naming conventions.",
|
||||
"delete": "Delete",
|
||||
"detached_cmds": "Detached Commands",
|
||||
"detached_cmds_add": "Add Detached Command",
|
||||
|
|
@ -73,9 +78,11 @@
|
|||
"image_desc": "Application icon/picture/image path that will be sent to client. Image must be a PNG file. If not set, Sunshine will send default box image.",
|
||||
"loading": "Loading...",
|
||||
"name": "Name",
|
||||
"no_covers_found": "No covers found",
|
||||
"output_desc": "The file where the output of the command is stored, if it is not specified, the output is ignored",
|
||||
"output_name": "Output",
|
||||
"run_as_desc": "This can be necessary for some applications that require administrator permissions to run properly.",
|
||||
"searching_covers": "Searching for covers...",
|
||||
"wait_all": "Continue streaming until all app processes exit",
|
||||
"wait_all_desc": "This will continue streaming until all processes started by the app have terminated. When unchecked, streaming will stop when the initial app process exits, even if other app processes are still running.",
|
||||
"working_dir": "Working Directory",
|
||||
|
|
@ -334,6 +341,7 @@
|
|||
"qsv_slow_hevc": "Allow Slow HEVC Encoding",
|
||||
"qsv_slow_hevc_desc": "This can enable HEVC encoding on older Intel GPUs, at the cost of higher GPU usage and worse performance.",
|
||||
"restart_note": "Sunshine is restarting to apply changes.",
|
||||
"search_options": "Search configuration options...",
|
||||
"stream_audio": "Stream Audio",
|
||||
"stream_audio_desc": "Whether to stream audio or not. Disabling this can be useful for streaming headless displays as second monitors.",
|
||||
"sunshine_name": "Sunshine Name",
|
||||
|
|
@ -398,12 +406,25 @@
|
|||
"navbar": {
|
||||
"applications": "Applications",
|
||||
"configuration": "Configuration",
|
||||
"featured": "Featured Apps",
|
||||
"home": "Home",
|
||||
"password": "Change Password",
|
||||
"pin": "PIN",
|
||||
"theme_auto": "Auto",
|
||||
"theme_dark": "Dark",
|
||||
"theme_ember": "Ember",
|
||||
"theme_forest": "Forest",
|
||||
"theme_indigo": "Indigo",
|
||||
"theme_lavender": "Lavender",
|
||||
"theme_light": "Light",
|
||||
"theme_midnight": "Midnight",
|
||||
"theme_monochrome": "Monochrome",
|
||||
"theme_moonlight": "Moonlight",
|
||||
"theme_nord": "Nord",
|
||||
"theme_ocean": "Ocean",
|
||||
"theme_rose": "Rose",
|
||||
"theme_slate": "Slate",
|
||||
"theme_sunshine": "Sunshine",
|
||||
"toggle_theme": "Theme",
|
||||
"troubleshoot": "Troubleshooting"
|
||||
},
|
||||
|
|
@ -468,6 +489,25 @@
|
|||
"vigembus_force_reinstall_button": "Force Reinstall ViGEmBus v{version}",
|
||||
"vigembus_not_installed": "ViGEmBus is not installed."
|
||||
},
|
||||
"featured": {
|
||||
"categories": {
|
||||
"client": "Clients",
|
||||
"tool": "Tools"
|
||||
},
|
||||
"description": "Discover clients, tools, and integrations that enhance your Sunshine streaming experience.",
|
||||
"docs": "Docs",
|
||||
"documentation": "Documentation",
|
||||
"get": "Get",
|
||||
"github": "GitHub Repository",
|
||||
"github_forks": "Forks",
|
||||
"github_issues": "Open Issues",
|
||||
"github_stars": "Stars",
|
||||
"last_updated": "Last Updated",
|
||||
"no_apps": "No apps found in this category.",
|
||||
"official": "Official",
|
||||
"title": "Featured Apps",
|
||||
"website": "Website"
|
||||
},
|
||||
"welcome": {
|
||||
"confirm_password": "Confirm password",
|
||||
"create_creds": "Before Getting Started, we need you to make a new username and password for accessing the Web UI.",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,5 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sunshine</title>
|
||||
<link rel="icon" type="image/x-icon" href="./images/sunshine.ico">
|
||||
<link href="@fortawesome/fontawesome-free/css/all.min.css" rel="stylesheet">
|
||||
<link href="bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link href="./assets/css/sunshine.css" rel="stylesheet" />
|
||||
|
|
|
|||
|
|
@ -10,14 +10,30 @@ export const getPreferredTheme = () => {
|
|||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
// Define which themes are dark (for Bootstrap compatibility)
|
||||
const darkThemes = new Set([
|
||||
'dark',
|
||||
'ember',
|
||||
'midnight',
|
||||
'moonlight',
|
||||
'nord',
|
||||
'slate',
|
||||
])
|
||||
|
||||
const setTheme = theme => {
|
||||
if (theme === 'auto') {
|
||||
document.documentElement.setAttribute(
|
||||
'data-bs-theme',
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
)
|
||||
const preferredTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
document.documentElement.dataset.bsTheme = preferredTheme
|
||||
document.documentElement.dataset.theme = preferredTheme
|
||||
console.log(`Theme set to auto (resolved to: ${preferredTheme})`)
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', theme)
|
||||
// Set Bootstrap's data-bs-theme to 'light' or 'dark' for Bootstrap's own styles
|
||||
const bsTheme = darkThemes.has(theme) ? 'dark' : 'light'
|
||||
document.documentElement.dataset.bsTheme = bsTheme
|
||||
|
||||
// Set our custom data-theme attribute for our color schemes
|
||||
document.documentElement.dataset.theme = theme
|
||||
console.log(`Theme set to: ${theme} (Bootstrap: ${bsTheme})`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -29,9 +45,18 @@ export const showActiveTheme = (theme, focus = false) => {
|
|||
}
|
||||
|
||||
const themeSwitcherText = document.querySelector('#bd-theme-text')
|
||||
const activeThemeIcon = document.querySelector('.theme-icon-active i')
|
||||
const activeThemeIcon = document.querySelector('.theme-icon-active svg')
|
||||
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
|
||||
const classListOfActiveBtn = btnToActive.querySelector('i').classList
|
||||
|
||||
if (!btnToActive) {
|
||||
return
|
||||
}
|
||||
|
||||
const btnIcon = btnToActive.querySelector('svg')
|
||||
|
||||
if (!activeThemeIcon || !btnIcon) {
|
||||
return
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
|
||||
element.classList.remove('active')
|
||||
|
|
@ -40,8 +65,11 @@ export const showActiveTheme = (theme, focus = false) => {
|
|||
|
||||
btnToActive.classList.add('active')
|
||||
btnToActive.setAttribute('aria-pressed', 'true')
|
||||
activeThemeIcon.classList.remove(...activeThemeIcon.classList.values())
|
||||
activeThemeIcon.classList.add(...classListOfActiveBtn)
|
||||
|
||||
// Clone the SVG icon from the active button to the theme switcher
|
||||
const clonedIcon = btnIcon.cloneNode(true)
|
||||
activeThemeIcon.parentNode.replaceChild(clonedIcon, activeThemeIcon)
|
||||
|
||||
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.textContent.trim()})`
|
||||
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
|
||||
|
||||
|
|
@ -72,7 +100,8 @@ export function loadAutoTheme() {
|
|||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
const storedTheme = getStoredTheme()
|
||||
if (storedTheme !== 'light' && storedTheme !== 'dark') {
|
||||
// Only auto-switch if theme is set to 'auto'
|
||||
if (storedTheme === 'auto' || !storedTheme) {
|
||||
setTheme(getPreferredTheme())
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,37 +3,6 @@
|
|||
|
||||
<head>
|
||||
<%- 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>
|
||||
|
||||
<body id="app" v-cloak>
|
||||
|
|
@ -41,27 +10,31 @@
|
|||
<div class="container">
|
||||
<h1 class="my-4">{{ $t('troubleshooting.troubleshooting') }}</h1>
|
||||
<!-- 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">
|
||||
<h2 id="vigembus">{{ $t('troubleshooting.vigembus_install') }}</h2>
|
||||
<br>
|
||||
<p style="white-space: pre-line">{{ $t('troubleshooting.vigembus_desc') }}</p>
|
||||
<div v-if="vigembus.installed && vigembus.version">
|
||||
<p><strong>{{ $t('troubleshooting.vigembus_current_version') }}:</strong> {{ vigembus.version }}</p>
|
||||
<div class="alert alert-success" v-if="vigembus.version_compatible">
|
||||
<check-circle :size="18" class="icon"></check-circle>
|
||||
{{ $t('troubleshooting.vigembus_compatible') }}
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="!vigembus.version_compatible">
|
||||
<alert-circle :size="18" class="icon"></alert-circle>
|
||||
{{ $t('troubleshooting.vigembus_incompatible') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning" v-else-if="!vigembus.installed">
|
||||
<alert-triangle :size="18" class="icon"></alert-triangle>
|
||||
{{ $t('troubleshooting.vigembus_not_installed') }}
|
||||
</div>
|
||||
<div class="alert alert-success" v-if="vigemBusInstallStatus === true">
|
||||
<check-circle :size="18" class="icon"></check-circle>
|
||||
{{ $t('troubleshooting.vigembus_install_success') }}
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="vigemBusInstallStatus === false">
|
||||
<alert-circle :size="18" class="icon"></alert-circle>
|
||||
{{ vigemBusInstallError || $t('troubleshooting.vigembus_install_error') }}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -69,65 +42,67 @@
|
|||
:class="vigembus.installed && vigembus.version === vigembus.packaged_version ? 'btn btn-danger' : 'btn btn-primary'"
|
||||
:disabled="vigemBusInstallPressed"
|
||||
@click="installViGEmBus">
|
||||
<template v-if="vigembus.installed && vigembus.version === vigembus.packaged_version">
|
||||
{{ $t('troubleshooting.vigembus_force_reinstall_button', { version: vigembus.packaged_version }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('troubleshooting.vigembus_install_button', { version: vigembus.packaged_version }) }}
|
||||
</template>
|
||||
<download :size="18" class="icon" v-if="!(vigembus.installed && vigembus.version === vigembus.packaged_version)"></download>
|
||||
<refresh-cw :size="18" class="icon" v-else></refresh-cw>
|
||||
{{ 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 }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Force Close App -->
|
||||
<div class="card p-2 my-4">
|
||||
<div class="card my-4">
|
||||
<div class="card-body">
|
||||
<h2 id="close_apps">{{ $t('troubleshooting.force_close') }}</h2>
|
||||
<br>
|
||||
<p>{{ $t('troubleshooting.force_close_desc') }}</p>
|
||||
<div class="alert alert-success" v-if="closeAppStatus === true">
|
||||
<check-circle :size="18" class="icon"></check-circle>
|
||||
{{ $t('troubleshooting.force_close_success') }}
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="closeAppStatus === false">
|
||||
<alert-circle :size="18" class="icon"></alert-circle>
|
||||
{{ $t('troubleshooting.force_close_error') }}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-warning" :disabled="closeAppPressed" @click="closeApp">
|
||||
<x-circle :size="18" class="icon"></x-circle>
|
||||
{{ $t('troubleshooting.force_close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Restart Sunshine -->
|
||||
<div class="card p-2 my-4">
|
||||
<div class="card my-4">
|
||||
<div class="card-body">
|
||||
<h2 id="restart">{{ $t('troubleshooting.restart_sunshine') }}</h2>
|
||||
<br>
|
||||
<p>{{ $t('troubleshooting.restart_sunshine_desc') }}</p>
|
||||
<div class="alert alert-success" v-if="restartPressed === true">
|
||||
<check-circle :size="18" class="icon"></check-circle>
|
||||
{{ $t('troubleshooting.restart_sunshine_success') }}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-warning" :disabled="restartPressed" @click="restart">
|
||||
<refresh-cw :size="18" class="icon"></refresh-cw>
|
||||
{{ $t('troubleshooting.restart_sunshine') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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">
|
||||
<h2 id="dd_reset">{{ $t('troubleshooting.dd_reset') }}</h2>
|
||||
<br>
|
||||
<p style="white-space: pre-line">{{ $t('troubleshooting.dd_reset_desc') }}</p>
|
||||
<div class="alert alert-success" v-if="ddResetStatus === true">
|
||||
<check-circle :size="18" class="icon"></check-circle>
|
||||
{{ $t('troubleshooting.dd_reset_success') }}
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="ddResetStatus === false">
|
||||
<alert-circle :size="18" class="icon"></alert-circle>
|
||||
{{ $t('troubleshooting.dd_reset_error') }}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-warning" :disabled="ddResetPressed" @click="ddResetPersistence">
|
||||
<rotate-ccw :size="18" class="icon"></rotate-ccw>
|
||||
{{ $t('troubleshooting.dd_reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -136,50 +111,77 @@
|
|||
<!-- Unpair Clients -->
|
||||
<div class="card my-4">
|
||||
<div class="card-body">
|
||||
<div class="p-2">
|
||||
<div class="d-flex justify-content-end align-items-center">
|
||||
<h2 id="unpair" class="text-center me-auto">{{ $t('troubleshooting.unpair_title') }}</h2>
|
||||
<button class="btn btn-danger" :disabled="unpairAllPressed" @click="unpairAll">
|
||||
{{ $t('troubleshooting.unpair_all') }}
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
<p class="mb-0">{{ $t('troubleshooting.unpair_desc') }}</p>
|
||||
<div id="apply-alert" class="alert alert-success d-flex align-items-center mt-3" :style="{ 'display': (showApplyMessage ? 'flex !important': 'none !important') }">
|
||||
<div class="me-2"><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>
|
||||
</div>
|
||||
<div class="alert alert-success mt-3" v-if="unpairAllStatus === true">
|
||||
{{ $t('troubleshooting.unpair_all_success') }}
|
||||
</div>
|
||||
<div class="alert alert-danger mt-3" v-if="unpairAllStatus === false">
|
||||
{{ $t('troubleshooting.unpair_all_error') }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 id="unpair" class="mb-0">{{ $t('troubleshooting.unpair_title') }}</h2>
|
||||
<button class="btn btn-danger" :disabled="unpairAllPressed" @click="unpairAll">
|
||||
<trash-2 :size="18" class="icon"></trash-2>
|
||||
{{ $t('troubleshooting.unpair_all') }}
|
||||
</button>
|
||||
</div>
|
||||
<p>{{ $t('troubleshooting.unpair_desc') }}</p>
|
||||
<div class="alert alert-success d-flex align-items-center" v-if="showApplyMessage">
|
||||
<check-circle :size="18" class="icon"></check-circle>
|
||||
<div><b>{{ $t('_common.success') }}</b> {{ $t('troubleshooting.unpair_single_success') }}</div>
|
||||
<button class="btn btn-success ms-auto" @click="clickedApplyBanner">{{ $t('_common.dismiss') }}</button>
|
||||
</div>
|
||||
<div class="alert alert-success" v-if="unpairAllStatus === true">
|
||||
<check-circle :size="18" class="icon"></check-circle>
|
||||
{{ $t('troubleshooting.unpair_all_success') }}
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="unpairAllStatus === false">
|
||||
<alert-circle :size="18" class="icon"></alert-circle>
|
||||
{{ $t('troubleshooting.unpair_all_error') }}
|
||||
</div>
|
||||
</div>
|
||||
<ul id="client-list" class="list-group list-group-flush list-group-item-light" v-if="clients && clients.length > 0">
|
||||
<div v-for="client in clients" class="list-group-item d-flex">
|
||||
<div class="p-2 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>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" v-if="clients && clients.length > 0">
|
||||
<li v-for="client in clients" :key="client.uuid" class="list-group-item d-flex align-items-center">
|
||||
<div class="flex-grow-1">{{ client.name !== "" ? client.name : $t('troubleshooting.unpair_single_unknown') }}</div>
|
||||
<button class="btn btn-danger ms-auto" @click="unpairSingle(client.uuid)">
|
||||
<trash-2 :size="18" class="icon"></trash-2>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else class="list-group list-group-flush list-group-item-light">
|
||||
<div class="list-group-item p-3 text-center"><em>{{ $t('troubleshooting.unpair_single_no_devices') }}</em></div>
|
||||
<ul v-else class="list-group list-group-flush">
|
||||
<li class="list-group-item p-3 text-center">
|
||||
<em>{{ $t('troubleshooting.unpair_single_no_devices') }}</em>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<!-- Logs -->
|
||||
<div class="card p-2 my-4">
|
||||
<div class="card my-4">
|
||||
<div class="card-body">
|
||||
<h2 id="logs">{{ $t('troubleshooting.logs') }}</h2>
|
||||
<br>
|
||||
<div class="d-flex justify-content-between align-items-baseline py-2">
|
||||
<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 class="troubleshooting-logs">
|
||||
<button class="copy-icon"><i class="fas fa-copy " @click="copyLogs"></i></button>{{actualLogs}}
|
||||
<div class="troubleshooting-logs" ref="logsContainer">
|
||||
<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>
|
||||
|
|
@ -190,10 +192,40 @@
|
|||
import { createApp } from 'vue'
|
||||
import { initApp } from './init'
|
||||
import Navbar from './Navbar.vue'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const app = createApp({
|
||||
components: {
|
||||
Navbar
|
||||
Navbar,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
Copy,
|
||||
Download,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
XCircle,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -220,14 +252,126 @@
|
|||
vigemBusInstallPressed: false,
|
||||
vigemBusInstallStatus: null,
|
||||
vigemBusInstallError: null,
|
||||
currentLogIndex: -1,
|
||||
logLines: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
actualLogs() {
|
||||
if (!this.logFilter) return this.logs;
|
||||
let lines = this.logs.split("\n");
|
||||
lines = lines.filter(x => x.indexOf(this.logFilter) !== -1);
|
||||
return lines.join("\n");
|
||||
const filterLower = this.logFilter.toLowerCase();
|
||||
return this.logs
|
||||
.split("\n")
|
||||
.filter((x) => x.toLowerCase().includes(filterLower))
|
||||
.join("\n");
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse the (possibly multi-line) log output into timestamp-prefixed entries.
|
||||
* Each entry starts with: [YYYY-MM-DD HH:MM:SS.mmm]:
|
||||
*/
|
||||
parsedLogEntries() {
|
||||
const text = this.actualLogs || '';
|
||||
|
||||
// Match on timestamp tokens, but keep everything between them as the entry body.
|
||||
// Using a global exec loop lets us split without losing delimiters and works
|
||||
// even when entries span multiple lines.
|
||||
const tsRegex = /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]:/g;
|
||||
|
||||
const entries = [];
|
||||
const matches = Array.from(text.matchAll(tsRegex));
|
||||
|
||||
// If no timestamps are found, treat everything as a single entry.
|
||||
if (matches.length === 0) {
|
||||
const raw = text.trimEnd();
|
||||
if (!raw) return [];
|
||||
return [
|
||||
{
|
||||
index: 0,
|
||||
raw,
|
||||
level: 'Info',
|
||||
cssClass: 'log-line-info',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const start = matches[i].index;
|
||||
const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
|
||||
const raw = text.slice(start, end).trimEnd();
|
||||
if (!raw) continue;
|
||||
|
||||
// Determine level based on the *first* level token in the entry.
|
||||
// Sunshine logs are typically: "[ts]: Level: message".
|
||||
// Some messages may contain additional embedded timestamps, but we treat
|
||||
// those as part of the entry content.
|
||||
let level = 'Info';
|
||||
let cssClass = 'log-line-info';
|
||||
|
||||
if (/\]:\s*Fatal:/i.test(raw)) {
|
||||
level = 'Fatal';
|
||||
cssClass = 'log-line-fatal';
|
||||
} else if (/\]:\s*(Error|Critical):/i.test(raw)) {
|
||||
level = 'Error';
|
||||
cssClass = 'log-line-error';
|
||||
} else if (/\]:\s*Warning:/i.test(raw)) {
|
||||
level = 'Warning';
|
||||
cssClass = 'log-line-warning';
|
||||
} else if (/\]:\s*Debug:/i.test(raw)) {
|
||||
level = 'Debug';
|
||||
cssClass = 'log-line-debug';
|
||||
}
|
||||
|
||||
entries.push({
|
||||
index: entries.length,
|
||||
raw,
|
||||
level,
|
||||
cssClass,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
},
|
||||
|
||||
highlightedLogs() {
|
||||
const escapeHtml = (s) =>
|
||||
s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
return this.parsedLogEntries
|
||||
.map((entry) => {
|
||||
const safe = escapeHtml(entry.raw);
|
||||
const isSelected = entry.index === this.currentLogIndex;
|
||||
const selectedClass = isSelected ? ' log-entry-selected' : '';
|
||||
return `<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() {
|
||||
|
|
@ -323,6 +467,7 @@
|
|||
this.showApplyMessage = false;
|
||||
},
|
||||
copyLogs() {
|
||||
// Copy the filtered view if a filter is active.
|
||||
navigator.clipboard.writeText(this.actualLogs);
|
||||
},
|
||||
restart() {
|
||||
|
|
@ -403,6 +548,62 @@
|
|||
}, 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' });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export default defineConfig({
|
|||
input: {
|
||||
apps: resolve(assetsSrcPath, 'apps.html'),
|
||||
config: resolve(assetsSrcPath, 'config.html'),
|
||||
featured: resolve(assetsSrcPath, 'featured.html'),
|
||||
index: resolve(assetsSrcPath, 'index.html'),
|
||||
password: resolve(assetsSrcPath, 'password.html'),
|
||||
pin: resolve(assetsSrcPath, 'pin.html'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue