New main page way of browsing the courses
This commit is contained in:
+66
-45
@@ -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):
|
|
||||||
if depth > max_depth:
|
|
||||||
return
|
|
||||||
try:
|
try:
|
||||||
entries = sorted(
|
entries = sorted(
|
||||||
(p for p in current.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
(p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
||||||
key=lambda p: p.name.lower()
|
key=lambda p: p.name.lower()
|
||||||
)
|
)
|
||||||
except (PermissionError, OSError) as e:
|
except (PermissionError, OSError) as e:
|
||||||
errors.append(f"Could not read {current}: {e}")
|
return {'items': items, 'errors': [f"Could not read {dir_path}: {e}"]}
|
||||||
return
|
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if _looks_like_course(entry):
|
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([
|
media_count = len([
|
||||||
f for f in entry.rglob('*')
|
f for f in entry.rglob('*')
|
||||||
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||||
])
|
])
|
||||||
|
items.append({
|
||||||
groups.setdefault(group_label, []).append({
|
'type': 'course',
|
||||||
'name': entry.name,
|
'name': entry.name,
|
||||||
'path': str(entry),
|
'path': str(entry),
|
||||||
'media_files': media_count
|
'media_files': media_count
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
walk(entry, depth + 1)
|
has_course_inside = any(
|
||||||
|
f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
|
||||||
|
for f in entry.rglob('*')
|
||||||
|
)
|
||||||
|
if has_course_inside:
|
||||||
|
items.append({
|
||||||
|
'type': 'directory',
|
||||||
|
'name': entry.name,
|
||||||
|
'path': str(entry),
|
||||||
|
})
|
||||||
|
|
||||||
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'],
|
||||||
|
|||||||
@@ -680,53 +680,82 @@
|
|||||||
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 => {
|
||||||
|
if (isRoot) {
|
||||||
document.getElementById('library-path-bar').textContent =
|
document.getElementById('library-path-bar').textContent =
|
||||||
`Scanning ${data.library_path}`;
|
`Scanning ${data.library_path}`;
|
||||||
|
}
|
||||||
|
renderLibraryLevel(data, container, isRoot);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
container.innerHTML =
|
||||||
|
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const container = document.getElementById('library-groups');
|
function renderLibraryLevel(data, container, isRoot) {
|
||||||
const groupNames = Object.keys(data.groups).sort((a, b) => a.localeCompare(b));
|
if (!data.items || data.items.length === 0) {
|
||||||
|
|
||||||
if (groupNames.length === 0) {
|
|
||||||
const reason = (data.errors && data.errors.length)
|
const reason = (data.errors && data.errors.length)
|
||||||
? data.errors.join(' ')
|
? data.errors.join(' ')
|
||||||
: `No course folders found under ${data.library_path}.`;
|
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
|
||||||
container.innerHTML = `<p style="color:#999;">${reason} You can still load a course by path below.</p>`;
|
container.innerHTML = `<p style="color:#999; padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = groupNames.map((group, i) => `
|
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-item">
|
||||||
<div class="tree-header directory" onclick="toggleTree(this)">
|
<div class="tree-header directory" onclick="toggleLibraryDir(this, '${item.path.replace(/'/g, "\\'")}')">
|
||||||
<div class="tree-title">
|
<div class="tree-title">
|
||||||
<span class="tree-icon">📁</span>
|
<span class="tree-icon">📁</span>
|
||||||
<span class="tree-name">${group}</span>
|
<span class="tree-name">${item.name}</span>
|
||||||
<span class="tree-stats">${data.groups[group].length} course${data.groups[group].length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="tree-toggle">▶</button>
|
<button class="tree-toggle">▶</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-content">
|
<div class="tree-content" data-loaded="false"></div>
|
||||||
${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>
|
</div>
|
||||||
<span class="lesson-meta">${course.media_files} media file${course.media_files === 1 ? '' : 's'}</span>
|
`;
|
||||||
</div>
|
}).join('');
|
||||||
`).join('')}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
function toggleLibraryDir(headerEl, path) {
|
||||||
`).join('');
|
const content = headerEl.nextElementSibling;
|
||||||
})
|
const toggle = headerEl.querySelector('.tree-toggle');
|
||||||
.catch(() => {
|
if (!content || !content.classList.contains('tree-content')) return;
|
||||||
document.getElementById('library-groups').innerHTML =
|
|
||||||
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
|
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) {
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user