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} - Activiteiten Inventaris`);
+ html = html.replace(/.*?<\/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
@@ -429,14 +446,14 @@ export function QuestionnaireDetail() {
)}
{/* Activities */}
-
Activiteiten ({activities.length})
+
{labels.plural} ({activities.length})
{activities.length > 0 ? (
- | Activiteit |
+ {labels.singular} |
Toegevoegd door |
Voor |
Tegen |
@@ -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").
+
+
+
+