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:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
Generated
+2741
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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.1–2.4). comments/ratings (§2.5–2.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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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];
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 %>">← 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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' %>">← <%= 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>
|
||||||
@@ -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 %>">← <%= 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 %>">← <%= prev.title %></a><% } %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<% if (next) { %><a href="/story/<%= story.slug %>/<%= next.slug %>"><%= next.title %> →</a><% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user