<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to the Unthinking Depths</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400&family=Source+Sans+3:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
display: ['"Cormorant Garamond"', 'serif'],
body: ['"Source Sans 3"', 'sans-serif'],
}
}
}
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: 'Source Sans 3', sans-serif; background: #0c0a09; color: #e7e5e4; min-height: 100vh; overflow-x: hidden; }
::selection { background: #d97706; color: #0c0a09; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1c1917; }
::-webkit-scrollbar-thumb { background: #44403c; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #57534e; }
/* Navigation */
#main-nav { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; background: rgba(12,10,9,0.85); backdrop-filter: blur(16px); border-bottom: 1px solid #292524; transition: background 0.3s; }
.nav-inner { max-width: 1200px; margin: 0 auto; padding: 0 1.5rem; display: flex; align-items: center; justify-content: space-between; height: 64px; }
.nav-logo { font-family: 'Cormorant Garamond', serif; font-size: 1.25rem; font-weight: 600; color: #f59e0b; text-decoration: none; letter-spacing: 0.02em; }
.nav-links { display: flex; list-style: none; gap: 0; align-items: center; }
.nav-item { position: relative; }
.nav-item > a { display: flex; align-items: center; gap: 5px; padding: 0.5rem 1rem; color: #a8a29e; text-decoration: none; font-size: 0.9rem; font-weight: 500; transition: color 0.2s; letter-spacing: 0.03em; text-transform: uppercase; }
.nav-item > a:hover, .nav-item > a.active { color: #f59e0b; }
.nav-item > a .fa-chevron-down { font-size: 0.6rem; transition: transform 0.2s; }
.nav-item:hover > a .fa-chevron-down { transform: rotate(180deg); }
.dropdown { position: absolute; top: 100%; left: 0; min-width: 180px; background: #1c1917; border: 1px solid #292524; border-radius: 6px; padding: 0.5rem 0; list-style: none; opacity: 0; visibility: hidden; transform: translateY(8px); transition: all 0.25s ease; box-shadow: 0 12px 40px rgba(0,0,0,0.5); }
.nav-item:hover > .dropdown { opacity: 1; visibility: visible; transform: translateY(0); }
.dropdown a { display: block; padding: 0.6rem 1.2rem; color: #a8a29e; text-decoration: none; font-size: 0.85rem; transition: all 0.15s; text-transform: none; letter-spacing: 0; }
.dropdown a:hover { color: #f59e0b; background: rgba(217,119,6,0.08); padding-left: 1.5rem; }
.mobile-toggle { display: none; background: none; border: none; color: #a8a29e; font-size: 1.3rem; cursor: pointer; padding: 0.5rem; }
/* Toast */
#toast-container { position: fixed; top: 80px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 20px; border-radius: 8px; font-size: 0.9rem; font-weight: 500; color: #0c0a09; transform: translateX(120%); transition: transform 0.3s ease; box-shadow: 0 4px 20px rgba(0,0,0,0.4); }
.toast.show { transform: translateX(0); }
.toast-success { background: #34d399; }
.toast-error { background: #f87171; }
.toast-info { background: #fbbf24; }
/* Page transitions */
#app { min-height: calc(100vh - 64px - 80px); padding-top: 64px; }
.page-enter { animation: pageIn 0.35s ease forwards; }
@keyframes pageIn { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
/* Hero canvas */
#depths-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
/* Post cards */
.post-card { background: #1c1917; border: 1px solid #292524; border-radius: 10px; padding: 1.75rem; transition: all 0.3s ease; cursor: pointer; }
.post-card:hover { border-color: #d97706; transform: translateY(-4px); box-shadow: 0 12px 40px rgba(217,119,6,0.08); }
/* Sort bar */
.sort-select { background: #1c1917; border: 1px solid #292524; color: #e7e5e4; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; font-family: 'Source Sans 3', sans-serif; cursor: pointer; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23a8a29e' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 2rem; }
.sort-select:focus { outline: none; border-color: #d97706; }
/* Badge */
.badge { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 99px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
/* Tags */
.tag { display: inline-block; padding: 0.15rem 0.55rem; background: rgba(217,119,6,0.1); border: 1px solid rgba(217,119,6,0.2); border-radius: 4px; font-size: 0.75rem; color: #fbbf24; margin: 0.15rem; }
/* Admin layout */
.admin-layout { display: flex; min-height: calc(100vh - 64px); }
.admin-sidebar { width: 250px; background: #1c1917; border-right: 1px solid #292524; padding: 1.5rem 0; flex-shrink: 0; }
.admin-sidebar-link { display: flex; align-items: center; gap: 10px; padding: 0.65rem 1.5rem; color: #a8a29e; text-decoration: none; font-size: 0.9rem; transition: all 0.15s; border-left: 3px solid transparent; }
.admin-sidebar-link:hover, .admin-sidebar-link.active { color: #f59e0b; background: rgba(217,119,6,0.06); border-left-color: #d97706; }
.admin-sidebar-link i { width: 18px; text-align: center; }
.admin-content { flex: 1; padding: 2rem; overflow-y: auto; max-height: calc(100vh - 64px); }
/* Admin table */
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th { text-align: left; padding: 0.75rem 1rem; color: #78716c; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #292524; font-weight: 600; }
.admin-table td { padding: 0.75rem 1rem; border-bottom: 1px solid #1c1917; font-size: 0.9rem; }
.admin-table tr:hover td { background: rgba(217,119,6,0.03); }
/* Form elements */
.form-input, .form-textarea, .form-select { width: 100%; background: #0c0a09; border: 1px solid #292524; color: #e7e5e4; padding: 0.65rem 0.9rem; border-radius: 6px; font-size: 0.9rem; font-family: 'Source Sans 3', sans-serif; transition: border-color 0.2s; }
.form-input:focus, .form-textarea:focus, .form-select:focus { outline: none; border-color: #d97706; }
.form-textarea { resize: vertical; min-height: 300px; line-height: 1.7; }
.form-label { display: block; font-size: 0.8rem; font-weight: 600; color: #a8a29e; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.06em; }
/* Toolbar */
.toolbar-btn { background: #1c1917; border: 1px solid #292524; color: #a8a29e; width: 34px; height: 34px; border-radius: 4px; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; display: inline-flex; align-items: center; justify-content: center; }
.toolbar-btn:hover { color: #f59e0b; border-color: #d97706; }
/* Buttons */
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 0.6rem 1.3rem; border-radius: 6px; font-size: 0.9rem; font-weight: 600; font-family: 'Source Sans 3', sans-serif; cursor: pointer; border: none; transition: all 0.2s; text-decoration: none; }
.btn-primary { background: #d97706; color: #0c0a09; }
.btn-primary:hover { background: #f59e0b; transform: translateY(-1px); }
.btn-danger { background: rgba(239,68,68,0.15); color: #f87171; border: 1px solid rgba(239,68,68,0.2); }
.btn-danger:hover { background: rgba(239,68,68,0.25); }
.btn-ghost { background: transparent; color: #a8a29e; border: 1px solid #292524; }
.btn-ghost:hover { color: #f59e0b; border-color: #d97706; }
.btn-sm { padding: 0.35rem 0.8rem; font-size: 0.8rem; }
/* Login modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(8px); z-index: 2000; display: flex; align-items: center; justify-content: center; }
.modal-box { background: #1c1917; border: 1px solid #292524; border-radius: 12px; padding: 2.5rem; width: 100%; max-width: 400px; box-shadow: 0 24px 80px rgba(0,0,0,0.5); }
/* Markdown content */
.md-content h1 { font-family: 'Cormorant Garamond', serif; font-size: 2rem; font-weight: 700; margin: 1.5rem 0 0.75rem; color: #f5f5f4; }
.md-content h2 { font-family: 'Cormorant Garamond', serif; font-size: 1.5rem; font-weight: 600; margin: 1.25rem 0 0.6rem; color: #f5f5f4; }
.md-content h3 { font-family: 'Cormorant Garamond', serif; font-size: 1.2rem; font-weight: 600; margin: 1rem 0 0.5rem; color: #e7e5e4; }
.md-content p { margin-bottom: 1rem; line-height: 1.8; color: #d6d3d1; }
.md-content strong { color: #f5f5f4; font-weight: 600; }
.md-content em { color: #fbbf24; font-style: italic; }
.md-content blockquote { border-left: 3px solid #d97706; padding: 0.5rem 1.25rem; margin: 1rem 0; background: rgba(217,119,6,0.05); border-radius: 0 6px 6px 0; }
.md-content blockquote p { margin-bottom: 0; color: #a8a29e; font-style: italic; }
.md-content ul, .md-content ol { margin: 0.75rem 0 1rem 1.5rem; }
.md-content li { margin-bottom: 0.3rem; line-height: 1.7; color: #d6d3d1; }
.md-content a { color: #f59e0b; text-decoration: underline; text-underline-offset: 2px; }
.md-content hr { border: none; border-top: 1px solid #292524; margin: 2rem 0; }
/* Footer */
#main-footer { border-top: 1px solid #292524; padding: 1.5rem; text-align: center; }
.admin-link { color: #44403c; font-size: 1.1rem; transition: color 0.2s; text-decoration: none; margin-left: 1rem; }
.admin-link:hover { color: #d97706; }
/* Responsive */
@media (max-width: 768px) {
.mobile-toggle { display: block; }
.nav-links { position: fixed; top: 64px; left: 0; right: 0; background: #1c1917; border-bottom: 1px solid #292524; flex-direction: column; padding: 1rem 0; transform: translateY(-120%); transition: transform 0.3s ease; box-shadow: 0 12px 40px rgba(0,0,0,0.5); }
.nav-links.open { transform: translateY(0); }
.nav-item > a { padding: 0.75rem 1.5rem; }
.dropdown { position: static; opacity: 1; visibility: visible; transform: none; background: #0c0a09; border: none; border-radius: 0; box-shadow: none; padding-left: 1rem; display: none; }
.nav-item.dropdown-open > .dropdown { display: block; }
.admin-sidebar { width: 100%; border-right: none; border-bottom: 1px solid #292524; padding: 0.5rem 0; }
.admin-layout { flex-direction: column; }
.admin-content { max-height: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* Stat cards */
.stat-card { background: #1c1917; border: 1px solid #292524; border-radius: 10px; padding: 1.25rem; }
.stat-card .stat-num { font-family: 'Cormorant Garamond', serif; font-size: 2.5rem; font-weight: 700; color: #f59e0b; }
.stat-card .stat-label { font-size: 0.8rem; color: #78716c; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.25rem; }
</style>
</head>
<body>
<!-- Toast notifications -->
<div id="toast-container"></div>
<!-- Navigation -->
<nav id="main-nav">
<div class="nav-inner">
<a href="#/" class="nav-logo">The Unthinking Depths</a>
<button id="mobile-toggle" class="mobile-toggle" aria-label="Toggle menu"><i class="fas fa-bars"></i></button>
<ul class="nav-links" id="nav-links"></ul>
</div>
</nav>
<!-- Main app container -->
<main id="app"></main>
<!-- Footer -->
<footer id="main-footer">
<span class="text-stone-500 text-sm">© 2024 The Unthinking Depths. All rights reserved.</span>
<a href="#/admin" class="admin-link" title="Admin Panel"><i class="fas fa-cog"></i></a>
</footer>
<script>
/* ================================================================
DATA LAYER — localStorage-backed CRUD
================================================================ */
const DB = {
_get(key, fallback) {
try { const d = localStorage.getItem('utd_' + key); return d ? JSON.parse(d) : fallback; }
catch { return fallback; }
},
_set(key, val) { localStorage.setItem('utd_' + key, JSON.stringify(val)); },
getPosts() { return this._get('posts', []); },
savePosts(p) { this._set('posts', p); },
getCats() { return this._get('cats', null) || DEFAULT_CATS; },
saveCats(c) { this._set('cats', c); },
getSubs() { return this._get('subs', null) || DEFAULT_SUBS; },
saveSubs(s) { this._set('subs', s); },
getMessages() { return this._get('messages', []); },
saveMessages(m) { this._set('messages', m); },
getPin() { return this._get('pin', 'admin'); },
setPin(p) { this._set('pin', p); },
createPost(data) {
const posts = this.getPosts();
const post = { id: 'p' + Date.now() + Math.random().toString(36).slice(2, 6), createdAt: Date.now(), updatedAt: Date.now(), ...data };
posts.push(post);
this.savePosts(posts);
return post;
},
updatePost(id, data) {
const posts = this.getPosts();
const idx = posts.findIndex(p => p.id === id);
if (idx < 0) return null;
posts[idx] = { ...posts[idx], ...data, updatedAt: Date.now() };
this.savePosts(posts);
return posts[idx];
},
deletePost(id) {
this.savePosts(this.getPosts().filter(p => p.id !== id));
},
getPost(id) { return this.getPosts().find(p => p.id === id) || null; },
getPostsByCat(catId) { return this.getPosts().filter(p => p.category === catId && p.status === 'published'); },
getPostsBySub(subId) { return this.getPosts().filter(p => p.subcategory === subId && p.status === 'published'); },
addSubcategory(catId, name) {
const subs = this.getSubs();
const sub = { id: 's' + Date.now(), name, categoryId: catId, slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '-') };
subs.push(sub);
this.saveSubs(subs);
return sub;
},
deleteSubcategory(id) {
this.saveSubs(this.getSubs().filter(s => s.id !== id));
},
getSubsForCat(catId) { return this.getSubs().filter(s => s.categoryId === catId); }
};
/* Default categories */
const DEFAULT_CATS = [
{ id: 'book-reviews', name: 'Book Reviews', navLabel: 'Book Reviews', slug: 'book-reviews', hasDropdown: true },
{ id: 'casual-essays', name: 'Casual Essays', navLabel: 'Essays', slug: 'essays' },
{ id: 'published-work', name: 'Published Work', navLabel: 'Published Work', slug: 'published-work' },
{ id: 'events', name: 'Events', navLabel: 'Events', slug: 'events' },
];
const DEFAULT_SUBS = [
{ id: 'fiction', name: 'Fiction', categoryId: 'book-reviews', slug: 'fiction' },
{ id: 'nonfiction', name: 'Nonfiction', categoryId: 'book-reviews', slug: 'nonfiction' },
];
/* Seed data on first visit */
function seedData() {
if (localStorage.getItem('utd_seeded')) return;
const seeds = [
{ title: 'The Remains of the Day', category: 'book-reviews', subcategory: 'fiction', authorLastName: 'Ishiguro', bookPubDate: '1989-05-01', status: 'published',
content: '## A Study in Restraint\n\nKazuo Ishiguro\'s *The Remains of the Day* is a masterpiece of understated emotion. Through the fastidious butler Stevens, Ishiguro explores the cost of professional devotion at the expense of personal fulfillment.\n\n> The great butlers are great not because of what they do, but because of who they are.\n\nThe novel\'s power lies in what remains unsaid. Stevens\'s narratitive voice—polite, evasive, meticulously controlled—becomes the vehicle for devastating self-revelation. We understand what he cannot admit: that a life spent in service to an unworthy employer is a life diminished.\n\nIshiguro\'s prose mirrors Stevens\'s character: precise, mannered, and gradually revealing deep currents of feeling beneath its placid surface. The famous scene where Stevens finally admits to having been "blinkered" is among the most quietly heartbreaking in modern fiction.',
tags: ['Ishiguro', '1989', 'Literary Fiction', 'British Literature'], createdAt: Date.now() - 86400000 * 2 },
{ title: 'Sapiens: A Brief History of Humankind', category: 'book-reviews', subcategory: 'nonfiction', authorLastName: 'Harari', bookPubDate: '2011-01-01', status: 'published',
content: '## Ambition Outpaces Rigor\n\nYuval Noah Harari\'s *Sapiens* is that rare book that makes you rethink everything you thought you knew about human history—and then makes you question whether the rethinking itself was warranted.\n\nHarari\'s central thesis, that the Cognitive Revolution enabled humans to cooperate flexibly in large numbers through shared fictions, is genuinely illuminating. Money, religion, nations—all are, in his framing, collective myths that bind us.\n\nHowever, the book\'s scope is both its strength and its weakness. When Harari writes about evolutionary biology or the Agricultural Revolution, he\'s on solid ground. When he ventures into philosophy or makes predictions about the future, the arguments thin considerably.\n\n**Worth reading?** Absolutely. But read it as a provocation, not a textbook.',
tags: ['Harari', '2011', 'Anthropology', 'History'], createdAt: Date.now() - 86400000 * 5 },
{ title: 'Beloved', category: 'book-reviews', subcategory: 'fiction', authorLastName: 'Morrison', bookPubDate: '1987-09-16', status: 'published',
content: '## The Weight of Memory\n\nToni Morrison\'s *Beloved* does not ask for your attention—it demands it. From the haunting opening line ("124 was spiteful") to its shattering conclusion, this novel operates at a level of emotional and linguistic intensity that few works can match.\n\nMorrison transforms the historical record of Margaret Garner into something far larger: an exploration of how slavery\'s violence persists across generations, inhabiting bodies, houses, and language itself.\n\nThe character of Beloved—simultaneously a ghost, a traumatized child, and the embodied weight of history—is one of the great creations of American literature.',
tags: ['Morrison', '1987', 'Literary Fiction', 'American Literature', 'Pulitzer Prize'], createdAt: Date.now() - 86400000 * 1 },
{ title: 'Thinking, Fast and Slow', category: 'book-reviews', subcategory: 'nonfiction', authorLastName: 'Kahneman', bookPubDate: '2011-10-25', status: 'published',
content: '## Two Systems, One Mind\n\nDaniel Kahneman\'s magnum opus distills decades of research into the dual-process model of human cognition. System 1—fast, intuitive, emotional—operates automatically. System 2—slow, deliberate, logical—requires effort and is prone to disengagement.\n\nThe book\'s achievement is not just explanatory but therapeutic. Reading it, you begin to notice your own cognitive biases in real time: the anchoring effect in negotiations, the availability heuristic in risk assessment, the sunk-cost fallacy in decision-making.\n\nKahneman is careful to note that knowing about biases doesn\'t eliminate them. But awareness is the first step toward mitigation, and this book provides that awareness in abundance.',
tags: ['Kahneman', '2011', 'Psychology', 'Behavioral Economics'], createdAt: Date.now() - 86400000 * 8 },
{ title: 'The Road', category: 'book-reviews', subcategory: 'fiction', authorLastName: 'McCarthy', bookPubDate: '2006-09-26', status: 'published',
content: '## Ash and Light\n\nCormac McCarthy\'s *The Road* strips civilization down to its irreducible core: a parent and a child walking through ash toward an uncertain coast. The prose mirrors the landscape—spare, stripped of ornament, punctuated by sudden beauty.\n\nWhat makes this novel unbearable and essential is the tension between the father\'s pragmatic ruthlessness and the boy\'s insistence on maintaining moral light in a world gone dark. "We\'re carrying the fire," the boy says, and the phrase becomes a mantra, a prayer, a definition of what it means to be human when humanity has been abolished.\n\nThis is not post-apocalyptic entertainment. It is a love letter so fierce it burns.',
tags: ['McCarthy', '2006', 'Literary Fiction', 'Post-Apocalyptic'], createdAt: Date.now() - 86400000 * 12 },
{ title: 'On the Pleasure of Hating', category: 'casual-essays', status: 'published',
content: '## A Defense of an Unpopular Emotion\n\nWe are taught from childhood that hatred is corrosive, that it diminishes the hater more than the hated. This is probably true. But it is also incomplete.\n\nThere is a particular pleasure in well-directed disdain—a clarity of judgment, a sharpening of taste, a sense of having identified something genuinely unworthy. The critic who never hates anything is not generous; they are simply incurious.\n\nThe trick, of course, is distinguishing productive disdain from the reflexive kind. The former emerges from engagement and knowledge. The latter from laziness and fear.',
tags: ['Essays', 'Criticism'], createdAt: Date.now() - 86400000 * 3 },
{ title: 'Upcoming Reading at Powell\'s Books', category: 'events', eventDate: '2025-02-15', eventLocation: 'Powell\'s City of Books, Portland, OR', status: 'published',
content: '## Details\n\nI\'ll be reading from my forthcoming essay collection at Powell\'s City of Books on February 15th, 2025 at 7:00 PM.\n\nThe reading will be followed by a Q&A and book signing. Copies will be available for purchase at the event.\n\n**Admission is free and open to the public.**',
tags: ['Reading', 'Portland', 'Powell\'s'], createdAt: Date.now() - 86400000 * 1 },
];
seeds.forEach(s => DB.createPost(s));
localStorage.setItem('utd_seeded', '1');
}
seedData();
/* ================================================================
UTILITIES
================================================================ */
function showToast(msg, type = 'success') {
const c = document.getElementById('toast-container');
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.textContent = msg;
c.appendChild(t);
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 3000);
}
function renderMd(text) {
if (!text) return '';
let h = text
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^> (.+)$/gm, '<blockquote><p>$1</p></blockquote>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/^---$/gm, '<hr>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/\n/g, '<br>');
return '<p>' + h + '</p>';
}
function formatDate(ts) {
return new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
function formatDateShort(ts) {
return new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
}
function getCatName(id) { return (DB.getCats().find(c => c.id === id) || {}).name || id; }
function getSubName(id) { return (DB.getSubs().find(s => s.id === id) || {}).name || ''; }
function getCatSlug(id) { return (DB.getCats().find(c => c.id === id) || {}).slug || id; }
function slugify(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); }
function escapeHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
/* ================================================================
ROUTER
================================================================ */
let currentSort = 'newest';
function route() {
const hash = location.hash || '#/';
const app = document.getElementById('app');
renderNav();
// Admin routes
if (hash.startsWith('#/admin')) {
if (!sessionStorage.getItem('utd_auth')) { renderLogin(); return; }
if (hash === '#/admin') return renderAdminDashboard();
if (hash === '#/admin/posts') return renderAdminPosts();
if (hash === '#/admin/post/new') return renderAdminPostForm();
if (hash.startsWith('#/admin/post/edit/')) return renderAdminPostForm(hash.split('/').pop());
if (hash === '#/admin/categories') return renderAdminCategories();
if (hash === '#/admin/messages') return renderAdminMessages();
if (hash === '#/admin/settings') return renderAdminSettings();
renderAdminDashboard();
return;
}
// Frontend routes
if (hash === '#/' || hash === '#') return renderHome();
if (hash === '#/book-reviews/fiction') return renderBookReviews('fiction');
if (hash === '#/book-reviews/nonfiction') return renderBookReviews('nonfiction');
if (hash === '#/book-reviews') return renderBookReviews(null);
if (hash === '#/essays') return renderCategoryPage('casual-essays');
if (hash === '#/published-work') return renderCategoryPage('published-work');
if (hash === '#/events') return renderCategoryPage('events');
if (hash === '#/contact') return renderContact();
if (hash.startsWith('#/post/')) return renderPost(hash.split('/').pop());
renderHome();
}
/* ================================================================
NAVIGATION
================================================================ */
function renderNav() {
const cats = DB.getCats();
const subs = DB.getSubs();
const hash = location.hash || '#/';
let html = '';
cats.forEach(cat => {
const catSubs = subs.filter(s => s.categoryId === cat.id);
const isActive = hash.includes(cat.slug) || (cat.hasDropdown && hash.startsWith('#/book-reviews'));
const activeClass = isActive ? ' active' : '';
if (catSubs.length > 0) {
html += `<li class="nav-item has-dropdown${isActive ? ' dropdown-open' : ''}">
<a href="#/${cat.slug}" class="${activeClass}">${cat.navLabel} <i class="fas fa-chevron-down"></i></a>
<ul class="dropdown">${catSubs.map(s => `<li><a href="#/${cat.slug}/${s.slug}">${s.name}</a></li>`).join('')}</ul>
</li>`;
} else {
html += `<li class="nav-item"><a href="#/${cat.slug}" class="${activeClass}">${cat.navLabel}</a></li>`;
}
});
html += `<li class="nav-item"><a href="#/contact"${hash === '#/contact' ? ' class="active"' : ''}>Contact</a></li>`;
document.getElementById('nav-links').innerHTML = html;
// Mobile toggle
const toggle = document.getElementById('mobile-toggle');
const links = document.getElementById('nav-links');
toggle.onclick = () => links.classList.toggle('open');
// Close mobile menu on link click
links.querySelectorAll('a').forEach(a => a.addEventListener('click', () => links.classList.remove('open')));
}
/* ================================================================
PAGE: HOME
================================================================ */
function renderHome() {
const posts = DB.getPosts().filter(p => p.status === 'published').sort((a, b) => b.createdAt - a.createdAt).slice(0, 6);
document.getElementById('app').innerHTML = `
<div class="page-enter">
<section class="relative flex items-center justify-center overflow-hidden" style="min-height:85vh;">
<canvas id="depths-canvas"></canvas>
<div class="relative z-10 text-center px-6 max-w-3xl">
<h1 class="font-display text-5xl md:text-7xl font-300 text-stone-100 leading-tight mb-6" style="font-weight:300;">Welcome to the<br>Unthinking Depths</h1>
<p class="text-stone-400 text-lg md:text-xl font-light max-w-xl mx-auto leading-relaxed">A space for long reads, close readings, and the occasional provocation.</p>
<div class="mt-10 flex justify-center gap-4">
<a href="#/book-reviews" class="btn btn-primary">Browse Book Reviews</a>
<a href="#/essays" class="btn btn-ghost">Read Essays</a>
</div>
</div>
</section>
<section class="max-w-5xl mx-auto px-6 pb-20">
<h2 class="font-display text-3xl font-600 text-stone-200 mb-8 border-b border-stone-800 pb-4">Recent Writing</h2>
<div class="grid md:grid-cols-2 gap-5">
${posts.map(p => postCard(p)).join('')}
</div>
</section>
</div>`;
initDepthsCanvas();
}
function postCard(p) {
const catName = getCatName(p.category);
const subName = p.subcategory ? getSubName(p.subcategory) : '';
const excerpt = p.content ? p.content.replace(/[#>*\-]/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').substring(0, 160).trim() + '...' : '';
let meta = `<span class="text-stone-500 text-sm">${formatDateShort(p.createdAt)}</span>`;
if (p.category === 'book-reviews' && p.authorLastName) meta = `<span class="text-amber-500 text-sm font-semibold">${escapeHtml(p.authorLastName)}</span><span class="text-stone-600 mx-1">·</span>` + meta;
return `<article class="post-card" onclick="location.hash='#/post/${p.id}'">
<div class="flex items-center gap-2 mb-3 flex-wrap">
<span class="badge bg-amber-600/15 text-amber-400 border border-amber-600/20">${escapeHtml(catName)}</span>
${subName ? `<span class="badge bg-stone-700/50 text-stone-400">${escapeHtml(subName)}</span>` : ''}
</div>
<h3 class="font-display text-xl font-600 text-stone-100 mb-2 leading-snug">${escapeHtml(p.title)}</h3>
<p class="text-stone-400 text-sm leading-relaxed mb-3">${escapeHtml(excerpt)}</p>
<div class="flex items-center gap-2">${meta}</div>
${p.tags && p.tags.length ? `<div class="mt-3">${p.tags.slice(0, 3).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
</article>`;
}
/* ================================================================
PAGE: BOOK REVIEWS (with sorting)
================================================================ */
function renderBookReviews(subId) {
let posts = subId ? DB.getPostsBySub(subId) : DB.getPostsByCat('book-reviews');
const subName = subId ? getSubName(subId) : '';
const title = subName ? `${subName} Book Reviews` : 'Book Reviews';
// Sort
const sortFns = {
'newest': (a, b) => b.createdAt - a.createdAt,
'author-az': (a, b) => (a.authorLastName || '').localeCompare(b.authorLastName || ''),
'author-za': (a, b) => (b.authorLastName || '').localeCompare(a.authorLastName || ''),
'book-newest': (a, b) => new Date(b.bookPubDate || 0) - new Date(a.bookPubDate || 0),
'book-oldest': (a, b) => new Date(a.bookPubDate || 0) - new Date(b.bookPubDate || 0),
};
posts = [...posts].sort(sortFns[currentSort] || sortFns['newest']);
const subs = DB.getSubsForCat('book-reviews');
document.getElementById('app').innerHTML = `
<div class="page-enter max-w-5xl mx-auto px-6 py-12">
<h1 class="font-display text-4xl md:text-5xl font-300 text-stone-100 mb-2" style="font-weight:300;">${escapeHtml(title)}</h1>
<p class="text-stone-500 mb-8">${posts.length} review${posts.length !== 1 ? 's' : ''} found</p>
<div class="flex flex-wrap items-center gap-3 mb-8">
<span class="text-stone-500 text-sm font-semibold uppercase tracking-wider">Subcategory:</span>
<a href="#/book-reviews" class="btn btn-sm ${!subId ? 'btn-primary' : 'btn-ghost'}">All</a>
${subs.map(s => `<a href="#/book-reviews/${s.slug}" class="btn btn-sm ${subId === s.id ? 'btn-primary' : 'btn-ghost'}">${escapeHtml(s.name)}</a>`).join('')}
</div>
<div class="flex items-center gap-3 mb-8">
<label class="text-stone-500 text-sm font-semibold uppercase tracking-wider" for="sort-select">Sort by:</label>
<select id="sort-select" class="sort-select" onchange="handleSort(this.value)">
<option value="newest" ${currentSort==='newest'?'selected':''}>Newest Posts First</option>
<option value="author-az" ${currentSort==='author-az'?'selected':''}>Author Last Name (A–Z)</option>
<option value="author-za" ${currentSort==='author-za'?'selected':''}>Author Last Name (Z–A)</option>
<option value="book-newest" ${currentSort==='book-newest'?'selected':''}>Book Publication Date (Newest First)</option>
<option value="book-oldest" ${currentSort==='book-oldest'?'selected':''}>Book Publication Date (Oldest First)</option>
</select>
</div>
${posts.length === 0 ? '<p class="text-stone-500 text-center py-20 text-lg">No reviews yet in this category.</p>' :
`<div class="space-y-4">${posts.map(p => bookReviewRow(p)).join('')}</div>`}
</div>`;
}
function bookReviewRow(p) {
const subName = p.subcategory ? getSubName(p.subcategory) : '';
return `<article class="post-card" onclick="location.hash='#/post/${p.id}'">
<div class="flex flex-wrap items-center gap-3 mb-2">
<h3 class="font-display text-xl font-600 text-stone-100">${escapeHtml(p.title)}</h3>
${subName ? `<span class="badge bg-stone-700/50 text-stone-400">${escapeHtml(subName)}</span>` : ''}
</div>
<div class="flex flex-wrap items-center gap-3 text-sm mb-2">
${p.authorLastName ? `<span class="text-amber-500 font-semibold">by ${escapeHtml(p.authorLastName)}</span>` : ''}
${p.bookPubDate ? `<span class="text-stone-500"><i class="far fa-calendar-alt mr-1"></i>Original publication: ${formatDateShort(new Date(p.bookPubDate).getTime())}</span>` : ''}
<span class="text-stone-600">·</span>
<span class="text-stone-500">Reviewed ${formatDate(p.createdAt)}</span>
</div>
${p.tags && p.tags.length ? `<div>${p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
</article>`;
}
function handleSort(val) { currentSort = val; route(); }
/* ================================================================
PAGE: GENERIC CATEGORY
================================================================ */
function renderCategoryPage(catId) {
const cat = DB.getCats().find(c => c.id === catId);
if (!cat) return renderHome();
const posts = DB.getPostsByCat(catId).sort((a, b) => b.createdAt - a.createdAt);
document.getElementById('app').innerHTML = `
<div class="page-enter max-w-5xl mx-auto px-6 py-12">
<h1 class="font-display text-4xl md:text-5xl font-300 text-stone-100 mb-2" style="font-weight:300;">${escapeHtml(cat.name)}</h1>
<p class="text-stone-500 mb-8">${posts.length} post${posts.length !== 1 ? 's' : ''}</p>
${posts.length === 0 ? '<p class="text-stone-500 text-center py-20 text-lg">No posts yet in this category.</p>' :
`<div class="grid md:grid-cols-2 gap-5">${posts.map(p => postCard(p)).join('')}</div>`}
</div>`;
}
/* ================================================================
PAGE: SINGLE POST
================================================================ */
function renderPost(id) {
const p = DB.getPost(id);
if (!p || p.status !== 'published') { document.getElementById('app').innerHTML = '<div class="page-enter text-center py-32"><h2 class="font-display text-3xl text-stone-400">Post not found</h2><a href="#/" class="btn btn-ghost mt-6 inline-block">Back Home</a></div>'; return; }
const catName = getCatName(p.category);
const subName = p.subcategory ? getSubName(p.subcategory) : '';
document.getElementById('app').innerHTML = `
<div class="page-enter max-w-3xl mx-auto px-6 py-12">
<a href="#/${getCatSlug(p.category)}" class="text-stone-500 text-sm hover:text-amber-500 transition-colors mb-6 inline-block"><i class="fas fa-arrow-left mr-2"></i>Back to ${escapeHtml(catName)}</a>
<div class="flex items-center gap-2 mb-4 flex-wrap">
<span class="badge bg-amber-600/15 text-amber-400 border border-amber-600/20">${escapeHtml(catName)}</span>
${subName ? `<span class="badge bg-stone-700/50 text-stone-400">${escapeHtml(subName)}</span>` : ''}
</div>
<h1 class="font-display text-4xl md:text-5xl font-600 text-stone-100 mb-4 leading-tight">${escapeHtml(p.title)}</h1>
<div class="flex flex-wrap items-center gap-3 text-sm text-stone-500 mb-8 pb-6 border-b border-stone-800">
<span>${formatDate(p.createdAt)}</span>
${p.authorLastName ? `<span class="text-stone-600">·</span><span class="text-amber-500 font-semibold">Author: ${escapeHtml(p.authorLastName)}</span>` : ''}
${p.bookPubDate ? `<span class="text-stone-600">·</span><span>Book published: ${formatDate(new Date(p.bookPubDate).getTime())}</span>` : ''}
${p.eventDate ? `<span class="text-stone-600">·</span><span><i class="far fa-calendar-alt mr-1"></i>${p.eventDate}</span>` : ''}
${p.eventLocation ? `<span class="text-stone-600">·</span><span><i class="fas fa-map-marker-alt mr-1"></i>${escapeHtml(p.eventLocation)}</span>` : ''}
</div>
<div class="md-content">${renderMd(p.content)}</div>
${p.tags && p.tags.length ? `<div class="mt-10 pt-6 border-t border-stone-800"><h4 class="text-stone-500 text-sm font-semibold uppercase tracking-wider mb-3">Tags</h4><div>${p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div></div>` : ''}
</div>`;
}
/* ================================================================
PAGE: CONTACT
================================================================ */
function renderContact() {
document.getElementById('app').innerHTML = `
<div class="page-enter max-w-2xl mx-auto px-6 py-12">
<h1 class="font-display text-4xl md:text-5xl font-300 text-stone-100 mb-4" style="font-weight:300;">Contact</h1>
<p class="text-stone-400 mb-10 leading-relaxed">Have a question, comment, or proposal? Send a message below and I\'ll get back to you as soon as possible.</p>
<form id="contact-form" class="space-y-5">
<div><label class="form-label">Name</label><input type="text" name="name" class="form-input" required></div>
<div><label class="form-label">Email</label><input type="email" name="email" class="form-input" required></div>
<div><label class="form-label">Subject</label><input type="text" name="subject" class="form-input" required></div>
<div><label class="form-label">Message</label><textarea name="message" class="form-textarea" rows="6" required></textarea></div>
<button type="submit" class="btn btn-primary">Send Message</button>
</form>
</div>`;
document.getElementById('contact-form').addEventListener('submit', e => {
e.preventDefault();
const fd = new FormData(e.target);
const msgs = DB.getMessages();
msgs.push({ id: 'm' + Date.now(), name: fd.get('name'), email: fd.get('email'), subject: fd.get('subject'), message: fd.get('message'), date: Date.now(), read: false });
DB.saveMessages(msgs);
showToast('Message sent successfully!');
e.target.reset();
});
}
/* ================================================================
ADMIN: LOGIN
================================================================ */
function renderLogin() {
document.getElementById('app').innerHTML = `
<div class="modal-overlay" id="login-modal">
<div class="modal-box">
<h2 class="font-display text-2xl text-stone-100 mb-2 text-center">Admin Access</h2>
<p class="text-stone-500 text-sm text-center mb-6">Enter your PIN to continue. Default: <code class="text-amber-500">admin</code></p>
<form id="login-form" class="space-y-4">
<input type="password" id="login-pin" class="form-input text-center text-lg tracking-widest" placeholder="Enter PIN" required autofocus>
<button type="submit" class="btn btn-primary w-full justify-center">Log In</button>
</form>
<a href="#/" class="block text-center text-stone-500 text-sm mt-4 hover:text-amber-500 transition-colors">Cancel</a>
</div>
</div>`;
document.getElementById('login-form').addEventListener('submit', e => {
e.preventDefault();
if (document.getElementById('login-pin').value === DB.getPin()) {
sessionStorage.setItem('utd_auth', '1');
showToast('Logged in');
location.hash = '#/admin';
} else {
showToast('Incorrect PIN', 'error');
}
});
}
/* ================================================================
ADMIN: SIDEBAR HELPER
================================================================ */
function adminLayout(activePage, content) {
const links = [
{ href: '#/admin', icon: 'fa-tachometer-alt', label: 'Dashboard', id: 'dashboard' },
{ href: '#/admin/posts', icon: 'fa-list', label: 'All Posts', id: 'posts' },
{ href: '#/admin/post/new', icon: 'fa-plus', label: 'Add New Post', id: 'new' },
{ href: '#/admin/categories', icon: 'fa-folder', label: 'Categories', id: 'categories' },
{ href: '#/admin/messages', icon: 'fa-envelope', label: 'Messages', id: 'messages' },
{ href: '#/admin/settings', icon: 'fa-cog', label: 'Settings', id: 'settings' },
];
return `<div class="page-enter admin-layout">
<aside class="admin-sidebar">
<div class="px-5 mb-6 pb-4 border-b border-stone-800">
<span class="font-display text-lg text-amber-500 font-600">Admin Panel</span>
</div>
${links.map(l => `<a href="${l.href}" class="admin-sidebar-link${activePage === l.id ? ' active' : ''}"><i class="fas ${l.icon}"></i>${l.label}</a>`).join('')}
<a href="#/" class="admin-sidebar-link mt-4 border-t border-stone-800 pt-4"><i class="fas fa-arrow-left"></i>Back to Site</a>
<a href="#" class="admin-sidebar-link text-red-400 hover:text-red-300" onclick="sessionStorage.removeItem('utd_auth');location.hash='#/';return false;"><i class="fas fa-sign-out-alt"></i>Log Out</a>
</aside>
<div class="admin-content">${content}</div>
</div>`;
}
/* ================================================================
ADMIN: DASHBOARD
================================================================ */
function renderAdminDashboard() {
const posts = DB.getPosts();
const published = posts.filter(p => p.status === 'published');
const drafts = posts.filter(p => p.status === 'draft');
const cats = DB.getCats();
const msgs = DB.getMessages();
const unread = msgs.filter(m => !m.read).length;
let catStats = cats.map(c => {
const count = posts.filter(p => p.category === c.id).length;
return `<div class="stat-card"><div class="stat-num">${count}</div><div class="stat-label">${escapeHtml(c.name)}</div></div>`;
}).join('');
let recentPosts = published.sort((a, b) => b.createdAt - a.createdAt).slice(0, 5).map(p =>
`<tr><td><a href="#/admin/post/edit/${p.id}" class="text-amber-500 hover:underline">${escapeHtml(p.title)}</a></td><td class="text-stone-400">${escapeHtml(getCatName(p.category))}</td><td class="text-stone-500 text-sm">${formatDate(p.createdAt)}</td></tr>`
).join('');
document.getElementById('app').innerHTML = adminLayout('dashboard', `
<h2 class="font-display text-3xl text-stone-100 mb-6">Dashboard</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div class="stat-card"><div class="stat-num">${published.length}</div><div class="stat-label">Published</div></div>
<div class="stat-card"><div class="stat-num">${drafts.length}</div><div class="stat-label">Drafts</div></div>
<div class="stat-card"><div class="stat-num">${msgs.length}</div><div class="stat-label">Messages${unread ? ` (${unread} new)` : ''}</div></div>
<div class="stat-card"><div class="stat-num">${posts.length}</div><div class="stat-label">Total Posts</div></div>
</div>
<h3 class="font-display text-xl text-stone-200 mt-8 mb-4">Posts by Category</h3>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">${catStats}</div>
<h3 class="font-display text-xl text-stone-200 mb-4">Recent Posts</h3>
<table class="admin-table"><thead><tr><th>Title</th><th>Category</th><th>Date</th></tr></thead><tbody>${recentPosts || '<tr><td colspan="3" class="text-stone-500 text-center py-8">No posts yet</td></tr>'}</tbody></table>
`);
}
/* ================================================================
ADMIN: ALL POSTS
================================================================ */
function renderAdminPosts() {
const posts = DB.getPosts().sort((a, b) => b.createdAt - a.createdAt);
const rows = posts.map(p => {
const statusBadge = p.status === 'published'
? '<span class="badge bg-emerald-600/15 text-emerald-400">Published</span>'
: '<span class="badge bg-stone-600/30 text-stone-400">Draft</span>';
return `<tr>
<td><a href="#/admin/post/edit/${p.id}" class="text-amber-500 hover:underline">${escapeHtml(p.title)}</a></td>
<td class="text-stone-400 text-sm">${escapeHtml(getCatName(p.category))}</td>
<td>${statusBadge}</td>
<td class="text-stone-500 text-sm">${formatDate(p.createdAt)}</td>
<td>
<a href="#/post/${p.id}" class="text-stone-500 hover:text-amber-500 mr-3" title="View"${p.status !== 'published' ? ' target="_blank"' : ''}><i class="fas fa-external-link-alt"></i></a>
<a href="#/admin/post/edit/${p.id}" class="text-stone-500 hover:text-amber-500 mr-3" title="Edit"><i class="fas fa-pen"></i></a>
<button onclick="adminDeletePost('${p.id}')" class="text-stone-500 hover:text-red-400" title="Delete"><i class="fas fa-trash"></i></button>
</td>
</tr>`;
}).join('');
document.getElementById('app').innerHTML = adminLayout('posts', `
<div class="flex items-center justify-between mb-6">
<h2 class="font-display text-3xl text-stone-100">All Posts</h2>
<a href="#/admin/post/new" class="btn btn-primary btn-sm"><i class="fas fa-plus"></i> Add New</a>
</div>
<table class="admin-table"><thead><tr><th>Title</th><th>Category</th><th>Status</th><th>Date</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="5" class="text-stone-500 text-center py-8">No posts yet. Create your first post!</td></tr>'}</tbody></table>
`);
}
function adminDeletePost(id) {
if (!confirm('Delete this post permanently?')) return;
DB.deletePost(id);
showToast('Post deleted');
route();
}
/* ================================================================
ADMIN: POST FORM (Create / Edit)
================================================================ */
function renderAdminPostForm(editId) {
const cats = DB.getCats();
const subs = DB.getSubs();
const post = editId ? DB.getPost(editId) : null;
const isEdit = !!post;
const selectedCat = post ? post.category : '';
const catSubs = selectedCat ? subs.filter(s => s.categoryId === selectedCat) : [];
document.getElementById('app').innerHTML = adminLayout('new', `
<div class="max-w-3xl">
<h2 class="font-display text-3xl text-stone-100 mb-6">${isEdit ? 'Edit Post' : 'Create New Post'}</h2>
<form id="post-form" class="space-y-5">
<div><label class="form-label">Title</label><input type="text" id="pf-title" class="form-input text-lg" value="${isEdit ? escapeHtml(post.title) : ''}" required></div>
<div><label class="form-label">Content (Markdown supported)</label>
<div class="flex gap-1 mb-2 flex-wrap">
<button type="button" class="toolbar-btn" onclick="insertMd('bold')" title="Bold"><i class="fas fa-bold"></i></button>
<button type="button" class="toolbar-btn" onclick="insertMd('italic')" title="Italic"><i class="fas fa-italic"></i></button>
<button type="button" class="toolbar-btn" onclick="insertMd('h2')" title="Heading 2">H2</button>
<button type="button" class="toolbar-btn" onclick="insertMd('h3')" title="Heading 3">H3</button>
<button type="button" class="toolbar-btn" onclick="insertMd('list')" title="List"><i class="fas fa-list-ul"></i></button>
<button type="button" class="toolbar-btn" onclick="insertMd('quote')" title="Blockquote"><i class="fas fa-quote-right"></i></button>
<button type="button" class="toolbar-btn" onclick="insertMd('link')" title="Link"><i class="fas fa-link"></i></button>
</div>
<textarea id="pf-content" class="form-textarea" rows="14">${isEdit ? escapeHtml(post.content) : ''}</textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="form-label">Category</label>
<select id="pf-category" class="form-select" onchange="updateSubcategoryDropdown()">
<option value="">Select category...</option>
${cats.map(c => `<option value="${c.id}" ${selectedCat === c.id ? 'selected' : ''}>${escapeHtml(c.name)}</option>`).join('')}
</select>
</div>
<div><label class="form-label">Subcategory</label>
<select id="pf-subcategory" class="form-select">
<option value="">None</option>
${catSubs.map(s => `<option value="${s.id}" ${post && post.subcategory === s.id ? 'selected' : ''}>${escapeHtml(s.name)}</option>`).join('')}
</select>
</div>
</div>
<div id="book-review-fields" style="display:${selectedCat === 'book-reviews' ? 'block' : 'none'};">
<h4 class="text-amber-500 text-sm font-semibold uppercase tracking-wider mb-3">Book Review Fields</h4>
<div class="grid grid-cols-2 gap-4">
<div><label class="form-label">Author Last Name</label><input type="text" id="pf-author" class="form-input" value="${isEdit && post.authorLastName ? escapeHtml(post.authorLastName) : ''}"></div>
<div><label class="form-label">Book Publication Date</label><input type="date" id="pf-bookdate" class="form-input" value="${isEdit && post.bookPubDate ? post.bookPubDate : ''}"></div>
</div>
</div>
<div id="event-fields" style="display:${selectedCat === 'events' ? 'block' : 'none'};">
<h4 class="text-amber-500 text-sm font-semibold uppercase tracking-wider mb-3">Event Fields</h4>
<div class="grid grid-cols-2 gap-4">
<div><label class="form-label">Event Date</label><input type="date" id="pf-eventdate" class="form-input" value="${isEdit && post.eventDate ? post.eventDate : ''}"></div>
<div><label class="form-label">Event Location</label><input type="text" id="pf-eventlocation" class="form-input" value="${isEdit && post.eventLocation ? escapeHtml(post.eventLocation) : ''}"></div>
</div>
</div>
<div><label class="form-label">Tags (comma-separated)</label><input type="text" id="pf-tags" class="form-input" value="${isEdit && post.tags ? post.tags.join(', ') : ''}" placeholder="e.g. Fiction, 2023, American Literature"></div>
<div><label class="form-label">Status</label>
<select id="pf-status" class="form-select">
<option value="draft" ${isEdit && post.status === 'draft' ? 'selected' : ''}>Draft</option>
<option value="published" ${!isEdit || post.status === 'published' ? 'selected' : ''}>Published</option>
</select>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="btn btn-primary">${isEdit ? 'Update Post' : 'Publish Post'}</button>
<button type="button" class="btn btn-ghost" onclick="location.hash='#/admin/posts'">Cancel</button>
${isEdit ? `<button type="button" class="btn btn-danger" onclick="adminDeletePost('${post.id}')">Delete</button>` : ''}
</div>
</form>
</div>
`);
// Show/hide conditional fields
document.getElementById('pf-category').addEventListener('change', function() {
document.getElementById('book-review-fields').style.display = this.value === 'book-reviews' ? 'block' : 'none';
document.getElementById('event-fields').style.display = this.value === 'events' ? 'block' : 'none';
});
// Submit
document.getElementById('post-form').addEventListener('submit', e => {
e.preventDefault();
const data = {
title: document.getElementById('pf-title').value.trim(),
content: document.getElementById('pf-content').value,
category: document.getElementById('pf-category').value,
subcategory: document.getElementById('pf-subcategory').value || null,
authorLastName: document.getElementById('pf-author').value.trim() || null,
bookPubDate: document.getElementById('pf-bookdate').value || null,
eventDate: document.getElementById('pf-eventdate').value || null,
eventLocation: document.getElementById('pf-eventlocation').value.trim() || null,
tags: document.getElementById('pf-tags').value.split(',').map(t => t.trim()).filter(Boolean),
status: document.getElementById('pf-status').value,
};
if (!data.title || !data.category) { showToast('Title and category are required', 'error'); return; }
if (isEdit) { DB.updatePost(editId, data); showToast('Post updated'); }
else { DB.createPost(data); showToast('Post created'); }
location.hash = '#/admin/posts';
});
}
function updateSubcategoryDropdown() {
const catId = document.getElementById('pf-category').value;
const subs = DB.getSubsForCat(catId);
const sel = document.getElementById('pf-subcategory');
sel.innerHTML = '<option value="">None</option>' + subs.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
}
function insertMd(type) {
const ta = document.getElementById('pf-content');
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.substring(start, end);
let ins = '';
switch(type) {
case 'bold': ins = `**${sel || 'bold text'}**`; break;
case 'italic': ins = `*${sel || 'italic text'}*`; break;
case 'h2': ins = `\n## ${sel || 'Heading'}\n`; break;
case 'h3': ins = `\n### ${sel || 'Subheading'}\n`; break;
case 'list': ins = `\n- ${sel || 'List item'}\n- \n- \n`; break;
case 'quote': ins = `\n> ${sel || 'Quote text'}\n`; break;
case 'link': ins = `[${sel || 'link text'}](url)`; break;
}
ta.value = ta.value.substring(0, start) + ins + ta.value.substring(end);
ta.focus();
ta.selectionStart = ta.selectionEnd = start + ins.length;
}
/* ================================================================
ADMIN: CATEGORIES
================================================================ */
function renderAdminCategories() {
const cats = DB.getCats();
const subs = DB.getSubs();
let html = cats.map(c => {
const cSubs = subs.filter(s => s.categoryId === c.id);
return `<div class="mb-8">
<div class="flex items-center justify-between mb-3">
<h3 class="font-display text-xl text-stone-200">${escapeHtml(c.name)} <span class="text-stone-600 text-sm font-body">(${c.slug})</span></h3>
</div>
<div class="ml-4 space-y-2 mb-3">
${cSubs.length ? cSubs.map(s => `
<div class="flex items-center justify-between bg-stone-950 rounded-lg px-4 py-2 border border-stone-800">
<span class="text-stone-300">${escapeHtml(s.name)} <span class="text-stone-600 text-sm">(${s.slug})</span></span>
<button onclick="adminDeleteSub('${s.id}')" class="text-stone-500 hover:text-red-400 text-sm"><i class="fas fa-trash"></i></button>
</div>
`).join('') : '<p class="text-stone-600 text-sm">No subcategories</p>'}
</div>
<form onsubmit="adminAddSub(event, '${c.id}')" class="flex gap-2 ml-4">
<input type="text" class="form-input" placeholder="New subcategory name..." required style="max-width:300px;">
<button type="submit" class="btn btn-ghost btn-sm">Add Subcategory</button>
</form>
</div>`;
}).join('');
document.getElementById('app').innerHTML = adminLayout('categories', `
<h2 class="font-display text-3xl text-stone-100 mb-6">Categories & Subcategories</h2>
<div class="bg-amber-600/10 border border-amber-600/20 rounded-lg p-4 mb-8 text-sm text-amber-300">
<i class="fas fa-info-circle mr-2"></i>Default categories cannot be deleted. You can add subcategories to any category and delete custom subcategories.
</div>
${html}
`);
}
function adminAddSub(e, catId) {
e.preventDefault();
const input = e.target.querySelector('input');
const name = input.value.trim();
if (!name) return;
DB.addSubcategory(catId, name);
showToast(`Subcategory "${name}" added`);
route();
}
function adminDeleteSub(id) {
if (!confirm('Delete this subcategory?')) return;
DB.deleteSubcategory(id);
showToast('Subcategory deleted');
route();
}
/* ================================================================
ADMIN: MESSAGES
================================================================ */
function renderAdminMessages() {
const msgs = DB.getMessages().sort((a, b) => b.date - a.date);
// Mark all as read
if (msgs.some(m => !m.read)) {
msgs.forEach(m => m.read = true);
DB.saveMessages(msgs);
}
let rows = msgs.map(m => `
<tr>
<td><strong class="text-stone-200">${escapeHtml(m.name)}</strong><br><span class="text-stone-500 text-sm">${escapeHtml(m.email)}</span></td>
<td class="text-stone-300">${escapeHtml(m.subject)}</td>
<td class="text-stone-500 text-sm">${formatDate(m.date)}</td>
<td><button onclick="this.closest('tr').nextElementSibling.classList.toggle('hidden');this.querySelector('i').classList.toggle('fa-chevron-down');this.querySelector('i').classList.toggle('fa-chevron-up');" class="text-stone-500 hover:text-amber-500"><i class="fas fa-chevron-down"></i></button></td>
</tr>
<tr class="hidden"><td colspan="4" class="bg-stone-950 p-4"><p class="text-stone-400 text-sm whitespace-pre-wrap">${escapeHtml(m.message)}</p></td></tr>
`).join('');
document.getElementById('app').innerHTML = adminLayout('messages', `
<h2 class="font-display text-3xl text-stone-100 mb-6">Messages</h2>
${msgs.length === 0 ? '<p class="text-stone-500 text-center py-12">No messages yet.</p>' :
`<table class="admin-table"><thead><tr><th>From</th><th>Subject</th><th>Date</th><th></th></tr></thead><tbody>${rows}</tbody></table>`}
`);
}
/* ================================================================
ADMIN: SETTINGS
================================================================ */
function renderAdminSettings() {
document.getElementById('app').innerHTML = adminLayout('settings', `
<h2 class="font-display text-3xl text-stone-100 mb-6">Settings</h2>
<div class="max-w-md space-y-6">
<div>
<h3 class="text-stone-200 font-semibold mb-3">Change Admin PIN</h3>
<form onsubmit="adminChangePin(event)" class="space-y-3">
<div><label class="form-label">Current PIN</label><input type="password" id="set-oldpin" class="form-input" required></div>
<div><label class="form-label">New PIN</label><input type="password" id="set-newpin" class="form-input" required minlength="3"></div>
<div><label class="form-label">Confirm New PIN</label><input type="password" id="set-confpin" class="form-input" required></div>
<button type="submit" class="btn btn-primary btn-sm">Update PIN</button>
</form>
</div>
<div class="border-t border-stone-800 pt-6">
<h3 class="text-stone-200 font-semibold mb-3">Danger Zone</h3>
<button onclick="if(confirm('This will delete ALL posts, categories, and messages. Are you sure?')){localStorage.clear();location.reload();}" class="btn btn-danger btn-sm">Reset All Data</button>
</div>
</div>
`);
}
function adminChangePin(e) {
e.preventDefault();
const old = document.getElementById('set-oldpin').value;
const newP = document.getElementById('set-newpin').value;
const conf = document.getElementById('set-confpin').value;
if (old !== DB.getPin()) { showToast('Current PIN is incorrect', 'error'); return; }
if (newP !== conf) { showToast('New PINs do not match', 'error'); return; }
if (newP.length < 3) { showToast('PIN must be at least 3 characters', 'error'); return; }
DB.setPin(newP);
showToast('PIN updated');
e.target.reset();
}
/* ================================================================
DEPTH PARTICLE EFFECT (Home page canvas)
================================================================ */
function initDepthsCanvas() {
const canvas = document.getElementById('depths-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let w, h, particles = [], animId;
function resize() {
const rect = canvas.parentElement.getBoundingClientRect();
w = canvas.width = rect.width;
h = canvas.height = rect.height;
}
function createParticles() {
particles = [];
const count = Math.min(80, Math.floor(w * h / 12000));
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * w,
y: Math.random() * h,
r: Math.max(0.5, Math.random() * 2.5 + 0.5),
vx: (Math.random() - 0.5) * 0.3,
vy: -Math.random() * 0.4 - 0.1,
alpha: Math.random() * 0.5 + 0.1,
pulse: Math.random() * Math.PI * 2,
pulseSpeed: Math.random() * 0.02 + 0.005,
hue: 35 + Math.random() * 15,
});
}
}
function draw() {
ctx.clearRect(0, 0, w, h);
// Subtle radial glow at center
const grad = ctx.createRadialGradient(w * 0.5, h * 0.55, 0, w * 0.5, h * 0.55, Math.max(w, h) * 0.6);
grad.addColorStop(0, 'rgba(217,119,6,0.03)');
grad.addColorStop(0.5, 'rgba(217,119,6,0.01)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
p.pulse += p.pulseSpeed;
const a = p.alpha * (0.5 + 0.5 * Math.sin(p.pulse));
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
if (p.x < -10) p.x = w + 10;
if (p.x > w + 10) p.x = -10;
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(0.5, p.r), 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${a})`;
ctx.fill();
// Glow
if (p.r > 1.5) {
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(1, p.r * 3), 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${a * 0.1})`;
ctx.fill();
}
});
animId = requestAnimationFrame(draw);
}
resize();
createParticles();
draw();
window.addEventListener('resize', () => { resize(); createParticles(); });
// Stop animation when navigating away
const obs = new MutationObserver(() => {
if (!document.getElementById('depths-canvas')) {
cancelAnimationFrame(animId);
obs.disconnect();
}
});
obs.observe(document.getElementById('app'), { childList: true });
}
/* ================================================================
INITIALIZATION
================================================================ */
window.addEventListener('hashchange', route);
route();
// Expose global functions for inline handlers
window.handleSort = handleSort;
window.adminDeletePost = adminDeletePost;
window.adminDeleteSub = adminDeleteSub;
window.adminAddSub = adminAddSub;
window.adminChangePin = adminChangePin;
window.insertMd = insertMd;
window.updateSubcategoryDropdown = updateSubcategoryDropdown;
</script>
</body>
</html>