Scans an Unsorted folder at the library root and proposes destinations for new courses by matching keywords against the existing category tree, suggesting new subfolders when nothing matches closely, and flagging items for manual review when nothing matches at all. Review and apply happen on a dedicated /unsorted page linked from Settings; nothing moves until the user confirms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1280 lines
56 KiB
HTML
1280 lines
56 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>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;
|
||
--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-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;
|
||
padding-bottom: 70px;
|
||
}
|
||
.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-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;
|
||
}
|
||
.bottom-tab-bar {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 9000;
|
||
display: flex;
|
||
background: var(--bg-secondary);
|
||
border-top: 1px solid var(--border-color);
|
||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||
}
|
||
.tab-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 8px 4px;
|
||
color: var(--text-muted);
|
||
text-decoration: none;
|
||
font-size: 0.72em;
|
||
transition: color 0.2s;
|
||
}
|
||
.tab-item .tab-icon {
|
||
font-size: 1.3em;
|
||
}
|
||
.tab-item:hover,
|
||
.tab-item.active {
|
||
color: var(--accent);
|
||
}
|
||
.icon {
|
||
display: inline-block;
|
||
vertical-align: -3px;
|
||
flex-shrink: 0;
|
||
}
|
||
.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: var(--error);
|
||
}
|
||
.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: var(--success);
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
#save-status.visible { opacity: 1; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-header">
|
||
<div class="header-inner">
|
||
<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 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 group_label, choices in theme_groups %}
|
||
<optgroup label="{{ group_label }}">
|
||
{% for choice in choices %}
|
||
<option value="{{ choice }}" {% if settings.theme == choice %}selected{% endif %}>{{ theme_display_names[choice] }}</option>
|
||
{% endfor %}
|
||
</optgroup>
|
||
{% 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 style="display: flex; align-items: center; gap: 10px;">
|
||
<button class="btn btn-secondary btn-sm" onclick="refreshLibraryCache()">{{ icons.icon('refresh', 14) }} Refresh Library</button>
|
||
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||
</div>
|
||
<span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span>
|
||
|
||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
|
||
<button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Lengths & Cover Art</button>
|
||
<span id="prewarm-durations-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||
</div>
|
||
<span class="setting-desc">Reads every video/audio file's length and generates cover art for any course without one, up front - so course cards in the Library show their runtime and artwork immediately instead of computing it the first time each course is viewed.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Sort Unsorted Courses</h2>
|
||
<p class="setting-desc" style="margin-bottom: 12px;">
|
||
Drop new courses in an "Unsorted" folder at the root of your library, then use this to
|
||
propose where each one belongs based on your existing folder structure - nothing moves
|
||
until you review and confirm on its own page.
|
||
</p>
|
||
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open Sorter</a>
|
||
</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, the Next Up queue, recently-viewed history, Outline config, and every course's progress/notes - not the course files themselves.</span>
|
||
</label>
|
||
<a class="btn" href="/api/backup">Download Backup</a>
|
||
</div>
|
||
<div class="setting-row">
|
||
<label for="restore-backup-input">Restore from a backup
|
||
<span class="setting-desc">Overwrites current settings and progress with what's in the zip. Course files on disk aren't touched.</span>
|
||
</label>
|
||
<input type="file" id="restore-backup-input" accept=".zip" style="display: none;" onchange="handleRestoreFileChosen(this)">
|
||
<button class="btn btn-secondary" onclick="document.getElementById('restore-backup-input').click()">Choose Backup File…</button>
|
||
</div>
|
||
<span id="restore-backup-status" style="font-size: 0.85em; min-height: 1.2em; display: block;"></span>
|
||
</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">{{ icons.icon('check', 14) }} Saved</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<nav class="bottom-tab-bar">
|
||
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('home', 20) }}</span><span class="tab-label">Home</span>
|
||
</a>
|
||
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('pencil', 20) }}</span><span class="tab-label">Notes</span>
|
||
</a>
|
||
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('help', 20) }}</span><span class="tab-label">Help</span>
|
||
</a>
|
||
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
|
||
<span class="tab-icon">{{ icons.icon('settings', 20) }}</span><span class="tab-label">Settings</span>
|
||
</a>
|
||
</nav>
|
||
|
||
<script src="/static/theme.js"></script>
|
||
<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 = {
|
||
ban: '<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"><circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line></svg>',
|
||
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
|
||
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
|
||
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>',
|
||
check: '<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"><polyline points="20 6 9 17 4 12"></polyline></svg>',
|
||
};
|
||
|
||
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>${ICON_SVGS.ban}</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:var(--error);">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' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
|
||
const hiddenClass = item.hidden ? 'is-hidden' : '';
|
||
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</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 = 'var(--error)';
|
||
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 = 'var(--error)';
|
||
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 ? 'var(--error)' : 'var(--success)';
|
||
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 = 'var(--error)';
|
||
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 = 'var(--success)';
|
||
status.innerHTML = ICON_SVGS.check + ' Saved — reload the main page to see it take effect';
|
||
showSaved();
|
||
} else {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = data.error || 'Could not save';
|
||
}
|
||
})
|
||
.catch(err => {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = 'Could not reach the server';
|
||
});
|
||
}
|
||
|
||
function refreshLibraryCache() {
|
||
const status = document.getElementById('library-refresh-status');
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Refreshing…';
|
||
fetch('/api/library/refresh', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
|
||
status.innerHTML = data.success ? (ICON_SVGS.check + ' Refreshed') : 'Could not refresh';
|
||
if (data.success) setTimeout(() => { status.textContent = ''; }, 3000);
|
||
})
|
||
.catch(() => {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = 'Could not reach the server';
|
||
});
|
||
}
|
||
|
||
function updatePrewarmStatus(data) {
|
||
const btn = document.getElementById('prewarm-durations-btn');
|
||
const status = document.getElementById('prewarm-durations-status');
|
||
if (data.running) {
|
||
btn.disabled = true;
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = `Scanning… ${data.done}/${data.total} courses`;
|
||
return;
|
||
}
|
||
btn.disabled = false;
|
||
if (data.error) {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = `Error: ${data.error}`;
|
||
} else if (data.total) {
|
||
status.style.color = 'var(--success)';
|
||
status.innerHTML = `${ICON_SVGS.check} Scanned ${data.total} course${data.total === 1 ? '' : 's'}`;
|
||
setTimeout(() => { status.textContent = ''; }, 5000);
|
||
}
|
||
}
|
||
|
||
function pollPrewarmStatus() {
|
||
fetch('/api/library/prewarm-durations/status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
updatePrewarmStatus(data);
|
||
if (data.running) setTimeout(pollPrewarmStatus, 1500);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function startDurationPrewarm() {
|
||
const status = document.getElementById('prewarm-durations-status');
|
||
document.getElementById('prewarm-durations-btn').disabled = true;
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Starting…';
|
||
fetch('/api/library/prewarm-durations', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
updatePrewarmStatus(data);
|
||
if (data.running) setTimeout(pollPrewarmStatus, 1500);
|
||
})
|
||
.catch(() => {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = 'Could not reach the server';
|
||
document.getElementById('prewarm-durations-btn').disabled = false;
|
||
});
|
||
}
|
||
|
||
// Pick back up a prewarm that's already running from a previous
|
||
// page load, rather than leaving the button looking idle while
|
||
// work is actually happening in the background.
|
||
(function resumePrewarmStatusIfRunning() {
|
||
fetch('/api/library/prewarm-durations/status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.running) {
|
||
updatePrewarmStatus(data);
|
||
setTimeout(pollPrewarmStatus, 1500);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
})();
|
||
|
||
function handleRestoreFileChosen(input) {
|
||
const file = input.files && input.files[0];
|
||
input.value = ''; // allow re-choosing the same file later
|
||
if (!file) return;
|
||
|
||
if (!confirm(`Restore from "${file.name}"? This overwrites your current settings and every course's progress/notes with what's in the backup.`)) {
|
||
return;
|
||
}
|
||
|
||
const status = document.getElementById('restore-backup-status');
|
||
status.style.color = 'var(--text-muted)';
|
||
status.textContent = 'Restoring…';
|
||
|
||
const formData = new FormData();
|
||
formData.append('backup', file);
|
||
fetch('/api/backup/restore', { method: 'POST', body: formData })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
status.style.color = 'var(--success)';
|
||
const skippedNote = data.skipped && data.skipped.length
|
||
? ` (${data.skipped.length} course${data.skipped.length === 1 ? '' : 's'} skipped - not found in the current library)`
|
||
: '';
|
||
status.innerHTML = `${ICON_SVGS.check} Restored ${data.restored} file${data.restored === 1 ? '' : 's'}${skippedNote} - reload to see it take effect`;
|
||
} else {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = data.error || 'Restore failed';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
status.style.color = 'var(--error)';
|
||
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 = 'var(--error)';
|
||
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 = 'var(--success)';
|
||
status.innerHTML = ICON_SVGS.check + ` Loaded "${data.course_name}" — redirecting...`;
|
||
setTimeout(() => { window.location.href = '/'; }, 1000);
|
||
} else {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = data.error || 'Could not load course';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
status.style.color = 'var(--error)';
|
||
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 = 'var(--success)';
|
||
status.textContent = 'Saved.';
|
||
document.getElementById('outline-token').value = '';
|
||
loadOutlineConfig();
|
||
} else {
|
||
status.style.color = 'var(--error)';
|
||
status.textContent = data.error || 'Could not save.';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
status.style.color = 'var(--error)';
|
||
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 ? 'var(--success)' : 'var(--error)';
|
||
status.textContent = data.success ? 'Connected.' : (data.error || 'Connection failed.');
|
||
})
|
||
.catch(() => {
|
||
status.style.color = 'var(--error)';
|
||
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>
|