From a7b2169ec84aed897d9e973b6e3ece2110ba4e6f Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Tue, 6 Jan 2026 02:03:51 +0100 Subject: [PATCH] Add OG image support for WhatsApp/social media link previews --- server/database.ts | 18 +++++++++---- server/index.ts | 45 +++++++++++++++++++++++++++++++-- server/routes/admin.ts | 8 +++--- src/pages/QuestionnaireForm.tsx | 35 ++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/server/database.ts b/server/database.ts index 36cebb5..548a269 100644 --- a/server/database.ts +++ b/server/database.ts @@ -34,6 +34,7 @@ export interface Questionnaire { slug: string; title: string; description: string | null; + og_image: string | null; is_private: boolean; created_by: number; created_at: string; @@ -121,6 +122,13 @@ export function initializeDatabase(): void { // Column already exists, ignore } + // Migration: Add og_image column if it doesn't exist + try { + db.exec(`ALTER TABLE questionnaires ADD COLUMN og_image TEXT`); + } catch (e) { + // Column already exists, ignore + } + // Participants table db.exec(` CREATE TABLE IF NOT EXISTS participants ( @@ -251,10 +259,10 @@ export const userOps = { // Questionnaire operations export const questionnaireOps = { - create: (uuid: string, slug: string, title: string, description: string | null, isPrivate: boolean, createdBy: number): number => { + create: (uuid: string, slug: string, title: string, description: string | null, ogImage: string | null, isPrivate: boolean, createdBy: number): number => { const result = db.prepare( - 'INSERT INTO questionnaires (uuid, slug, title, description, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?)' - ).run(uuid, slug, title, description, isPrivate ? 1 : 0, createdBy); + 'INSERT INTO questionnaires (uuid, slug, title, description, og_image, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(uuid, slug, title, description, ogImage, isPrivate ? 1 : 0, createdBy); return result.lastInsertRowid as number; }, @@ -280,8 +288,8 @@ export const questionnaireOps = { `).all() as Questionnaire[]; }, - update: (id: number, slug: string, title: string, description: string | null, isPrivate: boolean): void => { - db.prepare('UPDATE questionnaires SET slug = ?, title = ?, description = ?, is_private = ? WHERE id = ?').run(slug, title, description, isPrivate ? 1 : 0, id); + update: (id: number, slug: string, title: string, description: string | null, ogImage: string | null, isPrivate: boolean): void => { + db.prepare('UPDATE questionnaires SET slug = ?, title = ?, description = ?, og_image = ?, is_private = ? WHERE id = ?').run(slug, title, description, ogImage, isPrivate ? 1 : 0, id); }, delete: (id: number): void => { diff --git a/server/index.ts b/server/index.ts index e4a648e..e34ea6f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,9 +3,10 @@ import session from 'express-session'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import path from 'path'; +import fs from 'fs'; import { fileURLToPath } from 'url'; -import { initializeDatabase } from './database.js'; +import { initializeDatabase, questionnaireOps } from './database.js'; import authRoutes from './routes/auth.js'; import adminRoutes from './routes/admin.js'; import questionnaireRoutes from './routes/questionnaire.js'; @@ -63,7 +64,47 @@ if (isProduction) { const clientPath = path.join(__dirname, '../client'); app.use(express.static(clientPath)); - // SPA fallback + // Dynamic OG meta tags for questionnaire pages + app.get('/q/:slug', (req, res) => { + const questionnaire = questionnaireOps.findBySlug(req.params.slug); + const indexPath = path.join(clientPath, 'index.html'); + + if (!questionnaire) { + res.sendFile(indexPath); + return; + } + + // Read the index.html template + let html = fs.readFileSync(indexPath, 'utf-8'); + + // Build meta tags + const baseUrl = `${req.protocol}://${req.get('host')}`; + const pageUrl = `${baseUrl}/q/${questionnaire.slug}`; + const title = questionnaire.title; + const description = questionnaire.description || 'Activiteiten Inventaris - Voeg activiteiten toe en stem!'; + + const ogTags = ` + + + + + ${questionnaire.og_image ? `` : ''} + + + + ${questionnaire.og_image ? `` : ''} + `; + + // Insert OG tags before + html = html.replace('', `${ogTags}`); + + // Update title + html = html.replace(/.*?<\/title>/, `<title>${title} - Activiteiten Inventaris`); + + res.send(html); + }); + + // SPA fallback for other routes app.get('*', (_req, res) => { res.sendFile(path.join(clientPath, 'index.html')); }); diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 30d28a7..5cb3a63 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -22,7 +22,7 @@ function isValidSlug(slug: string): boolean { // Create questionnaire router.post('/questionnaires', (req: Request, res: Response) => { - const { title, description, slug, isPrivate } = req.body; + const { title, description, slug, ogImage, isPrivate } = req.body; if (!title?.trim()) { res.status(400).json({ error: 'Titel is verplicht' }); @@ -52,7 +52,7 @@ router.post('/questionnaires', (req: Request, res: Response) => { } const uuid = uuidv4(); - const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate, req.session.user!.id); + const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate, req.session.user!.id); const questionnaire = questionnaireOps.findById(id); res.json({ success: true, questionnaire }); @@ -73,7 +73,7 @@ router.get('/questionnaires/:id', (req: Request, res: Response) => { // Update questionnaire router.put('/questionnaires/:id', (req: Request, res: Response) => { - const { title, description, slug, isPrivate } = req.body; + const { title, description, slug, ogImage, isPrivate } = req.body; const questionnaire = questionnaireOps.findById(parseInt(req.params.id)); if (!questionnaire) { @@ -108,7 +108,7 @@ router.put('/questionnaires/:id', (req: Request, res: Response) => { return; } - questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate); + questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate); res.json({ success: true }); }); diff --git a/src/pages/QuestionnaireForm.tsx b/src/pages/QuestionnaireForm.tsx index 22e0c6d..58c6383 100644 --- a/src/pages/QuestionnaireForm.tsx +++ b/src/pages/QuestionnaireForm.tsx @@ -9,6 +9,7 @@ export function QuestionnaireForm() { const [title, setTitle] = useState('') const [slug, setSlug] = useState('') const [description, setDescription] = useState('') + const [ogImage, setOgImage] = useState('') const [isPrivate, setIsPrivate] = useState(false) const [error, setError] = useState('') const [slugError, setSlugError] = useState('') @@ -28,6 +29,7 @@ export function QuestionnaireForm() { setTitle(data.questionnaire.title) setSlug(data.questionnaire.slug) setDescription(data.questionnaire.description || '') + setOgImage(data.questionnaire.og_image || '') setIsPrivate(!!data.questionnaire.is_private) } } catch (error) { @@ -86,7 +88,7 @@ export function QuestionnaireForm() { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', - body: JSON.stringify({ title, slug, description, isPrivate }), + body: JSON.stringify({ title, slug, description, ogImage, isPrivate }), }) const data = await res.json() @@ -180,6 +182,37 @@ export function QuestionnaireForm() { /> +
+ + setOgImage(e.target.value)} + className="w-full px-4 py-3 bg-bg-input border border-border rounded-lg text-text placeholder-text-faint focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-colors" + placeholder="https://voorbeeld.nl/afbeelding.jpg" + /> +

+ URL naar een afbeelding die getoond wordt bij het delen van de link op WhatsApp, Facebook, etc. + Aanbevolen formaat: 1200x630 pixels. +

+ {ogImage && ( +
+

Preview:

+ Preview { + (e.target as HTMLImageElement).style.display = 'none' + }} + /> +
+ )} +
+