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:
@@ -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();
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user