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'
'card_style': 'flat', # 'flat' | 'elevated' | 'bordered'
'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
@@ -86,6 +87,10 @@ def load_settings() -> Dict[str, Any]:
for key, value in saved.items():
if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(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]:
settings[key] = value
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():
if key == 'accent_color' and isinstance(value, str) and HEX_COLOR_RE.match(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]:
current[key] = value
os.makedirs(DATA_DIR, exist_ok=True)
@@ -364,69 +374,69 @@ def _looks_like_course(directory: Path) -> bool:
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
parent directory they live in, so the UI can show something like:
List only the immediate children of dir_path for the lazy-loading
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/
Python Bootcamp
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.
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.
"""
library_root = Path(library_path)
groups: Dict[str, List[Dict[str, Any]]] = {}
errors: List[str] = []
directory = Path(dir_path)
items: List[Dict[str, Any]] = []
if not library_root.exists() or not library_root.is_dir():
return {'groups': groups, 'errors': [f"Library path not found: {library_path}"]}
if not directory.exists() or not directory.is_dir():
return {'items': items, 'errors': [f"Directory not found: {dir_path}"]}
def walk(current: Path, depth: int):
if depth > max_depth:
return
try:
entries = sorted(
(p for p in current.iterdir() if p.is_dir() and not p.name.startswith('.')),
key=lambda p: p.name.lower()
try:
entries = sorted(
(p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
key=lambda p: p.name.lower()
)
except (PermissionError, OSError) as e:
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:
errors.append(f"Could not read {current}: {e}")
return
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({
if has_course_inside:
items.append({
'type': 'directory',
'name': entry.name,
'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:
@@ -617,15 +627,25 @@ def browse_directories():
@app.route('/library')
def browse_library():
"""
Scan the configured courses library and return courses grouped by
parent directory, so the UI can offer a click-through picker instead
of requiring a typed filesystem path.
Lazily list one directory level of the courses library at a time, so
the UI can start collapsed at the top level and drill down on click
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)
result = scan_course_library(library_path)
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)
return jsonify({
'library_path': library_path,
'groups': result['groups'],
'library_path': library_root,
'current_path': target_path,
'items': result['items'],
'errors': result['errors']
})
@@ -637,6 +657,7 @@ def settings_page():
return render_template(
'settings.html',
settings=current,
default_library_path=LIBRARY_PATH,
theme_choices=sorted(SETTINGS_CHOICES['theme']),
font_family_choices=['system', 'sans', 'serif', 'monospace'],
font_size_choices=['small', 'medium', 'large', 'xlarge'],
+68 -39
View File
@@ -680,55 +680,84 @@
function loadLibrary() {
const libraryCard = document.getElementById('library-card');
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(data => {
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;
if (isRoot) {
document.getElementById('library-path-bar').textContent =
`Scanning ${data.library_path}`;
}
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('');
renderLibraryLevel(data, container, isRoot);
})
.catch(() => {
document.getElementById('library-groups').innerHTML =
container.innerHTML =
'<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) {
fetch('/load_course', {
method: 'POST',
+40
View File
@@ -312,6 +312,21 @@
</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">
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
<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() {
fetch('/api/settings/reset', { method: 'POST' })
.then(r => r.json())