Scans an Unsorted folder at the library root and proposes destinations for new courses by matching keywords against the existing category tree, suggesting new subfolders when nothing matches closely, and flagging items for manual review when nothing matches at all. Review and apply happen on a dedicated /unsorted page linked from Settings; nothing moves until the user confirms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
480 lines
18 KiB
HTML
480 lines
18 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>Sort Unsorted - 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;
|
|
}
|
|
.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-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-dest {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
min-width: 240px;
|
|
}
|
|
.sort-row-dest input {
|
|
flex: 1;
|
|
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);
|
|
}
|
|
.sort-row-dest input:invalid,
|
|
.sort-row-dest input.empty {
|
|
border-color: var(--error);
|
|
}
|
|
.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);
|
|
}
|
|
</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) }} Sort Unsorted</h1>
|
|
<p class="subtitle">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure. Nothing moves until you apply it.</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>
|
|
</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="/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>',
|
|
};
|
|
|
|
let lastItems = [];
|
|
|
|
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 renderRows(items) {
|
|
const container = document.getElementById('sort-rows');
|
|
container.innerHTML = items.map((item, idx) => {
|
|
const checked = item.type !== 'manual' ? 'checked' : '';
|
|
const destValue = item.destination || '';
|
|
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>
|
|
<div class="sort-row-dest">
|
|
${ICON_SVGS.folder}
|
|
<input type="text" class="dest-input ${destValue ? '' : 'empty'}" value="${escapeAttr(destValue)}" placeholder="e.g. IT/AWS">
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
container.querySelectorAll('.dest-input').forEach(input => {
|
|
input.addEventListener('input', () => {
|
|
input.classList.toggle('empty', input.value.trim() === '');
|
|
});
|
|
});
|
|
}
|
|
|
|
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 destInput = row.querySelector('.dest-input');
|
|
const destination = destInput.value.trim();
|
|
if (!destination) {
|
|
skippedEmpty.push(item.name);
|
|
destInput.classList.add('empty');
|
|
return;
|
|
}
|
|
moves.push({ source: item.path, destination });
|
|
});
|
|
|
|
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);
|
|
}
|
|
scanUnsorted();
|
|
})
|
|
.catch(() => {
|
|
btn.disabled = false;
|
|
status.style.color = 'var(--error)';
|
|
status.textContent = 'Could not reach the server.';
|
|
});
|
|
}
|
|
|
|
scanUnsorted();
|
|
</script>
|
|
</body>
|
|
</html>
|