New mobile layout, new regular screen, added the ability to edit directory names from settings
This commit is contained in:
@@ -97,6 +97,32 @@ built locally from a private Gitea repo rather than pulling the upstream image.
|
|||||||
official palette (nordtheme.com). Anyone with `theme: "dainty"` already saved
|
official palette (nordtheme.com). Anyone with `theme: "dainty"` already saved
|
||||||
in `settings.json` falls back gracefully to the `dark` default on next load.
|
in `settings.json` falls back gracefully to the `dark` default on next load.
|
||||||
|
|
||||||
|
## Mobile UI overhaul + in-app renaming (this session)
|
||||||
|
- **Fixed the mobile text-overlap bug** — `.lesson-item`/`.tree-header` in
|
||||||
|
`course_dashboard.html` had no `min-width: 0` or truncation on their flex
|
||||||
|
children, so long course/lesson names (especially scene-release-style dotted
|
||||||
|
names like `Udemy.crash.course.electronics...Mimir` with zero whitespace to
|
||||||
|
wrap on) would overflow and visually overlap neighboring text on narrow
|
||||||
|
screens. Fixed with `min-width:0` + ellipsis truncation (desktop/tablet) and
|
||||||
|
a `@media (max-width: 600px)` block that stacks title/metadata into separate
|
||||||
|
lines with `overflow-wrap: break-word` (the dotted-name case needed this
|
||||||
|
specifically — plain `white-space: normal` doesn't create a break opportunity
|
||||||
|
in a string with no spaces).
|
||||||
|
- **Library browser redesigned** — replaced the nested-indent accordion
|
||||||
|
(`AI` → `ChatGPT` → course, each level eating horizontal space) with a
|
||||||
|
single-level drill-down + breadcrumb (`Library / AI / ChatGPT`), the standard
|
||||||
|
mobile file-browser pattern. Tapping a folder now replaces the list instead
|
||||||
|
of nesting under it. In-course lesson tree keeps its old expand-in-place
|
||||||
|
behavior (shallower, wasn't the reported problem).
|
||||||
|
- **Rename courses/folders from Settings** — "Curate Library" is now
|
||||||
|
"Manage Library": each row gets a ✏️ button alongside Hide/Show that renames
|
||||||
|
the directory in place on disk via a new `POST /api/rename-path`. Handles
|
||||||
|
path-traversal/collision validation, and rebases any `hidden_paths.json` /
|
||||||
|
`recent_views.json` entries nested under the renamed path (progress files
|
||||||
|
need no rebasing — they live inside the directory and are keyed relative to
|
||||||
|
it). If you rename your *currently loaded* course's folder, the app resets
|
||||||
|
to the library view rather than serving a stale path.
|
||||||
|
|
||||||
## Known limitations still open
|
## Known limitations still open
|
||||||
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
- App is unauthenticated by design (matches upstream) — settings and hidden-path
|
||||||
curation apply app-wide, not per-browser/per-user.
|
curation apply app-wide, not per-browser/per-user.
|
||||||
|
|||||||
@@ -557,6 +557,48 @@ def set_path_hidden(path: str, hidden: bool) -> List[str]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str:
|
||||||
|
"""If `value` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`."""
|
||||||
|
if value == old_abs:
|
||||||
|
return new_abs
|
||||||
|
if value.startswith(old_abs + os.sep):
|
||||||
|
return new_abs + value[len(old_abs):]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def rebase_library_path(old_abs: str, new_abs: str) -> None:
|
||||||
|
"""
|
||||||
|
After a directory in the library is renamed/moved on disk, rewrite any
|
||||||
|
stored absolute paths that pointed inside it (hidden_paths.json,
|
||||||
|
recent_views.json's course_path) so curation and Recently Viewed don't
|
||||||
|
silently go stale. Lesson-level progress needs no rebasing - it lives
|
||||||
|
inside the directory itself, keyed by paths relative to it, so it moves
|
||||||
|
with the rename automatically.
|
||||||
|
"""
|
||||||
|
hidden = get_hidden_paths()
|
||||||
|
rebased_hidden = [_rebase_prefix(p, old_abs, new_abs) for p in hidden]
|
||||||
|
if rebased_hidden != hidden:
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
with open(HIDDEN_PATHS_FILE, 'w') as f:
|
||||||
|
json.dump(sorted(set(rebased_hidden)), f, indent=2)
|
||||||
|
|
||||||
|
views = get_recent_views()
|
||||||
|
changed = False
|
||||||
|
for entry in views:
|
||||||
|
course_path = entry.get('course_path', '')
|
||||||
|
rebased = _rebase_prefix(course_path, old_abs, new_abs)
|
||||||
|
if rebased != course_path:
|
||||||
|
entry['course_path'] = rebased
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
try:
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
with open(RECENT_VIEWS_FILE, 'w') as f:
|
||||||
|
json.dump(views, f, indent=2)
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Could not rebase recent views after rename: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _contains_visible_course(directory: Path, hidden_set: set) -> bool:
|
def _contains_visible_course(directory: Path, hidden_set: set) -> bool:
|
||||||
"""
|
"""
|
||||||
Whether `directory` leads to at least one course that isn't curated
|
Whether `directory` leads to at least one course that isn't curated
|
||||||
@@ -1037,6 +1079,54 @@ def set_hidden_path_api():
|
|||||||
return jsonify({'success': True, 'hidden_paths': updated})
|
return jsonify({'success': True, 'hidden_paths': updated})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/rename-path', methods=['POST'])
|
||||||
|
def rename_path_api():
|
||||||
|
"""Rename a course/folder directory in the library, in place on disk."""
|
||||||
|
global current_course
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
path = data.get('path', '')
|
||||||
|
new_name = (data.get('new_name') or '').strip()
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return jsonify({'error': 'path is required'}), 400
|
||||||
|
if not new_name:
|
||||||
|
return jsonify({'error': 'New name cannot be empty'}), 400
|
||||||
|
if '/' in new_name or '\\' in new_name or '\x00' in new_name or new_name in ('.', '..'):
|
||||||
|
return jsonify({'error': 'New name cannot contain path separators'}), 400
|
||||||
|
if new_name.startswith('.'):
|
||||||
|
return jsonify({'error': 'New name cannot start with a dot'}), 400
|
||||||
|
|
||||||
|
library_root = os.path.abspath(get_library_root())
|
||||||
|
old_abs = os.path.abspath(path)
|
||||||
|
if not (old_abs == library_root or old_abs.startswith(library_root + os.sep)):
|
||||||
|
return jsonify({'error': 'Path outside library root'}), 403
|
||||||
|
if old_abs == library_root:
|
||||||
|
return jsonify({'error': 'Cannot rename the library root itself'}), 400
|
||||||
|
if not os.path.isdir(old_abs):
|
||||||
|
return jsonify({'error': 'Directory not found'}), 404
|
||||||
|
|
||||||
|
new_abs = os.path.join(os.path.dirname(old_abs), new_name)
|
||||||
|
if os.path.exists(new_abs):
|
||||||
|
return jsonify({'error': f'"{new_name}" already exists here'}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.rename(old_abs, new_abs)
|
||||||
|
except OSError as e:
|
||||||
|
return jsonify({'error': f'Rename failed: {e}'}), 500
|
||||||
|
|
||||||
|
rebase_library_path(old_abs, new_abs)
|
||||||
|
|
||||||
|
active_course_reset = False
|
||||||
|
if current_course is not None:
|
||||||
|
course_abs = os.path.abspath(current_course.path)
|
||||||
|
if course_abs == old_abs or course_abs.startswith(old_abs + os.sep):
|
||||||
|
current_course = None
|
||||||
|
active_course_reset = True
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'new_path': new_abs, 'active_course_reset': active_course_reset})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings')
|
@app.route('/settings')
|
||||||
def settings_page():
|
def settings_page():
|
||||||
"""Render the display-settings page."""
|
"""Render the display-settings page."""
|
||||||
|
|||||||
+141
-38
@@ -248,6 +248,39 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.library-breadcrumb {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-breadcrumb .crumb {
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-breadcrumb .crumb:not(.current):hover {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-breadcrumb .crumb.current {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-breadcrumb .crumb-sep {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dynamic Directory Tree Styles */
|
/* Dynamic Directory Tree Styles */
|
||||||
.tree-container {
|
.tree-container {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
@@ -295,22 +328,31 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-icon {
|
.tree-icon {
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-name {
|
.tree-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-stats {
|
.tree-stats {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-toggle {
|
.tree-toggle {
|
||||||
@@ -405,10 +447,20 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-icon {
|
.lesson-icon {
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-meta {
|
.lesson-meta {
|
||||||
@@ -417,6 +469,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-meta-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lesson-type {
|
.lesson-type {
|
||||||
@@ -473,6 +535,29 @@
|
|||||||
margin-left: 15px;
|
margin-left: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.lesson-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-title,
|
||||||
|
.lesson-meta {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-name,
|
||||||
|
.lesson-meta-text {
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -563,7 +648,7 @@
|
|||||||
{% elif lesson.lesson_type == 'mixed' %}📦
|
{% elif lesson.lesson_type == 'mixed' %}📦
|
||||||
{% else %}📄{% endif %}
|
{% else %}📄{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{{ lesson.title }}
|
<span class="lesson-name">{{ lesson.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="lesson-meta">
|
<div class="lesson-meta">
|
||||||
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
|
||||||
@@ -599,7 +684,7 @@
|
|||||||
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
|
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
|
||||||
<div class="lesson-title">
|
<div class="lesson-title">
|
||||||
<span class="lesson-icon">▶️</span>
|
<span class="lesson-icon">▶️</span>
|
||||||
<span>{{ view.lesson_title }}</span>
|
<span class="lesson-name">{{ view.lesson_title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="lesson-meta">
|
<div class="lesson-meta">
|
||||||
{% if view.completed %}
|
{% if view.completed %}
|
||||||
@@ -607,7 +692,7 @@
|
|||||||
{% elif view.percent_watched %}
|
{% elif view.percent_watched %}
|
||||||
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
|
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span>{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
|
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
{% if view.percent_watched %}
|
{% if view.percent_watched %}
|
||||||
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
|
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
|
||||||
@@ -621,7 +706,7 @@
|
|||||||
<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>
|
||||||
<p id="library-path-bar" style="color: #999; font-size: 13px; margin-top: 6px;"></p>
|
<p id="library-path-bar" class="library-breadcrumb"></p>
|
||||||
<div id="library-groups" style="margin-top: 15px;"></div>
|
<div id="library-groups" style="margin-top: 15px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -665,22 +750,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-level drill-down: the Library browser shows one folder's
|
||||||
|
// contents at a time (replacing the list, not nesting under it) and
|
||||||
|
// tracks how we got here in libraryTrail so a breadcrumb can jump
|
||||||
|
// back up. Much less horizontal space wasted than the old
|
||||||
|
// indent-per-level accordion, especially on narrow screens.
|
||||||
|
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
|
||||||
|
|
||||||
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);
|
libraryTrail = [];
|
||||||
|
fetchLibraryLevel(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchLibraryLevel(path, container, isRoot) {
|
function fetchLibraryLevel(path) {
|
||||||
|
const container = document.getElementById('library-groups');
|
||||||
|
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
|
||||||
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
|
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (isRoot) {
|
renderLibraryBreadcrumb();
|
||||||
document.getElementById('library-path-bar').textContent =
|
renderLibraryLevel(data);
|
||||||
`Scanning ${data.library_path}`;
|
|
||||||
}
|
|
||||||
renderLibraryLevel(data, container, isRoot);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
container.innerHTML =
|
container.innerHTML =
|
||||||
@@ -688,7 +780,35 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLibraryLevel(data, container, isRoot) {
|
function renderLibraryBreadcrumb() {
|
||||||
|
const bar = document.getElementById('library-path-bar');
|
||||||
|
const atRoot = libraryTrail.length === 0;
|
||||||
|
let html = `<span class="crumb ${atRoot ? 'current' : ''}"` +
|
||||||
|
(atRoot ? '' : ` onclick="goToLibraryCrumb(-1)"`) + `>Library</span>`;
|
||||||
|
libraryTrail.forEach((crumb, i) => {
|
||||||
|
const isLast = i === libraryTrail.length - 1;
|
||||||
|
html += ` <span class="crumb-sep">/</span> ` +
|
||||||
|
`<span class="crumb ${isLast ? 'current' : ''}"` +
|
||||||
|
(isLast ? '' : ` onclick="goToLibraryCrumb(${i})"`) +
|
||||||
|
`>${crumb.name}</span>`;
|
||||||
|
});
|
||||||
|
bar.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToLibraryCrumb(index) {
|
||||||
|
if (index < 0) {
|
||||||
|
libraryTrail = [];
|
||||||
|
fetchLibraryLevel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = libraryTrail[index];
|
||||||
|
libraryTrail = libraryTrail.slice(0, index + 1);
|
||||||
|
fetchLibraryLevel(target.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLibraryLevel(data) {
|
||||||
|
const container = document.getElementById('library-groups');
|
||||||
|
const isRoot = libraryTrail.length === 0;
|
||||||
if (!data.items || data.items.length === 0) {
|
if (!data.items || data.items.length === 0) {
|
||||||
const reason = (data.errors && data.errors.length)
|
const reason = (data.errors && data.errors.length)
|
||||||
? data.errors.join(' ')
|
? data.errors.join(' ')
|
||||||
@@ -698,52 +818,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = data.items.map(item => {
|
container.innerHTML = data.items.map(item => {
|
||||||
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
if (item.type === 'course') {
|
if (item.type === 'course') {
|
||||||
return `
|
return `
|
||||||
<div class="lesson-item"
|
<div class="lesson-item"
|
||||||
onclick="loadCoursePath('${item.path.replace(/'/g, "\\'")}')">
|
onclick="loadCoursePath('${safePath}')">
|
||||||
<div class="lesson-title">
|
<div class="lesson-title">
|
||||||
<span class="lesson-icon">🎓</span>
|
<span class="lesson-icon">🎓</span>
|
||||||
<span>${item.name}</span>
|
<span class="lesson-name">${item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
|
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
const safeName = item.name.replace(/'/g, "\\'");
|
||||||
return `
|
return `
|
||||||
<div class="tree-item">
|
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
|
||||||
<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">${item.name}</span>
|
<span class="tree-name">${item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="tree-toggle">▶</button>
|
<span class="tree-toggle">›</span>
|
||||||
</div>
|
|
||||||
<div class="tree-content" data-loaded="false"></div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLibraryDir(headerEl, path) {
|
function enterLibraryDir(path, name) {
|
||||||
const content = headerEl.nextElementSibling;
|
libraryTrail.push({ name: name, path: path });
|
||||||
const toggle = headerEl.querySelector('.tree-toggle');
|
fetchLibraryLevel(path);
|
||||||
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) {
|
||||||
|
|||||||
+110
-6
@@ -209,6 +209,7 @@
|
|||||||
}
|
}
|
||||||
.curate-row {
|
.curate-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -255,6 +256,28 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
.curate-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.curate-rename-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
.curate-row-status {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
#save-status {
|
#save-status {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: #28a745;
|
color: #28a745;
|
||||||
@@ -384,10 +407,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Curate Library</h2>
|
<h2>Manage Library</h2>
|
||||||
<p class="setting-desc" style="margin-bottom: 12px;">
|
<p class="setting-desc" style="margin-bottom: 12px;">
|
||||||
Hide courses or whole folders from the Library browser without touching anything on disk.
|
Hide courses or whole folders from the Library browser without touching anything on disk
|
||||||
Hiding a folder hides everything inside it.
|
(hiding a folder hides everything inside it), or rename a course/folder directly - no need
|
||||||
|
to go to the NAS.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
|
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
|
||||||
@@ -395,7 +419,7 @@
|
|||||||
<div id="hidden-list"></div>
|
<div id="hidden-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse to hide</div>
|
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
|
||||||
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
|
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
|
||||||
<div id="curate-tree"></div>
|
<div id="curate-tree"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -549,9 +573,12 @@
|
|||||||
}
|
}
|
||||||
container.innerHTML = data.items.map(item => {
|
container.innerHTML = data.items.map(item => {
|
||||||
const safePath = item.path.replace(/'/g, "\\'");
|
const safePath = item.path.replace(/'/g, "\\'");
|
||||||
|
const safeName = item.name.replace(/'/g, "\\'");
|
||||||
const icon = item.type === 'course' ? '🎓' : '📁';
|
const icon = item.type === 'course' ? '🎓' : '📁';
|
||||||
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
||||||
|
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">✏️</button>`;
|
||||||
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
|
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
|
||||||
|
const actions = `<div class="curate-actions">${renameBtn}${toggleBtn}</div>`;
|
||||||
|
|
||||||
if (item.type === 'course') {
|
if (item.type === 'course') {
|
||||||
return `
|
return `
|
||||||
@@ -560,7 +587,7 @@
|
|||||||
<span>${icon}</span>
|
<span>${icon}</span>
|
||||||
<span class="name">${item.name}</span>
|
<span class="name">${item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
${toggleBtn}
|
${actions}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -571,7 +598,7 @@
|
|||||||
<span>${icon}</span>
|
<span>${icon}</span>
|
||||||
<span class="name">${item.name}</span>
|
<span class="name">${item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
${toggleBtn}
|
${actions}
|
||||||
</div>
|
</div>
|
||||||
<div class="curate-children" data-loaded="false"></div>
|
<div class="curate-children" data-loaded="false"></div>
|
||||||
`;
|
`;
|
||||||
@@ -620,6 +647,83 @@
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Library curation: rename a course/folder in place on disk ----
|
||||||
|
function startRename(btnEl, path, currentName) {
|
||||||
|
const row = btnEl.closest('.curate-row');
|
||||||
|
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
|
||||||
|
if (!row || !nameSpan) return;
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'curate-rename-input';
|
||||||
|
input.value = currentName;
|
||||||
|
input.onclick = (e) => e.stopPropagation();
|
||||||
|
nameSpan.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
input.replaceWith(nameSpan);
|
||||||
|
}
|
||||||
|
function commit() {
|
||||||
|
if (settled) return;
|
||||||
|
const newName = input.value.trim();
|
||||||
|
if (!newName || newName === currentName) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
submitRename(btnEl, path, newName, row, nameSpan, input);
|
||||||
|
}
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitRename(btnEl, path, newName, row, nameSpan, input) {
|
||||||
|
fetch('/api/rename-path', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: path, new_name: newName })
|
||||||
|
})
|
||||||
|
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||||||
|
.then(({ ok, data }) => {
|
||||||
|
if (!ok || !data.success) {
|
||||||
|
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
|
||||||
|
showRowError(row, data.error || 'Rename failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.active_course_reset) {
|
||||||
|
alert('That was your active course - reload the main page to pick it up under its new name.');
|
||||||
|
}
|
||||||
|
loadHiddenList();
|
||||||
|
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
|
||||||
|
const isRootContainer = container.id === 'curate-tree';
|
||||||
|
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||||||
|
loadCurateLevel(refreshPath, container, isRootContainer);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
input.replaceWith(nameSpan);
|
||||||
|
showRowError(row, 'Could not reach the server');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRowError(row, message) {
|
||||||
|
let status = row.querySelector('.curate-row-status');
|
||||||
|
if (!status) {
|
||||||
|
status = document.createElement('div');
|
||||||
|
status.className = 'curate-row-status';
|
||||||
|
row.appendChild(status);
|
||||||
|
}
|
||||||
|
status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
loadHiddenList();
|
loadHiddenList();
|
||||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user