New mobile layout, new regular screen, added the ability to edit directory names from settings

This commit is contained in:
2026-08-22 08:37:25 -04:00
parent fa00a0b0c4
commit c92268e04c
4 changed files with 370 additions and 47 deletions
+144 -41
View File
@@ -248,6 +248,39 @@
border-radius: 10px;
}
.library-breadcrumb {
color: var(--text-muted);
font-size: 13px;
margin-top: 6px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.library-breadcrumb .crumb {
cursor: pointer;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-breadcrumb .crumb:not(.current):hover {
color: var(--accent);
text-decoration: underline;
}
.library-breadcrumb .crumb.current {
color: var(--text-primary);
font-weight: 600;
cursor: default;
}
.library-breadcrumb .crumb-sep {
opacity: 0.5;
}
/* Dynamic Directory Tree Styles */
.tree-container {
margin-top: 20px;
@@ -295,22 +328,31 @@
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.tree-icon {
font-size: 1.2em;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.tree-name {
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-stats {
font-size: 0.85em;
color: var(--text-muted);
margin-left: 10px;
flex-shrink: 0;
white-space: nowrap;
}
.tree-toggle {
@@ -405,10 +447,20 @@
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.lesson-icon {
font-size: 1.1em;
flex-shrink: 0;
}
.lesson-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-meta {
@@ -417,6 +469,16 @@
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
min-width: 0;
max-width: 45%;
}
.lesson-meta-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.lesson-type {
@@ -473,6 +535,29 @@
margin-left: 15px;
}
}
@media (max-width: 600px) {
.lesson-item {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.lesson-title,
.lesson-meta {
width: 100%;
max-width: 100%;
}
.lesson-name,
.lesson-meta-text {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
}
</style>
</head>
<body>
@@ -563,7 +648,7 @@
{% elif lesson.lesson_type == 'mixed' %}📦
{% else %}📄{% endif %}
</span>
{{ lesson.title }}
<span class="lesson-name">{{ lesson.title }}</span>
</div>
<div class="lesson-meta">
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
@@ -599,7 +684,7 @@
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
<div class="lesson-title">
<span class="lesson-icon">▶️</span>
<span>{{ view.lesson_title }}</span>
<span class="lesson-name">{{ view.lesson_title }}</span>
</div>
<div class="lesson-meta">
{% if view.completed %}
@@ -607,7 +692,7 @@
{% elif view.percent_watched %}
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
{% endif %}
<span>{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
</div>
{% if view.percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
@@ -621,7 +706,7 @@
<div class="container">
<div class="card" id="library-card">
<h2>Your Courses</h2>
<p id="library-path-bar" style="color: #999; font-size: 13px; margin-top: 6px;"></p>
<p id="library-path-bar" class="library-breadcrumb"></p>
<div id="library-groups" style="margin-top: 15px;"></div>
</div>
</div>
@@ -665,22 +750,29 @@
}
}
// Single-level drill-down: the Library browser shows one folder's
// contents at a time (replacing the list, not nesting under it) and
// tracks how we got here in libraryTrail so a breadcrumb can jump
// back up. Much less horizontal space wasted than the old
// indent-per-level accordion, especially on narrow screens.
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
function loadLibrary() {
const libraryCard = document.getElementById('library-card');
if (!libraryCard) return;
fetchLibraryLevel(null, document.getElementById('library-groups'), true);
libraryTrail = [];
fetchLibraryLevel(null);
}
function fetchLibraryLevel(path, container, isRoot) {
function fetchLibraryLevel(path) {
const container = document.getElementById('library-groups');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
fetch(url)
.then(r => r.json())
.then(data => {
if (isRoot) {
document.getElementById('library-path-bar').textContent =
`Scanning ${data.library_path}`;
}
renderLibraryLevel(data, container, isRoot);
renderLibraryBreadcrumb();
renderLibraryLevel(data);
})
.catch(() => {
container.innerHTML =
@@ -688,7 +780,35 @@
});
}
function renderLibraryLevel(data, container, isRoot) {
function renderLibraryBreadcrumb() {
const bar = document.getElementById('library-path-bar');
const atRoot = libraryTrail.length === 0;
let html = `<span class="crumb ${atRoot ? 'current' : ''}"` +
(atRoot ? '' : ` onclick="goToLibraryCrumb(-1)"`) + `>Library</span>`;
libraryTrail.forEach((crumb, i) => {
const isLast = i === libraryTrail.length - 1;
html += ` <span class="crumb-sep">/</span> ` +
`<span class="crumb ${isLast ? 'current' : ''}"` +
(isLast ? '' : ` onclick="goToLibraryCrumb(${i})"`) +
`>${crumb.name}</span>`;
});
bar.innerHTML = html;
}
function goToLibraryCrumb(index) {
if (index < 0) {
libraryTrail = [];
fetchLibraryLevel(null);
return;
}
const target = libraryTrail[index];
libraryTrail = libraryTrail.slice(0, index + 1);
fetchLibraryLevel(target.path);
}
function renderLibraryLevel(data) {
const container = document.getElementById('library-groups');
const isRoot = libraryTrail.length === 0;
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length)
? data.errors.join(' ')
@@ -698,52 +818,35 @@
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
if (item.type === 'course') {
return `
<div class="lesson-item"
onclick="loadCoursePath('${item.path.replace(/'/g, "\\'")}')">
onclick="loadCoursePath('${safePath}')">
<div class="lesson-title">
<span class="lesson-icon">🎓</span>
<span>${item.name}</span>
<span class="lesson-name">${item.name}</span>
</div>
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
</div>
`;
}
const safeName = item.name.replace(/'/g, "\\'");
return `
<div class="tree-item">
<div class="tree-header directory" onclick="toggleLibraryDir(this, '${item.path.replace(/'/g, "\\'")}')">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">${item.name}</span>
</div>
<button class="tree-toggle">▶</button>
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">${item.name}</span>
</div>
<div class="tree-content" data-loaded="false"></div>
<span class="tree-toggle"></span>
</div>
`;
}).join('');
}
function toggleLibraryDir(headerEl, path) {
const content = headerEl.nextElementSibling;
const toggle = headerEl.querySelector('.tree-toggle');
if (!content || !content.classList.contains('tree-content')) return;
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
toggle.textContent = '▶';
return;
}
content.classList.add('expanded');
toggle.textContent = '▼';
if (content.dataset.loaded === 'true') return; // already fetched this branch
content.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
content.dataset.loaded = 'true';
fetchLibraryLevel(path, content, false);
function enterLibraryDir(path, name) {
libraryTrail.push({ name: name, path: path });
fetchLibraryLevel(path);
}
function loadCoursePath(path) {
+110 -6
View File
@@ -209,6 +209,7 @@
}
.curate-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
@@ -255,6 +256,28 @@
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: #ff6b6b;
}
#save-status {
font-size: 0.9em;
color: #28a745;
@@ -384,10 +407,11 @@
</div>
<div class="card">
<h2>Curate Library</h2>
<h2>Manage Library</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Hide courses or whole folders from the Library browser without touching anything on disk.
Hiding a folder hides everything inside it.
Hide courses or whole folders from the Library browser without touching anything on disk
(hiding a folder hides everything inside it), or rename a course/folder directly - no need
to go to the NAS.
</p>
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
@@ -395,7 +419,7 @@
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse to hide</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>
@@ -549,9 +573,12 @@
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? '🎓' : '📁';
const hiddenClass = item.hidden ? 'is-hidden' : '';
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">✏️</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">${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course') {
return `
@@ -560,7 +587,7 @@
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${toggleBtn}
${actions}
</div>
`;
}
@@ -571,7 +598,7 @@
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${toggleBtn}
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
@@ -620,6 +647,83 @@
.catch(() => {});
}
// ---- Library curation: 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();
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);
})
.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;
}
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);