Adding ability to hide / show courses
This commit is contained in:
+152
-8
@@ -267,6 +267,7 @@ class Lesson:
|
|||||||
completed: bool = False
|
completed: bool = False
|
||||||
last_accessed: Optional[str] = None
|
last_accessed: Optional[str] = None
|
||||||
progress_seconds: int = 0
|
progress_seconds: int = 0
|
||||||
|
duration_seconds: int = 0
|
||||||
order: int = 0
|
order: int = 0
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
@@ -501,7 +502,38 @@ def _looks_like_course(directory: Path) -> bool:
|
|||||||
return len(children_with_media) >= 2
|
return len(children_with_media) >= 2
|
||||||
|
|
||||||
|
|
||||||
def list_library_directory(dir_path: str) -> Dict[str, Any]:
|
HIDDEN_PATHS_FILE = os.path.join(DATA_DIR, 'hidden_paths.json')
|
||||||
|
|
||||||
|
|
||||||
|
def get_hidden_paths() -> List[str]:
|
||||||
|
"""Load the set of course/directory paths curated out of the Library browser."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(HIDDEN_PATHS_FILE):
|
||||||
|
with open(HIDDEN_PATHS_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
print(f"Could not load hidden paths: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def set_path_hidden(path: str, hidden: bool) -> List[str]:
|
||||||
|
"""Add or remove a path from the hidden set; returns the updated list."""
|
||||||
|
paths = set(get_hidden_paths())
|
||||||
|
normalized = os.path.abspath(path)
|
||||||
|
if hidden:
|
||||||
|
paths.add(normalized)
|
||||||
|
else:
|
||||||
|
paths.discard(normalized)
|
||||||
|
result = sorted(paths)
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
with open(HIDDEN_PATHS_FILE, 'w') as f:
|
||||||
|
json.dump(result, f, indent=2)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
List only the immediate children of dir_path for the lazy-loading
|
List only the immediate children of dir_path for the lazy-loading
|
||||||
Library browser: subdirectories to drill into, and course folders to
|
Library browser: subdirectories to drill into, and course folders to
|
||||||
@@ -511,9 +543,16 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
Directories that don't lead to any course anywhere inside them are
|
Directories that don't lead to any course anywhere inside them are
|
||||||
left out entirely, so drilling down never dead-ends on an empty folder.
|
left out entirely, so drilling down never dead-ends on an empty folder.
|
||||||
|
|
||||||
|
Items the user has curated out (see get_hidden_paths) are excluded
|
||||||
|
when skip_hidden=True (normal browsing). When skip_hidden=False, they
|
||||||
|
are included but flagged with 'hidden': True instead - used by the
|
||||||
|
Settings-page curation UI, which needs to show hidden items so they
|
||||||
|
can be un-hidden.
|
||||||
"""
|
"""
|
||||||
directory = Path(dir_path)
|
directory = Path(dir_path)
|
||||||
items: List[Dict[str, Any]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
|
hidden_set = set(get_hidden_paths())
|
||||||
|
|
||||||
if not directory.exists() or not directory.is_dir():
|
if not directory.exists() or not directory.is_dir():
|
||||||
return {'items': items, 'errors': [f"Directory not found: {dir_path}"]}
|
return {'items': items, 'errors': [f"Directory not found: {dir_path}"]}
|
||||||
@@ -527,6 +566,11 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
|
|||||||
return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]}
|
return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]}
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
|
entry_path_str = str(entry)
|
||||||
|
is_hidden = os.path.abspath(entry_path_str) in hidden_set
|
||||||
|
if skip_hidden and is_hidden:
|
||||||
|
continue
|
||||||
|
|
||||||
if _looks_like_course(entry):
|
if _looks_like_course(entry):
|
||||||
media_count = len([
|
media_count = len([
|
||||||
f for f in entry.rglob('*')
|
f for f in entry.rglob('*')
|
||||||
@@ -535,8 +579,9 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
|
|||||||
items.append({
|
items.append({
|
||||||
'type': 'course',
|
'type': 'course',
|
||||||
'name': entry.name,
|
'name': entry.name,
|
||||||
'path': str(entry),
|
'path': entry_path_str,
|
||||||
'media_files': media_count
|
'media_files': media_count,
|
||||||
|
'hidden': is_hidden
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
has_course_inside = any(
|
has_course_inside = any(
|
||||||
@@ -547,7 +592,8 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
|
|||||||
items.append({
|
items.append({
|
||||||
'type': 'directory',
|
'type': 'directory',
|
||||||
'name': entry.name,
|
'name': entry.name,
|
||||||
'path': str(entry),
|
'path': entry_path_str,
|
||||||
|
'hidden': is_hidden
|
||||||
})
|
})
|
||||||
|
|
||||||
return {'items': items, 'errors': []}
|
return {'items': items, 'errors': []}
|
||||||
@@ -614,8 +660,26 @@ def get_recent_views() -> List[Dict[str, Any]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _read_lesson_progress_entry(course_path: str, lesson_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Read a single lesson's progress entry directly from its course's own
|
||||||
|
progress file, without needing that course to be the currently loaded
|
||||||
|
one. Used to show watch progress for 'Recently Viewed' entries that may
|
||||||
|
belong to a different course than whatever's active right now.
|
||||||
|
"""
|
||||||
|
if not course_path or not lesson_path:
|
||||||
|
return {}
|
||||||
|
progress_file = os.path.join(course_path, '.offlineu_progress.json')
|
||||||
|
try:
|
||||||
|
with open(progress_file, 'r') as f:
|
||||||
|
progress = json.load(f)
|
||||||
|
return progress.get(lesson_path, {})
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_recent_views_for_display() -> List[Dict[str, Any]]:
|
def get_recent_views_for_display() -> List[Dict[str, Any]]:
|
||||||
"""Recent views with a human-friendly timestamp added for the template."""
|
"""Recent views with a human-friendly timestamp and watch progress added."""
|
||||||
entries = get_recent_views()
|
entries = get_recent_views()
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
try:
|
try:
|
||||||
@@ -623,6 +687,19 @@ def get_recent_views_for_display() -> List[Dict[str, Any]]:
|
|||||||
entry['viewed_display'] = dt.strftime('%b %d, %I:%M %p').replace(' 0', ' ')
|
entry['viewed_display'] = dt.strftime('%b %d, %I:%M %p').replace(' 0', ' ')
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
entry['viewed_display'] = ''
|
entry['viewed_display'] = ''
|
||||||
|
|
||||||
|
lesson_progress = _read_lesson_progress_entry(entry.get('course_path', ''), entry.get('lesson_path', ''))
|
||||||
|
completed = lesson_progress.get('completed', False)
|
||||||
|
progress_seconds = lesson_progress.get('progress_seconds', 0)
|
||||||
|
duration_seconds = lesson_progress.get('duration_seconds', 0)
|
||||||
|
|
||||||
|
entry['completed'] = completed
|
||||||
|
if completed:
|
||||||
|
entry['percent_watched'] = 100
|
||||||
|
elif duration_seconds:
|
||||||
|
entry['percent_watched'] = max(0, min(100, round(100 * progress_seconds / duration_seconds)))
|
||||||
|
else:
|
||||||
|
entry['percent_watched'] = 0
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
@@ -648,15 +725,25 @@ class ProgressTracker:
|
|||||||
print(f"Error saving progress: {e}")
|
print(f"Error saving progress: {e}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_lesson_progress(course: Course, lesson_path: str, completed: bool = False, progress_seconds: int = 0):
|
def update_lesson_progress(course: Course, lesson_path: str, completed: bool = False,
|
||||||
|
progress_seconds: int = 0, duration_seconds: Optional[int] = None):
|
||||||
"""Update progress for specific lesson by path"""
|
"""Update progress for specific lesson by path"""
|
||||||
progress = ProgressTracker.load_progress(course)
|
progress = ProgressTracker.load_progress(course)
|
||||||
|
existing = progress.get(lesson_path, {})
|
||||||
|
|
||||||
progress[lesson_path] = {
|
entry = {
|
||||||
'completed': completed,
|
'completed': completed,
|
||||||
'progress_seconds': progress_seconds,
|
'progress_seconds': progress_seconds,
|
||||||
'last_accessed': datetime.now().isoformat()
|
'last_accessed': datetime.now().isoformat()
|
||||||
}
|
}
|
||||||
|
# Preserve a previously-known duration if this particular save
|
||||||
|
# didn't report one, rather than clobbering it back to unknown.
|
||||||
|
if duration_seconds:
|
||||||
|
entry['duration_seconds'] = duration_seconds
|
||||||
|
elif existing.get('duration_seconds'):
|
||||||
|
entry['duration_seconds'] = existing['duration_seconds']
|
||||||
|
|
||||||
|
progress[lesson_path] = entry
|
||||||
|
|
||||||
# Update last accessed path
|
# Update last accessed path
|
||||||
progress['last_accessed_path'] = lesson_path
|
progress['last_accessed_path'] = lesson_path
|
||||||
@@ -683,10 +770,12 @@ class ProgressTracker:
|
|||||||
lesson.completed = progress[lesson_path].get('completed', False)
|
lesson.completed = progress[lesson_path].get('completed', False)
|
||||||
lesson.last_accessed = progress[lesson_path].get('last_accessed')
|
lesson.last_accessed = progress[lesson_path].get('last_accessed')
|
||||||
lesson.progress_seconds = progress[lesson_path].get('progress_seconds', 0)
|
lesson.progress_seconds = progress[lesson_path].get('progress_seconds', 0)
|
||||||
|
lesson.duration_seconds = progress[lesson_path].get('duration_seconds', 0)
|
||||||
elif lesson_path_with_title in progress:
|
elif lesson_path_with_title in progress:
|
||||||
lesson.completed = progress[lesson_path_with_title].get('completed', False)
|
lesson.completed = progress[lesson_path_with_title].get('completed', False)
|
||||||
lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed')
|
lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed')
|
||||||
lesson.progress_seconds = progress[lesson_path_with_title].get('progress_seconds', 0)
|
lesson.progress_seconds = progress[lesson_path_with_title].get('progress_seconds', 0)
|
||||||
|
lesson.duration_seconds = progress[lesson_path_with_title].get('duration_seconds', 0)
|
||||||
|
|
||||||
# Recursively apply to children
|
# Recursively apply to children
|
||||||
for child in node.children.values():
|
for child in node.children.values():
|
||||||
@@ -839,6 +928,60 @@ def browse_library():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/library/manage')
|
||||||
|
def browse_library_manage():
|
||||||
|
"""
|
||||||
|
Same as /library, but includes items the user has hidden (flagged
|
||||||
|
'hidden': true rather than filtered out), so the Settings-page
|
||||||
|
curation UI can browse the whole tree and toggle visibility.
|
||||||
|
"""
|
||||||
|
library_root = os.path.abspath(get_library_root())
|
||||||
|
requested_path = request.args.get('path', library_root)
|
||||||
|
target_path = os.path.abspath(requested_path)
|
||||||
|
|
||||||
|
if not (target_path == library_root or target_path.startswith(library_root + os.sep)):
|
||||||
|
return jsonify({'error': 'Path outside library root', 'items': [], 'errors': ['Access denied']}), 403
|
||||||
|
|
||||||
|
result = list_library_directory(target_path, skip_hidden=False)
|
||||||
|
return jsonify({
|
||||||
|
'library_path': library_root,
|
||||||
|
'current_path': target_path,
|
||||||
|
'items': result['items'],
|
||||||
|
'errors': result['errors']
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/hidden-paths', methods=['GET'])
|
||||||
|
def get_hidden_paths_api():
|
||||||
|
"""List currently-hidden course/directory paths, with display names."""
|
||||||
|
paths = get_hidden_paths()
|
||||||
|
return jsonify({
|
||||||
|
'hidden_paths': [
|
||||||
|
{'path': p, 'name': os.path.basename(p.rstrip(os.sep)) or p}
|
||||||
|
for p in paths
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/hidden-paths', methods=['POST'])
|
||||||
|
def set_hidden_path_api():
|
||||||
|
"""Hide or un-hide a course/directory from the Library browser."""
|
||||||
|
data = request.json or {}
|
||||||
|
path = data.get('path', '')
|
||||||
|
hidden = bool(data.get('hidden', True))
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return jsonify({'error': 'path is required'}), 400
|
||||||
|
|
||||||
|
library_root = os.path.abspath(get_library_root())
|
||||||
|
target = os.path.abspath(path)
|
||||||
|
if not (target == library_root or target.startswith(library_root + os.sep)):
|
||||||
|
return jsonify({'error': 'Path outside library root'}), 403
|
||||||
|
|
||||||
|
updated = set_path_hidden(target, hidden)
|
||||||
|
return jsonify({'success': True, 'hidden_paths': updated})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings')
|
@app.route('/settings')
|
||||||
def settings_page():
|
def settings_page():
|
||||||
"""Render the display-settings page."""
|
"""Render the display-settings page."""
|
||||||
@@ -1068,10 +1211,11 @@ def update_progress():
|
|||||||
lesson_path = data.get('lesson_path')
|
lesson_path = data.get('lesson_path')
|
||||||
completed = data.get('completed', False)
|
completed = data.get('completed', False)
|
||||||
progress_seconds = data.get('progress_seconds', 0)
|
progress_seconds = data.get('progress_seconds', 0)
|
||||||
|
duration_seconds = data.get('duration_seconds') or None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ProgressTracker.update_lesson_progress(
|
ProgressTracker.update_lesson_progress(
|
||||||
current_course, lesson_path, completed, progress_seconds
|
current_course, lesson_path, completed, progress_seconds, duration_seconds
|
||||||
)
|
)
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -349,6 +349,8 @@
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-left: 3px solid #666;
|
border-left: 3px solid #666;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-item:hover {
|
.lesson-item:hover {
|
||||||
@@ -366,6 +368,38 @@
|
|||||||
background: #2d5a2d;
|
background: #2d5a2d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lesson-item.in-progress {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-progress-track {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-item.completed .lesson-progress-fill {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watched-badge {
|
||||||
|
font-size: 0.75em;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.lesson-title {
|
.lesson-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -458,25 +492,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
{% if recent_views %}
|
|
||||||
<div class="container">
|
|
||||||
<div class="card">
|
|
||||||
<h2>🕐 Recently Viewed</h2>
|
|
||||||
<div style="margin-top: 12px;">
|
|
||||||
{% for view in recent_views %}
|
|
||||||
<div class="lesson-item"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
<span class="lesson-meta">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if course %}
|
{% if course %}
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -537,7 +552,8 @@
|
|||||||
|
|
||||||
{% for lesson in node.lessons %}
|
{% for lesson in node.lessons %}
|
||||||
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
|
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
|
||||||
<div class="lesson-item {% if lesson.completed %}completed{% endif %}"
|
{% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %}
|
||||||
|
<div class="lesson-item {% if lesson.completed %}completed{% elif percent_watched %}in-progress{% endif %}"
|
||||||
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
|
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
|
||||||
<div class="lesson-title">
|
<div class="lesson-title">
|
||||||
<span class="lesson-icon">
|
<span class="lesson-icon">
|
||||||
@@ -553,10 +569,15 @@
|
|||||||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
||||||
{% if lesson.completed %}
|
{% if lesson.completed %}
|
||||||
<span class="status-icon completed">✓</span>
|
<span class="status-icon completed">✓</span>
|
||||||
|
{% elif percent_watched %}
|
||||||
|
<span class="watched-badge">{{ percent_watched }}% watched</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="status-icon pending">○</span>
|
<span class="status-icon pending">○</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if percent_watched %}
|
||||||
|
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -568,6 +589,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
{% if recent_views %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="card">
|
||||||
|
<h2>🕐 Recently Viewed</h2>
|
||||||
|
<div style="margin-top: 12px;">
|
||||||
|
{% for view in recent_views %}
|
||||||
|
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-meta">
|
||||||
|
{% if view.completed %}
|
||||||
|
<span class="status-icon completed">✓</span>
|
||||||
|
{% 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>
|
||||||
|
</div>
|
||||||
|
{% if view.percent_watched %}
|
||||||
|
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card" id="library-card">
|
<div class="card" id="library-card">
|
||||||
<h2>Your Courses</h2>
|
<h2>Your Courses</h2>
|
||||||
|
|||||||
@@ -452,13 +452,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveProgress(progressSeconds, completed = false) {
|
function saveProgress(progressSeconds, completed = false) {
|
||||||
|
const duration = activeMedia && isFinite(activeMedia.duration) ? Math.floor(activeMedia.duration) : null;
|
||||||
fetch('/api/progress', {
|
fetch('/api/progress', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
lesson_path: '{{ lesson_path }}',
|
lesson_path: '{{ lesson_path }}',
|
||||||
completed: completed,
|
completed: completed,
|
||||||
progress_seconds: Math.floor(progressSeconds)
|
progress_seconds: Math.floor(progressSeconds),
|
||||||
|
duration_seconds: duration
|
||||||
})
|
})
|
||||||
}).catch(err => console.error('Failed to save progress:', err));
|
}).catch(err => console.error('Failed to save progress:', err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,58 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { background: var(--bg-tertiary-hover); }
|
.btn-secondary:hover { background: var(--bg-tertiary-hover); }
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.curate-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.curate-row:hover {
|
||||||
|
background: var(--bg-tertiary-hover);
|
||||||
|
}
|
||||||
|
.curate-row.is-hidden {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.curate-row-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.curate-row-name span.name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.curate-toggle {
|
||||||
|
font-size: 1.1em;
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.curate-children {
|
||||||
|
margin-left: 22px;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.curate-children.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.curate-empty-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
#save-status {
|
#save-status {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: #28a745;
|
color: #28a745;
|
||||||
@@ -331,6 +383,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Curate 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
|
||||||
|
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
|
||||||
|
<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 id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
|
||||||
|
<div id="curate-tree"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Load a Course Manually</h2>
|
<h2>Load a Course Manually</h2>
|
||||||
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||||||
@@ -431,6 +500,129 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Library curation: hide/show courses & folders ----
|
||||||
|
function loadHiddenList() {
|
||||||
|
fetch('/api/hidden-paths')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const wrap = document.getElementById('hidden-list-wrap');
|
||||||
|
const list = document.getElementById('hidden-list');
|
||||||
|
const items = data.hidden_paths || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.style.display = 'block';
|
||||||
|
list.innerHTML = items.map(item => `
|
||||||
|
<div class="curate-row">
|
||||||
|
<div class="curate-row-name">
|
||||||
|
<span>🚫</span>
|
||||||
|
<span class="name">${item.name}</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCurateLevel(path, container, isRoot) {
|
||||||
|
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (isRoot) {
|
||||||
|
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
|
||||||
|
}
|
||||||
|
renderCurateLevel(data, container);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
container.innerHTML = '<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurateLevel(data, container) {
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
|
||||||
|
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = data.items.map(item => {
|
||||||
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
|
const icon = item.type === 'course' ? '🎓' : '📁';
|
||||||
|
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
||||||
|
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
|
||||||
|
|
||||||
|
if (item.type === 'course') {
|
||||||
|
return `
|
||||||
|
<div class="curate-row ${hiddenClass}">
|
||||||
|
<div class="curate-row-name">
|
||||||
|
<span>${icon}</span>
|
||||||
|
<span class="name">${item.name}</span>
|
||||||
|
</div>
|
||||||
|
${toggleBtn}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
|
||||||
|
<div class="curate-row-name">
|
||||||
|
<span class="curate-toggle">▶</span>
|
||||||
|
<span>${icon}</span>
|
||||||
|
<span class="name">${item.name}</span>
|
||||||
|
</div>
|
||||||
|
${toggleBtn}
|
||||||
|
</div>
|
||||||
|
<div class="curate-children" data-loaded="false"></div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCurateDir(rowEl, path) {
|
||||||
|
const content = rowEl.nextElementSibling;
|
||||||
|
const toggleIcon = rowEl.querySelector('.curate-toggle');
|
||||||
|
if (!content || !content.classList.contains('curate-children')) return;
|
||||||
|
|
||||||
|
if (content.classList.contains('expanded')) {
|
||||||
|
content.classList.remove('expanded');
|
||||||
|
if (toggleIcon) toggleIcon.textContent = '▶';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
content.classList.add('expanded');
|
||||||
|
if (toggleIcon) toggleIcon.textContent = '▼';
|
||||||
|
|
||||||
|
if (content.dataset.loaded === 'true') return;
|
||||||
|
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
|
||||||
|
content.dataset.loaded = 'true';
|
||||||
|
content.dataset.path = path;
|
||||||
|
loadCurateLevel(path, content, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHidden(path, hidden, btnEl) {
|
||||||
|
fetch('/api/hidden-paths', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: path, hidden: hidden })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
loadHiddenList();
|
||||||
|
// Refresh just the level this toggle happened in, rather
|
||||||
|
// than collapsing the whole tree back to root.
|
||||||
|
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
|
||||||
|
: document.getElementById('curate-tree');
|
||||||
|
const isRootContainer = container.id === 'curate-tree';
|
||||||
|
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||||||
|
loadCurateLevel(refreshPath, container, isRootContainer);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHiddenList();
|
||||||
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||||
|
|
||||||
function saveLibraryPath() {
|
function saveLibraryPath() {
|
||||||
const input = document.getElementById('library_path');
|
const input = document.getElementById('library_path');
|
||||||
const status = document.getElementById('library-path-status');
|
const status = document.getElementById('library-path-status');
|
||||||
|
|||||||
Reference in New Issue
Block a user