Adding ability to hide / show courses
This commit is contained in:
+154
-10
@@ -267,6 +267,7 @@ class Lesson:
|
||||
completed: bool = False
|
||||
last_accessed: Optional[str] = None
|
||||
progress_seconds: int = 0
|
||||
duration_seconds: int = 0
|
||||
order: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -501,7 +502,38 @@ def _looks_like_course(directory: Path) -> bool:
|
||||
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
|
||||
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
|
||||
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)
|
||||
items: List[Dict[str, Any]] = []
|
||||
hidden_set = set(get_hidden_paths())
|
||||
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
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}"]}
|
||||
|
||||
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):
|
||||
media_count = len([
|
||||
f for f in entry.rglob('*')
|
||||
@@ -535,8 +579,9 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
|
||||
items.append({
|
||||
'type': 'course',
|
||||
'name': entry.name,
|
||||
'path': str(entry),
|
||||
'media_files': media_count
|
||||
'path': entry_path_str,
|
||||
'media_files': media_count,
|
||||
'hidden': is_hidden
|
||||
})
|
||||
else:
|
||||
has_course_inside = any(
|
||||
@@ -547,7 +592,8 @@ def list_library_directory(dir_path: str) -> Dict[str, Any]:
|
||||
items.append({
|
||||
'type': 'directory',
|
||||
'name': entry.name,
|
||||
'path': str(entry),
|
||||
'path': entry_path_str,
|
||||
'hidden': is_hidden
|
||||
})
|
||||
|
||||
return {'items': items, 'errors': []}
|
||||
@@ -614,8 +660,26 @@ def get_recent_views() -> List[Dict[str, Any]]:
|
||||
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]]:
|
||||
"""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()
|
||||
for entry in entries:
|
||||
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', ' ')
|
||||
except (KeyError, ValueError):
|
||||
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
|
||||
|
||||
|
||||
@@ -648,16 +725,26 @@ class ProgressTracker:
|
||||
print(f"Error saving progress: {e}")
|
||||
|
||||
@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"""
|
||||
progress = ProgressTracker.load_progress(course)
|
||||
|
||||
progress[lesson_path] = {
|
||||
existing = progress.get(lesson_path, {})
|
||||
|
||||
entry = {
|
||||
'completed': completed,
|
||||
'progress_seconds': progress_seconds,
|
||||
'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
|
||||
progress['last_accessed_path'] = lesson_path
|
||||
|
||||
@@ -683,10 +770,12 @@ class ProgressTracker:
|
||||
lesson.completed = progress[lesson_path].get('completed', False)
|
||||
lesson.last_accessed = progress[lesson_path].get('last_accessed')
|
||||
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:
|
||||
lesson.completed = progress[lesson_path_with_title].get('completed', False)
|
||||
lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed')
|
||||
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
|
||||
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')
|
||||
def settings_page():
|
||||
"""Render the display-settings page."""
|
||||
@@ -1068,10 +1211,11 @@ def update_progress():
|
||||
lesson_path = data.get('lesson_path')
|
||||
completed = data.get('completed', False)
|
||||
progress_seconds = data.get('progress_seconds', 0)
|
||||
duration_seconds = data.get('duration_seconds') or None
|
||||
|
||||
try:
|
||||
ProgressTracker.update_lesson_progress(
|
||||
current_course, lesson_path, completed, progress_seconds
|
||||
current_course, lesson_path, completed, progress_seconds, duration_seconds
|
||||
)
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
|
||||
@@ -349,6 +349,8 @@
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid #666;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lesson-item:hover {
|
||||
@@ -366,6 +368,38 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -458,25 +492,6 @@
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<div class="nav">
|
||||
<div class="container">
|
||||
@@ -537,7 +552,8 @@
|
||||
|
||||
{% for lesson in node.lessons %}
|
||||
{% 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(' ', '_') }}'">
|
||||
<div class="lesson-title">
|
||||
<span class="lesson-icon">
|
||||
@@ -553,10 +569,15 @@
|
||||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
||||
{% if lesson.completed %}
|
||||
<span class="status-icon completed">✓</span>
|
||||
{% elif percent_watched %}
|
||||
<span class="watched-badge">{{ percent_watched }}% watched</span>
|
||||
{% else %}
|
||||
<span class="status-icon pending">○</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if percent_watched %}
|
||||
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -568,6 +589,35 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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="card" id="library-card">
|
||||
<h2>Your Courses</h2>
|
||||
|
||||
@@ -452,13 +452,15 @@
|
||||
}
|
||||
|
||||
function saveProgress(progressSeconds, completed = false) {
|
||||
const duration = activeMedia && isFinite(activeMedia.duration) ? Math.floor(activeMedia.duration) : null;
|
||||
fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
lesson_path: '{{ lesson_path }}',
|
||||
completed: completed,
|
||||
progress_seconds: Math.floor(progressSeconds)
|
||||
progress_seconds: Math.floor(progressSeconds),
|
||||
duration_seconds: duration
|
||||
})
|
||||
}).catch(err => console.error('Failed to save progress:', err));
|
||||
}
|
||||
|
||||
@@ -203,6 +203,58 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.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 {
|
||||
font-size: 0.9em;
|
||||
color: #28a745;
|
||||
@@ -331,6 +383,23 @@
|
||||
</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">
|
||||
<h2>Load a Course Manually</h2>
|
||||
<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() {
|
||||
const input = document.getElementById('library_path');
|
||||
const status = document.getElementById('library-path-status');
|
||||
|
||||
Reference in New Issue
Block a user