diff --git a/.gitignore b/.gitignore index 61fde2c..aa1dc1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules/ dist/ + +# tsc -b (project references) + emitted vite config +*.tsbuildinfo +vite.config.js +vite.config.d.ts data/ *.db .env diff --git a/server/database.ts b/server/database.ts index a88b63e..de0adf3 100644 --- a/server/database.ts +++ b/server/database.ts @@ -28,6 +28,10 @@ export interface User { created_at: string; } +/** Default labels for backwards compatibility and empty input */ +export const DEFAULT_ITEM_LABEL = 'Activiteit'; +export const DEFAULT_ITEM_LABEL_PLURAL = 'Activiteiten'; + export interface Questionnaire { id: number; uuid: string; @@ -38,10 +42,22 @@ export interface Questionnaire { is_private: boolean; created_by: number; created_at: string; + item_label: string | null; + item_label_plural: string | null; creator_name?: string; activity_count?: number; } +/** Resolved singular/plural labels for UI and API messages (max 60 chars each). */ +export function resolveItemLabels(q: { + item_label?: string | null; + item_label_plural?: string | null; +}): { singular: string; plural: string } { + const singular = (q.item_label?.trim() || DEFAULT_ITEM_LABEL).slice(0, 60); + const plural = (q.item_label_plural?.trim() || DEFAULT_ITEM_LABEL_PLURAL).slice(0, 60); + return { singular, plural }; +} + export interface Participant { id: number; questionnaire_id: number; @@ -129,6 +145,18 @@ export function initializeDatabase(): void { } catch (e) { // Column already exists, ignore } + + // Migration: configurable inventory item name (singular / plural) per questionnaire + try { + db.exec(`ALTER TABLE questionnaires ADD COLUMN item_label TEXT DEFAULT '${DEFAULT_ITEM_LABEL.replace(/'/g, "''")}'`); + } catch (e) { + // Column already exists, ignore + } + try { + db.exec(`ALTER TABLE questionnaires ADD COLUMN item_label_plural TEXT DEFAULT '${DEFAULT_ITEM_LABEL_PLURAL.replace(/'/g, "''")}'`); + } catch (e) { + // Column already exists, ignore + } // Participants table db.exec(` @@ -267,10 +295,20 @@ export const userOps = { // Questionnaire operations export const questionnaireOps = { - create: (uuid: string, slug: string, title: string, description: string | null, ogImage: string | null, isPrivate: boolean, createdBy: number): number => { + create: ( + uuid: string, + slug: string, + title: string, + description: string | null, + ogImage: string | null, + isPrivate: boolean, + createdBy: number, + itemLabel: string, + itemLabelPlural: string + ): number => { const result = db.prepare( - 'INSERT INTO questionnaires (uuid, slug, title, description, og_image, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(uuid, slug, title, description, ogImage, isPrivate ? 1 : 0, createdBy); + 'INSERT INTO questionnaires (uuid, slug, title, description, og_image, is_private, created_by, item_label, item_label_plural) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(uuid, slug, title, description, ogImage, isPrivate ? 1 : 0, createdBy, itemLabel, itemLabelPlural); return result.lastInsertRowid as number; }, @@ -296,8 +334,19 @@ export const questionnaireOps = { `).all() as Questionnaire[]; }, - 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); + update: ( + id: number, + slug: string, + title: string, + description: string | null, + ogImage: string | null, + isPrivate: boolean, + itemLabel: string, + itemLabelPlural: string + ): void => { + db.prepare( + 'UPDATE questionnaires SET slug = ?, title = ?, description = ?, og_image = ?, is_private = ?, item_label = ?, item_label_plural = ? WHERE id = ?' + ).run(slug, title, description, ogImage, isPrivate ? 1 : 0, itemLabel, itemLabelPlural, id); }, delete: (id: number): void => { diff --git a/server/index.ts b/server/index.ts index f01bb88..b38900f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -6,7 +6,7 @@ import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; -import { initializeDatabase, questionnaireOps, participantOps } from './database.js'; +import { initializeDatabase, questionnaireOps, participantOps, resolveItemLabels } from './database.js'; import authRoutes from './routes/auth.js'; import adminRoutes from './routes/admin.js'; import questionnaireRoutes from './routes/questionnaire.js'; @@ -108,7 +108,9 @@ if (isProduction) { 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 { plural } = resolveItemLabels(questionnaire); + const description = + questionnaire.description || `Voeg ${plural.toLowerCase()} toe en stem!`; // Make image URL absolute if it's a relative path let ogImageUrl = questionnaire.og_image; @@ -122,7 +124,7 @@ if (isProduction) { html = html.replace('', `${ogTags}`); // Update title - html = html.replace(/.*?<\/title>/, `<title>${title} - Activiteiten Inventaris`); + html = html.replace(/.*?<\/title>/, `<title>${title}`); res.send(html); }); @@ -153,8 +155,10 @@ if (isProduction) { // Get first name from participant name const firstName = participant.name.trim().split(/\s+/)[0] || participant.name; - const description = questionnaire.description - || `Hoi ${firstName}! Voeg je ideeën toe en stem op activiteiten.`; + const { plural } = resolveItemLabels(questionnaire); + const description = + questionnaire.description || + `Hoi ${firstName}! Voeg je ideeën toe en stem op ${plural.toLowerCase()}.`; // Make image URL absolute if it's a relative path let ogImageUrl = questionnaire.og_image; diff --git a/server/routes/admin.ts b/server/routes/admin.ts index f326d7a..2fb5ca4 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -5,7 +5,14 @@ import multer, { FileFilterCallback } from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; -import { userOps, questionnaireOps, activityOps, participantOps } from '../database.js'; +import { + userOps, + questionnaireOps, + activityOps, + participantOps, + resolveItemLabels, + DEFAULT_ITEM_LABEL, +} from '../database.js'; import { requireAuth } from '../middleware/auth.js'; const __filename = fileURLToPath(import.meta.url); @@ -74,9 +81,17 @@ function isValidSlug(slug: string): boolean { return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug); } +function parseItemLabelsFromBody(itemLabel: unknown, itemLabelPlural: unknown): { singular: string; plural: string } { + return resolveItemLabels({ + item_label: typeof itemLabel === 'string' ? itemLabel : null, + item_label_plural: typeof itemLabelPlural === 'string' ? itemLabelPlural : null, + }); +} + // Create questionnaire router.post('/questionnaires', (req: Request, res: Response) => { - const { title, description, slug, ogImage, isPrivate } = req.body; + const { title, description, slug, ogImage, isPrivate, itemLabel, itemLabelPlural } = req.body; + const { singular, plural } = parseItemLabelsFromBody(itemLabel, itemLabelPlural); if (!title?.trim()) { res.status(400).json({ error: 'Titel is verplicht' }); @@ -106,7 +121,17 @@ router.post('/questionnaires', (req: Request, res: Response) => { } const uuid = uuidv4(); - const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, ogImage?.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, + singular, + plural + ); const questionnaire = questionnaireOps.findById(id); res.json({ success: true, questionnaire }); @@ -127,7 +152,8 @@ router.get('/questionnaires/:id', (req: Request, res: Response) => { // Update questionnaire router.put('/questionnaires/:id', (req: Request, res: Response) => { - const { title, description, slug, ogImage, isPrivate } = req.body; + const { title, description, slug, ogImage, isPrivate, itemLabel, itemLabelPlural } = req.body; + const { singular, plural } = parseItemLabelsFromBody(itemLabel, itemLabelPlural); const questionnaire = questionnaireOps.findById(parseInt(req.params.id)); if (!questionnaire) { @@ -162,7 +188,16 @@ router.put('/questionnaires/:id', (req: Request, res: Response) => { return; } - questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate); + questionnaireOps.update( + questionnaire.id, + cleanSlug, + title.trim(), + description?.trim() || null, + ogImage?.trim() || null, + !!isPrivate, + singular, + plural + ); res.json({ success: true }); }); @@ -267,12 +302,15 @@ router.put('/activities/:id', (req: Request, res: Response) => { const activity = activityOps.findById(parseInt(req.params.id)); if (!activity) { - res.status(404).json({ error: 'Activiteit niet gevonden' }); + res.status(404).json({ error: `${DEFAULT_ITEM_LABEL} niet gevonden` }); return; } + + const q = questionnaireOps.findById(activity.questionnaire_id); + const itemSingular = q ? resolveItemLabels(q).singular : DEFAULT_ITEM_LABEL; if (!name?.trim()) { - res.status(400).json({ error: 'Naam activiteit is verplicht' }); + res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` }); return; } diff --git a/server/routes/questionnaire.ts b/server/routes/questionnaire.ts index fb2fe1c..aa2fbf6 100644 --- a/server/routes/questionnaire.ts +++ b/server/routes/questionnaire.ts @@ -1,5 +1,14 @@ import { Router, Request, Response } from 'express'; -import { questionnaireOps, activityOps, voteOps, commentOps, participantOps, Comment, Questionnaire } from '../database.js'; +import { + questionnaireOps, + activityOps, + voteOps, + commentOps, + participantOps, + Comment, + Questionnaire, + resolveItemLabels, +} from '../database.js'; // Helper to check if user can participate (write access) function canParticipate(questionnaire: Questionnaire, req: Request): { canWrite: boolean; participantName: string | null } { @@ -152,8 +161,9 @@ router.post('/:slug/activities', (req: Request, res: Response) => { return; } + const { singular: itemSingular } = resolveItemLabels(questionnaire); if (!name?.trim()) { - res.status(400).json({ error: 'Naam activiteit is verplicht' }); + res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` }); return; } @@ -186,20 +196,21 @@ router.put('/:slug/activities/:activityId', (req: Request, res: Response) => { return; } + const { singular: itemSingular, plural: itemPlural } = resolveItemLabels(questionnaire); const activity = activityOps.findById(parseInt(req.params.activityId)); if (!activity || activity.questionnaire_id !== questionnaire.id) { - res.status(404).json({ error: 'Activiteit niet gevonden' }); + res.status(404).json({ error: `${itemSingular} niet gevonden` }); return; } // Check if the user is the one who added the activity if (activity.added_by !== participantName) { - res.status(403).json({ error: 'Je kunt alleen je eigen activiteiten bewerken' }); + res.status(403).json({ error: `Je kunt alleen je eigen ${itemPlural.toLowerCase()} bewerken` }); return; } if (!name?.trim()) { - res.status(400).json({ error: 'Naam activiteit is verplicht' }); + res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` }); return; } @@ -231,9 +242,10 @@ router.post('/:slug/activities/:activityId/vote', (req: Request, res: Response) return; } + const { singular: itemSingular } = resolveItemLabels(questionnaire); const activity = activityOps.findById(parseInt(req.params.activityId)); if (!activity || activity.questionnaire_id !== questionnaire.id) { - res.status(404).json({ error: 'Activiteit niet gevonden' }); + res.status(404).json({ error: `${itemSingular} niet gevonden` }); return; } @@ -286,9 +298,10 @@ router.get('/:slug/activities/:activityId/comments', (req: Request, res: Respons return; } + const { singular: itemSingular } = resolveItemLabels(questionnaire); const activity = activityOps.findById(parseInt(req.params.activityId)); if (!activity || activity.questionnaire_id !== questionnaire.id) { - res.status(404).json({ error: 'Activiteit niet gevonden' }); + res.status(404).json({ error: `${itemSingular} niet gevonden` }); return; } @@ -337,9 +350,10 @@ router.post('/:slug/activities/:activityId/comments', (req: Request, res: Respon return; } + const { singular: itemSingular } = resolveItemLabels(questionnaire); const activity = activityOps.findById(parseInt(req.params.activityId)); if (!activity || activity.questionnaire_id !== questionnaire.id) { - res.status(404).json({ error: 'Activiteit niet gevonden' }); + res.status(404).json({ error: `${itemSingular} niet gevonden` }); return; } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 8cb530e..7bb64c2 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -11,6 +11,11 @@ interface Questionnaire { creator_name: string created_at: string activity_count: number + item_label_plural: string | null +} + +function pluralLabel(q: Questionnaire) { + return (q.item_label_plural?.trim() || 'Activiteiten').slice(0, 60) } export function Dashboard() { @@ -72,7 +77,7 @@ export function Dashboard() { Privé )} - {q.activity_count} activiteiten + {q.activity_count} {pluralLabel(q).toLowerCase()} @@ -111,7 +116,7 @@ export function Dashboard() {
📋

