From 96d3e3f602e04d89e03fb068546c7c7f9bbcf11b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 11 Sep 2026 00:27:41 +0200 Subject: [PATCH] feat(events): add weekly browsing and personal registrations --- src/app/(site)/events/page.tsx | 206 +++++++++++++++------ src/components/public/local-event-time.tsx | 18 ++ src/lib/services/public-events.test.ts | 65 +++++++ src/lib/services/public-events.ts | 176 ++++++++++++++++++ src/messages/en.json | 88 ++++++++- src/messages/it.json | 88 ++++++++- src/messages/nl.json | 88 ++++++++- 7 files changed, 655 insertions(+), 74 deletions(-) create mode 100644 src/components/public/local-event-time.tsx create mode 100644 src/lib/services/public-events.test.ts create mode 100644 src/lib/services/public-events.ts diff --git a/src/app/(site)/events/page.tsx b/src/app/(site)/events/page.tsx index f916b050..4eb9d97e 100644 --- a/src/app/(site)/events/page.tsx +++ b/src/app/(site)/events/page.tsx @@ -1,17 +1,13 @@ -import { count, desc, eq, inArray } from "drizzle-orm"; import type { Metadata } from "next"; import Image from "next/image"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; +import { LocalEventTime } from "@/components/public/local-event-time"; import { ContentCard, EmptyState } from "@/components/public/ui"; -import { - db, - WebsiteEvent, - WebsiteEventRegistration, - WebsiteEventType, -} from "@/lib/db"; +import { auth } from "@/lib/auth"; import { excerpt, slugify } from "@/lib/format"; -import { formatDate } from "@/lib/format-date"; +import { logger } from "@/lib/logger"; +import { loadPublicEvents } from "@/lib/services/public-events"; export async function generateMetadata(): Promise { const t = await getTranslations("pages.events"); @@ -26,62 +22,123 @@ export async function generateMetadata(): Promise { }; } -export default async function EventsPage() { +export default async function EventsPage({ + searchParams, +}: { + searchParams: Promise<{ + status?: string; + week?: string; + mine?: string; + page?: string; + }>; +}) { const t = await getTranslations("pages.events"); - const eventRows = await db - .select({ - id: WebsiteEvent.id, - title: WebsiteEvent.title, - description: WebsiteEvent.description, - status: WebsiteEvent.status, - image: WebsiteEvent.image, - startsAt: WebsiteEvent.startsAt, - maxPlayers: WebsiteEvent.maxPlayers, - typeName: WebsiteEventType.name, - typeColor: WebsiteEventType.color, - typeSlug: WebsiteEventType.slug, - }) - .from(WebsiteEvent) - .innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id)) - .where(inArray(WebsiteEvent.status, ["published", "completed"])) - .orderBy(desc(WebsiteEvent.startsAt)) - .limit(50) - .catch(() => []); - - const eventIds = eventRows.map((e) => e.id); - const regCounts = - eventIds.length > 0 - ? await db - .select({ - eventId: WebsiteEventRegistration.eventId, - value: count(), - }) - .from(WebsiteEventRegistration) - .where(inArray(WebsiteEventRegistration.eventId, eventIds)) - .groupBy(WebsiteEventRegistration.eventId) - .catch(() => []) - : []; - const countByEvent = new Map( - regCounts.map((r) => [r.eventId, Number(r.value)]), + const params = await searchParams; + const session = await auth(); + const currentWeek = new Date(); + currentWeek.setUTCDate( + currentWeek.getUTCDate() - ((currentWeek.getUTCDay() + 6) % 7), ); - - const events = eventRows.map((event) => ({ - ...event, - type: { - name: event.typeName, - color: event.typeColor, - slug: event.typeSlug, - }, - _count: { registrations: countByEvent.get(event.id) ?? 0 }, - })); - + const weeklyHref = (offset: number) => { + const date = new Date( + result?.week ? `${result.week}T00:00:00Z` : currentWeek, + ); + date.setUTCDate(date.getUTCDate() + offset * 7); + return ( + "/events?" + + new URLSearchParams({ + status: result?.status ?? "all", + mine: params.mine ?? "", + week: date.toISOString().slice(0, 10), + }) + ); + }; + const result = await loadPublicEvents({ + status: params.status, + week: params.week, + mine: params.mine === "1", + userId: Number(session?.user?.id) || undefined, + page: Number(params.page ?? 1), + }).catch((error) => { + logger.error("Public events query failed", { module: "events", error }); + return null; + }); + const events = result?.events ?? []; + const href = (page: number) => + `/events?${new URLSearchParams({ status: result?.status ?? "all", week: result?.week ?? "", mine: params.mine ?? "", page: String(page) })}`; return (
+ +
+ + + {session?.user && ( + + )} + + + {t("reset")} + +
+ +

{t("timeZoneHint")}

+ {params.mine === "1" && !session?.user && ( +

{t("loginForRegistrations")}

+ )} +
- {events.length === 0 ? ( + {!result ? ( +

{t("loadError")}

+ ) : events.length === 0 ? ( {t("empty")} ) : (
@@ -150,9 +207,8 @@ export default async function EventsPage() { style={{ margin: 0, fontSize: "0.75rem" }} > {event.type.name} - {event.status === "completed" - ? ` · ${t("statusCompleted")}` - : ""} + {` · ${t(event.phase === "completed" ? "statusCompleted" : event.phase === "upcoming" ? "statusUpcoming" : "statusOngoing")}`}{" "} + {event.registered && ` · ${t("youRegistered")}`}

{event.title} @@ -161,7 +217,7 @@ export default async function EventsPage() { className="muted" style={{ margin: 0, fontSize: "0.85rem" }} > - {formatDate(event.startsAt, "datetime", "")} +

)} + {result && ( + +

+ + )}

); } diff --git a/src/components/public/local-event-time.tsx b/src/components/public/local-event-time.tsx new file mode 100644 index 00000000..b44ef65a --- /dev/null +++ b/src/components/public/local-event-time.tsx @@ -0,0 +1,18 @@ +"use client"; +import { useLocale } from "next-intl"; +import { useEffect, useState } from "react"; +export function LocalEventTime({ value }: { value: string }) { + const locale = useLocale(); + const [text, setText] = useState( + `${value.slice(0, 16).replace("T", " ")} UTC`, + ); + useEffect(() => { + setText( + new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)), + ); + }, [locale, value]); + return ; +} diff --git a/src/lib/services/public-events.test.ts b/src/lib/services/public-events.test.ts new file mode 100644 index 00000000..448826a9 --- /dev/null +++ b/src/lib/services/public-events.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + total: 60, + fail: false, + queries: [] as { sql: string; params: unknown[] }[], +})); +vi.mock("@/lib/db", async () => { + const schema = await import("@/db/schema"); + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + return { + ...schema, + db: drizzle(async (sql, params) => { + if (state.fail) throw Error("unavailable"); + state.queries.push({ sql, params }); + return { rows: sql.includes("count(*)") ? [[state.total]] : [] }; + }), + }; +}); + +import { loadPublicEvents } from "./public-events"; + +describe("public events", () => { + beforeEach(() => { + state.queries = []; + state.fail = false; + state.total = 60; + }); + it("limits public upcoming events before pagination", async () => { + expect( + await loadPublicEvents({ + status: "upcoming", + page: 2, + now: new Date("2026-09-11"), + }), + ).toMatchObject({ page: 2, lastPage: 3 }); + expect(state.queries[1].params.slice(-2)).toEqual([24, 24]); + expect(state.queries[0].params.slice(0, 3)).toEqual([ + "published", + "completed", + "published", + ]); + expect(state.queries[0].sql).toContain("`starts_at` > ?"); + }); + it("filters ongoing events using both start and end", async () => { + await loadPublicEvents({ status: "ongoing" }); + expect(state.queries[0].sql).toContain("`starts_at` <= ?"); + expect(state.queries[0].sql).toContain("`ends_at` > ?"); + }); + it("filters registrations by the authenticated user and weekly bounds", async () => { + await loadPublicEvents({ mine: true, userId: 77, week: "2026-09-07" }); + expect(state.queries[0].sql).toContain("exists (select"); + expect(state.queries[0].params).toContain(77); + expect(state.queries[0].sql).toContain("`starts_at` >= ?"); + expect(state.queries[0].sql).toContain("`starts_at` < ?"); + }); + it("returns no personal events for signed out visitors", async () => { + await loadPublicEvents({ mine: true }); + expect(state.queries[0].sql).toContain("1 = 0"); + }); + it("does not mask service failures as an empty event list", async () => { + state.fail = true; + await expect(loadPublicEvents()).rejects.toThrow(); + }); +}); diff --git a/src/lib/services/public-events.ts b/src/lib/services/public-events.ts new file mode 100644 index 00000000..558b8538 --- /dev/null +++ b/src/lib/services/public-events.ts @@ -0,0 +1,176 @@ +import "server-only"; +import { + and, + asc, + count, + desc, + eq, + exists, + gt, + gte, + inArray, + isNull, + lt, + lte, + or, + sql, +} from "drizzle-orm"; +import { + db, + WebsiteEvent, + WebsiteEventRegistration, + WebsiteEventType, +} from "@/lib/db"; + +export async function loadPublicEvents( + options: { + status?: string; + week?: string; + page?: number; + mine?: boolean; + userId?: number; + now?: Date; + } = {}, +) { + const now = options.now ?? new Date(); + const status = ["upcoming", "ongoing", "completed"].includes( + options.status ?? "", + ) + ? options.status + : "all"; + const weekDate = /^\d{4}-\d{2}-\d{2}$/.test(options.week ?? "") + ? new Date(`${options.week}T00:00:00Z`) + : null; + const week = + weekDate && + Number.isFinite(weekDate.getTime()) && + weekDate.toISOString().slice(0, 10) === options.week + ? weekDate + : null; + const weekEnd = week ? new Date(week.getTime() + 7 * 86400000) : null; + const completed = or( + eq(WebsiteEvent.status, "completed"), + lte(WebsiteEvent.endsAt, now), + ); + const where = and( + inArray(WebsiteEvent.status, ["published", "completed"]), + status === "completed" + ? completed + : status === "upcoming" + ? and( + eq(WebsiteEvent.status, "published"), + gt(WebsiteEvent.startsAt, now), + or(isNull(WebsiteEvent.endsAt), gt(WebsiteEvent.endsAt, now)), + ) + : status === "ongoing" + ? and( + eq(WebsiteEvent.status, "published"), + lte(WebsiteEvent.startsAt, now), + or(isNull(WebsiteEvent.endsAt), gt(WebsiteEvent.endsAt, now)), + ) + : undefined, + week && weekEnd + ? and( + gte(WebsiteEvent.startsAt, week), + lt(WebsiteEvent.startsAt, weekEnd), + ) + : undefined, + options.mine + ? options.userId + ? exists( + db + .select({ id: WebsiteEventRegistration.id }) + .from(WebsiteEventRegistration) + .where( + and( + eq(WebsiteEventRegistration.eventId, WebsiteEvent.id), + eq(WebsiteEventRegistration.userId, options.userId), + ), + ), + ) + : sql`1 = 0` + : undefined, + ); + const totals = await db + .select({ value: count() }) + .from(WebsiteEvent) + .innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id)) + .where(where); + const total = Number(totals[0]?.value ?? 0); + const lastPage = Math.max(1, Math.ceil(total / 24)); + const page = Math.min( + lastPage, + Number.isFinite(options.page) + ? Math.max(1, Math.trunc(options.page ?? 1)) + : 1, + ); + const rows = await db + .select({ + id: WebsiteEvent.id, + title: WebsiteEvent.title, + description: WebsiteEvent.description, + status: WebsiteEvent.status, + image: WebsiteEvent.image, + startsAt: WebsiteEvent.startsAt, + endsAt: WebsiteEvent.endsAt, + maxPlayers: WebsiteEvent.maxPlayers, + typeName: WebsiteEventType.name, + typeColor: WebsiteEventType.color, + typeSlug: WebsiteEventType.slug, + }) + .from(WebsiteEvent) + .innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id)) + .where(where) + .orderBy( + status === "completed" + ? desc(WebsiteEvent.startsAt) + : asc(WebsiteEvent.startsAt), + asc(WebsiteEvent.id), + ) + .limit(24) + .offset((page - 1) * 24); + const ids = rows.map((row) => row.id); + const counts = ids.length + ? await db + .select({ eventId: WebsiteEventRegistration.eventId, value: count() }) + .from(WebsiteEventRegistration) + .where(inArray(WebsiteEventRegistration.eventId, ids)) + .groupBy(WebsiteEventRegistration.eventId) + : []; + const own = + ids.length && options.userId + ? await db + .select({ eventId: WebsiteEventRegistration.eventId }) + .from(WebsiteEventRegistration) + .where( + and( + inArray(WebsiteEventRegistration.eventId, ids), + eq(WebsiteEventRegistration.userId, options.userId), + ), + ) + : []; + const events = rows.map((row) => ({ + ...row, + phase: + row.status === "completed" || (row.endsAt && row.endsAt <= now) + ? "completed" + : row.startsAt > now + ? "upcoming" + : "ongoing", + registered: own.some((r) => r.eventId === row.id), + type: { name: row.typeName, color: row.typeColor, slug: row.typeSlug }, + _count: { + registrations: Number( + counts.find((r) => r.eventId === row.id)?.value ?? 0, + ), + }, + })); + return { + events, + total, + page, + lastPage, + status, + week: week?.toISOString().slice(0, 10) ?? "", + }; +} diff --git a/src/messages/en.json b/src/messages/en.json index f385dbb9..ba811530 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -217,7 +217,23 @@ "locked": "Doorbell", "password": "Password", "invisible": "Hidden" - } + }, + "privacy": { + "title": "Profile visibility", + "hint": "Choose what visitors can see on your public profile. Your email is never shown here. Your own view still shows your information. These controls apply to the profile page.", + "wallet": "Show my wallet", + "online": "Show my online status", + "friends": "Show my friends", + "photos": "Show my photos", + "registered": "Show my registration date", + "save": "Save visibility", + "saved": "Profile visibility saved.", + "error": "Profile visibility could not be loaded or saved. Please try again.", + "ownerHint": "You are viewing your own information, including hidden sections." + }, + "morePhotos": "Show more photos (up to 24 recent photos)", + "openPhoto": "Open full photo in a new tab", + "photosUnavailable": "Photos could not be loaded. Please try again later." }, "rankings": { "title": "Rankings", @@ -743,7 +759,26 @@ "cannotRegister": "Registration is not available.", "prizesTitle": "Prizes", "winnersTitle": "Winners", - "unknownUser": "User #{id}" + "unknownUser": "User #{id}", + "filterStatus": "Status", + "allEvents": "All events", + "statusOngoing": "In progress", + "statusUpcoming": "Upcoming", + "weekStarting": "Week starting (UTC)", + "myRegistrations": "My registrations", + "apply": "Apply", + "reset": "Reset", + "timeZoneHint": "Times are shown in your local time zone. The seven-day filter uses UTC dates.", + "loginForRegistrations": "Sign in to view your registrations.", + "loadError": "Events could not be loaded. Please try again.", + "youRegistered": "You are registered", + "pagination": "Event pages", + "previous": "Previous", + "next": "Next", + "pageSummary": "Page {page} of {pages} · {total} events", + "weeklyView": "Weekly view", + "previousWeek": "Previous week", + "nextWeek": "Next week" }, "polls": { "title": "Polls", @@ -3252,7 +3287,8 @@ "invalidDetails": "These legacy details cannot be displayed.", "beforeValue": "Before", "afterValue": "After", - "exportHint": "CSV exports the current filtered page, up to 100 records." + "exportHint": "CSV exports the current filtered page, up to 100 records.", + "operation": "Operation" }, "chat": { "colMessage": "Message", @@ -4028,7 +4064,21 @@ "page": "Page {page}", "pageOf": "Page {page} of {last}", "bulkError": "The operation failed. Your selection has been kept; check the result before retrying.", - "confirmBulk": "{action} on {count} selected rows?" + "confirmBulk": "{action} on {count} selected rows?", + "views": { + "title": "Saved views", + "hint": "Save filters, sorting and visible columns to your account. Up to 20 views per table.", + "saved": "View saved", + "error": "Saved views are temporarily unavailable. Try again.", + "retry": "Retry", + "loading": "Loading views...", + "empty": "No saved views yet.", + "deleteNamed": "Delete {name}", + "delete": "Delete", + "name": "View name", + "replace": "Replace this view", + "save": "Save current view" + } }, "actions": { "saved": "Saved successfully.", @@ -4099,7 +4149,8 @@ "sort": "Order", "recentFirst": "Most recent first", "frequentFirst": "Most frequent first", - "filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions." + "filterScope": "Counts refer to matching retained events. Group status and latest details remain current across versions.", + "auditTrail": "Related audit records" }, "importHistory": { "summary": "Import history · {count} active", @@ -4274,6 +4325,13 @@ "lastAttempt": "Last attempt: {date}", "queuing": "Queuing…", "queue": "Export now / retry" + }, + "syncQueue": { + "preparing": "Preparing synchronization...", + "queued": "Synchronization saved. Follow its progress in the history below.", + "start": "Start synchronization", + "description": "Synchronization runs in the background. Progress and safe retries remain in your import history after you leave this page.", + "failed": "Could not queue synchronization" } }, "mod": { @@ -4550,6 +4608,26 @@ "retry": "Try again", "rewardUnavailable": "Referral progress is temporarily unavailable. Please try again." } + }, + "search": { + "title": "Search", + "subtitle": "Find users, open rooms, news and events.", + "queryLabel": "Search term", + "submit": "Search", + "enterQuery": "Enter a search term to find users, rooms, news and events.", + "resultsFor": "Results for “{query}”", + "users": "Users", + "rooms": "Open rooms", + "news": "News", + "events": "Events", + "loadError": "These results could not be loaded. Please try again.", + "empty": "No matches found.", + "online": "Online", + "offline": "Offline", + "pagination": "{category} pages", + "previous": "Previous", + "next": "Next", + "pageSummary": "Page {page} of {pages} · {total} results" } }, "toolbar": { diff --git a/src/messages/it.json b/src/messages/it.json index 4ff82b3f..7e6af97f 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -217,7 +217,23 @@ "locked": "Campanello", "password": "Password", "invisible": "Nascosta" - } + }, + "privacy": { + "title": "Visibilità del profilo", + "hint": "Scegli cosa possono vedere i visitatori sul tuo profilo pubblico. La tua email non viene mai mostrata qui. Tu continui a vedere i tuoi dati. Questi controlli si applicano alla pagina del profilo.", + "wallet": "Mostra il mio portafoglio", + "online": "Mostra il mio stato online", + "friends": "Mostra i miei amici", + "photos": "Mostra le mie foto", + "registered": "Mostra la mia data di iscrizione", + "save": "Salva visibilità", + "saved": "Visibilità del profilo salvata.", + "error": "Non è stato possibile caricare o salvare la visibilità del profilo. Riprova.", + "ownerHint": "Stai visualizzando i tuoi dati, comprese le sezioni nascoste." + }, + "morePhotos": "Mostra altre foto (fino alle 24 più recenti)", + "openPhoto": "Apri la foto completa in una nuova scheda", + "photosUnavailable": "Non è stato possibile caricare le foto. Riprova più tardi." }, "rankings": { "title": "Classifica", @@ -743,7 +759,26 @@ "cannotRegister": "Le iscrizioni non sono disponibili.", "prizesTitle": "Premi", "winnersTitle": "Vincitori", - "unknownUser": "Utente #{id}" + "unknownUser": "Utente #{id}", + "filterStatus": "Stato", + "allEvents": "Tutti gli eventi", + "statusOngoing": "In corso", + "statusUpcoming": "In programma", + "weekStarting": "Settimana dal (UTC)", + "myRegistrations": "Le mie iscrizioni", + "apply": "Applica", + "reset": "Reimposta", + "timeZoneHint": "Gli orari sono nel tuo fuso locale. Il filtro di sette giorni usa date UTC.", + "loginForRegistrations": "Accedi per vedere le tue iscrizioni.", + "loadError": "Non è stato possibile caricare gli eventi. Riprova.", + "youRegistered": "Sei iscritto", + "pagination": "Pagine eventi", + "previous": "Precedente", + "next": "Successiva", + "pageSummary": "Pagina {page} di {pages} · {total} eventi", + "weeklyView": "Vista settimanale", + "previousWeek": "Settimana precedente", + "nextWeek": "Settimana successiva" }, "polls": { "title": "Sondaggi", @@ -3171,7 +3206,8 @@ "invalidDetails": "I dettagli di questa vecchia registrazione non sono visualizzabili.", "beforeValue": "Prima", "afterValue": "Dopo", - "exportHint": "Il CSV esporta la pagina filtrata corrente, fino a 100 registrazioni." + "exportHint": "Il CSV esporta la pagina filtrata corrente, fino a 100 registrazioni.", + "operation": "Operazione" }, "chat": { "colMessage": "Messaggio", @@ -4003,7 +4039,21 @@ "page": "Pagina {page}", "pageOf": "Pagina {page} di {last}", "bulkError": "Operazione non riuscita. La selezione è stata mantenuta: verifica il risultato prima di riprovare.", - "confirmBulk": "Eseguire {action} su {count} righe selezionate?" + "confirmBulk": "Eseguire {action} su {count} righe selezionate?", + "views": { + "title": "Viste salvate", + "hint": "Salva filtri, ordinamento e colonne sul tuo account. Fino a 20 viste per tabella.", + "saved": "Vista salvata", + "error": "Le viste salvate non sono disponibili. Riprova.", + "retry": "Riprova", + "loading": "Caricamento viste...", + "empty": "Nessuna vista salvata.", + "deleteNamed": "Elimina {name}", + "delete": "Elimina", + "name": "Nome della vista", + "replace": "Sostituisci questa vista", + "save": "Salva vista attuale" + } }, "actions": { "saved": "Salvato correttamente.", @@ -4074,7 +4124,8 @@ "sort": "Ordine", "recentFirst": "Prima i più recenti", "frequentFirst": "Prima i più frequenti", - "filterScope": "I conteggi riguardano gli eventi conservati che corrispondono ai filtri. Stato e dettagli più recenti del gruppo restano aggiornati tra le versioni." + "filterScope": "I conteggi riguardano gli eventi conservati che corrispondono ai filtri. Stato e dettagli più recenti del gruppo restano aggiornati tra le versioni.", + "auditTrail": "Cronologia collegata" }, "importHistory": { "summary": "Storico importazioni · {count} attive", @@ -4249,6 +4300,13 @@ "lastAttempt": "Ultimo tentativo: {date}", "queuing": "Accodamento…", "queue": "Esporta ora / riprova" + }, + "syncQueue": { + "preparing": "Preparazione sincronizzazione...", + "queued": "Sincronizzazione salvata. Segui i progressi nello storico qui sotto.", + "start": "Avvia sincronizzazione", + "description": "La sincronizzazione prosegue in background. Avanzamento e tentativi sicuri restano nello storico anche dopo aver chiuso la pagina.", + "failed": "Impossibile accodare la sincronizzazione" } }, "radioRequests": { @@ -4555,6 +4613,26 @@ "retry": "Riprova", "rewardUnavailable": "Il progresso degli inviti non è al momento disponibile. Riprova." } + }, + "search": { + "title": "Ricerca", + "subtitle": "Trova utenti, stanze aperte, notizie ed eventi.", + "queryLabel": "Termine di ricerca", + "submit": "Cerca", + "enterQuery": "Inserisci un termine per trovare utenti, stanze, notizie ed eventi.", + "resultsFor": "Risultati per “{query}”", + "users": "Utenti", + "rooms": "Stanze aperte", + "news": "Notizie", + "events": "Eventi", + "loadError": "Non è stato possibile caricare questi risultati. Riprova.", + "empty": "Nessun risultato trovato.", + "online": "Online", + "offline": "Offline", + "pagination": "Pagine: {category}", + "previous": "Precedente", + "next": "Successiva", + "pageSummary": "Pagina {page} di {pages} · {total} risultati" } }, "toolbar": { diff --git a/src/messages/nl.json b/src/messages/nl.json index 3adad8e2..6d85fc60 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -205,7 +205,23 @@ "ratelimit": "Je plaatst te snel. Wacht even en probeer opnieuw.", "invalid": "Ongeldig verzoek.", "error": "Er ging iets mis. Probeer het opnieuw." - } + }, + "privacy": { + "title": "Zichtbaarheid van profiel", + "hint": "Kies wat bezoekers op je openbare profiel kunnen zien. Je e-mailadres wordt hier nooit getoond. Je blijft zelf je gegevens zien. Deze instellingen gelden voor de profielpagina.", + "wallet": "Toon mijn portemonnee", + "online": "Toon mijn online status", + "friends": "Toon mijn vrienden", + "photos": "Toon mijn foto’s", + "registered": "Toon mijn registratiedatum", + "save": "Zichtbaarheid opslaan", + "saved": "Profielzichtbaarheid opgeslagen.", + "error": "De profielzichtbaarheid kon niet worden geladen of opgeslagen. Probeer het opnieuw.", + "ownerHint": "Je bekijkt je eigen gegevens, inclusief verborgen onderdelen." + }, + "morePhotos": "Meer foto’s tonen (maximaal de 24 nieuwste)", + "openPhoto": "Open de volledige foto in een nieuw tabblad", + "photosUnavailable": "Foto’s konden niet worden geladen. Probeer het later opnieuw." }, "rankings": { "title": "Rankings", @@ -731,7 +747,26 @@ "cannotRegister": "Inschrijven is niet beschikbaar.", "prizesTitle": "Prijzen", "winnersTitle": "Winnaars", - "unknownUser": "Gebruiker #{id}" + "unknownUser": "Gebruiker #{id}", + "filterStatus": "Status", + "allEvents": "Alle evenementen", + "statusOngoing": "Bezig", + "statusUpcoming": "Binnenkort", + "weekStarting": "Week vanaf (UTC)", + "myRegistrations": "Mijn inschrijvingen", + "apply": "Toepassen", + "reset": "Herstellen", + "timeZoneHint": "Tijden worden in je lokale tijdzone getoond. Het filter van zeven dagen gebruikt UTC-datums.", + "loginForRegistrations": "Log in om je inschrijvingen te bekijken.", + "loadError": "Evenementen konden niet worden geladen. Probeer het opnieuw.", + "youRegistered": "Je bent ingeschreven", + "pagination": "Evenementpagina’s", + "previous": "Vorige", + "next": "Volgende", + "pageSummary": "Pagina {page} van {pages} · {total} evenementen", + "weeklyView": "Weekoverzicht", + "previousWeek": "Vorige week", + "nextWeek": "Volgende week" }, "polls": { "title": "Peilingen", @@ -2809,7 +2844,8 @@ "invalidDetails": "Deze oude gegevens kunnen niet worden weergegeven.", "beforeValue": "Voor", "afterValue": "Na", - "exportHint": "Het CSV-bestand bevat de huidige gefilterde pagina, met maximaal 100 vermeldingen." + "exportHint": "Het CSV-bestand bevat de huidige gefilterde pagina, met maximaal 100 vermeldingen.", + "operation": "Operatie" }, "chat": { "colMessage": "Bericht", @@ -3682,7 +3718,8 @@ "sort": "Volgorde", "recentFirst": "Nieuwste eerst", "frequentFirst": "Meest voorkomend eerst", - "filterScope": "Aantallen gelden voor passende bewaarde gebeurtenissen. Groepsstatus en nieuwste details blijven actueel over alle versies." + "filterScope": "Aantallen gelden voor passende bewaarde gebeurtenissen. Groepsstatus en nieuwste details blijven actueel over alle versies.", + "auditTrail": "Gerelateerde auditregels" }, "installation": { "title": "Installatiediagnose", @@ -3863,6 +3900,29 @@ "lastAttempt": "Laatste poging: {date}", "queuing": "Inplannen…", "queue": "Nu exporteren / opnieuw proberen" + }, + "table": { + "views": { + "title": "Opgeslagen weergaven", + "hint": "Bewaar filters, sortering en kolommen in je account. Maximaal 20 weergaven per tabel.", + "saved": "Weergave opgeslagen", + "error": "Opgeslagen weergaven zijn tijdelijk niet beschikbaar. Probeer opnieuw.", + "retry": "Opnieuw", + "loading": "Weergaven laden...", + "empty": "Nog geen opgeslagen weergaven.", + "deleteNamed": "Verwijder {name}", + "delete": "Verwijderen", + "name": "Naam van weergave", + "replace": "Deze weergave vervangen", + "save": "Huidige weergave opslaan" + } + }, + "syncQueue": { + "preparing": "Synchronisatie voorbereiden...", + "queued": "Synchronisatie opgeslagen. Volg de voortgang in de geschiedenis hieronder.", + "start": "Synchronisatie starten", + "description": "Synchronisatie draait op de achtergrond. Voortgang en veilige nieuwe pogingen blijven beschikbaar in je importgeschiedenis.", + "failed": "Synchronisatie kon niet worden ingepland" } }, "mod": { @@ -4024,6 +4084,26 @@ "retry": "Opnieuw proberen", "rewardUnavailable": "De voortgang van uitnodigingen is tijdelijk niet beschikbaar. Probeer het opnieuw." } + }, + "search": { + "title": "Zoeken", + "subtitle": "Vind gebruikers, open kamers, nieuws en evenementen.", + "queryLabel": "Zoekterm", + "submit": "Zoeken", + "enterQuery": "Voer een zoekterm in voor gebruikers, kamers, nieuws en evenementen.", + "resultsFor": "Resultaten voor “{query}”", + "users": "Gebruikers", + "rooms": "Open kamers", + "news": "Nieuws", + "events": "Evenementen", + "loadError": "Deze resultaten konden niet worden geladen. Probeer het opnieuw.", + "empty": "Geen resultaten gevonden.", + "online": "Online", + "offline": "Offline", + "pagination": "Pagina’s voor {category}", + "previous": "Vorige", + "next": "Volgende", + "pageSummary": "Pagina {page} van {pages} · {total} resultaten" } }, "toolbar": {