diff --git a/offlineu_core.py b/offlineu_core.py index 6b5c38d..779095a 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -7,6 +7,7 @@ Enhanced version with dynamic subdirectory navigation import os import json import mimetypes +import random import re import sys import argparse @@ -1120,6 +1121,19 @@ def _scan_library_activity() -> Dict[str, Any]: } +def get_random_incomplete_course() -> Optional[Path]: + """A random course for the dashboard's "Surprise Me" pick - excludes + courses that are already fully watched where possible, falling back to + the whole library if everything's done.""" + all_dirs = get_all_course_dirs() + if not all_dirs: + return None + scan = _scan_library_activity() + fully_completed = {c['path'] for c in scan['courses'] if c['total'] > 0 and c['completed'] >= c['total']} + candidates = [d for d in all_dirs if str(d) not in fully_completed] + return random.choice(candidates or all_dirs) + + def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]: """Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result.""" return { @@ -1827,13 +1841,20 @@ def index(): if stats.get('total_duration_seconds'): stats['remaining_display'] = format_duration(stats['remaining_seconds']) + resume_lesson = None + if current_course.last_accessed_path: + lesson, _ = find_lesson_in_tree(current_course.root_node, current_course.last_accessed_path) + if lesson: + resume_lesson = {'title': lesson.title, 'url': get_lesson_url(lesson, current_course.path)} + return render_template('course_dashboard.html', course=current_course, stats=stats, continue_watching=continue_watching, recent_views=recent_views, has_note=bool(course_has_any_notes(current_course)), - is_queued=os.path.abspath(current_course.path) in get_next_up_paths()) + is_queued=os.path.abspath(current_course.path) in get_next_up_paths(), + resume_lesson=resume_lesson) @app.route('/browse') @@ -2037,6 +2058,24 @@ def set_hidden_path_api(): return jsonify({'success': True, 'hidden_paths': updated}) +@app.route('/api/hidden-paths/bulk', methods=['POST']) +def bulk_set_hidden_paths_api(): + """Hide or un-hide several courses/directories at once.""" + data = request.json or {} + paths = data.get('paths') or [] + hidden = bool(data.get('hidden', True)) + if not isinstance(paths, list) or not paths: + return jsonify({'error': 'paths is required'}), 400 + + library_root = os.path.abspath(get_library_root()) + updated = get_hidden_paths() + for path in paths: + target = os.path.abspath(path) + if target == library_root or target.startswith(library_root + os.sep): + updated = set_path_hidden(target, hidden) + return jsonify({'success': True, 'hidden_paths': updated}) + + def validate_new_name(new_name: str) -> Optional[str]: """Validate a proposed directory name; returns an error message, or None if valid.""" if not new_name: @@ -2302,6 +2341,17 @@ def load_course(): return jsonify({'error': str(e)}), 500 +@app.route('/random-pick') +def random_pick(): + """"Surprise Me" - load a random not-yet-fully-watched course and land on its dashboard.""" + global current_course + course_dir = get_random_incomplete_course() + if not course_dir: + return redirect(url_for('index')) + current_course = get_course_tree(str(course_dir)) + return redirect(url_for('index')) + + @app.route('/recent/open') def open_recent(): """ @@ -2383,6 +2433,23 @@ def set_next_up_api(): return jsonify({'success': True, 'next_up': updated}) +@app.route('/api/next-up/bulk', methods=['POST']) +def bulk_set_next_up_api(): + """Add several courses to the Next Up queue at once.""" + data = request.json or {} + paths = data.get('paths') or [] + if not isinstance(paths, list) or not paths: + return jsonify({'error': 'paths is required'}), 400 + + library_root = os.path.abspath(get_library_root()) + updated = get_next_up_paths() + for path in paths: + target = os.path.abspath(path) + if target == library_root or target.startswith(library_root + os.sep): + updated = set_path_queued(target, True) + return jsonify({'success': True, 'next_up': updated}) + + @app.route('/api/next-up/reorder', methods=['POST']) def reorder_next_up_api(): """Replace the Next Up order wholesale, from the client's up/down-reordered list.""" diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index 310b128..b55e274 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -248,6 +248,25 @@ background: #555; } + .resume-btn { + display: block; + width: 100%; + text-align: center; + margin-top: 12px; + font-size: 1.05em; + font-weight: 600; + box-sizing: border-box; + } + + .surprise-me-btn { + display: block; + width: 100%; + text-align: center; + font-size: 1.05em; + font-weight: 600; + box-sizing: border-box; + } + .progress-bar { background: #444; border-radius: 10px; @@ -653,6 +672,32 @@ color: white; border-color: var(--accent); } + #library-select-btn { + border-radius: var(--radius); + margin-left: 4px; + } + + .bulk-action-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + padding: 10px; + background: var(--bg-tertiary); + border-radius: var(--radius); + } + .bulk-selected-count { + font-size: 0.9em; + color: var(--text-muted); + margin-right: auto; + } + .select-checkbox { + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; + } .library-grid { display: grid; @@ -881,6 +926,9 @@
No matching transcripts.
'; return; } transcriptContainer.innerHTML = `Transcript search failed.
'; }); } diff --git a/templates/help.html b/templates/help.html index 0b81e32..4b4184d 100644 --- a/templates/help.html +++ b/templates/help.html @@ -142,22 +142,70 @@