feat: 媒体库支持批量选择、移动与删除文件

This commit is contained in:
tigerenwork 2026-08-15 01:24:08 +08:00
parent c1535227b9
commit e87c927a09
18 changed files with 449 additions and 25 deletions

View File

@ -27,8 +27,8 @@ default, e.g. via QEMU emulation).
```sh
docker buildx build --platform linux/amd64 \
--build-arg VERSION=1.20 \
-t 192.168.2.212:3000/tigeren/metube:1.20 \
--build-arg VERSION=1.21 \
-t 192.168.2.212:3000/tigeren/metube:1.21 \
--push .
```

View File

@ -4,6 +4,7 @@
import os
import sys
import asyncio
import shutil
from pathlib import Path
from aiohttp import web
from aiohttp.log import access_logger
@ -505,6 +506,64 @@ async def library_delete(request):
return web.Response(text=serializer.encode({'status': 'ok', 'deleted': deleted, 'errors': errors}))
@routes.post(config.URL_PREFIX + 'library/move')
async def library_move(request):
post = await request.json()
paths = post.get('paths')
folder = post.get('folder', '')
if not paths:
raise web.HTTPBadRequest()
root = _library_root()
if root is None:
raise web.HTTPBadRequest(text='LIBRARY_DIR is not configured')
target_dir = os.path.realpath(os.path.join(root, folder or ''))
if target_dir != root and not target_dir.startswith(root + os.sep):
raise web.HTTPBadRequest(text='Invalid library folder')
def _move():
os.makedirs(target_dir, exist_ok=True)
moved = 0
errors = []
for rel in paths:
_, src = _resolve_library_path(rel)
if src is None or not os.path.isfile(src):
errors.append(f'{rel}: invalid path')
continue
if os.path.realpath(os.path.dirname(src)) == target_dir:
continue
dst = os.path.join(target_dir, os.path.basename(src))
if os.path.exists(dst):
errors.append(f'{rel}: target already exists')
continue
try:
shutil.move(src, dst)
except OSError as e:
errors.append(f'{rel}: {e}')
continue
meta_src = library_meta_path(src)
if os.path.isfile(meta_src):
try:
shutil.move(meta_src, library_meta_path(dst))
except OSError:
pass
moved += 1
# Clean up emptied parent directories, never the library root itself
parent = os.path.dirname(src)
while parent != root and parent.startswith(root + os.sep):
try:
os.rmdir(parent)
except OSError:
break
parent = os.path.dirname(parent)
return moved, errors
moved, errors = await asyncio.get_running_loop().run_in_executor(None, _move)
if moved:
_invalidate_library_folders()
log.info(f"Library move request processed: moved={moved} errors={len(errors)}")
return web.Response(text=serializer.encode({'status': 'ok', 'moved': moved, 'errors': errors}))
@routes.get(config.URL_PREFIX + 'library/thumbnail')
async def library_thumbnail(request):
"""Thumbnail for a library file: ffmpeg frame extraction, cached on disk."""

View File

@ -94,9 +94,12 @@
<app-library-browser
[files]="libraryFiles"
[folderPath]="libraryFolder"
[folders]="libraryFolderNames"
[loading]="libraryLoading"
(play)="playLibraryFile($event)"
(del)="deleteLibraryFile($event)"
(batchDelete)="deleteLibraryFiles($event)"
(batchMove)="moveLibraryFiles($event)"
(refresh)="loadLibraryFolders(); loadLibraryFiles()">
</app-library-browser>
</div>
@ -169,7 +172,10 @@
[searchable]="true"
[clearable]="true"
appendTo="body">
</ng-select>
<ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="closeOrganize()">取消</button>

View File

