Files
ShadowMountPlus/web/index.html
T

1372 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>ShadowMount Library</title>
<style>
:root {
color-scheme: dark;
--bg: #080b12;
--surface: #111722;
--surface-2: #171f2d;
--line: #263145;
--text: #f4f7fb;
--muted: #95a2b8;
--blue: #55a7ff;
--blue-strong: #147ee8;
--green: #52d39a;
--amber: #f2bd5d;
--red: #ff6c79;
--radius: 14px;
--shadow: 0 18px 50px rgba(0, 0, 0, .34);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(circle at 15% -10%, rgba(20, 126, 232, .17), transparent 34rem),
var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
}
button, input, select { font: inherit; }
button, select { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.shell { width: min(1500px, 100%); margin: 0 auto; padding: 22px; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.brand { display: flex; align-items: center; gap: 13px; }
.brand-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 1px solid rgba(85, 167, 255, .45);
border-radius: 12px;
background: linear-gradient(145deg, #147ee8, #74baff);
box-shadow: 0 8px 24px rgba(20, 126, 232, .25);
font-size: 20px;
font-weight: 800;
}
h1, h2, h3, p { margin: 0; }
h1 { font-size: 20px; letter-spacing: -.02em; }
h2 { font-size: 17px; }
.subtle { color: var(--muted); font-size: 13px; }
.connection {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
.connection-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 0 4px rgba(242, 189, 93, .12);
}
.connection.online .connection-dot {
background: var(--green);
box-shadow: 0 0 0 4px rgba(82, 211, 154, .12);
}
.connection.offline .connection-dot {
background: var(--red);
box-shadow: 0 0 0 4px rgba(255, 108, 121, .12);
}
.toolbar, .panel, .stat, .game-card, .modal-card {
border: 1px solid var(--line);
background: rgba(17, 23, 34, .96);
box-shadow: var(--shadow);
}
.toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) 150px 110px auto auto auto;
gap: 10px;
padding: 13px;
border-radius: var(--radius);
}
input, select {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 10px;
background: #0c111a;
color: var(--text);
padding: 0 12px;
}
input::placeholder { color: #6f7c92; }
.check {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
color: var(--muted);
white-space: nowrap;
}
.check input { min-height: 0; width: 17px; height: 17px; accent-color: var(--blue); }
.btn {
min-height: 40px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-2);
color: var(--text);
padding: 0 14px;
transition: border-color .15s ease, background .15s ease, transform .15s ease;
}
.btn:hover { border-color: #3b4a63; background: #1c2637; }
.btn:active { transform: translateY(1px); }
.btn:disabled { cursor: wait; opacity: .55; }
.btn.primary { border-color: var(--blue-strong); background: var(--blue-strong); }
.btn.primary:hover { background: #238cf3; }
.btn.danger { border-color: rgba(255, 108, 121, .45); color: #ff9aa3; }
.btn.small { min-height: 34px; padding: 0 11px; font-size: 13px; }
.stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 14px 0;
}
.stat { padding: 15px 17px; border-radius: var(--radius); box-shadow: none; }
.stat-value { display: block; margin-top: 3px; font-size: 25px; font-weight: 750; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 310px;
align-items: start;
gap: 14px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 4px 0 11px;
}
.games {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.game-card {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: start;
gap: 14px;
min-height: 116px;
padding: 13px;
border-radius: var(--radius);
box-shadow: none;
}
.cover {
position: relative;
width: 88px;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(145deg, #1d2c43, #0c111a);
}
.cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.cover-fallback { display: grid; place-items: center; width: 100%; height: 100%; color: var(--blue); font-weight: 800; }
.game-main { min-width: 0; }
.game-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; }
.game-identity { min-width: 0; }
.game-title { overflow: hidden; font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.game-id { color: var(--muted); font: 12px/1.5 ui-monospace, "SFMono-Regular", Consolas, monospace; }
.game-card-size { color: var(--blue); font-size: 14px; font-weight: 750; white-space: nowrap; }
.badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.badge { border: 1px solid var(--line); border-radius: 999px; color: var(--muted); padding: 2px 7px; font-size: 11px; text-transform: uppercase; }
.badge.live { border-color: rgba(82, 211, 154, .35); color: var(--green); }
.badge.image { border-color: rgba(85, 167, 255, .38); color: #8cc4ff; }
.game-path { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.game-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
.panel { padding: 16px; border-radius: var(--radius); box-shadow: none; }
.sidebar { display: grid; gap: 14px; position: sticky; top: 14px; }
.storage-list { display: grid; gap: 9px; margin-top: 12px; }
.storage-row {
min-width: 0;
padding: 10px;
border-radius: 10px;
background: #0c111a;
}
.storage-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.storage-path { overflow: hidden; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.storage-meta { overflow: hidden; margin-top: 3px; color: var(--muted); font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.storage-space { display: flex; justify-content: space-between; gap: 8px; margin-top: 7px; color: var(--muted); font-size: 11px; }
.storage-meter {
height: 6px;
margin-top: 6px;
overflow: hidden;
border-radius: 999px;
background: #202a3a;
}
.storage-fill { height: 100%; border-radius: inherit; background: var(--blue); }
.storage-fill.warning { background: var(--amber); }
.storage-fill.danger { background: var(--red); }
.transfer-details { display: grid; gap: 10px; margin-top: 12px; }
.transfer-details[hidden] { display: none; }
.transfer-route { overflow-wrap: anywhere; color: var(--muted); font: 11px/1.45 ui-monospace, monospace; }
.transfer-meter { height: 9px; margin-top: 0; }
.transfer-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.transfer-stat { padding: 8px 9px; border-radius: 9px; background: #0c111a; }
.transfer-stat strong { display: block; margin-top: 2px; font-size: 13px; }
.transfer-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.manual-form { display: flex; gap: 7px; margin-top: 12px; }
.manual-form input { min-width: 0; width: 100%; }
.manual-list, .activity-list { display: grid; gap: 7px; margin-top: 12px; }
.manual-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
padding: 8px 9px;
border-radius: 9px;
background: #0c111a;
}
.manual-path { overflow: hidden; color: var(--muted); font: 11px/1.4 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.activity-item { border-left: 2px solid var(--line); padding-left: 9px; color: var(--muted); font-size: 12px; }
.activity-item.ok { border-color: var(--green); }
.activity-item.error { border-color: var(--red); color: #ff9aa3; }
.empty {
grid-column: 1 / -1;
padding: 46px 20px;
border: 1px dashed var(--line);
border-radius: var(--radius);
color: var(--muted);
text-align: center;
}
.modal {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgba(2, 5, 10, .78);
}
.modal[hidden] { display: none; }
.modal-card { width: min(720px, 100%); max-height: 90vh; overflow: auto; border-radius: 18px; }
.modal-head { display: flex; align-items: start; justify-content: space-between; gap: 20px; padding: 18px; border-bottom: 1px solid var(--line); }
.close { width: 36px; min-height: 36px; padding: 0; font-size: 22px; }
.details { display: grid; grid-template-columns: 168px minmax(0, 1fr); align-items: start; gap: 18px; padding: 18px; }
.details .cover { width: 168px; }
.detail-grid { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 7px 12px; margin-top: 13px; font-size: 13px; }
.detail-key { color: var(--muted); }
.detail-value { min-width: 0; overflow-wrap: anywhere; }
.modal-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 18px 18px; }
.toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 30;
max-width: min(420px, calc(100vw - 40px));
border: 1px solid var(--line);
border-radius: 11px;
background: #172131;
box-shadow: var(--shadow);
padding: 12px 15px;
}
.toast[hidden] { display: none; }
.toast.error { border-color: rgba(255, 108, 121, .5); color: #ffabb2; }
@media (max-width: 980px) {
.toolbar { grid-template-columns: minmax(180px, 1fr) 150px auto; }
.toolbar .check { order: 2; }
.layout { grid-template-columns: 1fr; }
.sidebar { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 660px) {
.shell { padding: 14px; }
.topbar { align-items: flex-start; }
.toolbar { grid-template-columns: 1fr 1fr; }
.toolbar #search { grid-column: 1 / -1; }
.stats { grid-template-columns: 1fr 1fr; }
.games { grid-template-columns: 1fr; }
.sidebar { grid-template-columns: 1fr; }
.details { grid-template-columns: 1fr; }
.details .cover { width: 120px; }
.detail-grid { grid-template-columns: 105px minmax(0, 1fr); }
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">S</div>
<div>
<h1 data-i18n="pageTitle">ShadowMount Library</h1>
<p class="subtle" id="version" data-i18n="connecting">Connecting to API…</p>
</div>
</div>
<div class="connection" id="connection" role="status">
<span class="connection-dot"></span>
<span id="connectionText" data-i18n="checking">Checking</span>
</div>
</header>
<section class="toolbar" aria-label="Library controls" data-i18n-aria-label="libraryControls">
<input id="search" type="search" autocomplete="off" placeholder="Name, Title ID, Content ID, or path" data-i18n-placeholder="searchPlaceholder">
<select id="filter" aria-label="Library filter" data-i18n-aria-label="libraryFilter">
<option value="all" data-i18n="allGames">All games</option>
<option value="folder" data-i18n="folders">Folders</option>
<option value="image" data-i18n="images">Images</option>
<option value="ps4">PS4</option>
<option value="ps5">PS5</option>
</select>
<select id="language" aria-label="Language" data-i18n-aria-label="language">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
<label class="check"><input id="includeSize" type="checkbox"> <span data-i18n="calculateSize">Calculate size</span></label>
<button class="btn" id="refreshBtn" type="button" data-i18n="refresh">Refresh</button>
<button class="btn primary" id="scanBtn" type="button" data-i18n="scan">Scan</button>
</section>
<section class="stats" aria-label="Library summary" data-i18n-aria-label="librarySummary">
<div class="stat"><span class="subtle" data-i18n="totalGames">Total games</span><strong class="stat-value" id="totalGames">—</strong></div>
<div class="stat"><span class="subtle" data-i18n="images">Images</span><strong class="stat-value" id="imageGames">—</strong></div>
<div class="stat"><span class="subtle" data-i18n="folders">Folders</span><strong class="stat-value" id="folderGames">—</strong></div>
<div class="stat"><span class="subtle" data-i18n="mounted">Mounted</span><strong class="stat-value" id="mountedGames">—</strong></div>
</section>
<div class="layout">
<section aria-labelledby="gamesHeading">
<div class="section-head">
<h2 id="gamesHeading" data-i18n="library">Library</h2>
<span class="subtle" id="visibleCount"></span>
</div>
<div class="games" id="games" aria-live="polite">
<div class="empty" data-i18n="loadingLibrary">Loading library…</div>
</div>
</section>
<aside class="sidebar">
<section class="panel" aria-labelledby="storageHeading">
<h2 id="storageHeading" data-i18n="storage">Storage</h2>
<p class="subtle" data-i18n="storageDescription">Free space at available mount points</p>
<div class="storage-list" id="storageList" aria-live="polite"><span class="subtle" data-i18n="loading">Loading…</span></div>
</section>
<section class="panel" aria-labelledby="transferHeading">
<div class="storage-head">
<h2 id="transferHeading" data-i18n="dataOperations">Data operations</h2>
<span class="badge" id="transferState" data-i18n="checking">Checking</span>
</div>
<p class="subtle" id="transferIdle" data-i18n="gettingOperationStatus">Getting operation status…</p>
<div class="transfer-details" id="transferDetails" hidden>
<strong id="transferSummary"></strong>
<div class="transfer-route" id="transferRoute"></div>
<div class="storage-meter transfer-meter" id="transferMeter" role="progressbar" aria-label="Operation progress" data-i18n-aria-label="operationProgress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="storage-fill" id="transferFill"></div>
</div>
<div class="transfer-stats">
<div class="transfer-stat"><span class="subtle" data-i18n="size">Size</span><strong id="transferBytes">—</strong></div>
<div class="transfer-stat"><span class="subtle" data-i18n="files">Files</span><strong id="transferFiles">—</strong></div>
<div class="transfer-stat"><span class="subtle" data-i18n="speed">Speed</span><strong id="transferSpeed">—</strong></div>
<div class="transfer-stat"><span class="subtle" data-i18n="time">Time</span><strong id="transferElapsed">—</strong></div>
</div>
<div class="transfer-actions">
<strong id="transferPercent">0%</strong>
<button class="btn small danger" id="cancelTransfer" type="button" data-i18n="cancel">Cancel</button>
</div>
</div>
</section>
<section class="panel" aria-labelledby="manualHeading">
<h2 id="manualHeading">manual.lst</h2>
<p class="subtle" data-i18n="manualDescription">Additional scan sources</p>
<form class="manual-form" id="manualForm">
<input id="manualPath" required placeholder="/mnt/usb0/games">
<button class="btn small" type="submit" data-i18n="add">Add</button>
</form>
<div class="manual-list" id="manualList"><span class="subtle" data-i18n="loading">Loading…</span></div>
</section>
<section class="panel" aria-labelledby="scanHeading">
<h2 id="scanHeading" data-i18n="scanOptions">Scan options</h2>
<label class="check"><input id="resetAttempts" type="checkbox"> <span data-i18n="resetAttempts">Reset attempt counters</span></label>
<p class="subtle" data-i18n="resetAttemptsDescription">This option applies only to the next forced scan.</p>
</section>
<section class="panel" aria-labelledby="activityHeading">
<h2 id="activityHeading" data-i18n="recentOperations">Recent operations</h2>
<div class="activity-list" id="activity"><span class="subtle" data-i18n="noOperations">No operations yet</span></div>
</section>
</aside>
</div>
</main>
<div class="modal" id="modal" hidden role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-card">
<div class="modal-head">
<div>
<h2 id="modalTitle" data-i18n="gameInformation">Game information</h2>
<p class="subtle" id="modalSubtitle"></p>
</div>
<button class="btn close" id="closeModal" type="button" aria-label="Close" data-i18n-aria-label="close">&times;</button>
</div>
<div class="details" id="details"></div>
<div class="modal-actions" id="modalActions"></div>
</div>
</div>
<div class="toast" id="toast" hidden role="status"></div>
<script>
(() => {
"use strict";
const API = "/api/v1";
const state = {
games: [],
selected: null,
activity: [],
busy: false,
storageJob: null,
storagePollTimer: 0,
reportedStorageJob: "",
apiVersion: null,
connectionStatus: "",
connectionKey: "checking"
};
const $ = (id) => document.getElementById(id);
const translations = {
en: {
pageTitle: "ShadowMount Library",
connecting: "Connecting to API…",
checking: "Checking",
libraryControls: "Library controls",
searchPlaceholder: "Name, Title ID, Content ID, or path",
libraryFilter: "Library filter",
allGames: "All games",
folders: "Folders",
images: "Images",
language: "Language",
calculateSize: "Calculate size",
refresh: "Refresh",
scan: "Scan",
librarySummary: "Library summary",
totalGames: "Total games",
mounted: "Mounted",
library: "Library",
loadingLibrary: "Loading library…",
storage: "Storage",
storageDescription: "Free space at available mount points",
loading: "Loading…",
dataOperations: "Data operations",
gettingOperationStatus: "Getting operation status…",
operationProgress: "Operation progress",
size: "Size",
files: "Files",
speed: "Speed",
perSecond: "/s",
time: "Time",
cancel: "Cancel",
manualDescription: "Additional scan sources",
add: "Add",
scanOptions: "Scan options",
resetAttempts: "Reset attempt counters",
resetAttemptsDescription: "This option applies only to the next forced scan.",
recentOperations: "Recent operations",
noOperations: "No operations yet",
gameInformation: "Game information",
close: "Close",
coverAlt: "Cover for {title}",
unknown: "unknown",
folder: "folder",
image: "image",
sourceUnavailable: "Source unavailable",
details: "Details",
connect: "Mount",
disconnect: "Unmount",
nothingFound: "Nothing found",
libraryEmpty: "The library is empty",
shown: "Shown: {count}",
jobIdle: "No job",
jobPreparing: "Preparing",
jobMeasuring: "Measuring",
jobDeleting: "Deleting",
jobFinalizing: "Finalizing",
jobCompleted: "Completed",
jobFailed: "Failed",
jobCancelled: "Cancelled",
operationCopy: "Copying",
operationMove: "Moving",
operationDelete: "Deleting",
noActiveOperations: "No active operations",
calculating: "Calculating…",
cancelling: "Cancelling…",
operationCompleted: "{operation} {title} completed",
operationCompletedShort: "{operation} completed",
operationCancelled: "{operation} {title} cancelled",
operationCancelledShort: "{operation} cancelled",
statusError: "error {status}",
operationError: "{operation} {title}: {error}",
unavailable: "Unavailable",
cancelRequested: "Cancellation requested for job {id}",
storageNotFound: "No storage found",
freeSpace: "Free {size}",
totalSpace: "of {size}",
storageUsed: "Used space at {path}",
connected: "PS5 connected",
disconnected: "No connection",
apiError: "API error: {error}",
listEmpty: "The list is empty",
remove: "Remove",
manualAdded: "Source added to manual.lst",
manualExists: "Source is already in manual.lst",
removeManualConfirm: "Remove this source from manual.lst?\n\n{path}",
manualRemoved: "Source removed from manual.lst",
scanQueuedReset: "Scan queued with attempt counters reset",
scanQueued: "Scan queued",
scanStarted: "Scan started",
mountedTitle: "Mounted {title}{mode}",
unmountedTitle: "Unmounted {title}",
source: "Source",
runtimePath: "Runtime path",
sizeError: "Error {status}",
installed: "Installed",
lastLaunch: "Last launch",
mountReadOnly: "Mount R/O",
mountReadWrite: "Mount R/W",
copy: "Copy",
move: "Move",
deleteSource: "Delete source",
storageBusyHint: "Wait for the current storage operation to finish",
uninstall: "Uninstall",
loadingSource: "Calculating source size…",
storageBusy: "A storage operation is already running",
copyDestination: "Destination folder for copying:",
moveDestination: "Destination folder for moving:",
jobStarted: "{operation} {title} started as job {id}",
operationStarted: "Operation started",
uninstallConfirm: "Request uninstall of {game}?\n\nThe source image or folder will not be deleted.",
uninstallAccepted: "Uninstall request for {title} accepted",
deleteConfirm: "Permanently delete the physical source for {game}?\n\nThis cannot be undone."
},
ru: {
pageTitle: "Библиотека ShadowMount",
connecting: "Подключение к API…",
checking: "Проверка",
libraryControls: "Управление библиотекой",
searchPlaceholder: "Название, Title ID, Content ID или путь",
libraryFilter: "Фильтр библиотеки",
allGames: "Все игры",
folders: "Папки",
images: "Образы",
language: "Язык",
calculateSize: "Подсчитать размер",
refresh: "Обновить",
scan: "Сканировать",
librarySummary: "Сводка библиотеки",
totalGames: "Всего игр",
mounted: "Смонтировано",
library: "Библиотека",
loadingLibrary: "Загрузка библиотеки…",
storage: "Хранилища",
storageDescription: "Свободное место в доступных точках монтирования",
loading: "Загрузка…",
dataOperations: "Операции с данными",
gettingOperationStatus: "Получение статуса операции…",
operationProgress: "Прогресс операции",
size: "Размер",
files: "Файлы",
speed: "Скорость",
perSecond: "/с",
time: "Время",
cancel: "Отменить",
manualDescription: "Дополнительные источники сканирования",
add: "Добавить",
scanOptions: "Параметры сканирования",
resetAttempts: "Сбросить счётчики попыток",
resetAttemptsDescription: "Параметр применяется только к следующему принудительному сканированию.",
recentOperations: "Последние операции",
noOperations: "Операций пока нет",
gameInformation: "Информация об игре",
close: "Закрыть",
coverAlt: "Обложка {title}",
unknown: "неизвестно",
folder: "папка",
image: "образ",
sourceUnavailable: "Источник недоступен",
details: "Подробнее",
connect: "Подключить",
disconnect: "Отключить",
nothingFound: "Ничего не найдено",
libraryEmpty: "Библиотека пуста",
shown: "Показано: {count}",
jobIdle: "Нет задачи",
jobPreparing: "Подготовка",
jobMeasuring: "Подсчёт",
jobDeleting: "Удаление",
jobFinalizing: "Завершение",
jobCompleted: "Готово",
jobFailed: "Ошибка",
jobCancelled: "Отменено",
operationCopy: "Копирование",
operationMove: "Перемещение",
operationDelete: "Удаление",
noActiveOperations: "Активных операций нет",
calculating: "Подсчёт…",
cancelling: "Отмена…",
operationCompleted: "{operation} {title} завершено",
operationCompletedShort: "{operation} завершено",
operationCancelled: "{operation} {title} отменено",
operationCancelledShort: "{operation} отменено",
statusError: "ошибка {status}",
operationError: "{operation} {title}: {error}",
unavailable: "Недоступно",
cancelRequested: "Запрошена отмена задачи {id}",
storageNotFound: "Хранилища не найдены",
freeSpace: "Свободно {size}",
totalSpace: "из {size}",
storageUsed: "Заполнено {path}",
connected: "PS5 подключена",
disconnected: "Нет связи",
apiError: "Ошибка API: {error}",
listEmpty: "Список пуст",
remove: "Удалить",
manualAdded: "Источник добавлен в manual.lst",
manualExists: "Источник уже есть в manual.lst",
removeManualConfirm: "Удалить источник из manual.lst?\n\n{path}",
manualRemoved: "Источник удалён из manual.lst",
scanQueuedReset: "Сканирование поставлено в очередь со сбросом попыток",
scanQueued: "Сканирование поставлено в очередь",
scanStarted: "Сканирование запущено",
mountedTitle: "Смонтировано {title}{mode}",
unmountedTitle: "Размонтировано {title}",
source: "Источник",
runtimePath: "Runtime path",
sizeError: "Ошибка {status}",
installed: "Установлена",
lastLaunch: "Последний запуск",
mountReadOnly: "Подключить R/O",
mountReadWrite: "Подключить R/W",
copy: "Копировать",
move: "Переместить",
deleteSource: "Удалить источник",
storageBusyHint: "Дождитесь завершения текущей операции с хранилищем",
uninstall: "Деинсталлировать",
loadingSource: "Подсчёт размера источника…",
storageBusy: "Уже выполняется операция с хранилищем",
copyDestination: "Папка назначения для копирования:",
moveDestination: "Папка назначения для перемещения:",
jobStarted: "{operation} {title} запущено, задача {id}",
operationStarted: "Операция запущена",
uninstallConfirm: "Запросить деинсталляцию {game}?\n\nИсходный образ или папка не удаляются.",
uninstallAccepted: "Деинсталляция {title} принята",
deleteConfirm: "Удалить физический источник игры {game}?\n\nОперацию нельзя отменить."
}
};
const byteUnits = {
en: ["B", "KiB", "MiB", "GiB", "TiB"],
ru: ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ"]
};
const supportedLanguages = Object.keys(translations);
let savedLanguage = null;
try { savedLanguage = localStorage.getItem("shadowmount.language"); } catch (_) { /* Storage may be disabled. */ }
let currentLanguage = supportedLanguages.includes(savedLanguage)
? savedLanguage
: String(navigator.language || "en").toLowerCase().startsWith("ru") ? "ru" : "en";
function t(key, values = {}) {
const template = translations[currentLanguage][key] || translations.en[key] || key;
return template.replace(/\{(\w+)\}/g, (match, name) =>
Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match
);
}
function applyLanguage(language, persist = false) {
currentLanguage = supportedLanguages.includes(language) ? language : "en";
document.documentElement.lang = currentLanguage;
document.title = t("pageTitle");
$("language").value = currentLanguage;
document.querySelectorAll("[data-i18n]").forEach((item) => {
item.textContent = t(item.dataset.i18n);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((item) => {
item.placeholder = t(item.dataset.i18nPlaceholder);
});
document.querySelectorAll("[data-i18n-aria-label]").forEach((item) => {
item.setAttribute("aria-label", t(item.dataset.i18nAriaLabel));
});
if (state.apiVersion) {
$("version").textContent = `ShadowMount ${state.apiVersion.shadowmount_version} · API v${state.apiVersion.api_version}`;
}
if (state.connectionStatus) setConnection(state.connectionStatus, state.connectionKey);
if (persist) {
try { localStorage.setItem("shadowmount.language", currentLanguage); } catch (_) { /* Keep the in-memory selection. */ }
}
}
function node(tag, className, text) {
const item = document.createElement(tag);
if (className) item.className = className;
if (text !== undefined) item.textContent = text;
return item;
}
function formatBytes(value) {
if (!Number.isFinite(value) || value < 0) return "—";
const units = byteUnits[currentLanguage];
if (value === 0) return `0 ${units[0]}`;
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const precision = index < 2 ? 0 : 1;
return `${(value / (1024 ** index)).toFixed(precision)} ${units[index]}`;
}
function formatDuration(value) {
const totalSeconds = Math.max(0, Math.floor((Number(value) || 0) / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
: `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
async function api(path, payload = {}) {
const response = await fetch(`${API}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
cache: "no-store"
});
const type = response.headers.get("content-type") || "";
const data = type.includes("application/json") ? await response.json() : null;
if (!response.ok || !data || data.status !== 0) {
const reason = data && data.error ? data.error : `HTTP ${response.status}`;
throw new Error(reason);
}
return data;
}
function setConnection(status, textKey) {
state.connectionStatus = status;
state.connectionKey = textKey;
const item = $("connection");
item.className = `connection ${status}`;
$("connectionText").textContent = t(textKey);
}
let toastTimer = 0;
function toast(message, isError = false) {
const item = $("toast");
item.textContent = message;
item.className = isError ? "toast error" : "toast";
item.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { item.hidden = true; }, 4500);
}
function logActivity(message, kind = "ok") {
state.activity.unshift({ message, kind, time: new Date().toLocaleTimeString(currentLanguage, { hour: "2-digit", minute: "2-digit" }) });
state.activity = state.activity.slice(0, 8);
const list = $("activity");
list.replaceChildren();
state.activity.forEach((entry) => {
list.append(node("div", `activity-item ${entry.kind}`, `${entry.time} · ${entry.message}`));
});
}
function coverFor(game, large = false) {
const cover = node("div", "cover");
const fallback = node("div", "cover-fallback", (game.title_id || "SM").slice(0, 4));
cover.append(fallback);
if (game.icon_url) {
const image = new Image();
image.alt = large ? t("coverAlt", { title: game.title_name || game.title_id }) : "";
image.addEventListener("load", () => fallback.remove(), { once: true });
image.addEventListener("error", () => image.remove(), { once: true });
cover.append(image);
image.src = game.icon_url;
}
return cover;
}
function badge(text, extra = "") {
return node("span", `badge ${extra}`.trim(), text);
}
function gameLabel(game) {
const name = game.title_name || game.title_id;
return name === game.title_id ? game.title_id : `${name} (${game.title_id})`;
}
function gameCard(game) {
const card = node("article", "game-card");
card.append(coverFor(game));
const body = node("div", "game-main");
const heading = node("div", "game-heading");
const identity = node("div", "game-identity");
identity.append(node("h3", "game-title", game.title_name || game.title_id));
identity.append(node("div", "game-id", game.title_id));
heading.append(identity);
if (game.size_bytes !== undefined) {
heading.append(node("strong", "game-card-size", formatBytes(game.size_bytes)));
}
body.append(heading);
const badges = node("div", "badges");
badges.append(badge(game.platform || t("unknown")));
badges.append(badge(game.image_backed ? (game.image_type || t("image")) : t("folder"), game.image_backed ? "image" : ""));
if (game.mounted) badges.append(badge(t("mounted"), "live"));
body.append(badges);
const path = node("div", "game-path", game.path || t("sourceUnavailable"));
path.title = game.path || "";
body.append(path);
const actions = node("div", "game-actions");
const details = node("button", "btn small", t("details"));
details.type = "button";
details.addEventListener("click", () => showDetails(game.title_id));
actions.append(details);
if (game.mounted) {
actions.append(actionButton(t("disconnect"), () => titleRuntime(game, "unmount")));
} else if (game.image_backed) {
actions.append(actionButton(t("mountReadOnly"), () => titleRuntime(game, "mount", "ro")));
actions.append(actionButton(t("mountReadWrite"), () => titleRuntime(game, "mount", "rw")));
} else {
actions.append(actionButton(t("connect"), () => titleRuntime(game, "mount")));
}
body.append(actions);
card.append(body);
return card;
}
function filteredGames() {
const query = $("search").value.trim().toLocaleLowerCase(currentLanguage);
const filter = $("filter").value;
return state.games.filter((game) => {
const matchesFilter = filter === "all" ||
(filter === "folder" && !game.image_backed) ||
(filter === "image" && game.image_backed) ||
game.platform === filter;
if (!matchesFilter) return false;
if (!query) return true;
return [game.title_name, game.title_id, game.content_id, game.path]
.some((value) => String(value || "").toLocaleLowerCase(currentLanguage).includes(query));
});
}
function renderGames() {
const games = filteredGames();
const list = $("games");
list.replaceChildren();
if (!games.length) {
list.append(node("div", "empty", t(state.games.length ? "nothingFound" : "libraryEmpty")));
} else {
games.forEach((game) => list.append(gameCard(game)));
}
$("visibleCount").textContent = t("shown", { count: games.length });
}
function renderStats() {
const imageCount = state.games.filter((game) => game.image_backed).length;
$("totalGames").textContent = state.games.length;
$("imageGames").textContent = imageCount;
$("folderGames").textContent = state.games.length - imageCount;
$("mountedGames").textContent = state.games.filter((game) => game.mounted).length;
}
function storageJobStateLabel(job) {
const labels = {
idle: t("jobIdle"),
preparing: t("jobPreparing"),
measuring: t("jobMeasuring"),
transferring: storageOperationLabel(job.operation),
deleting: t("jobDeleting"),
finalizing: t("jobFinalizing"),
completed: t("jobCompleted"),
failed: t("jobFailed"),
cancelled: t("jobCancelled")
};
return labels[job.state] || job.state;
}
function storageOperationLabel(operation) {
if (operation === "move") return t("operationMove");
if (operation === "delete") return t("operationDelete");
return t("operationCopy");
}
function renderStorageJob(job) {
state.storageJob = job;
const hasJob = job && Number(job.job_id) > 0;
$("transferState").textContent = hasJob ? storageJobStateLabel(job) : t("jobIdle");
$("transferState").className = `badge ${job && job.active ? "live" : ""}`.trim();
$("transferIdle").hidden = hasJob;
$("transferIdle").textContent = t("noActiveOperations");
$("transferDetails").hidden = !hasJob;
if (!hasJob) return;
const percent = Math.max(0, Math.min(100, Number(job.progress_percent) || 0));
const operation = storageOperationLabel(job.operation);
$("transferSummary").textContent = `${operation} ${job.title_id}`;
$("transferRoute").textContent = job.operation === "delete"
? job.source
: `${job.source} → ${job.destination}`;
$("transferFill").style.width = `${percent}%`;
$("transferMeter").setAttribute("aria-valuenow", String(Math.round(percent)));
$("transferBytes").textContent = job.total_bytes > 0
? `${formatBytes(Number(job.processed_bytes))} / ${formatBytes(Number(job.total_bytes))}`
: job.state === "measuring" ? t("calculating") : formatBytes(Number(job.processed_bytes));
$("transferFiles").textContent = job.total_files > 0
? `${job.processed_files} / ${job.total_files}`
: job.state === "measuring" ? t("calculating") : String(job.processed_files || 0);
$("transferSpeed").textContent = job.speed_bytes_per_second > 0
? `${formatBytes(Number(job.speed_bytes_per_second))}${t("perSecond")}`
: "—";
$("transferElapsed").textContent = formatDuration(job.elapsed_ms);
$("transferPercent").textContent = `${percent.toFixed(percent < 10 && percent > 0 ? 1 : 0)}%`;
$("cancelTransfer").disabled = !job.cancellable || job.cancel_requested;
$("cancelTransfer").textContent = job.cancel_requested ? t("cancelling") : t("cancel");
}
function reportStorageJob(job) {
const key = `${job.job_id}:${job.state}`;
if (!job.job_id || job.active || state.reportedStorageJob === key) return;
state.reportedStorageJob = key;
const operation = storageOperationLabel(job.operation);
if (job.state === "completed") {
logActivity(t("operationCompleted", { operation, title: job.title_id }));
toast(t("operationCompletedShort", { operation }));
} else if (job.state === "cancelled") {
logActivity(t("operationCancelled", { operation, title: job.title_id }));
toast(t("operationCancelledShort", { operation }));
} else if (job.state === "failed") {
const reason = job.result_error || t("statusError", { status: job.result_status });
logActivity(t("operationError", { operation, title: job.title_id, error: reason }), "error");
toast(reason, true);
}
refreshAll();
}
function scheduleStorageJobPoll() {
clearTimeout(state.storagePollTimer);
const delay = state.storageJob && state.storageJob.active ? 750 : 5000;
state.storagePollTimer = setTimeout(refreshStorageJob, delay);
}
async function refreshStorageJob() {
try {
const wasActive = Boolean(state.storageJob && state.storageJob.active);
const job = await api("/games/storage/status");
renderStorageJob(job);
if (wasActive && !job.active) reportStorageJob(job);
} catch (error) {
if (!state.storageJob) {
$("transferState").textContent = t("unavailable");
$("transferIdle").textContent = errorMessage(error);
}
} finally {
scheduleStorageJobPoll();
}
}
async function cancelStorageJob() {
const job = state.storageJob;
if (!job || !job.active || !job.cancellable) return;
$("cancelTransfer").disabled = true;
try {
renderStorageJob(await api("/games/storage/cancel", { job_id: job.job_id }));
logActivity(t("cancelRequested", { id: job.job_id }));
scheduleStorageJobPoll();
} catch (error) {
toast(errorMessage(error), true);
await refreshStorageJob();
}
}
async function refreshStorage() {
const list = $("storageList");
try {
const data = await api("/storage");
const allMounts = (data.mounts || []).slice().sort((left, right) =>
String(left.mount_point).localeCompare(String(right.mount_point))
);
const libraryMounts = allMounts.filter((mount) =>
mount.mount_point === "/user" || /^\/mnt\/(?:usb|ext)\d+(?:\/|$)/.test(mount.mount_point)
);
const mounts = libraryMounts.length ? libraryMounts : allMounts.filter((mount) => !mount.read_only);
list.replaceChildren();
if (!mounts.length) {
list.append(node("span", "subtle", t("storageNotFound")));
return;
}
mounts.forEach((mount) => {
const total = Number(mount.total_bytes) || 0;
const available = Number(mount.available_bytes) || 0;
const usedPercent = total > 0 ? Math.max(0, Math.min(100, ((total - available) / total) * 100)) : 0;
const freePercent = 100 - usedPercent;
const row = node("div", "storage-row");
const head = node("div", "storage-head");
const path = node("span", "storage-path", mount.mount_point);
path.title = mount.mount_point;
head.append(path, badge(mount.read_only ? "R/O" : mount.filesystem || "FS", mount.read_only ? "" : "live"));
row.append(head);
const meta = node("div", "storage-meta", `${mount.filesystem || t("unknown")} · ${mount.source || "—"}`);
meta.title = mount.source || "";
row.append(meta);
const space = node("div", "storage-space");
space.append(node("span", "", t("freeSpace", { size: formatBytes(available) })));
space.append(node("span", "", t("totalSpace", { size: formatBytes(total) })));
row.append(space);
const meter = node("div", "storage-meter");
meter.setAttribute("role", "progressbar");
meter.setAttribute("aria-label", t("storageUsed", { path: mount.mount_point }));
meter.setAttribute("aria-valuemin", "0");
meter.setAttribute("aria-valuemax", "100");
meter.setAttribute("aria-valuenow", String(Math.round(usedPercent)));
const level = freePercent < 10 ? "danger" : freePercent < 20 ? "warning" : "";
const fill = node("div", `storage-fill ${level}`.trim());
fill.style.width = `${usedPercent}%`;
meter.append(fill);
row.append(meter);
list.append(row);
});
} catch (error) {
list.replaceChildren(node("span", "subtle", errorMessage(error)));
}
}
async function refreshLibrary() {
if (state.busy) return;
state.busy = true;
$("refreshBtn").disabled = true;
try {
const [version, games] = await Promise.all([
api("/version"),
api("/games", { include_size: $("includeSize").checked })
]);
state.games = games.games || [];
state.apiVersion = version;
$("version").textContent = `ShadowMount ${version.shadowmount_version} · API v${version.api_version}`;
setConnection("online", "connected");
renderStats();
renderGames();
} catch (error) {
setConnection("offline", "disconnected");
$("games").replaceChildren(node("div", "empty", t("apiError", { error: errorMessage(error) })));
toast(errorMessage(error), true);
} finally {
state.busy = false;
$("refreshBtn").disabled = false;
}
}
function refreshAll() {
return Promise.all([refreshLibrary(), refreshStorage()]);
}
async function refreshManual() {
const list = $("manualList");
try {
const data = await api("/manual/list");
list.replaceChildren();
if (!data.paths.length) {
list.append(node("span", "subtle", t("listEmpty")));
return;
}
data.paths.forEach((path) => {
const row = node("div", "manual-row");
const value = node("span", "manual-path", path);
value.title = path;
const remove = node("button", "btn small danger", t("remove"));
remove.type = "button";
remove.addEventListener("click", () => removeManual(path));
row.append(value, remove);
list.append(row);
});
} catch (error) {
list.replaceChildren(node("span", "subtle", errorMessage(error)));
}
}
async function addManual(event) {
event.preventDefault();
const input = $("manualPath");
const path = input.value.trim();
if (!path) return;
try {
const result = await api("/manual/add", { path });
input.value = "";
logActivity(t(result.changed ? "manualAdded" : "manualExists"));
await refreshManual();
} catch (error) {
logActivity(errorMessage(error), "error");
toast(errorMessage(error), true);
}
}
async function removeManual(path) {
if (!confirm(t("removeManualConfirm", { path }))) return;
try {
await api("/manual/remove", { path });
logActivity(t("manualRemoved"));
await refreshManual();
} catch (error) {
logActivity(errorMessage(error), "error");
toast(errorMessage(error), true);
}
}
async function requestScan() {
const resetAttempts = $("resetAttempts").checked;
$("scanBtn").disabled = true;
try {
await api("/scan", { reset_attempts: resetAttempts });
logActivity(t(resetAttempts ? "scanQueuedReset" : "scanQueued"));
toast(t("scanStarted"));
setTimeout(refreshAll, 1600);
} catch (error) {
logActivity(errorMessage(error), "error");
toast(errorMessage(error), true);
} finally {
$("scanBtn").disabled = false;
}
}
async function runMutation(label, task, closeAfter = false) {
try {
await task();
logActivity(label);
toast(label);
if (closeAfter) closeModal();
await refreshAll();
} catch (error) {
logActivity(`${label}: ${errorMessage(error)}`, "error");
toast(errorMessage(error), true);
}
}
function titleRuntime(game, operation, mode) {
const payload = { title_id: game.title_id };
if (mode) payload.mode = mode;
const label = operation === "mount"
? t("mountedTitle", { title: game.title_id, mode: mode ? ` (${mode})` : "" })
: t("unmountedTitle", { title: game.title_id });
return runMutation(label, async () => {
await api(`/games/${operation}`, payload);
if (!$('modal').hidden && state.selected &&
state.selected.title_id === game.title_id) {
state.selected.mounted = operation === "mount";
renderDetails(state.selected, true);
}
});
}
function actionButton(text, action, extra = "") {
const button = node("button", `btn small ${extra}`.trim(), text);
button.type = "button";
button.addEventListener("click", action);
return button;
}
function detailRow(grid, key, value) {
grid.append(node("div", "detail-key", key));
grid.append(node("div", "detail-value", value || "—"));
}
function renderDetails(game, focusRuntimeAction = false) {
state.selected = game;
$("modalTitle").textContent = game.title_name || game.title_id;
$("modalSubtitle").textContent = `${game.title_id} · ${(game.platform || t("unknown")).toUpperCase()}`;
const details = $("details");
details.replaceChildren();
details.append(coverFor(game, true));
const content = node("div");
const badges = node("div", "badges");
badges.append(badge(game.source_type === "folder" ? t("folder") : game.source_type === "image" ? t("image") : game.source_type));
if (game.image_type) badges.append(badge(game.image_type, "image"));
if (game.mounted) badges.append(badge(t("mounted"), "live"));
content.append(badges);
const grid = node("div", "detail-grid");
detailRow(grid, "Content ID", game.content_id);
detailRow(grid, t("source"), game.path);
detailRow(grid, t("runtimePath"), game.runtime_path);
detailRow(grid, t("size"), game.size_status === 0 ? formatBytes(game.size_bytes) : t("sizeError", { status: game.size_status }));
detailRow(grid, "app.db", formatBytes(game.app_db_size_bytes));
detailRow(grid, t("installed"), game.install_time);
detailRow(grid, t("lastLaunch"), game.last_access_time);
content.append(grid);
details.append(content);
const actions = $("modalActions");
actions.replaceChildren();
let runtimeAction;
if (game.mounted) {
runtimeAction = actionButton(t("disconnect"), () => titleRuntime(game, "unmount"));
actions.append(runtimeAction);
} else if (game.image_backed) {
runtimeAction = actionButton(t("mountReadOnly"), () => titleRuntime(game, "mount", "ro"));
actions.append(runtimeAction);
actions.append(actionButton(t("mountReadWrite"), () => titleRuntime(game, "mount", "rw")));
} else {
runtimeAction = actionButton(t("connect"), () => titleRuntime(game, "mount"));
actions.append(runtimeAction);
}
const storageBusy = Boolean(state.storageJob && state.storageJob.active);
const copy = actionButton(t("copy"), () => storageOperation(game, "copy"));
const move = actionButton(t("move"), () => storageOperation(game, "move"));
const remove = actionButton(t("deleteSource"), () => deleteSource(game), "danger");
copy.disabled = storageBusy;
move.disabled = storageBusy;
remove.disabled = storageBusy;
if (storageBusy) {
copy.title = t("storageBusyHint");
move.title = copy.title;
remove.title = copy.title;
}
actions.append(copy, move);
actions.append(actionButton(t("uninstall"), () => uninstallGame(game), "danger"));
actions.append(remove);
if (focusRuntimeAction) runtimeAction.focus();
}
async function showDetails(titleId) {
$("modal").hidden = false;
$("modalTitle").textContent = t("loading");
$("modalSubtitle").textContent = titleId;
$("details").replaceChildren(node("div", "empty", t("loadingSource")));
$("modalActions").replaceChildren();
try {
renderDetails(await api("/games/info", { title_id: titleId }), true);
} catch (error) {
$("details").replaceChildren(node("div", "empty", errorMessage(error)));
}
}
function closeModal() {
$("modal").hidden = true;
state.selected = null;
}
async function storageOperation(game, operation) {
if (state.storageJob && state.storageJob.active) {
toast(t("storageBusy"), true);
return;
}
const destination = prompt(t(operation === "copy" ? "copyDestination" : "moveDestination"), "/mnt/usb0");
if (!destination) return;
await startStorageJob(game, operation, { title_id: game.title_id, destination_dir: destination });
}
async function startStorageJob(game, operation, payload) {
try {
const job = await api(`/games/${operation}`, payload);
renderStorageJob(job);
closeModal();
logActivity(t("jobStarted", { operation: storageOperationLabel(operation), title: game.title_id, id: job.job_id }));
toast(t("operationStarted"));
if (job.active) scheduleStorageJobPoll();
else reportStorageJob(job);
} catch (error) {
logActivity(`${storageOperationLabel(operation)} ${game.title_id}: ${errorMessage(error)}`, "error");
toast(errorMessage(error), true);
await refreshStorageJob();
}
}
function uninstallGame(game) {
if (!confirm(t("uninstallConfirm", { game: gameLabel(game) }))) return;
runMutation(t("uninstallAccepted", { title: game.title_id }), () => api("/games/uninstall", { title_id: game.title_id }), true);
}
async function deleteSource(game) {
if (state.storageJob && state.storageJob.active) {
toast(t("storageBusy"), true);
return;
}
if (!confirm(t("deleteConfirm", { game: gameLabel(game) }))) return;
await startStorageJob(game, "delete", { title_id: game.title_id, confirm: true });
}
$("search").addEventListener("input", renderGames);
$("filter").addEventListener("change", renderGames);
$("language").addEventListener("change", () => {
applyLanguage($("language").value, true);
renderStats();
renderGames();
if (state.storageJob) renderStorageJob(state.storageJob);
if (state.selected) renderDetails(state.selected);
refreshStorage();
refreshManual();
});
$("includeSize").addEventListener("change", refreshLibrary);
$("refreshBtn").addEventListener("click", refreshAll);
$("scanBtn").addEventListener("click", requestScan);
$("cancelTransfer").addEventListener("click", cancelStorageJob);
$("manualForm").addEventListener("submit", addManual);
$("closeModal").addEventListener("click", closeModal);
$("modal").addEventListener("click", (event) => { if (event.target === $("modal")) closeModal(); });
document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !$("modal").hidden) closeModal(); });
applyLanguage(currentLanguage);
Promise.all([refreshAll(), refreshManual(), refreshStorageJob()]);
})();
</script>
</body>
</html>