Nog geen vragenlijsten

-

Maak je eerste vragenlijst om activiteiten en stemmen te verzamelen.

+

Maak je eerste vragenlijst om voorstellen te inventariseren en stemmen te verzamelen.

@@ -396,14 +407,14 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } = {/* Add Activity */} {canWrite && (
-

Activiteit Toevoegen

+

{labels.singular} toevoegen

setActivityInput(e.target.value)} - placeholder="Naam activiteit" + placeholder={`Naam ${labels.singular.toLowerCase()}`} className="flex-1 px-4 py-2 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" required /> @@ -448,7 +459,7 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } = )} {/* Activities List */} -

Activiteiten

+

{labels.plural}

{activities.length > 0 ? (
@@ -529,7 +540,7 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } =
) : (
-

Nog geen activiteiten. Wees de eerste om er één toe te voegen!

+

Nog geen {labels.plural.toLowerCase()}. Wees de eerste om er één toe te voegen!

)}
@@ -619,7 +630,7 @@ export function PublicQuestionnaire({ accessToken }: { accessToken?: string } = > {/* Header */}
-

Activiteit Bewerken

+

{labels.singular} bewerken

{questionnaire.is_private - ? 'Iedereen met deze link kan activiteiten en stemmen bekijken, maar niet deelnemen.' - : 'Deel deze URL met mensen die activiteiten moeten toevoegen en stemmen.'} + ? `Iedereen met deze link kan ${labels.plural.toLowerCase()} en stemmen bekijken, maar niet deelnemen.` + : `Deel deze URL met mensen die ${labels.plural.toLowerCase()} moeten toevoegen en stemmen.`}

