Files
offlineu/templates/course_dashboard.html
T

873 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
}
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3d3d3d;
--bg-tertiary-hover: #404040;
--text-primary: #e0e0e0;
--text-muted: #999;
--border-color: #555;
--accent: #007acc;
--accent-hover: #005a9e;
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
--font-size-base: 16px;
--container-max-width: 1600px;
--radius: 8px;
}
[data-theme="light"] {
--bg-primary: #f2f2f2;
--bg-secondary: #ffffff;
--bg-tertiary: #eeeeee;
--bg-tertiary-hover: #e2e2e2;
--text-primary: #222222;
--text-muted: #666666;
--border-color: #cccccc;
}
[data-density="compact"] .container { padding: 10px; }
[data-density="compact"] .card,
[data-density="compact"] .lesson-item,
[data-density="compact"] .tree-header { padding: 10px; margin-bottom: 8px; }
[data-card-style="elevated"] .card,
[data-card-style="elevated"] .lesson-item,
[data-card-style="elevated"] .tree-header {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
[data-card-style="bordered"] .card,
[data-card-style="bordered"] .lesson-item,
[data-card-style="bordered"] .tree-header {
border: 1px solid var(--border-color);
}
html, body {
height: 100%;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.page-content {
flex: 1;
}
.container {
max-width: var(--container-max-width);
width: 95%;
margin: 0 auto;
padding: 20px;
}
.app-header {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
padding: 28px 0;
margin-bottom: 30px;
border-bottom: 3px solid var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
}
.app-header .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.brand-icon {
font-size: 2.2em;
line-height: 1;
}
.brand h1 {
color: var(--accent);
font-size: 1.9em;
letter-spacing: 0.5px;
}
.brand .tagline {
color: var(--text-muted);
font-size: 0.85em;
margin-top: 2px;
}
.header-course-badge {
background: var(--bg-primary);
border: 1px solid var(--accent);
color: var(--text-primary);
padding: 8px 16px;
border-radius: var(--radius);
font-size: 0.9em;
max-width: 340px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
margin-top: 40px;
padding: 18px 0;
}
.app-footer .container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
}
.footer-brand {
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
}
.footer-links {
display: flex;
gap: 20px;
}
.footer-links a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.footer-links a:hover {
color: var(--accent);
}
.nav {
background: var(--bg-tertiary);
padding: 10px 0;
margin-bottom: 20px;
}
.nav .container {
display: flex;
align-items: center;
gap: 15px;
}
.nav a {
color: var(--text-primary);
text-decoration: none;
padding: 10px 15px;
border-radius: var(--radius);
transition: background 0.3s;
}
.nav a:hover {
background: var(--accent);
}
.card {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 20px;
border-left: 4px solid var(--accent);
}
.btn {
background: var(--accent);
color: white;
border: none;
padding: 12px 24px;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.3s;
font-size: 14px;
}
.btn:hover {
background: var(--accent-hover);
}
.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: var(--accent);
height: 100%;
transition: width 0.3s ease;
border-radius: 10px;
}
.library-breadcrumb {
color: var(--text-muted);
font-size: 13px;
margin-top: 6px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.library-breadcrumb .crumb {
cursor: pointer;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-breadcrumb .crumb:not(.current):hover {
color: var(--accent);
text-decoration: underline;
}
.library-breadcrumb .crumb.current {
color: var(--text-primary);
font-weight: 600;
cursor: default;
}
.library-breadcrumb .crumb-sep {
opacity: 0.5;
}
/* Dynamic Directory Tree Styles */
.tree-container {
margin-top: 20px;
}
.tree-item {
margin-bottom: 5px;
}
.tree-header {
background: var(--bg-tertiary);
padding: 12px 15px;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.3s;
border-left: 3px solid #666;
}
.tree-header:hover {
background: var(--bg-tertiary-hover);
}
.tree-header.directory {
border-left-color: var(--accent);
}
.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;
min-width: 0;
}
.tree-icon {
font-size: 1.2em;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.tree-name {
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-stats {
font-size: 0.85em;
color: var(--text-muted);
margin-left: 10px;
flex-shrink: 0;
white-space: nowrap;
}
.tree-toggle {
background: none;
border: none;
color: var(--text-primary);
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: var(--bg-tertiary);
padding: 10px 15px;
border-radius: var(--radius);
margin-bottom: 5px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.3s;
cursor: pointer;
border-left: 3px solid #666;
position: relative;
overflow: hidden;
}
.lesson-item:hover {
border-left-color: var(--accent);
background: var(--bg-tertiary-hover);
transform: translateX(5px);
}
.lesson-item.completed {
border-left-color: #28a745;
background: #1e3d1e;
}
.lesson-item.completed:hover {
background: #2d5a2d;
}
.lesson-item.in-progress {
border-left-color: var(--accent);
}
.lesson-progress-track {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.08);
}
.lesson-progress-fill {
height: 100%;
background: var(--accent);
}
.lesson-item.completed .lesson-progress-fill {
background: #28a745;
}
.watched-badge {
font-size: 0.75em;
background: var(--bg-primary);
color: var(--accent);
padding: 2px 7px;
border-radius: 3px;
border: 1px solid var(--accent);
white-space: nowrap;
}
.lesson-title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.lesson-icon {
font-size: 1.1em;
flex-shrink: 0;
}
.lesson-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lesson-meta {
font-size: 0.85em;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
min-width: 0;
max-width: 45%;
}
.lesson-meta-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.lesson-type {
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;
}
.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;
}
.tree-content {
margin-left: 15px;
}
}
@media (max-width: 600px) {
.lesson-item {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.lesson-title,
.lesson-meta {
width: 100%;
max-width: 100%;
}
.lesson-name,
.lesson-meta-text {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
}
</style>
</head>
<body>
<div class="app-header">
<div class="container">
<a href="/reset_course" class="brand">
<span class="brand-icon">📚</span>
<div>
<h1>OfflineU</h1>
<p class="tagline">Your self-hosted course library</p>
</div>
</a>
{% if course %}
<div class="header-course-badge">{{ course.name }}</div>
{% endif %}
</div>
</div>
<div class="page-content">
{% 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) %}
{% set percent_watched = 100 if lesson.completed else (((100 * lesson.progress_seconds / lesson.duration_seconds)|round|int) if (lesson.duration_seconds and lesson.progress_seconds) else 0) %}
<div class="lesson-item {% if lesson.completed %}completed{% elif percent_watched %}in-progress{% 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>
<span class="lesson-name">{{ lesson.title }}</span>
</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>
{% elif percent_watched %}
<span class="watched-badge">{{ percent_watched }}% watched</span>
{% else %}
<span class="status-icon pending"></span>
{% endif %}
</div>
{% if percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ percent_watched }}%;"></div></div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
{{ render_tree_node(course.root_node) }}
</div>
</div>
{% else %}
{% if recent_views %}
<div class="container">
<div class="card">
<h2>🕐 Recently Viewed</h2>
<div style="margin-top: 12px;">
{% for view in recent_views %}
<div class="lesson-item {% if view.completed %}completed{% endif %} {% if view.percent_watched and not view.completed %}in-progress{% endif %}"
onclick="window.location.href='/recent/open?course_path={{ view.course_path | urlencode }}&lesson_path={{ view.lesson_path | urlencode }}'">
<div class="lesson-title">
<span class="lesson-icon">▶️</span>
<span class="lesson-name">{{ view.lesson_title }}</span>
</div>
<div class="lesson-meta">
{% if view.completed %}
<span class="status-icon completed"></span>
{% elif view.percent_watched %}
<span class="watched-badge">{{ view.percent_watched }}% watched</span>
{% endif %}
<span class="lesson-meta-text">{{ view.course_name }}{% if view.viewed_display %} · {{ view.viewed_display }}{% endif %}</span>
</div>
{% if view.percent_watched %}
<div class="lesson-progress-track"><div class="lesson-progress-fill" style="width: {{ view.percent_watched }}%;"></div></div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="container">
<div class="card" id="library-card">
<h2>Your Courses</h2>
<p id="library-path-bar" class="library-breadcrumb"></p>
<div id="library-groups" style="margin-top: 15px;"></div>
</div>
</div>
{% endif %}
</div>
<footer class="app-footer">
<div class="container">
<div class="footer-brand">
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/help">❓ Help</a>
<a href="/settings">⚙ Settings</a>
</div>
</div>
</footer>
<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');
}
}
// Single-level drill-down: the Library browser shows one folder's
// contents at a time (replacing the list, not nesting under it) and
// tracks how we got here in libraryTrail so a breadcrumb can jump
// back up. Much less horizontal space wasted than the old
// indent-per-level accordion, especially on narrow screens.
let libraryTrail = []; // [{name, path}], root is implicit (not in the array)
function loadLibrary() {
const libraryCard = document.getElementById('library-card');
if (!libraryCard) return;
libraryTrail = [];
fetchLibraryLevel(null);
}
function fetchLibraryLevel(path) {
const container = document.getElementById('library-groups');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
const url = path ? `/library?path=${encodeURIComponent(path)}` : '/library';
fetch(url)
.then(r => r.json())
.then(data => {
renderLibraryBreadcrumb();
renderLibraryLevel(data);
})
.catch(() => {
container.innerHTML =
'<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
});
}
function renderLibraryBreadcrumb() {
const bar = document.getElementById('library-path-bar');
const atRoot = libraryTrail.length === 0;
let html = `<span class="crumb ${atRoot ? 'current' : ''}"` +
(atRoot ? '' : ` onclick="goToLibraryCrumb(-1)"`) + `>Library</span>`;
libraryTrail.forEach((crumb, i) => {
const isLast = i === libraryTrail.length - 1;
html += ` <span class="crumb-sep">/</span> ` +
`<span class="crumb ${isLast ? 'current' : ''}"` +
(isLast ? '' : ` onclick="goToLibraryCrumb(${i})"`) +
`>${crumb.name}</span>`;
});
bar.innerHTML = html;
}
function goToLibraryCrumb(index) {
if (index < 0) {
libraryTrail = [];
fetchLibraryLevel(null);
return;
}
const target = libraryTrail[index];
libraryTrail = libraryTrail.slice(0, index + 1);
fetchLibraryLevel(target.path);
}
function renderLibraryLevel(data) {
const container = document.getElementById('library-groups');
const isRoot = libraryTrail.length === 0;
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length)
? data.errors.join(' ')
: (isRoot ? `No course folders found under ${data.library_path}.` : 'No courses in here.');
container.innerHTML = `<p style="color:#999; padding: 6px 0;">${reason}${isRoot ? ' You can still load a course by path below.' : ''}</p>`;
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
if (item.type === 'course') {
return `
<div class="lesson-item"
onclick="loadCoursePath('${safePath}')">
<div class="lesson-title">
<span class="lesson-icon">🎓</span>
<span class="lesson-name">${item.name}</span>
</div>
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
</div>
`;
}
const safeName = item.name.replace(/'/g, "\\'");
return `
<div class="tree-header directory" onclick="enterLibraryDir('${safePath}', '${safeName}')">
<div class="tree-title">
<span class="tree-icon">📁</span>
<span class="tree-name">${item.name}</span>
</div>
<span class="tree-toggle"></span>
</div>
`;
}).join('');
}
function enterLibraryDir(path, name) {
libraryTrail.push({ name: name, path: path });
fetchLibraryLevel(path);
}
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);
</script>
<script src="/static/theme.js"></script>
</body>
</html>