This commit is contained in:
2026-07-21 11:48:50 -05:00
commit 5c2ac5f7e1
24 changed files with 4839 additions and 0 deletions

11
src/backup.js Normal file
View File

@@ -0,0 +1,11 @@
const { createBackup } = require("./server");
createBackup()
.then((backup) => {
console.log(`Backup created: ${backup.file_path}`);
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});

41
src/config.js Normal file
View File

@@ -0,0 +1,41 @@
const path = require("path");
const root = path.resolve(__dirname, "..");
function bool(value, fallback = false) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}
function fromRoot(value, fallback) {
const selected = value || fallback;
return path.isAbsolute(selected) ? selected : path.join(root, selected);
}
module.exports = {
root,
env: process.env.NODE_ENV || "development",
port: Number(process.env.PORT || 3000),
baseUrl: process.env.APP_BASE_URL || "http://localhost:3000",
primaryDomain: process.env.PRIMARY_DOMAIN || "debbiewindlerseamstress.com",
secondaryDomain: process.env.SECONDARY_DOMAIN || "debbiewindler.com",
sessionSecret: process.env.SESSION_SECRET || "development-only-change-me",
databasePath: fromRoot(process.env.DATABASE_PATH, "./storage/site.sqlite"),
uploadDir: fromRoot(process.env.UPLOAD_DIR, "./public/uploads"),
backupDir: fromRoot(process.env.BACKUP_DIR, "./storage/backups"),
smtp: {
host: process.env.SMTP_HOST || "",
port: Number(process.env.SMTP_PORT || 587),
secure: bool(process.env.SMTP_SECURE, false),
user: process.env.SMTP_USER || "",
pass: process.env.SMTP_PASS || "",
from: process.env.SMTP_FROM || "Debbie Windler Seamstress <website@debbiewindlerseamstress.com>",
ownerEmail: process.env.OWNER_EMAIL || ""
},
captcha: {
enabled: bool(process.env.CAPTCHA_ENABLED, false),
siteKey: process.env.CAPTCHA_SITE_KEY || "",
secretKey: process.env.CAPTCHA_SECRET_KEY || ""
},
production: process.env.NODE_ENV === "production"
};

471
src/database.js Normal file
View File

