feat(web-ui): Added Filtering & Searching to the Apps page (#5158)

Co-authored-by: Noklef <281545466+Noklef@users.noreply.github.com>
This commit is contained in:
Noklef
2026-05-22 03:16:01 +10:00
committed by GitHub
parent 3a69acefa0
commit 3a72015161
3 changed files with 124 additions and 7 deletions

View File

@@ -9,18 +9,43 @@
<Navbar></Navbar>
<div class="container">
<div class="my-4">
<h1>{{ $t('apps.applications_title') }}</h1>
<h1>{{ $t('apps.applications_title') }}<span v-if="apps.length"> ({{ appCountLabel }})</span></h1>
<p>{{ $t('apps.applications_desc') }}</p>
</div>
<!-- Actions toolbar -->
<div class="apps-toolbar d-flex flex-wrap justify-content-end align-items-center gap-2 mb-3"
v-if="apps && apps.length > 0">
<!-- Sort by name toggle -->
<button class="btn btn-outline-secondary" type="button" @click="toggleSort"
:class="{ active: sortMode !== 'default' }"
:aria-pressed="sortMode !== 'default'"
:title="$t('apps.sort_by_name') + ': ' + sortModeLabel">
<arrow-up-down v-if="sortMode === 'default'" :size="16" class="icon me-1"></arrow-up-down>
<arrow-up v-else-if="sortMode === 'asc'" :size="16" class="icon me-1"></arrow-up>
<arrow-down v-else :size="16" class="icon me-1"></arrow-down>
{{ $t('apps.sort_by_name') }}
</button>
<!-- Search box -->
<div class="input-group">
<span class="input-group-text">
<search :size="16" class="icon"></search>
</span>
<input type="text" class="form-control" v-model="searchQuery" :placeholder="$t('apps.search_placeholder')" />
<button v-if="searchQuery" class="btn btn-outline-secondary" type="button" @click="resetSearchQuery" :aria-label="$t('_common.close')">
<x :size="16" class="icon"></x>
</button>
</div>
</div>
<!-- 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="row g-3" v-if="displayedApps.length > 0">
<div class="col-12 col-sm-6 col-md-4 col-lg-3" v-for="{ app, index } in displayedApps" :key="index">
<div class="card app-card h-100">
<div class="app-poster-container">
<img
v-if="app['image-path']"
:src="'/api/covers/' + i"
:src="'/api/covers/' + index"
class="app-poster"
:alt="app.name"
@error="handleImageError"
@@ -42,11 +67,11 @@
</div>
</div>
<div class="mt-auto d-flex gap-2">
<button class="btn btn-sm btn-primary flex-fill" @click="editApp(i)">
<button class="btn btn-sm btn-primary flex-fill" @click="editApp(index)">
<edit :size="16" class="icon"></edit>
{{ $t('apps.edit') }}
</button>
<button class="btn btn-sm btn-danger" @click="showDeleteForm(i)">
<button class="btn btn-sm btn-danger" @click="showDeleteForm(index)">
<trash-2 :size="16" class="icon"></trash-2>
</button>
</div>
@@ -55,6 +80,13 @@
</div>
</div>
<!-- No search results -->
<div v-else-if="apps && apps.length > 0" class="card">
<div class="card-body text-center py-5">
<p class="text-muted">{{ $t('apps.no_search_results') }}</p>
</div>
</div>
<!-- Empty State -->
<div v-else class="card">
<div class="card-body text-center py-5">
@@ -484,7 +516,10 @@
import { apiFetch } from './fetch_utils'
import { Modal } from 'bootstrap/dist/js/bootstrap'
import {
ArrowDown,
ArrowRight,
ArrowUp,
ArrowUpDown,
Check,
Edit,
FileText,
@@ -508,7 +543,10 @@
components: {
Navbar,
Checkbox,
ArrowDown,
ArrowRight,
ArrowUp,
ArrowUpDown,
Check,
Edit,
FileText,
@@ -548,8 +586,54 @@
fileBrowserError: "",
fileBrowserSelectedPath: "",
fileBrowserTypedPath: "",
searchQuery: "",
sortMode: "default",
};
},
computed: {
displayedApps() {
let list = this.apps.map((app, index) => ({ app, index }));
const query = this.searchQuery.trim().toLowerCase();
if (query) {
list = list.filter(({ app }) =>
(app.name || "").toLowerCase().includes(query)
);
}
if (this.sortMode !== "default") {
// Apps can be created without a name, so we compare name || ""
list.sort((a, b) => {
const result = (a.app.name || "").localeCompare(
b.app.name || "", undefined, { sensitivity: "base" }
);
// localeCompare returns 0 if the strings are equal, a negative value if a < b, and a positive value if a > b
return this.sortMode === "asc"
? result
: -result;
});
}
return list;
},
sortModeLabel() {
switch (this.sortMode) {
case "asc":
return this.$t("apps.sort_ascending");
case "desc":
return this.$t("apps.sort_descending");
default:
return this.$t("apps.sort_default");
}
},
appCountLabel() {
const total = this.apps.length;
const shown = this.displayedApps.length;
return shown === total
? `${total}`
: `${shown} / ${total}`;
},
},
created() {
fetch("./api/apps")
.then((r) => r.json())
@@ -826,7 +910,19 @@
if (placeholder && placeholder.classList.contains('app-poster-placeholder')) {
placeholder.style.display = 'flex';
}
}
},
resetSearchQuery() {
this.searchQuery = "";
},
toggleSort() {
// Sorting goes default -> ascending -> descending -> default
if (this.sortMode === "default")
this.sortMode = "asc";
else if (this.sortMode === "asc")
this.sortMode = "desc";
else
this.sortMode = "default";
},
},
});

View File

@@ -1712,6 +1712,20 @@ p {
align-items: center;
}
.apps-toolbar .input-group {
max-width: 18rem;
}
.apps-toolbar .btn-outline-secondary {
border: 1px solid var(--color-border);
}
.apps-toolbar .btn-outline-secondary.active {
background-color: var(--color-bg-muted);
border-color: var(--color-border-strong);
color: var(--color-text-base);
}
/* ==========================================================================
Featured Apps Page Styles
========================================================================== */

View File

@@ -81,11 +81,18 @@
"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_applications": "No applications have been added yet",
"no_covers_found": "No covers found",
"no_search_results": "No applications match your search",
"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.",
"search_placeholder": "Search applications...",
"searching_covers": "Searching for covers...",
"sort_ascending": "Ascending",
"sort_by_name": "Sort by Name",
"sort_default": "Default order",
"sort_descending": "Descending",
"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",