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 }} `; } @@ -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);