feat: Light mode default, auth, Posts rename, display scaling, TinyMCE improvements

This commit is contained in:
Jess Rogerson
2026-05-21 14:22:46 +10:00
parent 51d086dab9
commit 39fcd9ec6e
154 changed files with 12897 additions and 133 deletions
+5 -5
View File
@@ -18,7 +18,7 @@
<div class="stat-icon"><i class="bi bi-display"></i></div>
<div class="stat-info">
<div class="stat-value">@ViewBag.DeviceCount</div>
<div class="stat-label">Kittens (Devices)</div>
<div class="stat-label">Posts (Devices)</div>
</div>
</div>
</div>
@@ -37,7 +37,7 @@
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-display me-2"></i>Active Kittens</h5>
<h5 class="mb-0"><i class="bi bi-display me-2"></i>Active Posts</h5>
<a href="/admin/devices/create" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg me-1"></i>Add</a>
</div>
<div class="card-body p-0">
@@ -67,8 +67,8 @@
{
<div class="p-4 text-center text-muted">
<i class="bi bi-display" style="font-size:2em;"></i>
<p class="mt-2">No kittens configured yet.</p>
<a href="/admin/devices/create" class="btn btn-primary btn-sm">Create your first kitten</a>
<p class="mt-2">No posts configured yet.</p>
<a href="/admin/devices/create" class="btn btn-primary btn-sm">Create your first post</a>
</div>
}
</div>
@@ -83,7 +83,7 @@
<tr><td><strong>Meow</strong></td><td>An individual slide</td></tr>
<tr><td><strong>PURR</strong></td><td>Persistent User Requests &amp; Reminders — a playlist</td></tr>
<tr><td><strong>ASK</strong></td><td>Attention Seeking Kitty — priority override</td></tr>
<tr><td><strong>Kitten</strong></td><td>A display node/device</td></tr>
<tr><td><strong>Post</strong></td><td>A display node/device</td></tr>
<tr><td><strong>Scratching Post</strong></td><td>This backend</td></tr>
<tr><td><strong>Sunbeam</strong></td><td>The framework</td></tr>
</tbody>
+12 -16
View File
@@ -1,20 +1,16 @@
@model Device
@{ ViewData["Title"] = "New Kitten"; }
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card"><div class="card-body">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div class="mb-3"><label asp-for="Name" class="form-label">Display Name *</label><input asp-for="Name" class="form-control" placeholder="e.g. Front of House" required /><span asp-validation-for="Name" class="text-danger"></span></div>
<div class="mb-3"><label asp-for="Slug" class="form-label">URL Slug *</label><div class="input-group"><span class="input-group-text">/</span><input asp-for="Slug" class="form-control font-monospace" placeholder="frontofhouse" /></div><div class="form-text">Lowercase, no spaces. Auto-generated from name if left empty.</div><span asp-validation-for="Slug" class="text-danger"></span></div>
<div class="row g-3 mb-3"><div class="col-6"><label asp-for="ResolutionWidth" class="form-label">Width (px)</label><input asp-for="ResolutionWidth" class="form-control" type="number" min="320" max="7680" /></div><div class="col-6"><label asp-for="ResolutionHeight" class="form-label">Height (px)</label><input asp-for="ResolutionHeight" class="form-control" type="number" min="240" max="4320" /></div></div>
<div class="mb-3"><label class="form-label">Quick Presets</label><div class="d-flex flex-wrap gap-2"><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(1920,1080)">1080p</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(3840,2160)">4K</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(1080,1920)">Portrait</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(1280,720)">720p</button></div></div>
<div class="mb-4"><label asp-for="Transition" class="form-label">Transition Effect</label><select asp-for="Transition" class="form-select"><option value="fade">Fade</option><option value="slide">Slide</option><option value="none">None</option></select></div>
<div class="d-grid gap-2"><button type="submit" class="btn btn-primary btn-lg"><i class="bi bi-check-lg me-1"></i>Create Kitten</button><a href="/admin/devices" class="btn btn-outline-secondary">Cancel</a></div>
</form>
</div></div>
</div>
</div>
@{ ViewData["Title"] = "New Post"; }
<div class="row justify-content-center"><div class="col-md-8 col-lg-6"><div class="card"><div class="card-body">
<form asp-action="Create" method="post">
@Html.AntiForgeryToken()
<div class="mb-3"><label asp-for="Name" class="form-label">Display Name *</label><input asp-for="Name" class="form-control" placeholder="e.g. Front of House" required /><span asp-validation-for="Name" class="text-danger"></span></div>
<div class="mb-3"><label asp-for="Slug" class="form-label">URL Slug *</label><div class="input-group"><span class="input-group-text">/</span><input asp-for="Slug" class="form-control font-monospace" placeholder="frontofhouse" /></div><div class="form-text">Lowercase, no spaces. Auto-generated from name if left empty.</div><span asp-validation-for="Slug" class="text-danger"></span></div>
<div class="row g-3 mb-3"><div class="col-6"><label asp-for="ResolutionWidth" class="form-label">Width (px)</label><input asp-for="ResolutionWidth" class="form-control" type="number" min="320" max="7680" /></div><div class="col-6"><label asp-for="ResolutionHeight" class="form-label">Height (px)</label><input asp-for="ResolutionHeight" class="form-control" type="number" min="240" max="4320" /></div></div>
<div class="mb-3"><label class="form-label">Quick Presets</label><div class="d-flex flex-wrap gap-2"><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(1920,1080)">1080p</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(3840,2160)">4K</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(1080,1920)">Portrait</button><button type="button" class="btn btn-sm btn-outline-secondary" onclick="setRes(1280,720)">720p</button></div></div>
<div class="mb-4"><label asp-for="Transition" class="form-label">Transition Effect</label><select asp-for="Transition" class="form-select"><option value="fade">Fade</option><option value="slide">Slide</option><option value="none">None</option></select></div>
<div class="d-grid gap-2"><button type="submit" class="btn btn-primary btn-lg"><i class="bi bi-check-lg me-1"></i>Create Post</button><a href="/admin/devices" class="btn btn-outline-secondary">Cancel</a></div>
</form>
</div></div></div></div>
@section Scripts {
<script>
function setRes(w,h){document.getElementById('ResolutionWidth').value=w;document.getElementById('ResolutionHeight').value=h;}
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="row justify-content-center"><div class="col-md-6"><div class="card border-danger">
<div class="card-header bg-danger text-white"><h5 class="mb-0"><i class="bi bi-exclamation-triangle me-2"></i>Confirm Deletion</h5></div>
<div class="card-body">
<p>Are you sure you want to delete the kitten <strong>"@Model.Name"</strong>?</p>
<p>Are you sure you want to delete the post <strong>"@Model.Name"</strong>?</p>
<p class="text-muted">URL: <code>/@Model.Slug</code> · @Model.DeviceSlides.Count slide(s) assigned</p>
<p class="text-muted">This will remove the device and its playlist. Slides themselves will not be deleted.</p>
<form asp-action="Delete" method="post">
+4 -4
View File
@@ -1,8 +1,8 @@
@model List<Device>
@{ ViewData["Title"] = "Kittens (Devices)"; }
@{ ViewData["Title"] = "Posts (Devices)"; }
@section HeaderActions {
<a href="/admin/devices/create" class="btn btn-primary"><i class="bi bi-plus-lg me-1"></i>New Kitten</a>
<a href="/admin/devices/create" class="btn btn-primary"><i class="bi bi-plus-lg me-1"></i>New Post</a>
}
@if (Model.Any())
@@ -42,8 +42,8 @@ else
{
<div class="empty-state">
<i class="bi bi-display" style="font-size:3em;"></i>
<h4 class="mt-3">No kittens yet</h4>
<h4 class="mt-3">No posts yet</h4>
<p class="text-muted">Create your first display device.</p>
<a href="/admin/devices/create" class="btn btn-primary"><i class="bi bi-plus-lg me-1"></i>Create Kitten</a>
<a href="/admin/devices/create" class="btn btn-primary"><i class="bi bi-plus-lg me-1"></i>Create Post</a>
</div>
}
+1 -1
View File
@@ -53,7 +53,7 @@
</div>
</div>
<div class="col-lg-4">
<div class="card mb-4"><div class="card-header"><h6 class="mb-0">Kitten Info</h6></div><div class="card-body"><p class="mb-1"><strong>@Model.Name</strong></p><p class="mb-1 text-muted"><code>/@Model.Slug</code></p><p class="mb-0 text-muted">@(Model.ResolutionWidth)x@(Model.ResolutionHeight) · @Model.Transition</p></div></div>
<div class="card mb-4"><div class="card-header"><h6 class="mb-0">Post Info</h6></div><div class="card-body"><p class="mb-1"><strong>@Model.Name</strong></p><p class="mb-1 text-muted"><code>/@Model.Slug</code></p><p class="mb-0 text-muted">@(Model.ResolutionWidth)x@(Model.ResolutionHeight) · @Model.Transition</p></div></div>
<div class="card"><div class="card-header"><h6 class="mb-0"><i class="bi bi-plus-circle me-1"></i>Add Meow</h6></div><div class="card-body">
@if (availableSlides != null && availableSlides.Any())
{
+3 -3
View File
@@ -4,7 +4,7 @@
<html lang="en">
<head>
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Device Not Found — Sunbeam</title>
<title>Post Not Found — Sunbeam</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<style>
body { background: #0f0f1a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
@@ -17,9 +17,9 @@
<body>
<div class="container">
<h1>Meow?</h1>
<p class="text-muted mb-4">No kitten found at this address.</p>
<p class="text-muted mb-4">No post found at this address.</p>
@if (Model.Any()) {
<h5 class="mb-3">Available Kittens:</h5>
<h5 class="mb-3">Available Posts:</h5>
<div class="device-list">@foreach (var d in Model) { <a href="/@d.Slug"><strong>@d.Name</strong><br /><code>/@d.Slug</code></a> }</div>
} else { <p>No devices configured yet.</p> }
<p class="mt-4"><a href="/admin" class="btn btn-outline-light btn-sm">Go to Admin</a></p>
+14 -7
View File
@@ -7,18 +7,25 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=@(Model.ResolutionWidth), height=@(Model.ResolutionHeight), initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>@Model.Name — Sunbeam Display</title>
<link href="/css/display.css" rel="stylesheet" />
<style>:root { --display-width: @(Model.ResolutionWidth)px; --display-height: @(Model.ResolutionHeight)px; }</style>
<style>
:root {
--display-width: @(Model.ResolutionWidth)px;
--display-height: @(Model.ResolutionHeight)px;
}
</style>
</head>
<body data-slug="@Model.Slug" data-transition="@Model.Transition">
<div id="display-container">
<div id="slide-a" class="slide-layer active"></div>
<div id="slide-b" class="slide-layer"></div>
<body data-slug="@Model.Slug" data-transition="@Model.Transition" data-width="@Model.ResolutionWidth" data-height="@Model.ResolutionHeight">
<div id="scale-wrapper">
<div id="display-container">
<div id="slide-a" class="slide-layer active"></div>
<div id="slide-b" class="slide-layer"></div>
</div>
<div id="clock-overlay"><span id="clock-time"></span><span id="clock-date"></span></div>
</div>
<div id="progress-bar"><div id="progress-fill"></div></div>
<div id="clock-overlay"><span id="clock-time"></span><span id="clock-date"></span></div>
<div id="status-dot" title="Connected"></div>
<script>
var playlistData = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(slides.Select(ds => new {
+14 -7
View File
@@ -8,9 +8,8 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link href="/css/admin.css" rel="stylesheet" />
<script>
// Apply saved theme immediately to prevent flash
(function() {
var t = localStorage.getItem('sb-theme') || 'dark';
var t = localStorage.getItem('sb-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
@@ -42,17 +41,25 @@
<li>
<a href="/admin/devices" class="@(ViewContext.RouteData.Values["controller"]?.ToString() == "Devices" ? "active" : "")">
<i class="bi bi-display"></i>
<span>Kittens</span>
<span>Posts</span>
<small class="nav-hint">Devices</small>
</a>
</li>
</ul>
<div class="sidebar-footer">
<small>Sunbeam v1.0</small>
<button id="themeToggle" class="theme-toggle" title="Toggle theme">
<i class="bi bi-sun"></i>
</button>
<div>
<small>Sunbeam v1.0</small><br />
<small style="opacity:0.5;">&copy; Jess Rogerson — 2026</small>
</div>
<div class="d-flex gap-1">
<button id="themeToggle" class="theme-toggle" title="Toggle theme">
<i class="bi bi-moon-stars"></i>
</button>
<a href="/account/logout" class="theme-toggle" title="Sign Out">
<i class="bi bi-box-arrow-right"></i>
</a>
</div>
</div>
</nav>
+77 -10
View File
@@ -35,7 +35,6 @@
<div class="mb-3">
<label asp-for="IcsSource" class="form-label">ICS Calendar Source</label>
<input asp-for="IcsSource" class="form-control" placeholder="https://calendar.example.com/feed.ics" />
<div class="form-text">Enter a URL to an .ics calendar feed, or upload an ICS file below.</div>
</div>
<div class="mb-3">
<label class="form-label">Or Upload ICS File</label>
@@ -65,13 +64,12 @@
<div class="mb-3">
<label asp-for="BackgroundSize" class="form-label">Image Sizing</label>
<select asp-for="BackgroundSize" class="form-select">
<option value="cover">Cover — fill entire slide, crop if needed</option>
<option value="contain">Contain — fit whole image, may show background</option>
<option value="fill">Fill — stretch to fit exactly</option>
<option value="scale-down">Scale Down — shrink to fit, never enlarge</option>
<option value="cover">Cover — fill, crop if needed</option>
<option value="contain">Contain — fit whole image</option>
<option value="fill">Fill — stretch to fit</option>
<option value="scale-down">Scale Down — shrink only</option>
<option value="auto">Auto — original size</option>
</select>
<div class="form-text">How the background image fills the slide area.</div>
</div>
<div class="mb-3">
<label asp-for="CustomCss" class="form-label">Custom CSS</label>
@@ -104,19 +102,88 @@
function initTinyMCE() {
if (tinymce.get('contentEditor')) return;
tinymce.init({
selector: '#contentEditor', height: 500,
selector: '#contentEditor',
height: 500,
license_key: 'gpl',
menubar: 'file edit view insert format table',
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table help wordcount',
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | image media table | removeformat code fullscreen',
toolbar: 'undo redo | blocks fontsize | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | image media table | removeformat code fullscreen',
font_size_formats: '8pt 10pt 12pt 14pt 16pt 18pt 20pt 24pt 28pt 32pt 36pt 48pt 64pt 72pt 96pt',
images_upload_handler: function (blobInfo) {
return new Promise(function (resolve, reject) {
var fd = new FormData(); fd.append('file', blobInfo.blob(), blobInfo.filename());
fetch('/api/upload', { method: 'POST', body: fd }).then(r => r.json()).then(d => resolve(d.location)).catch(e => reject(e));
});
},
content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; color: #fff; background: #1a1a2e; }',
skin: 'oxide-dark', content_css: 'dark', promotion: false, branding: false
file_picker_types: 'image',
file_picker_callback: function (cb, value, meta) {
fetch('/api/listuploads').then(r => r.json()).then(function (files) {
var input = document.createElement('input');
input.type = 'file'; input.accept = 'image/*';
// If we have existing uploads, show a picker dialog
if (files && files.length > 0) {
var dialog = document.createElement('div');
dialog.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:99999;display:flex;align-items:center;justify-content:center;';
var panel = document.createElement('div');
panel.style.cssText = 'background:#fff;border-radius:12px;padding:1.5em;max-width:700px;max-height:80vh;overflow-y:auto;width:90%;';
panel.innerHTML = '<h4 style="margin:0 0 1em;color:#333;">Select Image or Upload New</h4>';
var grid = document.createElement('div');
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin-bottom:1em;';
files.forEach(function (f) {
var thumb = document.createElement('div');
thumb.style.cssText = 'cursor:pointer;border:2px solid #ddd;border-radius:8px;overflow:hidden;aspect-ratio:1;background:#f0f0f0;';
thumb.innerHTML = '<img src="' + f.value + '" style="width:100%;height:100%;object-fit:cover;" />';
thumb.title = f.title;
thumb.addEventListener('click', function () {
cb(f.value, { title: f.title });
document.body.removeChild(dialog);
});
thumb.addEventListener('mouseenter', function () { this.style.borderColor = '#3b6fd4'; });
thumb.addEventListener('mouseleave', function () { this.style.borderColor = '#ddd'; });
grid.appendChild(thumb);
});
panel.appendChild(grid);
var uploadBtn = document.createElement('button');
uploadBtn.textContent = 'Upload New Image';
uploadBtn.style.cssText = 'background:#3b6fd4;color:#fff;border:none;padding:0.6em 1.5em;border-radius:6px;cursor:pointer;margin-right:0.5em;';
uploadBtn.addEventListener('click', function () {
document.body.removeChild(dialog);
input.click();
});
var cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'background:#eee;color:#333;border:none;padding:0.6em 1.5em;border-radius:6px;cursor:pointer;';
cancelBtn.addEventListener('click', function () { document.body.removeChild(dialog); });
panel.appendChild(uploadBtn);
panel.appendChild(cancelBtn);
dialog.appendChild(panel);
dialog.addEventListener('click', function (e) { if (e.target === dialog) document.body.removeChild(dialog); });
document.body.appendChild(dialog);
} else {
input.click();
}
input.addEventListener('change', function () {
if (!this.files[0]) return;
var fd = new FormData(); fd.append('file', this.files[0]);
fetch('/api/upload', { method: 'POST', body: fd })
.then(r => r.json())
.then(d => cb(d.location, { title: this.files[0].name }));
});
}).catch(function () { input.click(); });
},
content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; color: #000; background: #fff; }',
skin: 'oxide',
content_css: 'default',
promotion: false,
branding: false
});
}
if (document.getElementById('slideType').value === '0') initTinyMCE();
+57 -9
View File
@@ -65,13 +65,12 @@
<div class="mb-3">
<label asp-for="BackgroundSize" class="form-label">Image Sizing</label>
<select asp-for="BackgroundSize" class="form-select">
<option value="cover">Cover — fill entire slide, crop if needed</option>
<option value="contain">Contain — fit whole image, may show background</option>
<option value="fill">Fill — stretch to fit exactly</option>
<option value="scale-down">Scale Down — shrink to fit, never enlarge</option>
<option value="cover">Cover — fill, crop if needed</option>
<option value="contain">Contain — fit whole image</option>
<option value="fill">Fill — stretch to fit</option>
<option value="scale-down">Scale Down — shrink only</option>
<option value="auto">Auto — original size</option>
</select>
<div class="form-text">How the background image fills the slide area.</div>
</div>
<div class="mb-3">
<label asp-for="CustomCss" class="form-label">Custom CSS</label>
@@ -103,19 +102,68 @@
function initTinyMCE() {
if (tinymce.get('contentEditor')) return;
tinymce.init({
selector: '#contentEditor', height: 500,
selector: '#contentEditor',
height: 500,
license_key: 'gpl',
menubar: 'file edit view insert format table',
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table help wordcount',
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | image media table | removeformat code fullscreen',
toolbar: 'undo redo | blocks fontsize | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | image media table | removeformat code fullscreen',
font_size_formats: '8pt 10pt 12pt 14pt 16pt 18pt 20pt 24pt 28pt 32pt 36pt 48pt 64pt 72pt 96pt',
images_upload_handler: function (blobInfo) {
return new Promise(function (resolve, reject) {
var fd = new FormData(); fd.append('file', blobInfo.blob(), blobInfo.filename());
fetch('/api/upload', { method: 'POST', body: fd }).then(r => r.json()).then(d => resolve(d.location)).catch(e => reject(e));
});
},
content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; color: #fff; background: #1a1a2e; }',
skin: 'oxide-dark', content_css: 'dark', promotion: false, branding: false
file_picker_types: 'image',
file_picker_callback: function (cb, value, meta) {
fetch('/api/listuploads').then(r => r.json()).then(function (files) {
var input = document.createElement('input');
input.type = 'file'; input.accept = 'image/*';
if (files && files.length > 0) {
var dialog = document.createElement('div');
dialog.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:99999;display:flex;align-items:center;justify-content:center;';
var panel = document.createElement('div');
panel.style.cssText = 'background:#fff;border-radius:12px;padding:1.5em;max-width:700px;max-height:80vh;overflow-y:auto;width:90%;';
panel.innerHTML = '<h4 style="margin:0 0 1em;color:#333;">Select Image or Upload New</h4>';
var grid = document.createElement('div');
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin-bottom:1em;';
files.forEach(function (f) {
var thumb = document.createElement('div');
thumb.style.cssText = 'cursor:pointer;border:2px solid #ddd;border-radius:8px;overflow:hidden;aspect-ratio:1;background:#f0f0f0;';
thumb.innerHTML = '<img src="' + f.value + '" style="width:100%;height:100%;object-fit:cover;" />';
thumb.title = f.title;
thumb.addEventListener('click', function () { cb(f.value, { title: f.title }); document.body.removeChild(dialog); });
thumb.addEventListener('mouseenter', function () { this.style.borderColor = '#3b6fd4'; });
thumb.addEventListener('mouseleave', function () { this.style.borderColor = '#ddd'; });
grid.appendChild(thumb);
});
panel.appendChild(grid);
var uploadBtn = document.createElement('button');
uploadBtn.textContent = 'Upload New Image';
uploadBtn.style.cssText = 'background:#3b6fd4;color:#fff;border:none;padding:0.6em 1.5em;border-radius:6px;cursor:pointer;margin-right:0.5em;';
uploadBtn.addEventListener('click', function () { document.body.removeChild(dialog); input.click(); });
var cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.style.cssText = 'background:#eee;color:#333;border:none;padding:0.6em 1.5em;border-radius:6px;cursor:pointer;';
cancelBtn.addEventListener('click', function () { document.body.removeChild(dialog); });
panel.appendChild(uploadBtn); panel.appendChild(cancelBtn);
dialog.appendChild(panel);
dialog.addEventListener('click', function (e) { if (e.target === dialog) document.body.removeChild(dialog); });
document.body.appendChild(dialog);
} else { input.click(); }
input.addEventListener('change', function () {
if (!this.files[0]) return;
var fd = new FormData(); fd.append('file', this.files[0]);
fetch('/api/upload', { method: 'POST', body: fd }).then(r => r.json()).then(d => cb(d.location, { title: this.files[0].name }));
});
}).catch(function () { input.click(); });
},
content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; color: #000; background: #fff; }',
skin: 'oxide',
content_css: 'default',
promotion: false,
branding: false
});
}
if (document.getElementById('slideType').value === '0') initTinyMCE();