New main page way of browsing the courses

This commit is contained in:
2026-08-20 18:48:17 -04:00
parent 5c5b068c4c
commit 66dff7e69f
3 changed files with 187 additions and 97 deletions
+79 -58
View File
@@ -46,6 +46,7 @@ DEFAULT_SETTINGS = {
'density': 'comfortable', # 'comfortable' | 'compact' 'density': 'comfortable', # 'comfortable' | 'compact'
'card_style': 'flat', # 'flat' | 'elevated' | 'bordered' 'card_style': 'flat', # 'flat' | 'elevated' | 'bordered'
'corner_radius': 'rounded', # 'sharp' | 'rounded' | 'pill' 'corner_radius': 'rounded', # 'sharp' | 'rounded' | 'pill'
'library_path': '', # '' = use COURSES_LIBRARY_PATH/--library-path default
} }
# Allowed values for every setting except accent_color (validated separately # Allowed values for every setting except accent_color (validated separately
@@ -86,6 +87,10 @@ def load_settings() -> Dict[str, Any]:
for key, value in saved.items(): for key, value in saved.items():
if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value): if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value):
settings[key] = value settings[key] = value
elif key == 'library_path' and isinstance(value, str):
# Lenient on load (directory may be transiently
# unavailable at startup) - only validated on save.
settings[key] = value
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
settings[key] = value settings[key] = value
except (json.JSONDecodeError, OSError) as e: except (json.JSONDecodeError, OSError) as e:
@@ -99,6 +104,11 @@ def save_settings(new_settings: Dict[str, Any]) -> Dict[str, Any]:
for key, value in new_settings.items(): for key, value in new_settings.items():
if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value): if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(value):
current[key] = value current[key] = value
elif key == 'library_path' and isinstance(value, str):
value = value.strip()
if value and not os.path.isdir(value):
raise ValueError(f"Directory not found: {value}")
current[key] = value
elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]: elif key in SETTINGS_CHOICES and value in SETTINGS_CHOICES[key]:
current[key] = value current[key] = value
os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
@@ -364,69 +374,69 @@ def _looks_like_course(directory: Path) -> bool:
return len(children_with_media) >= 2 return len(children_with_media) >= 2
def scan_course_library(library_path: str, max_depth: int = 5) -> Dict[str, Any]: def list_library_directory(dir_path: str) -> Dict[str, Any]:
""" """
Scan a base 'library' directory for course folders and group them by the List only the immediate children of dir_path for the lazy-loading
parent directory they live in, so the UI can show something like: Library browser: subdirectories to drill into, and course folders to
load directly - without recursively scanning the whole tree. This is
what lets the UI start collapsed at the top level and expand on demand
instead of rendering hundreds of courses at once.
Udemy/ Directories that don't lead to any course anywhere inside them are
Python Bootcamp left out entirely, so drilling down never dead-ends on an empty folder.
Web Dev Masterclass
Pluralsight/
Docker Deep Dive
(Library Root)
Standalone Course
Recursion stops as soon as a directory looks like a course (see
_looks_like_course), so nested "Section" folders inside a course aren't
mistaken for more courses.
""" """
library_root = Path(library_path) directory = Path(dir_path)
groups: Dict[str, List[Dict[str, Any]]] = {} items: List[Dict[str, Any]] = []
errors: List[str] = []
if not library_root.exists() or not library_root.is_dir(): if not directory.exists() or not directory.is_dir():
return {'groups': groups, 'errors': [f"Library path not found: {library_path}"]} return {'items': items, 'errors': [f"Directory not found: {dir_path}"]}
def walk(current: Path, depth: int): try:
if depth > max_depth: entries = sorted(
return (p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
try: key=lambda p: p.name.lower()
entries = sorted( )
(p for p in current.iterdir() if p.is_dir() and not p.name.startswith('.')), except (PermissionError, OSError) as e:
key=lambda p: p.name.lower() return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]}
for entry in entries:
if _looks_like_course(entry):
media_count = len([
f for f in entry.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
])
items.append({
'type': 'course',
'name': entry.name,
'path': str(entry),
'media_files': media_count
})
else:
has_course_inside = any(
f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
for f in entry.rglob('*')
) )
except (PermissionError, OSError) as e: if has_course_inside:
errors.append(f"Could not read {current}: {e}") items.append({
return 'type': 'directory',
for entry in entries:
if _looks_like_course(entry):
try:
rel_parent = entry.parent.relative_to(library_root)
except ValueError:
rel_parent = Path('.')
group_label = str(rel_parent).replace('\\', '/') if str(rel_parent) != '.' else '(Library Root)'
media_count = len([
f for f in entry.rglob('*')
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
])
groups.setdefault(group_label, []).append({
'name': entry.name, 'name': entry.name,
'path': str(entry), 'path': str(entry),
'media_files': media_count
}) })
else:
walk(entry, depth + 1)
walk(library_root, 0) return {'items': items, 'errors': []}
for courses in groups.values():
courses.sort(key=lambda c: c['name'].lower())
return {'groups': groups, 'errors': errors} def get_library_root() -> str:
"""
The effective root the Library browser scans: the persisted
'library_path' setting if it's set and exists, otherwise the
COURSES_LIBRARY_PATH/--library-path default.
"""
settings = load_settings()
override = (settings.get('library_path') or '').strip()
if override and os.path.isdir(override):
return override
return LIBRARY_PATH
class ProgressTracker: class ProgressTracker:
@@ -617,15 +627,25 @@ def browse_directories():
@app.route('/library') @app.route('/library')
def browse_library(): def browse_library():
""" """
Scan the configured courses library and return courses grouped by Lazily list one directory level of the courses library at a time, so
parent directory, so the UI can offer a click-through picker instead the UI can start collapsed at the top level and drill down on click
of requiring a typed filesystem path. instead of scanning/rendering the whole tree upfront. Defaults to the
effective library root (persisted setting, or the
COURSES_LIBRARY_PATH/--library-path default); the 'path' query param
lets the browser descend, but is restricted to stay within that root.
""" """
library_path = request.args.get('path', LIBRARY_PATH) library_root = os.path.abspath(get_library_root())
result = scan_course_library(library_path) 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)
return jsonify({ return jsonify({
'library_path': library_path, 'library_path': library_root,
'groups': result['groups'], 'current_path': target_path,
'items': result['items'],
'errors': result['errors'] 'errors': result['errors']
}) })
@@ -637,6 +657,7 @@ def settings_page():
return render_template( return render_template(
'settings.html', 'settings.html',
settings=current, settings=current,
default_library_path=LIBRARY_PATH,
theme_choices=sorted(SETTINGS_CHOICES['theme']), theme_choices=sorted(SETTINGS_CHOICES['theme']),
font_family_choices=['system', 'sans', 'serif', 'monospace'], font_family_choices=['system', 'sans', 'serif', 'monospace'],
font_size_choices=['small', 'medium', 'large', 'xlarge'], font_size_choices=['small', 'medium', 'large', 'xlarge'],
+68 -39
View File
@@ -680,55 +680,84 @@
function loadLibrary() { function loadLibrary() {
const libraryCard = document.getElementById('library-card'); const libraryCard = document.getElementById('library-card');
if (!libraryCard) return; if (!libraryCard) return;
fetchLibraryLevel(null, document.getElementById('library-groups'), true);
}
fetch('/library') function fetchLibraryLevel(path, container, isRoot) {
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
fetch(url)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
document.getElementById('library-path-bar').textContent = if (isRoot) {
`Scanning ${data.library_path}`; document.getElementById('library-path-bar').textContent =
`Scanning ${data.library_path}`;
const container = document.getElementById('library-groups');
const groupNames = Object.keys(data.groups).sort((a, b) => a.localeCompare(b));
if (groupNames.length === 0) {
const reason = (data.errors && data.errors.length)
? data.errors.join(' ')
: `No course folders found under ${data.library_path}.`;
container.innerHTML = `<p style="color:#999;">${reason} You can still load a course by path below.</p>`;
return;
} }
renderLibraryLevel(data, container, isRoot);
container.innerHTML = groupNames.map((group, i) => `
<div class="tree-item">
<div class="tree-header directory" onclick="toggleTree(this)">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">${group}</span>
<span class="tree-stats">${data.groups[group].length} course${data.groups[group].length === 1 ? '' : 's'}</span>
</div>
<button class="tree-toggle">▶</button>
</div>
<div class="tree-content">
${data.groups[group].map(course => `
<div class="lesson-item"
onclick="loadCoursePath('${course.path.replace(/'/g, "\\'")}')">
<div class="lesson-title">
<span class="lesson-icon">🎓</span>
<span>${course.name}</span>
</div>
<span class="lesson-meta">${course.media_files} media file${course.media_files === 1 ? '' : 's'}</span>
</div>
`).join('')}
</div>
</div>
`).join('');
}) })
.catch(() => { .catch(() => {
document.getElementById('library-groups').innerHTML = container.innerHTML =
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>'; '<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
}); });
} }
function renderLibraryLevel(data, container, isRoot) {
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length)
? data.errors.join(' ')
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
container.innerHTML = `<p style="color:#999; padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
return;
}
container.innerHTML = data.items.map(item => {
if (item.type === 'course') {
return `
<div class="lesson-item"
onclick="loadCoursePath('${item.path.replace(/'/g, "\\'")}')">
<div class="lesson-title">
<span class="lesson-icon">🎓</span>
<span>${item.name}</span>
</div>
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
</div>
`;
}
return `
<div class="tree-item">
<div class="tree-header directory" onclick="toggleLibraryDir(this, '${item.path.replace(/'/g, "\\'")}')">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">${item.name}</span>
</div>
<button class="tree-toggle">▶</button>
</div>
<div class="tree-content" data-loaded="false"></div>
</div>
`;
}).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 = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
content.dataset.loaded = 'true';
fetchLibraryLevel(path, content, false);
}
function loadCoursePath(path) { function loadCoursePath(path) {
fetch('/load_course', { fetch('/load_course', {
method: 'POST', method: 'POST',
+40
View File
@@ -312,6 +312,21 @@
</div> </div>
</div> </div>
<div class="card">
<h2>Library</h2>
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
<label for="library_path">Default courses directory
<span class="setting-desc">Where the Library browser starts. Leave blank to use the server default ({{ default_library_path }}).</span>
</label>
<div style="display: flex; gap: 10px;">
<input type="text" id="library_path" class="hex-input" style="flex: 1; width: auto; font-family: var(--font-family);"
placeholder="{{ default_library_path }}" value="{{ settings.library_path }}">
<button class="btn" onclick="saveLibraryPath()">Save</button>
</div>
<span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
</div>
</div>
<div class="actions"> <div class="actions">
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button> <button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
<span id="save-status">✓ Saved</span> <span id="save-status">✓ Saved</span>
@@ -380,6 +395,31 @@
} }
}); });
function saveLibraryPath() {
const input = document.getElementById('library_path');
const status = document.getElementById('library-path-status');
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library_path: input.value.trim() })
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.color = '#28a745';
status.textContent = '✓ Saved — reload the main page to see it take effect';
showSaved();
} else {
status.style.color = '#ff6b6b';
status.textContent = data.error || 'Could not save';
}
})
.catch(err => {
status.style.color = '#ff6b6b';
status.textContent = 'Could not reach the server';
});
}
function resetSettings() { function resetSettings() {
fetch('/api/settings/reset', { method: 'POST' }) fetch('/api/settings/reset', { method: 'POST' })
.then(r => r.json()) .then(r => r.json())