Initial commit: design-018 Phases 0-5 (stomping.me)

Auth BFF (OIDC + prompt=none silent SSO), Mongo data layer, admin CRUD
(folders/tags/stories/chapters with TipTap), public reader with tag
filtering. Built and verified same-session per design-018-stories.md.
This commit is contained in:
Claude Code
2026-07-04 05:36:43 +00:00
commit d6b16f5e06
47 changed files with 5680 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
*.log
+16
View File
@@ -0,0 +1,16 @@
module.exports = {
apps: [
{
name: 'stomping.me',
script: 'app.js',
cwd: '/opt/stomping/src',
instances: 1,
autorestart: true,
watch: false,
env: {
NODE_ENV: 'production'
},
env_file: '/etc/AGWOL/stomping/.env'
}
]
};
+2741
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "stomping.me",
"version": "1.0.0",
"description": "AGWOL Stories Service (stomping.me)",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "NODE_ENV=development node --watch src/app.js",
"build:editor": "esbuild src/editor/main.js --bundle --outfile=src/public/admin-js/editor-bundle.js --format=iife --minify"
},
"dependencies": {
"@tiptap/core": "^3.27.1",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/html": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"ejs": "^4.0.1",
"express": "^5.2.1",
"helmet": "^8.1.0",
"ioredis": "^5.9.3",
"jsonwebtoken": "^9.0.3",
"mongodb": "^7.1.0",
"sanitize-html": "^2.17.5"
},
"devDependencies": {
"esbuild": "^0.28.1"
}
}
+68
View File
@@ -0,0 +1,68 @@
'use strict';
require('dotenv').config({ path: '/etc/AGWOL/stomping/.env' });
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const { env } = require('./config/env');
const { connectMongo } = require('./config/mongo');
const { createRedis } = require('./config/redis');
const { router: authRoutes } = require('./routes/auth');
const indexRoutes = require('./routes/index');
const adminPagesRoutes = require('./routes/adminPages');
const adminFoldersRoutes = require('./routes/adminFolders');
const adminTagsRoutes = require('./routes/adminTags');
const adminStoriesRoutes = require('./routes/adminStories');
const adminChaptersRoutes = require('./routes/adminChapters');
const publicPagesRoutes = require('./routes/publicPages');
async function start() {
await connectMongo();
createRedis();
const app = express();
// nginx is the only proxy hop — trust loopback only.
app.set('trust proxy', 'loopback');
app.use(helmet({
// Stories/covers are image-by-URL (design-018 §4 — no upload pipeline in
// v1), so images can come from any https host, not just our own.
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
'img-src': ["'self'", 'data:', 'https:'],
},
},
}));
app.use(cors({ origin: env.ALLOWED_ORIGINS, credentials: true }));
app.use(cookieParser(env.COOKIE_SECRET));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', authRoutes);
app.use('/', adminPagesRoutes);
app.use('/', adminFoldersRoutes);
app.use('/', adminTagsRoutes);
app.use('/', adminStoriesRoutes);
app.use('/', adminChaptersRoutes);
app.use('/', indexRoutes);
app.use('/', publicPagesRoutes);
app.listen(env.PORT, '127.0.0.1', () => {
console.log(`stomping.me running on port ${env.PORT}`);
});
}
start().catch(err => {
console.error('[startup] fatal:', err.message);
process.exit(1);
});
+45
View File
@@ -0,0 +1,45 @@
'use strict';
require('dotenv').config({ path: '/etc/AGWOL/stomping/.env' });
const env = {
NODE_ENV: process.env.NODE_ENV || 'production',
PORT: parseInt(process.env.PORT || '5003', 10),
// Auth — shared platform HS256 secret, must match auth.agwol.com
ACCESS_TOKEN_SECRET: process.env.ACCESS_TOKEN_SECRET,
COOKIE_SECRET: process.env.COOKIE_SECRET,
// MongoDB — own `stomping` database, per-service pattern
MONGODB_URI: process.env.MONGODB_URI,
// Redis — shared instance (revocation checks, same DB as auth/api/hub/chat)
REDIS_HOST: process.env.REDIS_HOST || 'localhost',
REDIS_PORT: parseInt(process.env.REDIS_PORT || '6379', 10),
REDIS_PASSWORD: process.env.REDIS_PASSWORD || undefined,
REDIS_DB: parseInt(process.env.REDIS_DB || '0', 10),
// CORS
ALLOWED_ORIGINS: (process.env.ALLOWED_ORIGINS || 'https://stomping.me').split(','),
// OIDC — standard client of auth.agwol.com
AUTH_PUBLIC_ORIGIN: process.env.AUTH_PUBLIC_ORIGIN || 'https://auth.agwol.com',
AUTH_INTERNAL_URL: process.env.AUTH_INTERNAL_URL || 'http://127.0.0.1:3001',
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET,
OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI,
OIDC_POST_LOGOUT_URI: process.env.OIDC_POST_LOGOUT_URI,
};
const required = [
'ACCESS_TOKEN_SECRET', 'COOKIE_SECRET', 'MONGODB_URI',
'REDIS_PASSWORD', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI',
];
for (const key of required) {
if (!env[key]) {
console.error(`[ENV] Missing required environment variable: ${key}`);
process.exit(1);
}
}
module.exports = { env };
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const { MongoClient, ServerApiVersion } = require('mongodb');
const { env } = require('./env');
let client = null;
let db = null;
async function connectMongo() {
client = new MongoClient(env.MONGODB_URI, {
serverApi: {
version: ServerApiVersion.v1,
strict: true,
deprecationErrors: true,
},
serverSelectionTimeoutMS: 10000,
});
await client.connect();
db = client.db('stomping');
// Collections + indexes (design-018 §2.12.4). comments/ratings (§2.52.6)
// are schema-reserved only — not created until Phase 6. createIndex is a
// no-op if the index already exists, so this is safe to run on every boot.
const folders = db.collection('folders');
await folders.createIndex({ slug: 1 }, { unique: true });
await folders.createIndex({ parent_id: 1 });
const stories = db.collection('stories');
await stories.createIndex({ slug: 1 }, { unique: true });
await stories.createIndex({ folder_id: 1 });
await stories.createIndex({ status: 1, folder_id: 1 });
await stories.createIndex({ story_type: 1 });
await stories.createIndex({ published_at: 1 });
const chapters = db.collection('chapters');
await chapters.createIndex({ story_id: 1, slug: 1 }, { unique: true });
await chapters.createIndex({ story_id: 1, display_order: 1 });
const tags = db.collection('tags');
await tags.createIndex({ slug: 1 }, { unique: true });
console.log('[MongoDB] Connected to stomping database, indexes ready');
return db;
}
function getDb() {
if (!db) throw new Error('MongoDB not initialized');
return db;
}
async function closeMongo() {
if (client) await client.close();
}
module.exports = { connectMongo, getDb, closeMongo };
+28
View File
@@ -0,0 +1,28 @@
'use strict';
const Redis = require('ioredis');
const { env } = require('./env');
let redisClient = null;
function createRedis() {
redisClient = new Redis({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
password: env.REDIS_PASSWORD,
db: env.REDIS_DB,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
redisClient.on('connect', () => console.log('[Redis] Connected'));
redisClient.on('error', (err) => console.error('[Redis] Error', err.message));
return redisClient;
}
function getRedis() {
if (!redisClient) throw new Error('Redis not initialized');
return redisClient;
}
module.exports = { createRedis, getRedis };
+21
View File
@@ -0,0 +1,21 @@
// editor/main.js — bundled locally with esbuild (design-018 §4: "no CDN
// dependency"). Entry point for the browser-side TipTap instance; shares
// the exact extension list the server uses to render/sanitize
// (tiptapExtensions.js) so what you see in the editor is what gets saved.
import { Editor } from '@tiptap/core';
import { StarterKit } from '@tiptap/starter-kit';
import { Image } from '@tiptap/extension-image';
function createEditor({ element, content, onUpdate, onSelectionUpdate }) {
const editor = new Editor({
element,
extensions: [StarterKit, Image],
content: content || '',
onUpdate: ({ editor }) => { if (onUpdate) onUpdate(editor); },
onSelectionUpdate: ({ editor }) => { if (onSelectionUpdate) onSelectionUpdate(editor); },
onTransaction: ({ editor }) => { if (onSelectionUpdate) onSelectionUpdate(editor); },
});
return editor;
}
window.StompingEditor = { createEditor };
+58
View File
@@ -0,0 +1,58 @@
// folderTree.js — re-parent cycle detection for admin folder CRUD
// (design-018 §6 Phase 3: "incl. re-parent — must reject cycles").
'use strict';
// Would setting `folderId`'s parent to `newParentId` create a cycle? Walks up
// from the proposed new parent; if folderId's own id is ever reached, the
// move would make folderId an ancestor of itself.
async function wouldCreateCycle(db, folderId, newParentId) {
if (!newParentId) return false;
const folderKey = String(folderId);
if (String(newParentId) === folderKey) return true;
const folders = db.collection('folders');
const seen = new Set();
let current = newParentId;
while (current) {
const key = String(current);
if (key === folderKey) return true;
if (seen.has(key)) break; // defensive — stop on a pre-existing corrupt loop
seen.add(key);
const doc = await folders.findOne({ _id: current }, { projection: { parent_id: 1 } });
if (!doc) break;
current = doc.parent_id;
}
return false;
}
// Flattens a folder list into depth-first tree order, annotating each with
// `depth` (0 = root) for indentation. Orphaned folders (parent_id points at
// something no longer present) are appended at depth 0 rather than dropped.
function buildFolderTree(folders) {
const byParent = new Map();
const ids = new Set(folders.map(f => String(f._id)));
for (const folder of folders) {
const parentKey = folder.parent_id && ids.has(String(folder.parent_id))
? String(folder.parent_id)
: 'root';
if (!byParent.has(parentKey)) byParent.set(parentKey, []);
byParent.get(parentKey).push(folder);
}
for (const list of byParent.values()) list.sort((a, b) => a.display_order - b.display_order);
const out = [];
function walk(parentKey, depth) {
for (const folder of byParent.get(parentKey) || []) {
out.push({ ...folder, depth });
walk(String(folder._id), depth + 1);
}
}
walk('root', 0);
return out;
}
module.exports = { wouldCreateCycle, buildFolderTree };
+127
View File
@@ -0,0 +1,127 @@
// normalize.js — shared strip-empty-to-null normalizer (design-018 §2, same
// pattern as design-015's castNormalizer.js). Every write passes through one
// of these before hitting Mongo, so render-if-present is enforced at the
// write path, not scattered across templates.
//
// created_at/updated_at are set by the caller at insert/update time, not
// here — matches the established convention (see castRoutes.js).
'use strict';
const { renderBodyHtml } = require('./renderTiptap');
const { sanitizeBodyHtml } = require('./sanitizeHtml');
const { countWords } = require('./wordCount');
const STORY_TYPES = ['one_shot', 'serial', 'bite_size'];
const CONTENT_RATINGS = ['general', 'teen', 'mature', 'adult'];
const SERIAL_STATUSES = ['ongoing', 'completed', 'hiatus'];
const ENTITY_STATUSES = ['draft', 'published'];
const TAG_KINDS = ['explicit', 'general'];
function stripEmpty(value) {
if (typeof value === 'string') return value.trim() === '' ? null : value;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return normalizeObject(value);
}
return value;
}
function normalizeObject(obj) {
const out = {};
for (const key of Object.keys(obj)) out[key] = stripEmpty(obj[key]);
return out;
}
function normalizeSlugArray(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map(v => (typeof v === 'string' ? v.trim() : ''))
.filter(v => v !== '');
}
// ── folders (§2.1) ────────────────────────────────────────────────────────
function normalizeFolder(raw) {
return {
slug: String(raw.slug || '').trim(),
name: String(raw.name || '').trim(),
parent_id: raw.parent_id ?? null,
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
};
}
// ── stories (§2.2) ────────────────────────────────────────────────────────
function normalizeStory(raw) {
return {
slug: String(raw.slug || '').trim(),
folder_id: raw.folder_id ?? null,
title: String(raw.title || '').trim(),
subtitle: stripEmpty(raw.subtitle ?? null),
badges: normalizeSlugArray(raw.badges),
summary: stripEmpty(raw.summary ?? null),
cover_url: stripEmpty(raw.cover_url ?? null),
story_type: STORY_TYPES.includes(raw.story_type) ? raw.story_type : 'one_shot',
content_rating: CONTENT_RATINGS.includes(raw.content_rating) ? raw.content_rating : 'general',
explicit_tags: normalizeSlugArray(raw.explicit_tags),
general_tags: normalizeSlugArray(raw.general_tags),
status: ENTITY_STATUSES.includes(raw.status) ? raw.status : 'draft',
serial_status: SERIAL_STATUSES.includes(raw.serial_status) ? raw.serial_status : null,
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
chronological_order: raw.chronological_order != null && raw.chronological_order !== ''
? Number(raw.chronological_order) : null,
published_at: raw.published_at ?? null,
word_count: 0,
rating_avg: null,
rating_count: 0,
author_user_id: Number(raw.author_user_id),
};
}
// ── chapters (§2.3) ───────────────────────────────────────────────────────
// body_html/word_count are always derived from body_json here — never
// accepted from the caller — so JSON stays the one source of truth.
function normalizeChapter(raw) {
const bodyJson = raw.body_json && typeof raw.body_json === 'object'
? raw.body_json
: { type: 'doc', content: [] };
return {
story_id: raw.story_id,
slug: String(raw.slug || '').trim(),
title: String(raw.title || '').trim(),
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
status: ENTITY_STATUSES.includes(raw.status) ? raw.status : 'draft',
body_json: bodyJson,
body_html: sanitizeBodyHtml(renderBodyHtml(bodyJson)),
note_top: stripEmpty(raw.note_top ?? null),
note_bottom: stripEmpty(raw.note_bottom ?? null),
word_count: countWords(bodyJson),
published_at: raw.published_at ?? null,
};
}
// ── tags (§2.4) ───────────────────────────────────────────────────────────
function normalizeTag(raw) {
return {
slug: String(raw.slug || '').trim().toLowerCase(),
label: String(raw.label || '').trim(),
kind: TAG_KINDS.includes(raw.kind) ? raw.kind : 'general',
description: stripEmpty(raw.description ?? null),
};
}
module.exports = {
stripEmpty,
normalizeObject,
normalizeFolder,
normalizeStory,
normalizeChapter,
normalizeTag,
STORY_TYPES,
CONTENT_RATINGS,
SERIAL_STATUSES,
ENTITY_STATUSES,
TAG_KINDS,
};
+14
View File
@@ -0,0 +1,14 @@
// renderTiptap.js — TipTap JSON -> HTML, server-side. JSON is the canonical
// source (design-018 §2.3); HTML is re-rendered from it on every save, never
// hand-edited, never trusted from the client directly.
'use strict';
const { generateHTML } = require('@tiptap/html/server');
const extensions = require('./tiptapExtensions');
function renderBodyHtml(bodyJson) {
if (!bodyJson || typeof bodyJson !== 'object') return '';
return generateHTML(bodyJson, extensions);
}
module.exports = { renderBodyHtml };
+32
View File
@@ -0,0 +1,32 @@
// sanitizeHtml.js — whitelist sanitizer for chapter body_html. TipTap's own
// output is structured, but this is sanitized anyway (design-018 §2.3) since
// it's a write path that will eventually be shared with other content
// (comments, someday). Whitelist matches exactly the tags/marks the v1
// editor extensions (StarterKit + Image, see tiptapExtensions.js) can emit —
// nothing else is expected here, so nothing else is allowed through.
'use strict';
const sanitizeHtml = require('sanitize-html');
const OPTIONS = {
allowedTags: [
'p', 'br', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'strong', 'em', 's',
'ul', 'ol', 'li',
'blockquote',
'a', 'img',
],
allowedAttributes: {
a: ['href', 'target', 'rel'],
img: ['src', 'alt'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowProtocolRelative: false,
};
function sanitizeBodyHtml(html) {
return sanitizeHtml(html || '', OPTIONS);
}
module.exports = { sanitizeBodyHtml };
+21
View File
@@ -0,0 +1,21 @@
// storyAggregate.js — story.word_count is the sum of its published chapters'
// word counts (design-018 §2.2). Recomputed on every chapter save/publish-
// toggle/delete — full recount each time, matching this project's established
// "cheap at this scale, full recount is correct" convention (design-018 §2.6
// documents the same tradeoff for rating aggregates).
'use strict';
async function recomputeStoryWordCount(db, storyId) {
const chapters = await db.collection('chapters')
.find({ story_id: storyId, status: 'published' }, { projection: { word_count: 1 } })
.toArray();
const wordCount = chapters.reduce((sum, c) => sum + (c.word_count || 0), 0);
await db.collection('stories').updateOne(
{ _id: storyId },
{ $set: { word_count: wordCount, updated_at: new Date() } }
);
return wordCount;
}
module.exports = { recomputeStoryWordCount };
+11
View File
@@ -0,0 +1,11 @@
// tiptapExtensions.js — shared extension set for server-side rendering
// (renderTiptap.js) and, later, the admin editor bundle (Phase 3). One list,
// one source of truth, per design-018 §4: StarterKit + image-by-URL. Link is
// bundled inside StarterKit v3 by default — do not add @tiptap/extension-link
// separately, it registers a duplicate 'link' extension name.
'use strict';
const { StarterKit } = require('@tiptap/starter-kit');
const { Image } = require('@tiptap/extension-image');
module.exports = [StarterKit, Image];
+26
View File
@@ -0,0 +1,26 @@
// wordCount.js — word count from TipTap JSON, computed on every chapter
// save (design-018 §2.3). Walks the document tree collecting text node
// content, joining across node boundaries with whitespace so words never
// merge across a paragraph/heading/list-item break.
'use strict';
function collectText(node, parts) {
if (!node || typeof node !== 'object') return;
if (typeof node.text === 'string') {
parts.push(node.text);
return;
}
if (Array.isArray(node.content)) {
for (const child of node.content) collectText(child, parts);
}
}
function countWords(bodyJson) {
if (!bodyJson || typeof bodyJson !== 'object') return 0;
const parts = [];
collectText(bodyJson, parts);
const words = parts.join(' ').trim().split(/\s+/).filter(Boolean);
return words.length;
}
module.exports = { countWords };
+138
View File
@@ -0,0 +1,138 @@
(function () {
var storyId = document.body.dataset.storyId;
var chapterId = document.body.dataset.chapterId || null;
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function wordCount(editor) {
return editor.getText().trim().split(/\s+/).filter(Boolean).length;
}
function updateToolbarState(editor) {
document.querySelectorAll('.toolbar button[data-cmd]').forEach(function (btn) {
var cmd = btn.dataset.cmd;
var active = false;
if (cmd === 'bold') active = editor.isActive('bold');
else if (cmd === 'italic') active = editor.isActive('italic');
else if (cmd === 'strike') active = editor.isActive('strike');
else if (cmd === 'heading1') active = editor.isActive('heading', { level: 1 });
else if (cmd === 'heading2') active = editor.isActive('heading', { level: 2 });
else if (cmd === 'bulletList') active = editor.isActive('bulletList');
else if (cmd === 'orderedList') active = editor.isActive('orderedList');
else if (cmd === 'blockquote') active = editor.isActive('blockquote');
btn.classList.toggle('is-active', active);
});
document.getElementById('word-count').textContent = wordCount(editor) + ' words';
}
let publishState = null;
async function init() {
let content = { type: 'doc', content: [{ type: 'paragraph' }] };
if (chapterId) {
const chapter = await api('GET', '/api/admin/stories/' + storyId + '/chapters/' + chapterId);
document.getElementById('slug').value = chapter.slug;
document.getElementById('title').value = chapter.title;
document.getElementById('display_order').value = chapter.display_order;
document.getElementById('note_top').value = chapter.note_top || '';
document.getElementById('note_bottom').value = chapter.note_bottom || '';
content = chapter.body_json;
publishState = chapter.status;
const publishBtn = document.getElementById('publish-btn');
publishBtn.textContent = publishState === 'published' ? 'Unpublish' : 'Publish';
publishBtn.addEventListener('click', async function () {
const nextStatus = publishState === 'published' ? 'draft' : 'published';
try {
await api('PATCH', '/api/admin/stories/' + storyId + '/chapters/' + chapterId + '/status', { status: nextStatus });
window.location.reload();
} catch (err) {
alert('Failed: ' + err.message);
}
});
}
const editor = window.StompingEditor.createEditor({
element: document.getElementById('editor'),
content,
onUpdate: updateToolbarState,
onSelectionUpdate: updateToolbarState,
});
updateToolbarState(editor);
document.querySelectorAll('.toolbar button[data-cmd]').forEach(function (btn) {
btn.addEventListener('click', function () {
const chain = editor.chain().focus();
switch (btn.dataset.cmd) {
case 'bold': chain.toggleBold().run(); break;
case 'italic': chain.toggleItalic().run(); break;
case 'strike': chain.toggleStrike().run(); break;
case 'heading1': chain.toggleHeading({ level: 1 }).run(); break;
case 'heading2': chain.toggleHeading({ level: 2 }).run(); break;
case 'bulletList': chain.toggleBulletList().run(); break;
case 'orderedList': chain.toggleOrderedList().run(); break;
case 'blockquote': chain.toggleBlockquote().run(); break;
case 'horizontalRule': chain.setHorizontalRule().run(); break;
case 'undo': chain.undo().run(); break;
case 'redo': chain.redo().run(); break;
case 'link': {
const url = window.prompt('Link URL (blank to remove):');
if (url === null) break;
if (url.trim() === '') chain.unsetLink().run();
else chain.extendMarkRange('link').setLink({ href: url.trim() }).run();
break;
}
case 'image': {
const src = window.prompt('Image URL:');
if (src && src.trim()) chain.setImage({ src: src.trim() }).run();
break;
}
}
});
});
document.getElementById('save-btn').addEventListener('click', async function () {
const statusEl = document.getElementById('save-status');
statusEl.textContent = '';
statusEl.className = 'status-msg';
const body = {
slug: document.getElementById('slug').value.trim(),
title: document.getElementById('title').value.trim(),
display_order: Number(document.getElementById('display_order').value),
note_top: document.getElementById('note_top').value,
note_bottom: document.getElementById('note_bottom').value,
body_json: editor.getJSON(),
};
try {
if (chapterId) {
await api('PUT', '/api/admin/stories/' + storyId + '/chapters/' + chapterId, body);
statusEl.textContent = 'Saved.';
statusEl.className = 'status-msg ok';
} else {
const created = await api('POST', '/api/admin/stories/' + storyId + '/chapters', body);
window.location.href = '/admin/stories/' + storyId + '/chapters/' + created._id;
}
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = 'status-msg error';
}
});
}
init().catch(function (err) {
document.getElementById('save-status').textContent = 'Failed to load: ' + err.message;
document.getElementById('save-status').className = 'status-msg error';
});
})();
File diff suppressed because one or more lines are too long
+57
View File
@@ -0,0 +1,57 @@
(function () {
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
document.querySelectorAll('#folder-table tbody tr').forEach(function (row) {
const id = row.dataset.id;
row.querySelector('.f-save').addEventListener('click', async function () {
try {
await api('PUT', '/api/admin/folders/' + id, {
name: row.querySelector('.f-name').value,
parent_id: row.querySelector('.f-parent').value || null,
display_order: Number(row.querySelector('.f-order').value),
});
window.location.reload();
} catch (err) {
alert('Save failed: ' + err.message);
}
});
row.querySelector('.f-delete').addEventListener('click', async function () {
if (!confirm('Delete this folder?')) return;
try {
await api('DELETE', '/api/admin/folders/' + id);
window.location.reload();
} catch (err) {
alert('Delete failed: ' + err.message);
}
});
});
document.getElementById('create-folder').addEventListener('click', async function () {
const statusEl = document.getElementById('create-status');
statusEl.textContent = '';
statusEl.className = 'status-msg';
try {
await api('POST', '/api/admin/folders', {
slug: document.getElementById('new-slug').value.trim(),
name: document.getElementById('new-name').value.trim(),
parent_id: document.getElementById('new-parent').value || null,
display_order: Number(document.getElementById('new-order').value),
});
window.location.reload();
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = 'status-msg error';
}
});
})();
+35
View File
@@ -0,0 +1,35 @@
(function () {
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
document.querySelectorAll('#story-table tbody tr').forEach(function (row) {
const id = row.dataset.id;
row.querySelector('.s-toggle').addEventListener('click', async function (e) {
try {
await api('PATCH', '/api/admin/stories/' + id + '/status', { status: e.target.dataset.status });
window.location.reload();
} catch (err) {
alert('Failed: ' + err.message);
}
});
row.querySelector('.s-delete').addEventListener('click', async function () {
if (!confirm('Delete this story and all its chapters?')) return;
try {
await api('DELETE', '/api/admin/stories/' + id);
window.location.reload();
} catch (err) {
alert('Delete failed: ' + err.message);
}
});
});
})();
+83
View File
@@ -0,0 +1,83 @@
(function () {
var storyId = document.body.dataset.storyId || null;
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function checkedValues(selector) {
return Array.prototype.map.call(document.querySelectorAll(selector + ':checked'), function (el) { return el.value; });
}
document.getElementById('story-form').addEventListener('submit', async function (e) {
e.preventDefault();
const statusEl = document.getElementById('save-status');
statusEl.textContent = '';
statusEl.className = 'status-msg';
const body = {
slug: document.getElementById('slug').value.trim(),
title: document.getElementById('title').value.trim(),
subtitle: document.getElementById('subtitle').value,
summary: document.getElementById('summary').value,
folder_id: document.getElementById('folder_id').value || null,
story_type: document.getElementById('story_type').value,
content_rating: document.getElementById('content_rating').value,
serial_status: document.getElementById('serial_status').value || null,
badges: document.getElementById('badges').value.split(',').map(function (s) { return s.trim(); }).filter(Boolean),
cover_url: document.getElementById('cover_url').value,
explicit_tags: checkedValues('.tag-explicit'),
general_tags: checkedValues('.tag-general'),
display_order: Number(document.getElementById('display_order').value),
chronological_order: document.getElementById('chronological_order').value || null,
author_user_id: Number(document.getElementById('author_user_id').value),
};
try {
if (storyId) {
await api('PUT', '/api/admin/stories/' + storyId, body);
statusEl.textContent = 'Saved.';
statusEl.className = 'status-msg ok';
} else {
const created = await api('POST', '/api/admin/stories', body);
window.location.href = '/admin/stories/' + created._id;
}
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = 'status-msg error';
}
});
var chapterBody = document.getElementById('chapter-table-body');
if (chapterBody) {
chapterBody.querySelectorAll('tr').forEach(function (row) {
var cid = row.dataset.id;
row.querySelector('.c-toggle').addEventListener('click', async function (e) {
try {
await api('PATCH', '/api/admin/stories/' + storyId + '/chapters/' + cid + '/status', { status: e.target.dataset.status });
window.location.reload();
} catch (err) {
alert('Failed: ' + err.message);
}
});
row.querySelector('.c-delete').addEventListener('click', async function () {
if (!confirm('Delete this chapter?')) return;
try {
await api('DELETE', '/api/admin/stories/' + storyId + '/chapters/' + cid);
window.location.reload();
} catch (err) {
alert('Delete failed: ' + err.message);
}
});
});
}
})();
+57
View File
@@ -0,0 +1,57 @@
(function () {
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
document.querySelectorAll('#tag-table tbody tr').forEach(function (row) {
const id = row.dataset.id;
row.querySelector('.t-save').addEventListener('click', async function () {
try {
await api('PUT', '/api/admin/tags/' + id, {
label: row.querySelector('.t-label').value,
kind: row.querySelector('.t-kind').value,
description: row.querySelector('.t-description').value,
});
window.location.reload();
} catch (err) {
alert('Save failed: ' + err.message);
}
});
row.querySelector('.t-delete').addEventListener('click', async function () {
if (!confirm('Delete this tag?')) return;
try {
await api('DELETE', '/api/admin/tags/' + id);
window.location.reload();
} catch (err) {
alert('Delete failed: ' + err.message);
}
});
});
document.getElementById('create-tag').addEventListener('click', async function () {
const statusEl = document.getElementById('create-status');
statusEl.textContent = '';
statusEl.className = 'status-msg';
try {
await api('POST', '/api/admin/tags', {
slug: document.getElementById('new-slug').value.trim(),
label: document.getElementById('new-label').value.trim(),
kind: document.getElementById('new-kind').value,
description: document.getElementById('new-description').value,
});
window.location.reload();
} catch (err) {
statusEl.textContent = err.message;
statusEl.className = 'status-msg error';
}
});
})();
+13
View File
@@ -0,0 +1,13 @@
// Silent SSO check — if the visitor already has a session at auth.agwol.com
// (from another AGWOL site), recognize them here too without an explicit
// click. A top-level redirect is required (not a hidden iframe): the identity
// cookie is SameSite=Lax, scoped to .agwol.com, and stomping.me is a
// different site, so it's only sent on real top-level navigation. Anonymous
// visitors get bounced through and straight back (prompt=none never shows a
// login form), landing on the same page with nothing changed. Runs at most
// once per tab, so a failed check doesn't loop.
(function () {
if (sessionStorage.getItem('stompingSilentAuthDone')) return;
sessionStorage.setItem('stompingSilentAuthDone', '1');
window.location.href = '/auth/login?silent=1&returnTo=' + encodeURIComponent(window.location.pathname + window.location.search);
})();
+195
View File
@@ -0,0 +1,195 @@
// adminChapters.js — chapter CRUD (TipTap body) + publish toggle,
// admin.content gated (design-018 §2.3/§6)
'use strict';
const express = require('express');
const { ObjectId } = require('mongodb');
const { getDb } = require('../config/mongo');
const { normalizeChapter } = require('../lib/normalize');
const { recomputeStoryWordCount } = require('../lib/storyAggregate');
const { requireAdminContent } = require('./auth');
const router = express.Router();
function parseId(raw) {
try {
return new ObjectId(raw);
} catch {
return null;
}
}
async function loadStory(db, sid) {
const storyId = parseId(sid);
if (!storyId) return null;
return db.collection('stories').findOne({ _id: storyId });
}
router.get('/api/admin/stories/:sid/chapters', requireAdminContent, async (req, res) => {
try {
const story = await loadStory(getDb(), req.params.sid);
if (!story) return res.status(404).json({ error: 'story not found' });
const chapters = await getDb().collection('chapters')
.find({ story_id: story._id }, { projection: { body_json: 0 } })
.sort({ display_order: 1 })
.toArray();
res.json(chapters);
} catch (err) {
console.error('[chapters] list', err.message);
res.status(500).json({ error: 'internal' });
}
});
router.get('/api/admin/stories/:sid/chapters/:cid', requireAdminContent, async (req, res) => {
try {
const story = await loadStory(getDb(), req.params.sid);
if (!story) return res.status(404).json({ error: 'story not found' });
const cid = parseId(req.params.cid);
if (!cid) return res.status(400).json({ error: 'invalid id' });
const chapter = await getDb().collection('chapters').findOne({ _id: cid, story_id: story._id });
if (!chapter) return res.status(404).json({ error: 'not found' });
res.json(chapter);
} catch (err) {
console.error('[chapters] get one', err.message);
res.status(500).json({ error: 'internal' });
}
});
router.post('/api/admin/stories/:sid/chapters', requireAdminContent, async (req, res) => {
try {
const db = getDb();
const story = await loadStory(db, req.params.sid);
if (!story) return res.status(404).json({ error: 'story not found' });
const { slug } = req.body;
if (!slug || !/^[a-z0-9-]+$/.test(slug)) {
return res.status(400).json({ error: 'slug required (lowercase letters, digits, hyphens only)' });
}
if (!req.body.title || !String(req.body.title).trim()) {
return res.status(400).json({ error: 'title required' });
}
const normalized = normalizeChapter({ ...req.body, story_id: story._id });
const now = new Date();
const doc = { ...normalized, created_at: now, updated_at: now };
const result = await db.collection('chapters').insertOne(doc);
if (doc.status === 'published') await recomputeStoryWordCount(db, story._id);
res.status(201).json({ ...doc, _id: result.insertedId });
} catch (err) {
if (err.code === 11000) return res.status(409).json({ error: 'a chapter with that slug already exists on this story' });
console.error('[chapters] create', err.message);
res.status(500).json({ error: 'internal' });
}
});
// slug/status/published_at are not editable here — status has its own
// publish-toggle endpoint below, matching the story-level convention.
router.put('/api/admin/stories/:sid/chapters/:cid', requireAdminContent, async (req, res) => {
try {
const db = getDb();
const story = await loadStory(db, req.params.sid);
if (!story) return res.status(404).json({ error: 'story not found' });
const cid = parseId(req.params.cid);
if (!cid) return res.status(400).json({ error: 'invalid id' });
const existing = await db.collection('chapters').findOne({ _id: cid, story_id: story._id });
if (!existing) return res.status(404).json({ error: 'not found' });
if (!req.body.title || !String(req.body.title).trim()) {
return res.status(400).json({ error: 'title required' });
}
const normalized = normalizeChapter({ ...req.body, story_id: story._id, slug: existing.slug, status: existing.status });
const after = await db.collection('chapters').findOneAndUpdate(
{ _id: cid },
{ $set: {
title: normalized.title,
display_order: normalized.display_order,
body_json: normalized.body_json,
body_html: normalized.body_html,
note_top: normalized.note_top,
note_bottom: normalized.note_bottom,
word_count: normalized.word_count,
updated_at: new Date(),
} },
{ returnDocument: 'after' }
);
if (after.status === 'published') await recomputeStoryWordCount(db, story._id);
res.json(after);
} catch (err) {
console.error('[chapters] update', err.message);
res.status(500).json({ error: 'internal' });
}
});
// Chapters publish individually (§2.3). published_at is set once, on first
// publish, same rule as the story-level toggle.
router.patch('/api/admin/stories/:sid/chapters/:cid/status', requireAdminContent, async (req, res) => {
try {
const { status } = req.body;
if (status !== 'draft' && status !== 'published') {
return res.status(400).json({ error: 'status must be draft or published' });
}
const db = getDb();
const story = await loadStory(db, req.params.sid);
if (!story) return res.status(404).json({ error: 'story not found' });
const cid = parseId(req.params.cid);
if (!cid) return res.status(400).json({ error: 'invalid id' });
const existing = await db.collection('chapters').findOne({ _id: cid, story_id: story._id });
if (!existing) return res.status(404).json({ error: 'not found' });
const set = { status, updated_at: new Date() };
if (status === 'published' && !existing.published_at) {
set.published_at = new Date();
}
const after = await db.collection('chapters').findOneAndUpdate(
{ _id: cid },
{ $set: set },
{ returnDocument: 'after' }
);
await recomputeStoryWordCount(db, story._id);
res.json(after);
} catch (err) {
console.error('[chapters] status toggle', err.message);
res.status(500).json({ error: 'internal' });
}
});
router.delete('/api/admin/stories/:sid/chapters/:cid', requireAdminContent, async (req, res) => {
try {
const db = getDb();
const story = await loadStory(db, req.params.sid);
if (!story) return res.status(404).json({ error: 'story not found' });
const cid = parseId(req.params.cid);
if (!cid) return res.status(400).json({ error: 'invalid id' });
const existing = await db.collection('chapters').findOne({ _id: cid, story_id: story._id });
if (!existing) return res.status(404).json({ error: 'not found' });
await db.collection('chapters').deleteOne({ _id: cid });
await recomputeStoryWordCount(db, story._id);
res.json({ deleted: req.params.cid });
} catch (err) {
console.error('[chapters] delete', err.message);
res.status(500).json({ error: 'internal' });
}
});
module.exports = router;
+129
View File
@@ -0,0 +1,129 @@
// adminFolders.js — folder CRUD, admin.content gated (design-018 §3/§6 Phase 3)
'use strict';
const express = require('express');
const { ObjectId } = require('mongodb');
const { getDb } = require('../config/mongo');
const { normalizeFolder } = require('../lib/normalize');
const { wouldCreateCycle } = require('../lib/folderTree');
const { requireAdminContent } = require('./auth');
const router = express.Router();
function parseId(raw) {
try {
return new ObjectId(raw);
} catch {
return null;
}
}
router.get('/api/admin/folders', requireAdminContent, async (req, res) => {
try {
const folders = await getDb().collection('folders')
.find({})
.sort({ parent_id: 1, display_order: 1 })
.toArray();
res.json(folders);
} catch (err) {
console.error('[folders] list', err.message);
res.status(500).json({ error: 'internal' });
}
});
router.post('/api/admin/folders', requireAdminContent, async (req, res) => {
try {
const { slug } = req.body;
if (!slug || !/^[a-z0-9-]+$/.test(slug)) {
return res.status(400).json({ error: 'slug required (lowercase letters, digits, hyphens only)' });
}
if (!req.body.name || !String(req.body.name).trim()) {
return res.status(400).json({ error: 'name required' });
}
let parentId = null;
if (req.body.parent_id) {
parentId = parseId(req.body.parent_id);
if (!parentId) return res.status(400).json({ error: 'invalid parent_id' });
const parent = await getDb().collection('folders').findOne({ _id: parentId });
if (!parent) return res.status(400).json({ error: 'parent folder not found' });
}
const now = new Date();
const doc = {
...normalizeFolder({ ...req.body, parent_id: parentId }),
created_at: now,
updated_at: now,
};
const result = await getDb().collection('folders').insertOne(doc);
res.status(201).json({ ...doc, _id: result.insertedId });
} catch (err) {
if (err.code === 11000) return res.status(409).json({ error: 'slug already exists' });
console.error('[folders] create', err.message);
res.status(500).json({ error: 'internal' });
}
});
// slug is unique/permanent (§2.1) — not editable here, matches the
// established "update, slug is permanent" convention (castRoutes.js).
router.put('/api/admin/folders/:id', requireAdminContent, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const existing = await getDb().collection('folders').findOne({ _id: id });
if (!existing) return res.status(404).json({ error: 'not found' });
if (!req.body.name || !String(req.body.name).trim()) {
return res.status(400).json({ error: 'name required' });
}
let parentId = null;
if (req.body.parent_id) {
parentId = parseId(req.body.parent_id);
if (!parentId) return res.status(400).json({ error: 'invalid parent_id' });
const parent = await getDb().collection('folders').findOne({ _id: parentId });
if (!parent) return res.status(400).json({ error: 'parent folder not found' });
if (await wouldCreateCycle(getDb(), id, parentId)) {
return res.status(400).json({ error: 'that move would create a folder cycle' });
}
}
const normalized = normalizeFolder({ ...req.body, slug: existing.slug, parent_id: parentId });
const after = await getDb().collection('folders').findOneAndUpdate(
{ _id: id },
{ $set: { name: normalized.name, parent_id: normalized.parent_id, display_order: normalized.display_order, updated_at: new Date() } },
{ returnDocument: 'after' }
);
res.json(after);
} catch (err) {
console.error('[folders] update', err.message);
res.status(500).json({ error: 'internal' });
}
});
// Conservative delete — no cascade. Must be empty of child folders and stories.
router.delete('/api/admin/folders/:id', requireAdminContent, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const existing = await getDb().collection('folders').findOne({ _id: id });
if (!existing) return res.status(404).json({ error: 'not found' });
const childFolder = await getDb().collection('folders').findOne({ parent_id: id });
if (childFolder) return res.status(409).json({ error: 'folder has child folders — move or delete them first' });
const childStory = await getDb().collection('stories').findOne({ folder_id: id });
if (childStory) return res.status(409).json({ error: 'folder has stories in it — move or delete them first' });
await getDb().collection('folders').deleteOne({ _id: id });
res.json({ deleted: req.params.id });
} catch (err) {
console.error('[folders] delete', err.message);
res.status(500).json({ error: 'internal' });
}
});
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
// adminPages.js — admin page routes (design-018 §3), gated admin.content
// from birth via requireAdminContentPage (redirect-to-login / 403 page,
// browser-navigation-friendly — the JSON APIs in adminFolders/Tags/Stories/
// Chapters.js use the 401/403-JSON requireAdminContent instead).
'use strict';
const express = require('express');
const { ObjectId } = require('mongodb');
const { getDb } = require('../config/mongo');
const { buildFolderTree } = require('../lib/folderTree');
const { requireAdminContentPage } = require('./auth');
const router = express.Router();
function parseId(raw) {
try {
return new ObjectId(raw);
} catch {
return null;
}
}
router.get('/admin', requireAdminContentPage, (req, res) => {
res.render('admin/dashboard', { user: req.user });
});
router.get('/admin/folders', requireAdminContentPage, async (req, res) => {
try {
const raw = await getDb().collection('folders').find({}).toArray();
res.render('admin/folders', { folders: buildFolderTree(raw) });
} catch (err) {
console.error('[admin/folders] page', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/admin/tags', requireAdminContentPage, async (req, res) => {
try {
const tags = await getDb().collection('tags').find({}).sort({ kind: 1, slug: 1 }).toArray();
res.render('admin/tags', { tags });
} catch (err) {
console.error('[admin/tags] page', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/admin/stories', requireAdminContentPage, async (req, res) => {
try {
const stories = await getDb().collection('stories').find({}).sort({ updated_at: -1 }).toArray();
res.render('admin/stories', { stories });
} catch (err) {
console.error('[admin/stories] page', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/admin/stories/new', requireAdminContentPage, async (req, res) => {
try {
const db = getDb();
const [rawFolders, tags] = await Promise.all([
db.collection('folders').find({}).toArray(),
db.collection('tags').find({}).sort({ label: 1 }).toArray(),
]);
res.render('admin/story-form', {
story: null,
chapters: [],
folders: buildFolderTree(rawFolders),
explicitTags: tags.filter(t => t.kind === 'explicit'),
generalTags: tags.filter(t => t.kind === 'general'),
currentUserId: req.user.id,
});
} catch (err) {
console.error('[admin/stories/new] page', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/admin/stories/:id', requireAdminContentPage, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(404).send('Not found');
const db = getDb();
const story = await db.collection('stories').findOne({ _id: id });
if (!story) return res.status(404).send('Not found');
const [rawFolders, tags, chapters] = await Promise.all([
db.collection('folders').find({}).toArray(),
db.collection('tags').find({}).sort({ label: 1 }).toArray(),
db.collection('chapters').find({ story_id: id }, { projection: { body_json: 0 } }).sort({ display_order: 1 }).toArray(),
]);
res.render('admin/story-form', {
story,
chapters,
folders: buildFolderTree(rawFolders),
explicitTags: tags.filter(t => t.kind === 'explicit'),
generalTags: tags.filter(t => t.kind === 'general'),
currentUserId: req.user.id,
});
} catch (err) {
console.error('[admin/stories/:id] page', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/admin/stories/:id/chapters/new', requireAdminContentPage, async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(404).send('Not found');
const story = await getDb().collection('stories').findOne({ _id: id });
if (!story) return res.status(404).send('Not found');
res.render('admin/chapter-editor', { storyId: req.params.id, chapterId: null });
});
router.get('/admin/stories/:id/chapters/:cid', requireAdminContentPage, async (req, res) => {
const id = parseId(req.params.id);
if (!id) return res.status(404).send('Not found');
const story = await getDb().collection('stories').findOne({ _id: id });
if (!story) return res.status(404).send('Not found');
res.render('admin/chapter-editor', { storyId: req.params.id, chapterId: req.params.cid });
});
module.exports = router;
+218
View File
@@ -0,0 +1,218 @@
// adminStories.js — story CRUD + publish toggle, admin.content gated (design-018 §2.2/§6)
'use strict';
const express = require('express');
const { ObjectId } = require('mongodb');
const { getDb } = require('../config/mongo');
const { normalizeStory } = require('../lib/normalize');
const { requireAdminContent } = require('./auth');
const router = express.Router();
function parseId(raw) {
try {
return new ObjectId(raw);
} catch {
return null;
}
}
// Tag slugs referenced by a story must exist in the curated vocabulary, and
// under the matching kind — stories don't get to invent tags inline.
async function validateTagSlugs(db, slugs, kind) {
if (!slugs.length) return true;
const count = await db.collection('tags').countDocuments({ slug: { $in: slugs }, kind });
return count === slugs.length;
}
router.get('/api/admin/stories', requireAdminContent, async (req, res) => {
try {
const stories = await getDb().collection('stories')
.find({})
.sort({ folder_id: 1, display_order: 1 })
.toArray();
res.json(stories);
} catch (err) {
console.error('[stories] list', err.message);
res.status(500).json({ error: 'internal' });
}
});
router.get('/api/admin/stories/:id', requireAdminContent, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const story = await getDb().collection('stories').findOne({ _id: id });
if (!story) return res.status(404).json({ error: 'not found' });
res.json(story);
} catch (err) {
console.error('[stories] get one', err.message);
res.status(500).json({ error: 'internal' });
}
});
router.post('/api/admin/stories', requireAdminContent, async (req, res) => {
try {
const { slug } = req.body;
if (!slug || !/^[a-z0-9-]+$/.test(slug)) {
return res.status(400).json({ error: 'slug required (lowercase letters, digits, hyphens only)' });
}
if (!req.body.title || !String(req.body.title).trim()) {
return res.status(400).json({ error: 'title required' });
}
const db = getDb();
let folderId = null;
if (req.body.folder_id) {
folderId = parseId(req.body.folder_id);
if (!folderId) return res.status(400).json({ error: 'invalid folder_id' });
if (!(await db.collection('folders').findOne({ _id: folderId }))) {
return res.status(400).json({ error: 'folder not found' });
}
}
const normalized = normalizeStory({
...req.body,
folder_id: folderId,
author_user_id: req.body.author_user_id ?? req.user.id,
});
if (!(await validateTagSlugs(db, normalized.explicit_tags, 'explicit'))) {
return res.status(400).json({ error: 'one or more explicit_tags are not valid explicit tags' });
}
if (!(await validateTagSlugs(db, normalized.general_tags, 'general'))) {
return res.status(400).json({ error: 'one or more general_tags are not valid general tags' });
}
const now = new Date();
const doc = { ...normalized, created_at: now, updated_at: now };
const result = await db.collection('stories').insertOne(doc);
res.status(201).json({ ...doc, _id: result.insertedId });
} catch (err) {
if (err.code === 11000) return res.status(409).json({ error: 'slug already exists' });
console.error('[stories] create', err.message);
res.status(500).json({ error: 'internal' });
}
});
// slug is unique/permanent, status/published_at are owned by the publish-toggle
// endpoint below, word_count/rating_* are derived — none are editable here.
router.put('/api/admin/stories/:id', requireAdminContent, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const db = getDb();
const existing = await db.collection('stories').findOne({ _id: id });
if (!existing) return res.status(404).json({ error: 'not found' });
if (!req.body.title || !String(req.body.title).trim()) {
return res.status(400).json({ error: 'title required' });
}
let folderId = null;
if (req.body.folder_id) {
folderId = parseId(req.body.folder_id);
if (!folderId) return res.status(400).json({ error: 'invalid folder_id' });
if (!(await db.collection('folders').findOne({ _id: folderId }))) {
return res.status(400).json({ error: 'folder not found' });
}
}
const normalized = normalizeStory({
...req.body,
slug: existing.slug,
folder_id: folderId,
author_user_id: req.body.author_user_id ?? existing.author_user_id,
});
if (!(await validateTagSlugs(db, normalized.explicit_tags, 'explicit'))) {
return res.status(400).json({ error: 'one or more explicit_tags are not valid explicit tags' });
}
if (!(await validateTagSlugs(db, normalized.general_tags, 'general'))) {
return res.status(400).json({ error: 'one or more general_tags are not valid general tags' });
}
const after = await db.collection('stories').findOneAndUpdate(
{ _id: id },
{ $set: {
folder_id: normalized.folder_id,
title: normalized.title,
subtitle: normalized.subtitle,
badges: normalized.badges,
summary: normalized.summary,
cover_url: normalized.cover_url,
story_type: normalized.story_type,
content_rating: normalized.content_rating,
explicit_tags: normalized.explicit_tags,
general_tags: normalized.general_tags,
serial_status: normalized.serial_status,
display_order: normalized.display_order,
chronological_order: normalized.chronological_order,
author_user_id: normalized.author_user_id,
updated_at: new Date(),
} },
{ returnDocument: 'after' }
);
res.json(after);
} catch (err) {
console.error('[stories] update', err.message);
res.status(500).json({ error: 'internal' });
}
});
// published_at is set once, on first publish (§2.2: "release-order sort
// key") — unpublishing and republishing later does not move it.
router.patch('/api/admin/stories/:id/status', requireAdminContent, async (req, res) => {
try {
const { status } = req.body;
if (status !== 'draft' && status !== 'published') {
return res.status(400).json({ error: 'status must be draft or published' });
}
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const db = getDb();
const existing = await db.collection('stories').findOne({ _id: id });
if (!existing) return res.status(404).json({ error: 'not found' });
const set = { status, updated_at: new Date() };
if (status === 'published' && !existing.published_at) {
set.published_at = new Date();
}
const after = await db.collection('stories').findOneAndUpdate(
{ _id: existing._id },
{ $set: set },
{ returnDocument: 'after' }
);
res.json(after);
} catch (err) {
console.error('[stories] status toggle', err.message);
res.status(500).json({ error: 'internal' });
}
});
// Cascades to the story's chapters — they have no independent meaning
// without their parent story.
router.delete('/api/admin/stories/:id', requireAdminContent, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const db = getDb();
const existing = await db.collection('stories').findOne({ _id: id });
if (!existing) return res.status(404).json({ error: 'not found' });
await db.collection('chapters').deleteMany({ story_id: id });
await db.collection('stories').deleteOne({ _id: id });
res.json({ deleted: req.params.id });
} catch (err) {
console.error('[stories] delete', err.message);
res.status(500).json({ error: 'internal' });
}
});
module.exports = router;
+95
View File
@@ -0,0 +1,95 @@
// adminTags.js — curated tag vocabulary CRUD, admin.content gated (design-018 §2.4/§6)
'use strict';
const express = require('express');
const { ObjectId } = require('mongodb');
const { getDb } = require('../config/mongo');
const { normalizeTag } = require('../lib/normalize');
const { requireAdminContent } = require('./auth');
const router = express.Router();
function parseId(raw) {
try {
return new ObjectId(raw);
} catch {
return null;
}
}
router.get('/api/admin/tags', requireAdminContent, async (req, res) => {
try {
const tags = await getDb().collection('tags').find({}).sort({ kind: 1, slug: 1 }).toArray();
res.json(tags);
} catch (err) {
console.error('[tags] list', err.message);
res.status(500).json({ error: 'internal' });
}
});
router.post('/api/admin/tags', requireAdminContent, async (req, res) => {
try {
const normalized = normalizeTag(req.body);
if (!normalized.slug || !/^[a-z0-9-]+$/.test(normalized.slug)) {
return res.status(400).json({ error: 'slug required (lowercase letters, digits, hyphens only)' });
}
if (!normalized.label) return res.status(400).json({ error: 'label required' });
const now = new Date();
const doc = { ...normalized, created_at: now, updated_at: now };
const result = await getDb().collection('tags').insertOne(doc);
res.status(201).json({ ...doc, _id: result.insertedId });
} catch (err) {
if (err.code === 11000) return res.status(409).json({ error: 'slug already exists' });
console.error('[tags] create', err.message);
res.status(500).json({ error: 'internal' });
}
});
// slug is unique/permanent — not editable here.
router.put('/api/admin/tags/:id', requireAdminContent, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const existing = await getDb().collection('tags').findOne({ _id: id });
if (!existing) return res.status(404).json({ error: 'not found' });
const normalized = normalizeTag({ ...req.body, slug: existing.slug });
if (!normalized.label) return res.status(400).json({ error: 'label required' });
const after = await getDb().collection('tags').findOneAndUpdate(
{ _id: id },
{ $set: { label: normalized.label, kind: normalized.kind, description: normalized.description, updated_at: new Date() } },
{ returnDocument: 'after' }
);
res.json(after);
} catch (err) {
console.error('[tags] update', err.message);
res.status(500).json({ error: 'internal' });
}
});
// §2.4: deleting a tag requires it be unused.
router.delete('/api/admin/tags/:id', requireAdminContent, async (req, res) => {
try {
const id = parseId(req.params.id);
if (!id) return res.status(400).json({ error: 'invalid id' });
const existing = await getDb().collection('tags').findOne({ _id: id });
if (!existing) return res.status(404).json({ error: 'not found' });
const inUse = await getDb().collection('stories').findOne({
$or: [{ explicit_tags: existing.slug }, { general_tags: existing.slug }],
});
if (inUse) return res.status(409).json({ error: 'tag is in use on at least one story' });
await getDb().collection('tags').deleteOne({ _id: id });
res.json({ deleted: req.params.id });
} catch (err) {
console.error('[tags] delete', err.message);
res.status(500).json({ error: 'internal' });
}
});
module.exports = router;
+281
View File
@@ -0,0 +1,281 @@
// src/routes/auth.js — BFF OIDC client (authorization code + PKCE), design-018 §3
'use strict';
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { env } = require('../config/env');
const { getRedis } = require('../config/redis');
const router = express.Router();
const SESSION_COOKIE_NAME = 'stompingSession';
const SESSION_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
const SESSION_TOKEN_TYPE = 'stomping-session';
const OIDC_STATE_COOKIE = 'stomping_oidc_state';
const OIDC_STATE_MAX_AGE = 5 * 60 * 1000;
// ── Session helpers ───────────────────────────────────────────────────────────
function signSession(user) {
return jwt.sign(
{
type: SESSION_TOKEN_TYPE,
sub: user.id,
username: user.username,
displayName: user.displayName || user.username,
permissions: user.permissions || [],
},
env.ACCESS_TOKEN_SECRET,
{ expiresIn: '7d' }
);
}
function parseSession(cookieValue) {
if (!cookieValue) return null;
try {
const decoded = jwt.verify(cookieValue, env.ACCESS_TOKEN_SECRET, { algorithms: ['HS256'] });
if (decoded.type !== SESSION_TOKEN_TYPE) return null;
return decoded;
} catch {
return null;
}
}
function setSessionCookie(res, sessionJwt) {
res.cookie(SESSION_COOKIE_NAME, sessionJwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: SESSION_COOKIE_MAX_AGE,
path: '/',
});
}
// ── PKCE helpers ──────────────────────────────────────────────────────────────
function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
function generateCodeChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
// ── Session resolution — shared by requireSession and optional-auth reads ────
// Returns the user object, or null if absent/invalid/revoked. Never rejects
// on a missing session — that's the caller's call (strict vs. optional).
async function resolveSession(req) {
const session = parseSession(req.cookies?.[SESSION_COOKIE_NAME]);
if (!session) return null;
try {
const redis = getRedis();
const revokedBefore = await redis.get(`access:user:revoked_before:${session.sub}`);
if (revokedBefore && session.iat < parseInt(revokedBefore, 10)) return null;
} catch (err) {
console.error('[Session] revoked_before check failed (fail-closed)', err.message);
return null;
}
return {
id: String(session.sub),
username: session.username,
displayName: session.displayName || session.username,
permissions: session.permissions || [],
};
}
// ── requireSession middleware — 401 JSON, for API-style routes ───────────────
async function requireSession(req, res, next) {
const user = await resolveSession(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
req.user = user;
next();
}
// ── optionalSession middleware — never rejects, just populates req.user ─────
// For public pages (design-018: "public reading requires no login") that still
// want to show logged-in state when a session is present.
async function optionalSession(req, res, next) {
req.user = await resolveSession(req);
next();
}
// ── requireAdminContent — every admin route, from birth (design-018 §3) ─────
// Scoped to admin.content (never a bare 'admin' check) — design-016 scoped-
// perm doctrine, same gate the hub cast/news admin already uses. Migrates to
// stories.write once a non-operator author exists (design-018 §7).
async function requireAdminContent(req, res, next) {
const user = await resolveSession(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
if (!user.permissions.includes('admin.content')) {
return res.status(403).json({ error: 'forbidden' });
}
req.user = user;
next();
}
// ── requireAdminContentPage — same gate, browser-navigation-friendly outcome
// (redirect to login vs. a 401 JSON blob) — for the admin page routes rather
// than their underlying JSON APIs.
async function requireAdminContentPage(req, res, next) {
const user = await resolveSession(req);
if (!user) {
return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
}
if (!user.permissions.includes('admin.content')) {
return res.status(403).render('admin/forbidden');
}
req.user = user;
next();
}
// ── OIDC routes ───────────────────────────────────────────────────────────────
router.get('/auth/login', (req, res) => {
const returnTo = req.query.returnTo || '/';
const silent = req.query.silent === '1';
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = crypto.randomBytes(16).toString('base64url');
const oidcState = JSON.stringify({ codeVerifier, state, returnTo, silent });
res.cookie(OIDC_STATE_COOKIE, oidcState, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: OIDC_STATE_MAX_AGE,
signed: true,
path: '/',
});
const params = new URLSearchParams({
client_id: env.OIDC_CLIENT_ID,
redirect_uri: env.OIDC_REDIRECT_URI,
response_type: 'code',
scope: 'openid profile agwol:permissions',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
if (silent) params.set('prompt', 'none');
return res.redirect(`${env.AUTH_PUBLIC_ORIGIN}/auth/authorize?${params.toString()}`);
});
// A silent check (prompt=none, auto-triggered — see public/silent-auth.js)
// failing is the ordinary outcome for an anonymous visitor: land back on the
// page they started from with no error shown, rather than an error page they
// never asked to see. An explicit, user-clicked login failure still shows one.
function failCallback(res, silent, returnTo, status, message) {
if (silent) {
const destination = returnTo?.startsWith('/') ? returnTo : '/';
return res.redirect(302, destination);
}
return res.status(status).send(message);
}
router.get('/auth/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
if (!state) {
return res.status(400).send('Missing state parameter');
}
const rawState = req.signedCookies?.[OIDC_STATE_COOKIE];
res.clearCookie(OIDC_STATE_COOKIE, { path: '/' });
if (!rawState) {
console.warn('[OIDC] Missing state cookie — possible CSRF or expired flow');
return res.redirect('/auth/login');
}
let oidcState;
try {
oidcState = JSON.parse(rawState);
} catch {
console.warn('[OIDC] Malformed state cookie');
return res.redirect('/auth/login');
}
if (oidcState.state !== state) {
console.warn('[OIDC] State mismatch — possible CSRF');
return res.status(400).send('Invalid state parameter');
}
const { codeVerifier, returnTo, silent } = oidcState;
if (error) {
// login_required is the expected outcome of a silent check for an
// anonymous visitor — not a real error, don't log it as one.
if (error !== 'login_required') {
console.error('[OIDC] Authorize error', error, error_description);
}
return failCallback(res, silent, returnTo, 400, `Authentication failed: ${error_description || error}`);
}
if (!code) {
return failCallback(res, silent, returnTo, 400, 'Missing authorization code');
}
try {
const tokenRes = await fetch(`${env.AUTH_INTERNAL_URL}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: env.OIDC_REDIRECT_URI,
client_id: env.OIDC_CLIENT_ID,
client_secret: env.OIDC_CLIENT_SECRET,
code_verifier: codeVerifier,
}),
});
if (!tokenRes.ok) {
const errBody = await tokenRes.text();
console.error('[OIDC] Token exchange failed', tokenRes.status, errBody);
return failCallback(res, silent, returnTo, 401, 'Authentication failed');
}
const tokenData = await tokenRes.json();
const idPayload = JSON.parse(
Buffer.from(tokenData.id_token.split('.')[1], 'base64url').toString()
);
const sessionJwt = signSession({
id: idPayload.sub,
username: idPayload.preferred_username,
displayName: idPayload.name || idPayload.preferred_username,
permissions: idPayload.permissions || [],
});
setSessionCookie(res, sessionJwt);
const destination = returnTo?.startsWith('/') ? returnTo : '/';
return res.redirect(302, destination);
} catch (err) {
console.error('[OIDC] Callback error', err.message);
return failCallback(res, silent, returnTo, 500, 'Authentication error');
}
});
router.get('/auth/logout', (req, res) => {
res.clearCookie(SESSION_COOKIE_NAME, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
});
return res.redirect(302, `${env.AUTH_PUBLIC_ORIGIN}/logout`);
});
router.get('/auth/me', requireSession, (req, res) => {
res.json(req.user);
});
module.exports = { router, requireSession, optionalSession, requireAdminContent, requireAdminContentPage };
+10
View File
@@ -0,0 +1,10 @@
'use strict';
const express = require('express');
const router = express.Router();
router.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'stomping.me', ts: new Date() });
});
module.exports = router;
+148
View File
@@ -0,0 +1,148 @@
// publicPages.js — public reader (design-018 §3/§6 Phase 4). Published-only
// throughout; draft/unknown slugs 404 identically — every lookup below
// queries {slug, status: 'published'} directly, so a draft and a nonexistent
// slug take the exact same code path to the exact same not-found response.
'use strict';
const express = require('express');
const { getDb } = require('../config/mongo');
const { buildFolderTree } = require('../lib/folderTree');
const { optionalSession } = require('./auth');
const router = express.Router();
const LANDING_STORY_COUNT = 12;
function resolveTagLabels(slugs, tags) {
const bySlug = new Map(tags.map(t => [t.slug, t.label]));
return (slugs || []).map(slug => ({ slug, label: bySlug.get(slug) || slug }));
}
// Simple v1 browse filter (design-018 §5/§6 Phase 5): one tag slug via
// ?tag=, matched against either array. Scoped to whatever the page already
// scopes to (root or a specific folder) — filtering doesn't search outside it.
function tagFilterClause(tagSlug) {
if (!tagSlug) return {};
return { $or: [{ explicit_tags: tagSlug }, { general_tags: tagSlug }] };
}
router.get('/', optionalSession, async (req, res) => {
try {
const db = getDb();
const stories = await db.collection('stories')
.find({ status: 'published' })
.sort({ published_at: -1 })
.limit(LANDING_STORY_COUNT)
.toArray();
res.render('site/landing', { stories, user: req.user });
} catch (err) {
console.error('[public] landing', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/browse', optionalSession, async (req, res) => {
try {
const db = getDb();
const activeTag = req.query.tag || null;
const [rawFolders, stories, tags] = await Promise.all([
db.collection('folders').find({}).toArray(),
db.collection('stories')
.find({ folder_id: null, status: 'published', ...tagFilterClause(activeTag) })
.sort({ display_order: 1 }).toArray(),
db.collection('tags').find({}).sort({ kind: 1, label: 1 }).toArray(),
]);
const tree = buildFolderTree(rawFolders);
const topLevel = tree.filter(f => f.depth === 0);
res.render('site/browse', { folder: null, subfolders: topLevel, stories, tags, activeTag, user: req.user });
} catch (err) {
console.error('[public] browse root', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/browse/:folderSlug', optionalSession, async (req, res) => {
try {
const db = getDb();
const folder = await db.collection('folders').findOne({ slug: req.params.folderSlug });
if (!folder) return res.status(404).render('site/not-found', { kind: 'folder', user: req.user });
let parent = null;
if (folder.parent_id) parent = await db.collection('folders').findOne({ _id: folder.parent_id });
const activeTag = req.query.tag || null;
const [rawFolders, stories, tags] = await Promise.all([
db.collection('folders').find({ parent_id: folder._id }).sort({ display_order: 1 }).toArray(),
db.collection('stories')
.find({ folder_id: folder._id, status: 'published', ...tagFilterClause(activeTag) })
.sort({ display_order: 1 }).toArray(),
db.collection('tags').find({}).sort({ kind: 1, label: 1 }).toArray(),
]);
const subfolders = rawFolders.map(f => ({ ...f, depth: 0 }));
res.render('site/browse', { folder, parent, subfolders, stories, tags, activeTag, user: req.user });
} catch (err) {
console.error('[public] browse folder', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/story/:slug', optionalSession, async (req, res) => {
try {
const db = getDb();
const story = await db.collection('stories').findOne({ slug: req.params.slug, status: 'published' });
if (!story) return res.status(404).render('site/not-found', { kind: 'story', user: req.user });
const [chapters, tags] = await Promise.all([
db.collection('chapters')
.find({ story_id: story._id, status: 'published' }, { projection: { body_json: 0 } })
.sort({ display_order: 1 })
.toArray(),
db.collection('tags').find({}).toArray(),
]);
res.render('site/story', {
story,
chapters,
singleChapter: chapters.length === 1 ? chapters[0] : null,
explicitTags: resolveTagLabels(story.explicit_tags, tags),
generalTags: resolveTagLabels(story.general_tags, tags),
user: req.user,
});
} catch (err) {
console.error('[public] story', err.message);
res.status(500).send('Something went wrong.');
}
});
router.get('/story/:storySlug/:chapterSlug', optionalSession, async (req, res) => {
try {
const db = getDb();
const story = await db.collection('stories').findOne({ slug: req.params.storySlug, status: 'published' });
if (!story) return res.status(404).render('site/not-found', { kind: 'story', user: req.user });
const chapter = await db.collection('chapters')
.findOne({ story_id: story._id, slug: req.params.chapterSlug, status: 'published' });
if (!chapter) return res.status(404).render('site/not-found', { kind: 'chapter', user: req.user });
const allChapters = await db.collection('chapters')
.find({ story_id: story._id, status: 'published' }, { projection: { body_json: 0, body_html: 0 } })
.sort({ display_order: 1 })
.toArray();
const idx = allChapters.findIndex(c => String(c._id) === String(chapter._id));
const prev = idx > 0 ? allChapters[idx - 1] : null;
const next = idx >= 0 && idx < allChapters.length - 1 ? allChapters[idx + 1] : null;
res.render('site/chapter', {
story, chapter, prev, next,
showNav: allChapters.length > 1,
user: req.user,
});
} catch (err) {
console.error('[public] chapter', err.message);
res.status(500).send('Something went wrong.');
}
});
module.exports = router;
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Chapter editor — stomping.me admin</title>
<%- include('../partials/siteStyles') %>
<style>
#editor { min-height: 300px; background: var(--input-bg); color: var(--text); border: 1px solid var(--input-border); border-radius: 5px; padding: 12px 14px; }
#editor p { margin: 0 0 0.8em; }
#editor:focus-within { border-color: var(--accent); }
.toolbar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.toolbar button { background: var(--card-bg); color: var(--text); border: 1px solid var(--input-border); padding: 5px 10px; font-size: 13px; font-weight: 600; }
.toolbar button.is-active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
</style>
</head>
<body data-story-id="<%= storyId %>" data-chapter-id="<%= chapterId || '' %>">
<%- include('../partials/adminNav', { active: 'stories' }) %>
<div class="wrap">
<p><a href="/admin/stories/<%= storyId %>">&larr; back to story</a></p>
<h1><%= chapterId ? 'Edit chapter' : 'New chapter' %></h1>
<div class="card">
<div class="row">
<div class="field">
<label>Slug</label>
<input type="text" id="slug" <%= chapterId ? 'readonly' : '' %>>
</div>
<div class="field"><label>Title</label><input type="text" id="title"></div>
<div class="field"><label>Order</label><input type="number" id="display_order" value="0" style="width:80px"></div>
</div>
<div class="field"><label>Note (top)</label><textarea id="note_top"></textarea></div>
<label>Body</label>
<div class="toolbar">
<button type="button" data-cmd="bold"><b>B</b></button>
<button type="button" data-cmd="italic"><i>I</i></button>
<button type="button" data-cmd="strike"><s>S</s></button>
<button type="button" data-cmd="heading1">H1</button>
<button type="button" data-cmd="heading2">H2</button>
<button type="button" data-cmd="bulletList">• List</button>
<button type="button" data-cmd="orderedList">1. List</button>
<button type="button" data-cmd="blockquote">Quote</button>
<button type="button" data-cmd="horizontalRule">HR</button>
<button type="button" data-cmd="link">Link</button>
<button type="button" data-cmd="image">Image</button>
<button type="button" data-cmd="undo">Undo</button>
<button type="button" data-cmd="redo">Redo</button>
</div>
<div id="editor"></div>
<div class="field" style="margin-top:14px"><label>Note (bottom)</label><textarea id="note_bottom"></textarea></div>
<div style="margin-top:14px">
<button id="save-btn"><%= chapterId ? 'Save' : 'Create chapter' %></button>
<% if (chapterId) { %><button id="publish-btn" class="secondary"></button><% } %>
<span class="muted" id="word-count"></span>
</div>
<div id="save-status" class="status-msg"></div>
</div>
</div>
<script src="/admin-js/editor-bundle.js"></script>
<script src="/admin-js/chapter-editor.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Admin — stomping.me</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/adminNav', { active: 'dashboard' }) %>
<div class="wrap">
<h1>stomping.me admin</h1>
<p class="muted">Logged in as <strong><%= user.displayName %></strong> (<%= user.username %>).</p>
<ul>
<li><a href="/admin/folders">Manage folders</a></li>
<li><a href="/admin/tags">Manage tags</a></li>
<li><a href="/admin/stories">Manage stories</a></li>
</ul>
</div>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Folders — stomping.me admin</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/adminNav', { active: 'folders' }) %>
<div class="wrap">
<h1>Folders</h1>
<table id="folder-table">
<thead><tr><th>Name</th><th>Slug</th><th>Parent</th><th>Order</th><th></th></tr></thead>
<tbody>
<% folders.forEach(f => { %>
<tr data-id="<%= f._id %>">
<td class="indent-<%= Math.min(f.depth, 4) %>"><input type="text" class="f-name" value="<%= f.name %>"></td>
<td class="muted"><%= f.slug %></td>
<td>
<select class="f-parent">
<option value="">(root)</option>
<% folders.forEach(opt => { %>
<option value="<%= opt._id %>" <%= String(opt._id) === String(f.parent_id) ? 'selected' : '' %> <%= String(opt._id) === String(f._id) ? 'disabled' : '' %>>
<%= '—'.repeat(opt.depth) %> <%= opt.name %>
</option>
<% }) %>
</select>
</td>
<td><input type="number" class="f-order" value="<%= f.display_order %>" style="width:70px"></td>
<td>
<button class="f-save">Save</button>
<button class="danger f-delete">Delete</button>
</td>
</tr>
<% }) %>
</tbody>
</table>
<div class="card">
<h2 style="margin-top:0">New folder</h2>
<div class="row">
<div class="field"><label>Slug</label><input type="text" id="new-slug" placeholder="my-folder"></div>
<div class="field"><label>Name</label><input type="text" id="new-name" placeholder="My Folder"></div>
</div>
<div class="row">
<div class="field">
<label>Parent</label>
<select id="new-parent">
<option value="">(root)</option>
<% folders.forEach(opt => { %>
<option value="<%= opt._id %>"><%= '—'.repeat(opt.depth) %> <%= opt.name %></option>
<% }) %>
</select>
</div>
<div class="field"><label>Order</label><input type="number" id="new-order" value="0"></div>
</div>
<button id="create-folder">Create folder</button>
<div id="create-status" class="status-msg"></div>
</div>
</div>
<script src="/admin-js/folders.js"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Forbidden — stomping.me admin</title><%- include('../partials/siteStyles') %></head>
<body>
<div class="wrap">
<h1>403 — Forbidden</h1>
<p class="muted">Your account doesn't have the <code>admin.content</code> permission required to use this area.</p>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Stories — stomping.me admin</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/adminNav', { active: 'stories' }) %>
<div class="wrap">
<h1>Stories</h1>
<p><a class="btn" href="/admin/stories/new">+ New story</a></p>
<table id="story-table">
<thead><tr><th>Title</th><th>Type</th><th>Status</th><th>Words</th><th></th></tr></thead>
<tbody>
<% stories.forEach(s => { %>
<tr data-id="<%= s._id %>">
<td><a href="/admin/stories/<%= s._id %>"><%= s.title %></a> <span class="muted"><%= s.slug %></span></td>
<td class="muted"><%= s.story_type %></td>
<td><span class="pill <%= s.status %>"><%= s.status %></span></td>
<td class="muted"><%= s.word_count %></td>
<td>
<button class="s-toggle" data-status="<%= s.status === 'published' ? 'draft' : 'published' %>">
<%= s.status === 'published' ? 'Unpublish' : 'Publish' %>
</button>
<button class="danger s-delete">Delete</button>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<script src="/admin-js/stories.js"></script>
</body>
</html>
+134
View File
@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><%= story ? 'Edit' : 'New' %> story — stomping.me admin</title><%- include('../partials/siteStyles') %></head>
<body data-story-id="<%= story ? story._id : '' %>">
<%- include('../partials/adminNav', { active: 'stories' }) %>
<div class="wrap">
<h1><%= story ? 'Edit story' : 'New story' %></h1>
<div class="card">
<form id="story-form">
<div class="row">
<div class="field">
<label>Slug</label>
<input type="text" id="slug" value="<%= story ? story.slug : '' %>" <%= story ? 'readonly' : '' %>>
</div>
<div class="field"><label>Title</label><input type="text" id="title" value="<%= story ? story.title : '' %>"></div>
</div>
<div class="field"><label>Subtitle</label><input type="text" id="subtitle" value="<%= story && story.subtitle ? story.subtitle : '' %>"></div>
<div class="field"><label>Summary</label><textarea id="summary"><%= story && story.summary ? story.summary : '' %></textarea></div>
<div class="row">
<div class="field">
<label>Folder</label>
<select id="folder_id">
<option value="">(none)</option>
<% folders.forEach(f => { %>
<option value="<%= f._id %>" <%= story && String(story.folder_id) === String(f._id) ? 'selected' : '' %>>
<%= '—'.repeat(f.depth) %> <%= f.name %>
</option>
<% }) %>
</select>
</div>
<div class="field">
<label>Story type</label>
<select id="story_type">
<% ['one_shot','serial','bite_size'].forEach(t => { %>
<option value="<%= t %>" <%= story && story.story_type === t ? 'selected' : '' %>><%= t %></option>
<% }) %>
</select>
</div>
<div class="field">
<label>Content rating</label>
<select id="content_rating">
<% ['general','teen','mature','adult'].forEach(r => { %>
<option value="<%= r %>" <%= story && story.content_rating === r ? 'selected' : (!story && r === 'general' ? 'selected' : '') %>><%= r %></option>
<% }) %>
</select>
</div>
<div class="field">
<label>Serial status</label>
<select id="serial_status">
<option value="">(n/a)</option>
<% ['ongoing','completed','hiatus'].forEach(s => { %>
<option value="<%= s %>" <%= story && story.serial_status === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
</div>
</div>
<div class="field">
<label>Badges (comma-separated)</label>
<input type="text" id="badges" value="<%= story && story.badges ? story.badges.join(', ') : '' %>">
</div>
<div class="field"><label>Cover URL</label><input type="url" id="cover_url" value="<%= story && story.cover_url ? story.cover_url : '' %>"></div>
<h2>Tags</h2>
<div class="field">
<label>Explicit tags</label>
<% explicitTags.forEach(t => { %>
<label style="display:inline-block;text-transform:none;font-weight:normal;margin-right:14px">
<input type="checkbox" class="tag-explicit" value="<%= t.slug %>"
<%= story && story.explicit_tags && story.explicit_tags.includes(t.slug) ? 'checked' : '' %>>
<%= t.label %>
</label>
<% }) %>
<% if (!explicitTags.length) { %><span class="muted">No explicit tags yet — add some on the Tags page.</span><% } %>
</div>
<div class="field">
<label>General tags</label>
<% generalTags.forEach(t => { %>
<label style="display:inline-block;text-transform:none;font-weight:normal;margin-right:14px">
<input type="checkbox" class="tag-general" value="<%= t.slug %>"
<%= story && story.general_tags && story.general_tags.includes(t.slug) ? 'checked' : '' %>>
<%= t.label %>
</label>
<% }) %>
<% if (!generalTags.length) { %><span class="muted">No general tags yet — add some on the Tags page.</span><% } %>
</div>
<div class="row">
<div class="field"><label>Display order</label><input type="number" id="display_order" value="<%= story ? story.display_order : 0 %>"></div>
<div class="field"><label>Chronological order</label><input type="number" id="chronological_order" value="<%= story && story.chronological_order != null ? story.chronological_order : '' %>"></div>
<div class="field"><label>Author user id</label><input type="number" id="author_user_id" value="<%= story ? story.author_user_id : currentUserId %>"></div>
</div>
<button type="submit" id="save-btn"><%= story ? 'Save' : 'Create story' %></button>
<div id="save-status" class="status-msg"></div>
</form>
</div>
<% if (story) { %>
<h2>Chapters</h2>
<% if (story.story_type === 'one_shot' || story.story_type === 'bite_size') { %>
<p class="muted" style="margin-top:-8px">
This is where the actual writing goes. One-shot and bite-size stories still use a
single chapter as their content — add one below; the reader won't show a chapter
list when there's only one.
</p>
<% } %>
<table>
<thead><tr><th>Order</th><th>Title</th><th>Status</th><th>Words</th><th></th></tr></thead>
<tbody id="chapter-table-body">
<% chapters.forEach(c => { %>
<tr data-id="<%= c._id %>">
<td class="muted"><%= c.display_order %></td>
<td><a href="/admin/stories/<%= story._id %>/chapters/<%= c._id %>"><%= c.title %></a></td>
<td><span class="pill <%= c.status %>"><%= c.status %></span></td>
<td class="muted"><%= c.word_count %></td>
<td>
<button class="c-toggle" data-status="<%= c.status === 'published' ? 'draft' : 'published' %>">
<%= c.status === 'published' ? 'Unpublish' : 'Publish' %>
</button>
<button class="danger c-delete">Delete</button>
</td>
</tr>
<% }) %>
</tbody>
</table>
<p><a class="btn" href="/admin/stories/<%= story._id %>/chapters/new">+ New chapter</a></p>
<% } %>
</div>
<script src="/admin-js/story-form.js"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Tags — stomping.me admin</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/adminNav', { active: 'tags' }) %>
<div class="wrap">
<h1>Tags</h1>
<table id="tag-table">
<thead><tr><th>Label</th><th>Slug</th><th>Kind</th><th>Description</th><th></th></tr></thead>
<tbody>
<% tags.forEach(t => { %>
<tr data-id="<%= t._id %>">
<td><input type="text" class="t-label" value="<%= t.label %>"></td>
<td class="muted"><%= t.slug %></td>
<td>
<select class="t-kind">
<option value="general" <%= t.kind === 'general' ? 'selected' : '' %>>general</option>
<option value="explicit" <%= t.kind === 'explicit' ? 'selected' : '' %>>explicit</option>
</select>
</td>
<td><input type="text" class="t-description" value="<%= t.description || '' %>"></td>
<td>
<button class="t-save">Save</button>
<button class="danger t-delete">Delete</button>
</td>
</tr>
<% }) %>
</tbody>
</table>
<div class="card">
<h2 style="margin-top:0">New tag</h2>
<div class="row">
<div class="field"><label>Slug</label><input type="text" id="new-slug" placeholder="found-family"></div>
<div class="field"><label>Label</label><input type="text" id="new-label" placeholder="Found Family"></div>
<div class="field">
<label>Kind</label>
<select id="new-kind">
<option value="general">general</option>
<option value="explicit">explicit</option>
</select>
</div>
</div>
<div class="field"><label>Description</label><input type="text" id="new-description"></div>
<button id="create-tag">Create tag</button>
<div id="create-status" class="status-msg"></div>
</div>
</div>
<script src="/admin-js/tags.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<nav class="admin-nav">
<a href="/admin" class="<%= active === 'dashboard' ? 'active' : '' %>">Dashboard</a>
<a href="/admin/folders" class="<%= active === 'folders' ? 'active' : '' %>">Folders</a>
<a href="/admin/tags" class="<%= active === 'tags' ? 'active' : '' %>">Tags</a>
<a href="/admin/stories" class="<%= active === 'stories' ? 'active' : '' %>">Stories</a>
</nav>
+9
View File
@@ -0,0 +1,9 @@
<nav class="site-nav">
<a href="/" class="brand">stomping.me</a>
<a href="/browse">Browse</a>
<% if (typeof user !== 'undefined' && user) { %>
<a href="/auth/logout" style="margin-left:auto">Log out (<%= user.displayName %>)</a>
<% } else { %>
<a href="/auth/login" style="margin-left:auto">Log in</a>
<% } %>
</nav>
+94
View File
@@ -0,0 +1,94 @@
<style>
:root {
--bg: #f5f6f8; --text: #1a1d23; --text-muted: #6b7280; --text-nav: #4b5563; --text-nav-active: #111827;
--border: #e2e4e8; --card-bg: #ffffff; --input-bg: #ffffff; --input-border: #d1d5db;
--link: #2f5fd0; --accent: #3a6df0; --accent-text: #fff;
--pill-draft-bg: #fdf1d2; --pill-draft-text: #8a6d1f;
--pill-pub-bg: #dff5e3; --pill-pub-text: #1f7a3c;
--status-error: #c23b3b; --status-ok: #1f7a3c;
--danger: #c23b3b;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14161a; --text: #e4e6eb; --text-muted: #8a92a3; --text-nav: #aab2c0; --text-nav-active: #fff;
--border: #2a2d33; --card-bg: #1b1e24; --input-bg: #14161a; --input-border: #33363d;
--link: #6ea8fe; --accent: #3a6df0; --accent-text: #fff;
--pill-draft-bg: #3a3220; --pill-draft-text: #e0b84c;
--pill-pub-bg: #1e3a26; --pill-pub-text: #5fd47a;
--status-error: #e07070; --status-ok: #5fd47a;
--danger: #a83a3a;
}
}
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); margin: 0; }
a { color: var(--link); }
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 24px 80px; }
nav.admin-nav { display: flex; gap: 18px; padding: 16px 24px; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
nav.admin-nav a { text-decoration: none; font-size: 14px; font-weight: 600; color: var(--text-nav); }
nav.admin-nav a.active { color: var(--text-nav-active); }
h1 { font-size: 20px; margin: 0 0 20px; }
h2 { font-size: 15px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin: 32px 0 12px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 14px; }
th { color: var(--text-muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }
.muted { color: var(--text-muted); }
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.pill.draft { background: var(--pill-draft-bg); color: var(--pill-draft-text); }
.pill.published { background: var(--pill-pub-bg); color: var(--pill-pub-text); }
form.inline { display: inline; }
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 24px; }
label { display: block; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 4px; }
.field { margin-bottom: 14px; }
input[type="text"], input[type="number"], input[type="url"], textarea, select {
width: 100%; background: var(--input-bg); border: 1px solid var(--input-border); border-radius: 5px;
color: var(--text); font-size: 14px; padding: 8px 10px; font-family: inherit;
}
textarea { min-height: 70px; resize: vertical; }
input[readonly] { opacity: 0.6; }
.row { display: flex; gap: 14px; }
.row > .field { flex: 1; }
button, .btn { background: var(--accent); color: var(--accent-text); border: none; border-radius: 5px; padding: 8px 16px; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
button.secondary, .btn.secondary { background: transparent; border: 1px solid var(--input-border); color: var(--text); }
button.danger { background: var(--danger); color: #fff; }
.status-msg { font-size: 13px; margin-top: 10px; }
.status-msg.error { color: var(--status-error); }
.status-msg.ok { color: var(--status-ok); }
.indent-0 { padding-left: 0; }
.indent-1 { padding-left: 20px; }
.indent-2 { padding-left: 40px; }
.indent-3 { padding-left: 60px; }
.indent-4 { padding-left: 80px; }
/* ── public site (Phase 4) ─────────────────────────────────────────── */
nav.site-nav { display: flex; align-items: center; gap: 20px; padding: 16px 24px; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
nav.site-nav a { text-decoration: none; font-size: 14px; font-weight: 600; color: var(--text-nav); }
nav.site-nav a.brand { color: var(--text-nav-active); font-size: 16px; }
nav.site-nav a:hover { color: var(--text-nav-active); }
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
.story-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-decoration: none; color: var(--text); display: block; }
.story-card:hover { border-color: var(--accent); }
.story-card .cover { width: 100%; aspect-ratio: 3/2; border-radius: 5px; background: var(--input-bg); border: 1px solid var(--border); object-fit: cover; margin-bottom: 10px; }
.story-card h3 { font-size: 15px; margin: 0 0 4px; }
.story-card .subtitle { font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.story-card .summary { font-size: 13px; color: var(--text-muted); }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; background: var(--input-bg); border: 1px solid var(--border); color: var(--text-muted); }
.tag.explicit { background: var(--pill-draft-bg); color: var(--pill-draft-text); border-color: transparent; }
a.tag.active { background: var(--accent); color: var(--accent-text); border-color: transparent; }
.folder-list a { display: block; padding: 10px 14px; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; text-decoration: none; color: var(--text); background: var(--card-bg); }
.folder-list a:hover { border-color: var(--accent); }
.chapter-body { font-size: 16px; line-height: 1.7; max-width: 720px; }
.chapter-body p { margin: 0 0 1em; }
.chapter-body h1, .chapter-body h2, .chapter-body h3 { margin: 1.4em 0 0.6em; }
.chapter-body img { max-width: 100%; border-radius: 6px; }
.chapter-note { font-style: italic; color: var(--text-muted); font-size: 14px; padding: 12px 16px; border-left: 3px solid var(--border); margin: 0 0 20px; }
.chapter-list a { display: flex; justify-content: space-between; padding: 10px 14px; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; text-decoration: none; color: var(--text); }
.chapter-list a:hover { border-color: var(--accent); }
.chapter-nav { display: flex; justify-content: space-between; margin: 32px 0; }
.not-found { text-align: center; padding: 80px 24px; color: var(--text-muted); }
</style>
+15
View File
@@ -0,0 +1,15 @@
<a class="story-card" href="/story/<%= story.slug %>">
<% if (story.cover_url) { %>
<img class="cover" src="<%= story.cover_url %>" alt="">
<% } else { %>
<div class="cover"></div>
<% } %>
<h3><%= story.title %></h3>
<% if (story.subtitle) { %><div class="subtitle"><%= story.subtitle %></div><% } %>
<div class="tag-row">
<span class="tag"><%= story.story_type %></span>
<span class="tag"><%= story.content_rating %></span>
<% (story.badges || []).forEach(b => { %><span class="tag"><%= b %></span><% }) %>
</div>
<% if (story.summary) { %><div class="summary"><%= story.summary %></div><% } %>
</a>
+51
View File
@@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><%= folder ? folder.name : 'Browse' %> — stomping.me</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/siteNav', { user }) %>
<div class="wrap">
<% if (folder) { %>
<p><a href="<%= parent ? '/browse/' + parent.slug : '/browse' %>">&larr; <%= parent ? parent.name : 'Browse' %></a></p>
<h1><%= folder.name %></h1>
<% } else { %>
<h1>Browse</h1>
<% } %>
<% if (subfolders.length) { %>
<h2>Folders</h2>
<div class="folder-list">
<% subfolders.forEach(f => { %>
<a href="/browse/<%= f.slug %>"><%= f.name %></a>
<% }) %>
</div>
<% } %>
<% var basePath = folder ? '/browse/' + folder.slug : '/browse'; %>
<% if (tags.length) { %>
<h2>Filter by tag</h2>
<div class="tag-row" style="margin-bottom:20px">
<a class="tag <%= !activeTag ? 'active' : '' %>" href="<%= basePath %>">All</a>
<% tags.forEach(t => { %>
<a class="tag <%= t.kind === 'explicit' ? 'explicit' : '' %> <%= activeTag === t.slug ? 'active' : '' %>"
href="<%= basePath %>?tag=<%= t.slug %>"><%= t.label %></a>
<% }) %>
</div>
<% } %>
<% if (stories.length) { %>
<h2>Stories</h2>
<div class="card-grid">
<% stories.forEach(story => { %>
<%- include('../partials/story-card', { story }) %>
<% }) %>
</div>
<% } else if (activeTag) { %>
<p class="muted">No stories here tagged "<%= activeTag %>".</p>
<% } %>
<% if (!subfolders.length && !stories.length && !activeTag) { %>
<p class="muted">Nothing here yet.</p>
<% } %>
</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><%= chapter.title %> — <%= story.title %> — stomping.me</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/siteNav', { user }) %>
<div class="wrap">
<p><a href="/story/<%= story.slug %>">&larr; <%= story.title %></a></p>
<h1><%= chapter.title %></h1>
<% if (chapter.note_top) { %><div class="chapter-note"><%= chapter.note_top %></div><% } %>
<div class="chapter-body"><%- chapter.body_html %></div>
<% if (chapter.note_bottom) { %><div class="chapter-note"><%= chapter.note_bottom %></div><% } %>
<% if (showNav) { %>
<div class="chapter-nav">
<div>
<% if (prev) { %><a href="/story/<%= story.slug %>/<%= prev.slug %>">&larr; <%= prev.title %></a><% } %>
</div>
<div>
<% if (next) { %><a href="/story/<%= story.slug %>/<%= next.slug %>"><%= next.title %> &rarr;</a><% } %>
</div>
</div>
<% } %>
</div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>stomping.me</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/siteNav', { user }) %>
<div class="wrap">
<h1>Recent stories</h1>
<% if (!stories.length) { %>
<p class="muted">Nothing published yet — check back soon.</p>
<% } else { %>
<div class="card-grid">
<% stories.forEach(story => { %>
<%- include('../partials/story-card', { story }) %>
<% }) %>
</div>
<% } %>
</div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Not found — stomping.me</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/siteNav', { user }) %>
<div class="wrap">
<div class="not-found">
<h1>Not found</h1>
<p>There's nothing here.</p>
</div>
</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><%= story.title %> — stomping.me</title><%- include('../partials/siteStyles') %></head>
<body>
<%- include('../partials/siteNav', { user }) %>
<div class="wrap">
<h1><%= story.title %></h1>
<% if (story.subtitle) { %><p class="muted" style="font-size:16px;margin-top:-14px"><%= story.subtitle %></p><% } %>
<div class="tag-row">
<span class="tag"><%= story.story_type %></span>
<span class="tag"><%= story.content_rating %></span>
<% if (story.serial_status) { %><span class="tag"><%= story.serial_status %></span><% } %>
<% (story.badges || []).forEach(b => { %><span class="tag"><%= b %></span><% }) %>
</div>
<% if (explicitTags.length) { %>
<div class="tag-row">
<% explicitTags.forEach(t => { %><span class="tag explicit"><%= t.label %></span><% }) %>
</div>
<% } %>
<% if (generalTags.length) { %>
<div class="tag-row">
<% generalTags.forEach(t => { %><span class="tag"><%= t.label %></span><% }) %>
</div>
<% } %>
<% if (story.cover_url) { %><img src="<%= story.cover_url %>" alt="" style="max-width:320px;border-radius:8px;margin:16px 0"><% } %>
<% if (story.summary) { %><p><%= story.summary %></p><% } %>
<p class="muted"><%= story.word_count %> words</p>
<% if (singleChapter) { %>
<% if (singleChapter.note_top) { %><div class="chapter-note"><%= singleChapter.note_top %></div><% } %>
<div class="chapter-body"><%- singleChapter.body_html %></div>
<% if (singleChapter.note_bottom) { %><div class="chapter-note"><%= singleChapter.note_bottom %></div><% } %>
<% } else if (chapters.length) { %>
<h2>Chapters</h2>
<div class="chapter-list">
<% chapters.forEach(c => { %>
<a href="/story/<%= story.slug %>/<%= c.slug %>"><span><%= c.title %></span> <span class="muted"><%= c.word_count %> words</span></a>
<% }) %>
</div>
<% } else { %>
<p class="muted">No chapters published yet.</p>
<% } %>
</div>
</body>
</html>