Files
Apollo/src_assets/common/assets/web/apps.html
2025-05-28 22:07:54 +08:00

874 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-bs-theme="auto">
<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;
}
.dragover {
border-top: 2px solid #ffc400;
}
</style>
</head>
<body id="app" v-cloak>
<Navbar></Navbar>
<div class="container">
<div class="my-4">
<h1>{{ $t('apps.applications_title') }}</h1>
<div>{{ $t('apps.applications_desc') }}</div>
<div>{{ $t('apps.applications_reorder_desc') }}</div>
</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="app.uuid"
:class="{dragover: app.dragover}"
draggable="true"
@dragstart="onDragStart($event, i)"
@dragenter="onDragEnter($event, app)"
@dragover="onDragOver($event)"
@dragleave="onDragLeave(app)"
@dragend="onDragEnd()"
@drop="onDrop($event, app, i)"
>
<td>{{app.name || ' '}}</td>
<td v-if="app.uuid">
<button class="btn btn-primary me-2" :disabled="actionDisabled" @click="editApp(app)">
<i class="fas fa-edit"></i> {{ $t('apps.edit') }}
</button>
<button class="btn btn-danger me-2" :disabled="actionDisabled" @click="showDeleteForm(app)">
<i class="fas fa-trash"></i> {{ $t('apps.delete') }}
</button>
<button class="btn btn-warning" :disabled="actionDisabled" @click="closeApp()" v-if="currentApp === app.uuid">
<i class="fas fa-stop"></i> {{ $t('apps.close') }}
</button>
<button class="btn btn-success" :disabled="actionDisabled" @click="launchApp(app)" v-else>
<i class="fas fa-play"></i> {{ $t('apps.launch') }}
</button>
</td>
<td v-else></td>
</tr>
</tbody>
</table>
</div>
<div class="edit-form card mt-2" v-if="showEditForm">
<div class="p-4">
<!-- Application Name -->
<div class="mb-3">
<label for="appName" class="form-label">{{ $t('apps.app_name') }}</label>
<div class="input-group dropup">
<input type="text" class="form-control" id="appName" aria-describedby="appNameHelp" v-model="editForm.name" />
<button class="btn btn-secondary dropdown-toggle" type="button" id="findCoverToggle"
aria-expanded="false" @click="showCoverFinder" ref="coverFinderDropdown">
{{ $t('apps.find_cover') }} (Online)
</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 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>
</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>
<label class="d-block text-nowrap text-center text-truncate">
{{cover.name}}
</label>
</div>
</div>
</div>
</div>
</div>
<div id="appNameHelp" class="form-text">{{ $t('apps.app_name_desc') }}</div>
</div>
<!-- Application Image -->
<div class="mb-3">
<label for="appImagePath" class="form-label">{{ $t('apps.image') }}</label>
<input type="text" class="form-control monospace" id="appImagePath" aria-describedby="appImagePathHelp"
v-model="editForm['image-path']" />
<div id="appImagePathHelp" class="form-text">{{ $t('apps.image_desc') }}</div>
</div>
<!-- gamepad override -->
<div class="mb-3" v-if="platform !== 'macos'">
<label for="gamepad" class="form-label">{{ $t('config.gamepad') }}</label>
<select id="gamepad" class="form-select" v-model="editForm.gamepad">
<option value="">{{ $t('_common.default_global') }}</option>
<option value="disabled">{{ $t('_common.disabled') }}</option>
<option value="auto">{{ $t('_common.auto') }}</option>
<template v-if="platform === 'linux'">
<option value="ds5">{{ $t("config.gamepad_ds5") }}</option>
<option value="switch">{{ $t("config.gamepad_switch") }}</option>
<option value="xone">{{ $t("config.gamepad_xone") }}</option>
</template>
<template v-if="platform === 'windows'">
<option value="ds4">{{ $t('config.gamepad_ds4') }}</option>
<option value="x360">{{ $t('config.gamepad_x360') }}</option>
</template>
</select>
<div class="form-text">{{ $t('config.gamepad_desc') }}</div>
</div>
<!-- allow client commands -->
<Checkbox class="mb-3"
id="clientCommands"
label="apps.allow_client_commands"
desc="apps.allow_client_commands_desc"
v-model="editForm['allow-client-commands']"
default="true"
></Checkbox>
<!-- 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(-1)">
<i class="fas fa-plus mr-1"></i> {{ $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" v-if="platform === 'windows'">
<i class="fas fa-shield-alt"></i> {{ $t('_common.run_as') }}
</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="(c, i) in editForm['prep-cmd']">
<td>
<input type="text" class="form-control monospace" v-model="c.do" />
</td>
<td>
<input type="text" class="form-control monospace" v-model="c.undo" />
</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>
<button class="btn btn-danger" @click="editForm['prep-cmd'].splice(i,1)">
<i class="fas fa-trash"></i>
</button>
<button class="btn btn-success" @click="addPrepCmd(i)">
<i class="fas fa-plus"></i>
</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 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)">
&times;
</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') }}
</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>
<input type="text" class="form-control monospace" id="appCmd" aria-describedby="appCmdHelp"
v-model="editForm.cmd" />
<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>
<input type="text" class="form-control monospace" id="appWorkingDir" aria-describedby="appWorkingDirHelp"
v-model="editForm['working-dir']" />
<div id="appWorkingDirHelp" class="form-text">{{ $t('apps.working_dir_desc') }}</div>
</div>
<!-- output -->
<div class="mb-3">
<label for="appOutput" class="form-label">{{ $t('apps.output_name') }}</label>
<input type="text" class="form-control monospace" id="appOutput" aria-describedby="appOutputHelp"
v-model="editForm.output" />
<div id="appOutputHelp" class="form-text">{{ $t('apps.output_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>
<!-- use virtual display -->
<Checkbox class="mb-3"
id="virtualDisplay"
label="apps.virtual_display"
desc="apps.virtual_display_desc"
v-model="editForm['virtual-display']"
default="false"
></Checkbox>
<!-- use app identity -->
<Checkbox class="mb-3"
id="useAppIdentity"
label="apps.use_app_identity"
desc="apps.use_app_identity_desc"
v-model="editForm['use-app-identity']"
default="false"
></Checkbox>
<!-- per-client app identity -->
<Checkbox class="mb-3"
v-if="editForm['use-app-identity']"
id="perClientAppIdentity"
label="apps.per_client_app_identity"
desc="apps.per_client_app_identity_desc"
v-model="editForm['per-client-app-identity']"
default="false"
></Checkbox>
<!-- resolution scale factor -->
<div class="mb-3" v-if="platform === 'windows'">
<label for="resolutionScaleFactor" class="form-label">{{ $t('apps.resolution_scale_factor') }}: {{editForm['scale-factor']}}%</label>
<input type="range" step="1" min="20" max="200" class="form-range" id="resolutionScaleFactor" v-model="editForm['scale-factor']"/>
<div class="form-text">{{ $t('apps.resolution_scale_factor_desc') }}</div>
</div>
<div class="env-hint alert alert-info overflow-auto">
<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">APOLLO_APP_ID</td>
<td>{{ $t('apps.env_app_id') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_APP_NAME</td>
<td>{{ $t('apps.env_app_name') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_APP_UUID</td>
<td>{{ $t('apps.env_app_uuid') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_UUID</td>
<td>{{ $t('apps.env_client_uuid') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_NAME</td>
<td>{{ $t('apps.env_client_name') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_WIDTH</td>
<td>{{ $t('apps.env_client_width') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_HEIGHT</td>
<td>{{ $t('apps.env_client_height') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_FPS</td>
<td>{{ $t('apps.env_client_fps') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_HDR</td>
<td>{{ $t('apps.env_client_hdr') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_GCMAP</td>
<td>{{ $t('apps.env_client_gcmap') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_HOST_AUDIO</td>
<td>{{ $t('apps.env_client_host_audio') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_ENABLE_SOPS</td>
<td>{{ $t('apps.env_client_enable_sops') }}</td>
</tr>
<tr>
<td style="font-family: monospace">APOLLO_CLIENT_AUDIO_CONFIGURATION</td>
<td>{{ $t('apps.env_client_audio_config') }}</td>
</tr>
</table>
<template v-if="platform === 'windows'">
<div class="form-text"><b>{{ $t('apps.env_rtss_cli_example') }}</b>
<pre>cmd /C \path\to\rtss-cli.exe limit:set %APOLLO_CLIENT_FPS%</pre>
</div>
</template>
<template v-if="platform === 'linux'">
<div class="form-text"><b>{{ $t('apps.env_xrandr_example') }}</b>
<pre>sh -c "xrandr --output HDMI-1 --mode \"${APOLLO_CLIENT_WIDTH}x${APOLLO_CLIENT_HEIGHT}\" --rate ${APOLLO_CLIENT_FPS}"</pre>
</div>
</template>
<template v-if="platform === 'macos'">
<div class="form-text"><b>{{ $t('apps.env_displayplacer_example') }}</b>
<pre>sh -c "displayplacer "id:&lt;screenId&gt; res:${APOLLO_CLIENT_WIDTH}x${APOLLO_CLIENT_HEIGHT} hz:${APOLLO_CLIENT_FPS} scaling:on origin:(0,0) degree:0""</pre>
</div>
</template>
<div class="form-text">
<a
href="https://docs.lizardbyte.dev/projects/sunshine/latest/md_docs_2app__examples.html"
target="_blank"
class="text-decoration-none"
>
{{ $t('_common.see_more') }}
</a>
</div>
</div>
<div class="alert alert-info">
<i class="fa-solid fa-xl fa-circle-info"></i> {{ $t('apps.env_sunshine_compatibility') }}
</div>
<!-- Save buttons -->
<div class="d-flex">
<button @click="showEditForm = false" class="btn btn-secondary m-2">
{{ $t('_common.cancel') }}
</button>
<button class="btn btn-primary m-2" :disabled="actionDisabled || !editForm.name.trim()" @click="save">{{ $t('_common.save') }}</button>
</div>
</div>
</div>
<div class="mt-2" v-else>
<button class="btn btn-primary" @click="newApp" :disabled="actionDisabled">
<i class="fas fa-plus"></i> {{ $t('apps.add_new') }}
</button>
<button class="btn btn-secondary float-end" @click="alphabetizeApps" :disabled="actionDisabled" v-if="!listReordered">
<i class="fas fa-sort-alpha-up"></i> {{ $t('apps.alphabetize') }}
</button>
<button class="btn btn-warning float-end" @click="saveOrder" :disabled="actionDisabled" v-if="listReordered">
<i class="fas fa-floppy-disk"></i> {{ $t('apps.save_order') }}
</button>
</div>
</div>
</body>
<script type="module">
import { createApp } from 'vue'
import { initApp } from './init'
import Navbar from './Navbar.vue'
import Checkbox from './Checkbox.vue'
import { Dropdown } from 'bootstrap/dist/js/bootstrap'
const newApp = {
"name": "New App",
"output": "",
"cmd": "",
"exclude-global-prep-cmd": false,
"elevated": false,
"auto-detach": true,
"wait-all": true,
"exit-timeout": 5,
"prep-cmd": [],
"detached": [],
"image-path": "",
"scale-factor": 100,
"use-app-identity": false,
"per-client-app-identity": false,
"allow-client-commands": true,
"virtual-display": false,
"gamepad": ""
}
const app = createApp({
components: {
Navbar,
Checkbox
},
data() {
return {
apps: [],
showEditForm: false,
actionDisabled: false,
editForm: null,
detachedCmd: "",
coverSearching: false,
coverFinderBusy: false,
coverCandidates: [],
platform: "",
currentApp: "",
draggingApp: -1,
listReordered: false
};
},
created() {
this.loadApps();
fetch("./api/config", {
credentials: 'include'
})
.then(r => r.json())
.then(r => this.platform = r.platform);
},
methods: {
onDragStart(e, idx) {
if (this.showEditForm) {
e.preventDefault();
return;
}
this.draggingApp = idx;
this.apps.push({})
},
onDragEnter(e, app) {
if (this.draggingApp < 0) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
app.dragover = true;
},
onDragOver(e) {
if (this.draggingApp < 0) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
},
onDragLeave(app) {
app.dragover = false;
},
onDragEnd() {
this.draggingApp = -1;
this.apps.pop();
},
onDrop(e, app, idx) {
app.dragover = false;
if (this.draggingApp < 0) {
return;
}
e.preventDefault();
if (idx === this.draggingApp || idx - 1 === this.draggingApp) {
return;
}
const draggedApp = this.apps[this.draggingApp];
this.apps.splice(this.draggingApp, 1);
if (idx > this.draggingApp) {
idx -= 1;
}
this.apps.splice(idx, 0, draggedApp);
this.listReordered = true;
},
alphabetizeApps() {
let orderStat = 0;
this.apps.sort((a, b) => {
const result = a.name.localeCompare(b.name);
orderStat += result;
return result;
});
this.listReordered = orderStat !== this.apps.length - 1;
if (!this.listReordered) {
alert(this.$t('apps.already_ordered'));
}
},
saveOrder() {
this.actionDisabled = true;
const reorderedUUIDs = this.apps.reduce((reordered, i) => {
if (i.uuid) {
reordered.push(i.uuid)
}
return reordered
}, [])
fetch("./api/apps/reorder", {
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({order: reorderedUUIDs})
})
.then(r => r.json())
.then((r) => {
if (!r.status) {
alert(this.$t("apps.reorder_failed") + r.error);
}
})
.finally(() => {
return this.loadApps();
})
.finally(() => {
this.actionDisabled = false;
})
},
loadApps() {
return fetch("./api/apps", {
credentials: 'include'
})
.then(r => r.json())
.then(r => {
this.apps = r.apps.filter(i => i.uuid).map(i => ({...i, launching: false, dragover: false}));
this.currentApp = r.current_app;
this.listReordered = false;
});
},
newApp() {
this.editForm = Object.assign({}, newApp);
this.showEditForm = true;
},
launchApp(app) {
if (confirm(this.$t('apps.launch_warning'))) {
this.actionDisabled = true;
fetch("./api/apps/launch", {
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({ uuid: app.uuid })
})
.then(r => r.json())
.then(r => {
if (!r.status) {
alert(this.$t('apps.launch_failed') + r.error);
}
})
.finally(() => {
this.actionDisabled = false;
this.loadApps()
});
}
},
closeApp() {
if (confirm(this.$t('apps.close_warning'))) {
this.actionDisabled = true;
fetch("./api/apps/close", {
credentials: 'include',
method: 'POST',
})
.then((r) => r.json())
.then((r) => {
if (!r.status) {
alert("apps.close_failed")
}
})
.finally(() => {
this.actionDisabled = false;
this.loadApps()
});
}
},
editApp(app) {
this.editForm = Object.assign({}, newApp, JSON.parse(JSON.stringify(app)));
this.showEditForm = true;
},
showDeleteForm(app) {
const resp = confirm(
"Are you sure to delete " + app.name + "?"
);
if (resp) {
this.actionDisabled = true;
fetch("./api/apps/delete", {
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({ uuid: app.uuid })
}).then((r) => r.json())
.then((r) => {
if (!r.status) {
alert("Delete failed! " + r.error);
}
})
.finally(() => {
this.actionDisabled = false;
this.loadApps();
});
}
},
addPrepCmd(idx) {
const template = {
do: "",
undo: ""
};
if (this.platform === 'windows') {
template.elevated = false;
}
this.editForm["prep-cmd"].splice(idx + 1, 0, template);
},
showCoverFinder($event) {
this.coverCandidates = [];
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();
function getSearchBucket(name) {
let bucket = name.substring(0, Math.min(name.length, 2)).toLowerCase().replaceAll(/[^a-z\d]/g, '');
if (!bucket) {
return '@';
}
return bucket;
}
function searchCovers(name) {
if (!name) {
return Promise.resolve([]);
}
let searchName = name.replaceAll(/\s+/g, '.').toLowerCase();
// Use raw.githubusercontent.com to avoid CORS issues as we migrate the CNAME
let dbUrl = "https://raw.githubusercontent.com/LizardByte/GameDB/gh-pages";
let bucket = getSearchBucket(name);
return fetch(`${dbUrl}/buckets/${bucket}.json`).then(function (r) {
if (!r.ok) throw new Error("Failed to search covers");
return r.json();
}).then(maps => Promise.all(Object.keys(maps).map(id => {
let item = maps[id];
if (item.name.replaceAll(/\s+/g, '.').toLowerCase().startsWith(searchName)) {
return fetch(`${dbUrl}/games/${id}.json`).then(function (r) {
return r.json();
}).catch(() => null);
}
return null;
}).filter(item => item)))
.then(results => results
.filter(item => item && item.cover && item.cover.url)
.map(game => {
const thumb = game.cover.url;
const dotIndex = thumb.lastIndexOf('.');
const slashIndex = thumb.lastIndexOf('/');
if (dotIndex < 0 || slashIndex < 0) {
return null;
}
const slug = thumb.substring(slashIndex + 1, dotIndex);
return {
name: game.name,
key: `igdb_${game.id}`,
url: `https://images.igdb.com/igdb/image/upload/t_cover_big/${slug}.jpg`,
saveUrl: `https://images.igdb.com/igdb/image/upload/t_cover_big_2x/${slug}.png`,
}
}).filter(item => item));
}
searchCovers(this.editForm["name"].toString().trim())
.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", {
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({
key: cover.key,
url: cover.saveUrl,
})
}).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())
.finally(() => this.coverFinderBusy = false);
},
save() {
this.editForm.name = this.editForm.name.trim();
if (!this.editForm.name) {
return;
}
this.editForm["exit-timeout"] = parseInt(this.editForm["exit-timeout"]) || 5
this.editForm["scale-factor"] = parseInt(this.editForm["scale-factor"]) || 100
this.editForm["image-path"] = this.editForm["image-path"].toString().trim().replace(/"/g, '');
delete this.editForm.id;
delete this.editForm.launching;
delete this.editForm.dragover;
fetch("./api/apps", {
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify(this.editForm),
}).then((r) => r.json())
.then((r) => {
if (!r.status) {
alert(this.$t('apps.save_failed') + r.error);
throw new Error(`App save failed: ${r.error}`);
}
})
.then(() => {
this.showEditForm = false;
this.loadApps();
});
},
},
});
app.directive('dropdown-show', {
mounted: function (el, binding) {
el.addEventListener('show.bs.dropdown', binding.value);
}
});
initApp(app);
</script>