@ -224,8 +224,13 @@ export class AppComponent implements OnInit {
}
deleteLibraryFile(file: LibraryFile): void {
if (!confirm(`确定删除「${file.name}」吗?此操作不可恢复。`)) return;
this.downloads.libraryDelete([this.libraryFilePath(file)]).subscribe((res) => {
this.deleteLibraryFiles([this.libraryFilePath(file)]);
}
deleteLibraryFiles(paths: string[]): void {
if (!paths.length) return;
if (!confirm(`确定删除所选 ${paths.length} 个文件吗?此操作不可恢复。`)) return;
this.downloads.libraryDelete(paths).subscribe((res) => {
if (res.status === 'error') {
this.toastService.show(`删除失败: ${res.msg}`, { error: true });
return;
@ -233,7 +238,24 @@ export class AppComponent implements OnInit {
if (res.errors?.length) {
this.toastService.show(res.errors.join('; '), { error: true });
} else {
this.toastService.show('已删除');
this.toastService.show(paths.length === 1 ? '已删除' : `已删除 ${paths.length} 个文件`);
}
this.loadLibraryFiles();
this.loadLibraryFolders();
});
}
moveLibraryFiles(evt: { paths: string[]; folder: string }): void {
if (!evt.paths.length) return;
this.downloads.libraryMove(evt.paths, evt.folder).subscribe((res) => {
if (res.status === 'error') {
this.toastService.show(`移动失败: ${res.msg}`, { error: true });
return;
}
if (res.errors?.length) {
this.toastService.show(`已移动 ${res.moved || 0} 个文件,${res.errors.length} 个失败: ${res.errors[0]}`, { error: true });
} else {
this.toastService.show(`已移动 ${res.moved || 0} 个文件到「${evt.folder || '根目录'}」`);
}
this.loadLibraryFiles();
this.loadLibraryFolders();

View File

@ -62,7 +62,11 @@
[disabled]="disabled"
[(ngModel)]="folder"
(ngModelChange)="folderChange.emit($event)"
ngbTooltip="选择保存目录,可直接输入创建新目录">
appendTo="body"
[ngbTooltip]="folder || '选择保存目录,可直接输入创建新目录'">
<ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select>
<button class="advanced-link" (click)="openAdvanced.emit()">

View File

@ -1,6 +1,6 @@
<div class="card" [class.selected]="selected">
<input type="checkbox" class="card-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb">
<div class="thumb" [class.clickable]="!!downloadUrl" (click)="downloadUrl && play.emit()">
<img [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
<span *ngIf="row.value.file_exists === false" class="file-warn" title="文件不存在">
@ -8,7 +8,7 @@
</span>
</div>
<div class="card-body">
<div class="card-title" [title]="row.value.title">{{ row.value.title }}</div>
<div class="card-title" [title]="row.value.title" [class.clickable]="!!downloadUrl" (click)="downloadUrl && play.emit()">{{ row.value.title }}</div>
<div class="card-meta">
<span class="badge folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
@ -45,7 +45,10 @@
[ngModel]="row.value.folder"
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
appendTo="body"
ngbTooltip="移动文件到其他目录">
[ngbTooltip]="row.value.folder || '移动文件到其他目录'">
<ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select>
</div>
</div>

View File

@ -72,6 +72,18 @@
overflow: hidden
min-height: 38px
.clickable
cursor: pointer
.card-title.clickable:hover
color: var(--accent-strong)
.thumb.clickable img
transition: opacity 0.12s ease
.thumb.clickable:hover img
opacity: 0.85
.card-meta
display: flex
align-items: center

View File

@ -1,6 +1,6 @@
<div class="q-row" [class.selected]="selected">
<input type="checkbox" class="q-check" [checked]="selected" (change)="selectChange.emit($event.target.checked)" [attr.aria-label]="'选择 ' + row.value.title">
<div class="thumb">
<div class="thumb" [class.clickable]="canPlay()" (click)="canPlay() && play.emit()">
<img *ngIf="status === 'completed' || row.value.thumbnail" [src]="thumbUrl()" [alt]="row.value.title" loading="lazy">
<span class="thumb-ph" *ngIf="status !== 'completed' && !row.value.thumbnail">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="10 9 15 12 10 15"/></svg>
@ -8,7 +8,7 @@
<span class="dur num" *ngIf="row.value.duration">{{ row.value.duration | duration }}</span>
</div>
<div class="q-main">
<div class="q-title" [title]="row.value.title">{{ row.value.title }}</div>
<div class="q-title" [title]="row.value.title" [class.clickable]="canPlay()" (click)="canPlay() && play.emit()">{{ row.value.title }}</div>
<div class="q-meta">
<span class="badge folder">{{ folderLabel() }}</span>
<span class="badge fmt">{{ qualityLabel(row.value.quality) }} · {{ formatLabel(row.value.format) }}</span>
@ -29,8 +29,11 @@
[closeOnSelect]="true"
[ngModel]="row.value.folder"
(change)="folderChange.emit({ key: row.key, folder: $event || '' })"
appendTo="body"
ngbTooltip="更改下载目录">
appendTo="body"
[ngbTooltip]="row.value.folder || '更改下载目录'">
<ng-template ng-option-tmp let-item="item">
<span [title]="item">{{ item }}</span>
</ng-template>
</ng-select>
</div>
</div>

View File

@ -67,6 +67,18 @@
-webkit-box-orient: vertical
overflow: hidden
.clickable
cursor: pointer
.q-title.clickable:hover
color: var(--accent-strong)
.thumb.clickable img
transition: opacity 0.12s ease
.thumb.clickable:hover img
opacity: 0.85
.q-meta
display: flex
gap: 8px

View File

@ -50,6 +50,10 @@ export class DownloadRowComponent {
return this.row.value.folder || '默认';
}
canPlay(): boolean {
return this.status === 'completed' && !!this.downloadUrl;
}
fullTime(): string {
return new Date((this.row.value.timestamp || 0) / 1e6).toLocaleString();
}

View File

@ -229,6 +229,12 @@ export class DownloadsService {
);
}
public libraryMove(paths: string[], folder: string): Observable<Status & { moved?: number; errors?: string[] }> {
return this.http.post<Status & { moved?: number; errors?: string[] }>('library/move', { paths, folder }).pipe(
catchError(this.handleHTTPError)
);
}
public libraryThumbUrl(path: string): string {
return 'library/thumbnail?path=' + encodeURIComponent(path);
}

View File

@ -26,6 +26,7 @@
<fa-icon [icon]="faThLarge"></fa-icon>
</button>
</div>
<button class="ghost-btn" (click)="toggleSelectAll()">{{ allSelected() ? '取消全选' : '全选' }}</button>
<button class="ghost-btn" (click)="refresh.emit()" title="刷新">
<fa-icon [icon]="faRedoAlt"></fa-icon>&nbsp;刷新
</button>
@ -40,12 +41,13 @@
<div class="list-frame" *ngIf="files.length > 0">
<div class="lib-rows" *ngIf="view === 'list'">
<div class="lib-row" *ngFor="let file of filteredFiles; trackBy: trackByName">
<div class="thumb">
<div class="lib-row" *ngFor="let file of filteredFiles; trackBy: trackByName" [class.selected]="isSelected(filePath(file))">
<input type="checkbox" class="lib-check" [checked]="isSelected(filePath(file))" (change)="toggleSelect(filePath(file), $event.target.checked)" [attr.aria-label]="'选择 ' + file.name">
<div class="thumb clickable" (click)="play.emit(file)">
<img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy">
</div>
<div class="lib-main">
<div class="lib-title" [title]="file.name">{{ file.name }}</div>
<div class="lib-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
<div class="lib-meta">
<span class="num">{{ file.size | fileSize }}</span>
<span>{{ fileTime(file) }}</span>
@ -66,12 +68,13 @@
</div>
<div class="grid" *ngIf="view === 'grid'">
<div class="card" *ngFor="let file of filteredFiles; trackBy: trackByName">
<div class="thumb" (click)="play.emit(file)">
<div class="card" *ngFor="let file of filteredFiles; trackBy: trackByName" [class.selected]="isSelected(filePath(file))">
<input type="checkbox" class="card-check" [checked]="isSelected(filePath(file))" (change)="toggleSelect(filePath(file), $event.target.checked)" [attr.aria-label]="'选择 ' + file.name">
<div class="thumb clickable" (click)="play.emit(file)">
<img [src]="thumbUrl(file)" [alt]="file.name" loading="lazy">
</div>
<div class="card-body">
<div class="card-title" [title]="file.name">{{ file.name }}</div>
<div class="card-title clickable" [title]="file.name" (click)="play.emit(file)">{{ file.name }}</div>
<div class="card-meta">
<span class="num">{{ file.size | fileSize }}</span>
<span>{{ fileTime(file) }}</span>
@ -91,4 +94,26 @@
</div>
</div>
</div>
<div class="batchbar" [class.show]="selected.size > 0">
<span class="batch-count">已选 {{ selected.size }} 项</span>
<div class="move-wrap">
<button type="button" class="batch-btn" (click)="moveMenuOpen = !moveMenuOpen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
移动到…
</button>
<div class="move-menu" *ngIf="moveMenuOpen">
<button *ngFor="let opt of folderOptions()" type="button" class="move-item" (click)="moveTo(opt.path)">{{ opt.label }}</button>
<div class="move-new">
<input type="text" placeholder="新建文件夹…" [(ngModel)]="newFolder" (keydown.enter)="confirmNewFolder()">
<button type="button" class="batch-btn small" (click)="confirmNewFolder()">创建</button>
</div>
</div>
</div>
<button type="button" class="batch-btn danger" (click)="delSelected()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
</button>
<button type="button" class="batch-btn" (click)="clearSelection()">取消选择</button>
</div>
</section>

View File

@ -180,15 +180,19 @@
.lib-row
display: grid
grid-template-columns: 88px 1fr auto
grid-template-columns: 30px 88px 1fr auto
gap: 12px
align-items: center
padding: 10px 14px
padding: 10px 14px 10px 8px
border-bottom: 1px solid var(--border)
transition: background 0.1s ease
&:last-child
border-bottom: none
&.selected
background: var(--accent-tint)
.thumb
width: 88px
aspect-ratio: 16 / 9
@ -202,6 +206,13 @@
height: 100%
object-fit: cover
.lib-check
width: 17px
height: 17px
accent-color: var(--accent)
cursor: pointer
margin-left: 6px
.lib-main
min-width: 0
@ -213,6 +224,18 @@
overflow: hidden
text-overflow: ellipsis
.clickable
cursor: pointer
.lib-title.clickable:hover, .card-title.clickable:hover
color: var(--accent-strong)
.thumb.clickable img
transition: opacity 0.12s ease
.thumb.clickable:hover img
opacity: 0.85
.lib-meta
display: flex
gap: 10px
@ -244,12 +267,17 @@
border: 1px solid var(--border)
border-radius: var(--radius)
overflow: hidden
position: relative
transition: border-color 0.12s ease, box-shadow 0.12s ease
&:hover
border-color: oklch(80% 0.012 240)
box-shadow: 0 4px 14px oklch(30% 0.02 240 / 0.08)
&.selected
border-color: var(--accent)
box-shadow: 0 0 0 1px var(--accent)
.thumb
aspect-ratio: 16 / 9
overflow: hidden
@ -262,6 +290,21 @@
height: 100%
object-fit: cover
.card-check
position: absolute
top: 8px
left: 8px
z-index: 2
width: 17px
height: 17px
accent-color: var(--accent)
cursor: pointer
opacity: 0
transition: opacity 0.12s ease
.card:hover .card-check, .card-check:checked
opacity: 1
.card-body
padding: 10px 12px 12px
@ -314,6 +357,119 @@
background: var(--bg)
color: var(--fg)
// Batch bar
.batchbar
position: fixed
left: 50%
bottom: 18px
transform: translate(-50%, 76px)
display: flex
align-items: center
gap: 6px
background: var(--fg)
color: oklch(96% 0.008 240)
border-radius: 10px
padding: 8px 8px 8px 16px
box-shadow: 0 10px 30px oklch(20% 0.02 240 / 0.3)
transition: transform 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.2)
z-index: 30
pointer-events: none
&.show
transform: translate(-50%, 0)
pointer-events: auto
.batch-count
font-size: 13px
font-weight: 550
margin-right: 8px
white-space: nowrap
.batch-btn
display: inline-flex
align-items: center
gap: 6px
border: none
background: oklch(100% 0 0 / 0.1)
color: oklch(96% 0.008 240)
border-radius: 6px
padding: 7px 12px
font-size: 12.5px
font-weight: 500
transition: background 0.12s ease
white-space: nowrap
&:hover
background: oklch(100% 0 0 / 0.2)
&.danger:hover
background: var(--danger)
&.small
padding: 6px 10px
font-size: 12px
.move-wrap
position: relative
.move-menu
position: absolute
bottom: calc(100% + 8px)
left: 0
z-index: 35
min-width: 190px
max-height: 320px
overflow-y: auto
background: var(--surface)
border: 1px solid var(--border)
border-radius: 8px
box-shadow: 0 8px 24px oklch(30% 0.02 240 / 0.18)
padding: 6px
display: flex
flex-direction: column
gap: 2px
color: var(--fg)
.move-item
width: 100%
border: none
background: none
border-radius: 6px
padding: 7px 10px
font-size: 12.5px
color: var(--fg)
text-align: left
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
&:hover:not(:disabled)
background: var(--bg)
&:disabled
color: var(--muted)
.move-new
display: flex
gap: 6px
padding: 6px 2px 2px
border-top: 1px solid var(--border)
margin-top: 4px
input
flex: 1
min-width: 0
border: 1px solid var(--border)
background: var(--bg)
border-radius: 6px
padding: 5px 8px
font-size: 12.5px
color: var(--fg)
outline: none
&:focus
border-color: var(--accent)
@media (max-width: 900px)
.filter-wrap
width: 100%

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { faExternalLinkAlt, faList, faPlay, faRedoAlt, faThLarge, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { DownloadsService, LibraryFile } from './downloads.service';
import { relativeTime } from './labels';
@ -9,19 +9,25 @@ import { relativeTime } from './labels';
templateUrl: './library-browser.component.html',
styleUrls: ['./library-browser.component.sass']
})
export class LibraryBrowserComponent {
export class LibraryBrowserComponent implements OnChanges {
@Input() files: LibraryFile[] = [];
@Input() folderPath = ''; // library-relative path of the current folder
@Input() folders: string[] = [];
@Input() loading = false;
@Output() play = new EventEmitter<LibraryFile>();
@Output() del = new EventEmitter<LibraryFile>();
@Output() refresh = new EventEmitter<void>();
@Output() batchDelete = new EventEmitter<string[]>();
@Output() batchMove = new EventEmitter<{ paths: string[]; folder: string }>();
filter = '';
sort: 'name' | 'date' | 'size' = 'name';
sortAscending = true;
view: 'list' | 'grid' = 'grid';
selected = new Set<string>();
moveMenuOpen = false;
newFolder = '';
faList = faList;
faThLarge = faThLarge;
@ -32,6 +38,12 @@ export class LibraryBrowserComponent {
constructor(public downloads: DownloadsService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['folderPath']) {
this.clearSelection();
}
}
get filteredFiles(): LibraryFile[] {
let files = this.files;
const q = this.filter.trim().toLowerCase();
@ -63,4 +75,59 @@ export class LibraryBrowserComponent {
trackByName(_: number, file: LibraryFile): string {
return file.name;
}
isSelected(path: string): boolean {
return this.selected.has(path);
}
toggleSelect(path: string, checked: boolean): void {
if (checked) {
this.selected.add(path);
} else {
this.selected.delete(path);
}
this.moveMenuOpen = false;
}
allSelected(): boolean {
return this.filteredFiles.length > 0 && this.filteredFiles.every(f => this.selected.has(this.filePath(f)));
}
toggleSelectAll(): void {
if (this.allSelected()) {
this.filteredFiles.forEach(f => this.selected.delete(this.filePath(f)));
} else {
this.filteredFiles.forEach(f => this.selected.add(this.filePath(f)));
}
this.moveMenuOpen = false;
}
clearSelection(): void {
this.selected.clear();
this.moveMenuOpen = false;
}
folderOptions(): { label: string; path: string }[] {
const opts = [{ label: '根目录', path: '' }];
[...this.folders].sort((a, b) => a.localeCompare(b, 'zh')).forEach(f => {
if (f) opts.push({ label: f, path: f });
});
return opts.filter(o => o.path !== this.folderPath);
}
moveTo(folder: string): void {
this.batchMove.emit({ paths: Array.from(this.selected), folder });
this.clearSelection();
}
confirmNewFolder(): void {
const folder = this.newFolder.trim();
if (folder) this.moveTo(folder);
this.newFolder = '';
}
delSelected(): void {
this.batchDelete.emit(Array.from(this.selected));
this.clearSelection();
}
}

View File

@ -48,6 +48,11 @@
<div class="nav-section" *ngIf="mode === 'library'">
<div class="nav-label">媒体库文件夹</div>
<div class="folder-filter">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" placeholder="筛选文件夹…" aria-label="筛选文件夹"
[(ngModel)]="folderFilter">
</div>
<button class="nav-item" [class.active]="selectedLibraryFolder === ''" (click)="selectLibraryFolder('')">
<fa-icon [icon]="faHome" class="nav-icon"></fa-icon>
<span>根目录</span>
@ -58,6 +63,7 @@
<span>{{ folder.name }}</span>
<span class="nav-count num">{{ folder.count }}</span>
</button>
<div class="nav-empty" *ngIf="folderFilter && libraryFoldersNonRoot.length === 0">无匹配文件夹</div>
</div>
<div class="sidebar-foot">

View File

@ -48,6 +48,41 @@
color: var(--muted)
padding: 0 8px 6px
.folder-filter
display: flex
align-items: center
gap: 7px
border: 1px solid var(--border)
background: var(--surface)
border-radius: 6px
padding: 0 8px
height: 30px
margin: 0 8px 6px
transition: border-color 0.12s ease
&:focus-within
border-color: var(--accent)
svg
color: var(--muted)
flex: none
input
border: none
background: none
outline: none
font-size: 12.5px
color: var(--fg)
width: 100%
&::placeholder
color: var(--muted)
.nav-empty
font-size: 12px
color: var(--muted)
padding: 4px 8px
.nav-item
display: flex
align-items: center

View File

@ -51,12 +51,15 @@ export class SidebarComponent {
return name || '未分类';
}
folderFilter = '';
get libraryRootCount(): number {
return this.libraryFolders.find(f => f.name === '')?.count || 0;
}
get libraryFoldersNonRoot(): FolderNav[] {
return this.libraryFolders.filter(f => f.name !== '');
const q = this.folderFilter.trim().toLowerCase();
return this.libraryFolders.filter(f => f.name !== '' && (!q || f.name.toLowerCase().includes(q)));
}
selectStatus(status: string): void {

View File

@ -31,3 +31,4 @@ input, select
// cascade — the `body >` selector wins on specificity instead.
body > .ng-dropdown-panel
z-index: 1060
min-width: 240px