Compare commits

...
20 Commits
Author SHA1 Message Date
jessikitty 5f6dbcf585 feat: Add [Authorize] to SlidesController 2026-05-21 13:57:42 +10:00
jessikitty eb409d9778 feat: Add [Authorize] to AdminController 2026-05-21 13:56:40 +10:00
jessikitty d17fd89e40 feat: Add admin credentials config (default: admin / ScratchingPost2026!) 2026-05-21 13:56:14 +10:00
jessikitty 53a33b558a feat: Add cookie authentication, account routes 2026-05-21 13:55:42 +10:00
jessikitty 64b0a5e62f feat: Login page view 2026-05-21 13:55:14 +10:00
jessikitty 3bf91892f6 feat: Add authentication — AccountController with cookie auth login/logout 2026-05-21 13:54:59 +10:00
jessikitty 4fffec4919 feat: Self-host TinyMCE — switch CDN to /lib/tinymce/ 2026-05-21 11:05:30 +10:00
jessikitty 5c3d3805d1 feat: Self-host TinyMCE — switch CDN to /lib/tinymce/ 2026-05-21 11:05:00 +10:00
jessikitty 29a7af4950 feat: Use BackgroundSize in Preview view 2026-05-21 10:19:51 +10:00
jessikitty 7da0aed466 feat: Use backgroundSize in slide rendering + buildBgStyle helper 2026-05-21 10:18:41 +10:00
jessikitty dddd3188ce feat: Pass backgroundSize to display engine 2026-05-21 10:16:57 +10:00
jessikitty 88d70926f4 feat: Add BackgroundSize dropdown to Edit view 2026-05-21 10:16:05 +10:00
jessikitty f4aec96ce0 feat: Add BackgroundSize dropdown to Create view 2026-05-21 10:15:01 +10:00
jessikitty 58c5f30b0d feat: Add theme toggle button to sidebar + flash-prevention script 2026-05-21 10:14:00 +10:00
jessikitty a060469adc feat: Theme toggle with localStorage persistence 2026-05-21 10:13:23 +10:00
jessikitty c154ea86a2 feat: Light/dark mode CSS with theme toggle support 2026-05-21 10:12:47 +10:00
jessikitty 37785c48e0 feat: Pass backgroundSize in playlist API response 2026-05-21 10:11:49 +10:00
jessikitty c0acb067b3 feat: Handle BackgroundSize in SlidesController Edit 2026-05-21 10:11:30 +10:00
jessikitty 9b4e2677dc feat: Add BackgroundSize property to Slide model 2026-05-21 10:11:09 +10:00
jessikitty c7e82a47e9 fix: Preview.cshtml — replace @: syntax with @Html.Raw for CSS output 2026-05-21 09:36:57 +10:00
16 changed files with 407 additions and 70 deletions
+63
View File
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace NoticeBoard.Controllers;
public class AccountController : Controller
{
private readonly IConfiguration _config;
public AccountController(IConfiguration config)
{
_config = config;
}
[HttpGet]
public IActionResult Login(string? returnUrl = null)
{
if (User.Identity?.IsAuthenticated == true)
return RedirectToAction("Index", "Admin");
ViewBag.ReturnUrl = returnUrl;
return View();
}
[HttpPost]
public async Task<IActionResult> Login(string username, string password, string? returnUrl = null)
{
var adminUser = _config["Admin:Username"] ?? "admin";
var adminPass = _config["Admin:Password"] ?? "admin";
if (username == adminUser && password == adminPass)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
return Redirect(returnUrl);
return RedirectToAction("Index", "Admin");
}
ViewBag.Error = "Invalid username or password.";
ViewBag.ReturnUrl = returnUrl;
return View();
}
[HttpGet]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToAction("Login");
}
}
+2
View File
@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NoticeBoard.Data;
namespace NoticeBoard.Controllers;
[Authorize]
public class AdminController : Controller
{
private readonly AppDbContext _db;
+1
View File
@@ -50,6 +50,7 @@ public class DisplayController : Controller
icsSource = ds.Slide.IcsSource,
backgroundColor = ds.Slide.BackgroundColor,
backgroundImage = ds.Slide.BackgroundImage,
backgroundSize = ds.Slide.BackgroundSize,
customCss = ds.Slide.CustomCss,
duration = ds.DurationSeconds,
updatedAt = ds.Slide.UpdatedAt
+3
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NoticeBoard.Data;
@@ -5,6 +6,7 @@ using NoticeBoard.Models;
namespace NoticeBoard.Controllers;
[Authorize]
public class SlidesController : Controller
{
private readonly AppDbContext _db;
@@ -70,6 +72,7 @@ public class SlidesController : Controller
existing.CustomCss = slide.CustomCss;
existing.BackgroundColor = slide.BackgroundColor;
existing.BackgroundImage = slide.BackgroundImage;
existing.BackgroundSize = slide.BackgroundSize;
existing.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
+4
View File
@@ -40,6 +40,10 @@ public class Slide
[MaxLength(500)]
public string? BackgroundImage { get; set; }
/// <summary>Background image sizing: cover, contain, fill, scale-down, auto</summary>
[MaxLength(20)]
public string BackgroundSize { get; set; } = "cover";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+19 -4
View File
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.Cookies;
using NoticeBoard.Data;
var builder = WebApplication.CreateBuilder(args);
@@ -11,9 +12,19 @@ builder.Services.AddDbContext<AppDbContext>(options =>
builder.Services.AddControllersWithViews();
builder.Services.AddHttpClient();
// Cookie authentication for admin panel
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/account/login";
options.LogoutPath = "/account/logout";
options.ExpireTimeSpan = TimeSpan.FromHours(12);
options.SlidingExpiration = true;
});
var app = builder.Build();
// Auto-create database on startup (use Migrate() if using EF migrations)
// Auto-create database on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -28,12 +39,19 @@ if (!app.Environment.IsDevelopment())
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// Ensure uploads directory exists
var uploadsPath = Path.Combine(app.Environment.WebRootPath, "uploads");
if (!Directory.Exists(uploadsPath))
Directory.CreateDirectory(uploadsPath);
app.MapControllerRoute(
name: "account",
pattern: "account/{action=Login}",
defaults: new { controller = "Account" });
app.MapControllerRoute(
name: "admin",
pattern: "admin/{action=Index}/{id?}",
@@ -54,20 +72,17 @@ app.MapControllerRoute(
pattern: "api/{action}/{id?}",
defaults: new { controller = "Api" });
// Display route: /{slug} — must be last to act as catch-all
app.MapControllerRoute(
name: "display",
pattern: "d/{slug}",
defaults: new { controller = "Display", action = "Show" });
// Also support root-level slugs
app.MapControllerRoute(
name: "display-root",
pattern: "{slug}",
defaults: new { controller = "Display", action = "Show" },
constraints: new { slug = new NoticeBoard.Routing.DeviceSlugConstraint() });
// Default route goes to admin
app.MapControllerRoute(
name: "default",
pattern: "",
+45
View File
@@ -0,0 +1,45 @@
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sign In — Scratching Post</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<style>
body { background: #f4f6f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; }
.login-card { width: 100%; max-width: 400px; }
.login-brand { text-align: center; margin-bottom: 2em; }
.login-brand i { font-size: 2.5em; color: #d48806; }
.login-brand h1 { font-size: 1.6em; font-weight: 700; margin: 0.3em 0 0; color: #1a1a2e; }
.login-brand p { color: #6b7280; font-size: 0.85em; }
</style>
</head>
<body>
<div class="login-card">
<div class="login-brand">
<i class="bi bi-sun"></i>
<h1>Sunbeam</h1>
<p>Scratching Post Admin</p>
</div>
<div class="card shadow-sm">
<div class="card-body p-4">
@if (ViewBag.Error != null)
{
<div class="alert alert-danger py-2"><i class="bi bi-exclamation-circle me-1"></i>@ViewBag.Error</div>
}
<form method="post" asp-action="Login">
@if (ViewBag.ReturnUrl != null) { <input type="hidden" name="returnUrl" value="@ViewBag.ReturnUrl" /> }
<div class="mb-3"><label class="form-label">Username</label><input type="text" name="username" class="form-control" required autofocus /></div>
<div class="mb-4"><label class="form-label">Password</label><input type="password" name="password" class="form-control" required /></div>
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-box-arrow-in-right me-1"></i>Sign In</button>
</form>
</div>
</div>
<p class="text-center text-muted mt-3" style="font-size:0.8em;">© Jess Rogerson — 2026</p>
</div>
</body>
</html>
+1
View File
@@ -25,6 +25,7 @@
id = ds.Slide.Id, name = ds.Slide.Name, type = ds.Slide.SlideType.ToString().ToLower(),
content = ds.Slide.Content, embedUrl = ds.Slide.EmbedUrl, icsSource = ds.Slide.IcsSource,
backgroundColor = ds.Slide.BackgroundColor, backgroundImage = ds.Slide.BackgroundImage,
backgroundSize = ds.Slide.BackgroundSize,
customCss = ds.Slide.CustomCss, duration = ds.DurationSeconds
})));
</script>
+11 -1
View File
@@ -7,6 +7,13 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<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';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
@RenderSection("Styles", required: false)
</head>
<body>
@@ -42,7 +49,10 @@
</ul>
<div class="sidebar-footer">
<small>Sunbeam Framework v1.0</small>
<small>Sunbeam v1.0</small>
<button id="themeToggle" class="theme-toggle" title="Toggle theme">
<i class="bi bi-sun"></i>
</button>
</div>
</nav>
+18 -12
View File
@@ -62,6 +62,17 @@
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="uploadBackgroundImage()"><i class="bi bi-upload me-1"></i>Upload</button>
<input type="file" id="bgImageUpload" class="d-none" accept="image/*" />
</div>
<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="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>
<textarea asp-for="CustomCss" class="form-control font-monospace" rows="4" placeholder="color: white;&#10;font-family: Arial;"></textarea>
@@ -81,7 +92,7 @@
}
@section Scripts {
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
<script src="/lib/tinymce/tinymce.min.js"></script>
<script>
document.getElementById('slideType').addEventListener('change', function () {
document.getElementById('contentSection').style.display = this.value === '0' ? '' : 'none';
@@ -94,17 +105,14 @@
if (tinymce.get('contentEditor')) return;
tinymce.init({
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',
images_upload_url: '/api/upload',
images_upload_handler: function (blobInfo, progress) {
images_upload_handler: function (blobInfo) {
return new Promise(function (resolve, reject) {
var formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
fetch('/api/upload', { method: 'POST', body: formData })
.then(r => r.json()).then(data => resolve(data.location))
.catch(err => reject('Upload failed: ' + err));
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; }',
@@ -115,13 +123,11 @@
document.getElementById('bgColorPicker').addEventListener('input', function () { document.getElementById('BackgroundColor').value = this.value; });
function uploadBackgroundImage() { document.getElementById('bgImageUpload').click(); }
document.getElementById('bgImageUpload').addEventListener('change', function () {
if (!this.files[0]) return;
var fd = new FormData(); fd.append('file', this.files[0]);
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 => { document.getElementById('BackgroundImage').value = d.location; });
});
document.getElementById('icsFileUpload')?.addEventListener('change', function () {
if (!this.files[0]) return;
var fd = new FormData(); fd.append('file', this.files[0]);
if (!this.files[0]) return; var fd = new FormData(); fd.append('file', this.files[0]);
fetch('/api/uploadfile', { method: 'POST', body: fd }).then(r => r.json()).then(d => { document.getElementById('IcsSource').value = d.url; });
});
</script>
+15 -2
View File
@@ -62,6 +62,17 @@
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" onclick="uploadBackgroundImage()"><i class="bi bi-upload me-1"></i>Upload</button>
<input type="file" id="bgImageUpload" class="d-none" accept="image/*" />
</div>
<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="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>
<textarea asp-for="CustomCss" class="form-control font-monospace" rows="4"></textarea>
@@ -81,7 +92,7 @@
}
@section Scripts {
<script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>
<script src="/lib/tinymce/tinymce.min.js"></script>
<script>
document.getElementById('slideType').addEventListener('change', function () {
document.getElementById('contentSection').style.display = this.value === '0' ? '' : 'none';
@@ -92,7 +103,9 @@
function initTinyMCE() {
if (tinymce.get('contentEditor')) return;
tinymce.init({
selector: '#contentEditor', height: 500, menubar: 'file edit view insert format table',
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',
images_upload_handler: function (blobInfo) {
+54 -12
View File
@@ -1,6 +1,11 @@
@model Slide
@{
Layout = null;
var bgStyle = $"background: {Model.BackgroundColor ?? "#1a1a2e"};";
if (!string.IsNullOrEmpty(Model.BackgroundImage))
{
bgStyle += $" background-image: url('{Model.BackgroundImage}'); background-size: {Model.BackgroundSize ?? "cover"}; background-position: center; background-repeat: no-repeat;";
}
}
<!DOCTYPE html>
<html lang="en">
@@ -11,31 +16,68 @@
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { background: @(Model.BackgroundColor ?? "#1a1a2e"); color: #fff; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@if (!string.IsNullOrEmpty(Model.BackgroundImage)) { @:background-image: url('@Model.BackgroundImage'); @:background-size: cover; @:background-position: center; }
body {
@Html.Raw(bgStyle)
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.slide-content {
width: 100%;
height: 100%;
@Html.Raw(Model.CustomCss ?? "")
}
.slide-content { width: 100%; height: 100%; @Html.Raw(Model.CustomCss ?? "") }
.slide-content img { max-width: 100%; height: auto; }
.slide-content table { border-collapse: collapse; }
.slide-content table td, .slide-content table th { border: 1px solid rgba(255,255,255,0.3); padding: 8px 12px; }
iframe.embed-frame { width: 100%; height: 100%; border: none; }
.ics-events { padding: 2em; } .ics-events h2 { margin-bottom: 1em; font-size: 2em; }
.ics-events { padding: 2em; }
.ics-events h2 { margin-bottom: 1em; font-size: 2em; }
.event-card { background: rgba(255,255,255,0.1); border-radius: 12px; padding: 1.2em; margin-bottom: 1em; }
.event-card h3 { font-size: 1.3em; margin-bottom: 0.3em; }
.event-meta { opacity: 0.7; font-size: 0.9em; }
.back-link { position: fixed; top: 10px; right: 10px; z-index: 999; }
</style>
</head>
<body>
<a href="/admin/slides" class="back-link btn btn-sm btn-light"><i class="bi bi-x-lg"></i> Close</a>
@if (Model.SlideType == SlideType.Content) { <div class="slide-content">@Html.Raw(Model.Content ?? "")</div> }
else if (Model.SlideType == SlideType.Embed) { <iframe class="embed-frame" src="@Model.EmbedUrl"></iframe> }
else if (Model.SlideType == SlideType.IcsCalendar) {
<div class="ics-events" id="icsContainer"><h2>Upcoming Events</h2><p>Loading...</p></div>
@if (Model.SlideType == SlideType.Content)
{
<div class="slide-content">@Html.Raw(Model.Content ?? "")</div>
}
else if (Model.SlideType == SlideType.Embed)
{
<iframe class="embed-frame" src="@Model.EmbedUrl"></iframe>
}
else if (Model.SlideType == SlideType.IcsCalendar)
{
<div class="ics-events" id="icsContainer">
<h2><i class="bi bi-calendar-event me-2"></i>Upcoming Events</h2>
<p>Loading calendar...</p>
</div>
<script>
fetch('/api/parseics?url=' + encodeURIComponent('@Model.IcsSource')).then(r => r.json()).then(data => {
var html = '<h2>Upcoming Events</h2>';
if (data.events && data.events.length > 0) { data.events.forEach(function(e) { html += '<div class="event-card"><h3>' + (e.summary||'Event') + '</h3><div style="opacity:0.7;">' + (e.start||'') + '</div></div>'; }); }
else { html += '<p>No upcoming events.</p>'; }
fetch('/api/parseics?url=' + encodeURIComponent('@Model.IcsSource'))
.then(r => r.json())
.then(data => {
var html = '<h2><i class="bi bi-calendar-event" style="margin-right:0.5em;"></i>Upcoming Events</h2>';
if (data.events && data.events.length > 0) {
data.events.forEach(function(e) {
html += '<div class="event-card"><h3>' + (e.summary || 'Event') + '</h3>';
html += '<div class="event-meta">';
if (e.start) html += '<i class="bi bi-clock"></i> ' + e.start;
if (e.end) html += ' — ' + e.end;
if (e.location) html += ' | <i class="bi bi-geo-alt"></i> ' + e.location;
html += '</div>';
if (e.description) html += '<p style="margin-top:0.5em;opacity:0.8;">' + e.description + '</p>';
html += '</div>';
});
} else {
html += '<p>No upcoming events found.</p>';
}
document.getElementById('icsContainer').innerHTML = html;
})
.catch(err => {
document.getElementById('icsContainer').innerHTML = '<h2>Calendar</h2><p>Error loading calendar: ' + err + '</p>';
});
</script>
}
+5 -1
View File
@@ -6,5 +6,9 @@
}
},
"AllowedHosts": "*",
"MaxUploadSizeMB": 20
"MaxUploadSizeMB": 20,
"Admin": {
"Username": "admin",
"Password": "ScratchingPost2026!"
}
}
+104 -27
View File
@@ -1,67 +1,144 @@
/* === Scratching Post Admin — Sunbeam Theme === */
:root {
--sb-bg: #0f0f1a; --sb-surface: #1a1a2e; --sb-surface2: #232340;
--sb-border: rgba(255, 255, 255, 0.08); --sb-text: #e0e0e0; --sb-text-muted: #8888aa;
--sb-accent: #f0a030; --sb-accent2: #ff6b6b; --sb-primary: #5b8def; --sidebar-width: 240px;
/* Dark mode (default) */
:root, [data-theme="dark"] {
--sb-bg: #0f0f1a;
--sb-surface: #1a1a2e;
--sb-surface2: #232340;
--sb-border: rgba(255, 255, 255, 0.08);
--sb-text: #e0e0e0;
--sb-text-muted: #8888aa;
--sb-accent: #f0a030;
--sb-accent2: #ff6b6b;
--sb-primary: #5b8def;
--sb-btn-text: #fff;
--sb-input-bg: #0f0f1a;
--sb-card-header-bg: rgba(255, 255, 255, 0.02);
--sb-hover-bg: rgba(255, 255, 255, 0.04);
--sb-active-bg: rgba(240, 160, 48, 0.08);
--sidebar-width: 240px;
}
/* Light mode */
[data-theme="light"] {
--sb-bg: #f4f6f9;
--sb-surface: #ffffff;
--sb-surface2: #e9ecf0;
--sb-border: rgba(0, 0, 0, 0.1);
--sb-text: #1a1a2e;
--sb-text-muted: #6b7280;
--sb-accent: #d48806;
--sb-accent2: #e04040;
--sb-primary: #3b6fd4;
--sb-btn-text: #fff;
--sb-input-bg: #ffffff;
--sb-card-header-bg: rgba(0, 0, 0, 0.02);
--sb-hover-bg: rgba(0, 0, 0, 0.03);
--sb-active-bg: rgba(212, 136, 6, 0.08);
}
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; background: var(--sb-bg); color: var(--sb-text); min-height: 100vh; }
body { margin: 0; font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif; background: var(--sb-bg); color: var(--sb-text); min-height: 100vh; transition: background 0.3s, color 0.3s; }
/* === Layout === */
.admin-wrapper { display: flex; min-height: 100vh; }
.admin-sidebar { width: var(--sidebar-width); background: var(--sb-surface); border-right: 1px solid var(--sb-border); display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; }
/* === Sidebar === */
.admin-sidebar { width: var(--sidebar-width); background: var(--sb-surface); border-right: 1px solid var(--sb-border); display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; transition: background 0.3s; }
.sidebar-brand { padding: 1.5em 1.2em 0.3em; font-size: 1.4em; font-weight: 700; color: var(--sb-accent); display: flex; align-items: center; gap: 0.5em; }
.sidebar-brand i { font-size: 1.2em; }
.sidebar-subtitle { padding: 0 1.2em 1.2em; font-size: 0.75em; color: var(--sb-text-muted); text-transform: uppercase; letter-spacing: 0.1em; }
.sidebar-nav { list-style: none; padding: 0; margin: 0; flex: 1; }
.sidebar-nav li a { display: flex; align-items: center; gap: 0.75em; padding: 0.8em 1.2em; color: var(--sb-text-muted); text-decoration: none; border-left: 3px solid transparent; transition: all 0.15s ease; font-size: 0.95em; }
.sidebar-nav li a:hover { background: rgba(255, 255, 255, 0.04); color: var(--sb-text); }
.sidebar-nav li a.active { background: rgba(240, 160, 48, 0.08); color: var(--sb-accent); border-left-color: var(--sb-accent); }
.sidebar-nav li a:hover { background: var(--sb-hover-bg); color: var(--sb-text); }
.sidebar-nav li a.active { background: var(--sb-active-bg); color: var(--sb-accent); border-left-color: var(--sb-accent); }
.sidebar-nav li a i { font-size: 1.1em; width: 1.2em; text-align: center; }
.nav-hint { margin-left: auto; font-size: 0.75em; opacity: 0.5; }
.sidebar-footer { padding: 1em 1.2em; border-top: 1px solid var(--sb-border); color: var(--sb-text-muted); font-size: 0.75em; }
.sidebar-footer { padding: 1em 1.2em; border-top: 1px solid var(--sb-border); color: var(--sb-text-muted); font-size: 0.75em; display: flex; align-items: center; justify-content: space-between; }
/* === Theme Toggle === */
.theme-toggle { background: none; border: 1px solid var(--sb-border); color: var(--sb-text-muted); border-radius: 6px; padding: 0.3em 0.5em; cursor: pointer; font-size: 1em; transition: all 0.2s; display: flex; align-items: center; }
.theme-toggle:hover { color: var(--sb-accent); border-color: var(--sb-accent); }
/* === Main Content === */
.admin-main { margin-left: var(--sidebar-width); flex: 1; display: flex; flex-direction: column; min-height: 100vh; }
.admin-header { padding: 1.5em 2em 1em; border-bottom: 1px solid var(--sb-border); display: flex; justify-content: space-between; align-items: center; }
.admin-header h1 { font-size: 1.5em; font-weight: 600; margin: 0; }
.admin-content { padding: 1.5em 2em 3em; flex: 1; }
.card { background: var(--sb-surface); border: 1px solid var(--sb-border); border-radius: 10px; color: var(--sb-text); }
.card-header { background: rgba(255, 255, 255, 0.02); border-bottom: 1px solid var(--sb-border); padding: 0.9em 1.2em; }
/* === Cards === */
.card { background: var(--sb-surface); border: 1px solid var(--sb-border); border-radius: 10px; color: var(--sb-text); transition: background 0.3s; }
.card-header { background: var(--sb-card-header-bg); border-bottom: 1px solid var(--sb-border); padding: 0.9em 1.2em; }
.card-body { padding: 1.2em; }
.card-footer { background: rgba(255, 255, 255, 0.02); border-top: 1px solid var(--sb-border); padding: 0.8em 1.2em; }
.stat-card { background: var(--sb-surface); border: 1px solid var(--sb-border); border-radius: 12px; padding: 1.5em; display: flex; align-items: center; gap: 1.2em; transition: transform 0.2s; }
.card-footer { background: var(--sb-card-header-bg); border-top: 1px solid var(--sb-border); padding: 0.8em 1.2em; }
/* === Stat Cards === */
.stat-card { background: var(--sb-surface); border: 1px solid var(--sb-border); border-radius: 12px; padding: 1.5em; display: flex; align-items: center; gap: 1.2em; transition: transform 0.2s, background 0.3s; }
.stat-card:hover { transform: translateY(-2px); }
.stat-card.accent { border-color: var(--sb-accent); background: rgba(240, 160, 48, 0.06); }
.stat-card.accent { border-color: var(--sb-accent); background: var(--sb-active-bg); }
.stat-icon { font-size: 2em; color: var(--sb-accent); width: 1.5em; text-align: center; }
.stat-value { font-size: 1.8em; font-weight: 700; line-height: 1; }
.stat-label { color: var(--sb-text-muted); font-size: 0.85em; margin-top: 0.2em; }
.table { color: var(--sb-text); --bs-table-bg: transparent; --bs-table-hover-bg: rgba(255, 255, 255, 0.03); }
/* === Tables === */
.table { color: var(--sb-text); --bs-table-bg: transparent; --bs-table-hover-bg: var(--sb-hover-bg); }
.table thead th { border-bottom-color: var(--sb-border); color: var(--sb-text-muted); font-weight: 500; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.05em; }
.table td { border-color: var(--sb-border); }
.form-control, .form-select { background: var(--sb-bg); border-color: var(--sb-border); color: var(--sb-text); }
.form-control:focus, .form-select:focus { background: var(--sb-bg); border-color: var(--sb-accent); color: var(--sb-text); box-shadow: 0 0 0 0.2rem rgba(240, 160, 48, 0.15); }
/* === Forms === */
.form-control, .form-select { background: var(--sb-input-bg); border-color: var(--sb-border); color: var(--sb-text); }
.form-control:focus, .form-select:focus { background: var(--sb-input-bg); border-color: var(--sb-accent); color: var(--sb-text); box-shadow: 0 0 0 0.2rem rgba(240, 160, 48, 0.15); }
.form-label { font-weight: 500; font-size: 0.9em; }
.form-text { color: var(--sb-text-muted); }
.input-group-text { background: var(--sb-surface2); border-color: var(--sb-border); color: var(--sb-text-muted); }
.form-check-input { background-color: var(--sb-input-bg); border-color: var(--sb-border); }
.form-check-input:checked { background-color: var(--sb-accent); border-color: var(--sb-accent); }
.btn-primary { background: var(--sb-primary); border-color: var(--sb-primary); }
.btn-primary:hover { background: #4a7ddf; border-color: #4a7ddf; }
/* === Buttons === */
.btn-primary { background: var(--sb-primary); border-color: var(--sb-primary); color: var(--sb-btn-text); }
.btn-primary:hover { filter: brightness(0.9); }
.btn-outline-secondary { color: var(--sb-text-muted); border-color: var(--sb-border); }
.btn-outline-secondary:hover { background: rgba(255, 255, 255, 0.05); color: var(--sb-text); border-color: var(--sb-text-muted); }
.btn-outline-secondary:hover { background: var(--sb-hover-bg); color: var(--sb-text); border-color: var(--sb-text-muted); }
/* === List Groups === */
.list-group-item { background: transparent; border-color: var(--sb-border); color: var(--sb-text); }
.list-group-item:hover { background: rgba(255, 255, 255, 0.02); }
.alert-success { background: rgba(40, 167, 69, 0.15); border-color: rgba(40, 167, 69, 0.3); color: #6bdb8a; }
.alert-danger { background: rgba(220, 53, 69, 0.15); border-color: rgba(220, 53, 69, 0.3); color: #f08090; }
.alert-warning { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.3); color: #ffd54f; }
.badge { font-weight: 500; } .badge-sm { font-size: 0.7em; padding: 0.2em 0.5em; }
.list-group-item:hover { background: var(--sb-hover-bg); }
/* === Alerts === */
.alert-success { background: rgba(40, 167, 69, 0.12); border-color: rgba(40, 167, 69, 0.25); color: #28a745; }
.alert-danger { background: rgba(220, 53, 69, 0.12); border-color: rgba(220, 53, 69, 0.25); color: #dc3545; }
.alert-warning { background: rgba(255, 193, 7, 0.12); border-color: rgba(255, 193, 7, 0.25); color: #d48806; }
[data-theme="dark"] .alert-success { color: #6bdb8a; }
[data-theme="dark"] .alert-danger { color: #f08090; }
[data-theme="dark"] .alert-warning { color: #ffd54f; }
/* === Badges === */
.badge { font-weight: 500; }
.badge-sm { font-size: 0.7em; padding: 0.2em 0.5em; }
/* === Playlist Items === */
.playlist-item { transition: background 0.15s; }
.playlist-item.sortable-ghost { background: rgba(240, 160, 48, 0.1); opacity: 0.6; }
.playlist-item.sortable-ghost { background: var(--sb-active-bg); opacity: 0.6; }
.drag-handle { cursor: grab; color: var(--sb-text-muted); font-size: 1.2em; padding: 0.2em; }
.drag-handle:hover { color: var(--sb-accent); }
.duration-input input { text-align: center; }
/* === Device Cards === */
.device-card { transition: transform 0.2s, border-color 0.2s; }
.device-card:hover { transform: translateY(-2px); border-color: var(--sb-accent); }
/* === Empty State === */
.empty-state { text-align: center; padding: 4em 2em; color: var(--sb-text-muted); }
/* === Code in light mode === */
[data-theme="light"] code { background: rgba(0,0,0,0.06); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; }
/* === Modal overrides === */
.modal-content { background: var(--sb-surface); color: var(--sb-text); border-color: var(--sb-border); }
/* === Responsive === */
@media (max-width: 768px) {
.admin-sidebar { width: 60px; overflow: hidden; }
.admin-sidebar span, .admin-sidebar .sidebar-subtitle, .admin-sidebar .sidebar-footer, .admin-sidebar .nav-hint { display: none; }
.admin-sidebar span, .admin-sidebar .sidebar-subtitle, .admin-sidebar .sidebar-footer small, .admin-sidebar .nav-hint { display: none; }
.sidebar-brand { justify-content: center; padding: 1em 0.5em; }
.sidebar-nav li a { justify-content: center; padding: 0.8em; }
.admin-main { margin-left: 60px; }
+41 -2
View File
@@ -1,7 +1,46 @@
// === Scratching Post Admin JS ===
// === Theme Toggle ===
(function () {
var saved = localStorage.getItem('sb-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
document.addEventListener('DOMContentLoaded', function () {
updateToggleIcon(saved);
var btn = document.getElementById('themeToggle');
if (btn) {
btn.addEventListener('click', function () {
var current = document.documentElement.getAttribute('data-theme') || 'dark';
var next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('sb-theme', next);
updateToggleIcon(next);
});
}
});
function updateToggleIcon(theme) {
var btn = document.getElementById('themeToggle');
if (!btn) return;
btn.innerHTML = theme === 'dark'
? '<i class="bi bi-sun"></i>'
: '<i class="bi bi-moon-stars"></i>';
btn.title = theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode';
}
})();
// Auto-dismiss alerts after 5 seconds
document.querySelectorAll('.alert-dismissible').forEach(function (alert) {
setTimeout(function () { var bsAlert = bootstrap.Alert.getOrCreateInstance(alert); bsAlert.close(); }, 5000);
setTimeout(function () {
var bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
bsAlert.close();
}, 5000);
});
// Confirm dangerous actions
document.querySelectorAll('[data-confirm]').forEach(function (el) {
el.addEventListener('click', function (e) { if (!confirm(this.dataset.confirm)) e.preventDefault(); });
el.addEventListener('click', function (e) {
if (!confirm(this.dataset.confirm)) e.preventDefault();
});
});
+19 -7
View File
@@ -19,19 +19,31 @@
layerA.classList.add('active'); return;
}
function renderSlide(slide, layer) {
function buildBgStyle(slide) {
var bg = slide.backgroundColor || '#0a0a14';
var bgImg = slide.backgroundImage ? 'url(' + slide.backgroundImage + ')' : 'none';
var css = slide.customCss || '';
var bgSize = slide.backgroundSize || 'cover';
var style = 'background-color:' + bg + ';';
if (slide.backgroundImage) {
style += 'background-image:url(' + slide.backgroundImage + ');';
style += 'background-size:' + bgSize + ';';
style += 'background-position:center;';
style += 'background-repeat:no-repeat;';
}
if (slide.customCss) style += slide.customCss;
return style;
}
function renderSlide(slide, layer) {
var style = buildBgStyle(slide);
if (slide.type === 'content') {
layer.innerHTML = '<div class="slide-inner" style="background-color:' + bg + ';background-image:' + bgImg + ';' + css + '"><div class="content-wrap">' + (slide.content || '') + '</div></div>';
layer.innerHTML = '<div class="slide-inner" style="' + style + '"><div class="content-wrap">' + (slide.content || '') + '</div></div>';
} else if (slide.type === 'embed') {
layer.innerHTML = '<div class="slide-inner" style="background-color:' + bg + ';' + css + '"><iframe class="embed-frame" src="' + escapeHtml(slide.embedUrl || '') + '" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>';
layer.innerHTML = '<div class="slide-inner" style="' + style + '"><iframe class="embed-frame" src="' + escapeHtml(slide.embedUrl || '') + '" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>';
} else if (slide.type === 'icscalendar') {
layer.innerHTML = '<div class="slide-inner" style="background-color:' + bg + ';' + css + '"><div class="ics-display"><h2>Upcoming Events</h2><div class="events-grid"><div class="loading-state">Loading calendar</div></div></div></div>';
layer.innerHTML = '<div class="slide-inner" style="' + style + '"><div class="ics-display"><h2>Upcoming Events</h2><div class="events-grid"><div class="loading-state">Loading calendar</div></div></div></div>';
loadIcsEvents(slide.icsSource, layer);
} else {
layer.innerHTML = '<div class="slide-inner" style="background-color:' + bg + ';"><div class="content-wrap">' + (slide.content || '') + '</div></div>';
layer.innerHTML = '<div class="slide-inner" style="' + style + '"><div class="content-wrap">' + (slide.content || '') + '</div></div>';
}
}