feat(web-ui): Updated Add / Edit app form to use modals + new Delete App UI flow (#5166)

This commit is contained in:
Noklef
2026-05-30 22:38:52 +10:00
committed by GitHub
parent a682ab07dc
commit 3d9e6d24a8
3 changed files with 473 additions and 380 deletions
+469 -379
View File
@@ -14,28 +14,38 @@
</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>
<div class="apps-toolbar d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<!-- Left side actions -->
<div class="d-flex align-items-center gap-2">
<!-- Add new application -->
<button class="btn btn-primary" @click="newApp">
<layers-plus :size="18" class="icon"></layers-plus>
{{ $t('apps.add_new') }}
</button>
</div>
<!-- Right side actions -->
<div class="d-flex align-items-center gap-2" v-if="apps && apps.length > 0">
<!-- Sort by name toggle -->
<button class="btn btn-outline-secondary text-nowrap" 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">
<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>
<span v-else class="input-group-text">
<search :size="16" class="icon"></search>
</span>
</div>
</div>
</div>
<!-- Apps Grid -->
@@ -71,7 +81,7 @@
<edit :size="16" class="icon"></edit>
{{ $t('apps.edit') }}
</button>
<button class="btn btn-sm btn-danger" @click="showDeleteForm(index)">
<button class="btn btn-sm btn-danger" @click="showDeleteModal(index)">
<trash-2 :size="16" class="icon"></trash-2>
</button>
</div>
@@ -94,349 +104,383 @@
</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>
<input type="text" class="form-control" id="appName" aria-describedby="appNameHelp" v-model="editForm.name" />
<div id="appNameHelp" class="form-text">{{ $t('apps.app_name_desc') }}</div>
</div>
<!-- output -->
<div class="mb-3">
<label for="appOutput" class="form-label">{{ $t('apps.output_name') }}</label>
<div class="input-group">
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
v-model="editForm.output" />
<button class="btn btn-secondary" type="button"
@click="browseFor('any', 'file_browser.select_file', editForm.output, v => editForm.output = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
<!-- Edit / Add Application modal -->
<div class="modal fade" ref="editModal" tabindex="-1" aria-labelledby="appEditModalLabel"
aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-xl modal-dialog-scrollable modal-fullscreen-lg-down">
<div class="modal-content" v-if="editForm">
<div class="modal-header">
<h5 class="modal-title" id="appEditModalLabel">
{{ editModalTitle }}
</h5>
<button type="button" class="btn-close" @click="closeEditModal"
:aria-label="$t('_common.close')"></button>
</div>
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</div>
</div>
<!-- prep-cmd -->
<Checkbox class="mb-3"
id="excludeGlobalPrep"
label="apps.global_prep_name"
desc="apps.global_prep_desc"
v-model="editForm['exclude-global-prep-cmd']"
default="true"
inverse-values
></Checkbox>
<div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.cmd_prep_name') }}</label>
<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">
<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">
<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'">
<shield :size="18" class="icon"></shield>
{{ $t('_common.run_as') }}
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in editForm['prep-cmd']">
<td>
<div class="input-group">
<input type="text" class="form-control monospace" v-model="c.do" />
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'do')">
<folder-open :size="14" class="icon"></folder-open>
</button>
</div>
</td>
<td>
<div class="input-group">
<input type="text" class="form-control monospace" v-model="c.undo" />
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'undo')">
<folder-open :size="14" class="icon"></folder-open>
</button>
</div>
</td>
<td v-if="platform === 'windows'" class="align-middle">
<Checkbox :id="'prep-cmd-admin-' + i"
label="_common.elevated"
desc=""
v-model="c.elevated"
></Checkbox>
</td>
<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 btn-sm ms-2" @click="addPrepCmd">
<plus :size="16" class="icon"></plus>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 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 align-items-center my-2">
<input type="text" v-model="editForm.detached[i]" class="form-control monospace">
<button class="btn btn-secondary btn-sm ms-2" @click="browseDetached(i)">
<folder-open :size="14" class="icon"></folder-open>
</button>
<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-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">
{{ $t('apps.detached_cmds_desc') }}<br>
<b>{{ $t('_common.note') }}</b> {{ $t('apps.detached_cmds_note') }}
</div>
</div>
<!-- command -->
<div class="mb-3">
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
<div class="input-group">
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
v-model="editForm.cmd" />
<button class="btn btn-secondary" type="button"
@click="browseFor('executable', 'file_browser.select_executable', editForm.cmd, v => editForm.cmd = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appCmdHelp" class="form-text">
{{ $t('apps.cmd_desc') }}<br>
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
</div>
</div>
<!-- working dir -->
<div class="mb-3">
<label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label>
<div class="input-group">
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
v-model="editForm['working-dir']" />
<button class="btn btn-secondary" type="button"
@click="browseFor('directory', 'file_browser.select_directory', editForm['working-dir'], v => editForm['working-dir'] = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appWorkingDirHelp" class="form-text">{{ $t('apps.working_dir_desc') }}</div>
</div>
<!-- elevation -->
<Checkbox v-if="platform === 'windows'"
class="mb-3"
id="appElevation"
label="_common.run_as"
desc="apps.run_as_desc"
v-model="editForm.elevated"
default="false"
></Checkbox>
<!-- auto-detach -->
<Checkbox class="mb-3"
id="autoDetach"
label="apps.auto_detach"
desc="apps.auto_detach_desc"
v-model="editForm['auto-detach']"
default="true"
></Checkbox>
<!-- wait for all processes -->
<Checkbox class="mb-3"
id="waitAll"
label="apps.wait_all"
desc="apps.wait_all_desc"
v-model="editForm['wait-all']"
default="true"
></Checkbox>
<!-- exit timeout -->
<div class="mb-3">
<label for="exitTimeout" class="form-label">{{ $t('apps.exit_timeout') }}</label>
<input type="number" class="form-control monospace" id="exitTimeout" aria-describedby="exitTimeoutHelp"
v-model="editForm['exit-timeout']" min="0" placeholder="5" />
<div id="exitTimeoutHelp" class="form-text">{{ $t('apps.exit_timeout_desc') }}</div>
</div>
<div class="mb-3">
<label for="appImagePath" class="form-label">{{ $t('apps.image') }}</label>
<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" type="button"
@click="browseFor('file', 'file_browser.select_file', editForm['image-path'], v => editForm['image-path'] = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
<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>
<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">
<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 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>
</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') }}
<div class="modal-body">
<!-- Application Name -->
<div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.app_name') }}</label>
<input type="text" class="form-control" id="appName" aria-describedby="appNameHelp" v-model="editForm.name" />
<div id="appNameHelp" class="form-text">{{ $t('apps.app_name_desc') }}</div>
</div>
<!-- output -->
<div class="mb-3">
<label for="appOutput" class="form-label">{{ $t('apps.output_name') }}</label>
<div class="input-group">
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
v-model="editForm.output" />
<button class="btn btn-secondary" type="button"
@click="browseFor('any', 'file_browser.select_file', editForm.output, v => editForm.output = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_desc') }}</div>
</div>
<!-- prep-cmd -->
<Checkbox class="mb-3"
id="excludeGlobalPrep"
label="apps.global_prep_name"
desc="apps.global_prep_desc"
v-model="editForm['exclude-global-prep-cmd']"
default="true"
inverse-values
></Checkbox>
<div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.cmd_prep_name') }}</label>
<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">
<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">
<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'">
<shield :size="18" class="icon"></shield>
{{ $t('_common.run_as') }}
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in editForm['prep-cmd']">
<td>
<div class="input-group">
<input type="text" class="form-control monospace" v-model="c.do" />
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'do')">
<folder-open :size="14" class="icon"></folder-open>
</button>
</div>
</td>
<td>
<div class="input-group">
<input type="text" class="form-control monospace" v-model="c.undo" />
<button class="btn btn-secondary btn-sm" type="button" @click="browsePrep(i, 'undo')">
<folder-open :size="14" class="icon"></folder-open>
</button>
</div>
</td>
<td v-if="platform === 'windows'" class="align-middle">
<Checkbox :id="'prep-cmd-admin-' + i"
label="_common.elevated"
desc=""
v-model="c.elevated"
></Checkbox>
</td>
<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 btn-sm ms-2" @click="addPrepCmd">
<plus :size="16" class="icon"></plus>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 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 align-items-center my-2">
<input type="text" v-model="editForm.detached[i]" class="form-control monospace">
<button class="btn btn-secondary btn-sm ms-2" @click="browseDetached(i)">
<folder-open :size="14" class="icon"></folder-open>
</button>
<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-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">
{{ $t('apps.detached_cmds_desc') }}<br>
<b>{{ $t('_common.note') }}</b> {{ $t('apps.detached_cmds_note') }}
</div>
</div>
<!-- command -->
<div class="mb-3">
<label for="appCmd" class="form-label">{{ $t('apps.cmd') }}</label>
<div class="input-group">
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
v-model="editForm.cmd" />
<button class="btn btn-secondary" type="button"
@click="browseFor('executable', 'file_browser.select_executable', editForm.cmd, v => editForm.cmd = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appCmdHelp" class="form-text">
{{ $t('apps.cmd_desc') }}<br>
<b>{{ $t('_common.note') }}</b> {{ $t('apps.cmd_note') }}
</div>
</div>
<!-- working dir -->
<div class="mb-3">
<label for="appWorkingDir" class="form-label">{{ $t('apps.working_dir') }}</label>
<div class="input-group">
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
v-model="editForm['working-dir']" />
<button class="btn btn-secondary" type="button"
@click="browseFor('directory', 'file_browser.select_directory', editForm['working-dir'], v => editForm['working-dir'] = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
</div>
<div id="appWorkingDirHelp" class="form-text">{{ $t('apps.working_dir_desc') }}</div>
</div>
<!-- elevation -->
<Checkbox v-if="platform === 'windows'"
class="mb-3"
id="appElevation"
label="_common.run_as"
desc="apps.run_as_desc"
v-model="editForm.elevated"
default="false"
></Checkbox>
<!-- auto-detach -->
<Checkbox class="mb-3"
id="autoDetach"
label="apps.auto_detach"
desc="apps.auto_detach_desc"
v-model="editForm['auto-detach']"
default="true"
></Checkbox>
<!-- wait for all processes -->
<Checkbox class="mb-3"
id="waitAll"
label="apps.wait_all"
desc="apps.wait_all_desc"
v-model="editForm['wait-all']"
default="true"
></Checkbox>
<!-- exit timeout -->
<div class="mb-3">
<label for="exitTimeout" class="form-label">{{ $t('apps.exit_timeout') }}</label>
<input type="number" class="form-control monospace" id="exitTimeout" aria-describedby="exitTimeoutHelp"
v-model="editForm['exit-timeout']" min="0" placeholder="5" />
<div id="exitTimeoutHelp" class="form-text">{{ $t('apps.exit_timeout_desc') }}</div>
</div>
<div class="mb-3">
<label for="appImagePath" class="form-label">{{ $t('apps.image') }}</label>
<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" type="button"
@click="browseFor('file', 'file_browser.select_file', editForm['image-path'], v => editForm['image-path'] = v)">
<folder-open :size="18" class="icon"></folder-open>
</button>
<button class="btn btn-secondary" type="button" @click="showCoverFinder">
<search :size="18" class="icon"></search>
{{ $t('apps.find_cover') }}
</button>
</div>
<div id="appImagePathHelp" class="form-text">{{ $t('apps.image_desc') }}</div>
</div>
<div class="env-hint alert alert-info">
<div class="form-text">
<h4>{{ $t('apps.env_vars_about') }}</h4>
{{ $t('apps.env_vars_desc') }}
</div>
<table class="env-table">
<tr>
<td><b>{{ $t('apps.env_var_name') }}</b></td>
<td><b></b></td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_APP_ID</td>
<td>{{ $t('apps.env_app_id') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_APP_NAME</td>
<td>{{ $t('apps.env_app_name') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_WIDTH</td>
<td>{{ $t('apps.env_client_width') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_HEIGHT</td>
<td>{{ $t('apps.env_client_height') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_FPS</td>
<td>{{ $t('apps.env_client_fps') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_HDR</td>
<td>{{ $t('apps.env_client_hdr') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_GCMAP</td>
<td>{{ $t('apps.env_client_gcmap') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_HOST_AUDIO</td>
<td>{{ $t('apps.env_client_host_audio') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_ENABLE_SOPS</td>
<td>{{ $t('apps.env_client_enable_sops') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_AUDIO_CONFIGURATION</td>
<td>{{ $t('apps.env_client_audio_config') }}</td>
</tr>
</table>
<div class="form-text" v-if="platform === 'windows'"><b>{{ $t('apps.env_qres_example') }}</b>
<pre>cmd /C &lt;{{ $t('apps.env_qres_path') }}&gt;\QRes.exe /X:%SUNSHINE_CLIENT_WIDTH% /Y:%SUNSHINE_CLIENT_HEIGHT% /R:%SUNSHINE_CLIENT_FPS%</pre>
</div>
<div class="form-text" v-else-if="platform === 'freebsd' || platform === 'linux'"><b>{{ $t('apps.env_xrandr_example') }}</b>
<pre>sh -c "xrandr --output HDMI-1 --mode \"${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}\" --rate ${SUNSHINE_CLIENT_FPS}"</pre>
</div>
<div class="form-text" v-else-if="platform === 'macos'"><b>{{ $t('apps.env_displayplacer_example') }}</b>
<pre>sh -c "displayplacer "id:&lt;screenId&gt; res:${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT} hz:${SUNSHINE_CLIENT_FPS} scaling:on origin:(0,0) degree:0""</pre>
</div>
<div class="form-text"><a
:href="`${documentationBaseUrl}/md_docs_2app__examples.html`"
target="_blank">{{ $t('_common.see_more') }}</a></div>
</div>
</div>
</div>
<div class="env-hint alert alert-info">
<div class="form-text">
<h4>{{ $t('apps.env_vars_about') }}</h4>
{{ $t('apps.env_vars_desc') }}
<!-- Save buttons -->
<div class="modal-footer">
<button @click="closeEditModal" class="btn btn-secondary">
<x :size="18" class="icon"></x>
{{ $t('_common.cancel') }}
</button>
<button class="btn btn-primary" @click="save">
<save :size="18" class="icon"></save>
{{ $t('_common.save') }}
</button>
</div>
<table class="env-table">
<tr>
<td><b>{{ $t('apps.env_var_name') }}</b></td>
<td><b></b></td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_APP_ID</td>
<td>{{ $t('apps.env_app_id') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_APP_NAME</td>
<td>{{ $t('apps.env_app_name') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_WIDTH</td>
<td>{{ $t('apps.env_client_width') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_HEIGHT</td>
<td>{{ $t('apps.env_client_height') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_FPS</td>
<td>{{ $t('apps.env_client_fps') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_HDR</td>
<td>{{ $t('apps.env_client_hdr') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_GCMAP</td>
<td>{{ $t('apps.env_client_gcmap') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_HOST_AUDIO</td>
<td>{{ $t('apps.env_client_host_audio') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_ENABLE_SOPS</td>
<td>{{ $t('apps.env_client_enable_sops') }}</td>
</tr>
<tr>
<td style="font-family: monospace">SUNSHINE_CLIENT_AUDIO_CONFIGURATION</td>
<td>{{ $t('apps.env_client_audio_config') }}</td>
</tr>
</table>
<div class="form-text" v-if="platform === 'windows'"><b>{{ $t('apps.env_qres_example') }}</b>
<pre>cmd /C &lt;{{ $t('apps.env_qres_path') }}&gt;\QRes.exe /X:%SUNSHINE_CLIENT_WIDTH% /Y:%SUNSHINE_CLIENT_HEIGHT% /R:%SUNSHINE_CLIENT_FPS%</pre>
</div>
<div class="form-text" v-else-if="platform === 'freebsd' || platform === 'linux'"><b>{{ $t('apps.env_xrandr_example') }}</b>
<pre>sh -c "xrandr --output HDMI-1 --mode \"${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT}\" --rate ${SUNSHINE_CLIENT_FPS}"</pre>
</div>
<div class="form-text" v-else-if="platform === 'macos'"><b>{{ $t('apps.env_displayplacer_example') }}</b>
<pre>sh -c "displayplacer "id:&lt;screenId&gt; res:${SUNSHINE_CLIENT_WIDTH}x${SUNSHINE_CLIENT_HEIGHT} hz:${SUNSHINE_CLIENT_FPS} scaling:on origin:(0,0) degree:0""</pre>
</div>
<div class="form-text"><a
:href="`${documentationBaseUrl}/md_docs_2app__examples.html`"
target="_blank">{{ $t('_common.see_more') }}</a></div>
</div>
<!-- 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">
<save :size="18" class="icon"></save>
{{ $t('_common.save') }}
</button>
</div>
</div>
</div>
<div class="mt-4" v-else>
<button class="btn btn-primary" @click="newApp">
<layers-plus :size="18" class="icon"></layers-plus>
{{ $t('apps.add_new') }}
</button>
<!-- Cover Finder modal -->
<div class="modal fade" id="coverFinderModal" tabindex="-1" aria-labelledby="coverFinderModalLabel" aria-hidden="true" ref="coverFinderModal">
<div class="modal-dialog modal-xl modal-dialog-scrollable modal-fullscreen-md-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="coverFinderModalLabel">
<span v-if="coverSearching">{{ $t('apps.searching_covers') }}</span>
<span v-else-if="coverCandidates.length > 0">{{ $t('apps.covers_found') }} ({{ coverCandidates.length }})</span>
<span v-else>{{ $t('apps.no_covers_found') }}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('_common.close')"></button>
</div>
<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 v-for="cover in coverCandidates" :key="cover.url" 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>
</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>
<!-- Delete confirmation modal -->
<div class="modal fade" ref="deleteModal" tabindex="-1" aria-labelledby="appDeleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content" v-if="deleteTarget">
<div class="modal-header">
<h5 class="modal-title" id="appDeleteModalLabel">{{ $t('apps.delete_title') }}</h5>
<button type="button" class="btn-close" @click="closeDeleteModal"
:aria-label="$t('_common.close')"></button>
</div>
<div class="modal-body">
<i18n-t keypath="apps.delete_confirm" tag="span">
<template #name><strong>{{ deleteTarget.name }}</strong></template>
</i18n-t>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeDeleteModal">
<x :size="18" class="icon"></x>
{{ $t('_common.cancel') }}
</button>
<button type="button" class="btn btn-danger" @click="confirmDelete">
<trash-2 :size="18" class="icon"></trash-2>
{{ $t('apps.delete') }}
</button>
</div>
</div>
</div>
</div>
<!-- Shared file browser modal -->
@@ -569,7 +613,6 @@
data() {
return {
apps: [],
showEditForm: false,
editForm: null,
detachedCmd: "",
coverSearching: false,
@@ -589,6 +632,7 @@
fileBrowserTypedPath: "",
searchQuery: "",
sortMode: "default",
deleteTarget: null,
version: null,
githubVersion: null,
};
@@ -646,14 +690,21 @@
? `${total}`
: `${shown} / ${total}`;
},
editModalTitle() {
if (!this.editForm) {
return "";
}
const action = this.editForm.index === -1
? this.$t("apps.add_new")
: this.$t("apps.edit");
return this.editForm.name
? `${action}: ${this.editForm.name}`
: action;
},
},
created() {
fetch("./api/apps")
.then((r) => r.json())
.then((r) => {
console.log(r);
this.apps = r.apps;
});
this.loadApps();
fetch("./api/config")
.then(r => r.json())
@@ -683,8 +734,7 @@
detached: [],
"image-path": ""
};
this.editForm.index = -1;
this.showEditForm = true;
this.openEditModal();
},
editApp(id) {
this.editForm = JSON.parse(JSON.stringify(this.apps[id]));
@@ -707,22 +757,34 @@
if (this.editForm["exit-timeout"] === undefined) {
this.editForm["exit-timeout"] = 5;
}
this.showEditForm = true;
this.openEditModal();
},
showDeleteForm(id) {
let resp = confirm(
"Are you sure to delete " + this.apps[id].name + "?"
);
if (resp) {
apiFetch("./api/apps/" + id, {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
}).then((r) => {
if (r.status === 200) document.location.reload();
showDeleteModal(id) {
this.deleteTarget = { index: id, name: this.apps[id].name };
this.$nextTick(() => {
Modal.getOrCreateInstance(this.$refs.deleteModal).show();
});
},
closeDeleteModal() {
const modal = Modal.getInstance(this.$refs.deleteModal);
if (modal) modal.hide();
},
loadApps() {
return fetch("./api/apps")
.then((r) => r.json())
.then((r) => {
this.apps = r.apps;
});
}
},
confirmDelete() {
apiFetch("./api/apps/" + this.deleteTarget.index, {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
}).then((r) => {
if (r.status === 200) document.location.reload();
});
},
addPrepCmd() {
let template = {
@@ -747,6 +809,8 @@
this.coverCandidates = [];
this.coverSearchQuery = "";
this.showStacked(this.$refs.coverFinderModal);
// Perform initial search with app name
this.performCoverSearch();
},
@@ -844,7 +908,7 @@
this.fileBrowserTypedPath = startPath || '';
this.fileBrowserError = '';
this.fileBrowserNavigate(startPath || '');
Modal.getOrCreateInstance(this.$refs.fileBrowserModal).show();
this.showStacked(this.$refs.fileBrowserModal);
},
fileBrowserClose() {
const modal = Modal.getInstance(this.$refs.fileBrowserModal);
@@ -944,6 +1008,32 @@
else
this.sortMode = "default";
},
openEditModal() {
// Use $nextTick because if the edit version of the modal is created too early, it breaks...
this.$nextTick(() => {
Modal.getOrCreateInstance(this.$refs.editModal).show();
});
},
closeEditModal() {
const modal = Modal.getInstance(this.$refs.editModal);
if (modal) modal.hide();
},
// We need to update the z-index since bootstrap gives all modals the same z-index
// Fixes the stacking issue of modals (cover finder / file browser)
showStacked(modalEl) {
modalEl.addEventListener('show.bs.modal', () => {
const openCount = document.querySelectorAll('.modal.show').length;
const z = 1055 + (openCount + 1) * 20;
modalEl.style.zIndex = z;
requestAnimationFrame(() => {
const backdrops = document.querySelectorAll('.modal-backdrop');
const backdrop = backdrops[backdrops.length - 1];
if (backdrop) backdrop.style.zIndex = z - 10;
});
}, { once: true });
Modal.getOrCreateInstance(modalEl).show();
},
},
});
@@ -682,6 +682,7 @@ body {
.modal-header,
.modal-footer {
border-color: var(--color-border) !important;
background-color: var(--color-surface-raised) !important;
}
.list-group-item {
@@ -1713,7 +1714,7 @@ p {
}
.apps-toolbar .input-group {
max-width: 18rem;
width: 14rem;
}
.apps-toolbar .btn-outline-secondary {
@@ -50,6 +50,8 @@
"covers_found": "Covers Found",
"cover_search_hint": "Search names should match IGDB naming conventions.",
"delete": "Delete",
"delete_confirm": "Are you sure you want to delete {name}?",
"delete_title": "Delete Application",
"detached_cmds": "Detached Commands",
"detached_cmds_add": "Add Detached Command",
"detached_cmds_desc": "A list of commands to be run in the background.",