← back to Crazy News Channel Shadowman
Add admin panel: create/delete/reset controls for breaking stories
c180c52f2bd4c582701beb5c7d3e487f82c6e0a4 · 2026-09-23 14:58:48 -0700 · Steve Abrams
Adds a backstage "control room" admin panel (toggled via a distinctly
styled Admin button, never themed by the broadcast mood system) that
lets Steve create and delete the 3 escalating breaking stories:
- Create form: headline + category (reuses the 6 existing mood categories
plus a new 7th "Uncategorized" category/mood, added to both the admin
select and the viewer's category filter so no admin-assigned category is
an orphan). Empty-headline submission shows a real inline error state
(no alert()); valid submissions synthesize a full 5-stage escalation
ladder by layering escalating bulletin framing onto the admin's headline,
so arbitrary input still plugs into the same DEVELOPING->TRANSCENDENT
timer system, ticker, and completion logic as the seeded stories.
- Delete: per-story Delete buttons in the admin list, working on seeded
and admin-created stories alike. Deleting the last story in a filtered
category correctly falls back to the existing per-category empty state;
deleting every story on the network triggers a new top-level "no stories
currently on the network" state (distinct from the per-category one,
since the page previously assumed >=1 story always existed somewhere).
- Persistence: localStorage-backed (with shape validation + fallback to
defaults on missing/corrupt data), plus a "Reset to Defaults" control.
Bugs found and fixed via the Playwright verification pass (47/47 new
checks + 19/19 original regression checks, zero console errors):
- Admin story row was an implicit-min-width CSS Grid item, pushing its
Delete button off-screen at 390px -- same bug class as the ticker/fieldset
overflow fixed in the initial build; added min-width:0.
- STORIES.every() is vacuously true on an empty array, which would have
falsely fired the "NETWORK OVERLOAD" meltdown banner the instant every
story was deleted -- added an explicit length guard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
Files touched
Diff
commit c180c52f2bd4c582701beb5c7d3e487f82c6e0a4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 14:58:48 2026 -0700
Add admin panel: create/delete/reset controls for breaking stories
Adds a backstage "control room" admin panel (toggled via a distinctly
styled Admin button, never themed by the broadcast mood system) that
lets Steve create and delete the 3 escalating breaking stories:
- Create form: headline + category (reuses the 6 existing mood categories
plus a new 7th "Uncategorized" category/mood, added to both the admin
select and the viewer's category filter so no admin-assigned category is
an orphan). Empty-headline submission shows a real inline error state
(no alert()); valid submissions synthesize a full 5-stage escalation
ladder by layering escalating bulletin framing onto the admin's headline,
so arbitrary input still plugs into the same DEVELOPING->TRANSCENDENT
timer system, ticker, and completion logic as the seeded stories.
- Delete: per-story Delete buttons in the admin list, working on seeded
and admin-created stories alike. Deleting the last story in a filtered
category correctly falls back to the existing per-category empty state;
deleting every story on the network triggers a new top-level "no stories
currently on the network" state (distinct from the per-category one,
since the page previously assumed >=1 story always existed somewhere).
- Persistence: localStorage-backed (with shape validation + fallback to
defaults on missing/corrupt data), plus a "Reset to Defaults" control.
Bugs found and fixed via the Playwright verification pass (47/47 new
checks + 19/19 original regression checks, zero console errors):
- Admin story row was an implicit-min-width CSS Grid item, pushing its
Delete button off-screen at 390px -- same bug class as the ticker/fieldset
overflow fixed in the initial build; added min-width:0.
- STORIES.every() is vacuously true on an empty array, which would have
falsely fired the "NETWORK OVERLOAD" meltdown banner the instant every
story was deleted -- added an explicit length guard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
---
index.html | 424 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 421 insertions(+), 3 deletions(-)
diff --git a/index.html b/index.html
index 46c86cf..445df7a 100644
--- a/index.html
+++ b/index.html
@@ -95,6 +95,11 @@ body[data-mood="scitech"] {
--accent: #00f0ff; --accent2: #ff00e6; --text: #eafcff; --text-dim: #c9f3ffaa;
--chrome: #08081ccc; --focus: #ff00e6;
}
+body[data-mood="uncategorized"] {
+ --bg: #e9edf0; --bg2: #ffffff; --panel: #ffffff; --panel-border: #54657833;
+ --accent: #546578; --accent2: #2f3b47; --text: #20272d; --text-dim: #20272d99;
+ --chrome: #ffffffcc; --focus: #0b3d91;
+}
/* ---------- 3. Chrome (header / nav) ---------- */
.chrome {
@@ -159,6 +164,20 @@ body[data-mood="scitech"] {
.sound-toggle input { width: 1rem; height: 1rem; accent-color: var(--accent); cursor: pointer; }
.audio-note { font-size: .7rem; color: var(--text-dim); max-width: 16rem; }
+/* Admin toggle — deliberately NOT themed by mood vars: fixed slate/mono
+ "control room" look so it never reads as part of the in-universe broadcast. */
+.admin-toggle {
+ cursor: pointer;
+ border: 1.5px dashed #6b7280;
+ background: #20242b; color: #d7dee5;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: .72rem; font-weight: 700; letter-spacing: .3px;
+ padding: .5rem .75rem; border-radius: .4rem;
+ transition: background-color .15s ease, border-color .15s ease, color .15s ease;
+}
+.admin-toggle:hover { background: #2a2f37; }
+.admin-toggle[aria-expanded="true"] { background: #343a40; border-style: solid; border-color: #7dd3fc; color: #7dd3fc; }
+
/* ---------- category filter (nav) ---------- */
.category-filter { padding: .6rem 1rem; border-bottom: 1px solid var(--panel-border); }
.category-filter fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
@@ -332,6 +351,80 @@ body.breaking-mode .ticker-track { color: #ff5959; font-weight: 800; }
margin-top: 1rem; border-color: var(--accent); border-style: solid;
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
}
+.story-card.just-added { outline: 3px dashed var(--accent); outline-offset: 2px; }
+
+/* ---------- 7b. Admin panel (backstage / control-room, not on-air) ----------
+ Deliberately fixed slate/mono palette independent of --mood vars so it
+ never gets confused with the in-universe broadcast chrome. Renders in
+ normal document flow (an expand/collapse section, not an overlay) so it
+ can never overlap content or fight the ticker/vignette z-index stack. */
+.admin-panel {
+ max-width: 1200px;
+ margin: 1rem auto 0;
+ padding: 1rem 1.25rem 1.25rem;
+ border-radius: .6rem;
+ background: #1b1f24;
+ color: #e8eaed;
+ border: 2px dashed #4a5057;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+.admin-panel h2 { font-size: .95rem; letter-spacing: .5px; text-transform: uppercase; color: #7dd3fc; margin-bottom: .15rem; }
+.admin-panel h3 { font-size: .78rem; letter-spacing: .4px; text-transform: uppercase; color: #b8c0c8; margin-bottom: .5rem; }
+.admin-hint { font-size: .72rem; color: #9aa4ad; margin-bottom: 1rem; font-style: italic; }
+.admin-panel hr { border: none; border-top: 1px dashed #3a4048; margin: 1.1rem 0; }
+
+.admin-form { display: grid; gap: .5rem; }
+.admin-form label { font-size: .7rem; text-transform: uppercase; letter-spacing: .4px; color: #b8c0c8; }
+.admin-form input[type="text"], .admin-form select {
+ width: 100%; padding: .5rem .6rem; border-radius: .35rem;
+ border: 1.5px solid #4a5057; background: #262b31; color: #f2f4f6;
+ font-family: inherit; font-size: .85rem;
+}
+.admin-form input[aria-invalid="true"] { border-color: #ff6b6b; }
+.admin-form input[type="text"]:focus-visible,
+.admin-form select:focus-visible,
+.admin-panel button:focus-visible {
+ outline: 3px solid #7dd3fc; outline-offset: 2px;
+}
+.admin-error { font-size: .78rem; color: #ff8b8b; font-weight: 700; }
+.admin-success { font-size: .78rem; color: #7dffb0; font-weight: 700; }
+.admin-submit-btn {
+ justify-self: start; margin-top: .2rem;
+ cursor: pointer; border: none; border-radius: .35rem;
+ padding: .55rem 1.1rem; font-weight: 800; font-size: .82rem;
+ background: #7dd3fc; color: #0b1a24;
+}
+.admin-reset-btn {
+ justify-self: start; margin-top: 1rem;
+ cursor: pointer; border: 1.5px solid #4a5057; border-radius: .35rem;
+ padding: .5rem 1rem; font-weight: 700; font-size: .78rem;
+ background: transparent; color: #e8eaed;
+}
+.admin-reset-btn:hover { background: #262b31; }
+
+.admin-story-list { list-style: none; margin: 0 0 .5rem; padding: 0; display: grid; gap: .5rem; max-height: 16rem; overflow-y: auto; }
+.admin-story-row {
+ /* min-width: 0 overrides the browser's implicit min-width:auto on grid
+ items — without it this row (and its nowrap/ellipsis child text) won't
+ shrink below its content's min-content width, pushing the Delete
+ button off-screen at narrow viewports (the same class of bug as the
+ ticker/fieldset overflow fixed earlier in this project). */
+ min-width: 0;
+ display: flex; align-items: center; justify-content: space-between; gap: .75rem;
+ padding: .5rem .6rem; border: 1px solid #343a40; border-radius: .4rem; background: #22262b;
+}
+.admin-story-info { flex: 1 1 auto; display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
+.admin-story-cat { font-size: .62rem; text-transform: uppercase; letter-spacing: .4px; color: #7dd3fc; }
+.admin-story-headline { font-size: .8rem; color: #e8eaed; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
+.admin-delete-btn {
+ flex: 0 0 auto; cursor: pointer; border: 1.5px solid #ff6b6b; background: transparent;
+ color: #ff8b8b; border-radius: .35rem; padding: .35rem .6rem; font-weight: 700; font-size: .72rem;
+}
+.admin-delete-btn:hover { background: #ff6b6b22; }
+.admin-empty { font-size: .8rem; color: #9aa4ad; padding: .5rem 0; }
+@media (min-width: 768px) {
+ .admin-panel-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
+}
/* ---------- 8. Focus & a11y helpers ---------- */
a, button, input, [tabindex] { outline-offset: 2px; }
@@ -350,6 +443,7 @@ a, button, input, [tabindex] { outline-offset: 2px; }
.chrome { padding: 1rem 1.5rem; }
.category-filter { padding: .75rem 1.5rem; }
main#channelContainer { padding: 1.25rem 1.5rem 1.75rem; }
+ .admin-panel { padding: 1.25rem 1.5rem 1.75rem; }
.story-grid { grid-template-columns: repeat(2, 1fr); }
.story-card.lead { grid-column: 1 / -1; }
}
@@ -357,6 +451,7 @@ a, button, input, [tabindex] { outline-offset: 2px; }
.chrome { padding: 1rem 2rem; }
.category-filter { padding: .75rem 2rem; }
main#channelContainer { padding: 1.5rem 2rem 2rem; }
+ .admin-panel { padding: 1.5rem 2rem 2rem; }
.story-grid {
grid-template-columns: 2fr 1fr;
grid-template-rows: auto auto;
@@ -409,6 +504,10 @@ a, button, input, [tabindex] { outline-offset: 2px; }
Klaxon sound (muted by default)
</label>
<span id="audioNote" class="audio-note" role="status" hidden></span>
+ <button id="adminToggleBtn" class="admin-toggle" type="button"
+ aria-expanded="false" aria-controls="adminPanel">
+ ⚙ Admin
+ </button>
</div>
</header>
@@ -447,10 +546,50 @@ a, button, input, [tabindex] { outline-offset: 2px; }
<input type="radio" name="category" id="cat-business" value="business" />
<label for="cat-business">Business</label>
</span>
+ <span class="chip-item">
+ <input type="radio" name="category" id="cat-uncategorized" value="uncategorized" />
+ <label for="cat-uncategorized">Uncategorized</label>
+ </span>
</div>
</fieldset>
</nav>
+<!-- ============================================================
+ ADMIN PANEL — backstage / control-room tooling for managing
+ the breaking stories. Renders in normal document flow (not an
+ overlay) so it can never overlap broadcast content, and is
+ styled with a fixed slate/mono palette independent of the
+ on-air mood theming so it's unmistakably operator tooling.
+ ============================================================ -->
+<section id="adminPanel" class="admin-panel" hidden aria-label="Admin: manage breaking stories">
+ <h2>Admin — Breaking Stories</h2>
+ <p class="admin-hint">Backstage controls. Not part of the broadcast.</p>
+
+ <div class="admin-panel-body">
+ <div>
+ <h3>Create a story</h3>
+ <form id="adminCreateForm" class="admin-form" novalidate>
+ <label for="adminHeadline">Headline</label>
+ <input type="text" id="adminHeadline" name="headline" autocomplete="off" />
+ <div id="adminHeadlineError" class="admin-error" role="alert" hidden></div>
+
+ <label for="adminCategory">Category</label>
+ <select id="adminCategory" name="category"></select>
+
+ <button type="submit" class="admin-submit-btn">+ Add Story</button>
+ <div id="adminCreateSuccess" class="admin-success" role="status" hidden></div>
+ </form>
+ </div>
+
+ <div>
+ <h3>Current stories (<span id="adminStoryCount">0</span>)</h3>
+ <ul id="adminStoryList" class="admin-story-list"></ul>
+ <p id="adminNoStories" class="admin-empty" hidden>No stories exist. Create one, or reset to defaults.</p>
+ <button id="adminResetBtn" type="button" class="admin-reset-btn">↺ Reset to Defaults</button>
+ </div>
+ </div>
+</section>
+
<!-- BREAKING banner (hidden by default, shown when klaxon toggled ON) -->
<div id="breakingBanner" class="breaking-banner" hidden aria-live="assertive">
<span class="bb-icon" aria-hidden="true">🚨</span>
@@ -469,6 +608,13 @@ a, button, input, [tabindex] { outline-offset: 2px; }
<span class="mood-readout" id="moodReadout">Channel mood: Calm-ish (Default)</span>
</div>
+ <div id="noStoriesState" class="empty-state" hidden>
+ <span class="es-emoji" aria-hidden="true">📡</span>
+ <h3>No stories currently on the network.</h3>
+ <p>P24 has nothing to report right now, which is itself deeply suspicious.<br />
+ Use the admin panel to add a story, or hit <strong>Reset to Defaults</strong>.</p>
+ </div>
+
<div id="storyGrid" class="story-grid"></div>
<div id="emptyState" class="empty-state" hidden>
@@ -519,12 +665,28 @@ const MOODS = {
sports: { label: "Hype Orange" },
entertainment: { label: "Glam Panic" },
business: { label: "Money Green" },
+ uncategorized: { label: "Slate Neutral" },
+};
+
+// Human-readable badge text per category (used for both seeded and
+// admin-created stories, and to populate the admin "category" select).
+const CATEGORY_LABELS = {
+ politics: "Politics",
+ weather: "Weather",
+ scitech: "Sci-Tech",
+ sports: "Sports",
+ entertainment: "Entertainment",
+ business: "Business",
+ uncategorized: "Uncategorized",
};
// Escalation ladder shared by every story.
const LEVEL_LABELS = ["DEVELOPING", "ESCALATING", "SPIRALING", "CRITICAL", "TRANSCENDENT"];
-const STORIES = [
+// The original 3 seeded stories — kept as an immutable template so the
+// admin panel's "Reset to Defaults" can always restore them exactly,
+// regardless of what's been created/deleted/escalated since.
+const DEFAULT_STORIES = [
{
id: "tuesdays",
category: "politics",
@@ -615,6 +777,47 @@ const FILLERS = [
"P24 exclusive: our interns have unionized. We fully support it. Please send snacks.",
];
+/* ---------- A2. Persistence (admin create/delete survive a reload) ---------- */
+const STORAGE_KEY = "pandemonium24.breakingStories.v1";
+
+function cloneStories(list) {
+ return JSON.parse(JSON.stringify(list));
+}
+
+function loadStories() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return cloneStories(DEFAULT_STORIES);
+ const parsed = JSON.parse(raw);
+ // Basic shape validation guards against corrupt/foreign localStorage
+ // data — falls back to the defaults rather than crashing the page.
+ // An empty array is valid (it's the legitimate "deleted everything"
+ // state) and must be preserved, not treated as corrupt.
+ const isValidShape = Array.isArray(parsed) && parsed.every((s) =>
+ s && typeof s.id === "string" && typeof s.category === "string" &&
+ Array.isArray(s.stages) && s.stages.length > 0 &&
+ typeof s.stageIndex === "number"
+ );
+ return isValidShape ? parsed : cloneStories(DEFAULT_STORIES);
+ } catch (err) {
+ return cloneStories(DEFAULT_STORIES);
+ }
+}
+
+function persistStories() {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(STORIES));
+ } catch (err) {
+ // Storage unavailable/full — non-fatal; admin changes just won't
+ // survive a reload this session. Everything else keeps working.
+ }
+}
+
+// `let`, not `const` — deleteStory()/resetToDefaults() reassign the whole
+// array (filter / clone). All readers (tick, renderStories, buildTickerItems)
+// reference the outer STORIES binding at call-time, so this is safe.
+let STORIES = loadStories();
+
/* ---------- B. State ---------- */
const state = {
category: "all",
@@ -627,6 +830,7 @@ const state = {
meltdownShown: false,
audioCtx: null,
klaxonAudioTimer: null,
+ justCreatedId: null,
};
/* ---------- C. Rendering ---------- */
@@ -645,6 +849,18 @@ const els = {
tickerScroll: document.getElementById("tickerScroll"),
tickerTrack: document.getElementById("tickerTrack"),
tickerStatic: document.getElementById("tickerStatic"),
+ noStoriesState: document.getElementById("noStoriesState"),
+ adminToggleBtn: document.getElementById("adminToggleBtn"),
+ adminPanel: document.getElementById("adminPanel"),
+ adminCreateForm: document.getElementById("adminCreateForm"),
+ adminHeadline: document.getElementById("adminHeadline"),
+ adminHeadlineError: document.getElementById("adminHeadlineError"),
+ adminCategory: document.getElementById("adminCategory"),
+ adminCreateSuccess: document.getElementById("adminCreateSuccess"),
+ adminStoryList: document.getElementById("adminStoryList"),
+ adminStoryCount: document.getElementById("adminStoryCount"),
+ adminNoStories: document.getElementById("adminNoStories"),
+ adminResetBtn: document.getElementById("adminResetBtn"),
};
function announce(text) {
@@ -672,8 +888,10 @@ function storyCardHTML(story, isLead) {
).join("");
const ariaLevel = `Escalation level ${story.stageIndex + 1} of ${story.stages.length}: ${levelLabel}`;
+ const justAdded = story.id === state.justCreatedId;
return `
- <article class="story-card${isLead ? " lead" : ""}${concluded ? " concluded" : ""}"
+ <article class="story-card${isLead ? " lead" : ""}${concluded ? " concluded" : ""}${justAdded ? " just-added" : ""}"
+ data-id="${escapeHTML(story.id)}"
aria-label="${isLead ? "Lead story" : "Story"}: ${escapeHTML(story.categoryLabel)}">
<div class="kicker">
<span class="badge-cat">${escapeHTML(story.categoryLabel)}</span>
@@ -703,11 +921,24 @@ function escapeHTML(str) {
}
function renderStories() {
+ // Top-level completion state: every story has been deleted (a state that
+ // didn't exist before the admin panel could delete the seeded 3) — this
+ // is distinct from the per-category empty state below.
+ if (STORIES.length === 0) {
+ els.storyGrid.hidden = true;
+ els.emptyState.hidden = true;
+ els.meltdownBanner.hidden = true;
+ els.noStoriesState.hidden = false;
+ return;
+ }
+ els.noStoriesState.hidden = true;
+
const list = visibleStories();
if (!list.length) {
els.storyGrid.hidden = true;
els.emptyState.hidden = false;
+ els.meltdownBanner.hidden = true;
return;
}
els.storyGrid.hidden = false;
@@ -718,7 +949,8 @@ function renderStories() {
.map((s) => storyCardHTML(s, s === lead))
.join("");
- // completion / meltdown state: every story has reached its final stage
+ // completion / meltdown state: every story has reached its final stage.
+ // (STORIES.length is guaranteed > 0 here, so .every() can't vacuously fire.)
const allConcluded = STORIES.every((s) => s.stageIndex === s.stages.length - 1);
els.meltdownBanner.hidden = !allConcluded;
if (allConcluded && !state.meltdownShown) {
@@ -860,6 +1092,7 @@ function tick() {
});
if (anyStoryAdvanced) {
+ persistStories();
renderStories();
refreshTicker();
} else if (state.elapsedSec % 5 === 0 && !state.reducedMotion) {
@@ -873,6 +1106,176 @@ function tick() {
}
}
+/* ---------- I. Admin panel (create / delete / reset breaking stories) ----------
+ Backstage tooling for Steve, not a broadcast feature. Manages the SAME
+ STORIES array + escalation-stage system the seeded stories use, so
+ admin-created stories fully participate in the ticker, the lead/secondary
+ hierarchy, and the completion/meltdown logic. */
+
+// Layers escalating satirical framing onto an arbitrary admin headline so a
+// freeform story still progresses through the same 5-stage ladder in-voice,
+// without hand-authored per-story copy (which isn't possible for arbitrary
+// input). Each stage keeps the admin's original headline as the anchor and
+// wraps it in progressively more unhinged bulletin language.
+function synthesizeStages(headline) {
+ const frames = [
+ {
+ headline: (h) => h,
+ detail: () =>
+ "P24 has confirmed initial reports. Our sources describe the situation as \"developing, technically.\"",
+ },
+ {
+ headline: (h) => `${h} — And It's Already Getting Worse`,
+ detail: () =>
+ "Multiple unnamed officials now confirm what one napkin-based memo predicted hours ago.",
+ },
+ {
+ headline: (h) => `Sources: "${h}" Is Now Somehow Everyone's Problem`,
+ detail: () =>
+ "A task force has been formed to form a task force. Neither has met yet.",
+ },
+ {
+ headline: (h) => `BULLETIN: ${h} — Has Escalated Beyond Any Reasonable Framework`,
+ detail: () =>
+ "Analysts are reaching for words that don't normally appear in a weekday broadcast.",
+ },
+ {
+ headline: (h) => `${h}: Nation Agrees This Is Somehow Fine Now`,
+ detail: () =>
+ "Historians say this will be, at best, a footnote in a much stranger book.",
+ conclusion: "This story has concluded. Reality has not yet followed up.",
+ },
+ ];
+ return frames.map((f) => {
+ const stage = { headline: f.headline(headline), detail: f.detail(headline) };
+ if (f.conclusion) stage.conclusion = f.conclusion;
+ return stage;
+ });
+}
+
+function createStory(headline, categoryKey) {
+ const categoryLabel = CATEGORY_LABELS[categoryKey] || CATEGORY_LABELS.uncategorized;
+ return {
+ id: `admin-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
+ category: categoryKey,
+ categoryLabel,
+ location: "Location Pending Confirmation",
+ byline: "P24 Assignment Desk",
+ intervalSec: 6,
+ // Phase the new story's ladder off the CURRENT elapsed time so it gets
+ // its own ~6s cadence starting now, rather than colliding with the
+ // seeded stories' schedule.
+ offsetSec: state.elapsedSec,
+ stageIndex: 0,
+ lastUpdated: new Date().toLocaleTimeString(),
+ stages: synthesizeStages(headline),
+ };
+}
+
+function renderAdminStoryList() {
+ els.adminStoryCount.textContent = String(STORIES.length);
+ if (!STORIES.length) {
+ els.adminStoryList.innerHTML = "";
+ els.adminNoStories.hidden = false;
+ return;
+ }
+ els.adminNoStories.hidden = true;
+ els.adminStoryList.innerHTML = STORIES.map((s) => {
+ const headline = s.stages[s.stageIndex].headline;
+ return `
+ <li class="admin-story-row" data-id="${escapeHTML(s.id)}">
+ <span class="admin-story-info">
+ <span class="admin-story-cat">${escapeHTML(s.categoryLabel)}</span>
+ <span class="admin-story-headline" title="${escapeHTML(headline)}">${escapeHTML(headline)}</span>
+ </span>
+ <button type="button" class="admin-delete-btn" data-delete-id="${escapeHTML(s.id)}"
+ aria-label="Delete story: ${escapeHTML(headline)}">Delete</button>
+ </li>
+ `;
+ }).join("");
+}
+
+function populateAdminCategorySelect() {
+ els.adminCategory.innerHTML = Object.entries(CATEGORY_LABELS)
+ .map(([key, label]) => `<option value="${key}">${escapeHTML(label)}</option>`)
+ .join("");
+}
+
+function refreshAfterStoriesChanged() {
+ persistStories();
+ renderStories();
+ refreshTicker();
+ renderAdminStoryList();
+}
+
+function deleteStory(id) {
+ if (!STORIES.some((s) => s.id === id)) return;
+ const deleted = STORIES.find((s) => s.id === id);
+ STORIES = STORIES.filter((s) => s.id !== id);
+ refreshAfterStoriesChanged();
+ announce(`Story deleted: ${deleted.stages[deleted.stageIndex].headline}`);
+}
+
+function resetToDefaults() {
+ STORIES = cloneStories(DEFAULT_STORIES);
+ state.meltdownShown = false;
+ state.justCreatedId = null;
+ refreshAfterStoriesChanged();
+ announce("All stories reset to defaults.");
+}
+
+function handleAdminCreateSubmit(e) {
+ e.preventDefault();
+ const headline = els.adminHeadline.value.trim();
+
+ // Error state: empty headline. Real inline feedback, not alert()/silent no-op.
+ if (!headline) {
+ els.adminHeadlineError.hidden = false;
+ els.adminHeadlineError.textContent =
+ "A headline's required — even P24 can't run a story about nothing (yet).";
+ els.adminHeadline.setAttribute("aria-invalid", "true");
+ els.adminCreateSuccess.hidden = true;
+ els.adminHeadline.focus();
+ announce("Error: a headline is required to create a story.");
+ return;
+ }
+
+ els.adminHeadlineError.hidden = true;
+ els.adminHeadline.removeAttribute("aria-invalid");
+
+ const categoryKey = els.adminCategory.value || "uncategorized";
+ const newStory = createStory(headline, categoryKey);
+ STORIES.push(newStory);
+ state.justCreatedId = newStory.id;
+ refreshAfterStoriesChanged();
+ announce(`New story created: ${newStory.stages[0].headline}`);
+
+ els.adminCreateSuccess.hidden = false;
+ els.adminCreateSuccess.textContent = `Story added to the ${newStory.categoryLabel} desk.`;
+ els.adminHeadline.value = "";
+ els.adminHeadline.focus();
+
+ // Clear the "just added" highlight after a few seconds (static outline,
+ // no animation — safe under reduced-motion since nothing moves/flashes).
+ setTimeout(() => {
+ if (state.justCreatedId === newStory.id) {
+ state.justCreatedId = null;
+ renderStories();
+ }
+ }, 4000);
+}
+
+function setAdminOpen(open) {
+ els.adminPanel.hidden = !open;
+ els.adminToggleBtn.setAttribute("aria-expanded", String(open));
+ els.adminToggleBtn.textContent = open ? "⚙ Admin (close)" : "⚙ Admin";
+ if (open) {
+ els.adminHeadline.focus();
+ } else {
+ els.adminToggleBtn.focus();
+ }
+}
+
/* ---------- H. Wiring / init ---------- */
function init() {
// category radios
@@ -910,6 +1313,21 @@ function init() {
? mq.addEventListener("change", (e) => applyMotionPref(e.matches))
: mq.addListener((e) => applyMotionPref(e.matches));
+ // admin panel: toggle, create form, delete delegation, reset
+ els.adminToggleBtn.addEventListener("click", () => setAdminOpen(els.adminPanel.hidden));
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && !els.adminPanel.hidden) setAdminOpen(false);
+ });
+ populateAdminCategorySelect();
+ els.adminCreateForm.addEventListener("submit", handleAdminCreateSubmit);
+ els.adminStoryList.addEventListener("click", (e) => {
+ const btn = e.target.closest("[data-delete-id]");
+ if (!btn) return;
+ deleteStory(btn.dataset.deleteId);
+ });
+ els.adminResetBtn.addEventListener("click", resetToDefaults);
+ renderAdminStoryList();
+
updateMoodUI();
setTickerMode(state.reducedMotion);
refreshTicker();
← cff03a3 Build PANDEMONIUM-24: satirical 24hr news channel (Crazy New
·
back to Crazy News Channel Shadowman
·
Re-anchor saved story offsets on load so admin stories keep 4459222 →