feat: Lessons page with API and sidebar nav entry (#5) #15

Merged
sscoble merged 1 commit from issue-5-lessons-page into main 2026-05-16 04:49:32 +00:00
5 changed files with 222 additions and 4 deletions

View file

@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Jellyfin.Plugin.Lessons.Data;
namespace Jellyfin.Plugin.Lessons.Api;
[ApiController]
[Route("Plugins/Lessons")]
public class LessonsController : ControllerBase
{
private readonly LessonStore _store;
public LessonsController(LessonStore store)
{
_store = store;
}
[HttpGet("List")]
public IActionResult List()
{
var lessons = _store.GetAllLessons();
var summaries = lessons.Select(l => new
{
id = l.Id,
title = l.Title,
grade = l.Grade,
subject = l.Subject,
lessonDate = l.LessonDate,
description = l.Description
});
return Ok(summaries);
}
}

View file

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lessons</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #101010;
color: #e0e0e0;
margin: 0;
padding: 0;
}
.page {
padding: 2em;
max-width: 960px;
margin: 0 auto;
}
h1 {
font-size: 1.8em;
font-weight: 600;
margin-bottom: 1em;
color: #fff;
}
.grade-group {
margin-bottom: 2em;
}
.grade-header {
font-size: 1.2em;
font-weight: 600;
color: #00a4dc;
margin-bottom: 0.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid #333;
}
.lesson-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 0.75em 1em;
margin-bottom: 0.5em;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.lesson-card:hover {
background: #222;
border-color: #00a4dc;
}
.lesson-title {
font-weight: 600;
color: #fff;
font-size: 1.05em;
}
.lesson-meta {
font-size: 0.85em;
color: #999;
margin-top: 0.25em;
}
.empty-state {
text-align: center;
padding: 3em;
color: #666;
}
.error-state {
text-align: center;
padding: 3em;
color: #c44;
}
.loading {
text-align: center;
padding: 3em;
color: #666;
}
</style>
</head>
<body>
<div class="page">
<h1>📚 Lessons</h1>
<div id="content" class="loading">Loading lessons&hellip;</div>
</div>
<script>
(function() {
var container = document.getElementById('content');
function apiUrl() {
// Use the Jellyfin base URL from the current page context
var base = window.location.origin;
return base + '/Plugins/Lessons/List';
}
function renderLessons(lessons) {
if (!lessons || lessons.length === 0) {
container.className = 'empty-state';
container.innerHTML = '<p>No lessons found. Lessons will appear here after you build them.</p>';
return;
}
// Group by grade
var gradeMap = {};
lessons.forEach(function(l) {
var grade = l.grade || 'Unknown';
if (!gradeMap[grade]) gradeMap[grade] = [];
gradeMap[grade].push(l);
});
var html = '';
var grades = Object.keys(gradeMap).sort(function(a, b) { return a - b; });
grades.forEach(function(grade) {
html += '<div class="grade-group">';
html += '<div class="grade-header">Grade ' + grade + '</div>';
gradeMap[grade].forEach(function(l) {
html += '<div class="lesson-card">';
html += '<div class="lesson-title">' + esc(l.title) + '</div>';
html += '<div class="lesson-meta">';
html += esc(l.subject || '');
if (l.lessonDate) html += ' &middot; ' + esc(l.lessonDate);
html += '</div>';
if (l.description) {
html += '<div class="lesson-meta">' + esc(l.description) + '</div>';
}
html += '</div>';
});
html += '</div>';
});
container.innerHTML = html;
}
function esc(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
fetch(apiUrl())
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(function(data) {
renderLessons(data);
})
.catch(function(err) {
container.className = 'error-state';
container.innerHTML = '<p>Failed to load lessons: ' + esc(err.message) + '</p>';
});
})();
</script>
</body>
</html>

View file

@ -3,6 +3,8 @@
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>JellyfinLessons</RootNamespace>
<AssemblyName>jellyfin-lessons</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
@ -16,4 +18,12 @@
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration/lessonsPage.html" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View file

@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Lessons.Configuration;
namespace Jellyfin.Plugin.Lessons;
public class Plugin : BasePlugin<PluginConfiguration>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public Plugin(IApplicationPaths appPaths, IXmlSerializer xmlSerializer)
: base(appPaths, xmlSerializer)
@ -18,4 +20,20 @@ public class Plugin : BasePlugin<PluginConfiguration>
public override Guid Id => Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
public override string Description => "Homeschool lesson plans with embedded media";
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = Name,
DisplayName = "Lessons",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.lessonsPage.html",
EnableInMainMenu = true,
MenuSection = "plugins",
MenuIcon = "book"
}
};
}
}

View file

@ -1,11 +1,15 @@
using MediaBrowser.Common.Plugins;
using Jellyfin.Plugin.Lessons.Data;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Lessons;
public class ServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection services)
public void RegisterServices(IServiceCollection services, IServerApplicationHost appHost)
{
services.AddSingleton<LessonStore>(sp =>
new LessonStore("~/.local/share/jellyfin-lessons/lessons.db"));
}
}