From c92268e04cbf689baf7c3d40897ea5e14ac151eb Mon Sep 17 00:00:00 2001 From: rmsitz Date: Sat, 22 Aug 2026 08:37:25 -0400 Subject: [PATCH] New mobile layout, new regular screen, added the ability to edit directory names from settings --- OfflineU-project-summary.md | 26 +++++ offlineu_core.py | 90 ++++++++++++++++ templates/course_dashboard.html | 185 +++++++++++++++++++++++++------- templates/settings.html | 116 ++++++++++++++++++-- 4 files changed, 370 insertions(+), 47 deletions(-) diff --git a/OfflineU-project-summary.md b/OfflineU-project-summary.md index 3fe3fca..919ccac 100644 --- a/OfflineU-project-summary.md +++ b/OfflineU-project-summary.md @@ -97,6 +97,32 @@ built locally from a private Gitea repo rather than pulling the upstream image. official palette (nordtheme.com). Anyone with `theme: "dainty"` already saved in `settings.json` falls back gracefully to the `dark` default on next load. +## Mobile UI overhaul + in-app renaming (this session) +- **Fixed the mobile text-overlap bug** — `.lesson-item`/`.tree-header` in + `course_dashboard.html` had no `min-width: 0` or truncation on their flex + children, so long course/lesson names (especially scene-release-style dotted + names like `Udemy.crash.course.electronics...Mimir` with zero whitespace to + wrap on) would overflow and visually overlap neighboring text on narrow + screens. Fixed with `min-width:0` + ellipsis truncation (desktop/tablet) and + a `@media (max-width: 600px)` block that stacks title/metadata into separate + lines with `overflow-wrap: break-word` (the dotted-name case needed this + specifically — plain `white-space: normal` doesn't create a break opportunity + in a string with no spaces). +- **Library browser redesigned** — replaced the nested-indent accordion + (`AI` → `ChatGPT` → course, each level eating horizontal space) with a + single-level drill-down + breadcrumb (`Library / AI / ChatGPT`), the standard + mobile file-browser pattern. Tapping a folder now replaces the list instead + of nesting under it. In-course lesson tree keeps its old expand-in-place + behavior (shallower, wasn't the reported problem). +- **Rename courses/folders from Settings** — "Curate Library" is now + "Manage Library": each row gets a ✏️ button alongside Hide/Show that renames + the directory in place on disk via a new `POST /api/rename-path`. Handles + path-traversal/collision validation, and rebases any `hidden_paths.json` / + `recent_views.json` entries nested under the renamed path (progress files + need no rebasing — they live inside the directory and are keyed relative to + it). If you rename your *currently loaded* course's folder, the app resets + to the library view rather than serving a stale path. + ## Known limitations still open - App is unauthenticated by design (matches upstream) — settings and hidden-path curation apply app-wide, not per-browser/per-user. diff --git a/offlineu_core.py b/offlineu_core.py index 307db3f..d225f33 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -557,6 +557,48 @@ def set_path_hidden(path: str, hidden: bool) -> List[str]: return result +def _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str: + """If `value` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`.""" + if value == old_abs: + return new_abs + if value.startswith(old_abs + os.sep): + return new_abs + value[len(old_abs):] + return value + + +def rebase_library_path(old_abs: str, new_abs: str) -> None: + """ + After a directory in the library is renamed/moved on disk, rewrite any + stored absolute paths that pointed inside it (hidden_paths.json, + recent_views.json's course_path) so curation and Recently Viewed don't + silently go stale. Lesson-level progress needs no rebasing - it lives + inside the directory itself, keyed by paths relative to it, so it moves + with the rename automatically. + """ + hidden = get_hidden_paths() + rebased_hidden = [_rebase_prefix(p, old_abs, new_abs) for p in hidden] + if rebased_hidden != hidden: + os.makedirs(DATA_DIR, exist_ok=True) + with open(HIDDEN_PATHS_FILE, 'w') as f: + json.dump(sorted(set(rebased_hidden)), f, indent=2) + + views = get_recent_views() + changed = False + for entry in views: + course_path = entry.get('course_path', '') + rebased = _rebase_prefix(course_path, old_abs, new_abs) + if rebased != course_path: + entry['course_path'] = rebased + changed = True + if changed: + try: + os.makedirs(DATA_DIR, exist_ok=True) + with open(RECENT_VIEWS_FILE, 'w') as f: + json.dump(views, f, indent=2) + except OSError as e: + print(f"Could not rebase recent views after rename: {e}") + + def _contains_visible_course(directory: Path, hidden_set: set) -> bool: """ Whether `directory` leads to at least one course that isn't curated @@ -1037,6 +1079,54 @@ def set_hidden_path_api(): return jsonify({'success': True, 'hidden_paths': updated}) +@app.route('/api/rename-path', methods=['POST']) +def rename_path_api(): + """Rename a course/folder directory in the library, in place on disk.""" + global current_course + + data = request.json or {} + path = data.get('path', '') + new_name = (data.get('new_name') or '').strip() + + if not path: + return jsonify({'error': 'path is required'}), 400 + if not new_name: + return jsonify({'error': 'New name cannot be empty'}), 400 + if '/' in new_name or '\\' in new_name or '\x00' in new_name or new_name in ('.', '..'): + return jsonify({'error': 'New name cannot contain path separators'}), 400 + if new_name.startswith('.'): + return jsonify({'error': 'New name cannot start with a dot'}), 400 + + library_root = os.path.abspath(get_library_root()) + old_abs = os.path.abspath(path) + if not (old_abs == library_root or old_abs.startswith(library_root + os.sep)): + return jsonify({'error': 'Path outside library root'}), 403 + if old_abs == library_root: + return jsonify({'error': 'Cannot rename the library root itself'}), 400 + if not os.path.isdir(old_abs): + return jsonify({'error': 'Directory not found'}), 404 + + new_abs = os.path.join(os.path.dirname(old_abs), new_name) + if os.path.exists(new_abs): + return jsonify({'error': f'"{new_name}" already exists here'}), 409 + + try: + os.rename(old_abs, new_abs) + except OSError as e: + return jsonify({'error': f'Rename failed: {e}'}), 500 + + rebase_library_path(old_abs, new_abs) + + active_course_reset = False + if current_course is not None: + course_abs = os.path.abspath(current_course.path) + if course_abs == old_abs or course_abs.startswith(old_abs + os.sep): + current_course = None + active_course_reset = True + + return jsonify({'success': True, 'new_path': new_abs, 'active_course_reset': active_course_reset}) + + @app.route('/settings') def settings_page(): """Render the display-settings page.""" diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 8d5dd09..14717d6 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -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; + } + } @@ -563,7 +648,7 @@ {% elif lesson.lesson_type == 'mixed' %}📦 {% else %}📄{% endif %} - {{ lesson.title }} + {{ lesson.title }}
{{ lesson.lesson_type|title }} @@ -599,7 +684,7 @@ onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
▶️ - {{ view.lesson_title }} + {{ view.lesson_title }}
{% if view.completed %} @@ -607,7 +692,7 @@ {% elif view.percent_watched %} {{ view.percent_watched }}% watched {% endif %} - {{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %} + {{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}
{% if view.percent_watched %}
@@ -621,7 +706,7 @@

Your Courses

-

+

@@ -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 = '

Loading...

'; 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 = `Library`; + libraryTrail.forEach((crumb, i) => { + const isLast = i === libraryTrail.length - 1; + html += ` / ` + + `${crumb.name}`; + }); + 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 `
+ onclick="loadCoursePath('${safePath}')">
🎓 - ${item.name} + ${item.name}
${item.media_files} media file${item.media_files === 1 ? '' : 's'}
`; } + const safeName = item.name.replace(/'/g, "\\'"); return ` -
-
-
- 📁 - ${item.name} -
- +
+
+ 📁 + ${item.name}
-
+
`; }).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 = '

Loading...

'; - content.dataset.loaded = 'true'; - fetchLibraryLevel(path, content, false); + function enterLibraryDir(path, name) { + libraryTrail.push({ name: name, path: path }); + fetchLibraryLevel(path); } function loadCoursePath(path) { diff --git a/templates/settings.html b/templates/settings.html index 5b9c773..373d170 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -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 @@
-

Curate Library

+

Manage Library

- 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.

-
Browse to hide
+
Browse
@@ -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 = ``; const toggleBtn = ``; + const actions = `
${renameBtn}${toggleBtn}
`; if (item.type === 'course') { return ` @@ -560,7 +587,7 @@ ${icon} ${item.name}
- ${toggleBtn} + ${actions}
`; } @@ -571,7 +598,7 @@ ${icon} ${item.name} - ${toggleBtn} + ${actions}
`; @@ -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);