@@ -429,14 +446,14 @@ export function QuestionnaireDetail() { )} {/* Activities */} -

Activiteiten ({activities.length})

+

{labels.plural} ({activities.length})

{activities.length > 0 ? (
- + @@ -480,7 +497,7 @@ export function QuestionnaireDetail() { ) : (
-

Er zijn nog geen activiteiten toegevoegd. Deel de vragenlijst URL zodat mensen activiteiten kunnen toevoegen.

+

Er zijn nog geen {labels.plural.toLowerCase()} toegevoegd. Deel de vragenlijst-URL zodat mensen {labels.plural.toLowerCase()} kunnen toevoegen.

)} @@ -506,7 +523,7 @@ export function QuestionnaireDetail() { > {/* Header */}
-

Activiteit Bewerken

+

{labels.singular} bewerken

+
+
+ + setItemLabel(e.target.value)} + maxLength={60} + 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="bijv. Activiteit, Muzieknummer" + /> +

+ Zo wordt één voorstel getoond in formulieren en knoppen. +

+
+
+ + setItemLabelPlural(e.target.value)} + maxLength={60} + 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="bijv. Activiteiten, Muzieknummers" + /> +

+ Voor lijsten en tellers (bijv. "12 muzieknummers"). +

+
+
+
Activiteit{labels.singular} Toegevoegd door Voor Tegen