feat: add admin dark mode and SEO/robots.txt settings

Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage

SEO Settings:
- Add robots.txt configuration in Settings > SEO tab
- Block AI crawlers (GPTBot, ChatGPT-User, etc.) with toggle
- Custom robots.txt rules management
- Add RobotsMetaTags component for gallery pages
- Backend service for dynamic robots.txt generation
- Database migration for SEO settings storage

UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
This commit is contained in:
Paul Nothaft
2026-02-06 23:26:01 +01:00
parent 4912e2bccf
commit 9c2a0d272a
79 changed files with 2467 additions and 1475 deletions
@@ -0,0 +1,54 @@
const DEFAULT_AI_AGENTS = [
'GPTBot',
'ChatGPT-User',
'Google-Extended',
'Claude-Web',
'Anthropic-AI',
'CCBot',
'Bytespider',
'FacebookBot',
'Omgilibot',
'Diffbot',
'PetalBot',
'Amazonbot',
'PerplexityBot',
'YouBot',
'Applebot-Extended'
];
exports.up = async function(knex) {
const defaults = [
{ setting_key: 'seo_allow_indexing', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_block_ai_crawlers', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_block_social_bots', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_blocked_ai_agents', setting_value: JSON.stringify(DEFAULT_AI_AGENTS), setting_type: 'seo' },
{ setting_key: 'seo_custom_rules', setting_value: JSON.stringify([]), setting_type: 'seo' },
{ setting_key: 'seo_meta_noindex', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_meta_nofollow', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_meta_noai', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_sitemap_url', setting_value: JSON.stringify(''), setting_type: 'seo' }
];
for (const setting of defaults) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
};
exports.down = async function(knex) {
await knex('app_settings')
.whereIn('setting_key', [
'seo_allow_indexing',
'seo_block_ai_crawlers',
'seo_block_social_bots',
'seo_blocked_ai_agents',
'seo_custom_rules',
'seo_meta_noindex',
'seo_meta_nofollow',
'seo_meta_noai',
'seo_sitemap_url'
])
.del();
};
+51
View File
@@ -252,10 +252,29 @@ function renderBrandFooter(branding) {
</footer>`;
}
function buildSeoMetaTags(seoSettings) {
const tags = [];
const robotsDirectives = [];
if (seoSettings.seo_meta_noindex) robotsDirectives.push('noindex');
if (seoSettings.seo_meta_nofollow) robotsDirectives.push('nofollow');
if (robotsDirectives.length > 0) {
tags.push(`<meta name="robots" content="${robotsDirectives.join(', ')}" />`);
}
if (seoSettings.seo_meta_noai) {
tags.push('<meta name="robots" content="noai, noimageai" />');
}
return tags.join('\n ');
}
function buildPublicSiteDocument(payload) {
const inlineStyles = composeInlineStyles(payload);
const header = renderBrandHeader(payload.branding);
const footer = renderBrandFooter(payload.branding);
const seoMeta = payload.seoSettings ? buildSeoMetaTags(payload.seoSettings) : '';
return `<!DOCTYPE html>
<html lang="en">
@@ -265,6 +284,7 @@ function buildPublicSiteDocument(payload) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${payload.title}</title>
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
${seoMeta}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
@@ -296,6 +316,21 @@ async function handlePublicSiteRequest(req, res, next) {
return;
}
// Inject SEO meta settings into payload
try {
const seoRows = await db('app_settings')
.where('setting_type', 'seo')
.whereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai'])
.select('setting_key', 'setting_value');
const seoSettings = {};
for (const row of seoRows) {
let val = row.setting_value;
if (typeof val === 'string') { try { val = JSON.parse(val); } catch {} }
seoSettings[row.setting_key] = val;
}
payload.seoSettings = seoSettings;
} catch {}
const document = buildPublicSiteDocument(payload);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
@@ -395,6 +430,22 @@ if (process.env.NODE_ENV === 'development') {
});
}
// robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
app.get('/robots.txt', async (req, res) => {
try {
const robotsTxt = await generateRobotsTxt();
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.status(200).send(robotsTxt);
} catch (error) {
logger.error('Failed to generate robots.txt', { error: error.message });
// Safe default for a private photo platform
res.setHeader('Content-Type', 'text/plain');
res.status(200).send('User-agent: *\nDisallow: /\n');
}
});
// Health check endpoint
app.get('/health', async (req, res) => {
try {
+64
View File
@@ -733,6 +733,70 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
}
});
// Update SEO settings
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
// Validate seo_blocked_ai_agents is an array of strings
if (settings.seo_blocked_ai_agents !== undefined) {
if (!Array.isArray(settings.seo_blocked_ai_agents) ||
!settings.seo_blocked_ai_agents.every(a => typeof a === 'string')) {
return res.status(400).json({ error: 'seo_blocked_ai_agents must be an array of strings' });
}
}
// Validate seo_custom_rules structure
if (settings.seo_custom_rules !== undefined) {
if (!Array.isArray(settings.seo_custom_rules)) {
return res.status(400).json({ error: 'seo_custom_rules must be an array' });
}
for (const rule of settings.seo_custom_rules) {
if (!rule.userAgent || typeof rule.userAgent !== 'string') {
return res.status(400).json({ error: 'Each custom rule must have a userAgent string' });
}
if (!Array.isArray(rule.disallow) || !rule.disallow.every(d => typeof d === 'string')) {
return res.status(400).json({ error: 'Each custom rule must have a disallow array of strings' });
}
}
}
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'seo',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
// Clear robots.txt cache
const { clearRobotsTxtCache } = require('../services/robotsTxtService');
clearRobotsTxtCache();
// Log activity
await db('activity_logs').insert({
activity_type: 'seo_settings_updated',
actor_type: 'admin',
actor_id: req.admin.id,
actor_name: req.admin.username,
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
});
res.json({ message: 'SEO settings updated successfully' });
} catch (error) {
console.error('SEO settings update error:', error);
res.status(500).json({ error: 'Failed to update SEO settings' });
}
});
// Get storage info
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
+7 -2
View File
@@ -12,7 +12,8 @@ router.get('/', async (req, res) => {
.where(function() {
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
.orWhere('setting_key', 'like', 'analytics_%')
.orWhere('setting_key', 'like', 'event_require_%');
.orWhere('setting_key', 'like', 'event_require_%')
.orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']);
})
.select('setting_key', 'setting_value');
});
@@ -76,7 +77,11 @@ router.get('/', async (req, res) => {
event_require_customer_email: settingsObject.event_require_customer_email !== false,
event_require_admin_email: settingsObject.event_require_admin_email !== false,
event_require_event_date: settingsObject.event_require_event_date !== false,
event_require_expiration: settingsObject.event_require_expiration !== false
event_require_expiration: settingsObject.event_require_expiration !== false,
// SEO meta tag flags (safe to expose - these are intended for crawlers)
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
seo_meta_noai: settingsObject.seo_meta_noai === true
};
res.json(publicSettings);
+128
View File
@@ -0,0 +1,128 @@
const { db } = require('../database/db');
let cachedRobotsTxt = null;
let cacheTimestamp = 0;
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
function parseSetting(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
async function getSeoSettings() {
const rows = await db('app_settings')
.where('setting_type', 'seo')
.select('setting_key', 'setting_value');
const settings = {};
for (const row of rows) {
settings[row.setting_key] = parseSetting(row.setting_value);
}
return settings;
}
const SOCIAL_BOTS = [
'Twitterbot',
'facebookexternalhit',
'LinkedInBot',
'Slackbot',
'WhatsApp',
'TelegramBot',
'Discordbot'
];
async function generateRobotsTxt() {
const now = Date.now();
if (cachedRobotsTxt && (now - cacheTimestamp) < CACHE_TTL_MS) {
return cachedRobotsTxt;
}
const settings = await getSeoSettings();
const allowIndexing = settings.seo_allow_indexing === true;
const blockAiCrawlers = settings.seo_block_ai_crawlers !== false;
const blockSocialBots = settings.seo_block_social_bots === true;
const aiAgents = Array.isArray(settings.seo_blocked_ai_agents)
? settings.seo_blocked_ai_agents
: [];
const customRules = Array.isArray(settings.seo_custom_rules)
? settings.seo_custom_rules
: [];
const sitemapUrl = settings.seo_sitemap_url || '';
const lines = [];
// Always block admin and API paths for all agents
lines.push('# Protected paths');
lines.push('User-agent: *');
lines.push('Disallow: /admin');
lines.push('Disallow: /api');
lines.push('');
if (!allowIndexing) {
// Block everything for all agents
lines.push('# Indexing disabled - block all crawlers');
lines.push('User-agent: *');
lines.push('Disallow: /');
lines.push('');
}
// Block AI crawlers if enabled
if (blockAiCrawlers && aiAgents.length > 0) {
lines.push('# AI/LLM crawler blocking');
for (const agent of aiAgents) {
lines.push(`User-agent: ${agent}`);
lines.push('Disallow: /');
lines.push('');
}
}
// Block social bots if enabled
if (blockSocialBots) {
lines.push('# Social media bot blocking');
for (const bot of SOCIAL_BOTS) {
lines.push(`User-agent: ${bot}`);
lines.push('Disallow: /');
lines.push('');
}
}
// Custom rules
if (customRules.length > 0) {
lines.push('# Custom rules');
for (const rule of customRules) {
if (rule.userAgent && Array.isArray(rule.disallow)) {
lines.push(`User-agent: ${rule.userAgent}`);
for (const path of rule.disallow) {
lines.push(`Disallow: ${path}`);
}
lines.push('');
}
}
}
// Sitemap
if (sitemapUrl) {
lines.push(`Sitemap: ${sitemapUrl}`);
lines.push('');
}
const result = lines.join('\n');
cachedRobotsTxt = result;
cacheTimestamp = now;
return result;
}
function clearRobotsTxtCache() {
cachedRobotsTxt = null;
cacheTimestamp = 0;
}
module.exports = {
generateRobotsTxt,
clearRobotsTxtCache
};