feat: Lessons page with API and sidebar nav entry (#5) #15
5 changed files with 222 additions and 4 deletions
34
plugin/Api/LessonsController.cs
Normal file
34
plugin/Api/LessonsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
152
plugin/Configuration/lessonsPage.html
Normal file
152
plugin/Configuration/lessonsPage.html
Normal 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…</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 += ' · ' + 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue