release: prepare Twitch VOD Manager 1.0.2

Redesign the seven-area desktop workspace with responsive navigation, searchable settings, localized controls, and accessible update states.

Harden Electron release coverage with fully isolated user data, download paths, offline fixtures, concurrency checks, and public-release manifest validation.
This commit is contained in:
Sucukdeluxe
2026-08-10 12:29:56 +02:00
parent dacd66fe1c
commit d599593966
29 changed files with 2388 additions and 725 deletions
+60 -48
View File
@@ -212,24 +212,33 @@
</div>
<div class="topbar-actions">
<div class="update-banner workspace-update" id="updateBanner" data-update-state="idle">
<button type="button" class="workspace-update-button" id="workspaceUpdateButton" onclick="handleWorkspaceUpdateAction()" aria-describedby="updateText">
<button type="button" class="workspace-update-button" id="workspaceUpdateButton" onclick="handleWorkspaceUpdateAction()" aria-describedby="updateText" aria-controls="workspaceUpdatePopover" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"></path><path d="m8 11 4 4 4-4"></path><path d="M4 19h16"></path></svg>
<span id="workspaceUpdateLabel">Update</span>
</button>
<div class="workspace-update-popover" role="status" aria-live="polite">
<span id="updateText">Nach Updates suchen.</span>
<div class="workspace-update-popover" id="workspaceUpdatePopover">
<div class="workspace-update-popover-header">
<span id="updateText" role="status" aria-live="polite">Nach Updates suchen.</span>
<button type="button" id="workspaceUpdateDismiss" onclick="dismissWorkspaceUpdatePopover()" aria-label="Update notification dismiss">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"></path></svg>
<span class="sr-only" id="workspaceUpdateDismissText">Close</span>
</button>
</div>
<div id="updateProgress" class="update-banner-progress-wrap is-hidden">
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
<div id="updateProgressBar" class="update-banner-progress-bar"></div>
</div>
</div>
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
<div class="workspace-update-popover-actions">
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
<button type="button" id="workspaceUpdateLater" onclick="postponeWorkspaceUpdatePopover()">Spater</button>
</div>
</div>
</div>
<button type="button" class="topbar-icon-button" onclick="showTab('settings')" aria-label="System status" title="System status">
<button type="button" class="topbar-icon-button" id="systemStatusButton" onclick="showTab('settings')" aria-label="System status" title="System status">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l2-5 4 10 2-5h6"></path></svg>
</button>
<button type="button" class="topbar-account-button" onclick="showTab('settings')" aria-label="Open settings" title="Open settings">
<button type="button" class="topbar-account-button" id="openSettingsButton" onclick="showTab('settings')" aria-label="Open settings" title="Open settings">
<span>TV</span>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 10 4 4 4-4"></path></svg>
</button>
@@ -240,8 +249,8 @@
<aside class="sidebar context-sidebar" aria-label="Workspace context">
<section class="context-panel" data-context-for="vods">
<div class="context-switcher" role="group" aria-label="VOD workspace">
<button type="button" class="active" onclick="focusWorkspaceTarget('streamerList')">Streamer</button>
<button type="button" onclick="focusWorkspaceTarget('queueList')">Queue</button>
<button type="button" id="streamerWorkspaceSwitch" class="active" aria-pressed="true" onclick="focusWorkspaceTarget('streamerList', this)">Streamer</button>
<button type="button" id="queueWorkspaceSwitch" aria-pressed="false" onclick="focusWorkspaceTarget('queueList', this)">Warteschlange</button>
</div>
<div class="section-title" id="streamerSectionTitle">
@@ -261,10 +270,10 @@
</div>
<div class="queue-list" id="queueList"></div>
<div class="queue-actions">
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()" disabled>Start</button>
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge &amp; Split</button>
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()" disabled>Leeren</button>
</div>
</div>
<div class="stats-bar" id="statsBar"></div>
@@ -273,58 +282,58 @@
<section class="context-panel" data-context-for="clips" hidden>
<div class="context-panel-heading" data-context-heading>Clips</div>
<nav class="context-list" aria-label="Clip sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('clipUrl')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h11a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4zM17 10l4-2v8l-4-2"></path></svg><span data-label-source="clipsHeading">Clip-Download</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('clipsInfoTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v6M12 7h.01"></path></svg><span data-label-source="clipsInfoTitle">Info</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('clipUrl', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h11a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4zM17 10l4-2v8l-4-2"></path></svg><span data-label-source="clipsHeading">Clip-Download</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('clipsInfoTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v6M12 7h.01"></path></svg><span data-label-source="clipsInfoTitle">Info</span></button>
</nav>
</section>
<section class="context-panel" data-context-for="cutter" hidden>
<div class="context-panel-heading" data-context-heading>Video schneiden</div>
<nav class="context-list" aria-label="Cutter sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswahlen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('cutterBrowseBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterSelectTitle">Video auswahlen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('timelineContainer', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 12h16M7 8v8M17 8v8"></path></svg><span data-label-source="cutterInfoSelectionLabel">Auswahl</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnCut', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg><span data-label-source="btnCut">Schneiden</span></button>
</nav>
</section>
<section class="context-panel" data-context-for="merge" hidden>
<div class="context-panel-heading" data-context-heading>Zusammenfugen</div>
<nav class="context-list" aria-label="Merge sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('mergeAddBtn')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4zM12 9v6M9 12h6"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('mergeFileList')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h14"></path></svg><span data-label-source="mergeTitle">Videos zusammenfugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnMerge')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg><span data-label-source="btnMerge">Zusammenfugen</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('mergeAddBtn', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 6h16v12H4zM12 9v6M9 12h6"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('mergeFileList', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h14"></path></svg><span data-label-source="mergeTitle">Videos zusammenfugen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('btnMerge', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg><span data-label-source="btnMerge">Zusammenfugen</span></button>
</nav>
</section>
<section class="context-panel" data-context-for="stats" hidden>
<div class="context-panel-heading" data-context-heading>Statistik</div>
<nav class="context-list" aria-label="Statistics sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('statsSummaryTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="statsSummaryTitle">Uebersicht</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsTopStreamersTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h8"></path></svg><span data-label-source="statsTopStreamersTitle">Top Streamer</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsActivityTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m3 17 5-5 4 3 7-8 2 2"></path></svg><span data-label-source="statsActivityTitle">Aktivitaet</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsSizeBucketsTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 3v9h9"></path></svg><span data-label-source="statsSizeBucketsTitle">Groessen-Verteilung</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('statsSummaryTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 19V9M10 19V5M16 19v-7M22 19V3"></path></svg><span data-label-source="statsSummaryTitle">Uebersicht</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsTopStreamersTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 7h16M4 12h12M4 17h8"></path></svg><span data-label-source="statsTopStreamersTitle">Top Streamer</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsActivityTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m3 17 5-5 4 3 7-8 2 2"></path></svg><span data-label-source="statsActivityTitle">Aktivitaet</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('statsSizeBucketsTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 3v9h9"></path></svg><span data-label-source="statsSizeBucketsTitle">Groessen-Verteilung</span></button>
</nav>
</section>
<section class="context-panel" data-context-for="archive" hidden>
<div class="context-panel-heading" data-context-heading>Archiv</div>
<nav class="context-list" aria-label="Archive sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('archiveSearchQuery')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg><span data-label-source="archiveTitle">Archiv durchsuchen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchType')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 5h16M7 12h10M10 19h4"></path></svg><span data-label-source="archiveSearchType">Filter</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchResults')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 4h16v16H4zM8 8h8M8 12h8M8 16h5"></path></svg><span>Ergebnisse</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('archiveSearchQuery', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg><span data-label-source="archiveTitle">Archiv durchsuchen</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchType', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 5h16M7 12h10M10 19h4"></path></svg><span data-label-source="archiveSearchType">Filter</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('archiveSearchResults', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 4h16v16H4zM8 8h8M8 12h8M8 16h5"></path></svg><span id="archiveResultsNavText">Ergebnisse</span></button>
</nav>
</section>
<section class="context-panel" data-context-for="settings" hidden>
<div class="context-panel-heading" data-context-heading>Einstellungen</div>
<nav class="context-list" aria-label="Settings sections">
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('designTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"></path></svg><span data-label-source="designTitle">Design</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('apiTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M8 12h8M12 8v8"></path><circle cx="12" cy="12" r="9"></circle></svg><span data-label-source="apiTitle">Twitch API</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('downloadSettingsTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg><span data-label-source="downloadSettingsTitle">Downloads</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('updateTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 19h16"></path></svg><span data-label-source="updateTitle">Updates</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('preflightTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 5 3.4 8 8 9 4.6-1 8-4 8-9V7z"></path><path d="m9 12 2 2 4-4"></path></svg><span data-label-source="preflightTitle">System-Check</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('debugLogTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h14v14H5zM8 9l2 2-2 2M12 15h4"></path></svg><span data-label-source="debugLogTitle">Debug-Log</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('storageCardTitle')"><svg aria-hidden="true" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3"></ellipse><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg><span data-label-source="storageCardTitle">Storage</span></button>
<button type="button" class="context-link active" onclick="focusWorkspaceTarget('designTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"></path></svg><span data-label-source="designTitle">Design</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('apiTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M8 12h8M12 8v8"></path><circle cx="12" cy="12" r="9"></circle></svg><span data-label-source="apiTitle">Twitch API</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('downloadSettingsTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg><span data-label-source="downloadSettingsTitle">Downloads</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('updateTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 19h16"></path></svg><span data-label-source="updateTitle">Updates</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('preflightTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3 4 7v5c0 5 3.4 8 8 9 4.6-1 8-4 8-9V7z"></path><path d="m9 12 2 2 4-4"></path></svg><span data-label-source="preflightTitle">System-Check</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('debugLogTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h14v14H5zM8 9l2 2-2 2M12 15h4"></path></svg><span data-label-source="debugLogTitle">Debug-Log</span></button>
<button type="button" class="context-link" onclick="focusWorkspaceTarget('storageCardTitle', this)"><svg aria-hidden="true" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3"></ellipse><path d="M4 6v6c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"></path></svg><span data-label-source="storageCardTitle">Storage</span></button>
</nav>
</section>
</aside>
@@ -333,34 +342,37 @@
<header class="header workspace-toolbar">
<div class="toolbar-context" data-toolbar-for="vods">
<button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('newStreamer')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span>Streamer</span></button>
<button type="button" class="toolbar-icon-button" onclick="refreshVODs()" aria-label="Refresh VODs" title="Refresh VODs"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="toolbarRefreshVodsBtn" onclick="refreshVODs()" aria-label="Refresh VODs" title="Refresh VODs"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="clips" hidden>
<button type="button" class="toolbar-primary" onclick="focusWorkspaceTarget('clipUrl')"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="clipsHeading">Clip</span></button>
<button type="button" class="toolbar-icon-button" onclick="downloadClip()" aria-label="Download clip" title="Download clip"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="toolbarClipDownloadBtn" onclick="downloadClip()" aria-label="Download clip" title="Download clip"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4M4 20h16"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="cutter" hidden>
<button type="button" class="toolbar-primary" onclick="selectCutterVideo()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"></path></svg><span data-label-source="cutterBrowseBtn">Durchsuchen</span></button>
<button type="button" class="toolbar-icon-button" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="toolbarCutBtn" onclick="startCutting()" aria-label="Cut video" title="Cut video"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="6" cy="7" r="3"></circle><circle cx="6" cy="17" r="3"></circle><path d="m8.5 8.5 11 7M8.5 15.5 20 8"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="merge" hidden>
<button type="button" class="toolbar-primary" onclick="addMergeFiles()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"></path></svg><span data-label-source="mergeAddBtn">Videos hinzufugen</span></button>
<button type="button" class="toolbar-icon-button" onclick="startMerging()" aria-label="Merge videos" title="Merge videos"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg></button>
<button type="button" class="toolbar-icon-button" id="toolbarMergeBtn" onclick="startMerging()" aria-label="Merge videos" title="Merge videos"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 5h4a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h4M16 16l3 3-3 3"></path></svg></button>
</div>
<div class="toolbar-context" data-toolbar-for="stats" hidden>
<button type="button" class="toolbar-primary" onclick="refreshArchiveStats()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg><span data-label-source="btnStatsRefresh">Aktualisieren</span></button>
<button type="button" class="toolbar-primary" id="toolbarStatsRefreshBtn" onclick="refreshArchiveStats()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg><span data-label-source="btnStatsRefresh">Aktualisieren</span></button>
</div>
<div class="toolbar-context" data-toolbar-for="archive" hidden>
<button type="button" class="toolbar-primary" onclick="performArchiveSearch()"><svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg><span data-label-source="btnArchiveSearch">Suchen</span></button>
</div>
<div class="toolbar-context" data-toolbar-for="settings" hidden>
<button type="button" class="toolbar-primary" onclick="saveSettings()"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 4h12l2 2v14H5zM8 4v6h8V4M8 20v-6h8v6"></path></svg><span data-label-source="saveSettingsBtn">Speichern</span></button>
<button type="button" class="toolbar-icon-button" onclick="checkUpdate()" aria-label="Check for updates" title="Check for updates"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button>
<label class="workspace-settings-search" for="settingsSearchInput">
<svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"></circle><path d="m15 15 5 5"></path></svg>
<input type="search" id="settingsSearchInput" placeholder="Search settings…" oninput="filterSettings(this.value)" autocomplete="off">
</label>
<button type="button" class="toolbar-icon-button" id="toolbarCheckUpdateBtn" onclick="checkUpdate()" aria-label="Check for updates" title="Check for updates"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6 7M5.5 15A7 7 0 0 0 18 17"></path></svg></button>
</div>
<h1 class="workspace-title" id="pageTitle">VODs</h1>
<div class="header-actions workspace-toolbar-actions" data-toolbar-search-for="vods">
<div class="header-search workspace-search">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen" onkeypress="if(event.key==='Enter')addStreamer()">
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
</div>
<span id="refreshText" class="sr-only">Aktualisieren</span>
@@ -398,7 +410,7 @@
</div>
<div class="vod-grid" id="vodGrid">
<div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h8l4 4v16H6z"></path><path d="M14 2v5h5M10 11l5 3-5 3z"></path></svg>
<h3 id="vodGridEmptyTitle">Keine VODs</h3>
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
</div>
@@ -604,11 +616,11 @@
</button>
<button type="button" class="workspace-theme-choice active" data-theme="twitch" aria-pressed="true" onclick="selectWorkspaceTheme('twitch')">
<span class="workspace-theme-preview theme-preview-dark" aria-hidden="true"><span></span><span></span><span></span></span>
<span>Dark</span>
<span id="themeDarkOption">Dark</span>
</button>
<button type="button" class="workspace-theme-choice" data-theme="system" aria-pressed="false" onclick="selectWorkspaceTheme('system')">
<span class="workspace-theme-preview theme-preview-system" aria-hidden="true"><span></span><span></span><span></span></span>
<span>System</span>
<span id="themeSystemOption">System</span>
</button>
</div>
<select id="themeSelect" class="theme-select-fallback" onchange="changeTheme(this.value)">
@@ -662,7 +674,7 @@
<label id="storageLabel" for="downloadPath">Speicherort</label>
<div class="form-row">
<input type="text" id="downloadPath" readonly>
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="selectFolderBtn" onclick="selectFolder()">Ordner</button>
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
</div>
</div>
@@ -785,7 +797,7 @@
<div class="settings-card">
<h3 id="updateTitle">Updates</h3>
<p id="versionInfo" class="card-intro">Version: v1.0.1</p>
<p id="versionInfo" class="card-intro">Version: v1.0.2</p>
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
</div>
@@ -938,7 +950,7 @@
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
<div class="modal command-palette">
<h2 id="commandPaletteTitle" class="cp-title">Command Palette</h2>
<h2 id="commandPaletteTitle" class="cp-title">Befehlspalette</h2>
<input
type="text"
id="commandPaletteInput"
@@ -946,9 +958,9 @@
placeholder="Suche Befehl..."
autocomplete="off"
spellcheck="false"
aria-label="Command Palette"
aria-label="Befehlspalette"
/>
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Command results"></ul>
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Befehlsergebnisse"></ul>
<p class="cp-hint" id="commandPaletteHint">Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen</p>
</div>
</div>
+2 -2
View File
@@ -2470,8 +2470,8 @@ async function getVodStoryboard(vodId: string): Promise<VodStoryboard | null> {
return null;
}
// The manifest URL points at e.g. .../storyboards/2767872722-info.json
// and sprite filenames are relative (e.g. "2767872722-high-0.jpg").
// The manifest URL points at e.g. .../storyboards/{vodId}-info.json
// and sprite filenames are relative (e.g. "{vodId}-high-0.jpg").
// Strip the JSON filename to get the base, then append the sprite.
const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/');
const firstSpriteUrl = baseUrl + entry.images[0];
View File
View File
+2 -2
View File
@@ -56,7 +56,7 @@ async function performArchiveSearch(): Promise<void> {
const result = await window.api.searchArchive(filter);
renderArchiveSearchResults(result);
} catch (e) {
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
if (summaryEl) summaryEl.textContent = `${UI_TEXT.static.errorPrefix}: ${String(e)}`;
applyHtml(resultsEl, '');
} finally {
archiveSearchInFlight = false;
@@ -91,7 +91,7 @@ function renderArchiveSearchResults(result: ArchiveSearchResult): void {
}
const rows = result.hits.map((hit) => {
const date = new Date(hit.mtimeMs).toLocaleString();
const date = formatUiDateTime(new Date(hit.mtimeMs));
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const chatBtn = hit.chatPath
+13 -11
View File
@@ -30,20 +30,22 @@ interface PaletteCommand {
// hint 'Open' statt 'Tab' — 'Tab' las sich wie eine Tastatur-Taste
// ('druecke Tab') statt 'oeffnet diesen Tab'.
const tabs: Array<{ id: string; labels: string[]; hint: string }> = [
{ id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Open' },
{ id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Open' },
{ id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Open' },
{ id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Open' },
{ id: 'archive', labels: ['Archive', 'archiv'], hint: 'Open' },
{ id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Open' },
const commandText = UI_TEXT.static.commandPaletteCommands;
const tabs: Array<{ id: string; label: string; keywords: string }> = [
{ id: 'vods', ...commandText.vods },
{ id: 'clips', ...commandText.clips },
{ id: 'cutter', ...commandText.cutter },
{ id: 'merge', ...commandText.merge },
{ id: 'stats', ...commandText.stats },
{ id: 'archive', ...commandText.archive },
{ id: 'settings', ...commandText.settings },
];
const tabCommands: PaletteCommand[] = tabs.map(t => ({
id: 'tab:' + t.id,
label: t.labels[0],
hint: t.hint,
keywords: t.labels.join(' ').toLowerCase(),
label: t.label,
hint: UI_TEXT.static.commandPaletteOpenHint,
keywords: `${t.label} ${t.keywords}`.toLowerCase(),
action: () => showTab(t.id),
}));
@@ -58,7 +60,7 @@ interface PaletteCommand {
streamerCommands.push({
id: 'streamer:' + name.toLowerCase(),
label: name,
hint: 'Streamer',
hint: UI_TEXT.static.commandPaletteStreamerHint,
keywords: ('@' + name + ' ' + name).toLowerCase(),
action: () => {
showTab('vods');
+40 -3
View File
@@ -13,15 +13,46 @@ const UI_TEXT_DE = {
healthGood: 'System: Stabil',
healthWarn: 'System: Warnung',
healthBad: 'System: Problem',
systemStatus: 'Systemstatus',
openSettings: 'Einstellungen offnen',
refreshVods: 'VODs aktualisieren',
downloadClip: 'Clip herunterladen',
mainNavigationAria: 'Hauptnavigation',
workspaceContextAria: 'Arbeitsbereichskontext',
vodWorkspaceAria: 'VOD-Arbeitsbereich',
clipSectionsAria: 'Clip-Bereiche',
cutterSectionsAria: 'Videoschnitt-Bereiche',
mergeSectionsAria: 'Zusammenfügen-Bereiche',
statisticsSectionsAria: 'Statistik-Bereiche',
archiveSectionsAria: 'Archiv-Bereiche',
settingsSectionsAria: 'Einstellungsbereiche',
cutVideoAction: 'Video schneiden',
mergeVideosAction: 'Videos zusammenfügen',
errorPrefix: 'Fehler',
settingsSearchPlaceholder: 'Einstellungen durchsuchen…',
clearQueue: 'Leeren',
refresh: 'Aktualisieren',
streamerPlaceholder: 'Streamer hinzufugen...',
streamerPlaceholder: 'Streamer hinzufugen',
clipsHeading: 'Twitch Clip-Download',
clipsInfoTitle: 'Info',
clipsInfoText: 'Unterstutzte Formate:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips werden im Download-Ordner unter "Clips/StreamerName/" gespeichert.',
cutterSelectTitle: 'Video auswahlen',
cutterPreviewPlaceholder: 'Video auswahlen um Vorschau zu sehen',
cutterBrowse: 'Durchsuchen',
commandPaletteTitle: 'Befehlspalette',
commandPaletteAria: 'Befehlspalette',
commandPaletteResultsAria: 'Befehlsergebnisse',
commandPaletteOpenHint: 'Öffnen',
commandPaletteStreamerHint: 'Streamer',
commandPaletteCommands: {
vods: { label: 'VODs', keywords: 'vod videos streams' },
clips: { label: 'Clips', keywords: 'clips clip-download' },
cutter: { label: 'Videoschnitt', keywords: 'videoschnitt schneiden cutter' },
merge: { label: 'Videos zusammenfügen', keywords: 'zusammenfügen videos merge' },
stats: { label: 'Statistiken', keywords: 'statistiken stats dashboard' },
archive: { label: 'Archiv', keywords: 'archiv archive' },
settings: { label: 'Einstellungen', keywords: 'einstellungen settings config' }
},
commandPaletteSearchPlaceholder: 'Befehl suchen...',
commandPaletteHint: 'Up/Down zum Navigieren, Enter zum Ausfuehren, Esc zum Schliessen',
mergeTitle: 'Videos zusammenfugen',
@@ -30,6 +61,8 @@ const UI_TEXT_DE = {
designTitle: 'Design',
themeLabel: 'Theme',
themeLight: 'Hell',
themeDark: 'Dunkel',
themeSystem: 'System',
languageLabel: 'Sprache',
languageDe: 'Deutsch',
languageEn: 'Englisch',
@@ -39,6 +72,7 @@ const UI_TEXT_DE = {
saveSettings: 'Speichern & Verbinden',
downloadSettingsTitle: 'Download-Einstellungen',
storageLabel: 'Speicherort',
selectFolder: 'Ordner',
openFolder: 'Offnen',
modeLabel: 'Download-Modus',
modeFull: 'Ganzes VOD',
@@ -141,8 +175,9 @@ const UI_TEXT_DE = {
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)',
archiveNoMatches: 'Keine Treffer.',
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
archiveSearchPlaceholder: 'Suche...',
archiveSearchPlaceholder: 'Suche',
archiveSearchAria: 'Archiv durchsuchen',
archiveResults: 'Ergebnisse',
archiveOpen: 'Oeffnen',
archiveShowInFolder: 'Ordner',
archiveViewChat: 'Chat',
@@ -461,7 +496,7 @@ const UI_TEXT_DE = {
infoSelection: 'Auswahl',
startLabel: 'Start:',
endLabel: 'Ende:',
filePathPlaceholder: 'Keine Datei ausgewaehlt...'
filePathPlaceholder: 'Keine Datei ausgewaehlt'
},
merge: {
empty: 'Keine Videos ausgewahlt',
@@ -504,6 +539,8 @@ const UI_TEXT_DE = {
modalReadyTitle: 'Update bereit',
modalReadyMessage: 'Version {version} wurde heruntergeladen. Jetzt installieren und neu starten?',
modalDismiss: 'Nein',
later: 'Später',
dismissAria: 'Schließen',
modalDownloadConfirm: 'Ja, herunterladen',
modalInstallConfirm: 'Ja, installieren',
modalSkipVersion: 'Diese Version ueberspringen',
+40 -3
View File
@@ -13,15 +13,46 @@ const UI_TEXT_EN = {
healthGood: 'System: Stable',
healthWarn: 'System: Warning',
healthBad: 'System: Problem',
systemStatus: 'System status',
openSettings: 'Open settings',
refreshVods: 'Refresh VODs',
downloadClip: 'Download clip',
mainNavigationAria: 'Main navigation',
workspaceContextAria: 'Workspace context',
vodWorkspaceAria: 'VOD workspace',
clipSectionsAria: 'Clip sections',
cutterSectionsAria: 'Video cutter sections',
mergeSectionsAria: 'Video merge sections',
statisticsSectionsAria: 'Statistics sections',
archiveSectionsAria: 'Archive sections',
settingsSectionsAria: 'Settings sections',
cutVideoAction: 'Cut video',
mergeVideosAction: 'Merge videos',
errorPrefix: 'Error',
settingsSearchPlaceholder: 'Search settings…',
clearQueue: 'Clear',
refresh: 'Refresh',
streamerPlaceholder: 'Add streamer...',
streamerPlaceholder: 'Add streamer',
clipsHeading: 'Twitch Clip Download',
clipsInfoTitle: 'Info',
clipsInfoText: 'Supported formats:\n- https://clips.twitch.tv/ClipName\n- https://www.twitch.tv/streamer/clip/ClipName\n\nClips are saved in your download folder under "Clips/StreamerName/".',
cutterSelectTitle: 'Select video',
cutterPreviewPlaceholder: 'Select a video to see a preview',
cutterBrowse: 'Browse',
commandPaletteTitle: 'Command palette',
commandPaletteAria: 'Command palette',
commandPaletteResultsAria: 'Command results',
commandPaletteOpenHint: 'Open',
commandPaletteStreamerHint: 'Streamer',
commandPaletteCommands: {
vods: { label: 'VODs', keywords: 'vod videos streams' },
clips: { label: 'Clips', keywords: 'clips clip download' },
cutter: { label: 'Video cutter', keywords: 'video cutter trim schneiden' },
merge: { label: 'Merge videos', keywords: 'merge videos zusammenfügen' },
stats: { label: 'Statistics', keywords: 'statistics stats dashboard' },
archive: { label: 'Archive', keywords: 'archive archiv' },
settings: { label: 'Settings', keywords: 'settings configuration einstellungen' }
},
commandPaletteSearchPlaceholder: 'Search command...',
commandPaletteHint: 'Up/Down to navigate, Enter to run, Esc to close',
mergeTitle: 'Merge videos',
@@ -30,6 +61,8 @@ const UI_TEXT_EN = {
designTitle: 'Design',
themeLabel: 'Theme',
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'System',
languageLabel: 'Language',
languageDe: 'German',
languageEn: 'English',
@@ -39,6 +72,7 @@ const UI_TEXT_EN = {
saveSettings: 'Save & Connect',
downloadSettingsTitle: 'Download Settings',
storageLabel: 'Storage Path',
selectFolder: 'Folder',
openFolder: 'Open',
modeLabel: 'Download Mode',
modeFull: 'Full VOD',
@@ -142,8 +176,9 @@ const UI_TEXT_EN = {
archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)',
archiveNoMatches: 'No matches.',
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
archiveSearchPlaceholder: 'Search...',
archiveSearchPlaceholder: 'Search',
archiveSearchAria: 'Search archive',
archiveResults: 'Results',
archiveOpen: 'Open',
archiveShowInFolder: 'Folder',
archiveViewChat: 'Chat',
@@ -461,7 +496,7 @@ const UI_TEXT_EN = {
infoSelection: 'Selection',
startLabel: 'Start:',
endLabel: 'End:',
filePathPlaceholder: 'No file selected...'
filePathPlaceholder: 'No file selected'
},
merge: {
empty: 'No videos selected',
@@ -504,6 +539,8 @@ const UI_TEXT_EN = {
modalReadyTitle: 'Update ready',
modalReadyMessage: 'Version {version} has been downloaded. Install and restart now?',
modalDismiss: 'No',
later: 'Later',
dismissAria: 'Close',
modalDownloadConfirm: 'Yes, download',
modalInstallConfirm: 'Yes, install',
modalSkipVersion: 'Skip this version',
+5 -1
View File
@@ -484,8 +484,12 @@ function renderQueue(): void {
const list = byId('queueList');
byId('queueCount').textContent = String(queue.length);
const retryBtn = byId<HTMLButtonElement>('btnRetryFailed');
const clearBtn = byId<HTMLButtonElement>('btnClear');
const hasFailed = queue.some((item) => item.status === 'error');
const hasCompleted = queue.some((item) => item.status === 'completed');
retryBtn.disabled = !hasFailed;
clearBtn.disabled = !hasCompleted;
updateDownloadButtonState();
const renderFingerprint = getQueueRenderFingerprint(queue);
if (renderFingerprint === lastQueueRenderFingerprint) {
@@ -556,7 +560,7 @@ function renderQueue(): void {
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(new Date(item.date).toLocaleString())}</div>
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(formatUiDateTime(item.date))}</div>
${renderQueueItemFileActions(item)}
</div>
</div>
+9
View File
@@ -179,6 +179,14 @@ function updateStatus(text: string, connected: boolean): void {
dot.classList.add(connected ? 'connected' : 'error');
}
function filterSettings(query: string): void {
const normalizedQuery = query.trim().toLocaleLowerCase(getIntlLocale());
document.querySelectorAll<HTMLElement>('#settingsTab .settings-card').forEach((card) => {
const searchableText = (card.textContent || '').toLocaleLowerCase(getIntlLocale());
card.hidden = normalizedQuery.length > 0 && !searchableText.includes(normalizedQuery);
});
}
function changeLanguage(lang: string): void {
const normalized = setLanguage(lang);
byId<HTMLSelectElement>('languageSelect').value = normalized;
@@ -208,6 +216,7 @@ function changeLanguage(lang: string): void {
void refreshRuntimeMetrics();
void refreshAutomationStatusLine();
validateFilenameTemplates();
filterSettings(byId<HTMLInputElement>('settingsSearchInput').value);
}
function updateLanguagePicker(lang: string): void {
+11 -2
View File
@@ -1,6 +1,13 @@
let archiveStatsRefreshInFlight = false;
async function refreshArchiveStats(): Promise<void> {
if (archiveStatsRefreshInFlight) return;
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
const toolbarBtn = document.getElementById('toolbarStatsRefreshBtn') as HTMLButtonElement | null;
archiveStatsRefreshInFlight = true;
if (btn) btn.disabled = true;
if (toolbarBtn) toolbarBtn.disabled = true;
const lastLabel = document.getElementById('statsLastScannedLabel');
if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...';
@@ -9,9 +16,11 @@ async function refreshArchiveStats(): Promise<void> {
renderArchiveStats(stats);
} catch (e) {
const summary = document.getElementById('statsSummaryGrid');
if (summary) summary.textContent = `Fehler: ${String(e)}`;
if (summary) summary.textContent = `${UI_TEXT.static.errorPrefix}: ${String(e)}`;
} finally {
archiveStatsRefreshInFlight = false;
if (btn) btn.disabled = false;
if (toolbarBtn) toolbarBtn.disabled = false;
}
}
@@ -19,7 +28,7 @@ function renderArchiveStats(stats: ArchiveStats): void {
const lastLabel = document.getElementById('statsLastScannedLabel');
if (lastLabel) {
const dt = new Date(stats.scannedAt);
lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${dt.toLocaleString()}`;
lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${formatUiDateTime(dt)}`;
}
renderStatsSummary(stats);
+20 -9
View File
@@ -726,13 +726,7 @@ async function removeStreamer(name: string): Promise<void> {
currentStreamer = null;
const hide = (window as unknown as { hideStreamerProfileHeader?: () => void }).hideStreamerProfileHeader;
if (typeof hide === 'function') hide();
byId('vodGrid').innerHTML = `
<div class="empty-state">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
<h3>${UI_TEXT.vods.noneTitle}</h3>
<p>${UI_TEXT.vods.noneText}</p>
</div>
`;
setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
}
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
@@ -803,6 +797,24 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
renderVODs(vods, name);
}
function createVodEmptyStateIcon(): SVGSVGElement {
const namespace = 'http://www.w3.org/2000/svg';
const icon = document.createElementNS(namespace, 'svg');
icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('viewBox', '0 0 24 24');
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '1.5');
icon.setAttribute('stroke-linecap', 'round');
icon.setAttribute('stroke-linejoin', 'round');
const page = document.createElementNS(namespace, 'path');
page.setAttribute('d', 'M6 2h8l4 4v16H6zM14 2v5h5');
const play = document.createElementNS(namespace, 'path');
play.setAttribute('d', 'M10 11l5 3-5 3z');
icon.append(page, play);
return icon;
}
function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void {
// Build via DOM API so the (locale-only) strings can never escape into HTML.
const wrap = document.createElement('div');
@@ -811,8 +823,7 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v
h3.textContent = title;
const p = document.createElement('p');
p.textContent = text;
wrap.appendChild(h3);
wrap.appendChild(p);
wrap.append(createVodEmptyStateIcon(), h3, p);
grid.replaceChildren(wrap);
}
+56
View File
@@ -17,6 +17,11 @@ function formatUiDate(input: string | Date): string {
return date.toLocaleDateString(getIntlLocale());
}
function formatUiDateTime(input: string | Date): string {
const date = input instanceof Date ? input : new Date(input);
return date.toLocaleString(getIntlLocale());
}
function formatUiNumber(value: number): string {
return value.toLocaleString(getIntlLocale());
}
@@ -62,6 +67,33 @@ function applyLanguageToStaticUI(): void {
setText('navMergeText', UI_TEXT.static.navMerge);
setText('navStatsText', UI_TEXT.static.navStats);
setText('navArchiveText', UI_TEXT.static.navArchive);
setAriaLabelAll('.top-nav', UI_TEXT.static.mainNavigationAria);
setAriaLabelAll('.context-sidebar', UI_TEXT.static.workspaceContextAria);
setAriaLabelAll('[data-context-for="vods"] .context-switcher', UI_TEXT.static.vodWorkspaceAria);
setAriaLabelAll('[data-context-for="clips"] .context-list', UI_TEXT.static.clipSectionsAria);
setAriaLabelAll('[data-context-for="cutter"] .context-list', UI_TEXT.static.cutterSectionsAria);
setAriaLabelAll('[data-context-for="merge"] .context-list', UI_TEXT.static.mergeSectionsAria);
setAriaLabelAll('[data-context-for="stats"] .context-list', UI_TEXT.static.statisticsSectionsAria);
setAriaLabelAll('[data-context-for="archive"] .context-list', UI_TEXT.static.archiveSectionsAria);
setAriaLabelAll('[data-context-for="settings"] .context-list', UI_TEXT.static.settingsSectionsAria);
setAriaLabel('toolbarCutBtn', UI_TEXT.static.cutVideoAction);
setTitle('toolbarCutBtn', UI_TEXT.static.cutVideoAction);
setAriaLabel('toolbarMergeBtn', UI_TEXT.static.mergeVideosAction);
setTitle('toolbarMergeBtn', UI_TEXT.static.mergeVideosAction);
document.querySelectorAll<HTMLButtonElement>('.top-nav [data-tab]').forEach((button) => {
const tab = button.dataset.tab;
const titles: Record<string, string> = {
vods: UI_TEXT.static.navVods,
clips: UI_TEXT.static.navClips,
cutter: UI_TEXT.static.navCutter,
merge: UI_TEXT.static.navMerge,
stats: UI_TEXT.static.navStats,
archive: UI_TEXT.static.navArchive,
settings: UI_TEXT.static.navSettings
};
button.title = tab ? titles[tab] || '' : '';
});
setText('archiveResultsNavText', UI_TEXT.static.archiveResults);
setText('archiveTitle', UI_TEXT.static.archiveTitle);
setText('archiveIntro', UI_TEXT.static.archiveIntro);
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
@@ -94,6 +126,8 @@ function applyLanguageToStaticUI(): void {
setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle);
setText('btnStatsRefresh', UI_TEXT.static.statsRefresh);
setText('queueTitleText', UI_TEXT.static.queueTitle);
setText('streamerWorkspaceSwitch', UI_TEXT.static.streamerSectionTitle);
setText('queueWorkspaceSwitch', UI_TEXT.static.queueTitle);
setText('healthBadge', UI_TEXT.static.healthUnknown);
setText('btnRetryFailed', UI_TEXT.static.retryFailed);
setTitle('btnRetryFailed', UI_TEXT.static.retryFailedHint);
@@ -120,6 +154,9 @@ function applyLanguageToStaticUI(): void {
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
setText('cutterPreviewPlaceholder', UI_TEXT.static.cutterPreviewPlaceholder);
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
setText('commandPaletteTitle', UI_TEXT.static.commandPaletteTitle);
setAriaLabel('commandPaletteInput', UI_TEXT.static.commandPaletteAria);
setAriaLabel('commandPaletteList', UI_TEXT.static.commandPaletteResultsAria);
setPlaceholder('commandPaletteInput', UI_TEXT.static.commandPaletteSearchPlaceholder);
setText('commandPaletteHint', UI_TEXT.static.commandPaletteHint);
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
@@ -136,6 +173,8 @@ function applyLanguageToStaticUI(): void {
setText('designTitle', UI_TEXT.static.designTitle);
setText('themeLabel', UI_TEXT.static.themeLabel);
setText('themeLightOption', UI_TEXT.static.themeLight);
setText('themeDarkOption', UI_TEXT.static.themeDark);
setText('themeSystemOption', UI_TEXT.static.themeSystem);
setText('languageLabel', UI_TEXT.static.languageLabel);
setText('languageDeText', UI_TEXT.static.languageDe);
setText('languageEnText', UI_TEXT.static.languageEn);
@@ -147,6 +186,7 @@ function applyLanguageToStaticUI(): void {
setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
setText('downloadSettingsTitle', UI_TEXT.static.downloadSettingsTitle);
setText('storageLabel', UI_TEXT.static.storageLabel);
setText('selectFolderBtn', UI_TEXT.static.selectFolder);
setText('openFolderBtn', UI_TEXT.static.openFolder);
setText('modeLabel', UI_TEXT.static.modeLabel);
setText('modeFullText', UI_TEXT.static.modeFull);
@@ -290,6 +330,10 @@ function applyLanguageToStaticUI(): void {
setText('runtimeMetricsOutput', UI_TEXT.static.runtimeMetricsLoading);
setText('updateText', UI_TEXT.static.checkUpdates);
setText('updateButton', UI_TEXT.updates.downloadNow);
setText('workspaceUpdateLater', UI_TEXT.updates.later);
setText('workspaceUpdateDismissText', UI_TEXT.updates.dismissAria);
setAriaLabel('workspaceUpdateDismiss', UI_TEXT.updates.dismissAria);
setTitle('workspaceUpdateDismiss', UI_TEXT.updates.dismissAria);
setText('updateModalEyebrow', UI_TEXT.static.updateTitle);
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
@@ -306,6 +350,18 @@ function applyLanguageToStaticUI(): void {
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
setPlaceholder('settingsSearchInput', UI_TEXT.static.settingsSearchPlaceholder);
setAriaLabel('settingsSearchInput', UI_TEXT.static.settingsSearchPlaceholder);
setAriaLabel('systemStatusButton', UI_TEXT.static.systemStatus);
setTitle('systemStatusButton', UI_TEXT.static.systemStatus);
setAriaLabel('openSettingsButton', UI_TEXT.static.openSettings);
setTitle('openSettingsButton', UI_TEXT.static.openSettings);
setAriaLabel('toolbarRefreshVodsBtn', UI_TEXT.static.refreshVods);
setTitle('toolbarRefreshVodsBtn', UI_TEXT.static.refreshVods);
setAriaLabel('toolbarClipDownloadBtn', UI_TEXT.static.downloadClip);
setTitle('toolbarClipDownloadBtn', UI_TEXT.static.downloadClip);
setAriaLabel('toolbarCheckUpdateBtn', UI_TEXT.static.checkUpdates);
setTitle('toolbarCheckUpdateBtn', UI_TEXT.static.checkUpdates);
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
if (typeof refreshVodSortSelectLabels === 'function') {
refreshVodSortSelectLabels();
+53 -5
View File
@@ -8,6 +8,7 @@ let latestDownloadProgress: UpdateDownloadProgress | null = null;
let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
let updateChangelogExpanded = false;
let shouldOpenUpdateModalOnAvailable = false;
let workspaceUpdatePopoverPostponed = false;
const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version';
@@ -98,6 +99,15 @@ function setCheckButtonCheckingState(enabled: boolean): void {
}
}
function syncWorkspaceUpdateExpansion(): void {
const banner = byId('updateBanner');
const expanded = banner.classList.contains('show')
&& !banner.classList.contains('popover-dismissed')
&& !workspaceUpdatePopoverPostponed
&& (banner.matches(':hover') || banner.matches(':focus-within'));
byId<HTMLButtonElement>('workspaceUpdateButton').setAttribute('aria-expanded', String(expanded));
}
function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' | 'ready'): void {
const banner = byId('updateBanner');
const button = byId<HTMLButtonElement>('workspaceUpdateButton');
@@ -106,25 +116,35 @@ function syncWorkspaceUpdateState(state: 'idle' | 'available' | 'downloading' |
banner.dataset.updateState = state;
button.dataset.updateState = state;
button.disabled = state === 'downloading';
button.disabled = false;
if (state === 'downloading') {
button.setAttribute('aria-disabled', 'true');
} else {
button.removeAttribute('aria-disabled');
}
label.textContent = state === 'ready' ? UI_TEXT.updates.installNow : 'Update';
button.title = state === 'idle' ? UI_TEXT.static.checkUpdates : description;
button.setAttribute('aria-label', button.title);
syncWorkspaceUpdateExpansion();
byId<HTMLButtonElement>('workspaceUpdateLater').hidden = state === 'idle' || state === 'downloading';
byId<HTMLButtonElement>('workspaceUpdateDismiss').hidden = state === 'idle';
}
function showUpdateBanner(): void {
byId('updateBanner').classList.add('show');
byId('updateBanner').classList.toggle('show', !workspaceUpdatePopoverPostponed);
syncWorkspaceUpdateState(updateBannerState);
}
function hideUpdateBanner(): void {
updateBannerState = 'idle';
workspaceUpdatePopoverPostponed = false;
const banner = byId('updateBanner');
const progress = byId('updateProgress');
const bar = byId('updateProgressBar');
const action = byId<HTMLButtonElement>('updateButton');
banner.classList.remove('show');
banner.classList.remove('popover-dismissed');
progress.classList.add('is-hidden');
bar.classList.remove('downloading');
bar.style.width = '0%';
@@ -135,6 +155,26 @@ function hideUpdateBanner(): void {
syncWorkspaceUpdateState('idle');
}
function postponeWorkspaceUpdatePopover(): void {
workspaceUpdatePopoverPostponed = true;
const banner = byId('updateBanner');
banner.classList.remove('show');
banner.classList.add('popover-dismissed');
byId<HTMLButtonElement>('workspaceUpdateButton').setAttribute('aria-expanded', 'false');
byId<HTMLButtonElement>('workspaceUpdateButton').focus();
}
function dismissWorkspaceUpdatePopover(): void {
hideUpdateBanner();
byId<HTMLButtonElement>('workspaceUpdateButton').focus();
}
for (const eventName of ['mouseenter', 'mouseleave', 'focusin', 'focusout']) {
byId('updateBanner').addEventListener(eventName, () => {
window.requestAnimationFrame(syncWorkspaceUpdateExpansion);
});
}
function handleWorkspaceUpdateAction(): void {
if (updateBannerState === 'downloading' || updateCheckInProgress) {
return;
@@ -148,12 +188,16 @@ function handleWorkspaceUpdateAction(): void {
void checkUpdate();
}
function setUpdateBannerAvailableUi(info: UpdateInfo): void {
function setUpdateBannerAvailableUi(info: UpdateInfo, reveal = true): void {
const activeInfo = rememberUpdateInfo(info);
updateReady = false;
updateDownloadInProgress = false;
latestDownloadProgress = null;
updateBannerState = 'available';
if (reveal) {
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
}
showUpdateBanner();
byId('updateProgress').classList.add('is-hidden');
@@ -172,6 +216,8 @@ function setUpdateBannerAvailableUi(info: UpdateInfo): void {
function setDownloadPendingUi(): void {
updateReady = false;
updateBannerState = 'downloading';
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
showUpdateBanner();
const button = byId<HTMLButtonElement>('updateButton');
@@ -193,11 +239,13 @@ function setDownloadPendingUi(): void {
function setDownloadReadyUi(info?: UpdateInfo): void {
const activeInfo = rememberUpdateInfo(info);
showUpdateBanner();
updateReady = true;
updateDownloadInProgress = false;
updateBannerState = 'ready';
workspaceUpdatePopoverPostponed = false;
byId('updateBanner').classList.remove('popover-dismissed');
latestDownloadProgress = null;
showUpdateBanner();
const bar = byId('updateProgressBar');
bar.classList.remove('downloading');
@@ -426,7 +474,7 @@ function refreshUpdateUiTexts(): void {
const bar = byId('updateProgressBar');
if (updateBannerState === 'available' && latestUpdateInfo) {
setUpdateBannerAvailableUi(latestUpdateInfo);
setUpdateBannerAvailableUi(latestUpdateInfo, false);
} else if (updateBannerState === 'downloading') {
button.textContent = UI_TEXT.updates.downloading;
button.disabled = true;
+65 -37
View File
@@ -44,26 +44,6 @@ async function init(): Promise<void> {
renderStreamers();
renderQueue();
// Keyboard activation for nav-items (Enter / Space). The items are
// div[role="button"][tabindex="0"], so browsers won't synthesise a
// click on Enter/Space natively — we wire it here once via event
// delegation so the listener doesn't need re-binding per tab switch.
const nav = document.querySelector('.nav');
if (nav && !nav.hasAttribute('data-keynav-bound')) {
nav.setAttribute('data-keynav-bound', '1');
nav.addEventListener('keydown', (event) => {
const ev = event as KeyboardEvent;
if (ev.key !== 'Enter' && ev.key !== ' ') return;
const target = ev.target as HTMLElement | null;
const item = target?.closest('.nav-item') as HTMLElement | null;
if (!item) return;
const tab = item.dataset.tab;
if (!tab) return;
ev.preventDefault();
showTab(tab);
});
}
// Kick off live-status subscription so the sidebar dots populate.
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
if (typeof liveStatusInit === 'function') void liveStatusInit();
@@ -807,10 +787,12 @@ function getQueueStateFingerprint(items: QueueItem[]): string {
}
function updateDownloadButtonState(): void {
const btn = byId('btnStart');
const btn = byId<HTMLButtonElement>('btnStart');
const hasPaused = queue.some((item) => item.status === 'paused');
const hasRunnable = queue.some((item) => item.status === 'pending' || item.status === 'paused');
btn.textContent = downloading ? UI_TEXT.queue.stop : (hasPaused ? UI_TEXT.queue.resume : UI_TEXT.queue.start);
btn.classList.toggle('downloading', downloading);
btn.disabled = !downloading && !hasRunnable;
}
async function syncQueueAndDownloadState(): Promise<void> {
@@ -888,15 +870,50 @@ function syncWorkspaceChrome(tab: string): void {
const activeContext = document.querySelector<HTMLElement>(`[data-context-for="${tab}"]`);
const contextHeading = activeContext?.querySelector<HTMLElement>('[data-context-heading]');
if (contextHeading && navLabel) contextHeading.textContent = navLabel;
if (activeContext) syncWorkspaceContextSelection(activeContext);
const workspace = document.querySelector<HTMLElement>('.workspace-shell');
if (workspace) workspace.dataset.activeWorkspace = tab;
}
function focusWorkspaceTarget(id: string): void {
function syncWorkspaceContextSelection(panel: HTMLElement): void {
const links = Array.from(panel.querySelectorAll<HTMLButtonElement>('.context-list .context-link'));
const activeLink = links.find((link) => link.classList.contains('active')) || links[0];
links.forEach((link) => {
const active = link === activeLink;
link.classList.toggle('active', active);
if (active) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
const switchers = Array.from(panel.querySelectorAll<HTMLButtonElement>('.context-switcher button'));
const activeSwitcher = switchers.find((button) => button.classList.contains('active')) || switchers[0];
switchers.forEach((button) => {
const active = button === activeSwitcher;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
}
function focusWorkspaceTarget(id: string, source?: HTMLElement): void {
const target = document.getElementById(id) as HTMLElement | null;
if (!target) return;
const group = source?.closest<HTMLElement>('.context-list, .context-switcher');
if (group && source) {
const buttons = Array.from(group.querySelectorAll<HTMLButtonElement>('button'));
buttons.forEach((button) => {
const active = button === source;
button.classList.toggle('active', active);
if (group.classList.contains('context-list')) {
if (active) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
} else {
button.setAttribute('aria-pressed', String(active));
}
});
}
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const focusTarget = target.matches('button, input, select, textarea, [tabindex]')
? target
@@ -1487,10 +1504,15 @@ async function confirmClipDialog(): Promise<void> {
closeClipDialog();
}
let clipDownloadInFlight = false;
async function downloadClip(): Promise<void> {
if (clipDownloadInFlight) return;
const url = byId<HTMLInputElement>('clipUrl').value.trim();
const status = byId('clipStatus');
const btn = byId('btnClip');
const btn = byId<HTMLButtonElement>('btnClip');
const toolbarBtn = byId<HTMLButtonElement>('toolbarClipDownloadBtn');
if (!url) {
status.textContent = UI_TEXT.clips.enterUrl;
@@ -1498,27 +1520,33 @@ async function downloadClip(): Promise<void> {
return;
}
clipDownloadInFlight = true;
btn.disabled = true;
toolbarBtn.disabled = true;
btn.textContent = UI_TEXT.clips.loadingButton;
status.textContent = UI_TEXT.clips.loadingStatus;
status.className = 'clip-status loading';
const result = await window.api.downloadClip(url);
try {
const result = await window.api.downloadClip(url);
if (result.success) {
status.textContent = UI_TEXT.clips.success;
status.className = 'clip-status success';
return;
}
btn.disabled = false;
btn.textContent = UI_TEXT.clips.downloadButton;
if (result.success) {
status.textContent = UI_TEXT.clips.success;
status.className = 'clip-status success';
return;
const backendError = (result.error || '').trim();
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
status.className = 'clip-status error';
} catch {
status.textContent = UI_TEXT.clips.errorPrefix + UI_TEXT.clips.unknownError;
status.className = 'clip-status error';
} finally {
clipDownloadInFlight = false;
btn.disabled = false;
toolbarBtn.disabled = false;
btn.textContent = UI_TEXT.clips.downloadButton;
}
// Backend now produces locale-aware error strings via tBackend(),
// so we no longer need a renderer-side translation table here.
const backendError = (result.error || '').trim();
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
status.className = 'clip-status error';
}
async function loadCutterFromPath(filePath: string): Promise<void> {
+376 -31
View File
@@ -17,6 +17,12 @@
--workspace-warning: #f2c66d;
--workspace-danger: #ef7d7d;
--workspace-info: #8fb5ff;
--workspace-empty-icon: #f3f3f3;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #ffffff;
--workspace-radius: 6px;
--workspace-radius-small: 4px;
--workspace-topbar-height: 40px;
@@ -61,6 +67,12 @@ body.theme-system {
--workspace-warning: #f2c66d;
--workspace-danger: #ef7d7d;
--workspace-info: #8fb5ff;
--workspace-empty-icon: #f3f3f3;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #ffffff;
--bg-main: var(--workspace-client);
--bg-sidebar: var(--workspace-panel);
--bg-card: var(--workspace-panel-raised);
@@ -95,6 +107,12 @@ body.theme-light {
--workspace-warning: #8f5d12;
--workspace-danger: #a33d3d;
--workspace-info: #315ea8;
--workspace-empty-icon: #596475;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #172033;
--bg-main: var(--workspace-client);
--bg-sidebar: var(--workspace-panel);
--bg-card: var(--workspace-panel-raised);
@@ -217,7 +235,7 @@ textarea:disabled,
height: 19px;
flex: 0 0 19px;
color: var(--workspace-primary);
fill: currentColor;
fill: none;
}
.topbar-brand-name {
@@ -266,29 +284,31 @@ textarea:disabled,
}
.top-nav-item.nav-item.active {
color: var(--workspace-primary);
background: var(--workspace-control);
border-color: var(--workspace-border-strong);
color: var(--workspace-primary-text);
background: var(--workspace-primary);
border-color: var(--workspace-primary);
}
.top-nav-item.nav-item svg {
width: 17px;
height: 17px;
flex: 0 0 17px;
fill: currentColor;
fill: none;
stroke: currentColor;
}
.top-nav-item .top-nav-label,
.top-nav-item.nav-item > span {
position: absolute;
width: 1px;
height: 1px;
position: static;
width: auto;
min-width: 0;
height: auto;
padding: 0;
margin: -1px;
margin: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip: auto;
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
}
@@ -385,9 +405,9 @@ textarea:disabled,
width: max-content;
max-width: 250px;
padding: 7px 9px;
color: var(--workspace-text);
background: #505050;
border: 1px solid #5c5c5c;
color: var(--workspace-popover-text);
background: var(--workspace-popover-bg);
border: 1px solid var(--workspace-popover-border);
border-radius: var(--workspace-radius-small);
font-size: 12px;
font-weight: 600;
@@ -444,7 +464,7 @@ textarea:disabled,
}
.context-panel {
display: none;
display: flex;
width: 100%;
min-width: 0;
min-height: 0;
@@ -453,10 +473,6 @@ textarea:disabled,
overflow: hidden;
}
.context-panel.active {
display: flex;
}
.context-sidebar-header {
display: flex;
flex: 0 0 auto;
@@ -1612,7 +1628,7 @@ input[type="range"] {
width: 56px;
height: 56px;
margin-bottom: 6px;
color: #f3f3f3;
color: var(--workspace-empty-icon);
opacity: 0.92;
}
@@ -2128,6 +2144,12 @@ input[type="range"] {
--workspace-warning: #8f5d12;
--workspace-danger: #a33d3d;
--workspace-info: #315ea8;
--workspace-empty-icon: #596475;
--workspace-popover-bg: #4f4d4d;
--workspace-popover-border: #5b5959;
--workspace-popover-text: #ffffff;
--workspace-popover-muted: #e4e2e2;
--workspace-focus-ring: #172033;
--bg-main: var(--workspace-client);
--bg-sidebar: var(--workspace-panel);
--bg-card: var(--workspace-panel-raised);
@@ -2333,9 +2355,9 @@ input[type="range"] {
align-items: stretch;
gap: 7px;
padding: 8px;
color: var(--workspace-text);
background: #4f4d4d;
border: 1px solid #5b5959;
color: var(--workspace-popover-text);
background: var(--workspace-popover-bg);
border: 1px solid var(--workspace-popover-border);
border-radius: 5px;
font-size: 13px;
font-weight: 600;
@@ -2351,15 +2373,15 @@ input[type="range"] {
right: 39px;
width: 9px;
height: 9px;
background: #4f4d4d;
border-top: 1px solid #5b5959;
border-left: 1px solid #5b5959;
background: var(--workspace-popover-bg);
border-top: 1px solid var(--workspace-popover-border);
border-left: 1px solid var(--workspace-popover-border);
content: "";
transform: rotate(45deg);
}
.workspace-update:hover .workspace-update-popover,
.workspace-update:focus-within .workspace-update-popover {
.workspace-update.show:not(.popover-dismissed):hover .workspace-update-popover,
.workspace-update.show:not(.popover-dismissed):focus-within .workspace-update-popover {
visibility: visible;
opacity: 1;
pointer-events: auto;
@@ -2398,10 +2420,6 @@ input[type="range"] {
height: 5px;
}
.context-panel {
display: flex;
}
.context-switcher {
display: flex;
flex: 0 0 auto;
@@ -2859,3 +2877,330 @@ input[type="range"] {
flex-basis: 190px;
}
}
.top-nav-item.nav-item.active:hover {
color: var(--workspace-primary-text);
background: var(--workspace-primary-hover);
border-color: var(--workspace-primary-hover);
}
.top-nav .top-nav-item:focus-visible {
outline: 2px solid var(--workspace-focus-ring);
outline-offset: 2px;
box-shadow: none;
}
#settingsTab .settings-card:has(#downloadPath) {
width: min(720px, 100%);
}
#settingsTab .form-row:has(#downloadPath) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
}
#settingsTab #downloadPath {
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#clipsTab.active {
display: grid;
grid-template-columns: minmax(420px, 1.35fr) minmax(280px, 0.65fr);
align-content: start;
align-items: start;
gap: 16px;
}
#clipsTab .clip-input {
display: grid;
width: 100%;
max-width: none;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
margin: 0;
padding: 16px;
text-align: left;
background: var(--workspace-panel-raised);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius);
}
#clipsTab .clip-input h2 {
grid-column: 1 / -1;
margin: 0 0 2px;
font-size: 16px;
line-height: 22px;
}
#clipsTab .clip-input #clipUrl {
width: 100%;
min-width: 0;
min-height: 38px;
margin: 0;
}
#clipsTab .clip-input #btnClip {
min-height: 38px;
white-space: nowrap;
}
#clipsTab .clip-input #clipStatus {
grid-column: 1 / -1;
min-height: 20px;
margin: 0;
font-size: 12px;
line-height: 20px;
}
#clipsTab > .settings-card.centered {
width: 100%;
max-width: none;
align-self: start;
margin: 0;
}
.workspace-settings-search {
position: relative;
display: flex;
flex: 0 1 550px;
min-width: 180px;
height: 36px;
align-items: center;
margin-left: auto;
}
.workspace-toolbar .toolbar-context[data-toolbar-for="settings"] {
flex: 1 1 auto;
width: 100%;
}
.workspace-settings-search input {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 36px 0 34px;
color: var(--workspace-text);
background: var(--workspace-control);
border: 1px solid var(--workspace-border);
border-radius: var(--workspace-radius-small);
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.workspace-settings-search > svg {
position: absolute;
left: 11px;
z-index: 1;
width: 14px;
height: 14px;
color: var(--workspace-text-muted);
fill: none;
stroke: currentColor;
pointer-events: none;
}
.workspace-settings-search > button {
position: absolute;
right: 4px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
color: var(--workspace-text-muted);
background: transparent;
border: 0;
border-radius: var(--workspace-radius-small);
cursor: pointer;
}
.workspace-settings-search > button:hover {
color: var(--workspace-text);
background: var(--workspace-control-hover);
}
.workspace-update-popover {
width: 260px;
}
.workspace-update-popover-header {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
color: var(--workspace-popover-text);
}
.workspace-update-popover-header #updateText {
flex: 1 1 auto;
min-width: 0;
}
#workspaceUpdateDismiss {
display: inline-flex;
width: 24px;
min-width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
color: var(--workspace-popover-muted);
background: transparent;
border: 1px solid transparent;
border-radius: var(--workspace-radius-small);
cursor: pointer;
}
#workspaceUpdateDismiss:hover {
color: var(--workspace-popover-text);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.18);
}
#workspaceUpdateDismiss::before {
content: "\00d7";
font-size: 17px;
font-weight: 500;
line-height: 1;
}
.workspace-update-popover-actions {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.workspace-update-popover-actions #updateButton {
flex: 1 1 auto;
}
#workspaceUpdateLater {
display: inline-flex;
min-height: 30px;
align-items: center;
justify-content: center;
padding: 0 9px;
color: var(--workspace-popover-text);
background: transparent;
border: 1px solid var(--workspace-popover-muted);
border-radius: var(--workspace-radius-small);
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
#workspaceUpdateLater:hover {
background: rgba(255, 255, 255, 0.12);
}
.workspace-update-popover .update-banner-progress-track {
background: rgba(255, 255, 255, 0.22);
}
@media (min-width: 1180px) {
.topbar-navigation-cluster {
flex: 0 1 1000px;
width: 1000px;
max-width: calc(100vw - 254px);
}
.topbar-navigation-cluster .topbar-brand {
flex-basis: 190px;
min-width: 190px;
padding: 0 12px;
}
.topbar-navigation-cluster .topbar-brand #logoText {
position: static;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: hidden;
clip: auto;
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
}
.topbar-navigation-cluster .top-nav {
min-width: 0;
overflow: hidden;
}
.topbar-navigation-cluster .top-nav-item.nav-item {
flex: 1 1 auto;
width: auto;
min-width: 44px;
max-width: none;
padding: 0 8px;
gap: 6px;
}
.topbar-navigation-cluster .top-nav-item .top-nav-label,
.topbar-navigation-cluster .top-nav-item.nav-item > span {
position: static;
display: block;
width: auto;
min-width: 0;
height: auto;
padding: 0;
margin: 0;
overflow: hidden;
clip: auto;
white-space: nowrap;
text-overflow: ellipsis;
border: 0;
font-size: 12px;
}
}
@media (max-width: 1179px) {
.topbar-navigation-cluster .top-nav-item .top-nav-label,
.topbar-navigation-cluster .top-nav-item.nav-item > span {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.topbar-navigation-cluster .top-nav-item.nav-item {
width: 44px;
min-width: 44px;
padding: 0;
gap: 0;
}
}
@media (max-width: 1350px) {
.workspace-settings-search {
flex-basis: 280px;
}
}
@media (max-width: 820px) {
#clipsTab.active {
grid-template-columns: minmax(0, 1fr);
}
#settingsTab .form-row:has(#downloadPath) {
grid-template-columns: minmax(0, 1fr);
}
}