Add search, bulk actions, undo, and storage usage to File Management
- Manage Library search: find any course/folder by name anywhere in the tree instead of expanding levels one by one; results carry the same Move/Rename/Hide actions. - Bulk select: every row gets a checkbox, so items found via search or expanded across different tree levels can be hidden, shown, or moved to the same destination together in one batch. - Undo last action: a "Last action: ... [Undo]" bar appears after any move/rename (Sort Unsorted apply, Bulk Rename apply, a Manage Library move/rename, a bulk move) and reverses the whole batch. Deliberately never covers Hide/Show (already a one-click toggle) or Delete (permanent by design) - only ever move/rename, which are trivially reversible. Re-checks each item before reversing it, so a partial failure reports exactly what did and didn't reverse. - Storage Usage: disk usage per top-level library folder, largest first, with a simple proportional bar; a manual scan since it reads every file's size. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -151,7 +151,23 @@ silently stay blank instead of erroring.
|
||||
bulk-select elsewhere to hide or queue several at once), rename a
|
||||
course/folder in place, or **move** one anywhere else in the library via
|
||||
the same folder-picker/create-new-folder UI Sort Unsorted uses - handy
|
||||
for correcting a bad auto-sort later or just reorganizing.
|
||||
for correcting a bad auto-sort later or just reorganizing. A search box
|
||||
finds any course/folder by name anywhere in the tree instead of
|
||||
expanding levels one by one (results get the same Move/Rename/Hide
|
||||
actions, just without an expand arrow, since a hit is somewhere specific
|
||||
rather than a level to browse into); every row also has a checkbox, so
|
||||
several items - found via search or expanded across different tree
|
||||
levels - can be hidden, shown, or moved to the same destination together
|
||||
in one batch instead of one at a time.
|
||||
- *Undo*: a "Last action: ... [Undo]" bar appears after any move or
|
||||
rename (Sort Unsorted apply, Bulk Rename apply, a Manage Library
|
||||
move/rename, a bulk move) and reverses the whole batch in one click.
|
||||
Only ever covers move/rename - hiding is already a one-click toggle with
|
||||
nothing to undo, and Delete is permanent by design, so it's deliberately
|
||||
never in this history no matter how "undo" gets framed. Re-checks each
|
||||
item before reversing it (the original spot may have been reused since),
|
||||
so a partial batch failure reports exactly what did and didn't reverse
|
||||
rather than silently doing nothing.
|
||||
- *Duplicate Courses*: scans every course in the library (Unsorted
|
||||
included) for names that look like the same thing filed twice, using the
|
||||
same tokenizer as Sort Unsorted so a platform-name or release-tag
|
||||
@@ -171,6 +187,9 @@ silently stay blank instead of erroring.
|
||||
the NAS instead of through the app (doing it through the app already
|
||||
keeps these in sync). Review individually or clear them all at once;
|
||||
course files themselves are never touched.
|
||||
- *Storage Usage*: disk usage per top-level library folder, largest
|
||||
first, with a simple proportional bar per entry - manually triggered
|
||||
(it reads every file's size) rather than run automatically.
|
||||
|
||||
**Dashboard**
|
||||
- Library-wide stats (courses, lessons completed, time watched, time
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026-08-24 17:56 UTC — version display fix
|
||||
2026-08-24 18:40 UTC — search, bulk actions, undo, storage usage
|
||||
|
||||
@@ -95,6 +95,34 @@ def cache_get_or_compute(key: str, compute, ttl: float = CACHE_TTL_SECONDS):
|
||||
return value
|
||||
|
||||
|
||||
# ---- Undo history for move/rename operations ----
|
||||
#
|
||||
# Only move and rename are ever recorded here - both are trivially
|
||||
# reversible (move/rename the result back). Hide/show is already a
|
||||
# one-click toggle with no need for "undo," and delete is permanent by
|
||||
# design, so it's never in this history no matter how it's framed in the
|
||||
# UI. Recorded as batches (not one entry per file) since the operations
|
||||
# that populate this - Sort Unsorted apply, Bulk Rename apply, a bulk
|
||||
# move - already act on several items as a single user action; undoing
|
||||
# should mirror that, not require clicking "undo" N times.
|
||||
_UNDO_HISTORY_MAX = 20
|
||||
_undo_history: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
def _record_undo_batch(label: str, entries: List[Dict[str, str]]) -> None:
|
||||
"""Push one reversible batch - entries are {'old_path', 'new_path'}
|
||||
pairs; the label is shown in the UI's Undo button. No-ops if entries
|
||||
is empty (e.g. every item in a batch failed)."""
|
||||
if not entries:
|
||||
return
|
||||
_undo_history.append({
|
||||
'label': label,
|
||||
'entries': entries,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
})
|
||||
del _undo_history[:-_UNDO_HISTORY_MAX]
|
||||
|
||||
|
||||
def invalidate_cache():
|
||||
"""Clear all cached library-scan results - call after anything that changes what's on disk (rename, hide/show, library path change)."""
|
||||
_cache_store.clear()
|
||||
@@ -3015,6 +3043,57 @@ def browse_library_manage():
|
||||
})
|
||||
|
||||
|
||||
def search_manage_tree(query: str, library_root: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Every course or category folder anywhere in the library whose name
|
||||
contains `query` (case-insensitive) - lets Manage Library jump
|
||||
straight to something instead of manually expanding the tree level by
|
||||
level. Includes hidden items (flagged 'hidden': True), unlike the
|
||||
normal Library browser - finding something specifically to un-hide it
|
||||
is exactly what this is for. Stops descending once a course is found,
|
||||
same as everywhere else, so a course's internal chapter folders never
|
||||
show up as if they were independently manageable.
|
||||
"""
|
||||
query_lower = query.lower().strip()
|
||||
if not query_lower:
|
||||
return []
|
||||
hidden_set = set(get_hidden_paths())
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
def walk(directory: Path):
|
||||
try:
|
||||
entries = sorted(
|
||||
(p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
||||
key=lambda p: p.name.lower()
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
return
|
||||
for entry in entries:
|
||||
is_course = _looks_like_course(entry)
|
||||
is_hidden = os.path.abspath(str(entry)) in hidden_set
|
||||
if query_lower in entry.name.lower():
|
||||
results.append({
|
||||
'type': 'course' if is_course else 'directory',
|
||||
'name': entry.name,
|
||||
'path': str(entry),
|
||||
'hidden': is_hidden,
|
||||
})
|
||||
if not is_course:
|
||||
walk(entry)
|
||||
|
||||
walk(Path(library_root))
|
||||
return results
|
||||
|
||||
|
||||
@app.route('/api/library/manage-search')
|
||||
def manage_search_api():
|
||||
"""Search endpoint backing Manage Library's search box (see
|
||||
search_manage_tree)."""
|
||||
query = request.args.get('q', '')
|
||||
library_root = os.path.abspath(get_library_root())
|
||||
return jsonify({'results': search_manage_tree(query, library_root)})
|
||||
|
||||
|
||||
@app.route('/library/thumbnail')
|
||||
def library_thumbnail():
|
||||
"""Serve a course's cover image (see find_course_thumbnail), if it has one."""
|
||||
@@ -3143,9 +3222,81 @@ def apply_unsorted_api():
|
||||
errors.append({'source': source, 'error': str(e)})
|
||||
|
||||
invalidate_cache()
|
||||
undo_entries = [{'old_path': m['source'], 'new_path': m['destination']} for m in moved]
|
||||
_record_undo_batch(f'Sort Unsorted ({len(moved)} item{"" if len(moved) == 1 else "s"})', undo_entries)
|
||||
return jsonify({'success': True, 'moved': len(moved), 'errors': errors})
|
||||
|
||||
|
||||
def _directory_size_bytes(directory: Path, max_depth: int = 20) -> int:
|
||||
"""Total size of every file anywhere under `directory` (bounded depth,
|
||||
same caution used elsewhere in File Management for deep/unusual
|
||||
trees). Skips anything it can't read rather than failing the whole
|
||||
scan - a NAS with mixed permissions shouldn't block a size report for
|
||||
everything else."""
|
||||
total = 0
|
||||
|
||||
def walk(d: Path, depth: int) -> None:
|
||||
nonlocal total
|
||||
try:
|
||||
entries = list(d.iterdir())
|
||||
except (PermissionError, OSError):
|
||||
return
|
||||
for e in entries:
|
||||
if e.name.startswith('.'):
|
||||
continue
|
||||
try:
|
||||
if e.is_file():
|
||||
total += e.stat().st_size
|
||||
elif e.is_dir() and depth < max_depth:
|
||||
walk(e, depth + 1)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
walk(directory, 0)
|
||||
return total
|
||||
|
||||
|
||||
def _format_bytes(n: int) -> str:
|
||||
"""Human-readable size, e.g. 12.3 GB - matches the precision people
|
||||
actually care about for a course library (no need for KB granularity)."""
|
||||
size = float(n)
|
||||
for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
|
||||
if size < 1024 or unit == 'TB':
|
||||
return f'{size:.0f} {unit}' if unit == 'B' else f'{size:.1f} {unit}'
|
||||
size /= 1024
|
||||
return f'{size:.1f} TB'
|
||||
|
||||
|
||||
@app.route('/api/library/storage-usage')
|
||||
def storage_usage_api():
|
||||
"""
|
||||
Disk usage per top-level library folder (category or a bare course
|
||||
sitting directly at the root), sorted largest-first, for spotting
|
||||
what's eating the most space on the NAS. Manually triggered like
|
||||
Duplicate Courses - a full recursive size scan touches every file in
|
||||
the library, too expensive to run automatically on page load.
|
||||
"""
|
||||
library_root = Path(get_library_root())
|
||||
try:
|
||||
top_level = sorted(
|
||||
(p for p in library_root.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
||||
key=lambda p: p.name.lower()
|
||||
)
|
||||
except (PermissionError, OSError) as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
entries = []
|
||||
for entry in top_level:
|
||||
if entry.name == UNSORTED_FOLDER_NAME:
|
||||
continue
|
||||
size = _directory_size_bytes(entry)
|
||||
entries.append({'name': entry.name, 'path': str(entry), 'bytes': size, 'human': _format_bytes(size)})
|
||||
|
||||
entries.sort(key=lambda e: e['bytes'], reverse=True)
|
||||
total_bytes = sum(e['bytes'] for e in entries)
|
||||
return jsonify({'entries': entries, 'total_bytes': total_bytes, 'total_human': _format_bytes(total_bytes)})
|
||||
|
||||
|
||||
@app.route('/api/library/categories')
|
||||
def library_categories_api():
|
||||
"""Every category/subcategory folder in the library, for destination
|
||||
@@ -3210,6 +3361,8 @@ def move_library_item_api():
|
||||
|
||||
invalidate_cache()
|
||||
rebase_library_path(source_abs, final_path)
|
||||
_record_undo_batch(f'Move "{os.path.basename(source_abs)}"',
|
||||
[{'old_path': source_abs, 'new_path': final_path}])
|
||||
|
||||
global current_course
|
||||
active_course_reset = False
|
||||
@@ -3222,6 +3375,145 @@ def move_library_item_api():
|
||||
return jsonify({'success': True, 'new_path': final_path, 'active_course_reset': active_course_reset})
|
||||
|
||||
|
||||
@app.route('/api/library/bulk-move', methods=['POST'])
|
||||
def bulk_move_library_items_api():
|
||||
"""
|
||||
Move several courses/folders already in the library to the same
|
||||
destination in one batch - the multi-select complement to
|
||||
/api/library/move for reorganizing many items at once. Same safety
|
||||
checks as a single move, applied per item; continues past individual
|
||||
failures and reports each outcome, the same pattern Bulk Rename uses.
|
||||
"""
|
||||
data = request.json or {}
|
||||
sources = data.get('sources') or []
|
||||
destination_relative = (data.get('destination') or '').strip().strip('/')
|
||||
if not isinstance(sources, list) or not sources or not destination_relative:
|
||||
return jsonify({'success': False, 'error': 'sources and destination are required'}), 400
|
||||
|
||||
library_root = os.path.abspath(get_library_root())
|
||||
dest_dir = os.path.abspath(os.path.join(library_root, destination_relative))
|
||||
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
|
||||
return jsonify({'success': False, 'error': 'Destination is outside the library'}), 403
|
||||
|
||||
global current_course
|
||||
results = []
|
||||
undo_entries = []
|
||||
active_course_reset = False
|
||||
|
||||
for source in sources:
|
||||
source_abs = os.path.abspath(source)
|
||||
result = {'source': source}
|
||||
|
||||
if not (source_abs == library_root or source_abs.startswith(library_root + os.sep)) or source_abs == library_root:
|
||||
result.update(success=False, error='Source is outside the library')
|
||||
elif dest_dir == source_abs or dest_dir.startswith(source_abs + os.sep):
|
||||
result.update(success=False, error='Cannot move a folder into itself')
|
||||
elif not os.path.isdir(source_abs):
|
||||
result.update(success=False, error='No longer exists')
|
||||
else:
|
||||
final_name = os.path.basename(source_abs)
|
||||
final_path = os.path.join(dest_dir, final_name)
|
||||
if final_path == source_abs:
|
||||
result.update(success=False, error='Already there')
|
||||
elif os.path.exists(final_path):
|
||||
result.update(success=False, error=f'"{final_name}" already exists at that destination')
|
||||
else:
|
||||
try:
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
shutil.move(source_abs, final_path)
|
||||
rebase_library_path(source_abs, final_path)
|
||||
undo_entries.append({'old_path': source_abs, 'new_path': final_path})
|
||||
result.update(success=True, new_path=final_path)
|
||||
if current_course is not None:
|
||||
course_abs = os.path.abspath(current_course.path)
|
||||
if course_abs == source_abs or course_abs.startswith(source_abs + os.sep):
|
||||
current_course = None
|
||||
active_course_reset = True
|
||||
except OSError as e:
|
||||
result.update(success=False, error=str(e))
|
||||
|
||||
results.append(result)
|
||||
|
||||
invalidate_cache()
|
||||
moved_count = len(undo_entries)
|
||||
_record_undo_batch(f'Bulk move ({moved_count} item{"" if moved_count == 1 else "s"}) to "{destination_relative}"',
|
||||
undo_entries)
|
||||
|
||||
return jsonify({'success': True, 'moved': moved_count, 'results': results, 'active_course_reset': active_course_reset})
|
||||
|
||||
|
||||
@app.route('/api/library/undo-last')
|
||||
def undo_last_status_api():
|
||||
"""Whether there's a move/rename batch available to undo, and what it
|
||||
was - lets the UI show a specific "Undo: <label>" instead of a blind
|
||||
button, or hide it entirely when there's nothing to undo."""
|
||||
if not _undo_history:
|
||||
return jsonify({'available': False})
|
||||
batch = _undo_history[-1]
|
||||
return jsonify({'available': True, 'label': batch['label'], 'count': len(batch['entries'])})
|
||||
|
||||
|
||||
@app.route('/api/library/undo-last', methods=['POST'])
|
||||
def undo_last_action_api():
|
||||
"""
|
||||
Reverse the most recent move/rename batch (Sort Unsorted apply, Bulk
|
||||
Rename apply, a Manage Library move/rename, a bulk move) as a single
|
||||
action, moving/renaming each entry back to where it came from.
|
||||
Re-checks every entry before touching it - the destination must still
|
||||
exist and the original location must still be free - since the
|
||||
filesystem may have changed since the batch was recorded; a partial
|
||||
failure still pops the batch (retrying the same undo again wouldn't
|
||||
help) and reports exactly what did and didn't reverse.
|
||||
"""
|
||||
if not _undo_history:
|
||||
return jsonify({'success': False, 'error': 'Nothing to undo'}), 400
|
||||
|
||||
batch = _undo_history.pop()
|
||||
library_root = os.path.abspath(get_library_root())
|
||||
results = []
|
||||
|
||||
global current_course
|
||||
active_course_reset = False
|
||||
|
||||
for entry in reversed(batch['entries']):
|
||||
old_abs = os.path.abspath(entry['old_path'])
|
||||
new_abs = os.path.abspath(entry['new_path'])
|
||||
result = {'old_path': entry['old_path'], 'new_path': entry['new_path']}
|
||||
|
||||
if not (new_abs == library_root or new_abs.startswith(library_root + os.sep)):
|
||||
result.update(success=False, error='Outside the library')
|
||||
elif not os.path.isdir(new_abs):
|
||||
result.update(success=False, error='No longer exists - already moved or renamed again?')
|
||||
elif os.path.exists(old_abs):
|
||||
result.update(success=False, error='Original location is occupied again')
|
||||
else:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(old_abs), exist_ok=True)
|
||||
shutil.move(new_abs, old_abs)
|
||||
rebase_library_path(new_abs, old_abs)
|
||||
result['success'] = True
|
||||
if current_course is not None:
|
||||
course_abs = os.path.abspath(current_course.path)
|
||||
if course_abs == new_abs or course_abs.startswith(new_abs + os.sep):
|
||||
current_course = None
|
||||
active_course_reset = True
|
||||
except OSError as e:
|
||||
result.update(success=False, error=str(e))
|
||||
|
||||
results.append(result)
|
||||
|
||||
invalidate_cache()
|
||||
reversed_count = sum(1 for r in results if r['success'])
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'label': batch['label'],
|
||||
'reversed': reversed_count,
|
||||
'total': len(results),
|
||||
'results': results,
|
||||
'active_course_reset': active_course_reset,
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/library/duplicates')
|
||||
def duplicate_courses_api():
|
||||
"""Scan for courses that look like copies of each other by name (see
|
||||
@@ -3537,6 +3829,9 @@ def rename_path_api():
|
||||
return jsonify({'error': 'path is required'}), 400
|
||||
|
||||
result = perform_rename(path, new_name)
|
||||
if result.get('success'):
|
||||
_record_undo_batch(f'Rename "{os.path.basename(path)}"',
|
||||
[{'old_path': os.path.abspath(path), 'new_path': result['new_path']}])
|
||||
status = result.pop('status')
|
||||
return jsonify(result), status
|
||||
|
||||
@@ -3658,6 +3953,7 @@ def bulk_rename_apply_api():
|
||||
return jsonify({'error': 'items is required'}), 400
|
||||
|
||||
results = []
|
||||
undo_entries = []
|
||||
for item in items:
|
||||
path = item.get('path', '')
|
||||
new_name = item.get('new_name', '')
|
||||
@@ -3666,8 +3962,13 @@ def bulk_rename_apply_api():
|
||||
outcome['path'] = path
|
||||
outcome['old_name'] = item.get('old_name', '')
|
||||
outcome['new_name'] = new_name
|
||||
if outcome.get('success'):
|
||||
undo_entries.append({'old_path': os.path.abspath(path), 'new_path': outcome['new_path']})
|
||||
results.append(outcome)
|
||||
|
||||
succeeded = len(undo_entries)
|
||||
_record_undo_batch(f'Bulk rename ({succeeded} item{"" if succeeded == 1 else "s"})', undo_entries)
|
||||
|
||||
return jsonify({'results': results})
|
||||
|
||||
|
||||
|
||||
+458
-52
@@ -286,6 +286,70 @@
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.undo-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.manage-bulk-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.manage-bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.storage-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.storage-row:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
.storage-row-name {
|
||||
flex: 0 0 160px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.storage-bar-track {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.storage-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 5px;
|
||||
}
|
||||
.storage-row-size {
|
||||
flex: 0 0 70px;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -501,6 +565,11 @@
|
||||
<h1>{{ icons.icon('folder', 24) }} File Management</h1>
|
||||
<p class="subtitle">Sort new courses, refresh the library cache, bulk-rename, hide/move/rename anything already filed, check for duplicates, and clean up stale references. Nothing changes on disk until you review and apply it.</p>
|
||||
|
||||
<div id="undo-bar" class="undo-bar">
|
||||
<span id="undo-label"></span>
|
||||
<button class="btn btn-secondary btn-sm" id="undo-btn" onclick="performUndo()">{{ icons.icon('refresh', 14) }} Undo</button>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">{{ icons.icon('folder', 18) }} Sort Unsorted</h2>
|
||||
<p class="section-desc">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure.</p>
|
||||
|
||||
@@ -555,16 +624,45 @@
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">{{ icons.icon('folder', 18) }} Manage Library</h2>
|
||||
<p class="section-desc">Hide courses or whole folders from the Library browser without touching anything on disk (hiding a folder hides everything inside it), or rename/move a course or folder directly - no need to go to the NAS.</p>
|
||||
<p class="section-desc">Hide courses or whole folders from the Library browser without touching anything on disk (hiding a folder hides everything inside it), or rename/move a course or folder directly - no need to go to the NAS. Select several to hide or move at once.</p>
|
||||
<div class="card">
|
||||
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
|
||||
<div id="hidden-list"></div>
|
||||
</div>
|
||||
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
|
||||
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
|
||||
<div id="curate-tree"></div>
|
||||
<div class="toolbar" style="margin-bottom: 10px;">
|
||||
<input type="text" id="manage-search-input" class="text-input" placeholder="Search courses and folders…" oninput="onManageSearchInput()">
|
||||
<button class="btn btn-secondary btn-sm" id="manage-search-clear" onclick="clearManageSearch()" style="display: none;">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="manage-bulk-bar" class="manage-bulk-bar">
|
||||
<span id="manage-bulk-count"></span>
|
||||
<div class="manage-bulk-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="bulkSetHidden(true)">Hide Selected</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="bulkSetHidden(false)">Show Selected</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="startBulkMove()">Move Selected…</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="clearManageSelection()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="manage-bulk-move-panel"></div>
|
||||
|
||||
<div id="manage-browse-wrap">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
|
||||
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
|
||||
<div id="curate-tree"></div>
|
||||
</div>
|
||||
<div id="manage-search-results" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">{{ icons.icon('bar-chart', 18) }} Storage Usage</h2>
|
||||
<p class="section-desc">Disk usage per top-level library folder, largest first - handy for spotting what's eating the most space on the NAS. Scans every file in the library, so it's a manual trigger rather than something that runs automatically.</p>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button>
|
||||
<span id="storage-status"></span>
|
||||
</div>
|
||||
<div id="storage-results"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Courses</h2>
|
||||
@@ -878,6 +976,7 @@
|
||||
console.warn('Unsorted apply errors:', data.errors);
|
||||
}
|
||||
loadCategories().then(() => scanUnsorted());
|
||||
checkUndoStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
@@ -1015,6 +1114,7 @@
|
||||
loadHiddenList();
|
||||
loadCategories();
|
||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||
checkUndoStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.color = 'var(--error)';
|
||||
@@ -1022,6 +1122,254 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Undo last move/rename ----
|
||||
function checkUndoStatus() {
|
||||
fetch('/api/library/undo-last')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const bar = document.getElementById('undo-bar');
|
||||
if (data.available) {
|
||||
document.getElementById('undo-label').textContent = `Last action: ${data.label}`;
|
||||
bar.style.display = 'flex';
|
||||
} else {
|
||||
bar.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function performUndo() {
|
||||
const btn = document.getElementById('undo-btn');
|
||||
btn.disabled = true;
|
||||
fetch('/api/library/undo-last', { method: 'POST' })
|
||||
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
btn.disabled = false;
|
||||
if (!ok || !data.success) {
|
||||
alert((data && data.error) || 'Undo failed');
|
||||
checkUndoStatus();
|
||||
return;
|
||||
}
|
||||
if (data.active_course_reset) {
|
||||
alert('That undo affected your active course - reload the main page to pick it up.');
|
||||
}
|
||||
const failed = (data.results || []).filter(r => !r.success);
|
||||
if (failed.length) {
|
||||
alert(`Undid ${data.reversed} of ${data.total} - ${failed.length} couldn't be reversed (see console).`);
|
||||
console.warn('Undo errors:', failed);
|
||||
}
|
||||
loadHiddenList();
|
||||
loadCategories();
|
||||
refreshManageView();
|
||||
checkUndoStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
alert('Could not reach the server.');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Manage Library: bulk selection ----
|
||||
let selectedManagePaths = new Map(); // path -> name
|
||||
|
||||
function curateCheckboxHtml(item) {
|
||||
const checked = selectedManagePaths.has(item.path) ? 'checked' : '';
|
||||
return `<input type="checkbox" class="sort-row-check curate-select" data-path="${escapeAttr(item.path)}" data-name="${escapeAttr(item.name)}" ${checked} onclick="event.stopPropagation(); toggleManageSelect(this)">`;
|
||||
}
|
||||
|
||||
function toggleManageSelect(checkboxEl) {
|
||||
const path = checkboxEl.dataset.path;
|
||||
if (checkboxEl.checked) {
|
||||
selectedManagePaths.set(path, checkboxEl.dataset.name);
|
||||
} else {
|
||||
selectedManagePaths.delete(path);
|
||||
}
|
||||
updateManageBulkBar();
|
||||
}
|
||||
|
||||
function clearManageSelection() {
|
||||
selectedManagePaths.clear();
|
||||
document.querySelectorAll('.curate-select').forEach(cb => { cb.checked = false; });
|
||||
updateManageBulkBar();
|
||||
}
|
||||
|
||||
function updateManageBulkBar() {
|
||||
const bar = document.getElementById('manage-bulk-bar');
|
||||
const count = selectedManagePaths.size;
|
||||
document.getElementById('manage-bulk-count').textContent = `${count} selected`;
|
||||
bar.style.display = count > 0 ? 'flex' : 'none';
|
||||
if (count === 0) {
|
||||
document.getElementById('manage-bulk-move-panel').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function isManageSearchActive() {
|
||||
return document.getElementById('manage-search-results').style.display !== 'none';
|
||||
}
|
||||
|
||||
// Bulk actions can touch items selected from several different
|
||||
// expanded tree levels (or search results) at once, so there's no
|
||||
// single "level" left to refresh afterward - just reload whichever
|
||||
// view is currently showing from scratch.
|
||||
function refreshManageView() {
|
||||
if (isManageSearchActive()) {
|
||||
runManageSearch();
|
||||
} else {
|
||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||
}
|
||||
}
|
||||
|
||||
function bulkSetHidden(hidden) {
|
||||
const paths = Array.from(selectedManagePaths.keys());
|
||||
if (!paths.length) return;
|
||||
fetch('/api/hidden-paths/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths, hidden })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
clearManageSelection();
|
||||
loadHiddenList();
|
||||
refreshManageView();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function startBulkMove() {
|
||||
const panelWrap = document.getElementById('manage-bulk-move-panel');
|
||||
if (panelWrap.innerHTML.trim()) {
|
||||
panelWrap.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const count = selectedManagePaths.size;
|
||||
const optionsHtml = categoryOptionsHtml();
|
||||
panelWrap.innerHTML = `
|
||||
<div class="curate-move-panel">
|
||||
<div class="sort-field">
|
||||
<label>Move ${count} selected item${count === 1 ? '' : 's'} to</label>
|
||||
<div class="dest-picker">
|
||||
${ICON_SVGS.folder}
|
||||
<select class="move-dest-select">
|
||||
${optionsHtml}
|
||||
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
|
||||
</select>
|
||||
</div>
|
||||
${newFolderFieldsHtml(optionsHtml, '', '', !optionsHtml)}
|
||||
</div>
|
||||
<div class="curate-move-actions">
|
||||
<button class="btn btn-sm">Move</button>
|
||||
<button class="btn btn-secondary btn-sm">Cancel</button>
|
||||
</div>
|
||||
<div class="curate-move-status"></div>
|
||||
</div>
|
||||
`;
|
||||
const panel = panelWrap.querySelector('.curate-move-panel');
|
||||
const select = panel.querySelector('.move-dest-select');
|
||||
wireNewFolderFields(select, panel.querySelector('.sort-field'));
|
||||
if (!optionsHtml) {
|
||||
select.value = NEW_FOLDER_VALUE;
|
||||
panel.querySelector('.new-folder-name').focus();
|
||||
}
|
||||
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
|
||||
cancelBtn.onclick = () => { panelWrap.innerHTML = ''; };
|
||||
moveBtn.onclick = () => submitBulkMove(panel);
|
||||
}
|
||||
|
||||
function submitBulkMove(panel) {
|
||||
const select = panel.querySelector('.move-dest-select');
|
||||
const fieldsWrap = panel.querySelector('.sort-field');
|
||||
const status = panel.querySelector('.curate-move-status');
|
||||
const usingNew = select.value === NEW_FOLDER_VALUE;
|
||||
const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim();
|
||||
if (!destination) {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = 'Choose or type a destination first.';
|
||||
if (usingNew) panel.querySelector('.new-folder-name').focus();
|
||||
return;
|
||||
}
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'Moving…';
|
||||
const sources = Array.from(selectedManagePaths.keys());
|
||||
fetch('/api/library/bulk-move', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sources, destination })
|
||||
})
|
||||
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||
.then(({ ok, data }) => {
|
||||
if (!ok || !data.success) {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = (data && data.error) || 'Move failed';
|
||||
return;
|
||||
}
|
||||
if (data.active_course_reset) {
|
||||
alert('Your active course was moved - reload the main page to pick it up under its new location.');
|
||||
}
|
||||
const failedCount = (data.results || []).filter(r => !r.success).length;
|
||||
clearManageSelection();
|
||||
loadHiddenList();
|
||||
loadCategories();
|
||||
refreshManageView();
|
||||
checkUndoStatus();
|
||||
if (failedCount) {
|
||||
alert(`Moved ${data.moved}, ${failedCount} failed - see console for details.`);
|
||||
console.warn('Bulk move errors:', data.results.filter(r => !r.success));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = 'Could not reach the server.';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Manage Library: search ----
|
||||
let manageSearchDebounce = null;
|
||||
|
||||
function onManageSearchInput() {
|
||||
clearTimeout(manageSearchDebounce);
|
||||
const query = document.getElementById('manage-search-input').value.trim();
|
||||
document.getElementById('manage-search-clear').style.display = query ? 'inline-flex' : 'none';
|
||||
if (!query) {
|
||||
clearManageSearch();
|
||||
return;
|
||||
}
|
||||
manageSearchDebounce = setTimeout(runManageSearch, 250);
|
||||
}
|
||||
|
||||
function runManageSearch() {
|
||||
const query = document.getElementById('manage-search-input').value.trim();
|
||||
if (!query) return;
|
||||
const resultsEl = document.getElementById('manage-search-results');
|
||||
document.getElementById('manage-browse-wrap').style.display = 'none';
|
||||
resultsEl.style.display = 'block';
|
||||
fetch(`/api/library/manage-search?q=${encodeURIComponent(query)}`)
|
||||
.then(r => r.json())
|
||||
.then(data => renderManageSearchResults(data.results || []))
|
||||
.catch(() => {
|
||||
resultsEl.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
function clearManageSearch() {
|
||||
document.getElementById('manage-search-input').value = '';
|
||||
document.getElementById('manage-search-clear').style.display = 'none';
|
||||
document.getElementById('manage-search-results').style.display = 'none';
|
||||
document.getElementById('manage-search-results').innerHTML = '';
|
||||
document.getElementById('manage-browse-wrap').style.display = 'block';
|
||||
}
|
||||
|
||||
function renderManageSearchResults(items) {
|
||||
const container = document.getElementById('manage-search-results');
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="curate-empty-hint">No matches.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(item => curateRowHtml(item, false)).join('');
|
||||
}
|
||||
|
||||
// ---- Manage Library: hide/show courses & folders ----
|
||||
function loadHiddenList() {
|
||||
fetch('/api/hidden-paths')
|
||||
@@ -1063,45 +1411,54 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Shared by the tree (expandable=true) and search results
|
||||
// (expandable=false, since a flat hit list has nowhere to expand
|
||||
// into) - keeps Move/Rename/Hide/select behavior identical either
|
||||
// way instead of maintaining two versions of the same row markup.
|
||||
function curateRowHtml(item, expandable) {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const safeName = item.name.replace(/'/g, "\\'");
|
||||
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
|
||||
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
||||
const checkbox = curateCheckboxHtml(item);
|
||||
const moveBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startMove(this, '${safePath}', '${safeName}')">Move</button>`;
|
||||
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
|
||||
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
|
||||
const actions = `<div class="curate-actions">${moveBtn}${renameBtn}${toggleBtn}</div>`;
|
||||
|
||||
if (item.type === 'course' || !expandable) {
|
||||
return `
|
||||
<div class="curate-row ${hiddenClass}">
|
||||
${checkbox}
|
||||
<div class="curate-row-name">
|
||||
<span>${icon}</span>
|
||||
<span class="name">${escapeHtml(item.name)}</span>
|
||||
</div>
|
||||
${actions}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
|
||||
${checkbox}
|
||||
<div class="curate-row-name">
|
||||
<span class="curate-toggle">▶</span>
|
||||
<span>${icon}</span>
|
||||
<span class="name">${escapeHtml(item.name)}</span>
|
||||
</div>
|
||||
${actions}
|
||||
</div>
|
||||
<div class="curate-children" data-loaded="false"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCurateLevel(data, container) {
|
||||
if (!data.items || data.items.length === 0) {
|
||||
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
|
||||
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.items.map(item => {
|
||||
const safePath = item.path.replace(/'/g, "\\'");
|
||||
const safeName = item.name.replace(/'/g, "\\'");
|
||||
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
|
||||
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
||||
const moveBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startMove(this, '${safePath}', '${safeName}')">Move</button>`;
|
||||
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
|
||||
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
|
||||
const actions = `<div class="curate-actions">${moveBtn}${renameBtn}${toggleBtn}</div>`;
|
||||
|
||||
if (item.type === 'course') {
|
||||
return `
|
||||
<div class="curate-row ${hiddenClass}">
|
||||
<div class="curate-row-name">
|
||||
<span>${icon}</span>
|
||||
<span class="name">${item.name}</span>
|
||||
</div>
|
||||
${actions}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
|
||||
<div class="curate-row-name">
|
||||
<span class="curate-toggle">▶</span>
|
||||
<span>${icon}</span>
|
||||
<span class="name">${item.name}</span>
|
||||
</div>
|
||||
${actions}
|
||||
</div>
|
||||
<div class="curate-children" data-loaded="false"></div>
|
||||
`;
|
||||
}).join('');
|
||||
container.innerHTML = data.items.map(item => curateRowHtml(item, true)).join('');
|
||||
}
|
||||
|
||||
function toggleCurateDir(rowEl, path) {
|
||||
@@ -1124,6 +1481,22 @@
|
||||
loadCurateLevel(path, content, false);
|
||||
}
|
||||
|
||||
// Refreshes wherever a single-row action happened: the tree level
|
||||
// it was in (root or one expanded folder), or re-runs the search
|
||||
// if that's the active view. Bulk actions use refreshManageView()
|
||||
// instead, since a bulk selection can span several levels at once.
|
||||
function refreshManageContainer(btnEl) {
|
||||
if (isManageSearchActive()) {
|
||||
runManageSearch();
|
||||
return;
|
||||
}
|
||||
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
|
||||
: document.getElementById('curate-tree');
|
||||
const isRootContainer = container.id === 'curate-tree';
|
||||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||||
loadCurateLevel(refreshPath, container, isRootContainer);
|
||||
}
|
||||
|
||||
function toggleHidden(path, hidden, btnEl) {
|
||||
fetch('/api/hidden-paths', {
|
||||
method: 'POST',
|
||||
@@ -1134,13 +1507,7 @@
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
loadHiddenList();
|
||||
// Refresh just the level this toggle happened in, rather
|
||||
// than collapsing the whole tree back to root.
|
||||
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
|
||||
: document.getElementById('curate-tree');
|
||||
const isRootContainer = container.id === 'curate-tree';
|
||||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||||
loadCurateLevel(refreshPath, container, isRootContainer);
|
||||
refreshManageContainer(btnEl);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -1202,10 +1569,8 @@
|
||||
alert('That was your active course - reload the main page to pick it up under its new name.');
|
||||
}
|
||||
loadHiddenList();
|
||||
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
|
||||
const isRootContainer = container.id === 'curate-tree';
|
||||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||||
loadCurateLevel(refreshPath, container, isRootContainer);
|
||||
refreshManageContainer(btnEl);
|
||||
checkUndoStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
input.replaceWith(nameSpan);
|
||||
@@ -1300,10 +1665,8 @@
|
||||
}
|
||||
loadHiddenList();
|
||||
loadCategories();
|
||||
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
|
||||
const isRootContainer = container.id === 'curate-tree';
|
||||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||||
loadCurateLevel(refreshPath, container, isRootContainer);
|
||||
refreshManageContainer(btnEl);
|
||||
checkUndoStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.color = 'var(--error)';
|
||||
@@ -1311,6 +1674,48 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Storage Usage ----
|
||||
function scanStorageUsage() {
|
||||
const btn = document.getElementById('storage-scan-btn');
|
||||
const status = document.getElementById('storage-status');
|
||||
const results = document.getElementById('storage-results');
|
||||
btn.disabled = true;
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'Scanning… this reads every file, may take a moment.';
|
||||
results.innerHTML = '';
|
||||
fetch('/api/library/storage-usage')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
if (data.error) {
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = data.error;
|
||||
return;
|
||||
}
|
||||
const entries = data.entries || [];
|
||||
if (!entries.length) {
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'Nothing found.';
|
||||
return;
|
||||
}
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = `Total: ${data.total_human}`;
|
||||
const maxBytes = Math.max(...entries.map(e => e.bytes), 1);
|
||||
results.innerHTML = entries.map(e => `
|
||||
<div class="storage-row">
|
||||
<span class="storage-row-name">${escapeHtml(e.name)}</span>
|
||||
<div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div>
|
||||
<span class="storage-row-size">${escapeHtml(e.human)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
status.style.color = 'var(--error)';
|
||||
status.textContent = 'Could not reach the server.';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Duplicate Courses ----
|
||||
function scanDuplicates() {
|
||||
const btn = document.getElementById('dup-scan-btn');
|
||||
@@ -1578,6 +1983,7 @@
|
||||
loadCategories().then(() => scanUnsorted());
|
||||
loadHiddenList();
|
||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||
checkUndoStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user