Add Favorites, storage drill-down, keyboard shortcuts, and What's New
- Favorites: star toggle on course rows, the course page, and a new dashboard card; rebased on rename/move, backed up/restored, and caught by stale-reference cleanup (also fixed a pre-existing gap where favorites.json wasn't in the backup file list). - Storage Usage: click a folder row to drill into its contents one level at a time, with a Back button. - Lesson player: added ,/. (speed step), [/] (prev/next lesson), and F (fullscreen) keyboard shortcuts. - Settings: a CHANGELOG.md-backed "What's New" list, since VERSION alone only ever shows the current build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
2026-08-24 23:22 UTC — Add Favorites, storage drill-down, shortcuts, and What's New
|
||||||
|
2026-08-24 19:00 UTC — Fix video resize handle drifting off-screen
|
||||||
|
2026-08-24 18:06 UTC — Cache the category tree walk (fixes ~1s File Management page load)
|
||||||
|
2026-08-24 14:41 UTC — Add search, bulk actions, undo, and storage usage to File Management
|
||||||
|
2026-08-24 14:00 UTC — Fix build version showing "unknown" in production
|
||||||
|
2026-08-24 13:52 UTC — Fix wrapped-course false positive in picker; add build version display
|
||||||
|
2026-08-24 13:21 UTC — Exclude media-free subtrees from the destination picker too
|
||||||
|
2026-08-24 13:13 UTC — Exclude course/item leaves from destination picker; ease new-folder UX
|
||||||
|
2026-08-24 11:45 UTC — Add wildcard and regex modes to Bulk Rename
|
||||||
|
2026-08-24 11:11 UTC — Add delete and ignore actions to Duplicate Courses
|
||||||
|
2026-08-24 10:37 UTC — Move File Management link into the bottom tab bar
|
||||||
|
2026-08-24 10:29 UTC — Tighten Sort Unsorted matching; consolidate file management tools
|
||||||
|
2026-08-24 09:10 UTC — Add Unsorted folder auto-sort with keyword matching
|
||||||
@@ -53,7 +53,10 @@ guessing. Deliberately not derived from `git rev-parse` at Docker build
|
|||||||
time - Dockhand's build context doesn't reliably have `.git` available,
|
time - Dockhand's build context doesn't reliably have `.git` available,
|
||||||
which silently produced "unknown" instead of an actual commit. Reads as
|
which silently produced "unknown" instead of an actual commit. Reads as
|
||||||
`dev` outside Docker (no `VERSION` file to read, e.g. before the first
|
`dev` outside Docker (no `VERSION` file to read, e.g. before the first
|
||||||
commit that adds one).
|
commit that adds one). `CHANGELOG.md` at the repo root accumulates that
|
||||||
|
same line on every commit (newest first) instead of overwriting it, and
|
||||||
|
shows as a "What's New" list under the version line in Settings - so you
|
||||||
|
can see recent history, not just the current build.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,18 +189,22 @@ silently stay blank instead of erroring.
|
|||||||
listed (and reversible) under "Ignored matches," and persist through
|
listed (and reversible) under "Ignored matches," and persist through
|
||||||
backup/restore alongside hidden paths and Next Up.
|
backup/restore alongside hidden paths and Next Up.
|
||||||
- *Clean Up Stale References*: finds entries in the hidden-paths list,
|
- *Clean Up Stale References*: finds entries in the hidden-paths list,
|
||||||
Next Up queue, or Recently Viewed history that point at a path no longer
|
Next Up queue, Favorites, or Recently Viewed history that point at a path
|
||||||
on disk - normally from renaming/moving/deleting a course directly on
|
no longer on disk - normally from renaming/moving/deleting a course
|
||||||
the NAS instead of through the app (doing it through the app already
|
directly on the NAS instead of through the app (doing it through the app
|
||||||
keeps these in sync). Review individually or clear them all at once;
|
already keeps these in sync). Review individually or clear them all at
|
||||||
course files themselves are never touched.
|
once; course files themselves are never touched.
|
||||||
- *Storage Usage*: disk usage per top-level library folder, largest
|
- *Storage Usage*: disk usage per top-level library folder, largest
|
||||||
first, with a simple proportional bar per entry - manually triggered
|
first, with a simple proportional bar per entry - manually triggered
|
||||||
(it reads every file's size) rather than run automatically.
|
(it reads every file's size) rather than run automatically. Click any
|
||||||
|
folder row to drill into its own contents one level at a time, with a
|
||||||
|
Back button to climb back out.
|
||||||
|
|
||||||
**Dashboard**
|
**Dashboard**
|
||||||
- Library-wide stats (courses, lessons completed, time watched, time
|
- Library-wide stats (courses, lessons completed, time watched, time
|
||||||
remaining, day streak) and a 90-day activity heatmap
|
remaining, day streak) and a 90-day activity heatmap
|
||||||
|
- Favorites — star any course from its list row or its own page for quick
|
||||||
|
access from the dashboard, independent of recency or curated queues
|
||||||
- Next Up queue (manually curated, reorderable)
|
- Next Up queue (manually curated, reorderable)
|
||||||
- Pick Back Up (courses with progress that have gone stale)
|
- Pick Back Up (courses with progress that have gone stale)
|
||||||
- Recently Added / Recently Viewed
|
- Recently Added / Recently Viewed
|
||||||
@@ -207,6 +214,10 @@ silently stay blank instead of erroring.
|
|||||||
**Playback & progress**
|
**Playback & progress**
|
||||||
- Video/audio player with resize, playback-speed presets, and resume-from-
|
- Video/audio player with resize, playback-speed presets, and resume-from-
|
||||||
last-position
|
last-position
|
||||||
|
- Keyboard shortcuts on the lesson page: Space (play/pause), ←/→ (seek
|
||||||
|
10s), ↑/↓ (volume), `,`/`.` (step playback speed), `[`/`]` (previous/next
|
||||||
|
lesson), `F` (fullscreen), `N` (quick-capture a note) - see Help for the
|
||||||
|
full list
|
||||||
- Auto-tracks watch progress and completion per lesson
|
- Auto-tracks watch progress and completion per lesson
|
||||||
- Video/audio durations read via `ffprobe` and cached persistently per
|
- Video/audio durations read via `ffprobe` and cached persistently per
|
||||||
course (`.offlineu_duration_cache.json`), so total runtime and per-lesson
|
course (`.offlineu_duration_cache.json`), so total runtime and per-lesson
|
||||||
@@ -236,9 +247,9 @@ silently stay blank instead of erroring.
|
|||||||
|
|
||||||
**Backup & integrations**
|
**Backup & integrations**
|
||||||
- One-click backup export and restore: settings, hidden-path choices, the
|
- One-click backup export and restore: settings, hidden-path choices, the
|
||||||
Next Up queue, recent-view history, ignored-duplicate pairs, Outline
|
Next Up queue, Favorites, recent-view history, ignored-duplicate pairs,
|
||||||
config, and every course's progress/notes as a zip (not the course files
|
Outline config, and every course's progress/notes as a zip (not the
|
||||||
themselves). Restore
|
course files themselves). Restore
|
||||||
overwrites current data and needs a matching course folder to already
|
overwrites current data and needs a matching course folder to already
|
||||||
exist for each course's progress to land - it's a "put my data back"
|
exist for each course's progress to land - it's a "put my data back"
|
||||||
action, not a merge
|
action, not a merge
|
||||||
@@ -256,6 +267,7 @@ Everything under `OFFLINEU_DATA_DIR` (app-wide, not tied to a course):
|
|||||||
| `settings.json` | Theme, layout, library path, etc. |
|
| `settings.json` | Theme, layout, library path, etc. |
|
||||||
| `hidden_paths.json` | Courses/folders curated out of the browser |
|
| `hidden_paths.json` | Courses/folders curated out of the browser |
|
||||||
| `next_up.json` | The Next Up queue, in order |
|
| `next_up.json` | The Next Up queue, in order |
|
||||||
|
| `favorites.json` | Favorited course paths |
|
||||||
| `recent_views.json` | Cross-course "Recently Viewed" history |
|
| `recent_views.json` | Cross-course "Recently Viewed" history |
|
||||||
| `ignored_duplicates.json` | Course-path pairs confirmed not duplicates |
|
| `ignored_duplicates.json` | Course-path pairs confirmed not duplicates |
|
||||||
| `outline_config.json` | Outline API token/collection mapping |
|
| `outline_config.json` | Outline API token/collection mapping |
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2026-08-24 23:00 UTC — fix video resize handle drifting off-screen
|
2026-08-24 23:22 UTC — add Favorites, storage drill-down, shortcuts, and What's New
|
||||||
|
|||||||
+170
-22
@@ -57,6 +57,25 @@ def _load_build_version() -> str:
|
|||||||
|
|
||||||
BUILD_VERSION = _load_build_version()
|
BUILD_VERSION = _load_build_version()
|
||||||
|
|
||||||
|
|
||||||
|
def load_changelog_entries(limit: int = 10) -> List[str]:
|
||||||
|
"""
|
||||||
|
Reads CHANGELOG.md for the Settings page's "What's New" list - one
|
||||||
|
line per commit (same timestamp + description format as VERSION),
|
||||||
|
newest first. VERSION itself is overwritten each commit and only ever
|
||||||
|
shows the current build; this accumulates that same line over time so
|
||||||
|
you can see what changed recently without digging through git log.
|
||||||
|
Falls back to an empty list if the file is missing (shouldn't happen
|
||||||
|
once committed, same reasoning as _load_build_version).
|
||||||
|
"""
|
||||||
|
changelog_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'CHANGELOG.md')
|
||||||
|
try:
|
||||||
|
with open(changelog_file, 'r') as f:
|
||||||
|
lines = [line.strip() for line in f if line.strip()]
|
||||||
|
return lines[:limit]
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
# Supported file types
|
# Supported file types
|
||||||
VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'}
|
VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v', '.flv', '.wmv'}
|
||||||
AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'}
|
AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.ogg', '.flac'}
|
||||||
@@ -1052,6 +1071,51 @@ def get_next_up_courses() -> List[Dict[str, Any]]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
FAVORITES_FILE = os.path.join(DATA_DIR, 'favorites.json')
|
||||||
|
|
||||||
|
|
||||||
|
def get_favorite_paths() -> List[str]:
|
||||||
|
"""Load the set of favorited course paths - order doesn't matter here (favorites always render alphabetically), unlike Next Up's queue."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(FAVORITES_FILE):
|
||||||
|
with open(FAVORITES_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 favorites: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_favorite_paths(paths: List[str]) -> None:
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
with open(FAVORITES_FILE, 'w') as f:
|
||||||
|
json.dump(paths, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def set_path_favorited(path: str, favorited: bool) -> List[str]:
|
||||||
|
"""Add or remove a course from favorites; returns the updated list."""
|
||||||
|
paths = get_favorite_paths()
|
||||||
|
normalized = os.path.abspath(path)
|
||||||
|
if favorited:
|
||||||
|
if normalized not in paths:
|
||||||
|
paths.append(normalized)
|
||||||
|
else:
|
||||||
|
paths = [p for p in paths if p != normalized]
|
||||||
|
_save_favorite_paths(paths)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def get_favorite_courses() -> List[Dict[str, Any]]:
|
||||||
|
"""Favorited courses resolved to displayable summaries (name order, since there's no queue order to preserve), silently dropping any path no longer on disk."""
|
||||||
|
results = []
|
||||||
|
for path in sorted(get_favorite_paths(), key=lambda p: os.path.basename(p.rstrip(os.sep)).lower()):
|
||||||
|
p = Path(path)
|
||||||
|
if p.is_dir():
|
||||||
|
results.append(_course_summary(p))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str:
|
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` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`."""
|
||||||
if value == old_abs:
|
if value == old_abs:
|
||||||
@@ -1083,6 +1147,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None:
|
|||||||
if rebased_queue != queued:
|
if rebased_queue != queued:
|
||||||
_save_next_up_paths(rebased_queue)
|
_save_next_up_paths(rebased_queue)
|
||||||
|
|
||||||
|
favorited = get_favorite_paths()
|
||||||
|
rebased_favorites = [_rebase_prefix(p, old_abs, new_abs) for p in favorited]
|
||||||
|
if rebased_favorites != favorited:
|
||||||
|
_save_favorite_paths(rebased_favorites)
|
||||||
|
|
||||||
views = get_recent_views()
|
views = get_recent_views()
|
||||||
changed = False
|
changed = False
|
||||||
for entry in views:
|
for entry in views:
|
||||||
@@ -1145,6 +1214,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
|
|||||||
directory = Path(dir_path)
|
directory = Path(dir_path)
|
||||||
items: List[Dict[str, Any]] = []
|
items: List[Dict[str, Any]] = []
|
||||||
hidden_set = set(get_hidden_paths())
|
hidden_set = set(get_hidden_paths())
|
||||||
|
favorite_set = set(get_favorite_paths())
|
||||||
|
|
||||||
if not directory.exists() or not directory.is_dir():
|
if not directory.exists() or not directory.is_dir():
|
||||||
return {'items': items, 'errors': [f"Directory not found: {dir_path}"]}
|
return {'items': items, 'errors': [f"Directory not found: {dir_path}"]}
|
||||||
@@ -1166,6 +1236,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
|
|||||||
if _looks_like_course(entry):
|
if _looks_like_course(entry):
|
||||||
item = _course_summary(entry)
|
item = _course_summary(entry)
|
||||||
item['hidden'] = is_hidden
|
item['hidden'] = is_hidden
|
||||||
|
item['favorited'] = os.path.abspath(entry_path_str) in favorite_set
|
||||||
item['completion_percentage'] = _course_completion_percentage(entry, item['media_files'])
|
item['completion_percentage'] = _course_completion_percentage(entry, item['media_files'])
|
||||||
items.append(item)
|
items.append(item)
|
||||||
else:
|
else:
|
||||||
@@ -1753,7 +1824,7 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
|
|||||||
/api/library/stale-references/clean is called with the exact items
|
/api/library/stale-references/clean is called with the exact items
|
||||||
reviewed here.
|
reviewed here.
|
||||||
"""
|
"""
|
||||||
stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': []}
|
stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': [], 'favorites': []}
|
||||||
|
|
||||||
for path in get_hidden_paths():
|
for path in get_hidden_paths():
|
||||||
if not os.path.isdir(path):
|
if not os.path.isdir(path):
|
||||||
@@ -1763,6 +1834,10 @@ def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
|
|||||||
if not os.path.isdir(path):
|
if not os.path.isdir(path):
|
||||||
stale['next_up'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
|
stale['next_up'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
|
||||||
|
|
||||||
|
for path in get_favorite_paths():
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
stale['favorites'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
|
||||||
|
|
||||||
seen: Set[str] = set()
|
seen: Set[str] = set()
|
||||||
for entry in get_recent_views():
|
for entry in get_recent_views():
|
||||||
path = entry.get('course_path', '')
|
path = entry.get('course_path', '')
|
||||||
@@ -1945,11 +2020,13 @@ def search_library_courses(query: str) -> List[Dict[str, Any]]:
|
|||||||
get_all_course_dirs.
|
get_all_course_dirs.
|
||||||
"""
|
"""
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
|
favorite_set = set(get_favorite_paths())
|
||||||
results = []
|
results = []
|
||||||
for course in get_all_course_dirs():
|
for course in get_all_course_dirs():
|
||||||
if query_lower not in course.name.lower():
|
if query_lower not in course.name.lower():
|
||||||
continue
|
continue
|
||||||
item = _course_summary(course)
|
item = _course_summary(course)
|
||||||
|
item['favorited'] = os.path.abspath(str(course)) in favorite_set
|
||||||
item['completion_percentage'] = _course_completion_percentage(course, item['media_files'])
|
item['completion_percentage'] = _course_completion_percentage(course, item['media_files'])
|
||||||
results.append(item)
|
results.append(item)
|
||||||
return results
|
return results
|
||||||
@@ -2897,6 +2974,7 @@ def index():
|
|||||||
stale_courses=format_stale_courses(scan),
|
stale_courses=format_stale_courses(scan),
|
||||||
activity_heatmap=format_activity_heatmap(scan),
|
activity_heatmap=format_activity_heatmap(scan),
|
||||||
next_up=get_next_up_courses(),
|
next_up=get_next_up_courses(),
|
||||||
|
favorites=get_favorite_courses(),
|
||||||
active_tab='home')
|
active_tab='home')
|
||||||
|
|
||||||
# Apply progress data to tree
|
# Apply progress data to tree
|
||||||
@@ -2917,6 +2995,7 @@ def index():
|
|||||||
recent_views=recent_views,
|
recent_views=recent_views,
|
||||||
has_note=bool(course_has_any_notes(current_course)),
|
has_note=bool(course_has_any_notes(current_course)),
|
||||||
is_queued=os.path.abspath(current_course.path) in get_next_up_paths(),
|
is_queued=os.path.abspath(current_course.path) in get_next_up_paths(),
|
||||||
|
is_favorited=os.path.abspath(current_course.path) in get_favorite_paths(),
|
||||||
resume_lesson=resume_lesson,
|
resume_lesson=resume_lesson,
|
||||||
active_tab='home')
|
active_tab='home')
|
||||||
|
|
||||||
@@ -3280,34 +3359,80 @@ def _format_bytes(n: int) -> str:
|
|||||||
return f'{size:.1f} TB'
|
return f'{size:.1f} TB'
|
||||||
|
|
||||||
|
|
||||||
|
def _list_entries_with_sizes(directory: Path, skip_names: Optional[set] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Every immediate child of `directory` with its total size - a
|
||||||
|
subdirectory gets its full recursive size (via _directory_size_bytes),
|
||||||
|
a loose file gets its own size directly. Sorted largest-first. Shared
|
||||||
|
by the top-level Storage Usage scan and its drill-down into a single
|
||||||
|
folder, which is otherwise the same operation one level deeper.
|
||||||
|
"""
|
||||||
|
skip_names = skip_names or set()
|
||||||
|
try:
|
||||||
|
children = [
|
||||||
|
p for p in directory.iterdir()
|
||||||
|
if not p.name.startswith('.') and p.name not in skip_names
|
||||||
|
]
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for entry in children:
|
||||||
|
try:
|
||||||
|
is_dir = entry.is_dir()
|
||||||
|
size = _directory_size_bytes(entry) if is_dir else entry.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
entries.append({
|
||||||
|
'name': entry.name,
|
||||||
|
'path': str(entry),
|
||||||
|
'bytes': size,
|
||||||
|
'human': _format_bytes(size),
|
||||||
|
'is_dir': is_dir,
|
||||||
|
})
|
||||||
|
|
||||||
|
entries.sort(key=lambda e: e['bytes'], reverse=True)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/library/storage-usage')
|
@app.route('/api/library/storage-usage')
|
||||||
def storage_usage_api():
|
def storage_usage_api():
|
||||||
"""
|
"""
|
||||||
Disk usage per top-level library folder (category or a bare course
|
Disk usage of a library folder's immediate contents, sorted
|
||||||
sitting directly at the root), sorted largest-first, for spotting
|
largest-first, for spotting what's eating the most space on the NAS.
|
||||||
what's eating the most space on the NAS. Manually triggered like
|
With no `path` param, scans the library root's top-level folders
|
||||||
Duplicate Courses - a full recursive size scan touches every file in
|
(category or a bare course sitting directly at the root); with `path`,
|
||||||
the library, too expensive to run automatically on page load.
|
drills into that folder instead (must resolve inside the library
|
||||||
|
root). Manually triggered like Duplicate Courses - a full recursive
|
||||||
|
size scan touches every file under each entry, too expensive to run
|
||||||
|
automatically on page load.
|
||||||
"""
|
"""
|
||||||
library_root = Path(get_library_root())
|
library_root = Path(get_library_root()).resolve()
|
||||||
|
subpath = (request.args.get('path') or '').strip()
|
||||||
|
|
||||||
|
if subpath:
|
||||||
|
base = Path(subpath).resolve()
|
||||||
try:
|
try:
|
||||||
top_level = sorted(
|
base.relative_to(library_root)
|
||||||
(p for p in library_root.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
except ValueError:
|
||||||
key=lambda p: p.name.lower()
|
return jsonify({'error': 'Path is outside the library'}), 400
|
||||||
)
|
if not base.is_dir():
|
||||||
except (PermissionError, OSError) as e:
|
return jsonify({'error': 'Not a directory'}), 400
|
||||||
return jsonify({'error': str(e)}), 500
|
else:
|
||||||
|
base = library_root
|
||||||
|
|
||||||
entries = []
|
is_root = base == library_root
|
||||||
for entry in top_level:
|
skip = {UNSORTED_FOLDER_NAME} if is_root else set()
|
||||||
if entry.name == UNSORTED_FOLDER_NAME:
|
entries = _list_entries_with_sizes(base, skip)
|
||||||
continue
|
|
||||||
size = _directory_size_bytes(entry)
|
|
||||||
entries.append({'name': entry.name, 'path': str(entry), 'bytes': size, 'human': _format_bytes(size)})
|
|
||||||
|
|
||||||
entries.sort(key=lambda e: e['bytes'], reverse=True)
|
|
||||||
total_bytes = sum(e['bytes'] for e in entries)
|
total_bytes = sum(e['bytes'] for e in entries)
|
||||||
return jsonify({'entries': entries, 'total_bytes': total_bytes, 'total_human': _format_bytes(total_bytes)})
|
return jsonify({
|
||||||
|
'entries': entries,
|
||||||
|
'total_bytes': total_bytes,
|
||||||
|
'total_human': _format_bytes(total_bytes),
|
||||||
|
'path': str(base),
|
||||||
|
'is_root': is_root,
|
||||||
|
'parent_path': str(base.parent) if not is_root else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/library/categories')
|
@app.route('/api/library/categories')
|
||||||
@@ -3658,6 +3783,9 @@ def clean_stale_references_api():
|
|||||||
elif category == 'recent_views':
|
elif category == 'recent_views':
|
||||||
remove_recent_views_for_path(path)
|
remove_recent_views_for_path(path)
|
||||||
removed += 1
|
removed += 1
|
||||||
|
elif category == 'favorites':
|
||||||
|
set_path_favorited(path, False)
|
||||||
|
removed += 1
|
||||||
|
|
||||||
return jsonify({'success': True, 'removed': removed})
|
return jsonify({'success': True, 'removed': removed})
|
||||||
|
|
||||||
@@ -4015,6 +4143,7 @@ def settings_page():
|
|||||||
corner_radius_choices=['sharp', 'rounded', 'pill'],
|
corner_radius_choices=['sharp', 'rounded', 'pill'],
|
||||||
active_tab='settings',
|
active_tab='settings',
|
||||||
build_version=BUILD_VERSION,
|
build_version=BUILD_VERSION,
|
||||||
|
changelog_entries=load_changelog_entries(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4062,6 +4191,7 @@ BACKUP_ROOT_FILES = {
|
|||||||
'settings.json': SETTINGS_FILE,
|
'settings.json': SETTINGS_FILE,
|
||||||
'hidden_paths.json': HIDDEN_PATHS_FILE,
|
'hidden_paths.json': HIDDEN_PATHS_FILE,
|
||||||
'next_up.json': NEXT_UP_FILE,
|
'next_up.json': NEXT_UP_FILE,
|
||||||
|
'favorites.json': FAVORITES_FILE,
|
||||||
'recent_views.json': RECENT_VIEWS_FILE,
|
'recent_views.json': RECENT_VIEWS_FILE,
|
||||||
'outline_config.json': OUTLINE_CONFIG_FILE,
|
'outline_config.json': OUTLINE_CONFIG_FILE,
|
||||||
'ignored_duplicates.json': IGNORED_DUPLICATES_FILE,
|
'ignored_duplicates.json': IGNORED_DUPLICATES_FILE,
|
||||||
@@ -4265,6 +4395,24 @@ def set_next_up_api():
|
|||||||
return jsonify({'success': True, 'next_up': updated})
|
return jsonify({'success': True, 'next_up': updated})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/favorites', methods=['POST'])
|
||||||
|
def set_favorite_api():
|
||||||
|
"""Add or remove a course from favorites."""
|
||||||
|
data = request.json or {}
|
||||||
|
path = data.get('path', '')
|
||||||
|
favorited = bool(data.get('favorited', 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_favorited(target, favorited)
|
||||||
|
return jsonify({'success': True, 'favorites': updated})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/next-up/bulk', methods=['POST'])
|
@app.route('/api/next-up/bulk', methods=['POST'])
|
||||||
def bulk_set_next_up_api():
|
def bulk_set_next_up_api():
|
||||||
"""Add several courses to the Next Up queue at once."""
|
"""Add several courses to the Next Up queue at once."""
|
||||||
|
|||||||
@@ -5,4 +5,4 @@
|
|||||||
platforms and clash with the app's vector logo. Import with
|
platforms and clash with the app's vector logo. Import with
|
||||||
{% import '_icons.html' as icons %} and call icons.icon('name', size).
|
{% import '_icons.html' as icons %} and call icons.icon('name', size).
|
||||||
#}
|
#}
|
||||||
{% macro icon(name, size=18) %}<svg class="icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if name == 'home' %}<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"></path>{% elif name == 'pencil' %}<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>{% elif name == 'help' %}<circle cx="12" cy="12" r="9"></circle><path d="M9.1 9a2.9 2.9 0 0 1 5.6 1c0 2-2.9 2.5-2.9 4.5"></path><line x1="12" y1="17.5" x2="12" y2="17.51"></line>{% elif name == 'settings' %}<circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>{% elif name == 'dice' %}<rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="8" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"></circle>{% elif name == 'folder' %}<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>{% elif name == 'graduation-cap' %}<path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path>{% elif name == 'check' %}<polyline points="20 6 9 17 4 12"></polyline>{% elif name == 'circle' %}<circle cx="12" cy="12" r="9"></circle>{% elif name == 'x' %}<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>{% elif name == 'pin' %}<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle>{% elif name == 'ban' %}<circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line>{% elif name == 'video' %}<rect x="2" y="5" width="14" height="14" rx="2"></rect><path d="M16 10.5 22 7v10l-6-3.5Z"></path>{% elif name == 'music' %}<path d="M9 18V5l11-2v13"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="17.5" cy="16" r="2.5"></circle>{% elif name == 'clipboard' %}<rect x="6" y="4" width="12" height="17" rx="2"></rect><path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"></path><line x1="9" y1="11" x2="15" y2="11"></line><line x1="9" y1="15" x2="13" y2="15"></line>{% elif name == 'package' %}<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path><path d="M3 8l9 5 9-5M12 13v8"></path>{% elif name == 'file-text' %}<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>{% elif name == 'trash' %}<path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>{% elif name == 'refresh' %}<path d="M21 12a9 9 0 0 1-15.3 6.4"></path><path d="M3 12a9 9 0 0 1 15.3-6.4"></path><polyline points="21 3 21 9 15 9"></polyline><polyline points="3 21 3 15 9 15"></polyline>{% elif name == 'checkbox' %}<path d="M9 11l3 3 7-7"></path><path d="M21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10"></path>{% elif name == 'list' %}<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>{% elif name == 'grid' %}<rect x="3" y="3" width="8" height="8" rx="1.5"></rect><rect x="13" y="3" width="8" height="8" rx="1.5"></rect><rect x="3" y="13" width="8" height="8" rx="1.5"></rect><rect x="13" y="13" width="8" height="8" rx="1.5"></rect>{% elif name == 'bar-chart' %}<line x1="6" y1="20" x2="6" y2="14"></line><line x1="12" y1="20" x2="12" y2="8"></line><line x1="18" y1="20" x2="18" y2="11"></line>{% elif name == 'list-dots' %}<line x1="8" y1="6" x2="20" y2="6"></line><line x1="8" y1="12" x2="20" y2="12"></line><line x1="8" y1="18" x2="20" y2="18"></line><circle cx="4" cy="6" r="1"></circle><circle cx="4" cy="12" r="1"></circle><circle cx="4" cy="18" r="1"></circle>{% elif name == 'history' %}<path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 4 3 9 8 9"></polyline>{% elif name == 'plus-circle' %}<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>{% elif name == 'play-circle' %}<circle cx="12" cy="12" r="9"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon>{% elif name == 'play' %}<polygon points="6 3 20 12 6 21 6 3"></polygon>{% endif %}</svg>{% endmacro %}
|
{% macro icon(name, size=18) %}<svg class="icon" width="{{ size }}" height="{{ size }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if name == 'home' %}<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"></path>{% elif name == 'pencil' %}<path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>{% elif name == 'help' %}<circle cx="12" cy="12" r="9"></circle><path d="M9.1 9a2.9 2.9 0 0 1 5.6 1c0 2-2.9 2.5-2.9 4.5"></path><line x1="12" y1="17.5" x2="12" y2="17.51"></line>{% elif name == 'settings' %}<circle cx="12" cy="12" r="3"></circle><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"></path>{% elif name == 'dice' %}<rect x="3" y="3" width="18" height="18" rx="4"></rect><circle cx="8" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="8" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="8" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="16" cy="16" r="1.3" fill="currentColor" stroke="none"></circle><circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"></circle>{% elif name == 'folder' %}<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>{% elif name == 'graduation-cap' %}<path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path>{% elif name == 'check' %}<polyline points="20 6 9 17 4 12"></polyline>{% elif name == 'circle' %}<circle cx="12" cy="12" r="9"></circle>{% elif name == 'x' %}<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>{% elif name == 'pin' %}<path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle>{% elif name == 'ban' %}<circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line>{% elif name == 'video' %}<rect x="2" y="5" width="14" height="14" rx="2"></rect><path d="M16 10.5 22 7v10l-6-3.5Z"></path>{% elif name == 'music' %}<path d="M9 18V5l11-2v13"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="17.5" cy="16" r="2.5"></circle>{% elif name == 'clipboard' %}<rect x="6" y="4" width="12" height="17" rx="2"></rect><path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1"></path><line x1="9" y1="11" x2="15" y2="11"></line><line x1="9" y1="15" x2="13" y2="15"></line>{% elif name == 'package' %}<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path><path d="M3 8l9 5 9-5M12 13v8"></path>{% elif name == 'file-text' %}<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="15" y2="17"></line>{% elif name == 'trash' %}<path d="M4 7h16"></path><path d="M10 11v6M14 11v6"></path><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path><path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>{% elif name == 'refresh' %}<path d="M21 12a9 9 0 0 1-15.3 6.4"></path><path d="M3 12a9 9 0 0 1 15.3-6.4"></path><polyline points="21 3 21 9 15 9"></polyline><polyline points="3 21 3 15 9 15"></polyline>{% elif name == 'checkbox' %}<path d="M9 11l3 3 7-7"></path><path d="M21 12v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h10"></path>{% elif name == 'list' %}<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>{% elif name == 'grid' %}<rect x="3" y="3" width="8" height="8" rx="1.5"></rect><rect x="13" y="3" width="8" height="8" rx="1.5"></rect><rect x="3" y="13" width="8" height="8" rx="1.5"></rect><rect x="13" y="13" width="8" height="8" rx="1.5"></rect>{% elif name == 'bar-chart' %}<line x1="6" y1="20" x2="6" y2="14"></line><line x1="12" y1="20" x2="12" y2="8"></line><line x1="18" y1="20" x2="18" y2="11"></line>{% elif name == 'list-dots' %}<line x1="8" y1="6" x2="20" y2="6"></line><line x1="8" y1="12" x2="20" y2="12"></line><line x1="8" y1="18" x2="20" y2="18"></line><circle cx="4" cy="6" r="1"></circle><circle cx="4" cy="12" r="1"></circle><circle cx="4" cy="18" r="1"></circle>{% elif name == 'history' %}<path d="M3 12a9 9 0 1 0 3-6.7"></path><polyline points="3 4 3 9 8 9"></polyline>{% elif name == 'plus-circle' %}<circle cx="12" cy="12" r="9"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line>{% elif name == 'play-circle' %}<circle cx="12" cy="12" r="9"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon>{% elif name == 'play' %}<polygon points="6 3 20 12 6 21 6 3"></polygon>{% elif name == 'star' %}<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>{% endif %}</svg>{% endmacro %}
|
||||||
|
|||||||
@@ -728,6 +728,13 @@
|
|||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.favorite-toggle-btn.active {
|
||||||
|
color: #e0a825;
|
||||||
|
border-color: #e0a825;
|
||||||
|
}
|
||||||
|
.favorite-toggle-btn.active svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
#library-select-btn {
|
#library-select-btn {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
@@ -1044,6 +1051,9 @@
|
|||||||
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
|
<button class="btn btn-secondary btn-sm" id="queue-toggle-btn" onclick="toggleQueued()">
|
||||||
{{ icons.icon('pin', 14) }} {{ 'Remove from Next Up' if is_queued else 'Add to Next Up' }}
|
{{ icons.icon('pin', 14) }} {{ 'Remove from Next Up' if is_queued else 'Add to Next Up' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-sm{{ ' favorite-toggle-btn active' if is_favorited else ' favorite-toggle-btn' }}" id="favorite-toggle-btn" onclick="toggleFavorited()">
|
||||||
|
{{ icons.icon('star', 14) }} {{ 'Remove from Favorites' if is_favorited else 'Add to Favorites' }}
|
||||||
|
</button>
|
||||||
{% if has_note %}
|
{% if has_note %}
|
||||||
<a class="btn btn-secondary btn-sm" href="/course/study-guide">{{ icons.icon('file-text', 14) }} Download Study Guide</a>
|
<a class="btn btn-secondary btn-sm" href="/course/study-guide">{{ icons.icon('file-text', 14) }} Download Study Guide</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1249,6 +1259,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if favorites %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="card" id="favorites-card">
|
||||||
|
<h2 class="collapsible-header" data-card-id="favorites"><span class="card-header-label">{{ icons.icon('star') }}Favorites</span></h2>
|
||||||
|
<div class="card-body" id="favorites-list">
|
||||||
|
{% for item in favorites %}
|
||||||
|
<div class="lesson-item" data-path="{{ item.path }}">
|
||||||
|
<div class="lesson-title" style="cursor: pointer;" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
|
||||||
|
{% if item.has_thumbnail %}
|
||||||
|
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
|
||||||
|
onerror="this.replaceWith(courseFallbackIcon('graduation-cap'))">
|
||||||
|
{% else %}
|
||||||
|
<span class="lesson-icon">{{ icons.icon('graduation-cap', 16) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="lesson-name">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-meta">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); removeFavorite(this)" title="Remove from Favorites">{{ icons.icon('x', 14) }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if next_up %}
|
{% if next_up %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card" id="next-up-card">
|
<div class="card" id="next-up-card">
|
||||||
@@ -1346,6 +1381,8 @@
|
|||||||
play: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>',
|
play: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"></polygon></svg>',
|
||||||
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
|
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
|
||||||
pin: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle></svg>',
|
pin: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s7-6.5 7-11.5A7 7 0 0 0 5 9.5C5 14.5 12 21 12 21Z"></path><circle cx="12" cy="9.5" r="2.3"></circle></svg>',
|
||||||
|
star: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>',
|
||||||
|
'star-filled': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>',
|
||||||
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
|
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1708,6 +1745,7 @@
|
|||||||
const progressBar = pct
|
const progressBar = pct
|
||||||
? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>`
|
? `<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: ${pct}%;"></div></div>`
|
||||||
: '';
|
: '';
|
||||||
|
const favorited = !!item.favorited;
|
||||||
return `
|
return `
|
||||||
<div class="lesson-item" onclick="${clickHandler}">
|
<div class="lesson-item" onclick="${clickHandler}">
|
||||||
<div class="lesson-title">
|
<div class="lesson-title">
|
||||||
@@ -1718,6 +1756,7 @@
|
|||||||
<div class="lesson-meta">
|
<div class="lesson-meta">
|
||||||
${progressBadge}
|
${progressBadge}
|
||||||
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span>
|
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}${item.duration_display ? ` · ${item.duration_display}` : ''}</span>
|
||||||
|
<button class="btn btn-secondary btn-sm favorite-toggle-btn${favorited ? ' active' : ''}" data-path="${escapeAttr(item.path)}" onclick="event.stopPropagation(); favoriteCourse('${safePath}', this)" title="${favorited ? 'Remove from Favorites' : 'Add to Favorites'}">${favorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star}</button>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button>
|
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">${ICON_SVGS.pin}</button>
|
||||||
</div>
|
</div>
|
||||||
${progressBar}
|
${progressBar}
|
||||||
@@ -1889,6 +1928,50 @@
|
|||||||
saveNextUpOrder();
|
saveNextUpOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeFavorite(btnEl) {
|
||||||
|
const row = btnEl.closest('.lesson-item');
|
||||||
|
const path = row.dataset.path;
|
||||||
|
fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: path, favorited: false })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
row.remove();
|
||||||
|
const list = document.getElementById('favorites-list');
|
||||||
|
if (list && list.children.length === 0) {
|
||||||
|
document.getElementById('favorites-card').closest('.container').remove();
|
||||||
|
}
|
||||||
|
document.querySelectorAll(`.favorite-toggle-btn[data-path="${CSS.escape(path)}"]`).forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.innerHTML = ICON_SVGS.star;
|
||||||
|
btn.title = 'Add to Favorites';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function favoriteCourse(path, btnEl) {
|
||||||
|
const nowFavorited = !btnEl.classList.contains('active');
|
||||||
|
fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: path, favorited: nowFavorited })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
btnEl.classList.toggle('active', nowFavorited);
|
||||||
|
btnEl.innerHTML = nowFavorited ? ICON_SVGS['star-filled'] : ICON_SVGS.star;
|
||||||
|
btnEl.title = nowFavorited ? 'Remove from Favorites' : 'Add to Favorites';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function removeNextUp(btnEl) {
|
function removeNextUp(btnEl) {
|
||||||
const row = btnEl.closest('.lesson-item');
|
const row = btnEl.closest('.lesson-item');
|
||||||
const path = row.dataset.path;
|
const path = row.dataset.path;
|
||||||
@@ -1940,6 +2023,24 @@
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleFavorited() {
|
||||||
|
const btn = document.getElementById('favorite-toggle-btn');
|
||||||
|
const currentlyFavorited = btn.classList.contains('active');
|
||||||
|
fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path: {{ course.path|tojson }}, favorited: !currentlyFavorited })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
btn.classList.toggle('active', !currentlyFavorited);
|
||||||
|
btn.innerHTML = ICON_SVGS.star + ' ' + (currentlyFavorited ? 'Add to Favorites' : 'Remove from Favorites');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
// ---- Collapsible dashboard cards (client-only, remembered per device) ----
|
// ---- Collapsible dashboard cards (client-only, remembered per device) ----
|
||||||
|
|||||||
@@ -173,6 +173,9 @@
|
|||||||
<li><strong>Space</strong> - play/pause</li>
|
<li><strong>Space</strong> - play/pause</li>
|
||||||
<li><strong>← / →</strong> - seek back/forward 10 seconds</li>
|
<li><strong>← / →</strong> - seek back/forward 10 seconds</li>
|
||||||
<li><strong>↑ / ↓</strong> - volume up/down</li>
|
<li><strong>↑ / ↓</strong> - volume up/down</li>
|
||||||
|
<li><strong>, / .</strong> - step playback speed down/up</li>
|
||||||
|
<li><strong>[ / ]</strong> - previous/next lesson</li>
|
||||||
|
<li><strong>F</strong> - toggle fullscreen</li>
|
||||||
<li><strong>N</strong> - jump into the quick-capture note box (pauses the video)</li>
|
<li><strong>N</strong> - jump into the quick-capture note box (pauses the video)</li>
|
||||||
<li>Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered</li>
|
<li>Drag the video's bottom-right corner to resize it, and pick a default playback speed in Settings - both are remembered</li>
|
||||||
<li>Reopening a lesson resumes from where you left off, and the <strong>▶ Resume</strong> button at the top of a loaded course jumps straight back to the last lesson you had open</li>
|
<li>Reopening a lesson resumes from where you left off, and the <strong>▶ Resume</strong> button at the top of a loaded course jumps straight back to the last lesson you had open</li>
|
||||||
|
|||||||
@@ -1291,6 +1291,18 @@
|
|||||||
return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' || el.isContentEditable;
|
return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' || el.isContentEditable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLAYBACK_SPEEDS = ['0.75', '1', '1.25', '1.5', '1.75', '2'];
|
||||||
|
|
||||||
|
function stepPlaybackSpeed(direction) {
|
||||||
|
if (!activeMedia) return;
|
||||||
|
let idx = PLAYBACK_SPEEDS.findIndex(s => parseFloat(s) === activeMedia.playbackRate);
|
||||||
|
if (idx === -1) idx = PLAYBACK_SPEEDS.indexOf('1');
|
||||||
|
idx = Math.max(0, Math.min(PLAYBACK_SPEEDS.length - 1, idx + direction));
|
||||||
|
const speed = PLAYBACK_SPEEDS[idx];
|
||||||
|
setPlaybackSpeed(speed);
|
||||||
|
showNotification(`Speed: ${speed}x`);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (isTypingTarget(e.target)) return;
|
if (isTypingTarget(e.target)) return;
|
||||||
if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
@@ -1298,6 +1310,33 @@
|
|||||||
openQuickCapture();
|
openQuickCapture();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
|
switch(e.key) {
|
||||||
|
case '[':
|
||||||
|
{% if prev_lesson %}
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = '/lesson/{{ prev_lesson }}';
|
||||||
|
{% endif %}
|
||||||
|
break;
|
||||||
|
case ']':
|
||||||
|
{% if next_lesson %}
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = '/lesson/{{ next_lesson }}';
|
||||||
|
{% endif %}
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
if (video) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
video.requestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
@@ -1326,6 +1365,14 @@
|
|||||||
activeMedia.volume = Math.max(0, activeMedia.volume - 0.1);
|
activeMedia.volume = Math.max(0, activeMedia.volume - 0.1);
|
||||||
showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`);
|
showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`);
|
||||||
break;
|
break;
|
||||||
|
case ',':
|
||||||
|
e.preventDefault();
|
||||||
|
stepPlaybackSpeed(-1);
|
||||||
|
break;
|
||||||
|
case '.':
|
||||||
|
e.preventDefault();
|
||||||
|
stepPlaybackSpeed(1);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -470,6 +470,17 @@
|
|||||||
<p style="text-align: center; color: var(--text-muted); font-size: 0.8em; margin-top: 30px;">
|
<p style="text-align: center; color: var(--text-muted); font-size: 0.8em; margin-top: 30px;">
|
||||||
Version {{ build_version }}
|
Version {{ build_version }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if changelog_entries %}
|
||||||
|
<details style="max-width: 500px; margin: 10px auto 0; color: var(--text-muted); font-size: 0.8em;">
|
||||||
|
<summary style="text-align: center; cursor: pointer;">What's New</summary>
|
||||||
|
<ul style="margin: 8px 0 0; padding-left: 20px;">
|
||||||
|
{% for entry in changelog_entries %}
|
||||||
|
<li style="margin-bottom: 4px;">{{ entry }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+35
-5
@@ -325,6 +325,12 @@
|
|||||||
.storage-row:first-of-type {
|
.storage-row:first-of-type {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
.storage-row-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.storage-row-clickable:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
.storage-row-name {
|
.storage-row-name {
|
||||||
flex: 0 0 160px;
|
flex: 0 0 160px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -662,6 +668,7 @@
|
|||||||
<button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button>
|
<button class="btn btn-secondary" id="storage-scan-btn" onclick="scanStorageUsage()">{{ icons.icon('refresh', 14) }} Scan Disk Usage</button>
|
||||||
<span id="storage-status"></span>
|
<span id="storage-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="storage-breadcrumb"></div>
|
||||||
<div id="storage-results"></div>
|
<div id="storage-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1675,15 +1682,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Storage Usage ----
|
// ---- Storage Usage ----
|
||||||
function scanStorageUsage() {
|
let storagePathStack = [];
|
||||||
|
|
||||||
|
function scanStorageUsage(path) {
|
||||||
const btn = document.getElementById('storage-scan-btn');
|
const btn = document.getElementById('storage-scan-btn');
|
||||||
const status = document.getElementById('storage-status');
|
const status = document.getElementById('storage-status');
|
||||||
const results = document.getElementById('storage-results');
|
const results = document.getElementById('storage-results');
|
||||||
|
const breadcrumb = document.getElementById('storage-breadcrumb');
|
||||||
|
if (!path) storagePathStack = [];
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
status.style.color = 'var(--text-muted)';
|
status.style.color = 'var(--text-muted)';
|
||||||
status.textContent = 'Scanning… this reads every file, may take a moment.';
|
status.textContent = 'Scanning… this reads every file, may take a moment.';
|
||||||
results.innerHTML = '';
|
results.innerHTML = '';
|
||||||
fetch('/api/library/storage-usage')
|
const url = path ? `/api/library/storage-usage?path=${encodeURIComponent(path)}` : '/api/library/storage-usage';
|
||||||
|
fetch(url)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -1692,6 +1704,9 @@
|
|||||||
status.textContent = data.error;
|
status.textContent = data.error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
breadcrumb.innerHTML = !data.is_root
|
||||||
|
? `<button class="btn btn-secondary btn-sm" onclick="storageGoBack()">${'←'} Back</button> <span style="color: var(--text-muted); font-size: 0.85em;">${escapeHtml(data.path)}</span>`
|
||||||
|
: '';
|
||||||
const entries = data.entries || [];
|
const entries = data.entries || [];
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
status.style.color = 'var(--text-muted)';
|
status.style.color = 'var(--text-muted)';
|
||||||
@@ -1702,7 +1717,7 @@
|
|||||||
status.textContent = `Total: ${data.total_human}`;
|
status.textContent = `Total: ${data.total_human}`;
|
||||||
const maxBytes = Math.max(...entries.map(e => e.bytes), 1);
|
const maxBytes = Math.max(...entries.map(e => e.bytes), 1);
|
||||||
results.innerHTML = entries.map(e => `
|
results.innerHTML = entries.map(e => `
|
||||||
<div class="storage-row">
|
<div class="storage-row${e.is_dir ? ' storage-row-clickable' : ''}" ${e.is_dir ? `onclick="storageDrillDown('${e.path.replace(/'/g, "\\'")}')"` : ''}>
|
||||||
<span class="storage-row-name">${escapeHtml(e.name)}</span>
|
<span class="storage-row-name">${escapeHtml(e.name)}</span>
|
||||||
<div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div>
|
<div class="storage-bar-track"><div class="storage-bar-fill" style="width: ${Math.round((e.bytes / maxBytes) * 100)}%;"></div></div>
|
||||||
<span class="storage-row-size">${escapeHtml(e.human)}</span>
|
<span class="storage-row-size">${escapeHtml(e.human)}</span>
|
||||||
@@ -1716,6 +1731,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storageDrillDown(path) {
|
||||||
|
const status = document.getElementById('storage-status');
|
||||||
|
const current = status.dataset.currentPath || '';
|
||||||
|
storagePathStack.push(current);
|
||||||
|
status.dataset.currentPath = path;
|
||||||
|
scanStorageUsage(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageGoBack() {
|
||||||
|
const status = document.getElementById('storage-status');
|
||||||
|
const prev = storagePathStack.pop() || '';
|
||||||
|
status.dataset.currentPath = prev;
|
||||||
|
scanStorageUsage(prev || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Duplicate Courses ----
|
// ---- Duplicate Courses ----
|
||||||
function scanDuplicates() {
|
function scanDuplicates() {
|
||||||
const btn = document.getElementById('dup-scan-btn');
|
const btn = document.getElementById('dup-scan-btn');
|
||||||
@@ -1918,9 +1948,9 @@
|
|||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed' };
|
const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed', favorites: 'Favorite' };
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const category of ['hidden', 'next_up', 'recent_views']) {
|
for (const category of ['hidden', 'next_up', 'recent_views', 'favorites']) {
|
||||||
for (const entry of (data[category] || [])) {
|
for (const entry of (data[category] || [])) {
|
||||||
items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] });
|
items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user