Add library browser feature

This commit is contained in:
2026-08-20 13:47:51 -04:00
commit 6f0fc6cb60
12 changed files with 2451 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Version control
.git
.gitignore
.github
# Ignore Docker-related files (not needed in the image)
Dockerfile
.dockerignore
# Ignore cache and temporary files. Logs for future use.
**/__pycache__/
*.pyc
*.pyo
*.pyd
*.log
# Ignore virtual environments (if using venv)
**/python_env/
**/.venv/
**/venv/
**/env/
**/ENV/
# OfflineU specific
.offlineu_progress.json
data/
courses/
# Auto-generated templates (created at runtime if missing)
# Leave commented if you modify templates
# templates/
# Harmless, but reduce image size. In the future, it may make more sense to put
# everything needed by the image in e.g., /app and modify the Dockerfile to copy from
# /app as opposed to blacklisting files in the repo one-by-one
LICENSE
README.md
images/
+24
View File
@@ -0,0 +1,24 @@
# set base image (host OS)
FROM python:3.13.5-slim-bookworm
# set the working directory in the container
WORKDIR /app
# copy the dependencies file to the working directory
COPY requirements.txt .
# install dependencies
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
# copy the content of the local src directory to the working directory
COPY . .
EXPOSE 5000
# add healthcheck using Python standard library
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health').read()"
# command to run on container start
CMD [ "python", "/app/offlineu_core.py" ]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 WhiskeyCoder
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+129
View File
@@ -0,0 +1,129 @@
# OfflineU: Self-Hosted Local Course Loader & Progress Tracker
**OfflineU** is a sleek, self-hosted web app designed to load and view your offline video, audio, text, and quiz-based training courses. Whether it's Udemy downloads, "open sourced" training archives, or personal content, OfflineU turns your course folder into a fully navigable dashboard with automatic progress tracking.
---
## ✨ Features
* 📁 **Dynamic folder parsing**: Scans and maps your course structure into a browsable tree view.
* 🎥 **Video & Audio player**: Integrated media player with resume & completion tracking.
* 📄 **Text & HTML viewer**: Supports .txt, .md, .html, .pdf, and more.
***Lesson progress tracking**: Auto-saves your time spent and marks lessons as completed.
* ♻️ **Continue where you left off**: Resume instantly from your last-accessed lesson.
* 💾 **Local-first & private**: 100% offline. No cloud, no tracking, no nonsense.
* 🧑‍💻 **Works with any course format**: No metadata required, just structured folders.
* 🧠 **Ideal for hoarders, students, or offline learning setups**
---
## 🗈️ Screenshots
> ![image](https://github.com/WhiskeyCoder/OfflineU/blob/main/images/lesson-0-8-2025-08-04-04_58_17.png)
---
## 🛠️ Installation
### 🔁 Quick Start (Local)
1. Clone the repo:
```bash
git clone https://github.com/WhiskeyCoder/OfflineU.git
cd OfflineU
```
2. Install Python dependencies:
```bash
pip install flask
```
3. Run the app:
```bash
python offlineu_core.py --create-templates
```
4. Open your browser:
```
http://127.0.0.1:5000
```
---
## 📂 Folder Structure Example
```bash
MyCourse/
├── Section 1/
│ ├── 01 - Intro.mp4
│ ├── 02 - Setup Guide.pdf
│ └── 03 - Quiz.html
├── Section 2/
│ ├── 04 - Advanced Tips.mp4
│ └── resources/
│ └── extras.md
└── .offlineu_progress.json ← created automatically
```
> 🌟 File types are detected automatically — videos, audio, quizzes, and docs.
---
## 📁 Supported File Types
| Type | Extensions |
| --------- | ----------------------------------------------------------- |
| Videos | `.mp4`, `.mkv`, `.webm`, `.mov`, `.avi`, etc. |
| Audio | `.mp3`, `.wav`, `.aac`, etc. |
| Docs | `.txt`, `.md`, `.html`, `.pdf`, `.docx` |
| Subtitles | `.srt`, `.vtt` |
| Quizzes | Detected if file name contains `quiz`, `exam`, `test`, etc. |
---
## ⚙️ CLI Options
| Option | Description |
| -------------------- | ------------------------------- |
| `--host` | Set host (default: `127.0.0.1`) |
| `--port` | Set port (default: `5000`) |
| `--debug` | Enable Flask debug mode |
| `--create-templates` | Generate default HTML templates |
| `<course_path>` | Load course directly at startup |
---
## 🧠 Roadmap
* [x] Base function and testing
* [ ] Multi-user profile support
* [ ] Dark/light theme switcher
* [ ] Built-in quiz interactivity
* [ ] Import/export course metadata
* [ ] Mobile app wrapper
* [ ] Self hosted Docker Deployment
---
## 💬 Community
Join the development, suggest features, or ask questions via:
* GitHub Issues: [https://github.com/WhiskeyCoder/OfflineU/issues](https://github.com/WhiskeyCoder/OfflineU/issues)
---
## 🛡️ License
MIT License — Use freely, modify locally, share widely.
---
## ✨ Author
Built with ❤️ by [@WhiskeyCoder](https://github.com/WhiskeyCoder)
Inspired by the dream of **learning freely, offline, and without limits.**
+22
View File
@@ -0,0 +1,22 @@
version: '3.8'
services:
offlineu:
build: .
container_name: offlineu-app
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
volumes:
# Course library — grouped/browsable via the new Library view
- /volume1/files/training:/app/courses
# Progress data persistence
- ./data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
+22
View File
@@ -0,0 +1,22 @@
version: '3.8'
services:
offlineu:
image: ghcr.io/skippysteve/offlineu:main
container_name: offlineu-app
ports:
- "5000:5000"
environment:
- FLASK_ENV=production
volumes:
# Mount a local directory for course data persistence
- ./courses:/app/courses
# Mount a local directory for user data/progress
- ./data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+1038
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
blinker==1.9.0
click==8.2.1
flask==3.1.1
itsdangerous==2.2.0
jinja2==3.1.6
markupsafe==3.0.2
werkzeug==3.1.3
+632
View File
@@ -0,0 +1,632 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if course %}{{ course.name }} - OfflineU{% else %}OfflineU{% endif %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1a1a1a;
color: #e0e0e0;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #2d2d2d;
padding: 20px 0;
margin-bottom: 30px;
border-bottom: 3px solid #007acc;
}
.header h1 {
color: #007acc;
text-align: center;
font-size: 2.5em;
}
.nav {
background: #333;
padding: 10px 0;
margin-bottom: 20px;
}
.nav .container {
display: flex;
align-items: center;
gap: 15px;
}
.nav a {
color: #e0e0e0;
text-decoration: none;
padding: 10px 15px;
border-radius: 5px;
transition: background 0.3s;
}
.nav a:hover {
background: #007acc;
}
.card {
background: #2d2d2d;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border-left: 4px solid #007acc;
}
.btn {
background: #007acc;
color: white;
border: none;
padding: 12px 24px;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.3s;
font-size: 14px;
}
.btn:hover {
background: #005a9e;
}
.btn:disabled {
background: #666;
cursor: not-allowed;
}
.btn-secondary {
background: #666;
}
.btn-secondary:hover {
background: #555;
}
.progress-bar {
background: #444;
border-radius: 10px;
overflow: hidden;
height: 20px;
margin: 10px 0;
}
.progress-fill {
background: linear-gradient(90deg, #007acc, #00a0ff);
height: 100%;
transition: width 0.3s ease;
border-radius: 10px;
}
/* Dynamic Directory Tree Styles */
.tree-container {
margin-top: 20px;
}
.tree-item {
margin-bottom: 5px;
}
.tree-header {
background: #3d3d3d;
padding: 12px 15px;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.3s;
border-left: 3px solid #666;
}
.tree-header:hover {
background: #404040;
}
.tree-header.directory {
border-left-color: #007acc;
}
.tree-header.lesson {
border-left-color: #28a745;
}
.tree-header.completed {
border-left-color: #28a745;
background: #1e3d1e;
}
.tree-header.completed:hover {
background: #2d5a2d;
}
.tree-title {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.tree-icon {
font-size: 1.2em;
width: 20px;
text-align: center;
}
.tree-name {
font-weight: 500;
}
.tree-stats {
font-size: 0.85em;
color: #999;
margin-left: 10px;
}
.tree-toggle {
background: none;
border: none;
color: #e0e0e0;
font-size: 1.2em;
cursor: pointer;
padding: 5px;
border-radius: 3px;
transition: background 0.3s;
}
.tree-toggle:hover {
background: #555;
}
.tree-content {
margin-left: 25px;
margin-top: 5px;
display: none;
}
.tree-content.expanded {
display: block;
}
.lesson-item {
background: #3d3d3d;
padding: 10px 15px;
border-radius: 5px;
margin-bottom: 5px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
cursor: pointer;
border-left: 3px solid #666;
}
.lesson-item:hover {
border-left-color: #007acc;
background: #404040;
transform: translateX(5px);
}
.lesson-item.completed {
border-left-color: #28a745;
background: #1e3d1e;
}
.lesson-item.completed:hover {
background: #2d5a2d;
}
.lesson-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.lesson-icon {
font-size: 1.1em;
}
.lesson-meta {
font-size: 0.85em;
color: #999;
display: flex;
align-items: center;
gap: 5px;
}
.lesson-type {
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
font-weight: 500;
text-transform: uppercase;
}
.lesson-type.video { background: #e74c3c; color: white; }
.lesson-type.audio { background: #9b59b6; color: white; }
.lesson-type.text { background: #34495e; color: white; }
.lesson-type.quiz { background: #f39c12; color: white; }
.lesson-type.mixed { background: #16a085; color: white; }
.status-icon {
font-size: 1.2em;
font-weight: bold;
}
.status-icon.completed {
color: #28a745;
}
.status-icon.pending {
color: #666;
}
.course-selector {
border: 2px dashed #666;
padding: 40px;
text-align: center;
margin: 40px 0;
border-radius: 10px;
background: #2d2d2d;
}
.course-selector input {
background: #3d3d3d;
border: 1px solid #666;
color: #e0e0e0;
padding: 12px;
font-size: 14px;
border-radius: 5px;
width: 80%;
margin-right: 10px;
}
.course-selector input::placeholder {
color: #999;
}
.course-selector input:focus {
outline: none;
border-color: #007acc;
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
}
.last-accessed {
background: #2d5a2d;
border-left-color: #28a745;
margin-top: 10px;
padding: 10px;
border-radius: 5px;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header h1 {
font-size: 2em;
}
.nav .container {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.course-selector input {
width: 100%;
margin-right: 0;
margin-bottom: 10px;
}
.tree-content {
margin-left: 15px;
}
}
</style>
</head>
<body>
<div class="header">
<div class="container">
<h1>OfflineU</h1>
</div>
</div>
{% if course %}
<div class="nav">
<div class="container">
<a href="/reset_course">← Select Different Course</a>
<a href="#stats">Progress</a>
<span style="color: #666; margin-left: auto;">
{{ stats.total_lessons }} lessons
</span>
</div>
</div>
<div class="container">
<div class="card">
<h2>{{ course.name }}</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px;">
<div>
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
</div>
<div style="color: #007acc; font-size: 1.2em;">
{{ "%.1f"|format(stats.completion_percentage) }}%
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ stats.completion_percentage }}%;"></div>
</div>
{% if stats.last_accessed_path %}
<div class="last-accessed">
<strong>Last Accessed:</strong> {{ stats.last_accessed_path }}
<a href="/lesson/{{ stats.last_accessed_path }}" class="btn" style="margin-left: 10px;">
Continue
</a>
</div>
{% endif %}
</div>
<div class="tree-container">
{% macro render_tree_node(node, depth=0) %}
<div class="tree-item">
<div class="tree-header directory" onclick="toggleTree(this)">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">{{ node.name }}</span>
<span class="tree-stats">
{{ (node.children|length + node.lessons|length) }} items
</span>
</div>
{% if node.children or node.lessons %}
<button class="tree-toggle"></button>
{% endif %}
</div>
{% if node.children or node.lessons %}
<div class="tree-content">
{% for child_name, child_node in node.children.items() %}
{{ render_tree_node(child_node, depth + 1) }}
{% endfor %}
{% for lesson in node.lessons %}
{% set lesson_relative_path = lesson.path|replace('\\', '/')|replace(course.path|replace('\\', '/'), '')|replace('//', '/')|replace('/', '', 1) %}
<div class="lesson-item {% if lesson.completed %}completed{% endif %}"
onclick="window.location.href='/lesson/{{ lesson_relative_path }}/{{ lesson.title|replace(' ', '_') }}'">
<div class="lesson-title">
<span class="lesson-icon">
{% if lesson.lesson_type == 'video' %}🎥
{% elif lesson.lesson_type == 'audio' %}🎵
{% elif lesson.lesson_type == 'quiz' %}📝
{% elif lesson.lesson_type == 'mixed' %}📦
{% else %}📄{% endif %}
</span>
{{ lesson.title }}
</div>
<div class="lesson-meta">
<span class="lesson-type {{ lesson.lesson_type }}">{{ lesson.lesson_type|title }}</span>
{% if lesson.completed %}
<span class="status-icon completed"></span>
{% else %}
<span class="status-icon pending"></span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{{ render_tree_node(course.root_node) }}
</div>
</div>
{% else %}
<div class="container">
<div class="card" id="library-card">
<h2>Your Courses</h2>
<p id="library-path-bar" style="color: #999; font-size: 13px; margin-top: 6px;"></p>
<div id="library-groups" style="margin-top: 15px;"></div>
</div>
<div class="course-selector card">
<p>
<a href="#" onclick="toggleManualPath(event)" style="color: #007acc;">
Don't see it? Enter a path manually
</a>
</p>
<div id="manual-path-box" style="display:none; margin-top: 15px;">
<p>Enter the full path to your course directory:</p>
<div style="margin: 20px 0;">
<input type="text" id="course-path" placeholder="e.g., /app/courses/My Course">
<button onclick="loadCourseFromPath()" class="btn">Load Course</button>
</div>
<div id="course-status" style="margin-top: 10px;"></div>
</div>
</div>
<div style="margin-top: 40px;">
<div class="card">
<h3>How to Use OfflineU:</h3>
<ul style="margin-top: 10px; list-style-type: none; padding-left: 20px;">
<li><strong>Prepare your course files</strong> in a directory structure</li>
<li><strong>Copy the full path</strong> to your course directory</li>
<li><strong>Paste the path</strong> in the input field above</li>
<li><strong>Click "Load Course"</strong> or press Enter</li>
<li><strong>Start learning!</strong> Your progress will be saved automatically</li>
</ul>
</div>
<div class="card">
<h3>Supported File Types:</h3>
<ul style="margin-top: 10px; list-style-type: none; padding-left: 20px;">
<li><strong>Videos:</strong> .mp4, .mkv, .avi, .mov, .webm</li>
<li><strong>Audio:</strong> .mp3, .wav, .m4a, .aac</li>
<li><strong>Documents:</strong> .txt, .md, .html, .pdf</li>
<li><strong>Subtitles:</strong> .srt, .vtt</li>
</ul>
</div>
</div>
</div>
{% endif %}
<script>
function toggleTree(element) {
console.log('Toggle clicked:', element);
// Find the content div that comes after this header
const content = element.nextElementSibling;
const toggle = element.querySelector('.tree-toggle');
console.log('Content element:', content);
console.log('Toggle element:', toggle);
if (content && content.classList.contains('tree-content')) {
const isExpanded = content.classList.contains('expanded');
content.classList.toggle('expanded');
if (toggle) {
toggle.textContent = content.classList.contains('expanded') ? '▼' : '▶';
}
console.log('Toggled tree, expanded:', content.classList.contains('expanded'));
} else {
console.log('No content element found or not tree-content');
}
}
function toggleManualPath(e) {
e.preventDefault();
const box = document.getElementById('manual-path-box');
box.style.display = box.style.display === 'none' ? 'block' : 'none';
}
function loadLibrary() {
const libraryCard = document.getElementById('library-card');
if (!libraryCard) return;
fetch('/library')
.then(r => r.json())
.then(data => {
document.getElementById('library-path-bar').textContent =
`Scanning ${data.library_path}`;
const container = document.getElementById('library-groups');
const groupNames = Object.keys(data.groups).sort((a, b) => a.localeCompare(b));
if (groupNames.length === 0) {
const reason = (data.errors && data.errors.length)
? data.errors.join(' ')
: `No course folders found under ${data.library_path}.`;
container.innerHTML = `<p style="color:#999;">${reason} You can still load a course by path below.</p>`;
return;
}
container.innerHTML = groupNames.map(group => `
<div style="margin-bottom: 20px;">
<div style="font-weight:bold; color:#00a0ff; border-bottom:1px solid #444; padding-bottom:4px; margin-bottom:8px;">
📁 ${group}
</div>
${data.groups[group].map(course => `
<div class="library-course"
onclick="loadCoursePath('${course.path.replace(/'/g, "\\'")}')"
style="background:#333; border-radius:5px; padding:10px 14px; margin:6px 0;
cursor:pointer; display:flex; justify-content:space-between; align-items:center;">
<span>🎓 ${course.name}</span>
<span style="color:#999; font-size:12px;">${course.media_files} media file${course.media_files === 1 ? '' : 's'}</span>
</div>
`).join('')}
</div>
`).join('');
})
.catch(() => {
document.getElementById('library-groups').innerHTML =
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
});
}
function loadCoursePath(path) {
fetch('/load_course', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({course_path: path})
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
});
}
document.addEventListener('DOMContentLoaded', loadLibrary);
function loadCourseFromPath() {
const pathInput = document.getElementById('course-path');
const statusDiv = document.getElementById('course-status');
const coursePath = pathInput.value.trim();
if (!coursePath) {
statusDiv.innerHTML = '<p style="color: #ff6b6b;">Please enter a course path.</p>';
return;
}
// Show loading status
statusDiv.innerHTML = '<p style="color: #007acc;">Loading course...</p>';
fetch('/load_course', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({course_path: coursePath})
})
.then(r => r.json())
.then(data => {
if (data.success) {
statusDiv.innerHTML = '<p style="color: #28a745;">Course loaded successfully! Redirecting...</p>';
setTimeout(() => location.reload(), 1000);
} else {
statusDiv.innerHTML = `<p style="color: #ff6b6b;">Error: ${data.error}</p>`;
}
})
.catch(error => {
console.error('Error loading course:', error);
statusDiv.innerHTML = '<p style="color: #ff6b6b;">Error loading course. Please check the path and try again.</p>';
});
}
// Allow Enter key to submit
document.addEventListener('DOMContentLoaded', function() {
const pathInput = document.getElementById('course-path');
if (pathInput) {
pathInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
loadCourseFromPath();
}
});
}
});
</script>
</body>
</html>
+365
View File
@@ -0,0 +1,365 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ lesson.title }} - {{ course.name }}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #1a1a1a;
color: #e0e0e0;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: #2d2d2d;
padding: 20px;
border-radius: 8px;
}
video, audio {
width: 100%;
max-width: 800px;
border-radius: 5px;
display: block;
margin: 0 auto;
}
.content {
margin: 20px 0;
}
.navigation {
margin: 20px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
background: #007acc;
color: white;
border: none;
border-radius: 5px;
transition: background 0.3s;
}
button:hover {
background: #005a9e;
}
button:disabled {
background: #666;
cursor: not-allowed;
}
.file-link {
display: block;
margin: 5px 0;
padding: 10px;
background: #3d3d3d;
text-decoration: none;
color: #e0e0e0;
border-radius: 5px;
transition: background 0.3s;
}
.file-link:hover {
background: #404040;
}
.lesson-path {
background: #333;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
font-family: monospace;
color: #999;
}
.lesson-title {
color: #007acc;
margin-bottom: 20px;
}
.text-content {
background: #3d3d3d;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
max-height: 600px;
overflow-y: auto;
white-space: pre-wrap;
font-family: 'Courier New', monospace;
line-height: 1.6;
}
.text-content iframe {
background: white;
border-radius: 5px;
}
.nav-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.nav-buttons a {
text-decoration: none;
color: inherit;
}
</style>
</head>
<body>
<div class="container">
<h1 class="lesson-title">{{ lesson.title }}</h1>
<div class="lesson-path">
<strong>Path:</strong> {{ lesson_path }}
</div>
<div class="navigation">
<a href="/" style="text-decoration: none; color: #007acc;">← Back to Course</a>
<button onclick="markCompleted()">Mark as Completed</button>
</div>
<div class="content">
{% if lesson.video_file %}
<h3>Video</h3>
<video controls preload="metadata" id="video-player">
<source src="/files/{{ lesson.video_file }}" type="video/mp4">
{% if lesson.subtitle_file %}
<track kind="subtitles" src="/files/{{ lesson.subtitle_file }}" srclang="en" label="English">
{% endif %}
Your browser does not support the video tag.
</video>
{% endif %}
{% if lesson.audio_file %}
<h3>Audio</h3>
<audio controls preload="metadata" id="audio-player">
<source src="/files/{{ lesson.audio_file }}" type="audio/mp3">
Your browser does not support the audio tag.
</audio>
{% endif %}
{% if lesson.text_files %}
<h3>Content</h3>
{% for text_file in lesson.text_files %}
<div style="margin-bottom: 20px;">
<h4>{{ text_file.split('/')[-1] }}</h4>
<div class="text-content" id="content-{{ loop.index0 }}">
<div style="text-align: center; color: #666;">Loading content...</div>
</div>
<a href="/files/{{ text_file|replace('\\', '/') }}" class="file-link" target="_blank">
📄 Open {{ text_file.split('/')[-1] }} in new tab
</a>
</div>
{% endfor %}
{% endif %}
</div>
<div class="nav-buttons">
{% if prev_lesson %}
<a href="/lesson/{{ prev_lesson }}">
<button>← Previous</button>
</a>
{% else %}
<button disabled>← Previous</button>
{% endif %}
{% if next_lesson %}
<a href="/lesson/{{ next_lesson }}">
<button>Next →</button>
</a>
{% else %}
<button disabled>Next →</button>
{% endif %}
</div>
<script>
// Load text content
{% for text_file in lesson.text_files %}
console.log('Loading file: {{ text_file }}');
const filePath = '{{ text_file|replace("\\", "/") }}';
console.log('Encoded file path:', filePath);
// Check if this is an HTML file
const isHtmlFile = filePath.toLowerCase().endsWith('.html');
const contentDiv = document.getElementById('content-{{ loop.index0 }}');
if (isHtmlFile) {
// For HTML files, create an iframe
console.log('Creating iframe for HTML file');
contentDiv.innerHTML = `
<iframe
src="/files/${encodeURIComponent(filePath)}"
style="width: 100%; height: 600px; border: none; border-radius: 5px;"
title="${filePath.split('/').pop()}"
></iframe>
`;
} else {
// For text files, fetch and display content
fetch('/files/' + encodeURIComponent(filePath))
.then(response => {
console.log('Response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.text();
})
.then(content => {
console.log('Content loaded successfully, length:', content.length);
if (contentDiv) {
// Check if it's HTML content
if (content.trim().startsWith('<') && content.includes('</')) {
contentDiv.innerHTML = content;
} else {
// Treat as plain text
contentDiv.textContent = content;
}
}
})
.catch(error => {
console.error('Error loading content:', error);
if (contentDiv) {
contentDiv.innerHTML =
'<div style="color: #ff6b6b;">Error loading content: ' + error.message + '</div>' +
'<div style="color: #999; font-size: 0.9em; margin-top: 10px;">File path: ' + filePath + '</div>';
}
});
}
{% endfor %}
// Media progress tracking
const video = document.getElementById('video-player');
const audio = document.getElementById('audio-player');
const activeMedia = video || audio;
let isCompleted = false;
if (activeMedia) {
let lastSaveTime = 0;
activeMedia.addEventListener('timeupdate', function() {
const currentTime = activeMedia.currentTime;
// Save progress every 15 seconds
if (currentTime - lastSaveTime > 15) {
saveProgress(currentTime);
lastSaveTime = currentTime;
}
});
activeMedia.addEventListener('ended', function() {
saveProgress(activeMedia.currentTime, true);
markAsCompleted();
});
// Resume from saved position
const savedProgress = {{ lesson.progress_seconds|default(0) }};
if (savedProgress > 0 && savedProgress < activeMedia.duration - 30) {
activeMedia.currentTime = savedProgress;
showNotification(`Resumed from ${Math.floor(savedProgress / 60)}:${String(Math.floor(savedProgress % 60)).padStart(2, '0')}`);
}
}
function saveProgress(progressSeconds, completed = false) {
fetch('/api/progress', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
lesson_path: '{{ lesson_path }}',
completed: completed,
progress_seconds: Math.floor(progressSeconds)
})
}).catch(err => console.error('Failed to save progress:', err));
}
function markCompleted() {
if (isCompleted) return;
const currentTime = activeMedia ? activeMedia.currentTime : 0;
saveProgress(currentTime, true);
markAsCompleted();
}
function markAsCompleted() {
const btn = document.querySelector('button[onclick="markCompleted()"]');
if (btn) {
btn.textContent = 'Completed ✓';
btn.style.background = '#28a745';
btn.disabled = true;
}
isCompleted = true;
showNotification('Lesson marked as completed!', 'success');
}
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? '#28a745' : '#007acc'};
color: white;
padding: 15px 20px;
border-radius: 5px;
z-index: 10000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 100);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(-20px)';
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 300);
}, 3000);
}
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (activeMedia && !e.ctrlKey && !e.altKey && !e.metaKey) {
switch(e.key) {
case ' ':
e.preventDefault();
if (activeMedia.paused) {
activeMedia.play();
} else {
activeMedia.pause();
}
break;
case 'ArrowLeft':
e.preventDefault();
activeMedia.currentTime = Math.max(0, activeMedia.currentTime - 10);
break;
case 'ArrowRight':
e.preventDefault();
activeMedia.currentTime = Math.min(activeMedia.duration, activeMedia.currentTime + 10);
break;
case 'ArrowUp':
e.preventDefault();
activeMedia.volume = Math.min(1, activeMedia.volume + 0.1);
showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`);
break;
case 'ArrowDown':
e.preventDefault();
activeMedia.volume = Math.max(0, activeMedia.volume - 0.1);
showNotification(`Volume: ${Math.round(activeMedia.volume * 100)}%`);
break;
}
}
});
// Set completed state if already completed
if ({{ 'true' if lesson.completed else 'false' }}) {
markAsCompleted();
}
</script>
</div>
</body>
</html>
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html>
<head>
<title>OfflineU - Select Course</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; color: #222; }
.container { max-width: 900px; margin: 0 auto; }
.tabs { margin-bottom: 20px; }
.tabs button {
padding: 8px 16px; margin-right: 8px; cursor: pointer;
border: 1px solid #ccc; background: #f5f5f5; border-radius: 4px;
}
.tabs button.active { background: #4CAF50; color: white; border-color: #4CAF50; }
.group { margin: 18px 0; }
.group-title {
font-size: 1.1em; font-weight: bold; margin-bottom: 6px;
padding-bottom: 4px; border-bottom: 2px solid #4CAF50; color: #2e7d32;
}
.course-card {
padding: 10px 14px; border: 1px solid #ddd; margin: 5px 0;
cursor: pointer; border-radius: 4px; background: #e8f5e8;
display: flex; justify-content: space-between; align-items: center;
}
.course-card:hover { background-color: #d7ecd7; }
.course-card .meta { color: #666; font-size: 0.85em; }
.directory { padding: 10px; border: 1px solid #ddd; margin: 5px 0; cursor: pointer; border-radius: 4px; }
.directory:hover { background-color: #f0f0f0; }
.course-candidate { background-color: #e8f5e8; }
.empty-state, .error-state {
padding: 16px; border: 1px dashed #ccc; border-radius: 4px;
color: #666; margin-top: 10px;
}
.error-state { border-color: #e57373; color: #b71c1c; background: #fff5f5; }
.path-bar { font-size: 0.9em; color: #666; margin-bottom: 10px; }
code { background: #f0f0f0; padding: 2px 5px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>OfflineU - Course Selection</h1>
<div class="tabs">
<button id="tab-library" class="active" onclick="showTab('library')">📚 Library</button>
<button id="tab-filesystem" onclick="showTab('filesystem')">🗂️ Browse Filesystem</button>
</div>
<div id="library-view">
<div class="path-bar" id="library-path-bar"></div>
<div id="library-groups"></div>
</div>
<div id="filesystem-view" style="display:none;">
<div id="browser"></div>
</div>
<script>
function showTab(tab) {
document.getElementById('tab-library').classList.toggle('active', tab === 'library');
document.getElementById('tab-filesystem').classList.toggle('active', tab === 'filesystem');
document.getElementById('library-view').style.display = tab === 'library' ? '' : 'none';
document.getElementById('filesystem-view').style.display = tab === 'filesystem' ? '' : 'none';
if (tab === 'filesystem' && !document.getElementById('browser').dataset.loaded) {
document.getElementById('browser').dataset.loaded = '1';
loadDirectories();
}
}
// ---- Library view: grouped, click-to-load, no typed paths ----
function loadLibrary() {
fetch('/library')
.then(r => r.json())
.then(data => {
document.getElementById('library-path-bar').innerHTML =
`Scanning: <code>${data.library_path}</code>`;
const container = document.getElementById('library-groups');
const groupNames = Object.keys(data.groups).sort((a, b) => a.localeCompare(b));
if (data.errors && data.errors.length && groupNames.length === 0) {
container.innerHTML = `<div class="error-state">
Couldn't load the library at <code>${data.library_path}</code>.<br>
${data.errors.map(e => e).join('<br>')}<br><br>
Make sure that path is mounted into the container, or use
"Browse Filesystem" to pick a course manually.
</div>`;
return;
}
if (groupNames.length === 0) {
container.innerHTML = `<div class="empty-state">
No courses found under <code>${data.library_path}</code>.
Drop course folders in there, or use "Browse Filesystem" instead.
</div>`;
return;
}
container.innerHTML = groupNames.map(group => `
<div class="group">
<div class="group-title">📁 ${group}</div>
${data.groups[group].map(course => `
<div class="course-card" onclick="loadCourse('${course.path.replace(/'/g, "\\'")}')">
<span>🎓 ${course.name}</span>
<span class="meta">${course.media_files} media file${course.media_files === 1 ? '' : 's'}</span>
</div>
`).join('')}
</div>
`).join('');
});
}
// ---- Filesystem view: original full-path browser (fallback) ----
function loadDirectories(path = '') {
fetch(`/browse?path=${encodeURIComponent(path)}`)
.then(r => r.json())
.then(data => {
const browser = document.getElementById('browser');
browser.innerHTML = `
<h3>Current: ${data.current_path}</h3>
${data.parent_path ? `<div class="directory" onclick="loadDirectories('${data.parent_path}')">📁 .. (Parent)</div>` : ''}
${data.directories.map(dir => `
<div class="directory ${dir.is_course_candidate ? 'course-candidate' : ''}"
onclick="${dir.is_course_candidate ? `loadCourse('${dir.path}')` : `loadDirectories('${dir.path}')`}">
📁 ${dir.name} ${dir.media_files > 0 ? `(${dir.media_files} media files)` : ''}
</div>
`).join('')}
`;
});
}
function loadCourse(path) {
fetch('/load_course', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({course_path: path})
})
.then(r => r.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error: ' + data.error);
}
});
}
loadLibrary();
</script>
</div>
</body>
</html>