@@ -0,0 +1,471 @@
const fs = require("fs");
const path = require("path");
const { DatabaseSync } = require("node:sqlite");
const config = require("./config");
const { now, makeSlug } = require("./utils");
fs.mkdirSync(path.dirname(config.databasePath), { recursive: true });
fs.mkdirSync(config.uploadDir, { recursive: true });
fs.mkdirSync(config.backupDir, { recursive: true });
const db = new DatabaseSync(config.databasePath);
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");
function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS administrators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'owner',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess TEXT NOT NULL,
expired_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
subtitle TEXT,
intro TEXT,
header_image_id INTEGER,
background_image_id INTEGER,
meta_title TEXT,
meta_description TEXT,
og_title TEXT,
og_description TEXT,
og_image_id INTEGER,
canonical_url TEXT,
search_visible INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'published',
draft_json TEXT,
published_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY(header_image_id) REFERENCES media(id) ON DELETE SET NULL,
FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL,
FOREIGN KEY(og_image_id) REFERENCES media(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS page_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL,
section_key TEXT,
title TEXT,
body TEXT,
layout TEXT NOT NULL DEFAULT 'text',
image_id INTEGER,
background_image_id INTEGER,
background_color TEXT,
button_label TEXT,
button_url TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
is_published INTEGER NOT NULL DEFAULT 1,
data_json TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY(page_id) REFERENCES pages(id) ON DELETE CASCADE,
FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL,
FOREIGN KEY(background_image_id) REFERENCES media(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL,
original_name TEXT NOT NULL,
title TEXT,
alt_text TEXT,
category TEXT,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
width INTEGER,
height INTEGER,
url TEXT NOT NULL,
thumb_url TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
display_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
short_description TEXT,
full_description TEXT,
category_id INTEGER,
main_image_id INTEGER,
price TEXT,
starting_price TEXT,
price_range TEXT,
contact_for_pricing INTEGER NOT NULL DEFAULT 0,
quantity TEXT,
availability_status TEXT NOT NULL DEFAULT 'Available',
custom_order_available INTEGER NOT NULL DEFAULT 0,
color_size_notes TEXT,
material_notes TEXT,
care_instructions TEXT,
featured INTEGER NOT NULL DEFAULT 0,
newly_added INTEGER NOT NULL DEFAULT 0,
on_sale INTEGER NOT NULL DEFAULT 0,
sale_text TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
is_published INTEGER NOT NULL DEFAULT 1,
is_archived INTEGER NOT NULL DEFAULT 0,
draft_json TEXT,
date_added TEXT NOT NULL,
published_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY(main_image_id) REFERENCES media(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS item_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
media_id INTEGER NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
is_main INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY(media_id) REFERENCES media(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS item_tags (
item_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY(item_id, tag_id),
FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
image_id INTEGER,
short_description TEXT,
full_description TEXT,
pricing_note TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
featured INTEGER NOT NULL DEFAULT 0,
is_published INTEGER NOT NULL DEFAULT 1,
draft_json TEXT,
published_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS equipment (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT,
brand TEXT,
model TEXT,
description TEXT,
image_id INTEGER,
display_order INTEGER NOT NULL DEFAULT 0,
is_published INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
FOREIGN KEY(image_id) REFERENCES media(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
preferred_contact TEXT,
subject TEXT NOT NULL,
message TEXT NOT NULL,
related_item TEXT,
related_service TEXT,
consent INTEGER NOT NULL DEFAULT 0,
source_page TEXT,
status TEXT NOT NULL DEFAULT 'unread',
spam_status TEXT NOT NULL DEFAULT 'clean',
ip_hash TEXT,
user_agent TEXT,
email_notification_status TEXT NOT NULL DEFAULT 'not_configured',
email_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS navigation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
url TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'internal',
opens_new_tab INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL DEFAULT 0,
is_published INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_type TEXT NOT NULL,
record_id INTEGER NOT NULL,
title TEXT,
snapshot_json TEXT NOT NULL,
created_by INTEGER,
created_at TEXT NOT NULL,
FOREIGN KEY(created_by) REFERENCES administrators(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER,
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(admin_id) REFERENCES administrators(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT PRIMARY KEY,
count INTEGER NOT NULL,
reset_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_items_public ON items(is_published, is_archived, name);
CREATE INDEX IF NOT EXISTS idx_items_flags ON items(featured, newly_added, on_sale);
CREATE INDEX IF NOT EXISTS idx_services_public ON services(is_published, display_order);
CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status, created_at);
CREATE INDEX IF NOT EXISTS idx_page_sections_order ON page_sections(page_id, display_order);
CREATE INDEX IF NOT EXISTS idx_revisions_record ON revisions(record_type, record_id, created_at);
`);
}
function setSetting(key, value) {
db.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run(key, String(value ?? ""), now());
}
function getSetting(key, fallback = "") {
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
return row ? row.value : fallback;
}
function allSettings() {
const rows = db.prepare("SELECT key, value FROM settings").all();
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
function ensureCategory(name) {
const slug = makeSlug(name, "category");
db.prepare(`
INSERT INTO categories (name, slug, display_order)
VALUES (?, ?, (SELECT COALESCE(MAX(display_order), 0) + 10 FROM categories))
ON CONFLICT(name) DO NOTHING
`).run(name, slug);
return db.prepare("SELECT id FROM categories WHERE name = ?").get(name).id;
}
function seed() {
const seeded = getSetting("system.seeded", "");
if (seeded) return;
const ts = now();
const settings = {
"system.seeded": ts,
"business.name": "Debbie Windler Seamstress",
"business.tagline": "Alterations, custom sewing, embroidery, and handmade items",
"business.email": "replace-with-owner-email@example.com",
"business.phone": "",
"business.facebook": "",
"business.service_area": "Service area to be supplied",
"business.hours": "By appointment",
"business.preferred_contact": "Email or website contact form",
"business.footer_text": "Warm, careful sewing work for everyday clothing and special occasions.",
"site.title": "Debbie Windler Seamstress",
"site.description": "Alterations, custom sewing, embroidery, and handmade items.",
"theme.bg": "#fffaf5",
"theme.bg_alt": "#f4e8df",
"theme.text": "#342b28",
"theme.heading": "#2d2522",
"theme.link": "#7d3f45",
"theme.button": "#7d3f45",
"theme.button_text": "#ffffff",
"theme.accent": "#7a8f73",
"theme.radius": "8",
"theme.spacing": "comfortable",
"theme.width": "1120",
"theme.heading_font": "Georgia, 'Times New Roman', serif",
"theme.body_font": "Arial, Helvetica, sans-serif",
"theme.footer_style": "light",
"home.hero.title": "Debbie Windler Seamstress",
"home.hero.subtitle": "Alterations, custom sewing, embroidery, and handmade pieces, with sample wording ready for Debbie to replace.",
"home.hero.contact": "Contact Debbie to talk through your project.",
"home.hero.image": "/uploads/sample-hero.png",
"home.hero.position": "center",
"home.hero.size": "cover",
"home.hero.overlay": "light",
"home.hero.overlay_opacity": "0.28",
"home.hero.text_color": "#2d2522",
"home.hero.button1_label": "Contact Debbie",
"home.hero.button1_url": "/about-contact#contact-form",
"home.hero.button2_label": "View Items",
"home.hero.button2_url": "/items",
"home.highlight.title": "Featured and Newly Added",
"home.highlight.intro": "Sample cards show how items can be featured, marked new, or offered as custom orders.",
"home.highlight.background_color": "#f4e8df",
"maintenance.enabled": "false",
"maintenance.message": "The website is getting a careful refresh. Please check back soon.",
"maintenance.return_note": "",
"maintenance.artwork": ""
};
for (const [key, value] of Object.entries(settings)) setSetting(key, value);
const pageInsert = db.prepare(`
INSERT INTO pages (slug, title, subtitle, intro, meta_title, meta_description, status, published_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?)
`);
const pages = [
["home", "Home", "Welcome to Debbie Windler Seamstress", "Sample home page copy can be replaced from the owner area."],
["items", "Items Available and Custom Orders", "Browse sample items alphabetically.", "Items can be available, made to order, on sale, sold as examples, seasonal, or hidden."],
["services", "Services", "Alterations, repairs, custom sewing, and embroidery.", "Service examples are seeded so Debbie can edit or remove them."],
["about-contact", "About and Contact", "A personal introduction and the full contact form.", "This page keeps Debbie's contact details editable and does not show a private home address by default."]
];
for (const page of pages) pageInsert.run(page[0], page[1], page[2], page[3], page[1], page[3], ts, ts);
const pageBySlug = db.prepare("SELECT id FROM pages WHERE slug = ?");
const sectionInsert = db.prepare(`
INSERT INTO page_sections
(page_id, section_key, title, body, layout, display_order, is_published, data_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
`);
sectionInsert.run(pageBySlug.get("home").id, "intro", "Sewing Services With a Personal Touch", "<p>This sample introduction can describe Debbie's experience, favorite work, and how customers should reach out.</p>", "image-right", 10, "{}", ts);
sectionInsert.run(pageBySlug.get("home").id, "contact-callout", "Have a project in mind?", "<p>Use the contact form to ask about alterations, embroidery, handmade items, or special orders.</p>", "contact-callout", 30, "{}", ts);
sectionInsert.run(pageBySlug.get("services").id, "service-note", "Not sure what to ask for?", "<p>Send a note with the garment or project details. Photos can be discussed later by email if needed.</p>", "contact-callout", 20, "{}", ts);
sectionInsert.run(pageBySlug.get("about-contact").id, "about", "About Debbie", "<p>Sample biography text: Debbie's real sewing history, experience, and photos can be added here from the owner area.</p>", "text", 10, "{}", ts);
const categories = ["Alterations", "Handmade Items", "Embroidery", "Custom Orders", "Seasonal"];
const categoryIds = Object.fromEntries(categories.map((name) => [name, ensureCategory(name)]));
const itemInsert = db.prepare(`
INSERT INTO items
(name, slug, short_description, full_description, category_id, price, starting_price, price_range, contact_for_pricing,
availability_status, custom_order_available, material_notes, care_instructions, featured, newly_added, on_sale,
sale_text, display_order, is_published, date_added, published_at, updated_at)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
`);
const sampleItems = [
["Baby Blanket Example", "Soft sample listing for a custom baby blanket.", "<p>Replace this with true details, sizes, fabrics, and ordering notes.</p>", "Handmade Items", "", "Contact for starting price", "", 1, "Made to Order", 1, "Fabric details to be supplied.", "Care instructions to be supplied.", 1, 1, 0, "", 10],
["Embroidered Tote Bag", "Sample tote bag entry for embroidery or custom wording.", "<p>This is sample content only and can be changed or hidden.</p>", "Embroidery", "", "", "", 1, "Custom Order", 1, "Canvas or fabric notes can go here.", "Spot clean or care note to be supplied.", 1, 1, 0, "", 20],
["Formalwear Alteration Example", "A sold/example listing showing previous-style work without checkout.", "<p>Use sold examples to show the type of work Debbie can discuss with customers.</p>", "Alterations", "", "", "", 1, "Sold", 0, "", "", 0, 0, 0, "", 30],
["Seasonal Pillow Cover", "Sample seasonal handmade item.", "<p>Mark items seasonal, available, hidden, sold, or made to order.</p>", "Seasonal", "$00 sample", "", "", 0, "Available", 1, "Material notes to be supplied.", "Care instructions to be supplied.", 1, 0, 1, "Sample sale badge", 40]
];
for (const item of sampleItems) {
itemInsert.run(item[0], makeSlug(item[0], "item"), item[1], item[2], categoryIds[item[3]], item[4], item[5], item[6], item[7], item[8], item[9], item[10], item[11], item[12], item[13], item[14], item[15], item[16], ts, ts, ts);
}
const serviceInsert = db.prepare(`
INSERT INTO services
(name, slug, short_description, full_description, pricing_note, display_order, featured, is_published, published_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
`);
[
["Clothing Alterations", "Hems, fit adjustments, and everyday garment alterations.", "<p>Sample service details. Debbie can replace this with her exact services and policies.</p>", "Pricing depends on the garment and work needed.", 10, 1],
["Zipper Repair and Replacement", "Repair or replace zippers on clothing and fabric items.", "<p>Describe accepted items and turnaround once details are known.</p>", "Contact for pricing.", 20, 1],
["Wedding Dress Alterations", "Fittings and alterations for wedding dresses and formalwear.", "<p>Sample text only. Add appointment expectations and timing when ready.</p>", "Contact early for availability.", 30, 1],
["Embroidery", "Custom embroidery projects and decorative additions.", "<p>Describe machine embroidery options, setup needs, and project limits.</p>", "Contact for pricing.", 40, 1],
["Baby Blankets", "Handmade or custom baby blanket projects.", "<p>Describe fabric choices, size options, and custom order timing.</p>", "Starting price to be supplied.", 50, 0],
["Patches Sewn Onto Clothing", "Patch placement and sewing for jackets, uniforms, and garments.", "<p>Describe accepted materials and any preparation instructions.</p>", "Contact for pricing.", 60, 0]
].forEach((service) => serviceInsert.run(service[0], makeSlug(service[0], "service"), service[1], service[2], service[3], service[4], service[5], ts, ts));
const equipmentInsert = db.prepare(`
INSERT INTO equipment (name, type, brand, model, description, display_order, is_published, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
`);
equipmentInsert.run("Industrial Sewing Machine", "Sewing machine", "", "", "Sample equipment entry. Add the real brand, model, and photo when available.", 10, ts);
equipmentInsert.run("Embroidery Machine", "Embroidery machine", "", "", "Sample equipment entry for embroidery work.", 20, ts);
equipmentInsert.run("Thread Rack", "Supplies", "", "", "Sample equipment entry for thread colors and materials.", 30, ts);
const navInsert = db.prepare("INSERT INTO navigation (label, url, kind, display_order, is_published) VALUES (?, ?, 'internal', ?, 1)");
[
["Home", "/", 10],
["Items", "/items", 20],
["Services", "/services", 30],
["About and Contact", "/about-contact", 40]
].forEach((nav) => navInsert.run(...nav));
}
function saveRevision(type, id, title, snapshot, adminId) {
db.prepare(`
INSERT INTO revisions (record_type, record_id, title, snapshot_json, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(type, id, title || "", JSON.stringify(snapshot), adminId || null, now());
const old = db.prepare(`
SELECT id FROM revisions
WHERE record_type = ? AND record_id = ?
ORDER BY created_at DESC
LIMIT -1 OFFSET 10
`).all(type, id);
if (old.length) {
db.prepare(`DELETE FROM revisions WHERE id IN (${old.map(() => "?").join(",")})`).run(...old.map((row) => row.id));
}
}
function audit(adminId, action, details, ipAddress) {
db.prepare(`
INSERT INTO audit_logs (admin_id, action, details, ip_address, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(adminId || null, action, details || "", ipAddress || "", now());
}
migrate();
seed();
if (require.main === module) {
console.log(`Database ready at ${config.databasePath}`);
}
module.exports = {
db,
migrate,
seed,
setSetting,
getSetting,
allSettings,
ensureCategory,
saveRevision,
audit
};

280
src/render.js Normal file
View File

@@ -0,0 +1,280 @@
const { escapeHtml, cleanRichText, bytes } = require("./utils");
function setting(settings, key, fallback = "") {
return settings[key] ?? fallback;
}
function publicLayout({ title, description, settings, nav = [], body, csrfToken = "", currentPath = "", extraHead = "" }) {
const business = setting(settings, "business.name", "Debbie Windler Seamstress");
const theme = themeVars(settings);
const navHtml = nav.map((link) => `
<a class="${currentPath === link.url ? "active" : ""}" href="${escapeHtml(link.url)}" ${link.opens_new_tab ? 'target="_blank" rel="noopener noreferrer"' : ""}>${escapeHtml(link.label)}</a>
`).join("");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title || setting(settings, "site.title", business))}</title>
<meta name="description" content="${escapeHtml(description || setting(settings, "site.description", ""))}">
<meta property="og:title" content="${escapeHtml(title || business)}">
<meta property="og:description" content="${escapeHtml(description || setting(settings, "site.description", ""))}">
<meta property="og:type" content="website">
<meta name="theme-color" content="${escapeHtml(setting(settings, "theme.button", "#7d3f45"))}">
<link rel="stylesheet" href="/css/site.css">
<style>:root{${theme}}</style>
${extraHead}
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<a class="brand" href="/" aria-label="${escapeHtml(business)} home">
<span class="brand-mark" aria-hidden="true"></span>
<span>${escapeHtml(business)}</span>
</a>
<button class="menu-toggle" data-menu-toggle aria-expanded="false" aria-controls="site-menu">Menu</button>
<nav id="site-menu" class="site-nav" aria-label="Main navigation">
${navHtml}
<a class="nav-cta" href="/about-contact#contact-form">Contact Debbie</a>
</nav>
</header>
<main id="main">${body}</main>
<footer class="site-footer">
<div>
<strong>${escapeHtml(business)}</strong>
<p>${escapeHtml(setting(settings, "business.footer_text", ""))}</p>
</div>
<div>
${contactLine(settings)}
<p>${escapeHtml(setting(settings, "business.hours", "By appointment"))}</p>
</div>
<nav aria-label="Footer navigation">${navHtml}</nav>
<p class="copyright">&copy; ${new Date().getFullYear()} ${escapeHtml(business)}. ${escapeHtml(setting(settings, "business.copyright", "All rights reserved."))}</p>
</footer>
<form hidden method="post"><input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}"></form>
<script src="/js/site.js" defer></script>
</body>
</html>`;
}
function adminLayout({ title, body, admin, csrfToken, active = "" }) {
const menu = [
["dashboard", "Dashboard", "/admin/dashboard"],
["home", "Home Page", "/admin/home"],
["pages", "Pages", "/admin/pages"],
["items", "Items", "/admin/items"],
["services", "Services", "/admin/services"],
["about", "About and Equipment", "/admin/about-equipment"],
["messages", "Messages", "/admin/messages"],
["media", "Media Library", "/admin/media"],
["appearance", "Appearance", "/admin/appearance"],
["navigation", "Navigation", "/admin/navigation"],
["business", "Business Settings", "/admin/business"],
["seo", "Search and Sharing", "/admin/seo"],
["security", "Security", "/admin/security"],
["backups", "Backups", "/admin/backups"],
["maintenance", "Maintenance", "/admin/maintenance"]
];
const menuHtml = menu.map(([key, label, url]) => `<a class="${active === key ? "active" : ""}" href="${url}">${label}</a>`).join("");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} - Owner Area</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<a class="skip-link" href="#admin-main">Skip to content</a>
<aside class="admin-sidebar">
<a class="admin-brand" href="/admin/dashboard">Debbie Windler<br><span>Owner Area</span></a>
<nav aria-label="Owner area">${menuHtml}</nav>
<a href="/" target="_blank" rel="noopener noreferrer">Preview Website</a>
<form method="post" action="/admin/logout">
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">
<button type="submit" class="link-button">Log Out</button>
</form>
</aside>
<main id="admin-main" class="admin-main">
<header class="admin-top">
<div>
<p class="eyebrow">Owner administration</p>
<h1>${escapeHtml(title)}</h1>
</div>
<p class="admin-user">${escapeHtml(admin?.name || "")}</p>
</header>
${body}
</main>
<script src="/js/admin.js" defer></script>
</body>
</html>`;
}
function authLayout(title, body) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} - Debbie Windler Seamstress</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="auth-page">
<main class="auth-card">
<h1>${escapeHtml(title)}</h1>
${body}
</main>
</body>
</html>`;
}
function themeVars(settings) {
const keys = {
"--bg": ["theme.bg", "#fffaf5"],
"--bg-alt": ["theme.bg_alt", "#f4e8df"],
"--text": ["theme.text", "#342b28"],
"--heading": ["theme.heading", "#2d2522"],
"--link": ["theme.link", "#7d3f45"],
"--button": ["theme.button", "#7d3f45"],
"--button-text": ["theme.button_text", "#ffffff"],
"--accent": ["theme.accent", "#7a8f73"],
"--radius": ["theme.radius", "8"],
"--site-width": ["theme.width", "1120"]
};
const css = Object.entries(keys).map(([name, [key, fallback]]) => `${name}:${escapeHtml(setting(settings, key, fallback))}${name === "--radius" || name === "--site-width" ? "px" : ""};`);
css.push(`--heading-font:${escapeHtml(setting(settings, "theme.heading_font", "Georgia, 'Times New Roman', serif"))};`);
css.push(`--body-font:${escapeHtml(setting(settings, "theme.body_font", "Arial, Helvetica, sans-serif"))};`);
return css.join("");
}
function contactLine(settings) {
const email = setting(settings, "business.email", "");
const phone = setting(settings, "business.phone", "");
const facebook = setting(settings, "business.facebook", "");
return `
${email ? `<p><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></p>` : ""}
${phone ? `<p><a href="tel:${escapeHtml(phone)}">${escapeHtml(phone)}</a></p>` : ""}
${facebook ? `<p><a href="${escapeHtml(facebook)}" target="_blank" rel="noopener noreferrer">Facebook</a></p>` : ""}
`;
}
function button(label, href, className = "button") {
if (!label || !href) return "";
return `<a class="${className}" href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
}
function itemCard(item) {
const badgeBits = [item.newly_added ? "New" : "", item.on_sale ? "Sale" : "", item.availability_status].filter(Boolean);
return `<article class="item-card">
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="${escapeHtml(item.image_alt || item.name)}" loading="lazy">` : `<div class="image-placeholder" aria-hidden="true"></div>`}
<div class="card-body">
<div class="badges">${badgeBits.map((badge) => `<span>${escapeHtml(badge)}</span>`).join("")}</div>
<h3><a href="/items/${escapeHtml(item.slug)}">${escapeHtml(item.name)}</a></h3>
<p>${escapeHtml(item.short_description || "")}</p>
<p class="price">${priceText(item)}</p>
<div class="card-actions">
${button("View Details", `/items/${item.slug}`, "text-button")}
${button("Ask About This Item", `/about-contact?item=${encodeURIComponent(item.name)}#contact-form`, "button small")}
</div>
</div>
</article>`;
}
function serviceCard(service) {
return `<article class="service-card">
${service.image_url ? `<img src="${escapeHtml(service.image_url)}" alt="${escapeHtml(service.image_alt || service.name)}" loading="lazy">` : `<div class="image-placeholder thread" aria-hidden="true"></div>`}
<div class="card-body">
<h3>${escapeHtml(service.name)}</h3>
<p>${escapeHtml(service.short_description || "")}</p>
${service.pricing_note ? `<p class="pricing-note">${escapeHtml(service.pricing_note)}</p>` : ""}
${button("Ask About This Service", `/about-contact?service=${encodeURIComponent(service.name)}#contact-form`, "button small")}
</div>
</article>`;
}
function priceText(item) {
if (item.contact_for_pricing) return "Contact for pricing";
if (item.price_range) return escapeHtml(item.price_range);
if (item.starting_price) return `Starting at ${escapeHtml(item.starting_price)}`;
if (item.price) return escapeHtml(item.price);
return "";
}
function contactForm({ csrfToken, item = "", service = "", success = false, settings = {} }) {
return `<section id="contact-form" class="contact-form-section">
<div class="section-heading">
<p class="eyebrow">Contact</p>
<h2>Contact Debbie</h2>
<p>${escapeHtml(setting(settings, "business.preferred_contact", "Email or the website form are preferred."))}</p>
${success ? `<p class="notice success" role="status">Thank you. Your message was saved and Debbie will be notified if email is configured.</p>` : ""}
</div>
<form class="contact-form" method="post" action="/contact" novalidate>
<input type="hidden" name="_csrf" value="${escapeHtml(csrfToken)}">
<input type="hidden" name="source_page" value="/about-contact">
<label class="hp">Leave this field empty <input type="text" name="website"></label>
<label>Name <input required maxlength="120" name="name" autocomplete="name"></label>
<label>Email <input required maxlength="180" type="email" name="email" autocomplete="email"></label>
<label>Phone, optional <input maxlength="80" name="phone" autocomplete="tel"></label>
<label>Preferred contact method
<select name="preferred_contact">
<option>Email</option>
<option>Phone</option>
<option>Text message</option>
</select>
</label>
<label>Subject <input required maxlength="160" name="subject" value="${escapeHtml(item ? `Question about ${item}` : service ? `Question about ${service}` : "")}"></label>
<label>Item or service being discussed <input maxlength="160" name="related" value="${escapeHtml(item || service)}"></label>
<label>Message <textarea required maxlength="3000" name="message" rows="7"></textarea></label>
<label class="checkbox"><input type="checkbox" required name="consent" value="1"> I understand this information will be used to answer my inquiry.</label>
<button type="submit">Send Message</button>
</form>
</section>`;
}
function sectionHtml(section) {
if (!section.is_published) return "";
const style = [
section.background_color ? `background-color:${escapeHtml(section.background_color)}` : "",
section.background_url ? `background-image:url('${escapeHtml(section.background_url)}')` : ""
].filter(Boolean).join(";");
const image = section.image_url ? `<img src="${escapeHtml(section.image_url)}" alt="${escapeHtml(section.image_alt || section.title || "")}" loading="lazy">` : "";
const content = `<div class="section-copy">
${section.title ? `<h2>${escapeHtml(section.title)}</h2>` : ""}
${cleanRichText(section.body || "")}
${button(section.button_label, section.button_url)}
</div>`;
if (section.layout === "image-left") return `<section class="content-section split" style="${style}">${image}${content}</section>`;
if (section.layout === "image-right") return `<section class="content-section split reverse" style="${style}">${content}${image}</section>`;
if (section.layout === "full-image") return `<section class="content-section full-image" style="${style}">${image}${content}</section>`;
if (section.layout === "contact-callout") return `<section class="content-section contact-callout" style="${style}">${content}</section>`;
return `<section class="content-section" style="${style}">${content}</section>`;
}
function mediaOption(media, selected) {
return `<option value="${media.id}" ${Number(selected) === Number(media.id) ? "selected" : ""}>${escapeHtml(media.title || media.original_name)}</option>`;
}
function mediaGrid(media) {
return `<div class="media-grid">${media.map((m) => `
<article class="media-card">
<img src="${escapeHtml(m.thumb_url || m.url)}" alt="${escapeHtml(m.alt_text || m.title || "")}">
<strong>${escapeHtml(m.title || m.original_name)}</strong>
<small>${escapeHtml(m.mime_type)} · ${bytes(m.size_bytes || 0)} ${m.width ? `· ${m.width}x${m.height}` : ""}</small>
</article>
`).join("")}</div>`;
}
module.exports = {
publicLayout,
adminLayout,
authLayout,
setting,
button,
itemCard,
serviceCard,
priceText,
contactForm,
sectionHtml,
mediaOption,
mediaGrid
};

1093
src/server.js Normal file

File diff suppressed because it is too large Load Diff

84
src/utils.js Normal file
View File

@@ -0,0 +1,84 @@
const crypto = require("crypto");
const sanitizeHtml = require("sanitize-html");
const slugify = require("slugify");
function escapeHtml(value = "") {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function cleanRichText(value = "") {
return sanitizeHtml(String(value), {
allowedTags: ["p", "br", "strong", "b", "em", "i", "h2", "h3", "h4", "ul", "ol", "li", "a"],
allowedAttributes: {
a: ["href", "target", "rel"]
},
allowedSchemes: ["http", "https", "mailto", "tel"],
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: {
href: attribs.href || "#",
target: attribs.target === "_blank" ? "_blank" : undefined,
rel: attribs.target === "_blank" ? "noopener noreferrer" : undefined
}
})
}
});
}
function makeSlug(value, fallback = "entry") {
const slug = slugify(String(value || ""), { lower: true, strict: true, trim: true });
return slug || `${fallback}-${Date.now()}`;
}
function token(size = 24) {
return crypto.randomBytes(size).toString("hex");
}
function now() {
return new Date().toISOString();
}
function bytes(n = 0) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function checkbox(value) {
return value ? 1 : 0;
}
function listFromText(value = "") {
return String(value)
.split(/\r?\n|,/)
.map((part) => part.trim())
.filter(Boolean);
}
function safeJson(value, fallback = {}) {
if (!value) return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
module.exports = {
escapeHtml,
cleanRichText,
makeSlug,
token,
now,
bytes,
checkbox,
listFromText,
safeJson
};