- Lesson page's global keyboard shortcuts (space/arrows for play-pause/ seek/volume) fired regardless of focus, so typing a space in the notes textarea toggled the video instead of typing. Now skipped entirely whenever a text field/select has focus. - Outline topics no longer spawn their own top-level collection each time. A topic is now a document inside one fixed, user-configured collection (Settings -> Outline Integration -> Default collection), and a lesson's note becomes a child document nested under its topic - matching how notes actually get organized in a real Outline instance instead of cluttering the collections list with one per topic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1103 lines
45 KiB
HTML
1103 lines
45 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Settings - OfflineU</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;
|
||
}
|
||
[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-card-style="elevated"] .card {
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||
}
|
||
[data-card-style="bordered"] .card {
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
font-family: var(--font-family);
|
||
font-size: var(--font-size-base);
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
margin: 0;
|
||
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 .header-inner {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.app-header .brand-icon {
|
||
font-size: 1.6em;
|
||
}
|
||
.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 .footer-inner {
|
||
max-width: 800px;
|
||
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(--accent);
|
||
text-decoration: none;
|
||
font-size: 0.9em;
|
||
}
|
||
.container {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
}
|
||
h1 {
|
||
color: var(--accent);
|
||
margin-bottom: 5px;
|
||
}
|
||
.subtitle {
|
||
color: var(--text-muted);
|
||
margin-bottom: 25px;
|
||
}
|
||
.card {
|
||
background: var(--bg-secondary);
|
||
border-radius: var(--radius);
|
||
padding: 20px 25px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.card h2 {
|
||
font-size: 1.1em;
|
||
margin-bottom: 15px;
|
||
color: var(--text-primary);
|
||
}
|
||
.setting-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--bg-tertiary);
|
||
gap: 20px;
|
||
}
|
||
.setting-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.setting-row label {
|
||
flex: 1;
|
||
}
|
||
.setting-row .setting-desc {
|
||
display: block;
|
||
font-size: 0.85em;
|
||
color: var(--text-muted);
|
||
margin-top: 2px;
|
||
}
|
||
select {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border-color);
|
||
padding: 8px 12px;
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
min-width: 160px;
|
||
}
|
||
input[type="color"] {
|
||
width: 44px;
|
||
height: 34px;
|
||
padding: 0;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
background: none;
|
||
cursor: pointer;
|
||
}
|
||
.color-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
input[type="text"].hex-input {
|
||
width: 90px;
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--border-color);
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius);
|
||
font-family: monospace;
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 10px;
|
||
}
|
||
.btn {
|
||
background: var(--accent);
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 20px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
font-size: 14px;
|
||
transition: background 0.3s;
|
||
}
|
||
.btn:hover { background: var(--accent-hover); }
|
||
.btn-secondary {
|
||
background: var(--bg-tertiary);
|
||
color: var(--text-primary);
|
||
}
|
||
.btn-secondary:hover { background: var(--bg-tertiary-hover); }
|
||
.btn-sm {
|
||
padding: 5px 12px;
|
||
font-size: 0.85em;
|
||
}
|
||
.curate-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius);
|
||
background: var(--bg-tertiary);
|
||
margin-bottom: 5px;
|
||
cursor: pointer;
|
||
}
|
||
.curate-row:hover {
|
||
background: var(--bg-tertiary-hover);
|
||
}
|
||
.curate-row.is-hidden {
|
||
opacity: 0.55;
|
||
}
|
||
.curate-row-name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.curate-row-name span.name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.curate-toggle {
|
||
font-size: 1.1em;
|
||
width: 18px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.curate-children {
|
||
margin-left: 22px;
|
||
margin-top: 4px;
|
||
display: none;
|
||
}
|
||
.curate-children.expanded {
|
||
display: block;
|
||
}
|
||
.curate-empty-hint {
|
||
color: var(--text-muted);
|
||
font-size: 0.9em;
|
||
padding: 6px 0;
|
||
}
|
||
.curate-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
.curate-rename-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
border: 1px solid var(--accent);
|
||
border-radius: var(--radius);
|
||
padding: 4px 8px;
|
||
font-size: 0.95em;
|
||
font-family: var(--font-family);
|
||
}
|
||
.curate-row-status {
|
||
flex-basis: 100%;
|
||
font-size: 0.8em;
|
||
color: #ff6b6b;
|
||
}
|
||
.bulk-rename-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius);
|
||
background: var(--bg-tertiary);
|
||
margin-bottom: 5px;
|
||
}
|
||
.bulk-rename-row-names {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-wrap: break-word;
|
||
word-break: break-word;
|
||
}
|
||
.bulk-rename-old {
|
||
color: var(--text-muted);
|
||
font-size: 0.9em;
|
||
text-decoration: line-through;
|
||
}
|
||
.bulk-rename-new {
|
||
color: var(--text-primary);
|
||
}
|
||
#save-status {
|
||
font-size: 0.9em;
|
||
color: #28a745;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
#save-status.visible { opacity: 1; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-header">
|
||
<div class="header-inner">
|
||
<span class="brand-icon">📚</span>
|
||
<a href="/reset_course" class="brand-link">OfflineU</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page-content">
|
||
<div class="container">
|
||
<h1 style="margin-top: 10px;">Settings</h1>
|
||
<p class="subtitle">Changes save and apply immediately across the app.</p>
|
||
|
||
<div class="card">
|
||
<h2>Appearance</h2>
|
||
<div class="setting-row">
|
||
<label for="theme">Theme
|
||
<span class="setting-desc">Overall light/dark palette</span>
|
||
</label>
|
||
<select id="theme" data-setting="theme">
|
||
{% for choice in theme_choices %}
|
||
<option value="{{ choice }}" {% if settings.theme == choice %}selected{% endif %}>{{ theme_display_names[choice] }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="setting-row">
|
||
<label for="accent-hex">Accent color
|
||
<span class="setting-desc">Used for links, buttons, and highlights</span>
|
||
</label>
|
||
<div class="color-row">
|
||
<input type="color" id="accent-picker" value="{{ settings.accent_color }}">
|
||
<input type="text" id="accent-hex" class="hex-input" value="{{ settings.accent_color }}" maxlength="7">
|
||
</div>
|
||
</div>
|
||
<div class="setting-row">
|
||
<label for="corner_radius">Corner style
|
||
<span class="setting-desc">Sharp, rounded, or pill-shaped elements</span>
|
||
</label>
|
||
<select id="corner_radius" data-setting="corner_radius">
|
||
{% for choice in corner_radius_choices %}
|
||
<option value="{{ choice }}" {% if settings.corner_radius == choice %}selected{% endif %}>{{ choice|capitalize }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="setting-row">
|
||
<label for="card_style">Card style
|
||
<span class="setting-desc">Flat, elevated with shadow, or bordered</span>
|
||
</label>
|
||
<select id="card_style" data-setting="card_style">
|
||
{% for choice in card_style_choices %}
|
||
<option value="{{ choice }}" {% if settings.card_style == choice %}selected{% endif %}>{{ choice|capitalize }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Typography</h2>
|
||
<div class="setting-row">
|
||
<label for="font_family">Font
|
||
<span class="setting-desc">Typeface used across the app</span>
|
||
</label>
|
||
<select id="font_family" data-setting="font_family">
|
||
{% for choice in font_family_choices %}
|
||
<option value="{{ choice }}" {% if settings.font_family == choice %}selected{% endif %}>{{ 'Monaspace' if choice == 'monaspace' else choice|capitalize }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="setting-row">
|
||
<label for="font_size">Text size
|
||
<span class="setting-desc">Base font size</span>
|
||
</label>
|
||
<select id="font_size" data-setting="font_size">
|
||
{% for choice in font_size_choices %}
|
||
<option value="{{ choice }}" {% if settings.font_size == choice %}selected{% endif %}>{{ choice|capitalize }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Layout</h2>
|
||
<div class="setting-row">
|
||
<label for="layout_width">Page width
|
||
<span class="setting-desc">How much of the browser window content uses</span>
|
||
</label>
|
||
<select id="layout_width" data-setting="layout_width">
|
||
{% for choice in layout_width_choices %}
|
||
<option value="{{ choice }}" {% if settings.layout_width == choice %}selected{% endif %}>{{ choice|capitalize }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="setting-row">
|
||
<label for="density">Density
|
||
<span class="setting-desc">Spacing and padding throughout the app</span>
|
||
</label>
|
||
<select id="density" data-setting="density">
|
||
{% for choice in density_choices %}
|
||
<option value="{{ choice }}" {% if settings.density == choice %}selected{% endif %}>{{ choice|capitalize }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Library</h2>
|
||
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||
<label for="library_path">Default courses directory
|
||
<span class="setting-desc">Where the Library browser starts. Leave blank to use the server default ({{ default_library_path }}).</span>
|
||
</label>
|
||
<div style="display: flex; gap: 10px;">
|
||
<input type="text" id="library_path" class="hex-input" style="flex: 1; width: auto; font-family: var(--font-family);"
|
||
placeholder="{{ default_library_path }}" value="{{ settings.library_path }}">
|
||
<button class="btn" onclick="saveLibraryPath()">Save</button>
|
||
</div>
|
||
<span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Manage Library</h2>
|
||
<p class="setting-desc" style="margin-bottom: 12px;">
|
||
Hide courses or whole folders from the Library browser without touching anything on disk
|
||
(hiding a folder hides everything inside it), or rename a course/folder directly - no need
|
||
to go to the NAS.
|
||
</p>
|
||
|
||
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
|
||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
|
||
<div id="hidden-list"></div>
|
||
</div>
|
||
|
||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
|
||
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
|
||
<div id="curate-tree"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Bulk Rename</h2>
|
||
<p class="setting-desc" style="margin-bottom: 12px;">
|
||
Find and replace text across every course/folder name in the library at once - handy for
|
||
stripping a release-group suffix off a batch of downloads. Nothing changes until you apply.
|
||
</p>
|
||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px;">
|
||
<input type="text" id="bulk-rename-find" class="hex-input"
|
||
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
|
||
placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
|
||
<input type="text" id="bulk-rename-replace" class="hex-input"
|
||
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
|
||
placeholder="Replace with (blank to remove)">
|
||
<button class="btn" onclick="previewBulkRename()">Preview</button>
|
||
</div>
|
||
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
|
||
<div id="bulk-rename-preview"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Load a Course Manually</h2>
|
||
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||
<label for="manual-course-path">Course directory path
|
||
<span class="setting-desc">For a course outside your library folder, or if it isn't showing up in the browser for some reason.</span>
|
||
</label>
|
||
<div style="display: flex; gap: 10px;">
|
||
<input type="text" id="manual-course-path" class="hex-input" style="flex: 1; width: auto; font-family: var(--font-family);"
|
||
placeholder="e.g., /app/courses/My Course">
|
||
<button class="btn" onclick="loadCourseFromPath()">Load Course</button>
|
||
</div>
|
||
<span id="manual-course-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Video Player</h2>
|
||
<div class="setting-row">
|
||
<label>Player size
|
||
<span class="setting-desc">
|
||
{% if settings.video_width and settings.video_height %}
|
||
Remembered at {{ settings.video_width }}×{{ settings.video_height }}px from your last resize
|
||
{% else %}
|
||
Using default responsive full-width sizing
|
||
{% endif %}
|
||
</span>
|
||
</label>
|
||
<button class="btn btn-secondary" onclick="resetVideoSize()">Reset size</button>
|
||
</div>
|
||
<div class="setting-row">
|
||
<label for="playback_speed">Default playback speed
|
||
<span class="setting-desc">Applied when a lesson loads; still adjustable per-lesson</span>
|
||
</label>
|
||
<select id="playback_speed" data-setting="playback_speed">
|
||
{% for speed in ['0.75', '1', '1.25', '1.5', '1.75', '2'] %}
|
||
<option value="{{ speed }}" {% if settings.playback_speed == speed %}selected{% endif %}>{{ speed }}x</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Backup & Export</h2>
|
||
<div class="setting-row">
|
||
<label>Download a backup
|
||
<span class="setting-desc">Settings, hidden-path curation, recently-viewed history, and every course's progress/notes - not the course files themselves.</span>
|
||
</label>
|
||
<a class="btn" href="/api/backup">Download Backup</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Outline Integration</h2>
|
||
<p class="setting-desc" style="margin-bottom: 12px;">
|
||
Pick a topic on a lesson's note (on the lesson page) to push it there when you leave the page -
|
||
each topic becomes a document inside the collection below, and the note is nested under it.
|
||
Notes without a topic stay local only.
|
||
</p>
|
||
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||
<label for="outline-url">Outline URL
|
||
<span class="setting-desc" id="outline-config-status">Not configured</span>
|
||
</label>
|
||
<input type="text" id="outline-url" class="hex-input" style="width: 100%; font-family: var(--font-family);"
|
||
placeholder="https://outline.example.com">
|
||
<label for="outline-token" style="margin-top: 4px;">API token</label>
|
||
<input type="password" id="outline-token" class="hex-input" style="width: 100%; font-family: var(--font-family);"
|
||
placeholder="Leave blank to keep the current token">
|
||
<label for="outline-collection" style="margin-top: 4px;">Default collection
|
||
<span class="setting-desc">Where topic documents get created - e.g. "Training Notes"</span>
|
||
</label>
|
||
<select id="outline-collection">
|
||
<option value="">Save your URL/token first, then pick one</option>
|
||
</select>
|
||
<div style="display: flex; gap: 10px; margin-top: 4px;">
|
||
<button class="btn" onclick="saveOutlineConfig()">Save</button>
|
||
<button class="btn btn-secondary" onclick="testOutlineConnection()">Test Connection</button>
|
||
</div>
|
||
<span id="outline-test-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
|
||
<span id="save-status">✓ Saved</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="app-footer">
|
||
<div class="footer-inner">
|
||
<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="/">← Back to app</a>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
<script src="/static/theme.js"></script>
|
||
<script>
|
||
let saveTimeout = null;
|
||
|
||
function showSaved() {
|
||
const status = document.getElementById('save-status');
|
||
status.classList.add('visible');
|
||
clearTimeout(saveTimeout);
|
||
saveTimeout = setTimeout(() => status.classList.remove('visible'), 1500);
|
||
}
|
||
|
||
function saveSetting(key, value) {
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ [key]: value })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success && window.__offlineuApplyTheme) {
|
||
window.__offlineuApplyTheme(data);
|
||
showSaved();
|
||
}
|
||
})
|
||
.catch(err => console.error('Failed to save setting:', err));
|
||
}
|
||
|
||
document.querySelectorAll('select[data-setting]').forEach(el => {
|
||
el.addEventListener('change', () => saveSetting(el.dataset.setting, el.value));
|
||
});
|
||
|
||
const accentPicker = document.getElementById('accent-picker');
|
||
const accentHex = document.getElementById('accent-hex');
|
||
|
||
accentPicker.addEventListener('input', () => {
|
||
accentHex.value = accentPicker.value;
|
||
saveSetting('accent_color', accentPicker.value);
|
||
});
|
||
|
||
accentHex.addEventListener('change', () => {
|
||
let value = accentHex.value.trim();
|
||
if (!value.startsWith('#')) value = '#' + value;
|
||
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
|
||
accentPicker.value = value;
|
||
saveSetting('accent_color', value);
|
||
} else {
|
||
accentHex.value = accentPicker.value; // revert invalid input
|
||
}
|
||
});
|
||
|
||
// ---- Library curation: hide/show courses & folders ----
|
||
function loadHiddenList() {
|
||
fetch('/api/hidden-paths')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const wrap = document.getElementById('hidden-list-wrap');
|
||
const list = document.getElementById('hidden-list');
|
||
const items = data.hidden_paths || [];
|
||
if (items.length === 0) {
|
||
wrap.style.display = 'none';
|
||
return;
|
||
}
|
||
wrap.style.display = 'block';
|
||
list.innerHTML = items.map(item => `
|
||
<div class="curate-row">
|
||
<div class="curate-row-name">
|
||
<span>🚫</span>
|
||
<span class="name">${item.name}</span>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
|
||
</div>
|
||
`).join('');
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function loadCurateLevel(path, container, isRoot) {
|
||
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
|
||
fetch(url)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (isRoot) {
|
||
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
|
||
}
|
||
renderCurateLevel(data, container);
|
||
})
|
||
.catch(() => {
|
||
container.innerHTML = '<p style="color:#ff6b6b;">Could not reach the library scanner.</p>';
|
||
});
|
||
}
|
||
|
||
function renderCurateLevel(data, container) {
|
||
if (!data.items || data.items.length === 0) {
|
||
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
|
||
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
|
||
return;
|
||
}
|
||
container.innerHTML = data.items.map(item => {
|
||
const safePath = item.path.replace(/'/g, "\\'");
|
||
const safeName = item.name.replace(/'/g, "\\'");
|
||
const icon = item.type === 'course' ? '🎓' : '📁';
|
||
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
||
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">✏️</button>`;
|
||
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
|
||
const actions = `<div class="curate-actions">${renameBtn}${toggleBtn}</div>`;
|
||
|
||
if (item.type === 'course') {
|
||
return `
|
||
<div class="curate-row ${hiddenClass}">
|
||
<div class="curate-row-name">
|
||
<span>${icon}</span>
|
||
<span class="name">${item.name}</span>
|
||
</div>
|
||
${actions}
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
|
||
<div class="curate-row-name">
|
||
<span class="curate-toggle">▶</span>
|
||
<span>${icon}</span>
|
||
<span class="name">${item.name}</span>
|
||
</div>
|
||
${actions}
|
||
</div>
|
||
<div class="curate-children" data-loaded="false"></div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleCurateDir(rowEl, path) {
|
||
const content = rowEl.nextElementSibling;
|
||
const toggleIcon = rowEl.querySelector('.curate-toggle');
|
||
if (!content || !content.classList.contains('curate-children')) return;
|
||
|
||
if (content.classList.contains('expanded')) {
|
||
content.classList.remove('expanded');
|
||
if (toggleIcon) toggleIcon.textContent = '▶';
|
||
return;
|
||
}
|
||
content.classList.add('expanded');
|
||
if (toggleIcon) toggleIcon.textContent = '▼';
|
||
|
||
if (content.dataset.loaded === 'true') return;
|
||
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
|
||
content.dataset.loaded = 'true';
|
||
content.dataset.path = path;
|
||
loadCurateLevel(path, content, false);
|
||
}
|
||
|
||
function toggleHidden(path, hidden, btnEl) {
|
||
fetch('/api/hidden-paths', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path, hidden: hidden })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
loadHiddenList();
|
||
// Refresh just the level this toggle happened in, rather
|
||
// than collapsing the whole tree back to root.
|
||
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
|
||
: document.getElementById('curate-tree');
|
||
const isRootContainer = container.id === 'curate-tree';
|
||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||
loadCurateLevel(refreshPath, container, isRootContainer);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// ---- Library curation: rename a course/folder in place on disk ----
|
||
function startRename(btnEl, path, currentName) {
|
||
const row = btnEl.closest('.curate-row');
|
||
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
|
||
if (!row || !nameSpan) return;
|
||
|
||
let settled = false;
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'curate-rename-input';
|
||
input.value = currentName;
|
||
input.onclick = (e) => e.stopPropagation();
|
||
nameSpan.replaceWith(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
function cancel() {
|
||
if (settled) return;
|
||
settled = true;
|
||
input.replaceWith(nameSpan);
|
||
}
|
||
function commit() {
|
||
if (settled) return;
|
||
const newName = input.value.trim();
|
||
if (!newName || newName === currentName) {
|
||
cancel();
|
||
return;
|
||
}
|
||
settled = true;
|
||
submitRename(btnEl, path, newName, row, nameSpan, input);
|
||
}
|
||
input.addEventListener('keydown', (e) => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||
});
|
||
input.addEventListener('blur', commit);
|
||
}
|
||
|
||
function submitRename(btnEl, path, newName, row, nameSpan, input) {
|
||
fetch('/api/rename-path', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path, new_name: newName })
|
||
})
|
||
.then(r => r.json().then(data => ({ ok: r.ok, data })))
|
||
.then(({ ok, data }) => {
|
||
if (!ok || !data.success) {
|
||
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
|
||
showRowError(row, data.error || 'Rename failed');
|
||
return;
|
||
}
|
||
if (data.active_course_reset) {
|
||
alert('That was your active course - reload the main page to pick it up under its new name.');
|
||
}
|
||
loadHiddenList();
|
||
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
|
||
const isRootContainer = container.id === 'curate-tree';
|
||
const refreshPath = isRootContainer ? null : container.dataset.path;
|
||
loadCurateLevel(refreshPath, container, isRootContainer);
|
||
})
|
||
.catch(() => {
|
||
input.replaceWith(nameSpan);
|
||
showRowError(row, 'Could not reach the server');
|
||
});
|
||
}
|
||
|
||
function showRowError(row, message) {
|
||
let status = row.querySelector('.curate-row-status');
|
||
if (!status) {
|
||
status = document.createElement('div');
|
||
status.className = 'curate-row-status';
|
||
row.appendChild(status);
|
||
}
|
||
status.textContent = message;
|
||
}
|
||
|
||
loadHiddenList();
|
||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||
|
||
// ---- Bulk rename: find/replace across every course/folder name ----
|
||
let bulkRenameMatches = [];
|
||
|
||
function previewBulkRename() {
|
||
const pattern = document.getElementById('bulk-rename-find').value;
|
||
const replacement = document.getElementById('bulk-rename-replace').value;
|
||
const status = document.getElementById('bulk-rename-status');
|
||
const preview = document.getElementById('bulk-rename-preview');
|
||
preview.innerHTML = '';
|
||
if (!pattern) {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Enter text to find first.';
|
||
return;
|
||
}
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Searching...';
|
||
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
bulkRenameMatches = data.matches || [];
|
||
renderBulkRenamePreview();
|
||
})
|
||
.catch(() => {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Could not reach the server.';
|
||
});
|
||
}
|
||
|
||
function renderBulkRenamePreview() {
|
||
const status = document.getElementById('bulk-rename-status');
|
||
const preview = document.getElementById('bulk-rename-preview');
|
||
if (bulkRenameMatches.length === 0) {
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'No matches.';
|
||
preview.innerHTML = '';
|
||
return;
|
||
}
|
||
status.textContent = '';
|
||
const rows = bulkRenameMatches.map((m, i) => `
|
||
<div class="bulk-rename-row">
|
||
<div class="bulk-rename-row-names">
|
||
<div class="bulk-rename-old">${m.old_name}</div>
|
||
<div class="bulk-rename-new">→ ${m.new_name}</div>
|
||
</div>
|
||
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
|
||
</div>
|
||
`).join('');
|
||
const count = bulkRenameMatches.length;
|
||
preview.innerHTML = rows +
|
||
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
|
||
}
|
||
|
||
function removeBulkRenameMatch(index) {
|
||
bulkRenameMatches.splice(index, 1);
|
||
renderBulkRenamePreview();
|
||
}
|
||
|
||
function applyBulkRename() {
|
||
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
|
||
const status = document.getElementById('bulk-rename-status');
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Applying...';
|
||
fetch('/api/bulk-rename/apply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ items: bulkRenameMatches })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const results = data.results || [];
|
||
const succeeded = results.filter(r => r.success).length;
|
||
const failed = results.filter(r => !r.success);
|
||
const activeCourseReset = results.some(r => r.active_course_reset);
|
||
|
||
status.style.color = failed.length ? '#ff6b6b' : '#28a745';
|
||
let message = `${succeeded} renamed`;
|
||
if (failed.length) {
|
||
message += `, ${failed.length} failed: ` +
|
||
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
|
||
} else {
|
||
message += '.';
|
||
}
|
||
if (activeCourseReset) {
|
||
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
|
||
}
|
||
status.textContent = message;
|
||
|
||
bulkRenameMatches = [];
|
||
document.getElementById('bulk-rename-preview').innerHTML = '';
|
||
document.getElementById('bulk-rename-find').value = '';
|
||
document.getElementById('bulk-rename-replace').value = '';
|
||
loadHiddenList();
|
||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||
})
|
||
.catch(() => {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Could not reach the server.';
|
||
});
|
||
}
|
||
|
||
function saveLibraryPath() {
|
||
const input = document.getElementById('library_path');
|
||
const status = document.getElementById('library-path-status');
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ library_path: input.value.trim() })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
status.style.color = '#28a745';
|
||
status.textContent = '✓ Saved — reload the main page to see it take effect';
|
||
showSaved();
|
||
} else {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = data.error || 'Could not save';
|
||
}
|
||
})
|
||
.catch(err => {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Could not reach the server';
|
||
});
|
||
}
|
||
|
||
function loadCourseFromPath() {
|
||
const input = document.getElementById('manual-course-path');
|
||
const status = document.getElementById('manual-course-status');
|
||
const coursePath = input.value.trim();
|
||
|
||
if (!coursePath) {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Please enter a course path.';
|
||
return;
|
||
}
|
||
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Loading course...';
|
||
|
||
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) {
|
||
status.style.color = '#28a745';
|
||
status.textContent = `✓ Loaded "${data.course_name}" — redirecting...`;
|
||
setTimeout(() => { window.location.href = '/'; }, 1000);
|
||
} else {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = data.error || 'Could not load course';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Could not reach the server';
|
||
});
|
||
}
|
||
|
||
document.getElementById('manual-course-path').addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') loadCourseFromPath();
|
||
});
|
||
|
||
function resetVideoSize() {
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video_width: '', video_height: '' })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) location.reload();
|
||
})
|
||
.catch(err => console.error('Failed to reset video size:', err));
|
||
}
|
||
|
||
// ---- Outline integration ----
|
||
function loadOutlineConfig() {
|
||
fetch('/api/outline/config')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
document.getElementById('outline-url').value = data.base_url || '';
|
||
document.getElementById('outline-config-status').textContent =
|
||
data.configured ? 'Configured' : 'Not configured';
|
||
loadOutlineCollectionsDropdown(data.default_collection_id || '');
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function loadOutlineCollectionsDropdown(selectedId) {
|
||
const select = document.getElementById('outline-collection');
|
||
fetch('/api/outline/collections')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const collections = data.collections || [];
|
||
if (collections.length === 0) {
|
||
select.innerHTML = '<option value="">No collections found - check your URL/token</option>';
|
||
return;
|
||
}
|
||
select.innerHTML = collections.map(c =>
|
||
`<option value="${c.id}">${c.name}</option>`
|
||
).join('');
|
||
if (selectedId) select.value = selectedId;
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function saveOutlineConfig() {
|
||
const status = document.getElementById('outline-test-status');
|
||
const url = document.getElementById('outline-url').value.trim();
|
||
const token = document.getElementById('outline-token').value.trim();
|
||
const collectionSelect = document.getElementById('outline-collection');
|
||
const collectionId = collectionSelect.value;
|
||
const collectionName = collectionId ? collectionSelect.options[collectionSelect.selectedIndex].text : '';
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Saving...';
|
||
fetch('/api/outline/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
base_url: url,
|
||
api_token: token,
|
||
default_collection_id: collectionId,
|
||
default_collection_name: collectionName
|
||
})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
status.style.color = '#28a745';
|
||
status.textContent = 'Saved.';
|
||
document.getElementById('outline-token').value = '';
|
||
loadOutlineConfig();
|
||
} else {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = data.error || 'Could not save.';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Could not reach the server.';
|
||
});
|
||
}
|
||
|
||
function testOutlineConnection() {
|
||
const status = document.getElementById('outline-test-status');
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Testing...';
|
||
fetch('/api/outline/test')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
status.style.color = data.success ? '#28a745' : '#ff6b6b';
|
||
status.textContent = data.success ? 'Connected.' : (data.error || 'Connection failed.');
|
||
})
|
||
.catch(() => {
|
||
status.style.color = '#ff6b6b';
|
||
status.textContent = 'Could not reach the server.';
|
||
});
|
||
}
|
||
|
||
loadOutlineConfig();
|
||
|
||
function resetSettings() {
|
||
fetch('/api/settings/reset', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
location.reload();
|
||
}
|
||
})
|
||
.catch(err => console.error('Failed to reset settings:', err));
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|