Add OG image support for WhatsApp/social media link previews
This commit is contained in:
@@ -34,6 +34,7 @@ export interface Questionnaire {
|
|||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
og_image: string | null;
|
||||||
is_private: boolean;
|
is_private: boolean;
|
||||||
created_by: number;
|
created_by: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -121,6 +122,13 @@ export function initializeDatabase(): void {
|
|||||||
// Column already exists, ignore
|
// 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
|
// Participants table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS participants (
|
CREATE TABLE IF NOT EXISTS participants (
|
||||||
@@ -251,10 +259,10 @@ export const userOps = {
|
|||||||
|
|
||||||
// Questionnaire operations
|
// Questionnaire operations
|
||||||
export const questionnaireOps = {
|
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(
|
const result = db.prepare(
|
||||||
'INSERT INTO questionnaires (uuid, slug, title, description, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?)'
|
'INSERT INTO questionnaires (uuid, slug, title, description, og_image, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(uuid, slug, title, description, isPrivate ? 1 : 0, createdBy);
|
).run(uuid, slug, title, description, ogImage, isPrivate ? 1 : 0, createdBy);
|
||||||
return result.lastInsertRowid as number;
|
return result.lastInsertRowid as number;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -280,8 +288,8 @@ export const questionnaireOps = {
|
|||||||
`).all() as Questionnaire[];
|
`).all() as Questionnaire[];
|
||||||
},
|
},
|
||||||
|
|
||||||
update: (id: number, slug: string, title: string, description: string | null, isPrivate: boolean): void => {
|
update: (id: number, slug: string, title: string, description: string | null, ogImage: 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);
|
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 => {
|
delete: (id: number): void => {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import session from 'express-session';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import { initializeDatabase } from './database.js';
|
import { initializeDatabase, questionnaireOps } from './database.js';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
import questionnaireRoutes from './routes/questionnaire.js';
|
import questionnaireRoutes from './routes/questionnaire.js';
|
||||||
@@ -63,7 +64,47 @@ if (isProduction) {
|
|||||||
const clientPath = path.join(__dirname, '../client');
|
const clientPath = path.join(__dirname, '../client');
|
||||||
app.use(express.static(clientPath));
|
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 = `
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="${pageUrl}" />
|
||||||
|
<meta property="og:title" content="${title}" />
|
||||||
|
<meta property="og:description" content="${description}" />
|
||||||
|
${questionnaire.og_image ? `<meta property="og:image" content="${questionnaire.og_image}" />` : ''}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="${title}" />
|
||||||
|
<meta name="twitter:description" content="${description}" />
|
||||||
|
${questionnaire.og_image ? `<meta name="twitter:image" content="${questionnaire.og_image}" />` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Insert OG tags before </head>
|
||||||
|
html = html.replace('</head>', `${ogTags}</head>`);
|
||||||
|
|
||||||
|
// Update title
|
||||||
|
html = html.replace(/<title>.*?<\/title>/, `<title>${title} - Activiteiten Inventaris</title>`);
|
||||||
|
|
||||||
|
res.send(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA fallback for other routes
|
||||||
app.get('*', (_req, res) => {
|
app.get('*', (_req, res) => {
|
||||||
res.sendFile(path.join(clientPath, 'index.html'));
|
res.sendFile(path.join(clientPath, 'index.html'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function isValidSlug(slug: string): boolean {
|
|||||||
|
|
||||||
// Create questionnaire
|
// Create questionnaire
|
||||||
router.post('/questionnaires', (req: Request, res: Response) => {
|
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()) {
|
if (!title?.trim()) {
|
||||||
res.status(400).json({ error: 'Titel is verplicht' });
|
res.status(400).json({ error: 'Titel is verplicht' });
|
||||||
@@ -52,7 +52,7 @@ router.post('/questionnaires', (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uuid = uuidv4();
|
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);
|
const questionnaire = questionnaireOps.findById(id);
|
||||||
|
|
||||||
res.json({ success: true, questionnaire });
|
res.json({ success: true, questionnaire });
|
||||||
@@ -73,7 +73,7 @@ router.get('/questionnaires/:id', (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// Update questionnaire
|
// Update questionnaire
|
||||||
router.put('/questionnaires/:id', (req: Request, res: Response) => {
|
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));
|
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
|
||||||
|
|
||||||
if (!questionnaire) {
|
if (!questionnaire) {
|
||||||
@@ -108,7 +108,7 @@ router.put('/questionnaires/:id', (req: Request, res: Response) => {
|
|||||||
return;
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function QuestionnaireForm() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
const [ogImage, setOgImage] = useState('')
|
||||||
const [isPrivate, setIsPrivate] = useState(false)
|
const [isPrivate, setIsPrivate] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [slugError, setSlugError] = useState('')
|
const [slugError, setSlugError] = useState('')
|
||||||
@@ -28,6 +29,7 @@ export function QuestionnaireForm() {
|
|||||||
setTitle(data.questionnaire.title)
|
setTitle(data.questionnaire.title)
|
||||||
setSlug(data.questionnaire.slug)
|
setSlug(data.questionnaire.slug)
|
||||||
setDescription(data.questionnaire.description || '')
|
setDescription(data.questionnaire.description || '')
|
||||||
|
setOgImage(data.questionnaire.og_image || '')
|
||||||
setIsPrivate(!!data.questionnaire.is_private)
|
setIsPrivate(!!data.questionnaire.is_private)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,7 +88,7 @@ export function QuestionnaireForm() {
|
|||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ title, slug, description, isPrivate }),
|
body: JSON.stringify({ title, slug, description, ogImage, isPrivate }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -180,6 +182,37 @@ export function QuestionnaireForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ogImage" className="block text-sm font-medium text-text mb-2">
|
||||||
|
Preview Afbeelding (voor WhatsApp/Social Media)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="ogImage"
|
||||||
|
value={ogImage}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-text-faint">
|
||||||
|
URL naar een afbeelding die getoond wordt bij het delen van de link op WhatsApp, Facebook, etc.
|
||||||
|
Aanbevolen formaat: 1200x630 pixels.
|
||||||
|
</p>
|
||||||
|
{ogImage && (
|
||||||
|
<div className="mt-3 p-3 bg-bg-input rounded-lg border border-border">
|
||||||
|
<p className="text-xs text-text-muted mb-2">Preview:</p>
|
||||||
|
<img
|
||||||
|
src={ogImage}
|
||||||
|
alt="Preview"
|
||||||
|
className="max-h-32 rounded border border-border"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-4 bg-bg-input rounded-lg border border-border">
|
<div className="flex items-center gap-3 p-4 bg-bg-input rounded-lg border border-border">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
Reference in New Issue
Block a user