feat: add web admin UI for lesson management (#7)

- REST API server on port 3099 with better-sqlite3
- CRUD endpoints for lessons, Typst compile endpoint
- Shared DB path with plugin (~/.local/share/jellyfin-lessons/lessons.db)
- Single-page admin UI with sidebar (grouped by grade/subject)
- Lesson editor with Typst source editing, live preview via compile
- Media items management (Jellyfin item UUIDs with sort order)
- CORS enabled for local dev
This commit is contained in:
Scott Scoble 2026-05-15 21:43:24 -07:00
parent 08f040d317
commit 6e7d2372e7
5 changed files with 1841 additions and 0 deletions

2
admin-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
*.db

1251
admin-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

13
admin-ui/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "jellyfin-lessons-admin-ui",
"version": "1.0.0",
"description": "Web admin UI for managing Jellyfin Lessons",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"express": "^4.21.0"
}
}

408
admin-ui/public/index.html Normal file
View file

@ -0,0 +1,408 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jellyfin Lessons — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5; color: #333; min-height: 100vh;
}
#app { display: flex; min-height: 100vh; }
/* Sidebar */
#sidebar {
width: 280px; background: #1a1a2e; color: #eee;
display: flex; flex-direction: column; flex-shrink: 0;
}
#sidebar h2 {
padding: 20px; font-size: 18px; border-bottom: 1px solid #333;
display: flex; justify-content: space-between; align-items: center;
}
#sidebar h2 button {
background: #0f3460; color: #e94560; border: none; padding: 6px 14px;
border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 600;
}
#sidebar h2 button:hover { background: #16213e; }
#lesson-list { flex: 1; overflow-y: auto; padding: 10px 0; }
.grade-group { margin-bottom: 4px; }
.grade-header {
padding: 8px 20px; font-size: 12px; font-weight: 700; text-transform: uppercase;
color: #999; cursor: pointer; user-select: none;
}
.grade-header:hover { color: #e94560; }
.grade-header::before { content: '▸ '; display: inline-block; transition: transform 0.2s; font-size: 10px; }
.grade-header.open::before { transform: rotate(90deg); }
.grade-lessons { display: none; }
.grade-lessons.open { display: block; }
.lesson-item {
padding: 8px 20px 8px 32px; cursor: pointer; font-size: 14px;
border-left: 3px solid transparent; transition: all 0.15s;
}
.lesson-item:hover { background: #16213e; }
.lesson-item.active { background: #0f3460; border-left-color: #e94560; font-weight: 600; }
.lesson-item .subject-tag {
font-size: 11px; color: #888; display: block;
}
/* Main */
#main {
flex: 1; padding: 30px 40px; overflow-y: auto;
}
#main h1 { font-size: 24px; margin-bottom: 24px; color: #1a1a2e; }
#editor { max-width: 800px; }
.field { margin-bottom: 16px; }
.field label {
display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #555;
}
.field input, .field textarea, .field select {
width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;
font-size: 14px; font-family: inherit;
}
.field textarea { resize: vertical; }
.field textarea#typst-source {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
min-height: 300px; tab-size: 2;
}
.row { display: flex; gap: 16px; }
.row .field { flex: 1; }
.btn-row { display: flex; gap: 10px; margin-top: 20px; }
button {
padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer;
font-size: 14px; font-weight: 600; transition: background 0.15s;
}
.btn-primary { background: #e94560; color: #fff; }
.btn-primary:hover { background: #c73650; }
.btn-secondary { background: #0f3460; color: #eee; }
.btn-secondary:hover { background: #16213e; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-danger:hover { background: #b52a38; }
/* Status */
#status { margin-top: 12px; font-size: 13px; min-height: 20px; }
.status-ok { color: #28a745; }
.status-err { color: #dc3545; }
/* Preview */
#preview-section { margin-top: 24px; }
#preview-section h3 {
font-size: 16px; margin-bottom: 10px; color: #1a1a2e;
}
#preview-frame {
width: 100%; min-height: 400px; border: 1px solid #ddd; border-radius: 4px;
background: #fff;
}
#preview-frame iframe {
width: 100%; height: 500px; border: none;
}
/* Media items */
#media-section { margin-top: 24px; border-top: 1px solid #ddd; padding-top: 16px; }
#media-section h3 { font-size: 16px; margin-bottom: 10px; color: #1a1a2e; }
.media-row {
display: flex; gap: 8px; align-items: center; margin-bottom: 8px;
}
.media-row input { flex: 1; }
.media-row input.sort { width: 70px; flex: none; }
.media-row button {
padding: 6px 12px; font-size: 12px; background: #666; color: #fff; border-radius: 4px;
}
.media-row button:hover { background: #444; }
.add-media-btn { background: #0f3460; color: #eee; margin-top: 4px; }
.add-media-btn:hover { background: #16213e; }
/* Empty state */
.empty-state {
text-align: center; padding: 60px 20px; color: #999;
}
.empty-state p { font-size: 16px; }
</style>
</head>
<body>
<div id="app">
<div id="sidebar">
<h2>Lessons <button onclick="newLesson()" title="New Lesson">+ New</button></h2>
<div id="lesson-list"><div class="empty-state"><p>Loading…</p></div></div>
</div>
<div id="main">
<h1>Lesson Editor</h1>
<div id="editor" style="display:none;">
<div class="row">
<div class="field"><label>Title</label><input id="title" placeholder="Lesson title"></div>
<div class="field"><label>Subject</label><input id="subject" placeholder="e.g. Math, History"></div>
</div>
<div class="row">
<div class="field"><label>Grade</label><input id="grade" type="number" placeholder="Grade level" min="0"></div>
<div class="field"><label>Date</label><input id="lesson-date" type="date"></div>
</div>
<div class="field"><label>Description</label><textarea id="description" rows="2" placeholder="Brief description of the lesson"></textarea></div>
<div class="field"><label>Typst Source</label><textarea id="typst-source" placeholder="#set page(width: auto, height: auto, margin: 15pt)
#set text(font: &quot;Linux Libertine&quot;, size: 12pt)
= Lesson Title
Your content here..."></textarea></div>
<div class="btn-row">
<button class="btn-secondary" onclick="compilePreview()">Preview</button>
<button class="btn-primary" onclick="saveLesson()">Save</button>
<button class="btn-danger" onclick="deleteLesson()" id="delete-btn" style="display:none;">Delete</button>
</div>
<div id="status"></div>
<div id="preview-section" style="display:none;">
<h3>Preview</h3>
<div id="preview-frame"></div>
</div>
<div id="media-section">
<h3>Media Items</h3>
<div id="media-list"></div>
<button class="add-media-btn" onclick="addMediaItem()">+ Add Media Item</button>
</div>
</div>
<div class="empty-state" id="no-selection">
<p>Select a lesson from the sidebar or create a new one.</p>
</div>
</div>
</div>
<script>
const API = '/api/lessons';
let currentLesson = null;
let allLessons = [];
// --- Sidebar ---
async function loadLessons() {
try {
const res = await fetch(API);
allLessons = await res.json();
renderSidebar();
} catch (e) {
document.getElementById('lesson-list').innerHTML = '<div class="empty-state"><p>Error loading lessons</p></div>';
}
}
function renderSidebar() {
const container = document.getElementById('lesson-list');
if (allLessons.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No lessons yet</p></div>';
return;
}
// Group by grade
const byGrade = {};
for (const l of allLessons) {
const g = l.grade ?? 'Ungrouped';
if (!byGrade[g]) byGrade[g] = [];
byGrade[g].push(l);
}
const sortedGrades = Object.keys(byGrade).sort((a, b) => {
if (a === 'Ungrouped') return 1;
if (b === 'Ungrouped') return -1;
return Number(a) - Number(b);
});
let html = '';
for (const grade of sortedGrades) {
const label = grade === 'Ungrouped' ? 'Ungrouped' : `Grade ${grade}`;
html += `<div class="grade-group">
<div class="grade-header open" onclick="toggleGrade(this)">${label} (${byGrade[grade].length})</div>
<div class="grade-lessons open">`;
for (const l of byGrade[grade].sort((a, b) => a.title.localeCompare(b.title))) {
const active = currentLesson && currentLesson.id === l.id ? ' active' : '';
const subject = l.subject ? `<span class="subject-tag">${escHtml(l.subject)}</span>` : '';
html += `<div class="lesson-item${active}" onclick="selectLesson(${l.id})">
${escHtml(l.title)}${subject}
</div>`;
}
html += '</div></div>';
}
container.innerHTML = html;
}
function toggleGrade(el) {
el.classList.toggle('open');
el.nextElementSibling.classList.toggle('open');
}
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// --- Editor ---
async function selectLesson(id) {
try {
const res = await fetch(`${API}/${id}`);
if (!res.ok) throw new Error('Not found');
currentLesson = await res.json();
renderSidebar();
showEditor();
} catch (e) {
setStatus('Error loading lesson: ' + e.message, 'err');
}
}
function newLesson() {
currentLesson = {
id: null, title: '', grade: '', subject: '', lesson_date: '',
description: '', source_path: '', compiled_html: '', mediaItems: []
};
renderSidebar();
showEditor();
}
function showEditor() {
document.getElementById('editor').style.display = 'block';
document.getElementById('no-selection').style.display = 'none';
document.getElementById('delete-btn').style.display = currentLesson.id ? 'inline-block' : 'none';
document.getElementById('preview-section').style.display = 'none';
document.getElementById('title').value = currentLesson.title || '';
document.getElementById('subject').value = currentLesson.subject || '';
document.getElementById('grade').value = currentLesson.grade ?? '';
document.getElementById('lesson-date').value = currentLesson.lesson_date || '';
document.getElementById('description').value = currentLesson.description || '';
document.getElementById('typst-source').value = currentLesson.source_path || '';
document.getElementById('status').textContent = '';
renderMediaItems();
}
// --- Media Items ---
function renderMediaItems() {
const container = document.getElementById('media-list');
const items = currentLesson.mediaItems || [];
let html = '';
items.forEach((item, i) => {
html += `<div class="media-row">
<input type="text" value="${escHtml(item.jellyfin_item_id)}"
onchange="updateMediaItem(${i}, 'jellyfinItemId', this.value)" placeholder="Jellyfin Item UUID">
<input type="number" class="sort" value="${item.sort_order || 0}"
onchange="updateMediaItem(${i}, 'sortOrder', parseInt(this.value) || 0)" placeholder="Order" min="0">
<button onclick="removeMediaItem(${i})">✕</button>
</div>`;
});
container.innerHTML = html;
}
function addMediaItem() {
if (!currentLesson) return;
if (!currentLesson.mediaItems) currentLesson.mediaItems = [];
currentLesson.mediaItems.push({ jellyfinItemId: '', sortOrder: currentLesson.mediaItems.length });
renderMediaItems();
}
function updateMediaItem(index, key, value) {
if (!currentLesson || !currentLesson.mediaItems) return;
currentLesson.mediaItems[index][key] = value;
}
function removeMediaItem(index) {
if (!currentLesson || !currentLesson.mediaItems) return;
currentLesson.mediaItems.splice(index, 1);
renderMediaItems();
}
// --- Compile ---
async function compilePreview() {
const typst = document.getElementById('typst-source').value;
if (!typst.trim()) {
setStatus('Typst source is empty', 'err');
return;
}
setStatus('Compiling…', '');
try {
const res = await fetch('/api/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ typst })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Compilation failed');
}
const data = await res.json();
// Show in iframe for proper rendering
const frame = document.getElementById('preview-frame');
frame.innerHTML = `<iframe srcdoc="${data.html.replace(/"/g, '&quot;')}"></iframe>`;
document.getElementById('preview-section').style.display = 'block';
// Also store compiled HTML
currentLesson.compiled_html = data.html;
setStatus('Compiled successfully', 'ok');
} catch (e) {
setStatus('Compile error: ' + e.message, 'err');
document.getElementById('preview-section').style.display = 'block';
document.getElementById('preview-frame').innerHTML =
`<pre style="padding:16px;color:#dc3545;white-space:pre-wrap;">${escHtml(e.message)}</pre>`;
}
}
// --- Save ---
async function saveLesson() {
const title = document.getElementById('title').value.trim();
if (!title) { setStatus('Title is required', 'err'); return; }
// Re-read fields to capture last-seen media items
const body = {
id: currentLesson.id || undefined,
title,
grade: parseInt(document.getElementById('grade').value) || null,
subject: document.getElementById('subject').value.trim() || null,
lessonDate: document.getElementById('lesson-date').value || null,
description: document.getElementById('description').value.trim() || null,
sourcePath: document.getElementById('typst-source').value,
compiledHtml: currentLesson.compiled_html || null,
mediaItems: (currentLesson.mediaItems || []).map(m => ({
jellyfinItemId: m.jellyfinItemId,
sortOrder: m.sortOrder
}))
};
setStatus('Saving…', '');
try {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Save failed');
}
currentLesson = await res.json();
setStatus('Saved!', 'ok');
loadLessons();
showEditor();
} catch (e) {
setStatus('Save error: ' + e.message, 'err');
}
}
// --- Delete ---
async function deleteLesson() {
if (!currentLesson || !currentLesson.id) return;
if (!confirm(`Delete "${currentLesson.title}"? This cannot be undone.`)) return;
try {
const res = await fetch(`${API}/${currentLesson.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
currentLesson = null;
setStatus('');
loadLessons();
document.getElementById('editor').style.display = 'none';
document.getElementById('no-selection').style.display = 'block';
} catch (e) {
setStatus('Delete error: ' + e.message, 'err');
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = cls ? 'status-' + cls : '';
}
// Init
loadLessons();
</script>
</body>
</html>

167
admin-ui/server.js Normal file
View file

@ -0,0 +1,167 @@
const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execSync } = require('child_process');
const app = express();
const PORT = 3099;
// DB path shared with plugin
const DB_PATH = path.join(os.homedir(), '.local', 'share', 'jellyfin-lessons', 'lessons.db');
// Ensure DB directory exists
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
// Initialize schema if needed
db.exec(`
CREATE TABLE IF NOT EXISTS lessons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
grade INTEGER,
subject TEXT,
lesson_date TEXT,
description TEXT,
source_path TEXT,
compiled_html TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS media_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lesson_id INTEGER NOT NULL,
jellyfin_item_id TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE
);
`);
// Middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// CORS for local dev
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
// GET /api/lessons — list all lessons
app.get('/api/lessons', (req, res) => {
try {
const lessons = db.prepare('SELECT * FROM lessons ORDER BY updated_at DESC').all();
res.json(lessons);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/lessons/:id — single lesson with media items
app.get('/api/lessons/:id', (req, res) => {
try {
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const mediaItems = db.prepare('SELECT * FROM media_items WHERE lesson_id = ? ORDER BY sort_order').all(req.params.id);
res.json({ ...lesson, mediaItems });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/lessons — create or update lesson
app.post('/api/lessons', (req, res) => {
try {
const { id, title, grade, subject, lessonDate, description, sourcePath, compiledHtml, mediaItems } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const upsertLesson = db.transaction(() => {
let lessonId = id;
if (lessonId) {
// Update existing
db.prepare(`
UPDATE lessons
SET title = ?, grade = ?, subject = ?, lesson_date = ?, description = ?,
source_path = ?, compiled_html = ?, updated_at = datetime('now')
WHERE id = ?
`).run(title, grade || null, subject || null, lessonDate || null, description || null,
sourcePath || null, compiledHtml || null, lessonId);
// Replace media items
db.prepare('DELETE FROM media_items WHERE lesson_id = ?').run(lessonId);
} else {
// Create new
const result = db.prepare(`
INSERT INTO lessons (title, grade, subject, lesson_date, description, source_path, compiled_html)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(title, grade || null, subject || null, lessonDate || null, description || null,
sourcePath || null, compiledHtml || null);
lessonId = result.lastInsertRowid;
}
// Insert media items
if (mediaItems && Array.isArray(mediaItems)) {
const insertMedia = db.prepare('INSERT INTO media_items (lesson_id, jellyfin_item_id, sort_order) VALUES (?, ?, ?)');
for (const item of mediaItems) {
insertMedia.run(lessonId, item.jellyfinItemId, item.sortOrder || 0);
}
}
return lessonId;
});
const lessonId = upsertLesson();
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(lessonId);
const media = db.prepare('SELECT * FROM media_items WHERE lesson_id = ? ORDER BY sort_order').all(lessonId);
res.json({ ...lesson, mediaItems: media });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/lessons/:id
app.delete('/api/lessons/:id', (req, res) => {
try {
const result = db.prepare('DELETE FROM lessons WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Lesson not found' });
res.json({ deleted: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/compile — compile Typst to HTML
app.post('/api/compile', (req, res) => {
try {
const { typst } = req.body;
if (!typst) return res.status(400).json({ error: 'typst source is required' });
// Write to temp file
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'typst-'));
const srcFile = path.join(tmpDir, 'lesson.typ');
const outFile = path.join(tmpDir, 'lesson.html');
fs.writeFileSync(srcFile, typst);
try {
execSync(`typst compile "${srcFile}" "${outFile}"`, { timeout: 10000, encoding: 'utf8' });
const html = fs.readFileSync(outFile, 'utf8');
res.json({ html });
} finally {
// Cleanup
fs.rmSync(tmpDir, { recursive: true, force: true });
}
} catch (err) {
res.status(500).json({ error: err.stderr || err.message });
}
});
app.listen(PORT, () => {
console.log(`Jellyfin Lessons Admin UI running at http://localhost:${PORT}`);
});