Files
offlineu/templates/lesson_view.html
T
rmsitzandClaude Sonnet 5 1fbf0c9312 Fix video resize handle drifting off-screen
The resize observer was persisting any rendered size change, including
incidental reflow from the page's own responsive max-width clamping on
window/viewport resize - not just deliberate corner-handle drags. This
let the stored size drift into a badly non-16:9 box over repeated views
on different window sizes, pushing the actual resize grip far below the
fold. Now only persists while the user has mousedown on the video, and
caps rendered height at 80vh as a backstop against already-drifted values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 19:00:40 -04:00

1357 lines
55 KiB
HTML

{% import '_icons.html' as icons %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ lesson.title }} - {{ course.name }}</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#007acc">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<style>
: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;
--success: #28a745;
--error: #ff6b6b;
}
[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"] .text-content { padding: 10px; margin: 10px 0; }
[data-density="compact"] .navigation,
[data-density="compact"] .nav-buttons { gap: 5px; margin: 10px 0; }
[data-card-style="elevated"] .container,
[data-card-style="elevated"] .text-content,
[data-card-style="elevated"] .file-link {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
[data-card-style="bordered"] .container,
[data-card-style="bordered"] .text-content,
[data-card-style="bordered"] .file-link {
border: 1px solid var(--border-color);
}
html, body {
height: 100%;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-base);
margin: 0;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
flex-direction: column;
min-height: 100vh;
}
.page-content {
flex: 1;
padding: 20px;
}
.app-header {
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
padding: 18px 0;
border-bottom: 3px solid var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
}
.app-header .container {
max-width: var(--container-max-width);
width: 95%;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.app-header .brand-mark {
flex-shrink: 0;
display: block;
}
.app-header a.brand-link {
color: var(--accent);
text-decoration: none;
font-size: 1.3em;
font-weight: 600;
letter-spacing: 0.5px;
}
.app-footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: 18px 0;
}
.app-footer .container-inner {
max-width: var(--container-max-width);
width: 95%;
margin: 0 auto;
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);
}
.icon {
display: inline-block;
vertical-align: -3px;
flex-shrink: 0;
}
.container {
max-width: var(--container-max-width);
width: 95%;
margin: 20px auto;
background: var(--bg-secondary);
padding: 20px;
border-radius: var(--radius);
}
video, audio {
width: 100%;
max-width: 100%;
border-radius: var(--radius);
display: block;
margin: 0 auto;
}
video {
resize: both;
overflow: hidden;
object-fit: contain;
background: #000;
min-width: 320px;
min-height: 200px;
max-height: 80vh;
}
.resize-hint {
text-align: center;
font-size: 0.8em;
color: var(--text-muted);
margin-top: 6px;
}
.speed-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.speed-label {
color: var(--text-muted);
font-size: 0.9em;
margin-right: 4px;
}
.speed-btn {
padding: 5px 12px;
margin: 0;
font-size: 0.85em;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.speed-btn:hover {
background: var(--bg-tertiary-hover);
}
.speed-btn.active {
background: var(--accent);
color: white;
}
.content {
margin: 20px 0;
}
.outline-section {
margin: 20px 0;
}
.section-lessons {
margin: 20px 0;
background: var(--bg-tertiary);
border-radius: var(--radius);
}
.section-lessons-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
cursor: pointer;
font-weight: 600;
}
.section-lessons-count {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85em;
}
.section-lessons-list {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
}
.section-lessons-list.expanded {
max-height: 2000px;
}
.section-lesson-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-top: 1px solid var(--border-color);
text-decoration: none;
color: var(--text-primary);
transition: background 0.2s;
}
a.section-lesson-row:hover {
background: var(--bg-tertiary-hover);
}
.section-lesson-row.current {
background: var(--bg-tertiary-hover);
font-weight: 600;
cursor: default;
}
.section-lesson-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-lesson-status {
font-size: 0.8em;
color: var(--text-muted);
flex-shrink: 0;
}
.section-lesson-status.completed {
color: var(--success);
}
.note-status {
display: block;
font-size: 0.85em;
color: var(--text-muted);
margin-top: 6px;
min-height: 1.2em;
}
.page-content .container {
padding-bottom: 90px;
}
.notes-panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9000;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.3);
}
.notes-panel-list {
max-height: 0;
overflow-y: auto;
transition: max-height 0.25s ease;
}
.notes-panel-list.expanded {
max-height: 40vh;
border-bottom: 1px solid var(--border-color);
}
.notes-list {
padding: 10px 16px;
}
.notes-empty-hint {
color: var(--text-muted);
font-size: 0.9em;
margin: 6px 0;
}
.note-entry {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.note-entry:last-child {
border-bottom: none;
}
.note-timestamp-badge {
flex-shrink: 0;
background: var(--bg-tertiary);
color: var(--accent);
border: none;
border-radius: var(--radius);
padding: 2px 8px;
margin: 0;
font-size: 0.8em;
font-family: var(--font-family);
}
.note-timestamp-badge:hover {
background: var(--bg-tertiary-hover);
}
.note-entry-text {
flex: 1;
min-width: 0;
word-break: break-word;
white-space: pre-wrap;
font-size: 0.95em;
padding-top: 3px;
}
.note-edit-textarea {
flex: 1;
min-width: 0;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 6px 8px;
font-family: var(--font-family);
font-size: 0.95em;
resize: vertical;
}
.note-entry-edit,
.note-entry-delete {
flex-shrink: 0;
background: transparent;
color: var(--text-muted);
padding: 2px 6px;
margin: 0;
font-size: 0.9em;
}
.note-entry-edit:hover,
.note-entry-delete:hover {
background: var(--bg-tertiary-hover);
color: var(--text-primary);
}
.notes-panel-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 16px;
}
.notes-panel-toggle {
background: transparent;
color: var(--text-primary);
padding: 6px 10px;
margin: 0;
font-size: 0.9em;
flex-shrink: 0;
}
.notes-panel-toggle:hover {
background: var(--bg-tertiary-hover);
}
.note-quick-timestamp {
flex-shrink: 0;
color: var(--text-muted);
font-size: 0.85em;
font-family: monospace;
min-width: 2.5em;
}
.note-quick-input {
flex: 1;
min-width: 120px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 10px;
font-family: var(--font-family);
font-size: 0.95em;
}
.note-quick-save {
flex-shrink: 0;
padding: 8px 16px;
margin: 0;
font-size: 0.9em;
}
.note-saved-row {
padding: 0 16px calc(6px + env(safe-area-inset-bottom, 0px));
min-height: 1.2em;
}
.note-saved-indicator {
font-size: 0.8em;
color: var(--text-muted);
}
.outline-topic-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.outline-topic-row label {
font-size: 0.9em;
color: var(--text-muted);
}
.outline-topic-row select,
.outline-new-topic-input {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 0.9em;
font-family: var(--font-family);
}
.outline-new-topic-input {
flex: 1;
min-width: 140px;
}
.navigation {
margin: 20px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
transition: background 0.3s;
}
button:hover {
background: var(--accent-hover);
}
button:disabled {
background: #666;
cursor: not-allowed;
}
.file-link {
display: block;
margin: 5px 0;
padding: 10px;
background: var(--bg-tertiary);
text-decoration: none;
color: var(--text-primary);
border-radius: var(--radius);
transition: background 0.3s;
}
.file-link:hover {
background: var(--bg-tertiary-hover);
}
.lesson-path {
background: var(--bg-tertiary);
padding: 10px;
border-radius: var(--radius);
margin-bottom: 20px;
font-family: monospace;
color: var(--text-muted);
}
.lesson-title {
color: var(--accent);
margin-bottom: 20px;
}
.text-content {
background: var(--bg-tertiary);
padding: 20px;
border-radius: var(--radius);
margin: 20px 0;
}
.text-content.plain-text {
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: var(--radius);
}
.nav-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.nav-buttons a {
text-decoration: none;
color: inherit;
}
</style>
</head>
<body>
<div class="app-header">
<div class="container">
<svg class="brand-mark" viewBox="0 0 64 64" width="28" height="28" aria-hidden="true">
<rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect>
<path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path>
</svg>
<a href="/reset_course" class="brand-link">OfflineU</a>
</div>
</div>
<div class="page-content">
<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: var(--accent);">← 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>
<p class="resize-hint">↘ Drag the bottom-right corner to resize the player</p>
{% 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.video_file or lesson.audio_file %}
<div class="speed-controls" id="speed-controls">
<span class="speed-label">Speed:</span>
{% for speed in ['0.75', '1', '1.25', '1.5', '1.75', '2'] %}
<button type="button" class="speed-btn" data-speed="{{ speed }}" onclick="setPlaybackSpeed('{{ speed }}')">{{ speed }}x</button>
{% endfor %}
</div>
{% 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: var(--text-muted);">Loading content...</div>
</div>
<a href="/files/{{ text_file|replace('\\', '/') }}" class="file-link" target="_blank">
{{ icons.icon('file-text', 14) }} Open {{ text_file.split('/')[-1] }} in new tab
</a>
</div>
{% endfor %}
{% endif %}
</div>
{% if section_lessons and section_lessons|length > 1 %}
<div class="section-lessons">
<div class="section-lessons-header" onclick="toggleSectionLessons()">
<span id="section-lessons-arrow"></span>
<span>Lessons in this section</span>
<span class="section-lessons-count">({{ section_lessons|length }})</span>
</div>
<div class="section-lessons-list expanded" id="section-lessons-list-wrap">
{% for item in section_lessons %}
{% if item.is_current %}
<span class="section-lesson-row current">
{% else %}
<a class="section-lesson-row" href="/lesson/{{ item.url }}">
{% endif %}
<span class="section-lesson-icon">
{% if item.lesson_type == 'video' %}{{ icons.icon('video', 16) }}
{% elif item.lesson_type == 'audio' %}{{ icons.icon('music', 16) }}
{% elif item.lesson_type == 'quiz' %}{{ icons.icon('clipboard', 16) }}
{% elif item.lesson_type == 'mixed' %}{{ icons.icon('package', 16) }}
{% else %}{{ icons.icon('file-text', 16) }}{% endif %}
</span>
<span class="section-lesson-title">{{ item.title }}</span>
{% if item.completed %}
<span class="section-lesson-status completed">{{ icons.icon('check', 14) }}</span>
{% elif item.percent_watched %}
<span class="section-lesson-status">{{ item.percent_watched }}%</span>
{% endif %}
{% if item.is_current %}
</span>
{% else %}
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<div class="outline-section">
<h3>Push to Outline</h3>
<div class="outline-topic-row">
<label for="outline-topic-select">Push notes to Outline topic</label>
<select id="outline-topic-select" onchange="handleOutlineTopicChange()">
<option value="">— Don't push —</option>
<option value="__new__">+ New topic…</option>
</select>
<input type="text" id="outline-new-topic-input" class="outline-new-topic-input"
placeholder="New topic name" style="display: none;">
</div>
<span id="outline-topic-status" class="note-status"></span>
</div>
<div class="notes-panel" id="notes-panel">
<div class="notes-panel-list" id="notes-panel-list-wrap">
<div class="notes-list" id="notes-list"></div>
</div>
<div class="notes-panel-bar">
<button type="button" class="notes-panel-toggle" id="notes-panel-toggle" onclick="toggleNotesPanel()">
<span id="notes-panel-toggle-arrow"></span> Notes <span id="notes-panel-count"></span>
</button>
<span class="note-quick-timestamp" id="quick-note-timestamp"></span>
<input type="text" id="quick-note-input" class="note-quick-input" placeholder="Add a note… (press N)">
<button type="button" class="note-quick-save" onclick="submitQuickNote()">Save</button>
</div>
<div class="note-saved-row"><span class="note-saved-indicator" id="note-saved-indicator"></span></div>
</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>
// Client-rendered mirror of a few templates/_icons.html entries -
// JS template literals can't call the Jinja macro, so the icons
// built dynamically here are kept in sync with the same path
// data by hand.
const ICON_SVGS = {
pencil: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></svg>',
trash: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></svg>',
};
// 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') || filePath.toLowerCase().endsWith('.htm');
const isPdfFile = filePath.toLowerCase().endsWith('.pdf');
const isBinaryDoc = ['.docx', '.doc', '.rtf'].some(ext => filePath.toLowerCase().endsWith(ext));
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 if (isPdfFile) {
// For PDFs, embed via iframe rather than fetching as text -
// browsers render PDFs natively in an iframe, but fetching
// the raw bytes as text (like below) produces garbage.
console.log('Embedding PDF in iframe');
contentDiv.innerHTML = `
<iframe
src="/files/${encodeURIComponent(filePath)}"
style="width: 100%; height: 85vh; border: none; border-radius: 5px; background: #fff;"
title="${filePath.split('/').pop()}"
></iframe>
`;
} else if (isBinaryDoc) {
// .docx/.doc/.rtf can't be rendered inline by the browser;
// fetching them as text would show garbled binary content,
// so just point at the download/open link instead.
contentDiv.innerHTML =
'<div style="color: var(--text-muted);">Preview isn\'t available for this file type. ' +
'Use the "Open in new tab" link below to view or download it.</div>';
} 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) {
contentDiv.classList.add('plain-text');
// 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: var(--error);">Error loading content: ' + error.message + '</div>' +
'<div style="color: var(--text-muted); 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;
// Restore last-used video player size, and persist future resizes
// (dragging the native corner handle) so it sticks next time.
if (video) {
let applyingStoredSize = true;
let resizeSaveTimeout = null;
fetch('/api/settings')
.then(r => r.json())
.then(data => {
const s = data.settings || {};
if (s.video_width && s.video_height) {
video.style.width = s.video_width + 'px';
video.style.height = s.video_height + 'px';
}
setTimeout(() => { applyingStoredSize = false; }, 300);
})
.catch(() => { applyingStoredSize = false; });
// Only persist a size when the user is actually dragging the
// corner handle - a bare ResizeObserver fires on ANY rendered
// size change, including the page's own responsive
// max-width/max-height clamping on window/viewport resize,
// which isn't a user resize at all and would otherwise get
// saved right back as if it were.
let userResizing = false;
video.addEventListener('mousedown', () => { userResizing = true; });
window.addEventListener('mouseup', () => {
setTimeout(() => { userResizing = false; }, 200);
});
if (window.ResizeObserver) {
const observer = new ResizeObserver(() => {
if (applyingStoredSize || !userResizing) return;
clearTimeout(resizeSaveTimeout);
resizeSaveTimeout = setTimeout(() => {
const rect = video.getBoundingClientRect();
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_width: Math.round(rect.width),
video_height: Math.round(rect.height)
})
}).catch(() => {});
}, 500);
});
observer.observe(video);
}
}
// Restore the persisted playback speed and highlight the active
// button; clicking a button applies it immediately and persists
// it via /api/settings, mirroring the video-size pattern above.
if (activeMedia) {
fetch('/api/settings')
.then(r => r.json())
.then(data => {
const speed = (data.settings || {}).playback_speed || '1';
activeMedia.playbackRate = parseFloat(speed);
highlightSpeedButton(speed);
})
.catch(() => {});
}
function highlightSpeedButton(speed) {
document.querySelectorAll('.speed-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.speed === speed);
});
}
function setPlaybackSpeed(speed) {
if (activeMedia) activeMedia.playbackRate = parseFloat(speed);
highlightSpeedButton(speed);
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playback_speed: speed })
}).catch(() => {});
}
// ---- Outline topic chooser + push-on-leave ----
const LESSON_PATH = {{ lesson_path|tojson }};
const LESSON_TITLE = {{ lesson.title|tojson }};
const INITIAL_TOPIC_ID = {{ outline_topic_id|tojson }};
const INITIAL_TOPIC_NAME = {{ outline_topic_name|tojson }};
// ---- Timestamped notes: floating panel, quick capture, live "Saved Xs ago" ----
const LESSON_NOTES_INITIAL = {{ lesson_notes|tojson }};
const INITIAL_SEEK_SECONDS = {{ initial_seek_seconds|tojson }};
let lessonNotes = LESSON_NOTES_INITIAL.slice();
let lastSavedAt = null;
let savedTickInterval = null;
const notesList = document.getElementById('notes-list');
const notesPanelCount = document.getElementById('notes-panel-count');
const notesPanelListWrap = document.getElementById('notes-panel-list-wrap');
const notesPanelToggleArrow = document.getElementById('notes-panel-toggle-arrow');
const quickNoteInput = document.getElementById('quick-note-input');
const quickNoteTimestamp = document.getElementById('quick-note-timestamp');
const noteSavedIndicator = document.getElementById('note-saved-indicator');
function formatTimestamp(seconds) {
if (seconds === null || seconds === undefined) return null;
seconds = Math.floor(seconds);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function renderNotes() {
if (!notesList) return;
if (notesPanelCount) notesPanelCount.textContent = lessonNotes.length ? `(${lessonNotes.length})` : '';
if (!lessonNotes.length) {
notesList.innerHTML = '<p class="notes-empty-hint">No notes yet — capture one below.</p>';
return;
}
const sorted = lessonNotes.slice().sort(function(a, b) {
const at = (a.timestamp_seconds === null || a.timestamp_seconds === undefined) ? Infinity : a.timestamp_seconds;
const bt = (b.timestamp_seconds === null || b.timestamp_seconds === undefined) ? Infinity : b.timestamp_seconds;
return at - bt;
});
notesList.innerHTML = sorted.map(function(n) {
const label = formatTimestamp(n.timestamp_seconds);
const badge = label
? `<button type="button" class="note-timestamp-badge" onclick="seekTo(${n.timestamp_seconds})">${label}</button>`
: '';
return `<div class="note-entry" data-note-id="${n.id}">
${badge}
<span class="note-entry-text">${escapeHtml(n.text)}</span>
<button type="button" class="note-entry-edit" onclick="startEditNote('${n.id}')" title="Edit">${ICON_SVGS.pencil}</button>
<button type="button" class="note-entry-delete" onclick="deleteNoteEntry('${n.id}')" title="Delete">${ICON_SVGS.trash}</button>
</div>`;
}).join('');
}
function seekTo(seconds) {
if (!activeMedia) return;
activeMedia.currentTime = seconds;
activeMedia.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function startEditNote(id) {
const note = lessonNotes.find(function(n) { return n.id === id; });
const entryEl = document.querySelector(`.note-entry[data-note-id="${id}"]`);
if (!note || !entryEl) return;
const textEl = entryEl.querySelector('.note-entry-text');
const textarea = document.createElement('textarea');
textarea.className = 'note-edit-textarea';
textarea.rows = 2;
textarea.value = note.text;
textEl.replaceWith(textarea);
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
function commit() {
const val = textarea.value.trim();
if (val && val !== note.text) {
updateNoteEntry(id, val);
} else {
renderNotes();
}
}
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { e.preventDefault(); renderNotes(); }
});
textarea.addEventListener('blur', commit);
}
function addNoteEntry(text, timestampSeconds) {
fetch('/api/lesson-note/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: LESSON_PATH, text: text, timestamp_seconds: timestampSeconds })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lessonNotes = data.notes;
renderNotes();
markSaved();
} else if (noteSavedIndicator) {
noteSavedIndicator.textContent = 'Could not save note';
}
})
.catch(() => { if (noteSavedIndicator) noteSavedIndicator.textContent = 'Could not save note'; });
}
function updateNoteEntry(id, text) {
fetch('/api/lesson-note/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: LESSON_PATH, note_id: id, text: text })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lessonNotes = data.notes;
renderNotes();
markSaved();
}
})
.catch(() => {});
}
function deleteNoteEntry(id) {
fetch('/api/lesson-note/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: LESSON_PATH, note_id: id })
})
.then(r => r.json())
.then(data => {
if (data.success) {
lessonNotes = data.notes;
renderNotes();
markSaved();
}
})
.catch(() => {});
}
function markSaved() {
lastSavedAt = Date.now();
updateSavedLabel();
if (savedTickInterval) clearInterval(savedTickInterval);
savedTickInterval = setInterval(updateSavedLabel, 1000);
}
function updateSavedLabel() {
if (!noteSavedIndicator || !lastSavedAt) return;
const secs = Math.floor((Date.now() - lastSavedAt) / 1000);
let text;
if (secs < 2) text = 'Saved just now';
else if (secs < 60) text = `Saved ${secs}s ago`;
else if (secs < 3600) text = `Saved ${Math.floor(secs / 60)}m ago`;
else text = `Saved ${Math.floor(secs / 3600)}h ago`;
noteSavedIndicator.textContent = text;
}
function submitQuickNote() {
if (!quickNoteInput) return;
const text = quickNoteInput.value.trim();
if (!text) return;
const ts = activeMedia ? Math.floor(activeMedia.currentTime) : null;
addNoteEntry(text, ts);
quickNoteInput.value = '';
}
if (quickNoteInput) {
quickNoteInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); submitQuickNote(); }
if (e.key === 'Escape') { e.preventDefault(); quickNoteInput.blur(); }
});
}
function updateQuickCaptureBadge() {
if (!quickNoteTimestamp) return;
if (activeMedia) {
quickNoteTimestamp.textContent = formatTimestamp(activeMedia.currentTime);
quickNoteTimestamp.style.display = '';
} else {
quickNoteTimestamp.style.display = 'none';
}
}
updateQuickCaptureBadge();
if (activeMedia) {
activeMedia.addEventListener('timeupdate', updateQuickCaptureBadge);
}
function toggleNotesPanel() {
if (!notesPanelListWrap) return;
const expanded = notesPanelListWrap.classList.toggle('expanded');
if (notesPanelToggleArrow) notesPanelToggleArrow.textContent = expanded ? '▴' : '▾';
localStorage.setItem('offlineu-notes-panel-expanded', expanded ? '1' : '0');
}
if (notesPanelListWrap && localStorage.getItem('offlineu-notes-panel-expanded') === '1') {
notesPanelListWrap.classList.add('expanded');
if (notesPanelToggleArrow) notesPanelToggleArrow.textContent = '▴';
}
// "Lessons in this section" defaults open (unlike notes, this
// is primary navigation) - only collapse it if the user
// explicitly did so on a previous visit.
function toggleSectionLessons() {
const listWrap = document.getElementById('section-lessons-list-wrap');
const arrow = document.getElementById('section-lessons-arrow');
if (!listWrap) return;
const expanded = listWrap.classList.toggle('expanded');
if (arrow) arrow.textContent = expanded ? '▾' : '▸';
localStorage.setItem('offlineu-section-lessons-expanded', expanded ? '1' : '0');
}
(function() {
const listWrap = document.getElementById('section-lessons-list-wrap');
const arrow = document.getElementById('section-lessons-arrow');
if (listWrap && localStorage.getItem('offlineu-section-lessons-expanded') === '0') {
listWrap.classList.remove('expanded');
if (arrow) arrow.textContent = '▸';
}
})();
function openQuickCapture() {
if (activeMedia && !activeMedia.paused) activeMedia.pause();
updateQuickCaptureBadge();
if (quickNoteInput) quickNoteInput.focus();
}
renderNotes();
// Seek once metadata is available (activeMedia.duration is NaN
// before that, so doing this synchronously on script load - as
// this used to - silently never applied). An explicit jump to
// a note's timestamp (?t=, see INITIAL_SEEK_SECONDS) takes
// priority over resuming general playback progress.
const savedProgressSeconds = {{ lesson_progress_seconds|default(0) }};
function applyInitialSeek() {
if (INITIAL_SEEK_SECONDS !== null && INITIAL_SEEK_SECONDS !== undefined) {
activeMedia.currentTime = INITIAL_SEEK_SECONDS;
} else if (savedProgressSeconds > 0 && savedProgressSeconds < activeMedia.duration - 30) {
activeMedia.currentTime = savedProgressSeconds;
showNotification(`Resumed from ${Math.floor(savedProgressSeconds / 60)}:${String(Math.floor(savedProgressSeconds % 60)).padStart(2, '0')}`);
}
}
if (activeMedia) {
if (activeMedia.readyState >= 1) {
applyInitialSeek();
} else {
activeMedia.addEventListener('loadedmetadata', applyInitialSeek, { once: true });
}
}
const topicSelect = document.getElementById('outline-topic-select');
const newTopicInput = document.getElementById('outline-new-topic-input');
const topicStatus = document.getElementById('outline-topic-status');
if (topicSelect) {
fetch('/api/outline/topics')
.then(r => r.json())
.then(data => {
const newOption = topicSelect.querySelector('option[value="__new__"]');
(data.topics || []).forEach(function(c) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
topicSelect.insertBefore(opt, newOption);
});
if (INITIAL_TOPIC_ID) {
topicSelect.value = INITIAL_TOPIC_ID;
} else if (INITIAL_TOPIC_NAME) {
topicSelect.value = '__new__';
newTopicInput.style.display = 'inline-block';
newTopicInput.value = INITIAL_TOPIC_NAME;
}
if (data.error && topicStatus) {
topicStatus.textContent = data.error;
}
})
.catch(() => {});
// Resolve a freshly-typed topic name to a real collection
// id immediately (find-or-create), rather than saving it as
// a pending name for the fire-and-forget pagehide push to
// deal with later - a page that fires pagehide more than
// once for the same view (e.g. restored from the browser's
// back/forward cache) would otherwise re-send the same "new
// topic" intent every time and create a duplicate
// collection per navigation.
newTopicInput.addEventListener('blur', function() {
const name = this.value.trim();
if (!name) return;
topicStatus.textContent = 'Creating topic…';
fetch('/api/outline/resolve-topic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
})
.then(r => r.json())
.then(data => {
if (!data.success) {
topicStatus.textContent = data.error || 'Could not create topic';
return;
}
const newOption = topicSelect.querySelector('option[value="__new__"]');
const opt = document.createElement('option');
opt.value = data.id;
opt.textContent = data.name;
topicSelect.insertBefore(opt, newOption);
topicSelect.value = data.id;
newTopicInput.style.display = 'none';
newTopicInput.value = '';
topicStatus.textContent = '';
saveOutlineTopicChoice(data.id, '');
})
.catch(() => {
topicStatus.textContent = 'Could not reach the server';
});
});
}
function handleOutlineTopicChange() {
if (topicSelect.value === '__new__') {
newTopicInput.style.display = 'inline-block';
newTopicInput.focus();
return; // wait for a name before resolving
}
newTopicInput.style.display = 'none';
newTopicInput.value = '';
saveOutlineTopicChoice(topicSelect.value, '');
}
function saveOutlineTopicChoice(topicId, topicName) {
fetch('/api/lesson-note/topic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_path: LESSON_PATH, topic_id: topicId, topic_name: topicName })
}).catch(() => {});
}
// Fire the push via sendBeacon on pagehide - fires on any real
// navigation away (Previous/Next/Back-to-Course, browser back,
// closing the tab) and is far more likely to actually complete
// than a beforeunload-triggered fetch. No result to show the
// user either way, since the page is already gone by the time
// it lands.
window.addEventListener('pagehide', function() {
if (!topicSelect) return;
const isNew = topicSelect.value === '__new__';
const topicId = isNew ? '' : topicSelect.value;
const newTopicName = isNew ? newTopicInput.value.trim() : '';
if (!topicId && !newTopicName) return;
const payload = JSON.stringify({
lesson_path: LESSON_PATH,
lesson_title: LESSON_TITLE,
topic_id: topicId,
new_topic_name: newTopicName
});
navigator.sendBeacon('/api/outline/push', new Blob([payload], { type: 'application/json' }));
});
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();
});
}
function saveProgress(progressSeconds, completed = false) {
const duration = activeMedia && isFinite(activeMedia.duration) ? Math.floor(activeMedia.duration) : null;
fetch('/api/progress', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
lesson_path: '{{ lesson_path }}',
completed: completed,
progress_seconds: Math.floor(progressSeconds),
duration_seconds: duration
})
}).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 = 'var(--success)';
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' ? 'var(--success)' : 'var(--accent)'};
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 - skip while typing in the note (or any
// other text field), so space/arrow keys type normally instead
// of hijacking video playback.
function isTypingTarget(el) {
if (!el) return false;
const tag = el.tagName;
return tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT' || el.isContentEditable;
}
document.addEventListener('keydown', function(e) {
if (isTypingTarget(e.target)) return;
if ((e.key === 'n' || e.key === 'N') && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
openQuickCapture();
return;
}
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>
</div>
<footer class="app-footer">
<div class="container-inner">
<div class="footer-brand">
<svg class="icon" viewBox="0 0 64 64" width="16" height="16" aria-hidden="true"><rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect><path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path></svg>
<a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/notes">{{ icons.icon('pencil', 14) }} Notes</a>
<a href="/help">{{ icons.icon('help', 14) }} Help</a>
<a href="/settings">{{ icons.icon('settings', 14) }} Settings</a>
</div>
</div>
</footer>
<script src="/static/theme.js"></script>
</body>
</html>