mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
feat: collapse the Downloading, Completed and Subscriptions sections (closes #1070)
All three section headers become toggles. Collapsing removes the section from the DOM rather than hiding it, so a long completed list stops costing render work while it is put away. The chevron sits at the right edge of the header. Putting it before the label indents that title past the ones without a chevron, and the three section titles are a left-aligned column; keeping them aligned matters more than keeping the affordance next to the word. Direction follows the Advanced Options disclosure already in the form — down when open, right when closed. The request covered Completed and Subscriptions only, but leaving Downloading as the one fixed section is arbitrary once its neighbours move. Each section remembers its own state in a metube_* cookie, matching how every other client-side preference here is persisted; the request asked for localStorage, but a second mechanism for the same job is not worth it. All three default to expanded, so an upgrade doesn't hide anything a user was already looking at. The Downloading and Completed blocks hold the viewChild.required targets behind their select-all checkboxes. Nothing reads them while a section is collapsed: the only caller is the checkbox's own (changed) output, and the queueChanged/doneChanged subscriptions reach it through an optional viewChild. Verified live on a download that finished while the section was put away.
This commit is contained in:
+24
-3
@@ -690,7 +690,13 @@
|
|||||||
Connecting to server...
|
Connecting to server...
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="metube-section-header">Downloading</div>
|
<div class="metube-section-header">
|
||||||
|
<button type="button" class="metube-section-toggle" (click)="toggleDownloadingCollapsed()" [attr.aria-expanded]="!downloadingCollapsed">
|
||||||
|
<span>Downloading</span>
|
||||||
|
<fa-icon [icon]="downloadingCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (!downloadingCollapsed) {
|
||||||
<div class="px-2 py-3 border-bottom">
|
<div class="px-2 py-3 border-bottom">
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
||||||
@@ -758,8 +764,15 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="metube-section-header">Completed</div>
|
<div class="metube-section-header">
|
||||||
|
<button type="button" class="metube-section-toggle" (click)="toggleCompletedCollapsed()" [attr.aria-expanded]="!completedCollapsed">
|
||||||
|
<span>Completed</span>
|
||||||
|
<fa-icon [icon]="completedCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (!completedCollapsed) {
|
||||||
<div class="px-2 py-3 border-bottom">
|
<div class="px-2 py-3 border-bottom">
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||||
@@ -913,8 +926,15 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="metube-section-header">Subscriptions</div>
|
<div class="metube-section-header">
|
||||||
|
<button type="button" class="metube-section-toggle" (click)="toggleSubscriptionsCollapsed()" [attr.aria-expanded]="!subscriptionsCollapsed">
|
||||||
|
<span>Subscriptions</span>
|
||||||
|
<fa-icon [icon]="subscriptionsCollapsed ? faChevronRight : faChevronDown" class="metube-section-chevron" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (!subscriptionsCollapsed) {
|
||||||
<div class="px-2 py-3 border-bottom">
|
<div class="px-2 py-3 border-bottom">
|
||||||
@if (checkingAllSubscriptions) {
|
@if (checkingAllSubscriptions) {
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||||
@@ -1087,6 +1107,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</main><!-- /.container -->
|
</main><!-- /.container -->
|
||||||
|
|
||||||
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
||||||
|
|||||||
@@ -10,6 +10,28 @@
|
|||||||
padding: 0.5rem 0
|
padding: 0.5rem 0
|
||||||
margin-top: 3.5rem
|
margin-top: 3.5rem
|
||||||
|
|
||||||
|
.metube-section-toggle
|
||||||
|
// Positioned so it paints above the header's full-bleed :before overlay.
|
||||||
|
position: relative
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
// Title left, chevron against the right edge, so all three section titles
|
||||||
|
// stay on the same left margin whether or not a chevron is present.
|
||||||
|
justify-content: space-between
|
||||||
|
gap: 0.75rem
|
||||||
|
width: 100%
|
||||||
|
padding: 0
|
||||||
|
border: 0
|
||||||
|
background: none
|
||||||
|
color: inherit
|
||||||
|
font: inherit
|
||||||
|
text-align: left
|
||||||
|
|
||||||
|
.metube-section-chevron
|
||||||
|
font-size: 1.1rem
|
||||||
|
width: 1.1rem
|
||||||
|
color: var(--bs-secondary-color)
|
||||||
|
|
||||||
.metube-section-header:before
|
.metube-section-header:before
|
||||||
content: ""
|
content: ""
|
||||||
position: absolute
|
position: absolute
|
||||||
|
|||||||
@@ -168,6 +168,31 @@ describe('App', () => {
|
|||||||
expect(fixture.componentInstance.folder).toBe('music');
|
expect(fixture.componentInstance.folder).toBe('music');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('collapses each section independently and remembers it (#1070)', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const cookies = TestBed.inject(CookieService);
|
||||||
|
|
||||||
|
expect(app.downloadingCollapsed).toBe(false);
|
||||||
|
expect(app.completedCollapsed).toBe(false);
|
||||||
|
expect(app.subscriptionsCollapsed).toBe(false);
|
||||||
|
|
||||||
|
app.toggleCompletedCollapsed();
|
||||||
|
|
||||||
|
expect(app.completedCollapsed).toBe(true);
|
||||||
|
expect(app.downloadingCollapsed).toBe(false);
|
||||||
|
expect(app.subscriptionsCollapsed).toBe(false);
|
||||||
|
expect(cookies.get('metube_completed_collapsed')).toBe('true');
|
||||||
|
|
||||||
|
// A fresh component picks the state back up from the cookie.
|
||||||
|
const restored = TestBed.createComponent(App);
|
||||||
|
restored.detectChanges();
|
||||||
|
expect(restored.componentInstance.completedCollapsed).toBe(true);
|
||||||
|
expect(restored.componentInstance.downloadingCollapsed).toBe(false);
|
||||||
|
expect(restored.componentInstance.subscriptionsCollapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
|
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
metubeVersion: string | null = null;
|
metubeVersion: string | null = null;
|
||||||
isAdvancedOpen = false;
|
isAdvancedOpen = false;
|
||||||
sortAscending = false;
|
sortAscending = false;
|
||||||
|
downloadingCollapsed = false;
|
||||||
|
completedCollapsed = false;
|
||||||
|
subscriptionsCollapsed = false;
|
||||||
expandedErrors: Set<string> = new Set<string>();
|
expandedErrors: Set<string> = new Set<string>();
|
||||||
cachedSortedDone: [string, Download][] = [];
|
cachedSortedDone: [string, Download][] = [];
|
||||||
// The done ids in rendered order, so a shift-click range follows the sort
|
// The done ids in rendered order, so a shift-click range follows the sort
|
||||||
@@ -289,6 +292,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.previousDownloadType = this.downloadType;
|
this.previousDownloadType = this.downloadType;
|
||||||
this.saveSelection(this.downloadType);
|
this.saveSelection(this.downloadType);
|
||||||
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
|
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
|
||||||
|
this.downloadingCollapsed = this.cookieService.get('metube_downloading_collapsed') === 'true';
|
||||||
|
this.completedCollapsed = this.cookieService.get('metube_completed_collapsed') === 'true';
|
||||||
|
this.subscriptionsCollapsed = this.cookieService.get('metube_subscriptions_collapsed') === 'true';
|
||||||
|
|
||||||
const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10);
|
const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10);
|
||||||
if (!Number.isNaN(ci) && ci >= 1) {
|
if (!Number.isNaN(ci) && ci >= 1) {
|
||||||
@@ -1569,6 +1575,21 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
this.rebuildSortedDone();
|
this.rebuildSortedDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleDownloadingCollapsed() {
|
||||||
|
this.downloadingCollapsed = !this.downloadingCollapsed;
|
||||||
|
this.cookieService.set('metube_downloading_collapsed', this.downloadingCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCompletedCollapsed() {
|
||||||
|
this.completedCollapsed = !this.completedCollapsed;
|
||||||
|
this.cookieService.set('metube_completed_collapsed', this.completedCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSubscriptionsCollapsed() {
|
||||||
|
this.subscriptionsCollapsed = !this.subscriptionsCollapsed;
|
||||||
|
this.cookieService.set('metube_subscriptions_collapsed', this.subscriptionsCollapsed ? 'true' : 'false', { expires: this.settingsCookieExpiryDays });
|
||||||
|
}
|
||||||
|
|
||||||
private rebuildSortedDone() {
|
private rebuildSortedDone() {
|
||||||
const result: [string, Download][] = [];
|
const result: [string, Download][] = [];
|
||||||
this.downloads.done.forEach((dl, key) => {
|
this.downloads.done.forEach((dl, key) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user