Course tree (lesson-page sidebar + dashboard's loaded-course view) and File Management's Manage Library browser both move from boxed, bordered rows to a flat list: no per-row background/border, a small leading chevron that doubles as the expand affordance instead of a separate right-edge button, indentation for hierarchy. The chevron's rotation is pure CSS (:has(+ .tree-content.expanded) / :has(+ .curate-children.expanded)) rather than JS swapping glyph text, which also let a fair amount of now-redundant JS come out. The course tree's new styles are scoped under .tree-container (course dashboard) and .lesson-sidebar (lesson page) so they don't leak into the Library folder browser and other rows that reuse the same base .tree-header/.lesson-item/etc. class names elsewhere in course_dashboard.html - confirmed those are visually untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2033 lines
89 KiB
HTML
2033 lines
89 KiB
HTML
{% import '_icons.html' as icons %}
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>File Management - OfflineU</title>
|
|
<link rel="manifest" href="/static/manifest.json">
|
|
<meta name="theme-color" content="#007acc">
|
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
|
<style>
|
|
:root {
|
|
--bg-primary: #1a1a1a;
|
|
--bg-secondary: #2d2d2d;
|
|
--bg-tertiary: #3d3d3d;
|
|
--bg-tertiary-hover: #404040;
|
|
--text-primary: #e0e0e0;
|
|
--text-muted: #999;
|
|
--border-color: #555;
|
|
--accent: #007acc;
|
|
--accent-hover: #005a9e;
|
|
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
--font-size-base: 16px;
|
|
--container-max-width: 1600px;
|
|
--radius: 8px;
|
|
--success: #28a745;
|
|
--error: #ff6b6b;
|
|
}
|
|
[data-theme="light"] {
|
|
--bg-primary: #f2f2f2;
|
|
--bg-secondary: #ffffff;
|
|
--bg-tertiary: #eeeeee;
|
|
--bg-tertiary-hover: #e2e2e2;
|
|
--text-primary: #222222;
|
|
--text-muted: #666666;
|
|
--border-color: #cccccc;
|
|
}
|
|
[data-card-style="elevated"] .card {
|
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
|
}
|
|
[data-card-style="bordered"] .card {
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
font-family: var(--font-family);
|
|
font-size: var(--font-size-base);
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
}
|
|
.page-content {
|
|
flex: 1;
|
|
padding: 20px;
|
|
padding-bottom: 90px;
|
|
}
|
|
.app-header {
|
|
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
|
|
padding: 18px 0;
|
|
border-bottom: 3px solid var(--accent);
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
|
}
|
|
.app-header .header-inner {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 0 20px;
|
|
}
|
|
.app-header .brand-mark { flex-shrink: 0; display: block; }
|
|
.app-header a.brand-link {
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
font-size: 1.3em;
|
|
font-weight: 600;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.icon {
|
|
display: inline-block;
|
|
vertical-align: -3px;
|
|
flex-shrink: 0;
|
|
}
|
|
.container {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
}
|
|
h1 {
|
|
color: var(--accent);
|
|
margin-bottom: 5px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.subtitle { color: var(--text-muted); margin-bottom: 20px; }
|
|
.card {
|
|
background: var(--bg-secondary);
|
|
border-radius: var(--radius);
|
|
padding: 20px 25px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.section-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 1.15em;
|
|
color: var(--text-primary);
|
|
margin: 34px 0 6px;
|
|
}
|
|
.section-title:first-of-type { margin-top: 0; }
|
|
.section-desc {
|
|
color: var(--text-muted);
|
|
font-size: 0.92em;
|
|
margin: 0 0 14px;
|
|
}
|
|
.section-desc code {
|
|
background: var(--bg-tertiary);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
font-family: monospace;
|
|
}
|
|
.text-input {
|
|
flex: 1;
|
|
min-width: 160px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 8px 10px;
|
|
font-size: 0.9em;
|
|
font-family: var(--font-family);
|
|
}
|
|
.bulk-rename-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border-radius: var(--radius);
|
|
background: var(--bg-tertiary);
|
|
margin-bottom: 5px;
|
|
}
|
|
.bulk-rename-row-names {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-wrap: break-word;
|
|
word-break: break-word;
|
|
}
|
|
.bulk-rename-old {
|
|
color: var(--text-muted);
|
|
font-size: 0.9em;
|
|
text-decoration: line-through;
|
|
}
|
|
.bulk-rename-new {
|
|
color: var(--text-primary);
|
|
}
|
|
.curate-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
padding: 5px 8px;
|
|
border-radius: var(--radius);
|
|
background: none;
|
|
margin-bottom: 0;
|
|
cursor: pointer;
|
|
}
|
|
.curate-row:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
.curate-row.is-hidden {
|
|
opacity: 0.55;
|
|
}
|
|
.curate-row-name {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.curate-row-name span.name {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
/* Leading chevron doubles as the expand affordance (matches the
|
|
course tree's leaner style - see _course_tree.html) and rotates
|
|
via :has() rather than any JS touching it directly; toggling is
|
|
just add/remove of one class on .curate-children. */
|
|
.curate-toggle {
|
|
display: inline-block;
|
|
width: 12px;
|
|
flex-shrink: 0;
|
|
color: var(--accent);
|
|
font-size: 0.85em;
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.curate-row:has(+ .curate-children.expanded) .curate-toggle {
|
|
transform: rotate(90deg);
|
|
}
|
|
.curate-children {
|
|
margin-left: 16px;
|
|
margin-top: 1px;
|
|
display: none;
|
|
}
|
|
.curate-children.expanded {
|
|
display: block;
|
|
}
|
|
.curate-empty-hint {
|
|
color: var(--text-muted);
|
|
font-size: 0.9em;
|
|
padding: 6px 0;
|
|
}
|
|
.curate-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
flex-shrink: 0;
|
|
}
|
|
.curate-rename-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius);
|
|
padding: 4px 8px;
|
|
font-size: 0.95em;
|
|
font-family: var(--font-family);
|
|
}
|
|
.curate-row-status {
|
|
flex-basis: 100%;
|
|
font-size: 0.8em;
|
|
color: var(--error);
|
|
}
|
|
.curate-move-panel {
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius);
|
|
padding: 12px 14px;
|
|
margin: -3px 0 8px 0;
|
|
}
|
|
.curate-move-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
}
|
|
.curate-move-status {
|
|
font-size: 0.82em;
|
|
margin-top: 6px;
|
|
min-height: 1.1em;
|
|
}
|
|
.dup-group {
|
|
background: var(--bg-tertiary);
|
|
border-radius: var(--radius);
|
|
padding: 12px 14px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.dup-group-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
font-size: 0.78em;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
color: var(--accent);
|
|
margin-bottom: 8px;
|
|
}
|
|
.dup-course-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 6px 0;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
.dup-course-row:first-of-type {
|
|
border-top: none;
|
|
}
|
|
.dup-course-name {
|
|
font-weight: 600;
|
|
}
|
|
.dup-course-path {
|
|
flex: 1;
|
|
min-width: 160px;
|
|
color: var(--text-muted);
|
|
font-size: 0.8em;
|
|
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-clickable {
|
|
cursor: pointer;
|
|
}
|
|
.storage-row-clickable:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
.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;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.btn {
|
|
background: var(--accent);
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 14px;
|
|
font-family: var(--font-family);
|
|
transition: background 0.3s;
|
|
}
|
|
.btn:hover { background: var(--accent-hover); }
|
|
.btn:disabled { background: #666; cursor: not-allowed; }
|
|
.btn-secondary {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
}
|
|
.btn-secondary:hover { background: var(--bg-tertiary-hover); }
|
|
.btn-danger {
|
|
background: transparent;
|
|
color: var(--error);
|
|
border: 1px solid var(--error);
|
|
}
|
|
.btn-danger:hover {
|
|
background: rgba(255, 107, 107, 0.15);
|
|
}
|
|
.btn-sm {
|
|
padding: 6px 12px;
|
|
font-size: 0.85em;
|
|
}
|
|
#scan-status {
|
|
font-size: 0.9em;
|
|
color: var(--text-muted);
|
|
}
|
|
.empty-hint {
|
|
color: var(--text-muted);
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
}
|
|
.sort-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 12px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: var(--radius);
|
|
padding: 14px 16px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.sort-row-check {
|
|
width: 18px;
|
|
height: 18px;
|
|
flex-shrink: 0;
|
|
cursor: pointer;
|
|
}
|
|
.sort-row-main {
|
|
flex: 1;
|
|
min-width: 220px;
|
|
}
|
|
.sort-row-name {
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.sort-badge {
|
|
font-size: 0.72em;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
white-space: nowrap;
|
|
}
|
|
.sort-badge.existing { background: rgba(40, 167, 69, 0.18); color: var(--success); border: 1px solid var(--success); }
|
|
.sort-badge.new_folder { background: rgba(0, 122, 204, 0.15); color: var(--accent); border: 1px solid var(--accent); }
|
|
.sort-badge.manual { background: rgba(255, 107, 107, 0.15); color: var(--error); border: 1px solid var(--error); }
|
|
.sort-row-reason {
|
|
font-size: 0.82em;
|
|
color: var(--text-muted);
|
|
margin-top: 4px;
|
|
}
|
|
.sort-row-fields {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 14px;
|
|
margin-top: 10px;
|
|
}
|
|
.sort-field {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
.sort-field label {
|
|
font-size: 0.72em;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
color: var(--text-muted);
|
|
}
|
|
.sort-field input,
|
|
.sort-field select {
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 8px 10px;
|
|
font-size: 0.9em;
|
|
font-family: var(--font-family);
|
|
width: 100%;
|
|
}
|
|
.dest-picker {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.dest-picker select { flex: 1; }
|
|
.new-folder-fields {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 6px;
|
|
}
|
|
.new-folder-fields .new-folder-parent,
|
|
.new-folder-fields .new-folder-name {
|
|
flex: 1;
|
|
min-width: 140px;
|
|
}
|
|
.new-folder-name.empty {
|
|
border-color: var(--error);
|
|
}
|
|
.new-folder-preview {
|
|
font-size: 0.8em;
|
|
color: var(--text-muted);
|
|
margin-top: 5px;
|
|
min-height: 1.1em;
|
|
}
|
|
.new-folder-preview.ready {
|
|
color: var(--accent);
|
|
}
|
|
.action-bar {
|
|
position: sticky;
|
|
bottom: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
background: var(--bg-secondary);
|
|
border-top: 1px solid var(--border-color);
|
|
padding: 14px 20px;
|
|
margin: 0 -25px -20px;
|
|
border-radius: 0 0 var(--radius) var(--radius);
|
|
}
|
|
#apply-status {
|
|
font-size: 0.85em;
|
|
color: var(--text-muted);
|
|
}
|
|
.bottom-tab-bar {
|
|
position: fixed;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 9000;
|
|
display: flex;
|
|
background: var(--bg-secondary);
|
|
border-top: 1px solid var(--border-color);
|
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
}
|
|
.tab-item {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 8px 4px;
|
|
color: var(--text-muted);
|
|
text-decoration: none;
|
|
font-size: 0.72em;
|
|
transition: color 0.2s;
|
|
}
|
|
.tab-item:hover,
|
|
.tab-item.active {
|
|
color: var(--accent);
|
|
}
|
|
/* Visible keyboard-focus ring - see course_dashboard.html for why
|
|
:focus-visible (keyboard/switch only, not mouse clicks). */
|
|
a:focus-visible, button:focus-visible, input:focus-visible,
|
|
select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 2px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-header">
|
|
<div class="header-inner">
|
|
<svg class="brand-mark" viewBox="0 0 64 64" width="28" height="28" aria-hidden="true">
|
|
<rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect>
|
|
<path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path>
|
|
</svg>
|
|
<a href="/reset_course" class="brand-link">OfflineU</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="page-content">
|
|
<div class="container">
|
|
<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>
|
|
|
|
<div class="card">
|
|
<div class="toolbar">
|
|
<button class="btn" id="scan-btn" onclick="scanUnsorted()">{{ icons.icon('refresh', 14) }} Scan Unsorted Folder</button>
|
|
<span id="scan-status"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="results-card" class="card" style="display: none;">
|
|
<div class="toolbar" style="margin-bottom: 14px; justify-content: space-between;">
|
|
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.9em; color: var(--text-muted);">
|
|
<input type="checkbox" id="select-all" class="sort-row-check" onchange="toggleSelectAll(this.checked)">
|
|
Select all
|
|
</label>
|
|
<span id="result-summary" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
|
</div>
|
|
<div id="sort-rows"></div>
|
|
<div class="action-bar">
|
|
<span id="apply-status"></span>
|
|
<button class="btn" id="apply-btn" onclick="applySelected()">{{ icons.icon('check', 14) }} Apply Selected</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="empty-state" class="empty-hint" style="display: none;"></div>
|
|
|
|
<h2 class="section-title">{{ icons.icon('refresh', 18) }} Refresh Library</h2>
|
|
<p class="section-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</p>
|
|
<div class="card">
|
|
<div class="toolbar">
|
|
<button class="btn btn-secondary" id="refresh-library-btn" onclick="refreshLibraryCache()">{{ icons.icon('refresh', 14) }} Refresh Library</button>
|
|
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="section-title">{{ icons.icon('pencil', 18) }} Bulk Rename</h2>
|
|
<p class="section-desc">Find and replace text across every course/folder name in the library at once - handy for stripping a release-group suffix off a batch of downloads. Wildcard or regex mode can match several slightly-different patterns in one pass, e.g. <code>.BOOKWARE*</code> catching <code>.BOOKWARE-GETH</code>, <code>.BOOKWARE-BOOKTIME</code>, <code>.BOOKWARE-BLZiSO</code>, etc. at once.</p>
|
|
<div class="card">
|
|
<div class="toolbar" style="margin-bottom: 10px;">
|
|
<select id="bulk-rename-mode" class="text-input" style="flex: 0 0 auto; min-width: 130px;" onchange="updateBulkRenamePlaceholder()">
|
|
<option value="plain">Plain text</option>
|
|
<option value="wildcard">Wildcard (*, ?)</option>
|
|
<option value="regex">Regex</option>
|
|
</select>
|
|
<input type="text" id="bulk-rename-find" class="text-input" placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
|
|
<input type="text" id="bulk-rename-replace" class="text-input" placeholder="Replace with (blank to remove)">
|
|
<button class="btn" onclick="previewBulkRename()">Preview</button>
|
|
</div>
|
|
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
|
|
<div id="bulk-rename-preview"></div>
|
|
</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. 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 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-breadcrumb"></div>
|
|
<div id="storage-results"></div>
|
|
</div>
|
|
|
|
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Courses</h2>
|
|
<p class="section-desc">Scan for courses that look like the same thing filed twice - a re-download that landed in a different folder, or something that's both sorted and still sitting in Unsorted. Matching reuses the same tokenizer as Sort Unsorted, so platform names and release-group tags don't throw it off.</p>
|
|
<div class="card">
|
|
<div class="toolbar">
|
|
<button class="btn btn-secondary" id="dup-scan-btn" onclick="scanDuplicates()">{{ icons.icon('refresh', 14) }} Scan for Duplicates</button>
|
|
<span id="dup-status"></span>
|
|
<button class="btn btn-secondary btn-sm" style="margin-left: auto;" onclick="toggleIgnoredDuplicates()">Ignored matches</button>
|
|
</div>
|
|
<div id="dup-ignored-wrap" style="display: none; margin-top: 14px;">
|
|
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Confirmed not duplicates</div>
|
|
<div id="dup-ignored-list"></div>
|
|
</div>
|
|
<div id="dup-results"></div>
|
|
</div>
|
|
|
|
<h2 class="section-title">{{ icons.icon('trash', 18) }} Clean Up Stale References</h2>
|
|
<p class="section-desc">Renaming, moving, or deleting a course directly on the NAS (instead of through this app) can leave behind references in Hidden, Next Up, or Recently Viewed that point at nothing. Review and remove them here - course files on disk are never touched.</p>
|
|
<div class="card">
|
|
<div class="toolbar">
|
|
<button class="btn btn-secondary" id="stale-scan-btn" onclick="scanStaleReferences()">{{ icons.icon('refresh', 14) }} Scan for Stale References</button>
|
|
<span id="stale-status"></span>
|
|
</div>
|
|
<div id="stale-results"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="bottom-tab-bar">
|
|
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
|
|
<span class="tab-icon">{{ icons.icon('home', 20) }}</span><span class="tab-label">Home</span>
|
|
</a>
|
|
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
|
|
<span class="tab-icon">{{ icons.icon('pencil', 20) }}</span><span class="tab-label">Notes</span>
|
|
</a>
|
|
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
|
|
<span class="tab-icon">{{ icons.icon('help', 20) }}</span><span class="tab-label">Help</span>
|
|
</a>
|
|
<a href="/unsorted" class="tab-item {% if active_tab == 'files' %}active{% endif %}">
|
|
<span class="tab-icon">{{ icons.icon('folder', 20) }}</span><span class="tab-label">Files</span>
|
|
</a>
|
|
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
|
|
<span class="tab-icon">{{ icons.icon('settings', 20) }}</span><span class="tab-label">Settings</span>
|
|
</a>
|
|
</nav>
|
|
|
|
<script src="/static/theme.js"></script>
|
|
<script>
|
|
// Client-rendered mirror of a templates/_icons.html entry - JS
|
|
// template literals can't call the Jinja macro.
|
|
const ICON_SVGS = {
|
|
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
|
|
check: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
|
|
ban: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line></svg>',
|
|
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
|
|
pencil: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>',
|
|
};
|
|
|
|
let lastItems = [];
|
|
let lastCategories = [];
|
|
const NEW_FOLDER_VALUE = '__new__';
|
|
|
|
function badgeLabel(type) {
|
|
if (type === 'existing') return 'Existing folder';
|
|
if (type === 'new_folder') return 'New folder';
|
|
return 'Needs review';
|
|
}
|
|
|
|
function escapeAttr(text) {
|
|
return String(text).replace(/&/g, '&').replace(/"/g, '"');
|
|
}
|
|
function escapeHtml(text) {
|
|
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function categoryOptionsHtml() {
|
|
return lastCategories.map(path =>
|
|
`<option value="${escapeAttr(path)}">${escapeHtml(path.split('/').join(' / '))}</option>`
|
|
).join('');
|
|
}
|
|
|
|
// Shared across Sort Unsorted's own picker and Manage Library's Move
|
|
// panel, so both work the same regardless of whether the Unsorted
|
|
// folder exists (Sort Unsorted's own scan skips categories entirely
|
|
// when there's nothing to sort).
|
|
function loadCategories() {
|
|
return fetch('/api/library/categories')
|
|
.then(r => r.json())
|
|
.then(data => { lastCategories = data.categories || []; })
|
|
.catch(() => {});
|
|
}
|
|
|
|
function newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew) {
|
|
return `
|
|
<div class="new-folder-fields" style="display: ${isNew ? 'flex' : 'none'};">
|
|
<select class="text-input new-folder-parent" data-initial="${escapeAttr(prefilledParent)}">
|
|
<option value="">(Library root — top level)</option>
|
|
${optionsHtml}
|
|
</select>
|
|
<input type="text" class="text-input new-folder-name ${isNew && !prefilledName ? 'empty' : ''}"
|
|
value="${escapeAttr(prefilledName)}" placeholder="New folder name">
|
|
</div>
|
|
<div class="new-folder-preview"></div>`;
|
|
}
|
|
|
|
// Wires the shared "+ Create new folder" behavior for one
|
|
// dest-select + its new-folder-fields sibling: toggling visibility,
|
|
// restoring the parent select's prefilled value (can't be an HTML
|
|
// `selected` attribute since the <option> list is shared/built once
|
|
// per render), and keeping the "Will create: ..." preview live.
|
|
function wireNewFolderFields(destSelect, fieldsWrap) {
|
|
const fieldsEl = fieldsWrap.querySelector('.new-folder-fields');
|
|
const preview = fieldsWrap.querySelector('.new-folder-preview');
|
|
const parentSelect = fieldsEl.querySelector('.new-folder-parent');
|
|
const nameInput = fieldsEl.querySelector('.new-folder-name');
|
|
if (parentSelect.dataset.initial) parentSelect.value = parentSelect.dataset.initial;
|
|
|
|
function updatePreview() {
|
|
const parent = parentSelect.value;
|
|
const name = nameInput.value.trim();
|
|
if (!name) {
|
|
preview.textContent = '';
|
|
preview.classList.remove('ready');
|
|
return;
|
|
}
|
|
preview.textContent = `Will create: ${parent ? parent + '/' + name : name}`;
|
|
preview.classList.add('ready');
|
|
}
|
|
|
|
destSelect.addEventListener('change', () => {
|
|
const showNew = destSelect.value === NEW_FOLDER_VALUE;
|
|
fieldsEl.style.display = showNew ? 'flex' : 'none';
|
|
preview.style.display = showNew ? 'block' : 'none';
|
|
if (showNew) nameInput.focus();
|
|
updatePreview();
|
|
});
|
|
parentSelect.addEventListener('change', updatePreview);
|
|
nameInput.addEventListener('input', () => {
|
|
nameInput.classList.toggle('empty', nameInput.value.trim() === '');
|
|
updatePreview();
|
|
});
|
|
preview.style.display = destSelect.value === NEW_FOLDER_VALUE ? 'block' : 'none';
|
|
updatePreview();
|
|
}
|
|
|
|
function newFolderDestination(fieldsWrap) {
|
|
const parent = fieldsWrap.querySelector('.new-folder-parent').value;
|
|
const name = fieldsWrap.querySelector('.new-folder-name').value.trim();
|
|
return name ? (parent ? `${parent}/${name}` : name) : '';
|
|
}
|
|
|
|
function renderRows(items) {
|
|
const container = document.getElementById('sort-rows');
|
|
const optionsHtml = categoryOptionsHtml();
|
|
|
|
container.innerHTML = items.map((item, idx) => {
|
|
const checked = item.type !== 'manual' ? 'checked' : '';
|
|
const destValue = item.destination || '';
|
|
// An 'existing' proposal always names a real folder already in
|
|
// lastCategories, so it's the only case the <select> can point
|
|
// straight at; 'new_folder' and 'manual' (and any hand-edited
|
|
// destination that no longer matches a real folder) fall back
|
|
// to the "create new" option, split into a parent-folder pick
|
|
// and a plain new-folder name.
|
|
const isNew = item.type !== 'existing' || !lastCategories.includes(destValue);
|
|
let prefilledParent = '', prefilledName = '';
|
|
if (isNew && destValue) {
|
|
const slash = destValue.lastIndexOf('/');
|
|
if (slash === -1) { prefilledName = destValue; }
|
|
else { prefilledParent = destValue.slice(0, slash); prefilledName = destValue.slice(slash + 1); }
|
|
}
|
|
return `
|
|
<div class="sort-row" data-idx="${idx}">
|
|
<input type="checkbox" class="sort-row-check" ${checked}>
|
|
<div class="sort-row-main">
|
|
<div class="sort-row-name">
|
|
${escapeHtml(item.name)}
|
|
<span class="sort-badge ${item.type}">${badgeLabel(item.type)}</span>
|
|
</div>
|
|
<div class="sort-row-reason">${escapeHtml(item.reason || '')}</div>
|
|
<div class="sort-row-fields">
|
|
<div class="sort-field">
|
|
<label>Folder name</label>
|
|
<input type="text" class="rename-input" value="${escapeAttr(item.name)}">
|
|
</div>
|
|
<div class="sort-field">
|
|
<label>Destination folder</label>
|
|
<div class="dest-picker">
|
|
${ICON_SVGS.folder}
|
|
<select class="dest-select" data-initial="${escapeAttr(isNew ? NEW_FOLDER_VALUE : destValue)}">
|
|
${optionsHtml}
|
|
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
|
|
</select>
|
|
</div>
|
|
${newFolderFieldsHtml(optionsHtml, prefilledParent, prefilledName, isNew)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
container.querySelectorAll('.dest-select').forEach(select => {
|
|
select.value = select.dataset.initial;
|
|
const field = select.closest('.sort-field');
|
|
wireNewFolderFields(select, field);
|
|
});
|
|
}
|
|
|
|
function scanUnsorted() {
|
|
const btn = document.getElementById('scan-btn');
|
|
const status = document.getElementById('scan-status');
|
|
btn.disabled = true;
|
|
status.textContent = 'Scanning…';
|
|
document.getElementById('empty-state').style.display = 'none';
|
|
document.getElementById('results-card').style.display = 'none';
|
|
|
|
fetch('/api/unsorted/scan')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
status.textContent = '';
|
|
|
|
if (!data.unsorted_exists) {
|
|
document.getElementById('empty-state').textContent =
|
|
`No "Unsorted" folder found at the root of your library (looked for ${data.unsorted_path || 'Unsorted'}). Create one and drop new courses in it.`;
|
|
document.getElementById('empty-state').style.display = 'block';
|
|
return;
|
|
}
|
|
if (!data.items.length) {
|
|
document.getElementById('empty-state').textContent = 'Nothing to sort right now - the Unsorted folder is empty.';
|
|
document.getElementById('empty-state').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
lastItems = data.items;
|
|
renderRows(lastItems);
|
|
document.getElementById('select-all').checked = true;
|
|
document.getElementById('result-summary').textContent =
|
|
`${data.items.length} item${data.items.length === 1 ? '' : 's'} · matched against ${data.category_count} existing folder${data.category_count === 1 ? '' : 's'}`;
|
|
document.getElementById('results-card').style.display = 'block';
|
|
})
|
|
.catch(() => {
|
|
btn.disabled = false;
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
function toggleSelectAll(checked) {
|
|
document.querySelectorAll('.sort-row-check').forEach(cb => { cb.checked = checked; });
|
|
}
|
|
|
|
function applySelected() {
|
|
const rows = Array.from(document.querySelectorAll('.sort-row'));
|
|
const moves = [];
|
|
const skippedEmpty = [];
|
|
|
|
rows.forEach(row => {
|
|
const checkbox = row.querySelector('.sort-row-check');
|
|
if (!checkbox.checked) return;
|
|
const idx = parseInt(row.dataset.idx, 10);
|
|
const item = lastItems[idx];
|
|
const select = row.querySelector('.dest-select');
|
|
const fieldsWrap = select.closest('.sort-field');
|
|
const renameInput = row.querySelector('.rename-input');
|
|
const usingNew = select.value === NEW_FOLDER_VALUE;
|
|
const destination = usingNew ? newFolderDestination(fieldsWrap) : select.value.trim();
|
|
const name = renameInput.value.trim();
|
|
if (!destination) {
|
|
skippedEmpty.push(item.name);
|
|
if (usingNew) fieldsWrap.querySelector('.new-folder-name').classList.add('empty');
|
|
return;
|
|
}
|
|
moves.push({ source: item.path, destination, name });
|
|
});
|
|
|
|
if (skippedEmpty.length) {
|
|
alert(`Set a destination for: ${skippedEmpty.join(', ')} (or uncheck them) before applying.`);
|
|
return;
|
|
}
|
|
if (!moves.length) {
|
|
alert('Nothing selected to move.');
|
|
return;
|
|
}
|
|
if (!confirm(`Move ${moves.length} course${moves.length === 1 ? '' : 's'} to their destination${moves.length === 1 ? '' : 's'} now? This reorganizes files on disk.`)) {
|
|
return;
|
|
}
|
|
|
|
const status = document.getElementById('apply-status');
|
|
const btn = document.getElementById('apply-btn');
|
|
btn.disabled = true;
|
|
status.textContent = 'Moving…';
|
|
|
|
fetch('/api/unsorted/apply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ moves })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
if (!data.success) {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = data.error || 'Move failed';
|
|
return;
|
|
}
|
|
const errorCount = (data.errors || []).length;
|
|
status.style.color = errorCount ? 'var(--error)' : 'var(--success)';
|
|
status.textContent = `Moved ${data.moved}${errorCount ? `, ${errorCount} error${errorCount === 1 ? '' : 's'}` : ''}`;
|
|
if (errorCount) {
|
|
console.warn('Unsorted apply errors:', data.errors);
|
|
}
|
|
loadCategories().then(() => scanUnsorted());
|
|
checkUndoStatus();
|
|
})
|
|
.catch(() => {
|
|
btn.disabled = false;
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
function refreshLibraryCache() {
|
|
const status = document.getElementById('library-refresh-status');
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = 'Refreshing…';
|
|
fetch('/api/library/refresh', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
|
|
status.innerHTML = data.success ? (ICON_SVGS.check + ' Refreshed') : 'Could not refresh';
|
|
if (data.success) setTimeout(() => { status.textContent = ''; }, 3000);
|
|
})
|
|
.catch(() => {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server';
|
|
});
|
|
}
|
|
|
|
// ---- Bulk rename: find/replace across every course/folder name ----
|
|
let bulkRenameMatches = [];
|
|
|
|
const BULK_RENAME_PLACEHOLDERS = {
|
|
plain: 'Find (e.g. .BOOKWARE-LERNSTUF)',
|
|
wildcard: 'Find (e.g. .BOOKWARE*)',
|
|
regex: String.raw`Find (e.g. \.BOOKWARE-\w+)`,
|
|
};
|
|
|
|
function updateBulkRenamePlaceholder() {
|
|
const mode = document.getElementById('bulk-rename-mode').value;
|
|
document.getElementById('bulk-rename-find').placeholder = BULK_RENAME_PLACEHOLDERS[mode] || BULK_RENAME_PLACEHOLDERS.plain;
|
|
}
|
|
|
|
function previewBulkRename() {
|
|
const pattern = document.getElementById('bulk-rename-find').value;
|
|
const replacement = document.getElementById('bulk-rename-replace').value;
|
|
const mode = document.getElementById('bulk-rename-mode').value;
|
|
const status = document.getElementById('bulk-rename-status');
|
|
const preview = document.getElementById('bulk-rename-preview');
|
|
preview.innerHTML = '';
|
|
if (!pattern) {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Enter text to find first.';
|
|
return;
|
|
}
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = 'Searching...';
|
|
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}&mode=${encodeURIComponent(mode)}`)
|
|
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
|
.then(({ ok, data }) => {
|
|
if (!ok) {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = data.error || 'Invalid pattern.';
|
|
bulkRenameMatches = [];
|
|
return;
|
|
}
|
|
bulkRenameMatches = data.matches || [];
|
|
renderBulkRenamePreview();
|
|
})
|
|
.catch(() => {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
function renderBulkRenamePreview() {
|
|
const status = document.getElementById('bulk-rename-status');
|
|
const preview = document.getElementById('bulk-rename-preview');
|
|
if (bulkRenameMatches.length === 0) {
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = 'No matches.';
|
|
preview.innerHTML = '';
|
|
return;
|
|
}
|
|
status.textContent = '';
|
|
const rows = bulkRenameMatches.map((m, i) => `
|
|
<div class="bulk-rename-row">
|
|
<div class="bulk-rename-row-names">
|
|
<div class="bulk-rename-old">${m.old_name}</div>
|
|
<div class="bulk-rename-new">→ ${m.new_name}</div>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
|
|
</div>
|
|
`).join('');
|
|
const count = bulkRenameMatches.length;
|
|
preview.innerHTML = rows +
|
|
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
|
|
}
|
|
|
|
function removeBulkRenameMatch(index) {
|
|
bulkRenameMatches.splice(index, 1);
|
|
renderBulkRenamePreview();
|
|
}
|
|
|
|
function applyBulkRename() {
|
|
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
|
|
const status = document.getElementById('bulk-rename-status');
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = 'Applying...';
|
|
fetch('/api/bulk-rename/apply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ items: bulkRenameMatches })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const results = data.results || [];
|
|
const succeeded = results.filter(r => r.success).length;
|
|
const failed = results.filter(r => !r.success);
|
|
const activeCourseReset = results.some(r => r.active_course_reset);
|
|
|
|
status.style.color = failed.length ? 'var(--error)' : 'var(--success)';
|
|
let message = `${succeeded} renamed`;
|
|
if (failed.length) {
|
|
message += `, ${failed.length} failed: ` +
|
|
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
|
|
} else {
|
|
message += '.';
|
|
}
|
|
if (activeCourseReset) {
|
|
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
|
|
}
|
|
status.textContent = message;
|
|
|
|
bulkRenameMatches = [];
|
|
document.getElementById('bulk-rename-preview').innerHTML = '';
|
|
document.getElementById('bulk-rename-find').value = '';
|
|
document.getElementById('bulk-rename-replace').value = '';
|
|
loadHiddenList();
|
|
loadCategories();
|
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
|
checkUndoStatus();
|
|
})
|
|
.catch(() => {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
// ---- 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')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const wrap = document.getElementById('hidden-list-wrap');
|
|
const list = document.getElementById('hidden-list');
|
|
const items = data.hidden_paths || [];
|
|
if (items.length === 0) {
|
|
wrap.style.display = 'none';
|
|
return;
|
|
}
|
|
wrap.style.display = 'block';
|
|
list.innerHTML = items.map(item => `
|
|
<div class="curate-row">
|
|
<div class="curate-row-name">
|
|
<span>${ICON_SVGS.ban}</span>
|
|
<span class="name">${item.name}</span>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
|
|
</div>
|
|
`).join('');
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function loadCurateLevel(path, container, isRoot) {
|
|
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
|
|
fetch(url)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (isRoot) {
|
|
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
|
|
}
|
|
renderCurateLevel(data, container);
|
|
})
|
|
.catch(() => {
|
|
container.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
|
|
});
|
|
}
|
|
|
|
// 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 => curateRowHtml(item, true)).join('');
|
|
}
|
|
|
|
function toggleCurateDir(rowEl, path) {
|
|
const content = rowEl.nextElementSibling;
|
|
if (!content || !content.classList.contains('curate-children')) return;
|
|
|
|
if (content.classList.contains('expanded')) {
|
|
content.classList.remove('expanded');
|
|
return;
|
|
}
|
|
content.classList.add('expanded');
|
|
|
|
if (content.dataset.loaded === 'true') return;
|
|
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
|
|
content.dataset.loaded = 'true';
|
|
content.dataset.path = path;
|
|
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',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: path, hidden: hidden })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
loadHiddenList();
|
|
refreshManageContainer(btnEl);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// ---- Manage Library: rename a course/folder in place on disk ----
|
|
function startRename(btnEl, path, currentName) {
|
|
const row = btnEl.closest('.curate-row');
|
|
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
|
|
if (!row || !nameSpan) return;
|
|
|
|
let settled = false;
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'curate-rename-input';
|
|
input.value = currentName;
|
|
input.onclick = (e) => e.stopPropagation();
|
|
nameSpan.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
function cancel() {
|
|
if (settled) return;
|
|
settled = true;
|
|
input.replaceWith(nameSpan);
|
|
}
|
|
function commit() {
|
|
if (settled) return;
|
|
const newName = input.value.trim();
|
|
if (!newName || newName === currentName) {
|
|
cancel();
|
|
return;
|
|
}
|
|
settled = true;
|
|
submitRename(btnEl, path, newName, row, nameSpan, input);
|
|
}
|
|
input.addEventListener('keydown', (e) => {
|
|
e.stopPropagation();
|
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
|
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
|
});
|
|
input.addEventListener('blur', commit);
|
|
}
|
|
|
|
function submitRename(btnEl, path, newName, row, nameSpan, input) {
|
|
fetch('/api/rename-path', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: path, new_name: newName })
|
|
})
|
|
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
|
.then(({ ok, data }) => {
|
|
if (!ok || !data.success) {
|
|
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
|
|
showRowError(row, data.error || 'Rename failed');
|
|
return;
|
|
}
|
|
if (data.active_course_reset) {
|
|
alert('That was your active course - reload the main page to pick it up under its new name.');
|
|
}
|
|
loadHiddenList();
|
|
refreshManageContainer(btnEl);
|
|
checkUndoStatus();
|
|
})
|
|
.catch(() => {
|
|
input.replaceWith(nameSpan);
|
|
showRowError(row, 'Could not reach the server');
|
|
});
|
|
}
|
|
|
|
function showRowError(row, message) {
|
|
let status = row.querySelector('.curate-row-status');
|
|
if (!status) {
|
|
status = document.createElement('div');
|
|
status.className = 'curate-row-status';
|
|
row.appendChild(status);
|
|
}
|
|
status.textContent = message;
|
|
}
|
|
|
|
// ---- Manage Library: move a course/folder elsewhere in the library ----
|
|
function startMove(btnEl, path, currentName) {
|
|
const row = btnEl.closest('.curate-row');
|
|
if (!row) return;
|
|
const existing = row.nextElementSibling;
|
|
if (existing && existing.classList.contains('curate-move-panel')) {
|
|
existing.remove();
|
|
return;
|
|
}
|
|
document.querySelectorAll('.curate-move-panel').forEach(p => p.remove());
|
|
|
|
const optionsHtml = categoryOptionsHtml();
|
|
const panel = document.createElement('div');
|
|
panel.className = 'curate-move-panel';
|
|
panel.innerHTML = `
|
|
<div class="sort-field">
|
|
<label>Move "${escapeHtml(currentName)}" 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>
|
|
`;
|
|
row.after(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 = () => panel.remove();
|
|
moveBtn.onclick = () => submitMove(panel, btnEl, path);
|
|
}
|
|
|
|
function submitMove(panel, btnEl, path) {
|
|
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…';
|
|
fetch('/api/library/move', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ source: path, 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.error || 'Move failed';
|
|
return;
|
|
}
|
|
if (data.active_course_reset) {
|
|
alert('That was your active course - reload the main page to pick it up under its new location.');
|
|
}
|
|
loadHiddenList();
|
|
loadCategories();
|
|
refreshManageContainer(btnEl);
|
|
checkUndoStatus();
|
|
})
|
|
.catch(() => {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
// ---- Storage Usage ----
|
|
let storagePathStack = [];
|
|
|
|
function scanStorageUsage(path) {
|
|
const btn = document.getElementById('storage-scan-btn');
|
|
const status = document.getElementById('storage-status');
|
|
const results = document.getElementById('storage-results');
|
|
const breadcrumb = document.getElementById('storage-breadcrumb');
|
|
if (!path) storagePathStack = [];
|
|
btn.disabled = true;
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = 'Scanning… this reads every file, may take a moment.';
|
|
results.innerHTML = '';
|
|
const url = path ? `/api/library/storage-usage?path=${encodeURIComponent(path)}` : '/api/library/storage-usage';
|
|
fetch(url)
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
if (data.error) {
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = data.error;
|
|
return;
|
|
}
|
|
breadcrumb.innerHTML = !data.is_root
|
|
? `<button class="btn btn-secondary btn-sm" onclick="storageGoBack()">${'←'} Back</button> <span style="color: var(--text-muted); font-size: 0.85em;">${escapeHtml(data.path)}</span>`
|
|
: '';
|
|
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${e.is_dir ? ' storage-row-clickable' : ''}" ${e.is_dir ? `onclick="storageDrillDown('${e.path.replace(/'/g, "\\'")}')"` : ''}>
|
|
<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.';
|
|
});
|
|
}
|
|
|
|
function storageDrillDown(path) {
|
|
const status = document.getElementById('storage-status');
|
|
const current = status.dataset.currentPath || '';
|
|
storagePathStack.push(current);
|
|
status.dataset.currentPath = path;
|
|
scanStorageUsage(path);
|
|
}
|
|
|
|
function storageGoBack() {
|
|
const status = document.getElementById('storage-status');
|
|
const prev = storagePathStack.pop() || '';
|
|
status.dataset.currentPath = prev;
|
|
scanStorageUsage(prev || undefined);
|
|
}
|
|
|
|
// ---- Duplicate Courses ----
|
|
function scanDuplicates() {
|
|
const btn = document.getElementById('dup-scan-btn');
|
|
const status = document.getElementById('dup-status');
|
|
const results = document.getElementById('dup-results');
|
|
btn.disabled = true;
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = 'Scanning…';
|
|
results.innerHTML = '';
|
|
fetch('/api/library/duplicates')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
const groups = data.groups || [];
|
|
if (!groups.length) {
|
|
status.style.color = 'var(--success)';
|
|
status.textContent = `No likely duplicates found across ${data.course_count} course${data.course_count === 1 ? '' : 's'}.`;
|
|
return;
|
|
}
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = `${groups.length} possible duplicate group${groups.length === 1 ? '' : 's'}, out of ${data.course_count} courses.`;
|
|
results.innerHTML = groups.map(group => `
|
|
<div class="dup-group">
|
|
<div class="dup-group-header">
|
|
<span>${Math.round(group.similarity * 100)}% similar</span>
|
|
<button class="btn btn-secondary btn-sm" onclick="ignoreDuplicateGroup(this)">Not a duplicate - ignore</button>
|
|
</div>
|
|
${group.courses.map(c => `
|
|
<div class="dup-course-row">
|
|
<span class="dup-course-name">${escapeHtml(c.name)}</span>
|
|
<span class="dup-course-path">${escapeHtml(c.path)}</span>
|
|
<button class="btn btn-secondary btn-sm" onclick="hideDuplicateCourse(this, '${c.path.replace(/'/g, "\\'")}')">Hide</button>
|
|
<button class="btn btn-danger btn-sm" onclick="deleteDuplicateCourse(this, '${c.path.replace(/'/g, "\\'")}', '${c.name.replace(/'/g, "\\'")}')">Delete</button>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`).join('');
|
|
})
|
|
.catch(() => {
|
|
btn.disabled = false;
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
function hideDuplicateCourse(btnEl, path) {
|
|
fetch('/api/hidden-paths', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: path, hidden: true })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
btnEl.disabled = true;
|
|
btnEl.textContent = 'Hidden';
|
|
const row = btnEl.closest('.dup-course-row');
|
|
if (row) row.style.opacity = '0.5';
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function deleteDuplicateCourse(btnEl, path, name) {
|
|
if (!confirm(`Permanently delete "${name}" and everything inside it?\n\n${path}\n\nThis cannot be undone.`)) return;
|
|
btnEl.disabled = true;
|
|
fetch('/api/library/delete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path })
|
|
})
|
|
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
|
.then(({ ok, data }) => {
|
|
if (!ok || !data.success) {
|
|
btnEl.disabled = false;
|
|
alert(data.error || 'Delete failed');
|
|
return;
|
|
}
|
|
if (data.active_course_reset) {
|
|
alert('That was your active course - reload the main page to pick a different one.');
|
|
}
|
|
const row = btnEl.closest('.dup-course-row');
|
|
const group = row ? row.closest('.dup-group') : null;
|
|
if (row) row.remove();
|
|
if (group && group.querySelectorAll('.dup-course-row').length < 2) {
|
|
group.remove();
|
|
}
|
|
refreshDupStatusText();
|
|
})
|
|
.catch(() => {
|
|
btnEl.disabled = false;
|
|
alert('Could not reach the server.');
|
|
});
|
|
}
|
|
|
|
// Keeps the "N possible duplicate groups" line honest after a
|
|
// client-side removal (delete/ignore), without needing a full
|
|
// re-scan that would lose the user's place in a long results list.
|
|
function refreshDupStatusText() {
|
|
const status = document.getElementById('dup-status');
|
|
const remaining = document.querySelectorAll('#dup-results .dup-group').length;
|
|
if (remaining === 0) {
|
|
status.style.color = 'var(--success)';
|
|
status.textContent = 'No possible duplicates remaining.';
|
|
} else {
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = `${remaining} possible duplicate group${remaining === 1 ? '' : 's'} remaining.`;
|
|
}
|
|
}
|
|
|
|
function ignoreDuplicateGroup(btnEl) {
|
|
const group = btnEl.closest('.dup-group');
|
|
if (!group) return;
|
|
const paths = Array.from(group.querySelectorAll('.dup-course-path')).map(el => el.textContent);
|
|
if (paths.length < 2) return;
|
|
if (!confirm('Mark these as NOT duplicates of each other? They won\'t be flagged together again (reversible from "Ignored matches").')) return;
|
|
btnEl.disabled = true;
|
|
fetch('/api/library/duplicates/ignore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paths })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
group.remove();
|
|
refreshDupStatusText();
|
|
} else {
|
|
btnEl.disabled = false;
|
|
}
|
|
})
|
|
.catch(() => { btnEl.disabled = false; });
|
|
}
|
|
|
|
function toggleIgnoredDuplicates() {
|
|
const wrap = document.getElementById('dup-ignored-wrap');
|
|
if (wrap.style.display !== 'none') {
|
|
wrap.style.display = 'none';
|
|
return;
|
|
}
|
|
wrap.style.display = 'block';
|
|
loadIgnoredDuplicates();
|
|
}
|
|
|
|
function loadIgnoredDuplicates() {
|
|
const list = document.getElementById('dup-ignored-list');
|
|
list.innerHTML = '<div class="curate-empty-hint">Loading…</div>';
|
|
fetch('/api/library/duplicates/ignored')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const ignored = data.ignored || [];
|
|
if (!ignored.length) {
|
|
list.innerHTML = '<div class="curate-empty-hint">Nothing ignored.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = ignored.map(pair => `
|
|
<div class="bulk-rename-row">
|
|
<div class="bulk-rename-row-names">
|
|
${escapeHtml(pair.a.name)} <span style="color: var(--text-muted);">↔</span> ${escapeHtml(pair.b.name)}
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm" onclick="restoreIgnoredDuplicate(this, '${pair.a.path.replace(/'/g, "\\'")}', '${pair.b.path.replace(/'/g, "\\'")}')">Restore</button>
|
|
</div>
|
|
`).join('');
|
|
})
|
|
.catch(() => {
|
|
list.innerHTML = '<p style="color:var(--error);">Could not reach the server.</p>';
|
|
});
|
|
}
|
|
|
|
function restoreIgnoredDuplicate(btnEl, pathA, pathB) {
|
|
btnEl.disabled = true;
|
|
fetch('/api/library/duplicates/ignore', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ a: pathA, b: pathB })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
loadIgnoredDuplicates();
|
|
} else {
|
|
btnEl.disabled = false;
|
|
}
|
|
})
|
|
.catch(() => { btnEl.disabled = false; });
|
|
}
|
|
|
|
// ---- Stale References ----
|
|
let lastStaleItems = [];
|
|
|
|
function scanStaleReferences() {
|
|
const btn = document.getElementById('stale-scan-btn');
|
|
const status = document.getElementById('stale-status');
|
|
const results = document.getElementById('stale-results');
|
|
btn.disabled = true;
|
|
status.style.color = 'var(--text-muted)';
|
|
status.textContent = 'Scanning…';
|
|
results.innerHTML = '';
|
|
fetch('/api/library/stale-references')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed', favorites: 'Favorite' };
|
|
const items = [];
|
|
for (const category of ['hidden', 'next_up', 'recent_views', 'favorites']) {
|
|
for (const entry of (data[category] || [])) {
|
|
items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] });
|
|
}
|
|
}
|
|
if (!items.length) {
|
|
status.style.color = 'var(--success)';
|
|
status.textContent = 'No stale references found.';
|
|
return;
|
|
}
|
|
status.textContent = '';
|
|
lastStaleItems = items;
|
|
results.innerHTML = `
|
|
<div id="stale-rows">${items.map((item, i) => `
|
|
<div class="bulk-rename-row" data-idx="${i}">
|
|
<div class="bulk-rename-row-names">
|
|
<span class="sort-badge new_folder">${item.label}</span>
|
|
${escapeHtml(item.name)}
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm" onclick="removeStaleItem(${i})">Remove</button>
|
|
</div>
|
|
`).join('')}</div>
|
|
<button class="btn" style="margin-top: 10px;" onclick="cleanAllStaleReferences()">Remove All ${items.length}</button>
|
|
`;
|
|
})
|
|
.catch(() => {
|
|
btn.disabled = false;
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
function removeStaleItem(index) {
|
|
const item = lastStaleItems[index];
|
|
if (!item) return;
|
|
fetch('/api/library/stale-references/clean', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ items: [{ category: item.category, path: item.path }] })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) scanStaleReferences();
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function cleanAllStaleReferences() {
|
|
if (!lastStaleItems.length) return;
|
|
if (!confirm(`Remove all ${lastStaleItems.length} stale reference(s)? This does not touch any files on disk.`)) return;
|
|
fetch('/api/library/stale-references/clean', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ items: lastStaleItems.map(i => ({ category: i.category, path: i.path })) })
|
|
})
|
|
.then(r => r.json())
|
|
.then(() => scanStaleReferences())
|
|
.catch(() => {});
|
|
}
|
|
|
|
loadCategories().then(() => scanUnsorted());
|
|
loadHiddenList();
|
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
|
checkUndoStatus();
|
|
</script>
|
|
</body>
|
|
</html>
|