From b22cf68bdafcfadf21191974d1d7eabb37bfb7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Marcin=20Brzuchalski?= Date: Sun, 19 Oct 2025 19:24:34 +0200 Subject: [PATCH 1/8] Implement i18n integration across routes and components. --- Dockerfile | 38 +-- app/lib/broadcast.server.ts | 31 +- app/lib/i18n.meta.ts | 24 ++ app/lib/i18n.server.ts | 49 +++ app/lib/i18n.shared.ts | 17 + app/lib/i18n.tsx | 38 +++ app/lib/ui.tsx | 7 +- app/locales/en.ts | 318 +++++++++++++++++ app/locales/index.ts | 9 + app/locales/pl.ts | 319 ++++++++++++++++++ app/root.tsx | 70 ++-- app/routes/_index.tsx | 46 ++- app/routes/about.tsx | 21 +- app/routes/actions.$action._index.tsx | 28 +- app/routes/actions.$action.edit.tsx | 46 ++- app/routes/actions._index.tsx | 25 +- app/routes/actions.add.tsx | 44 ++- app/routes/backup.tsx | 13 +- app/routes/broadcast._index.tsx | 16 +- app/routes/broadcast.builder.tsx | 39 ++- app/routes/broadcast.finish.tsx | 28 +- app/routes/broadcast.tts.tsx | 33 +- app/routes/broadcast.zone.tsx | 30 +- app/routes/calendar.tsx | 142 +++++--- app/routes/days.$day.edit.tsx | 13 +- app/routes/days.add.tsx | 40 ++- app/routes/days.assignments.tsx | 47 +-- app/routes/desktop-groups.$group._index.tsx | 6 +- app/routes/desktop-groups._index.tsx | 17 +- app/routes/desktop-groups.add.tsx | 25 +- app/routes/lockdown.tsx | 43 ++- app/routes/log.tsx | 15 +- app/routes/login.tsx | 21 +- app/routes/logout.tsx | 6 +- app/routes/schedule.$schedule.tsx | 67 ++-- app/routes/schedule._index.tsx | 39 ++- app/routes/schedule.add.tsx | 68 ++-- app/routes/settings.tsx | 28 +- app/routes/sounder-api.log.tsx | 16 +- app/routes/sounder-api.trigger-action.tsx | 30 +- app/routes/sounders.$sounder._index.tsx | 46 ++- app/routes/sounders.$sounder.edit.tsx | 37 +- app/routes/sounders._index.tsx | 15 +- app/routes/sounders.add.tsx | 32 +- app/routes/sounds.$sound._index.tsx | 26 +- app/routes/sounds.$sound.edit.tsx | 35 +- app/routes/sounds._index.tsx | 17 +- app/routes/sounds.add-tts.tsx | 38 ++- app/routes/sounds.add.tsx | 35 +- app/routes/webhooks.$webhook._index.tsx | 26 +- app/routes/webhooks.$webhook.edit.tsx | 32 +- app/routes/webhooks._index.tsx | 25 +- app/routes/webhooks.add.tsx | 32 +- .../webhooks.outbound.$webhook._index.tsx | 23 +- .../webhooks.outbound.$webhook.edit.tsx | 33 +- app/routes/webhooks.outbound.add.tsx | 33 +- app/routes/zones.$zone._index.tsx | 16 +- app/routes/zones.$zone.edit.tsx | 30 +- app/routes/zones._index.tsx | 19 +- app/routes/zones.add.tsx | 29 +- 60 files changed, 1938 insertions(+), 553 deletions(-) create mode 100644 app/lib/i18n.meta.ts create mode 100644 app/lib/i18n.server.ts create mode 100644 app/lib/i18n.shared.ts create mode 100644 app/lib/i18n.tsx create mode 100644 app/locales/en.ts create mode 100644 app/locales/index.ts create mode 100644 app/locales/pl.ts diff --git a/Dockerfile b/Dockerfile index 3f4ac3a..534a12c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,38 +6,38 @@ RUN apt-get update && apt-get install openssl -y # Create a new temp container called `deps` from `base` # Add the package files and install all the deps. - FROM base AS deps +FROM base AS deps - RUN mkdir /app - WORKDIR /app +RUN mkdir /app +WORKDIR /app - ADD package.json package-lock.json ./ - RUN npm install --production=false +ADD package.json package-lock.json ./ +RUN npm install --production=false # create a new temp container called `production-deps` from `base` # copy the `deps` node_modules folder over and prune it to production only. - FROM base AS production-deps +FROM base AS production-deps - RUN mkdir /app - WORKDIR /app +RUN mkdir /app +WORKDIR /app - COPY --from=deps /app/node_modules /app/node_modules - ADD package.json package-lock.json ./ - RUN npm prune --production +COPY --from=deps /app/node_modules /app/node_modules +ADD package.json package-lock.json ./ +RUN npm prune --production # create a new temp container called `build` from `base` # Copy over the full deps and run build. - FROM base AS build +FROM base AS build - ENV NODE_ENV=production +ENV NODE_ENV=production - RUN mkdir /app - WORKDIR /app +RUN mkdir /app +WORKDIR /app - COPY --from=deps /app/node_modules /app/node_modules +COPY --from=deps /app/node_modules /app/node_modules - ADD . . - RUN npm run build +ADD . . +RUN npm run build # Go back to the `base` image and copy in the production deps and build FROM base @@ -56,4 +56,4 @@ ADD . . RUN chmod +x /app/docker-entrypoint.sh ENTRYPOINT [ "/app/docker-entrypoint.sh" ] -CMD [] \ No newline at end of file +CMD [] diff --git a/app/lib/broadcast.server.ts b/app/lib/broadcast.server.ts index b86a3cc..f939ae2 100644 --- a/app/lib/broadcast.server.ts +++ b/app/lib/broadcast.server.ts @@ -17,11 +17,40 @@ export const broadcast = async (zone: string, sounds: string) => { include: {sounders: {include: {sounder: true}}} }) + let soundQueue: string[] + + try { + const parsed = JSON.parse(sounds) as unknown + soundQueue = Array.isArray(parsed) ? parsed : [] + } catch (error) { + soundQueue = [] + } + + if (soundQueue.length === 0) { + return + } + + const audio = await prisma.audio.findMany({ + where: {id: {in: soundQueue}}, + select: {id: true, fileName: true} + }) + + const audioMap = new Map(audio.map(item => [item.id, item])) + + const filteredQueue = soundQueue.filter(id => { + const audioItem = audioMap.get(id) + return Boolean(audioItem?.fileName) + }) + + if (filteredQueue.length === 0) { + return + } + return asyncForEach(z.sounders, async ({sounder}) => { await addJob('broadcast', { ip: sounder.ip, key: sounder.key, - sounds + sounds: JSON.stringify(filteredQueue) }) }) } diff --git a/app/lib/i18n.meta.ts b/app/lib/i18n.meta.ts new file mode 100644 index 0000000..2c765c4 --- /dev/null +++ b/app/lib/i18n.meta.ts @@ -0,0 +1,24 @@ +import type {Messages} from './i18n.shared' + +type MatchWithData = { + id: string + data?: unknown +} + +const ROOT_ID = 'root' + +type RootData = { + locale: string + messages: Messages +} + +export const getRootI18n = (matches: MatchWithData[]): RootData => { + const rootMatch = matches.find(match => match.id === ROOT_ID) + + if (rootMatch && typeof rootMatch.data === 'object' && rootMatch.data !== null) { + const {locale, messages} = rootMatch.data as RootData + return {locale, messages} + } + + return {locale: 'en', messages: {}} +} diff --git a/app/lib/i18n.server.ts b/app/lib/i18n.server.ts new file mode 100644 index 0000000..82803d3 --- /dev/null +++ b/app/lib/i18n.server.ts @@ -0,0 +1,49 @@ +import type {LoaderFunctionArgs} from '@remix-run/node' + +import {locales, type SupportedLocale} from '~/locales' +import type {Messages} from './i18n.shared' + +const FALLBACK_LOCALE: SupportedLocale = 'en' + +const resolveLocale = (request: LoaderFunctionArgs['request']): SupportedLocale => { + const acceptLanguage = request.headers.get('accept-language') + + if (acceptLanguage) { + const requestedLocales = acceptLanguage + .split(',') + .map(part => part.split(';')[0]?.trim()) + .filter(Boolean) as string[] + + for (const requested of requestedLocales) { + const normalized = requested.toLowerCase() + + const exactMatch = Object.keys(locales).find(locale => locale === normalized) + if (exactMatch) { + return exactMatch as SupportedLocale + } + + const prefixMatch = Object.keys(locales).find(locale => + normalized.startsWith(`${locale.toLowerCase()}-`) + ) + + if (prefixMatch) { + return prefixMatch as SupportedLocale + } + } + } + + return FALLBACK_LOCALE +} + +const getMessages = (locale: SupportedLocale): Messages => { + return locales[locale] +} + +export const initTranslations = (request: LoaderFunctionArgs['request']) => { + const locale = resolveLocale(request) + const messages = getMessages(locale) + + return {locale, messages} +} + +export type InitTranslationsReturn = ReturnType diff --git a/app/lib/i18n.shared.ts b/app/lib/i18n.shared.ts new file mode 100644 index 0000000..09eb8b0 --- /dev/null +++ b/app/lib/i18n.shared.ts @@ -0,0 +1,17 @@ +export type Messages = Record + +export type TranslateReplacements = Record + +export const translate = ( + messages: Messages, + key: string, + replacements: TranslateReplacements = {} +) => { + const template = messages[key] ?? key + + return Object.keys(replacements).reduce((acc, replacementKey) => { + const value = replacements[replacementKey] + const pattern = new RegExp(`{{\\s*${replacementKey}\\s*}}`, 'g') + return acc.replace(pattern, String(value)) + }, template) +} diff --git a/app/lib/i18n.tsx b/app/lib/i18n.tsx new file mode 100644 index 0000000..f4dd538 --- /dev/null +++ b/app/lib/i18n.tsx @@ -0,0 +1,38 @@ +import {createContext, useContext} from 'react' + +import type {Messages, TranslateReplacements} from './i18n.shared' +import {translate as baseTranslate} from './i18n.shared' + +type I18nContextValue = { + locale: string + messages: Messages +} + +const I18nContext = createContext({ + locale: 'en', + messages: {} +}) + +export const I18nProvider: React.FC<{ + locale: string + messages: Messages + children: React.ReactNode +}> = ({locale, messages, children}) => { + return ( + + {children} + + ) +} + +export const useTranslation = () => { + const context = useContext(I18nContext) + + const t = (key: string, replacements: TranslateReplacements = {}) => { + return baseTranslate(context.messages, key, replacements) + } + + return {t, locale: context.locale} +} + +export const translate = baseTranslate diff --git a/app/lib/ui.tsx b/app/lib/ui.tsx index 2d16bc7..2417e6c 100644 --- a/app/lib/ui.tsx +++ b/app/lib/ui.tsx @@ -1,6 +1,7 @@ import {NavLink} from '@remix-run/react' import {docsLink} from './utils' +import {useTranslation} from './i18n' export const SidebarLink: React.FC<{ to: string @@ -28,6 +29,8 @@ export const Page: React.FC<{ wide?: boolean helpLink?: string }> = ({title, wide, children, helpLink}) => { + const {t} = useTranslation() + return (
@@ -35,8 +38,8 @@ export const Page: React.FC<{ {title} {helpLink ? ( - - 📖 Docs + + 📖 {t('ui.docs')} ) : ( diff --git a/app/locales/en.ts b/app/locales/en.ts new file mode 100644 index 0000000..31c99e4 --- /dev/null +++ b/app/locales/en.ts @@ -0,0 +1,318 @@ +export const en = { + 'app.title': 'Open School Bell', + 'ui.docs': 'Docs', + 'nav.dashboard': 'Dashboard', + 'nav.broadcast': 'Broadcast', + 'nav.schedule': 'Schedule', + 'nav.calendar': 'Calendar', + 'nav.sounders': 'Sounders', + 'nav.sounds': 'Sounds', + 'nav.desktopGroups': 'Desktop Groups', + 'nav.actions': 'Actions', + 'nav.webhooks': 'Webhooks', + 'nav.zones': 'Zones', + 'nav.lockdown': 'Lockdown', + 'nav.settings': 'Settings', + 'nav.about': 'About', + 'nav.log': 'Log', + 'nav.backup': 'Backup', + 'nav.logout': 'Logout', + 'dashboard.pageTitle': 'Open School Bell', + 'dashboard.devices': 'Sounders', + 'dashboard.table.name': 'Name', + 'dashboard.table.status': 'Status', + 'dashboard.table.lastSeen': 'Last Seen', + 'dashboard.lockdown.message': 'Lockdown mode {{status}}', + 'dashboard.lockdown.status.enabled': 'enabled', + 'dashboard.lockdown.status.disabled': 'disabled', + 'dashboard.lockdown.confirmEnable': 'Are you sure you want to enable lockdown?', + 'dashboard.lockdown.confirmDisable': 'Are you sure you want to disable lockdown?', + 'dashboard.lockdown.button.enable': 'Enable', + 'dashboard.lockdown.button.disable': 'Disable', + 'about.title': 'About', + 'about.table.component': 'Component', + 'about.table.version': 'Version', + 'about.table.latest': 'Latest Version', + 'about.table.required': 'Required Version', + 'actions.title': 'Actions', + 'actions.titleWithCount': 'Actions ({{count}})', + 'actions.table.action': 'Action', + 'actions.table.type': 'Type', + 'actions.types.broadcast': 'Broadcast', + 'actions.types.lockdown': 'Lockdown Toggle', + 'actions.buttons.add': 'Add Action', + 'actions.add.pageTitle': 'Add Action', + 'actions.form.name.label': 'Name', + 'actions.form.name.helper': 'The name of the action as it will appear on the screens.', + 'actions.form.icon.label': 'Icon', + 'actions.form.icon.helper': + 'An emoji to use as the action icon. Note that emoji render differently on the RPi screen.', + 'actions.form.type.label': 'Type', + 'actions.form.type.helper': + 'Broadcast runs a broadcast to the supplied zone. Lockdown toggles a system wide lockdown.', + 'actions.form.sound.label': 'Sound', + 'actions.form.sound.helper': 'Which sound should be used when broadcasting?', + 'button.cancel': 'Cancel', + 'button.add': 'Add', + 'button.save': 'Save', + 'button.back': 'Back', + 'button.edit': 'Edit', + 'actions.detail.icon': 'Icon', + 'actions.detail.type': 'Type', + 'actions.detail.sound': 'Sound:', + 'actions.edit.metaTitle': 'Edit {{name}}', + 'actions.edit.pageTitle': 'Edit {{name}}', + 'backup.pageTitle': 'Backups', + 'backup.create': 'Create Backup', + 'broadcast.pageTitle': 'Broadcast', + 'broadcast.description': + 'Broadcast a sound (and its ringer wire) to a given zone or desktop group.', + 'broadcast.buildButton': 'Build Broadcast', + 'broadcast.builder.metaTitle': 'Sound', + 'broadcast.builder.pageTitle': 'Broadcast Builder', + 'broadcast.builder.sound.label': 'Sound', + 'broadcast.builder.sound.helper': 'Select the sound to add to the queue.', + 'broadcast.builder.totalDuration': 'Total duration {{duration}}', + 'broadcast.builder.createTts': 'Create new TTS', + 'button.next': 'Next', + 'common.error': 'Error', + 'broadcast.zone.metaTitle': 'Zone', + 'broadcast.zone.pageTitle': 'Broadcast (Zone)', + 'broadcast.zone.field.zone.label': 'Zone', + 'broadcast.zone.field.zone.helper': 'The zone to broadcast the sound to.', + 'broadcast.zone.noneOption': 'None', + 'broadcast.zone.submit': 'Broadcast!', + 'broadcast.finish.metaTitle': 'Finished', + 'broadcast.finish.message': 'Broadcast sent!', + 'broadcast.finish.startAgain': 'Start again', + 'broadcast.finish.rebroadcast': 'Re-broadcast', + 'broadcast.tts.metaTitle': 'Text to Speech', + 'broadcast.tts.pageTitle': 'Broadcast (Text to Speech)', + 'broadcast.tts.text.label': 'Text', + 'broadcast.tts.text.helper': 'The text to be broadcast.', + 'broadcast.tts.ringer.label': 'Ringer wire', + 'broadcast.tts.ringer.helper': 'How the ringer wire should be triggered.', + 'calendar.metaTitle': 'Calendar', + 'calendar.previous': '<< Previous month', + 'calendar.next': 'Next month >>', + 'calendar.weekdays.monday': 'Monday', + 'calendar.weekdays.tuesday': 'Tuesday', + 'calendar.weekdays.wednesday': 'Wednesday', + 'calendar.weekdays.thursday': 'Thursday', + 'calendar.weekdays.friday': 'Friday', + 'calendar.weekdays.saturday': 'Saturday', + 'calendar.weekdays.sunday': 'Sunday', + 'calendar.dayTypes': 'Day types', + 'calendar.dayTypesTable.day': 'Day', + 'calendar.buttons.addDay': 'Add day', + 'calendar.buttons.manageAssignments': 'Manage assignments', + 'days.add.pageTitle': 'Add day', + 'days.form.name.label': 'Name', + 'days.form.name.helper': 'Descriptive name for the day.', + 'days.form.copy.label': 'Copy from', + 'days.form.copy.helper': 'Copy the schedule from another day type.', + 'days.form.copy.none': 'None', + 'days.form.copy.default': 'Default', + 'days.add.submit': 'Add day', + 'days.edit.pageTitle': 'Edit {{name}}', + 'button.saveChanges': 'Save changes', + 'days.assignments.metaTitle': 'Day assignments', + 'days.assignments.title': 'Day assignments', + 'days.assignments.helper': + 'These assignments change the day type for the given dates to that type\'s schedule.', + 'days.assignments.table.date': 'Date', + 'days.assignments.table.dayType': 'Day type', + 'days.assignments.addTitle': 'Add assignments', + 'days.assignments.form.from.label': 'From', + 'days.assignments.form.from.helper': + 'Start date. To assign a single day, set both From and To to the same date.', + 'days.assignments.form.to.label': 'To', + 'days.assignments.form.to.helper': + 'End date. To assign a single day, set both From and To to the same date.', + 'days.assignments.form.day.label': 'Day type', + 'days.assignments.form.day.helper': 'Day type to assign to the selected dates.', + 'days.assignments.addButton': 'Add assignments', + 'desktopGroups.metaTitle': 'Desktop groups', + 'desktopGroups.titleWithCount': 'Desktop groups ({{count}})', + 'desktopGroups.table.name': 'Name', + 'desktopGroups.table.key': 'Key', + 'desktopGroups.addButton': 'Add desktop group', + 'desktopGroups.add.pageTitle': 'Add desktop group', + 'desktopGroups.form.name.label': 'Name', + 'desktopGroups.form.name.helper': 'The name of the desktop group.', + 'desktopGroups.add.submit': 'Add desktop group', + 'desktopGroups.detail.key': 'Key', + 'auth.login.metaTitle': 'Login', + 'auth.login.pageTitle': 'Login', + 'auth.login.password.label': 'Password', + 'auth.login.submit': 'Log in', + 'auth.login.error': 'Incorrect password', + 'auth.logout.message': 'Are you sure you want to log out?', + 'auth.logout.submit': 'Log out', + 'log.metaTitle': 'Log', + 'log.pageTitle': 'Log', + 'log.columns.time': 'Time', + 'log.columns.message': 'Message', + 'lockdown.metaTitle': 'Lockdown', + 'lockdown.pageTitle': 'Lockdown', + 'lockdown.status.active': 'Lockdown active', + 'lockdown.status.inactive': 'Lockdown inactive', + 'lockdown.field.entrySound.label': 'Lockdown start sound', + 'lockdown.field.entrySound.helper': + 'Sound used to start lockdown and for repetitions.', + 'lockdown.field.exitSound.label': 'Lockdown end sound', + 'lockdown.field.exitSound.helper': 'Sound used to end lockdown.', + 'lockdown.field.startCount.label': 'Lockdown start count', + 'lockdown.field.startCount.helper': + 'How many times to play the start sound at start and on repeats.', + 'lockdown.field.exitCount.label': 'Lockdown end count', + 'lockdown.field.exitCount.helper': 'How many times to play the end sound.', + 'lockdown.field.repeatInterval.label': 'Lockdown repeat interval (minutes)', + 'lockdown.field.repeatInterval.helper': + 'How often to repeat the start sound in minutes.', + 'lockdown.field.repeatRinger.label': 'Trigger ringer wire on repeats?', + 'lockdown.field.repeatRinger.helper': + 'Whether to trigger the ringer wire on repeats to avoid ambiguity between start and end.', + 'settings.pageTitle': 'Settings', + 'settings.controllerUrl.label': 'Controller URL', + 'settings.controllerUrl.helper': 'Network address of the controller (without the trailing /).', + 'settings.ttsSpeed.label': 'Text-to-speech speed', + 'settings.ttsSpeed.helper': 'Speed factor for text-to-speech generation. Default is 1; lower is faster.', + 'settings.password.label': 'Change password', + 'settings.password.helper': 'Leave fields empty to keep the current password.', + 'settings.password.placeholderNew': 'New password', + 'settings.password.placeholderConfirm': 'Repeat new password', + 'schedule.metaTitle': 'Schedule', + 'schedule.pageTitle': 'Schedule', + 'schedule.defaultOption': 'Default', + 'schedule.table.time': 'Time', + 'schedule.table.zone': 'Zone', + 'schedule.table.sound': 'Sound', + 'schedule.table.count': 'Count', + 'schedule.addButton': 'Add entry', + 'schedule.add.pageTitle': 'Add schedule entry', + 'schedule.form.time.label': 'Time', + 'schedule.form.time.helper': 'Time when the sound should trigger (always at 0 seconds past the minute).', + 'schedule.form.day.label': 'Day type', + 'schedule.form.day.helper': 'Day type that this schedule entry applies to.', + 'schedule.form.zone.label': 'Zone', + 'schedule.form.zone.helper': 'Zone where this schedule entry applies.', + 'schedule.form.sound.label': 'Sound', + 'schedule.form.sound.helper': 'Sound to play for this schedule entry.', + 'schedule.form.count.label': 'Repeat count', + 'schedule.form.count.helper': 'How many times to play the sound.', + 'schedule.add.submit': 'Add entry', + 'schedule.error.noDays': 'At least one day must be selected', + 'schedule.edit.metaTitle': 'Edit {{time}}', + 'schedule.edit.pageTitle': 'Edit schedule entry at {{time}}', + 'sounders.metaTitle': 'Sounders', + 'sounders.titleWithCount': 'Sounders ({{count}})', + 'sounders.table.device': 'Sounder', + 'sounders.addButton': 'Add sounder', + 'sounders.add.pageTitle': 'Add sounder', + 'sounders.form.name.label': 'Name', + 'sounders.form.name.helper': 'Descriptive name of the sounder.', + 'sounders.form.ip.label': 'IP address', + 'sounders.form.ip.helper': 'IP address where the controller can reach the sounder.', + 'sounders.add.submit': 'Add sounder', + 'sounders.detail.metaFallback': 'Sounder', + 'sounders.detail.infoTitle': 'About', + 'sounders.detail.ipLabel': 'IP', + 'sounders.detail.screenLabel': 'Screen', + 'sounders.detail.ringerPinLabel': 'Ringer pin', + 'sounders.detail.ringerPin.none': 'None', + 'sounders.detail.resetButton': 'Reset key (requires re-enrolment)', + 'sounders.detail.keyLabel': 'Key', + 'sounders.detail.zonesTitle': 'Zones', + 'sounders.detail.addToZone': 'Add to zone', + 'sounders.detail.logTitle': 'Log', + 'sounders.detail.editButton': 'Edit sounder', + 'common.yes': 'Yes', + 'common.no': 'No', + 'sounders.edit.metaTitle': 'Edit {{name}}', + 'sounders.edit.pageTitle': 'Edit sounder {{name}}', + 'sounders.form.ringer.label': 'Ringer pin', + 'sounders.form.ringer.helper': 'GPIO pin number that activates the ringer wire.', + 'sounders.form.screen.label': 'Screen', + 'sounders.form.screen.helper': + 'Enable the screen interface on this sounder? Restart required after changing.', + 'sounds.metaTitle': 'Sounds', + 'sounds.titleWithCount': 'Sounds ({{count}})', + 'sounds.table.name': 'Name', + 'sounds.addButton': 'Add sound', + 'sounds.addTtsButton': 'Add text-to-speech sound', + 'sounds.add.metaTitle': 'Add sound', + 'sounds.add.pageTitle': 'Add sound', + 'sounds.form.name.label': 'Name', + 'sounds.form.name.helper': 'Descriptive name for the sound.', + 'sounds.form.file.label': 'MP3 file', + 'sounds.form.file.helper': 'MP3 file to use as the sound.', + 'sounds.form.ringer.label': 'Ringer wire', + 'sounds.form.ringer.helper': + 'Comma separated list of seconds to operate the relay. ON,OFF,... e.g. 1,3,1,3. End with an OFF period.', + 'sounds.add.submit': 'Add sound', + 'sounds.addTts.metaTitle': 'Add TTS sound', + 'sounds.addTts.pageTitle': 'Add sound (text to speech)', + 'sounds.form.text.label': 'Text', + 'sounds.form.text.helper': 'Text that will be synthesised into speech.', + 'sounds.detail.metaFallback': 'Sound', + 'sounds.detail.ringerWire': 'Ringer wire', + 'sounds.detail.duration': 'Duration', + 'sounds.detail.audioType': 'Audio type', + 'sounds.detail.editButton': 'Edit sound', + 'sounds.edit.metaTitle': 'Edit {{name}}', + 'sounds.edit.pageTitle': 'Edit sound {{name}}', + 'sounds.form.file.helperEdit': 'Upload a new MP3 to replace the existing sound.', + 'webhooks.metaTitle': 'Webhooks', + 'webhooks.inbound.titleWithCount': 'Inbound webhooks ({{count}})', + 'webhooks.inbound.table.webhook': 'Webhook', + 'webhooks.inbound.addButton': 'Add webhook', + 'webhooks.outbound.titleWithCount': 'Outbound webhooks ({{count}})', + 'webhooks.outbound.table.webhook': 'Outbound webhook', + 'webhooks.outbound.addButton': 'Add outbound webhook', + 'webhooks.add.metaTitle': 'Add webhook', + 'webhooks.add.pageTitle': 'Add webhook', + 'webhooks.form.slug.label': 'Slug (identifier)', + 'webhooks.form.slug.helper': 'Name that will be shown on screens.', + 'webhooks.form.action.label': 'Action', + 'webhooks.form.action.helper': 'Which action should run when the webhook fires?', + 'webhooks.add.submit': 'Add', + 'webhooks.outbound.add.metaTitle': 'Add outbound webhook', + 'webhooks.outbound.add.pageTitle': 'Add outbound webhook', + 'webhooks.outbound.form.target.label': 'Target URL', + 'webhooks.outbound.form.target.helper': 'Full URL for the outbound webhook.', + 'webhooks.outbound.form.event.label': 'Event type', + 'webhooks.outbound.form.event.helper': 'Which events should trigger this webhook?', + 'webhooks.outbound.add.submit': 'Add outbound webhook', + 'webhooks.outbound.edit.metaTitle': 'Edit outbound webhook', + 'webhooks.outbound.edit.pageTitle': 'Edit outbound webhook', + 'webhooks.outbound.detail.pageTitle': 'Outbound webhook', + 'webhooks.outbound.detail.key': 'Key', + 'webhooks.outbound.detail.target': 'Target', + 'webhooks.outbound.detail.editButton': 'Edit', + 'webhooks.edit.metaTitle': 'Edit webhook', + 'webhooks.edit.pageTitle': 'Edit webhook', + 'webhooks.detail.key': 'Key', + 'webhooks.detail.action': 'Action:', + 'webhooks.detail.broadcastNotice': + 'If this webhook triggers a Broadcast action, include "zone": "ZONE_ID" in the JSON body.', + 'webhooks.detail.editButton': 'Edit', + 'zones.metaTitle': 'Zones', + 'zones.titleWithCount': 'Zones ({{count}})', + 'zones.table.zone': 'Zone', + 'zones.table.sounders': 'Sounders', + 'zones.table.schedules': 'Schedules', + 'zones.addButton': 'Add zone', + 'zones.add.metaTitle': 'Add zone', + 'zones.add.pageTitle': 'Add zone', + 'zones.form.name.label': 'Name', + 'zones.form.name.helper': 'Descriptive name for the zone.', + 'zones.add.submit': 'Add zone', + 'zones.edit.metaTitle': 'Edit {{name}}', + 'zones.edit.pageTitle': 'Edit zone {{name}}', + 'zones.detail.metaFallback': 'Zone', + 'zones.detail.soundersTitle': 'Sounders', + 'zones.detail.editButton': 'Edit zone' +} as const + +export type EnMessages = typeof en diff --git a/app/locales/index.ts b/app/locales/index.ts new file mode 100644 index 0000000..102af83 --- /dev/null +++ b/app/locales/index.ts @@ -0,0 +1,9 @@ +import {en} from './en' +import {pl} from './pl' + +export const locales = { + en, + pl +} + +export type SupportedLocale = keyof typeof locales diff --git a/app/locales/pl.ts b/app/locales/pl.ts new file mode 100644 index 0000000..3396dfd --- /dev/null +++ b/app/locales/pl.ts @@ -0,0 +1,319 @@ +export const pl = { + 'app.title': 'Open School Bell', + 'ui.docs': 'Dokumentacja', + 'nav.dashboard': 'Panel', + 'nav.broadcast': 'Nadawanie', + 'nav.schedule': 'Harmonogram', + 'nav.calendar': 'Kalendarz', + 'nav.sounders': 'Urządzenia', + 'nav.sounds': 'Dźwięki', + 'nav.desktopGroups': 'Grupy komputerów', + 'nav.actions': 'Akcje', + 'nav.webhooks': 'Webhooki', + 'nav.zones': 'Strefy', + 'nav.lockdown': 'Blokada', + 'nav.settings': 'Ustawienia', + 'nav.about': 'Informacje', + 'nav.log': 'Dziennik', + 'nav.backup': 'Kopia zapasowa', + 'nav.logout': 'Wyloguj', + 'dashboard.pageTitle': 'Open School Bell', + 'dashboard.devices': 'Urządzenia', + 'dashboard.table.name': 'Nazwa', + 'dashboard.table.status': 'Status', + 'dashboard.table.lastSeen': 'Ostatnia aktywność', + 'dashboard.lockdown.message': 'Tryb blokady {{status}}', + 'dashboard.lockdown.status.enabled': 'włączony', + 'dashboard.lockdown.status.disabled': 'wyłączony', + 'dashboard.lockdown.confirmEnable': 'Czy na pewno chcesz włączyć blokadę?', + 'dashboard.lockdown.confirmDisable': 'Czy na pewno chcesz wyłączyć blokadę?', + 'dashboard.lockdown.button.enable': 'Włącz', + 'dashboard.lockdown.button.disable': 'Wyłącz', + 'about.title': 'Informacje', + 'about.table.component': 'Komponent', + 'about.table.version': 'Wersja', + 'about.table.latest': 'Najnowsza wersja', + 'about.table.required': 'Wymagana wersja', + 'u': 'Urządzenie: {{name}}', + 'actions.title': 'Akcje', + 'actions.titleWithCount': 'Akcje ({{count}})', + 'actions.table.action': 'Akcja', + 'actions.table.type': 'Typ', + 'actions.types.broadcast': 'Nadawanie', + 'actions.types.lockdown': 'Przełącz blokadę', + 'actions.buttons.add': 'Dodaj akcję', + 'actions.add.pageTitle': 'Dodaj akcję', + 'actions.form.name.label': 'Nazwa', + 'actions.form.name.helper': 'Nazwa akcji wyświetlana na ekranach.', + 'actions.form.icon.label': 'Ikona', + 'actions.form.icon.helper': + 'Emoji używane jako ikona akcji. Pamiętaj, że emoji mogą wyglądać inaczej na ekranie RPi.', + 'actions.form.type.label': 'Typ', + 'actions.form.type.helper': + 'Nadawanie uruchamia transmisję w wybranej strefie. Blokada przełącza blokadę w całym systemie.', + 'actions.form.sound.label': 'Dźwięk', + 'actions.form.sound.helper': 'Jaki dźwięk ma zostać użyty podczas nadawania?', + 'button.cancel': 'Anuluj', + 'button.add': 'Dodaj', + 'button.save': 'Zapisz', + 'button.back': 'Wróć', + 'button.edit': 'Edytuj', + 'actions.detail.icon': 'Ikona', + 'actions.detail.type': 'Typ', + 'actions.detail.sound': 'Dźwięk:', + 'actions.edit.metaTitle': 'Edytuj {{name}}', + 'actions.edit.pageTitle': 'Edytuj {{name}}', + 'backup.pageTitle': 'Kopie zapasowe', + 'backup.create': 'Utwórz kopię zapasową', + 'broadcast.pageTitle': 'Nadawanie', + 'broadcast.description': + 'Nadaj dźwięk (oraz przewód dzwonka) do wybranej strefy lub grupy komputerów.', + 'broadcast.buildButton': 'Utwórz transmisję', + 'broadcast.builder.metaTitle': 'Dźwięk', + 'broadcast.builder.pageTitle': 'Kreator transmisji', + 'broadcast.builder.sound.label': 'Dźwięk', + 'broadcast.builder.sound.helper': 'Dźwięk, który ma zostać dodany do kolejki.', + 'broadcast.builder.totalDuration': 'Łączny czas {{duration}}', + 'broadcast.builder.createTts': 'Utwórz nowy TTS', + 'button.next': 'Dalej', + 'common.error': 'Błąd', + 'broadcast.zone.metaTitle': 'Strefa', + 'broadcast.zone.pageTitle': 'Nadawanie (strefa)', + 'broadcast.zone.field.zone.label': 'Strefa', + 'broadcast.zone.field.zone.helper': 'Strefa urządzeń, do której ma zostać nadany dźwięk.', + 'broadcast.zone.noneOption': 'Brak', + 'broadcast.zone.submit': 'Nadaj!', + 'broadcast.finish.metaTitle': 'Zakończono', + 'broadcast.finish.message': 'Transmisja wysłana!', + 'broadcast.finish.startAgain': 'Rozpocznij ponownie', + 'broadcast.finish.rebroadcast': 'Nadaj ponownie', + 'broadcast.tts.metaTitle': 'Tekst na mowę', + 'broadcast.tts.pageTitle': 'Nadawanie (tekst na mowę)', + 'broadcast.tts.text.label': 'Tekst', + 'broadcast.tts.text.helper': 'Tekst do nadania.', + 'broadcast.tts.ringer.label': 'Przewód dzwonka', + 'broadcast.tts.ringer.helper': 'Sposób uruchomienia przewodu dzwonka.', + 'calendar.metaTitle': 'Kalendarz', + 'calendar.previous': '<< Poprzedni miesiąc', + 'calendar.next': 'Następny miesiąc >>', + 'calendar.weekdays.monday': 'Poniedziałek', + 'calendar.weekdays.tuesday': 'Wtorek', + 'calendar.weekdays.wednesday': 'Środa', + 'calendar.weekdays.thursday': 'Czwartek', + 'calendar.weekdays.friday': 'Piątek', + 'calendar.weekdays.saturday': 'Sobota', + 'calendar.weekdays.sunday': 'Niedziela', + 'calendar.dayTypes': 'Typy dni', + 'calendar.dayTypesTable.day': 'Dzień', + 'calendar.buttons.addDay': 'Dodaj dzień', + 'calendar.buttons.manageAssignments': 'Zarządzaj przypisaniami', + 'days.add.pageTitle': 'Dodaj dzień', + 'days.form.name.label': 'Nazwa', + 'days.form.name.helper': 'Opisowa nazwa dnia.', + 'days.form.copy.label': 'Skopiuj z', + 'days.form.copy.helper': 'Typ dnia, z którego skopiować harmonogram.', + 'days.form.copy.none': 'Brak', + 'days.form.copy.default': 'Domyślny', + 'days.add.submit': 'Dodaj dzień', + 'days.edit.pageTitle': 'Edytuj {{name}}', + 'button.saveChanges': 'Zapisz zmiany', + 'days.assignments.metaTitle': 'Przypisania dni', + 'days.assignments.title': 'Przypisania dni', + 'days.assignments.helper': + 'Te przypisania zmieniają typ dnia dla wybranych dat na harmonogram tego typu.', + 'days.assignments.table.date': 'Data', + 'days.assignments.table.dayType': 'Typ dnia', + 'days.assignments.addTitle': 'Dodaj przypisania', + 'days.assignments.form.from.label': 'Od', + 'days.assignments.form.from.helper': + 'Data rozpoczęcia przypisania. Aby dodać tylko jeden dzień, ustaw pola Od i Do na tę samą datę.', + 'days.assignments.form.to.label': 'Do', + 'days.assignments.form.to.helper': + 'Data zakończenia przypisania. Aby dodać tylko jeden dzień, ustaw pola Od i Do na tę samą datę.', + 'days.assignments.form.day.label': 'Dzień', + 'days.assignments.form.day.helper': 'Typ dnia przypisywany do tych dat.', + 'days.assignments.addButton': 'Dodaj przypisania', + 'desktopGroups.metaTitle': 'Grupy komputerów', + 'desktopGroups.titleWithCount': 'Grupy komputerów ({{count}})', + 'desktopGroups.table.name': 'Nazwa', + 'desktopGroups.table.key': 'Klucz', + 'desktopGroups.addButton': 'Dodaj grupę komputerów', + 'desktopGroups.add.pageTitle': 'Dodaj grupę komputerów', + 'desktopGroups.form.name.label': 'Nazwa', + 'desktopGroups.form.name.helper': 'Nazwa grupy komputerów.', + 'desktopGroups.add.submit': 'Dodaj grupę komputerów', + 'desktopGroups.detail.key': 'Klucz', + 'auth.login.metaTitle': 'Logowanie', + 'auth.login.pageTitle': 'Logowanie', + 'auth.login.password.label': 'Hasło', + 'auth.login.submit': 'Zaloguj', + 'auth.login.error': 'Nieprawidłowe hasło', + 'auth.logout.message': 'Czy na pewno chcesz się wylogować?', + 'auth.logout.submit': 'Wyloguj', + 'log.metaTitle': 'Dziennik', + 'log.pageTitle': 'Dziennik', + 'log.columns.time': 'Czas', + 'log.columns.message': 'Komunikat', + 'lockdown.metaTitle': 'Blokada', + 'lockdown.pageTitle': 'Blokada', + 'lockdown.status.active': 'Blokada aktywna', + 'lockdown.status.inactive': 'Blokada nieaktywna', + 'lockdown.field.entrySound.label': 'Dźwięk startu blokady', + 'lockdown.field.entrySound.helper': + 'Dźwięk odtwarzany przy rozpoczęciu blokady oraz podczas powtórzeń.', + 'lockdown.field.exitSound.label': 'Dźwięk zakończenia blokady', + 'lockdown.field.exitSound.helper': 'Dźwięk odtwarzany przy zakończeniu blokady.', + 'lockdown.field.startCount.label': 'Liczba powtórzeń startu', + 'lockdown.field.startCount.helper': + 'Ile razy odtworzyć dźwięk rozpoczęcia blokady – zarówno przy starcie, jak i przy powtórzeniach.', + 'lockdown.field.exitCount.label': 'Liczba powtórzeń zakończenia', + 'lockdown.field.exitCount.helper': 'Ile razy odtworzyć dźwięk zakończenia blokady.', + 'lockdown.field.repeatInterval.label': 'Interwał powtórzeń blokady (minuty)', + 'lockdown.field.repeatInterval.helper': + 'Co ile minut powtarzać dźwięk rozpoczęcia blokady.', + 'lockdown.field.repeatRinger.label': 'Aktywować przewód dzwonka przy powtórzeniach?', + 'lockdown.field.repeatRinger.helper': + 'Czy uruchamiać przewód dzwonka przy powtórzeniach, aby uniknąć niejednoznaczności co do liczby dzwonków oznaczających start lub koniec blokady.', + 'settings.pageTitle': 'Ustawienia', + 'settings.controllerUrl.label': 'Adres kontrolera', + 'settings.controllerUrl.helper': 'Adres kontrolera w sieci (bez końcowego ukośnika).', + 'settings.ttsSpeed.label': 'Prędkość syntezy mowy', + 'settings.ttsSpeed.helper': 'Współczynnik prędkości generowania mowy. Domyślnie 1; mniejsza wartość oznacza szybsze odtwarzanie.', + 'settings.password.label': 'Zmień hasło', + 'settings.password.helper': 'Pozostaw pola puste, aby nie zmieniać hasła.', + 'settings.password.placeholderNew': 'Nowe hasło', + 'settings.password.placeholderConfirm': 'Powtórz nowe hasło', + 'schedule.metaTitle': 'Harmonogram', + 'schedule.pageTitle': 'Harmonogram', + 'schedule.defaultOption': 'Domyślny', + 'schedule.table.time': 'Godzina', + 'schedule.table.zone': 'Strefa', + 'schedule.table.sound': 'Dźwięk', + 'schedule.table.count': 'Liczba', + 'schedule.addButton': 'Dodaj wpis', + 'schedule.add.pageTitle': 'Dodaj wpis harmonogramu', + 'schedule.form.time.label': 'Godzina', + 'schedule.form.time.helper': 'Godzina uruchomienia dźwięku (zawsze o 0 sekund w danej minucie).', + 'schedule.form.day.label': 'Dzień', + 'schedule.form.day.helper': 'Typ dnia, którego dotyczy ten wpis harmonogramu.', + 'schedule.form.zone.label': 'Strefa', + 'schedule.form.zone.helper': 'Strefa, której dotyczy ten wpis.', + 'schedule.form.sound.label': 'Dźwięk', + 'schedule.form.sound.helper': 'Jaki dźwięk powinien zostać odtworzony?', + 'schedule.form.count.label': 'Liczba powtórzeń', + 'schedule.form.count.helper': 'Ile razy odtworzyć dźwięk.', + 'schedule.add.submit': 'Dodaj wpis', + 'schedule.error.noDays': 'Należy wybrać co najmniej jeden dzień', + 'schedule.edit.metaTitle': 'Edytuj {{time}}', + 'schedule.edit.pageTitle': 'Edytuj wpis harmonogramu o {{time}}', + 'sounders.metaTitle': 'Urządzenia', + 'sounders.titleWithCount': 'Urządzenia ({{count}})', + 'sounders.table.device': 'Urządzenie', + 'sounders.addButton': 'Dodaj urządzenie', + 'sounders.add.pageTitle': 'Dodaj urządzenie', + 'sounders.form.name.label': 'Nazwa', + 'sounders.form.name.helper': 'Opisowa nazwa urządzenia.', + 'sounders.form.ip.label': 'Adres IP', + 'sounders.form.ip.helper': 'Adres IP, pod którym kontroler łączy się z urządzeniem.', + 'sounders.add.submit': 'Dodaj urządzenie', + 'sounders.detail.metaFallback': 'Urządzenie', + 'sounders.detail.infoTitle': 'Informacje', + 'sounders.detail.ipLabel': 'IP', + 'sounders.detail.screenLabel': 'Ekran', + 'sounders.detail.ringerPinLabel': 'Pin dzwonka', + 'sounders.detail.ringerPin.none': 'Brak', + 'sounders.detail.resetButton': 'Resetuj klucz (wymaga ponownej rejestracji)', + 'sounders.detail.keyLabel': 'Klucz', + 'sounders.detail.zonesTitle': 'Strefy', + 'sounders.detail.addToZone': 'Dodaj do strefy', + 'sounders.detail.logTitle': 'Dziennik', + 'sounders.detail.editButton': 'Edytuj urządzenie', + 'common.yes': 'Tak', + 'common.no': 'Nie', + 'sounders.edit.metaTitle': 'Edytuj {{name}}', + 'sounders.edit.pageTitle': 'Edytuj urządzenie {{name}}', + 'sounders.form.ringer.label': 'PIN dzwonka', + 'sounders.form.ringer.helper': 'Numer pinu GPIO aktywującego przewód dzwonka.', + 'sounders.form.screen.label': 'Ekran', + 'sounders.form.screen.helper': + 'Włączyć interfejs ekranowy na tym urządzeniu? Po zmianie opcji należy zrestartować urządzenie.', + 'sounds.metaTitle': 'Dźwięki', + 'sounds.titleWithCount': 'Dźwięki ({{count}})', + 'sounds.table.name': 'Nazwa', + 'sounds.addButton': 'Dodaj dźwięk', + 'sounds.addTtsButton': 'Dodaj dźwięk TTS', + 'sounds.add.metaTitle': 'Dodaj dźwięk', + 'sounds.add.pageTitle': 'Dodaj dźwięk', + 'sounds.form.name.label': 'Nazwa', + 'sounds.form.name.helper': 'Opisowa nazwa dźwięku.', + 'sounds.form.file.label': 'Plik MP3', + 'sounds.form.file.helper': 'Plik MP3, który ma być użyty jako dźwięk.', + 'sounds.form.ringer.label': 'Przewód dzwonka', + 'sounds.form.ringer.helper': + 'Lista sekund oddzielonych przecinkami określająca pracę przekaźnika. Wzór: WŁ.,WYŁ.,WŁ.,WYŁ., np. 1,3,1,3. Upewnij się, że lista kończy się czasem wyłączenia.', + 'sounds.add.submit': 'Dodaj dźwięk', + 'sounds.addTts.metaTitle': 'Dodaj dźwięk TTS', + 'sounds.addTts.pageTitle': 'Dodaj dźwięk (tekst na mowę)', + 'sounds.form.text.label': 'Tekst', + 'sounds.form.text.helper': 'Tekst do wygenerowania.', + 'sounds.detail.metaFallback': 'Dźwięk', + 'sounds.detail.ringerWire': 'Przewód dzwonka', + 'sounds.detail.duration': 'Czas trwania', + 'sounds.detail.audioType': 'Typ audio', + 'sounds.detail.editButton': 'Edytuj dźwięk', + 'sounds.edit.metaTitle': 'Edytuj {{name}}', + 'sounds.edit.pageTitle': 'Edytuj dźwięk {{name}}', + 'sounds.form.file.helperEdit': 'Prześlij nowy plik MP3, aby zastąpić obecny.', + 'webhooks.metaTitle': 'Webhooki', + 'webhooks.inbound.titleWithCount': 'Webhooki przychodzące ({{count}})', + 'webhooks.inbound.table.webhook': 'Webhook', + 'webhooks.inbound.addButton': 'Dodaj webhook', + 'webhooks.outbound.titleWithCount': 'Webhooki wychodzące ({{count}})', + 'webhooks.outbound.table.webhook': 'Webhook wychodzący', + 'webhooks.outbound.addButton': 'Dodaj webhook wychodzący', + 'webhooks.add.metaTitle': 'Dodaj webhook', + 'webhooks.add.pageTitle': 'Dodaj webhook', + 'webhooks.form.slug.label': 'Slug (identyfikator)', + 'webhooks.form.slug.helper': 'Nazwa akcji wyświetlana na ekranach.', + 'webhooks.form.action.label': 'Akcja', + 'webhooks.form.action.helper': 'Jaka akcja ma zostać uruchomiona po wywołaniu webhooka?', + 'webhooks.add.submit': 'Dodaj', + 'webhooks.outbound.add.metaTitle': 'Dodaj webhook wychodzący', + 'webhooks.outbound.add.pageTitle': 'Dodaj webhook wychodzący', + 'webhooks.outbound.form.target.label': 'Adres docelowy', + 'webhooks.outbound.form.target.helper': 'Pełny adres URL webhooka.', + 'webhooks.outbound.form.event.label': 'Typ zdarzenia', + 'webhooks.outbound.form.event.helper': 'Które zdarzenia mają uruchamiać ten webhook?', + 'webhooks.outbound.add.submit': 'Dodaj webhook wychodzący', + 'webhooks.outbound.edit.metaTitle': 'Edytuj webhook wychodzący', + 'webhooks.outbound.edit.pageTitle': 'Edytuj webhook wychodzący', + 'webhooks.outbound.detail.pageTitle': 'Webhook wychodzący', + 'webhooks.outbound.detail.key': 'Klucz', + 'webhooks.outbound.detail.target': 'Adres docelowy', + 'webhooks.outbound.detail.editButton': 'Edytuj', + 'webhooks.edit.metaTitle': 'Edytuj webhook', + 'webhooks.edit.pageTitle': 'Edytuj webhook', + 'webhooks.detail.key': 'Klucz', + 'webhooks.detail.action': 'Akcja:', + 'webhooks.detail.broadcastNotice': + 'Jeśli webhook wykorzystuje akcję typu Nadawanie, dołącz do żądania JSON pole "zone": "ID_STREFY".', + 'webhooks.detail.editButton': 'Edytuj', + 'zones.metaTitle': 'Strefy', + 'zones.titleWithCount': 'Strefy ({{count}})', + 'zones.table.zone': 'Strefa', + 'zones.table.sounders': 'Urządzenia', + 'zones.table.schedules': 'Harmonogramy', + 'zones.addButton': 'Dodaj strefę', + 'zones.add.metaTitle': 'Dodaj strefę', + 'zones.add.pageTitle': 'Dodaj strefę', + 'zones.form.name.label': 'Nazwa', + 'zones.form.name.helper': 'Opisowa nazwa strefy.', + 'zones.add.submit': 'Dodaj strefę', + 'zones.edit.metaTitle': 'Edytuj {{name}}', + 'zones.edit.pageTitle': 'Edytuj strefę {{name}}', + 'zones.detail.metaFallback': 'Strefa', + 'zones.detail.soundersTitle': 'Urządzenia', + 'zones.detail.editButton': 'Edytuj strefę' +} as const + +export type PlMessages = typeof pl diff --git a/app/root.tsx b/app/root.tsx index 2e2b86d..af17258 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,5 +1,5 @@ -import {Links, Meta, Outlet, Scripts, ScrollRestoration} from '@remix-run/react' -import {type LinksFunction} from '@remix-run/node' +import {Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData, useRouteLoaderData} from '@remix-run/react' +import {json, type LinksFunction, type LoaderFunctionArgs} from '@remix-run/node' import BellIcon from '@heroicons/react/24/outline/BellIcon' import BellAlertIcon from '@heroicons/react/24/outline/BellAlertIcon' import Square3StackIcon from '@heroicons/react/24/outline/Square3Stack3DIcon' @@ -20,14 +20,30 @@ import LogIcon from '@heroicons/react/24/outline/ClipboardDocumentCheckIcon' import './tailwind.css' import {VERSION} from '~/lib/constants' +import {locales, type SupportedLocale} from '~/locales' import {SidebarLink, NavSep} from './lib/ui' +import {I18nProvider, useTranslation} from './lib/i18n' +import {initTranslations, type InitTranslationsReturn} from './lib/i18n.server' + +const FALLBACK_LOCALE: SupportedLocale = 'en' +const FALLBACK_TRANSLATIONS: InitTranslationsReturn = { + locale: FALLBACK_LOCALE, + messages: locales[FALLBACK_LOCALE] +} export const links: LinksFunction = () => [] +export const loader = async ({request}: LoaderFunctionArgs) => { + const {locale, messages} = initTranslations(request) + return json({locale, messages}) +} + export function Layout({children}: {children: React.ReactNode}) { + const data = useRouteLoaderData('root') ?? FALLBACK_TRANSLATIONS + const locale = data.locale ?? FALLBACK_LOCALE return ( - + @@ -43,66 +59,68 @@ export function Layout({children}: {children: React.ReactNode}) { ) } -const App = () => { +const AppContent = () => { + const {t} = useTranslation() + return (
- Open School Bell + {t('app.title')}
- Dashboard + {t('nav.dashboard')} - Broadcast + {t('nav.broadcast')} - Schedule + {t('nav.schedule')} - Calendar + {t('nav.calendar')} - Sounders + {t('nav.sounders')} - Sounds + {t('nav.sounds')} - Desktop Groups + {t('nav.desktopGroups')} - Actions + {t('nav.actions')} - Webhooks + {t('nav.webhooks')} - Zones + {t('nav.zones')} - Lockdown + {t('nav.lockdown')} - Settings + {t('nav.settings')} - About + {t('nav.about')} - Log + {t('nav.log')} - Backup + {t('nav.backup')} - Logout + {t('nav.logout')}
@@ -113,4 +131,14 @@ const App = () => { ) } +const App = () => { + const data = useLoaderData() ?? FALLBACK_TRANSLATIONS + + return ( + + + + ) +} + export default App diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 0735821..d9aed0a 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -5,12 +5,16 @@ import { } from '@remix-run/node' import {Link, useLoaderData} from '@remix-run/react' import {formatDistance} from 'date-fns' +import {enUS, pl} from 'date-fns/locale' import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {pageTitle} from '~/lib/utils' import {getSetting} from '~/lib/settings.server' import {Page} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' export const loader = async ({request}: LoaderFunctionArgs) => { const result = await checkSession(request) @@ -28,24 +32,27 @@ export const loader = async ({request}: LoaderFunctionArgs) => { return {sounders, lockdownMode} } -export const meta: MetaFunction = () => { - return [{title: pageTitle('Dashboard')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'dashboard.pageTitle'))}] } export default function Index() { const {sounders, lockdownMode} = useLoaderData() + const {t, locale} = useTranslation() + const dateLocale = locale === 'pl' ? pl : enUS return ( - +
-

Sounders

+

{t('dashboard.devices')}

- - - + + + @@ -64,7 +71,8 @@ export default function Index() { @@ -76,14 +84,26 @@ export default function Index() {
-

Lockdown Mode {lockdownMode === '0' ? 'Disabled' : 'Enabled'}

+

+ {t('dashboard.lockdown.message', { + status: t( + lockdownMode === '0' + ? 'dashboard.lockdown.status.disabled' + : 'dashboard.lockdown.status.enabled' + ) + })} +

{ if ( !confirm( - `Are you sure you want to ${lockdownMode === '0' ? 'enable' : 'disable'} lockdown?` + t( + lockdownMode === '0' + ? 'dashboard.lockdown.confirmEnable' + : 'dashboard.lockdown.confirmDisable' + ) ) ) { e.preventDefault() @@ -91,7 +111,11 @@ export default function Index() { }} >
diff --git a/app/routes/about.tsx b/app/routes/about.tsx index 7db5f82..91728f4 100644 --- a/app/routes/about.tsx +++ b/app/routes/about.tsx @@ -15,6 +15,9 @@ import {VERSION, RequiredVersions} from '~/lib/constants' import {getRedis} from '~/lib/redis.server.mjs' import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' const {readFile} = fs.promises @@ -140,8 +143,9 @@ export const loader = async ({request}: LoaderFunctionArgs) => { } } -export const meta: MetaFunction = () => { - return [{title: pageTitle('About')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'about.title'))}] } const About = () => { @@ -154,16 +158,17 @@ const About = () => { controllerLatest, license } = useLoaderData() + const {t} = useTranslation() return ( - +
NameOnlineLast Seen{t('dashboard.table.name')}{t('dashboard.table.status')}{t('dashboard.table.lastSeen')}
{formatDistance(lastCheckIn, new Date(), { - addSuffix: true + addSuffix: true, + locale: dateLocale })}
- - - - + + + + @@ -208,7 +213,7 @@ const About = () => { {sounders.map(({id, name}) => { return ( - +
ComponentVersionLatest VersionRequired Version{t('about.table.component')}{t('about.table.version')}{t('about.table.latest')}{t('about.table.required')}
Sounder: {name}{`Sounder: ${name}`} {sounderVersions[id]} { - return [{title: pageTitle('Actions')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'actions.title'))}] } export const loader = async ({request, params}: LoaderFunctionArgs) => { @@ -34,26 +38,36 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { const Action = () => { const {action} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() + const typeLabels: Record = { + broadcast: t('actions.types.broadcast'), + lockdown: t('actions.types.lockdown') + } return (
-

Icon: {action.icon}

-

Type: {action.action}

- Sound:{' '} + {t('actions.detail.icon')}: {action.icon} +

+

+ {t('actions.detail.type')}:{' '} + {typeLabels[action.action] ?? action.action} +

+

+ {t('actions.detail.sound')}{' '} {action.audio!.name}

navigate('/actions') }, { - label: 'Edit', + label: t('button.edit'), color: 'bg-blue-300', onClick: () => navigate(`/actions/${action.id}/edit`) } diff --git a/app/routes/actions.$action.edit.tsx b/app/routes/actions.$action.edit.tsx index 8c9a674..01ae071 100644 --- a/app/routes/actions.$action.edit.tsx +++ b/app/routes/actions.$action.edit.tsx @@ -11,9 +11,22 @@ import {getPrisma} from '~/lib/prisma.server' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Actions', 'Edit')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches, data}) => { + const {messages} = getRootI18n(matches) + const name = data?.action.name ?? '' + + return [ + { + title: pageTitle( + translate(messages, 'actions.title'), + translate(messages, 'actions.edit.metaTitle', {name}) + ) + } + ] } export const loader = async ({request, params}: LoaderFunctionArgs) => { @@ -66,13 +79,14 @@ export const action = async ({params, request}: ActionFunctionArgs) => { const AddAction = () => { const {sounds, action} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - +
{ /> { /> - - + + @@ -50,7 +59,9 @@ const ActionsPage = () => { - +
ActionType{t('actions.table.action')}{t('actions.table.type')}
{name} {action} + {typeLabels[action] ? typeLabels[action] : action} + @@ -65,7 +76,7 @@ const ActionsPage = () => { navigate('/actions/add') } diff --git a/app/routes/actions.add.tsx b/app/routes/actions.add.tsx index 7b61a0e..ca6b325 100644 --- a/app/routes/actions.add.tsx +++ b/app/routes/actions.add.tsx @@ -12,9 +12,20 @@ import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' import {trigger} from '~/lib/trigger' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Actions', 'Add')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'actions.title'), + translate(messages, 'actions.add.pageTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -64,34 +75,35 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddAction = () => { const {sounds} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - + diff --git a/app/routes/broadcast._index.tsx b/app/routes/broadcast._index.tsx index d08bf97..95830d7 100644 --- a/app/routes/broadcast._index.tsx +++ b/app/routes/broadcast._index.tsx @@ -8,9 +8,13 @@ import {useNavigate} from '@remix-run/react' import {pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Actions, Page} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Broadcast')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'broadcast.pageTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -25,9 +29,10 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const Broadcast = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - +
{
- Broadcast a sound (and its ringer wire) to a given zone or desktop - group. + {t('broadcast.description')}
navigate('/broadcast/builder') } diff --git a/app/routes/broadcast.builder.tsx b/app/routes/broadcast.builder.tsx index 06f04c1..8c5fe9e 100644 --- a/app/routes/broadcast.builder.tsx +++ b/app/routes/broadcast.builder.tsx @@ -15,9 +15,20 @@ import { useStatefulLocalStorage, clearLocalStorage } from '~/lib/hooks/use-local-storage' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Broadcast', 'Sound')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'broadcast.pageTitle'), + translate(messages, 'broadcast.builder.metaTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -40,6 +51,7 @@ const BroadcastSound = () => { const [LSqueue, setLSQueue] = useStatefulLocalStorage('broadcast-queue', '[]') const [selectedSound, setSelectedSound] = useState(sounds[0].id) const [searchParams] = useSearchParams() + const {t} = useTranslation() const queue = JSON.parse(LSqueue) as string[] @@ -56,7 +68,7 @@ const BroadcastSound = () => { let duration = 0 return ( - +
{ />
- + { e.preventDefault() navigate('/broadcast') } }, - {label: 'Re-Broadcast', color: 'bg-green-300'} + {label: t('broadcast.finish.rebroadcast'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/broadcast.tts.tsx b/app/routes/broadcast.tts.tsx index 837cec0..1005ec3 100644 --- a/app/routes/broadcast.tts.tsx +++ b/app/routes/broadcast.tts.tsx @@ -17,9 +17,20 @@ import {Actions, Page, FormElement} from '~/lib/ui' import {getPrisma} from '~/lib/prisma.server' import {getSetting} from '~/lib/settings.server' import {updateSounders} from '~/lib/update-sounders.server' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Broadcast', 'Text to Speech')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'broadcast.pageTitle'), + translate(messages, 'broadcast.tts.metaTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -87,9 +98,10 @@ export const action = async ({request}: ActionFunctionArgs) => { const BroadcastTTS = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - +
{ />
- + { e.preventDefault() @@ -118,7 +133,7 @@ const BroadcastTTS = () => { } }, { - label: 'Next', + label: t('button.next'), color: 'bg-blue-300' } ]} diff --git a/app/routes/broadcast.zone.tsx b/app/routes/broadcast.zone.tsx index 4bd7756..47871aa 100644 --- a/app/routes/broadcast.zone.tsx +++ b/app/routes/broadcast.zone.tsx @@ -9,9 +9,20 @@ import {pageTitle, INPUT_CLASSES} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Actions, FormElement, Page} from '~/lib/ui' import {getPrisma} from '~/lib/prisma.server' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Broadcast', 'Zone')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'broadcast.pageTitle'), + translate(messages, 'broadcast.zone.metaTitle') + ) + } + ] } export const action = async ({request}: ActionFunctionArgs) => { @@ -39,15 +50,16 @@ export const action = async ({request}: ActionFunctionArgs) => { const BroadcastZone = () => { const navigate = useNavigate() const data = useActionData() + const {t} = useTranslation() if (!data) { - return
ERROR
+ return
{t('common.error')}
} const {queue, zones, count} = data return ( - +
{
- + @@ -197,12 +231,12 @@ const CalendarPage = () => { navigate('/days/add') }, { - label: 'Manage Assignments', + label: t('calendar.buttons.manageAssignments'), color: 'bg-blue-300', onClick: () => navigate('/days/assignments') } diff --git a/app/routes/days.$day.edit.tsx b/app/routes/days.$day.edit.tsx index cc7d621..0e3afe9 100644 --- a/app/routes/days.$day.edit.tsx +++ b/app/routes/days.$day.edit.tsx @@ -10,6 +10,7 @@ import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' import {INPUT_CLASSES} from '~/lib/utils' +import {useTranslation} from '~/lib/i18n' export const loader = async ({request, params}: LoaderFunctionArgs) => { const result = await checkSession(request) @@ -53,11 +54,15 @@ export const action = async ({request, params}: ActionFunctionArgs) => { const AddDay = () => { const {dayType} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - + - + { { e.preventDefault() @@ -75,7 +80,7 @@ const AddDay = () => { } }, { - label: 'Edit Day', + label: t('button.saveChanges'), color: 'bg-green-300' } ]} diff --git a/app/routes/days.add.tsx b/app/routes/days.add.tsx index 44ed0a3..04433f6 100644 --- a/app/routes/days.add.tsx +++ b/app/routes/days.add.tsx @@ -1,7 +1,8 @@ import { redirect, type ActionFunctionArgs, - type LoaderFunctionArgs + type LoaderFunctionArgs, + type MetaFunction } from '@remix-run/node' import {useNavigate, useLoaderData} from '@remix-run/react' import {invariant} from '@arcath/utils' @@ -9,7 +10,22 @@ import {invariant} from '@arcath/utils' import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' -import {INPUT_CLASSES} from '~/lib/utils' +import {INPUT_CLASSES, pageTitle} from '~/lib/utils' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'calendar.metaTitle'), + translate(messages, 'days.add.pageTitle') + ) + } + ] +} export const loader = async ({request}: LoaderFunctionArgs) => { const result = await checkSession(request) @@ -62,22 +78,26 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddDay = () => { const navigate = useNavigate() const {days} = useLoaderData() + const {t} = useTranslation() return ( - + - +
Day{t('calendar.dayTypesTable.day')}
- - + + @@ -115,11 +124,11 @@ const DayAssignments = () => {
DateDay Type{t('days.assignments.table.date')}{t('days.assignments.table.dayType')}
- + { /> { /> - - + + @@ -50,7 +55,7 @@ const DesktopGroups = () => { navigate('/desktop-groups/add') } diff --git a/app/routes/desktop-groups.add.tsx b/app/routes/desktop-groups.add.tsx index 2363931..64ed4ae 100644 --- a/app/routes/desktop-groups.add.tsx +++ b/app/routes/desktop-groups.add.tsx @@ -1,15 +1,24 @@ import { redirect, type ActionFunctionArgs, - type LoaderFunctionArgs + type LoaderFunctionArgs, + type MetaFunction } from '@remix-run/node' import {useNavigate} from '@remix-run/react' import {invariant} from '@arcath/utils' import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' -import {INPUT_CLASSES, makeKey} from '~/lib/utils' +import {INPUT_CLASSES, makeKey, pageTitle} from '~/lib/utils' import {Page, FormElement, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'desktopGroups.metaTitle'), translate(messages, 'desktopGroups.add.pageTitle'))}] +} export const loader = async ({request}: LoaderFunctionArgs) => { const result = await checkSession(request) @@ -47,17 +56,21 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddDay = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - + - + { e.preventDefault() @@ -65,7 +78,7 @@ const AddDay = () => { } }, { - label: 'Add Desktop Group', + label: t('desktopGroups.add.submit'), color: 'bg-green-300' } ]} diff --git a/app/routes/lockdown.tsx b/app/routes/lockdown.tsx index f9ee639..769a989 100644 --- a/app/routes/lockdown.tsx +++ b/app/routes/lockdown.tsx @@ -12,9 +12,13 @@ import {getSettings, setSetting} from '~/lib/settings.server' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Lockdown')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'lockdown.metaTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -107,16 +111,21 @@ const Lockdown = () => { lockdownRepetitions, sounds } = useLoaderData() + const {t} = useTranslation() return ( - +
{`${lockdownMode === '1' ? 'Lockdown Active' : 'Lockdown Inactive'} `}
+ > + {lockdownMode === '1' + ? t('lockdown.status.active') + : t('lockdown.status.inactive')}{' '} + { /> { /> { /> { defaultChecked={lockdownRepeatRingerWire === '1'} /> - +
) diff --git a/app/routes/log.tsx b/app/routes/log.tsx index 0b4c9e0..07b20e8 100644 --- a/app/routes/log.tsx +++ b/app/routes/log.tsx @@ -10,9 +10,13 @@ import {pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page} from '~/lib/ui' import {getPrisma} from '~/lib/prisma.server' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Log')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'log.metaTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -31,14 +35,15 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const Log = () => { const {logs} = useLoaderData() + const {t} = useTranslation() return ( - +
NameKey{t('desktopGroups.table.name')}{t('desktopGroups.table.key')}
- - + + diff --git a/app/routes/login.tsx b/app/routes/login.tsx index a819f9b..abb1eb1 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -10,13 +10,19 @@ import {getSession, commitSession, jwtCreate} from '~/lib/session' import {getSetting} from '~/lib/settings.server' import {Page, FormElement, Actions} from '~/lib/ui' import {trigger} from '~/lib/trigger' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Login')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' +import {initTranslations} from '~/lib/i18n.server' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'auth.login.metaTitle'))}] } export const action = async ({request}: ActionFunctionArgs) => { const checkPassword = await getSetting('password') + const {messages} = initTranslations(request) const formData = await request.formData() @@ -26,7 +32,7 @@ export const action = async ({request}: ActionFunctionArgs) => { if (password !== checkPassword) { await trigger('🔒 Bad password supplied', 'ignore') - return {error: 'Incorrect Password'} + return {error: translate(messages, 'auth.login.error')} } const session = await getSession(request.headers.get('Cookie')) @@ -39,13 +45,14 @@ export const action = async ({request}: ActionFunctionArgs) => { } const Login = () => { + const {t} = useTranslation() return ( - +
- + - +
) diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx index e7c04b4..ae3ce7f 100644 --- a/app/routes/logout.tsx +++ b/app/routes/logout.tsx @@ -1,5 +1,6 @@ import {type ActionFunctionArgs, redirect} from '@remix-run/node' import {getSession, destroySession} from '~/lib/session' +import {useTranslation} from '~/lib/i18n' export const action = async ({request}: ActionFunctionArgs) => { const session = await getSession(request.headers.get('Cookie')) @@ -11,11 +12,12 @@ export const action = async ({request}: ActionFunctionArgs) => { } export default function LogoutRoute() { + const {t} = useTranslation() return ( <> -

Are you sure you want to log out?

+

{t('auth.logout.message')}

- + ) diff --git a/app/routes/schedule.$schedule.tsx b/app/routes/schedule.$schedule.tsx index d615c12..4ccee60 100644 --- a/app/routes/schedule.$schedule.tsx +++ b/app/routes/schedule.$schedule.tsx @@ -11,10 +11,21 @@ import {getPrisma} from '~/lib/prisma.server' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' - -export const meta: MetaFunction = ({data}) => { +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' +import {initTranslations} from '~/lib/i18n.server' + +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) + const time = data?.schedule.time ?? '' return [ - {title: pageTitle('Schedule', data ? data.schedule.time : 'View Schedule')} + { + title: pageTitle( + translate(messages, 'schedule.metaTitle'), + translate(messages, 'schedule.edit.metaTitle', {time}) + ) + } ] } @@ -48,6 +59,7 @@ export const action: ActionFunction = async ({request, params}) => { const prisma = getPrisma() const formData = await request.formData() + const {messages} = initTranslations(request) const monday = formData.get('day[1]') const tuesday = formData.get('day[2]') @@ -76,7 +88,7 @@ export const action: ActionFunction = async ({request, params}) => { .join(',') if (days === '') { - throw new Error('Days must be defined') + throw new Error(translate(messages, 'schedule.error.noDays')) } const time = formData.get('time') as string | undefined @@ -109,23 +121,24 @@ export const action: ActionFunction = async ({request, params}) => { const EditSchedule = () => { const {zones, days, sounds, schedule} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - +
{[ - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - 'Sunday' - ].map((day, i) => { + t('calendar.weekdays.monday'), + t('calendar.weekdays.tuesday'), + t('calendar.weekdays.wednesday'), + t('calendar.weekdays.thursday'), + t('calendar.weekdays.friday'), + t('calendar.weekdays.saturday'), + t('calendar.weekdays.sunday') + ].map((dayLabel, i) => { return (
{ /> { { e.preventDefault() navigate('/schedule') } }, - {label: 'Edit Schedule', color: 'bg-green-300'} + {label: t('button.saveChanges'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/schedule._index.tsx b/app/routes/schedule._index.tsx index 357cd09..c96d6bb 100644 --- a/app/routes/schedule._index.tsx +++ b/app/routes/schedule._index.tsx @@ -10,9 +10,13 @@ import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, Actions} from '~/lib/ui' import {useStatefulLocalStorage} from '~/lib/hooks/use-local-storage' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Schedule')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'schedule.metaTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -40,9 +44,10 @@ const Schedule = () => { const {schedules, days} = useLoaderData() const [day, setDay] = useStatefulLocalStorage('day', '_') const navigate = useNavigate() + const {t} = useTranslation() return ( - +
TimeMessage{t('log.columns.time')}{t('log.columns.message')}
- - - - - - - - - - - - + + + + + + + + + + + + @@ -128,7 +133,7 @@ const Schedule = () => { navigate('/schedule/add') } diff --git a/app/routes/schedule.add.tsx b/app/routes/schedule.add.tsx index 00affca..085d5a2 100644 --- a/app/routes/schedule.add.tsx +++ b/app/routes/schedule.add.tsx @@ -12,9 +12,21 @@ import {getPrisma} from '~/lib/prisma.server' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {useLocalStorage} from '~/lib/hooks/use-local-storage' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Schedule', 'Add')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' +import {initTranslations} from '~/lib/i18n.server' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'schedule.metaTitle'), + translate(messages, 'schedule.add.pageTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -43,6 +55,7 @@ export const action: ActionFunction = async ({request}) => { const prisma = getPrisma() const formData = await request.formData() + const {messages} = initTranslations(request) const monday = formData.get('day[1]') const tuesday = formData.get('day[2]') @@ -71,7 +84,7 @@ export const action: ActionFunction = async ({request}) => { .join(',') if (days === '') { - throw new Error('Days must be defined') + throw new Error(translate(messages, 'schedule.error.noDays')) } const time = formData.get('time') as string | undefined @@ -107,37 +120,38 @@ const AddSchedule = () => { const [zone, setZone] = useLocalStorage('zone', zones[0].id) const [sound, setSound] = useLocalStorage('sound', sounds[0].id) const [count, setCount] = useLocalStorage('count', '1') + const {t} = useTranslation() return ( - +
{[ - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', - 'Sunday' - ].map((day, i) => { + t('calendar.weekdays.monday'), + t('calendar.weekdays.tuesday'), + t('calendar.weekdays.wednesday'), + t('calendar.weekdays.thursday'), + t('calendar.weekdays.friday'), + t('calendar.weekdays.saturday'), + t('calendar.weekdays.sunday') + ].map((dayLabel, i) => { return ( ) })}
{ { e.preventDefault() navigate('/schedule') } }, - {label: 'Add Schedule', color: 'bg-green-300'} + {label: t('schedule.add.submit'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 9a3a512..15d2aa8 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -11,9 +11,13 @@ import {getSettings, setSetting} from '~/lib/settings.server' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Lockdown')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'settings.pageTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -54,13 +58,14 @@ export const action = async ({request}: ActionFunctionArgs) => { const Settings = () => { const {ttsSpeed, enrollUrl} = useLoaderData() + const {t} = useTranslation() return ( - +
{ /> { defaultValue={ttsSpeed} /> - + diff --git a/app/routes/sounder-api.log.tsx b/app/routes/sounder-api.log.tsx index 076b38f..a1174c5 100644 --- a/app/routes/sounder-api.log.tsx +++ b/app/routes/sounder-api.log.tsx @@ -1,5 +1,4 @@ import {type ActionFunctionArgs} from '@remix-run/node' -import {invariant} from '@arcath/utils' import {getPrisma} from '~/lib/prisma.server' @@ -9,15 +8,24 @@ export const action = async ({request}: ActionFunctionArgs) => { message?: string } - invariant(key) - invariant(message) + if (!key || typeof key !== 'string') { + return Response.json({error: 'missing key'}, {status: 400}) + } + + if (!message || typeof message !== 'string') { + return Response.json({error: 'missing message'}, {status: 400}) + } const prisma = getPrisma() - const sounder = await prisma.sounder.findFirstOrThrow({ + const sounder = await prisma.sounder.findFirst({ where: {key, enrolled: true} }) + if (!sounder) { + return Response.json({error: 'sounder not found'}, {status: 401}) + } + await prisma.sounderLog.create({data: {message, sounderId: sounder.id}}) return Response.json({status: 'ok'}) diff --git a/app/routes/sounder-api.trigger-action.tsx b/app/routes/sounder-api.trigger-action.tsx index dd5c826..e36f136 100644 --- a/app/routes/sounder-api.trigger-action.tsx +++ b/app/routes/sounder-api.trigger-action.tsx @@ -1,5 +1,4 @@ import {type ActionFunctionArgs} from '@remix-run/node' -import {invariant} from '@arcath/utils' import {getPrisma} from '~/lib/prisma.server' import {broadcast} from '~/lib/broadcast.server' @@ -12,24 +11,41 @@ export const action = async ({request}: ActionFunctionArgs) => { zone?: string } - invariant(key) - invariant(action) - invariant(zone) + if (!key || typeof key !== 'string') { + return Response.json({error: 'missing key'}, {status: 400}) + } + + if (!action || typeof action !== 'string') { + return Response.json({error: 'missing action'}, {status: 400}) + } const prisma = getPrisma() - await prisma.sounder.findFirstOrThrow({ + const sounder = await prisma.sounder.findFirst({ where: {key, enrolled: true} }) - const dbAction = await prisma.action.findFirstOrThrow({ + if (!sounder) { + return Response.json({error: 'sounder not found'}, {status: 401}) + } + + const dbAction = await prisma.action.findFirst({ where: {id: action} }) + if (!dbAction) { + return Response.json({error: 'action not found'}, {status: 404}) + } + switch (dbAction.action) { case 'broadcast': + if (!zone || typeof zone !== 'string' || zone.trim() === '') { + return Response.json({error: 'missing zone'}, {status: 400}) + } + if (dbAction.audioId) { - await broadcast(zone, JSON.stringify([dbAction.audioId])) + const zoneId = zone.trim() + await broadcast(zoneId, JSON.stringify([dbAction.audioId])) } break case 'lockdown': diff --git a/app/routes/sounders.$sounder._index.tsx b/app/routes/sounders.$sounder._index.tsx index a94a0a3..6e745ec 100644 --- a/app/routes/sounders.$sounder._index.tsx +++ b/app/routes/sounders.$sounder._index.tsx @@ -11,10 +11,16 @@ import {checkSession} from '~/lib/session' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {Page, Actions} from '~/lib/ui' import {getSetting} from '~/lib/settings.server' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) + const name = data ? data.sounder.name : translate(messages, 'sounders.detail.metaFallback') -export const meta: MetaFunction = ({data}) => { return [ - {title: pageTitle('Sounders', data ? data.sounder.name : 'View Sounder')} + {title: pageTitle(translate(messages, 'sounders.metaTitle'), name)} ] } @@ -48,26 +54,38 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { const Sounder = () => { const {sounder, zones, enrollUrl} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() + const screenLabel = sounder.screen ? t('common.yes') : t('common.no') + const ringerPinLabel = + sounder.ringerPin === 0 + ? t('sounders.detail.ringerPin.none') + : String(sounder.ringerPin) return (
-

About

-

IP: {sounder.ip}

-

Screen: {sounder.screen ? 'Yes' : 'No'}

+

{t('sounders.detail.infoTitle')}

+

+ {t('sounders.detail.ipLabel')}: {sounder.ip} +

+

+ {t('sounders.detail.screenLabel')}: {screenLabel} +

- Ringer Pin: {sounder.ringerPin === 0 ? 'No' : sounder.ringerPin} + {t('sounders.detail.ringerPinLabel')}: {ringerPinLabel}

{sounder.enrolled ? (
) : ( <> -

Key: {sounder.key}

+

+ {t('sounders.detail.keyLabel')}: {sounder.key} +

                   sounder --enroll {sounder.key} --controller {enrollUrl}
@@ -77,7 +95,7 @@ const Sounder = () => {
           )}
         
-

Zones

+

{t('sounders.detail.zonesTitle')}

    {sounder.zones.map(({id, zone}) => { return ( @@ -105,18 +123,18 @@ const Sounder = () => {
-

Log

+

{t('sounders.detail.logTitle')}

TimeMondayTuesdayWednesdayThursdayFridaySaturdaySundayZoneSoundCount
{t('schedule.table.time')}{t('calendar.weekdays.monday')}{t('calendar.weekdays.tuesday')}{t('calendar.weekdays.wednesday')}{t('calendar.weekdays.thursday')}{t('calendar.weekdays.friday')}{t('calendar.weekdays.saturday')}{t('calendar.weekdays.sunday')}{t('schedule.table.zone')}{t('schedule.table.sound')}{t('schedule.table.count')}
- - + + @@ -137,7 +155,7 @@ const Sounder = () => { navigate(`/sounders/${sounder.id}/edit`) } diff --git a/app/routes/sounders.$sounder.edit.tsx b/app/routes/sounders.$sounder.edit.tsx index 4d7b211..f9e63e7 100644 --- a/app/routes/sounders.$sounder.edit.tsx +++ b/app/routes/sounders.$sounder.edit.tsx @@ -11,13 +11,21 @@ import {getPrisma} from '~/lib/prisma.server' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = ({data}) => { +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) return [ { title: pageTitle( - 'Sounders', - data ? `Edit ${data.sounder.name}` : 'Edit Sounder' + translate(messages, 'sounders.metaTitle'), + data + ? translate(messages, 'sounders.edit.metaTitle', { + name: data.sounder.name + }) + : translate(messages, 'sounders.edit.metaTitle', {name: ''}) ) } ] @@ -70,13 +78,14 @@ export const action = async ({request, params}: ActionFunctionArgs) => { const EditSounder = () => { const {sounder} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - +
{ /> { /> { /> { { e.preventDefault() navigate(`/sounders/${sounder.id}`) } }, - {label: 'Edit Sounder', color: 'bg-green-300'} + {label: t('button.saveChanges'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/sounders._index.tsx b/app/routes/sounders._index.tsx index 769e906..eefb5ba 100644 --- a/app/routes/sounders._index.tsx +++ b/app/routes/sounders._index.tsx @@ -9,9 +9,13 @@ import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {pageTitle} from '~/lib/utils' import {Page, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Sounders')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'sounders.metaTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -31,17 +35,18 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const Sounders = () => { const {sounders} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return (
TimeMessage{t('log.columns.time')}{t('log.columns.message')}
- + @@ -61,7 +66,7 @@ const Sounders = () => { navigate('/sounders/add') } diff --git a/app/routes/sounders.add.tsx b/app/routes/sounders.add.tsx index fd8cbda..c094d91 100644 --- a/app/routes/sounders.add.tsx +++ b/app/routes/sounders.add.tsx @@ -11,9 +11,20 @@ import {getPrisma} from '~/lib/prisma.server' import {makeKey, INPUT_CLASSES, pageTitle} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Sounders', 'Add')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'sounders.metaTitle'), + translate(messages, 'sounders.add.pageTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -52,33 +63,34 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddSounder = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - +
{ e.preventDefault() navigate('/sounders') } }, - {label: 'Add Sounder', color: 'bg-green-300'} + {label: t('sounders.add.submit'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/sounds.$sound._index.tsx b/app/routes/sounds.$sound._index.tsx index 4e324c6..77e5310 100644 --- a/app/routes/sounds.$sound._index.tsx +++ b/app/routes/sounds.$sound._index.tsx @@ -11,9 +11,14 @@ import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {pageTitle, getSecondsAsTime} from '~/lib/utils' import {Page, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = ({data}) => { - return [{title: pageTitle('Sounds', data ? data.sound.name : 'View Sound')}] +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) + const name = data ? data.sound.name : translate(messages, 'sounds.detail.metaFallback') + return [{title: pageTitle(translate(messages, 'sounds.metaTitle'), name)}] } export const loader = async ({request, params}: LoaderFunctionArgs) => { @@ -53,6 +58,7 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { const Sound = () => { const {sound} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( @@ -60,19 +66,25 @@ const Sound = () => { -

Ringer Wire: {sound.ringerWire}

-

Duration: {getSecondsAsTime(sound.duration)}

-

Audio Type: {sound.audioContainer}

+

+ {t('sounds.detail.ringerWire')}: {sound.ringerWire} +

+

+ {t('sounds.detail.duration')}: {getSecondsAsTime(sound.duration)} +

+

+ {t('sounds.detail.audioType')}: {sound.audioContainer} +

navigate('/sounds') }, { - label: 'Edit Sound', + label: t('sounds.detail.editButton'), color: 'bg-blue-300', onClick: () => navigate(`/sounds/${sound.id}/edit`) } diff --git a/app/routes/sounds.$sound.edit.tsx b/app/routes/sounds.$sound.edit.tsx index 6218d84..cf19b11 100644 --- a/app/routes/sounds.$sound.edit.tsx +++ b/app/routes/sounds.$sound.edit.tsx @@ -17,12 +17,22 @@ import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {Page, FormElement, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' const {rename} = fs.promises -export const meta: MetaFunction = ({data}) => { +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) + const name = data ? data.sound.name : translate(messages, 'sounds.detail.metaFallback') return [ - {title: pageTitle('Sounds', data ? data.sound.name : 'Sound', 'Edit')} + { + title: pageTitle( + translate(messages, 'sounds.metaTitle'), + translate(messages, 'sounds.edit.metaTitle', {name}) + ) + } ] } @@ -105,11 +115,15 @@ export const action = async ({request, params}: ActionFunctionArgs) => { const AddSound = () => { const {sound} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - +
- + { /> { /> - Ringer Wire { { e.preventDefault() navigate(`/sounds/${sound.id}`) } }, - {label: 'Edit Sound', color: 'bg-green-300'} + {label: t('button.saveChanges'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/sounds._index.tsx b/app/routes/sounds._index.tsx index d4b097e..6a232c5 100644 --- a/app/routes/sounds._index.tsx +++ b/app/routes/sounds._index.tsx @@ -9,9 +9,13 @@ import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {pageTitle} from '~/lib/utils' import {Page, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Sounds')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'sounds.metaTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -31,17 +35,18 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const Sounds = () => { const {sounds} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return (
Sounder{t('sounders.table.device')}
- + @@ -66,12 +71,12 @@ const Sounds = () => { navigate('/sounds/add') }, { - label: 'Add Text-To-Speech Sound', + label: t('sounds.addTtsButton'), color: 'bg-green-300', onClick: () => navigate('/sounds/add-tts') } diff --git a/app/routes/sounds.add-tts.tsx b/app/routes/sounds.add-tts.tsx index 7ef7e36..ad54a97 100644 --- a/app/routes/sounds.add-tts.tsx +++ b/app/routes/sounds.add-tts.tsx @@ -18,9 +18,20 @@ import {checkSession} from '~/lib/session' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {getSetting} from '~/lib/settings.server' import {Page, FormElement, Actions} from '~/lib/ui' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Sounds', 'Add TTS')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'sounds.metaTitle'), + translate(messages, 'sounds.addTts.metaTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -97,33 +108,40 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddSound = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - +
- + - + { e.preventDefault() navigate('/sounds') } }, - {label: 'Add Sound', color: 'bg-green-300'} + {label: t('sounds.add.submit'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/sounds.add.tsx b/app/routes/sounds.add.tsx index a133171..fd6619a 100644 --- a/app/routes/sounds.add.tsx +++ b/app/routes/sounds.add.tsx @@ -19,11 +19,22 @@ import {checkSession} from '~/lib/session' import {INPUT_CLASSES, pageTitle} from '~/lib/utils' import {Page, FormElement, Actions} from '~/lib/ui' import {updateSounders} from '~/lib/update-sounders.server' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' const {rename} = fs.promises -export const meta: MetaFunction = () => { - return [{title: pageTitle('Sounds', 'Add Sound')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'sounds.metaTitle'), + translate(messages, 'sounds.add.metaTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -113,16 +124,20 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddSound = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - +
- + { /> { e.preventDefault() navigate('/sounds') } }, - {label: 'Add Sound', color: 'bg-green-300'} + {label: t('sounds.add.submit'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/webhooks.$webhook._index.tsx b/app/routes/webhooks.$webhook._index.tsx index b028f9f..62e5efb 100644 --- a/app/routes/webhooks.$webhook._index.tsx +++ b/app/routes/webhooks.$webhook._index.tsx @@ -10,9 +10,13 @@ import {checkSession} from '~/lib/session' import {pageTitle} from '~/lib/utils' import {Page, Actions} from '~/lib/ui' import {getSetting} from '~/lib/settings.server' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Webhooks')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'webhooks.metaTitle'))}] } export const loader = async ({request, params}: LoaderFunctionArgs) => { @@ -37,34 +41,34 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { const Webhook = () => { const {webhook, controllerUrl} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return (
-

Key: {webhook.key}

- Action:{' '} + {t('webhooks.detail.key')}: {webhook.key} +

+

+ {t('webhooks.detail.action')}{' '} {webhook.action.name}

- curl -H 'Content-Type: application/json' -d ' - {`{"key": "${webhook.key}"}`}' -X POST {controllerUrl}/hook/ - {webhook.slug} + {`curl -H 'Content-Type: application/json' -d '{"key": "${webhook.key}"}' -X POST ${controllerUrl}/hook/${webhook.slug}`}

- If this webhook uses an action that has the type Broadcast, your - request JSON must include "zone": "ZONE ID" + {t('webhooks.detail.broadcastNotice')}{' '}

navigate('/webhooks') }, { - label: 'Edit', + label: t('webhooks.detail.editButton'), color: 'bg-blue-300', onClick: () => navigate(`/webhooks/${webhook.id}/edit`) } diff --git a/app/routes/webhooks.$webhook.edit.tsx b/app/routes/webhooks.$webhook.edit.tsx index b480899..2c3633e 100644 --- a/app/routes/webhooks.$webhook.edit.tsx +++ b/app/routes/webhooks.$webhook.edit.tsx @@ -11,9 +11,20 @@ import {getPrisma} from '~/lib/prisma.server' import {INPUT_CLASSES, pageTitle, makeKey} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Webhooks', 'Add')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'webhooks.metaTitle'), + translate(messages, 'webhooks.edit.metaTitle') + ) + } + ] } export const loader = async ({request, params}: LoaderFunctionArgs) => { @@ -64,13 +75,14 @@ export const action = async ({request, params}: ActionFunctionArgs) => { const EditWebhook = () => { const {actions, webhook} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - +
{ />
Name{t('sounds.table.name')}
- + @@ -70,19 +75,23 @@ const WebhooksPage = () => { navigate('/webhooks/add') } ]} /> - +
Webhook{t('webhooks.inbound.table.webhook')}
- + @@ -110,7 +119,7 @@ const WebhooksPage = () => { navigate('/webhooks/outbound/add') } diff --git a/app/routes/webhooks.add.tsx b/app/routes/webhooks.add.tsx index 42a6c36..4aeff07 100644 --- a/app/routes/webhooks.add.tsx +++ b/app/routes/webhooks.add.tsx @@ -11,9 +11,20 @@ import {getPrisma} from '~/lib/prisma.server' import {INPUT_CLASSES, pageTitle, makeKey} from '~/lib/utils' import {checkSession} from '~/lib/session' import {Page, FormElement, Actions} from '~/lib/ui' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Webhooks', 'Add')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'webhooks.metaTitle'), + translate(messages, 'webhooks.add.metaTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -59,19 +70,20 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddWebhook = () => { const {actions} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - + { /> { { e.preventDefault() navigate(`/zones/${zone.id}`) } }, - {label: 'Edit Zone', color: 'bg-green-300'} + {label: t('button.saveChanges'), color: 'bg-green-300'} ]} /> diff --git a/app/routes/zones._index.tsx b/app/routes/zones._index.tsx index 67e776d..63a0b03 100644 --- a/app/routes/zones._index.tsx +++ b/app/routes/zones._index.tsx @@ -9,9 +9,13 @@ import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {pageTitle} from '~/lib/utils' import {Page, Actions} from '~/lib/ui' +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = () => { - return [{title: pageTitle('Zones')}] +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [{title: pageTitle(translate(messages, 'zones.metaTitle'))}] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -34,16 +38,17 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const Zones = () => { const {zones} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( - +
Outbound Webhook{t('webhooks.outbound.table.webhook')}
- - - + + + @@ -70,7 +75,7 @@ const Zones = () => { navigate('/zones/add') } diff --git a/app/routes/zones.add.tsx b/app/routes/zones.add.tsx index cd7fe85..c05c748 100644 --- a/app/routes/zones.add.tsx +++ b/app/routes/zones.add.tsx @@ -11,9 +11,20 @@ import {getPrisma} from '~/lib/prisma.server' import {checkSession} from '~/lib/session' import {pageTitle, INPUT_CLASSES} from '~/lib/utils' import {Page, FormElement, Actions} from '~/lib/ui' - -export const meta: MetaFunction = () => { - return [{title: pageTitle('Zones', 'Add')}] +import {useTranslation} from '~/lib/i18n' +import {translate} from '~/lib/i18n.shared' +import {getRootI18n} from '~/lib/i18n.meta' + +export const meta: MetaFunction = ({matches}) => { + const {messages} = getRootI18n(matches) + return [ + { + title: pageTitle( + translate(messages, 'zones.metaTitle'), + translate(messages, 'zones.add.metaTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { @@ -48,24 +59,28 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddZone = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - +
- + { e.preventDefault() navigate('/zones') } }, - {label: 'Add Zone', color: 'bg-green-300'} + {label: t('zones.add.submit'), color: 'bg-green-300'} ]} /> From fbf816b073812f71379edf5164e0458d20d707ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Marcin=20Brzuchalski?= Date: Sun, 19 Oct 2025 19:33:48 +0200 Subject: [PATCH 2/8] Refactor calendar month localization to use i18n keys. --- app/locales/en.ts | 12 +++++++++++ app/locales/pl.ts | 12 +++++++++++ app/routes/calendar.tsx | 46 ++++++++++++++--------------------------- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/app/locales/en.ts b/app/locales/en.ts index 31c99e4..8c8395c 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -95,6 +95,18 @@ export const en = { 'calendar.metaTitle': 'Calendar', 'calendar.previous': '<< Previous month', 'calendar.next': 'Next month >>', + 'calendar.months.january': 'January', + 'calendar.months.february': 'February', + 'calendar.months.march': 'March', + 'calendar.months.april': 'April', + 'calendar.months.may': 'May', + 'calendar.months.june': 'June', + 'calendar.months.july': 'July', + 'calendar.months.august': 'August', + 'calendar.months.september': 'September', + 'calendar.months.october': 'October', + 'calendar.months.november': 'November', + 'calendar.months.december': 'December', 'calendar.weekdays.monday': 'Monday', 'calendar.weekdays.tuesday': 'Tuesday', 'calendar.weekdays.wednesday': 'Wednesday', diff --git a/app/locales/pl.ts b/app/locales/pl.ts index 3396dfd..c05c8c2 100644 --- a/app/locales/pl.ts +++ b/app/locales/pl.ts @@ -96,6 +96,18 @@ export const pl = { 'calendar.metaTitle': 'Kalendarz', 'calendar.previous': '<< Poprzedni miesiąc', 'calendar.next': 'Następny miesiąc >>', + 'calendar.months.january': 'Styczeń', + 'calendar.months.february': 'Luty', + 'calendar.months.march': 'Marzec', + 'calendar.months.april': 'Kwiecień', + 'calendar.months.may': 'Maj', + 'calendar.months.june': 'Czerwiec', + 'calendar.months.july': 'Lipiec', + 'calendar.months.august': 'Sierpień', + 'calendar.months.september': 'Wrzesień', + 'calendar.months.october': 'Październik', + 'calendar.months.november': 'Listopad', + 'calendar.months.december': 'Grudzień', 'calendar.weekdays.monday': 'Poniedziałek', 'calendar.weekdays.tuesday': 'Wtorek', 'calendar.weekdays.wednesday': 'Środa', diff --git a/app/routes/calendar.tsx b/app/routes/calendar.tsx index 0b666d7..56901ff 100644 --- a/app/routes/calendar.tsx +++ b/app/routes/calendar.tsx @@ -41,36 +41,20 @@ export const loader = async ({request}: LoaderFunctionArgs) => { return {dayAssigments, days} } -const MONTH_LABELS: Record = { - en: [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December' - ], - pl: [ - 'Styczeń', - 'Luty', - 'Marzec', - 'Kwiecień', - 'Maj', - 'Czerwiec', - 'Lipiec', - 'Sierpień', - 'Wrzesień', - 'Październik', - 'Listopad', - 'Grudzień' - ] -} +const MONTH_KEYS: readonly string[] = [ + 'calendar.months.january', + 'calendar.months.february', + 'calendar.months.march', + 'calendar.months.april', + 'calendar.months.may', + 'calendar.months.june', + 'calendar.months.july', + 'calendar.months.august', + 'calendar.months.september', + 'calendar.months.october', + 'calendar.months.november', + 'calendar.months.december' +] const getStateDate = (date = new Date()) => { const year = date.getFullYear() @@ -117,7 +101,7 @@ const CalendarPage = () => { useState(getStateDate()) const {days, dayAssigments} = useLoaderData() const navigate = useNavigate() - const monthLabels = MONTH_LABELS[locale] ?? MONTH_LABELS.en + const monthLabels = MONTH_KEYS.map(key => t(key)) const weekdayKeys = [ 'calendar.weekdays.monday', 'calendar.weekdays.tuesday', From 9cf8971ff9b0eeb3b513b8e2dd05b35cb1abb076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Marcin=20Brzuchalski?= Date: Sun, 19 Oct 2025 20:05:48 +0200 Subject: [PATCH 3/8] Add i18n support for log messages in log route. --- app/locales/en.ts | 6 ++++++ app/locales/pl.ts | 6 ++++++ app/routes/log.tsx | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/locales/en.ts b/app/locales/en.ts index 8c8395c..c2a7c46 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -165,6 +165,12 @@ export const en = { 'log.pageTitle': 'Log', 'log.columns.time': 'Time', 'log.columns.message': 'Message', + 'log.messages.newAction': 'New Action: {{name}}', + 'log.messages.deletedAction': 'Deleted action: {{name}}', + 'log.messages.lockdownStart': '🔐 Lockdown Start', + 'log.messages.lockdownEnd': '🔐 Lockdown End', + 'log.messages.loggedIn': '🔓 Logged in', + 'log.messages.badPassword': '🔒 Bad password supplied', 'lockdown.metaTitle': 'Lockdown', 'lockdown.pageTitle': 'Lockdown', 'lockdown.status.active': 'Lockdown active', diff --git a/app/locales/pl.ts b/app/locales/pl.ts index c05c8c2..fccd84f 100644 --- a/app/locales/pl.ts +++ b/app/locales/pl.ts @@ -166,6 +166,12 @@ export const pl = { 'log.pageTitle': 'Dziennik', 'log.columns.time': 'Czas', 'log.columns.message': 'Komunikat', + 'log.messages.newAction': 'Nowa akcja: {{name}}', + 'log.messages.deletedAction': 'Usunięto akcję: {{name}}', + 'log.messages.lockdownStart': '🔐 Rozpoczęto blokadę', + 'log.messages.lockdownEnd': '🔐 Zakończono blokadę', + 'log.messages.loggedIn': '🔓 Zalogowano', + 'log.messages.badPassword': '🔒 Podano błędne hasło', 'lockdown.metaTitle': 'Blokada', 'lockdown.pageTitle': 'Blokada', 'lockdown.status.active': 'Blokada aktywna', diff --git a/app/routes/log.tsx b/app/routes/log.tsx index 07b20e8..3450bda 100644 --- a/app/routes/log.tsx +++ b/app/routes/log.tsx @@ -53,7 +53,7 @@ const Log = () => {
- + ) })} @@ -64,3 +64,36 @@ const Log = () => { } export default Log + +const translateLogMessage = ( + message: string, + t: ReturnType['t'] +) => { + const trimmedMessage = message.trim() + + const staticMessages: Record = { + '🔓 Logged in': 'log.messages.loggedIn', + '🔒 Bad password supplied': 'log.messages.badPassword', + '🔐 Lockdown Start': 'log.messages.lockdownStart', + '🔐 Lockdown End': 'log.messages.lockdownEnd' + } + + const staticKey = staticMessages[trimmedMessage] + if (staticKey) { + return t(staticKey) + } + + const newActionPrefix = 'New Action: ' + if (trimmedMessage.startsWith(newActionPrefix)) { + const name = trimmedMessage.slice(newActionPrefix.length).trim() + return t('log.messages.newAction', {name}) + } + + const deleteActionPrefix = 'Deleted action: ' + if (trimmedMessage.startsWith(deleteActionPrefix)) { + const name = trimmedMessage.slice(deleteActionPrefix.length).trim() + return t('log.messages.deletedAction', {name}) + } + + return message +} From a59aff4f8f944021a10cf239f7b70953f56a0dd5 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sun, 19 Oct 2025 19:30:21 +0100 Subject: [PATCH 4/8] fix: apply prettier --- app/lib/i18n.meta.ts | 6 +++- app/lib/i18n.server.ts | 8 +++-- app/locales/en.ts | 41 ++++++++++++++++--------- app/locales/pl.ts | 41 ++++++++++++++++--------- app/root.tsx | 34 +++++++++++++++----- app/routes/broadcast._index.tsx | 4 +-- app/routes/calendar.tsx | 6 +++- app/routes/desktop-groups._index.tsx | 4 ++- app/routes/desktop-groups.add.tsx | 9 +++++- app/routes/login.tsx | 4 ++- app/routes/schedule._index.tsx | 2 +- app/routes/sounders.$sounder._index.tsx | 8 ++--- app/routes/sounds.$sound._index.tsx | 4 ++- app/routes/sounds.$sound.edit.tsx | 4 ++- app/routes/webhooks.$webhook._index.tsx | 4 +-- app/routes/zones.$zone._index.tsx | 4 ++- app/routes/zones.$zone.edit.tsx | 3 +- 17 files changed, 128 insertions(+), 58 deletions(-) diff --git a/app/lib/i18n.meta.ts b/app/lib/i18n.meta.ts index 2c765c4..b8ef607 100644 --- a/app/lib/i18n.meta.ts +++ b/app/lib/i18n.meta.ts @@ -15,7 +15,11 @@ type RootData = { export const getRootI18n = (matches: MatchWithData[]): RootData => { const rootMatch = matches.find(match => match.id === ROOT_ID) - if (rootMatch && typeof rootMatch.data === 'object' && rootMatch.data !== null) { + if ( + rootMatch && + typeof rootMatch.data === 'object' && + rootMatch.data !== null + ) { const {locale, messages} = rootMatch.data as RootData return {locale, messages} } diff --git a/app/lib/i18n.server.ts b/app/lib/i18n.server.ts index 82803d3..ff9518c 100644 --- a/app/lib/i18n.server.ts +++ b/app/lib/i18n.server.ts @@ -5,7 +5,9 @@ import type {Messages} from './i18n.shared' const FALLBACK_LOCALE: SupportedLocale = 'en' -const resolveLocale = (request: LoaderFunctionArgs['request']): SupportedLocale => { +const resolveLocale = ( + request: LoaderFunctionArgs['request'] +): SupportedLocale => { const acceptLanguage = request.headers.get('accept-language') if (acceptLanguage) { @@ -17,7 +19,9 @@ const resolveLocale = (request: LoaderFunctionArgs['request']): SupportedLocale for (const requested of requestedLocales) { const normalized = requested.toLowerCase() - const exactMatch = Object.keys(locales).find(locale => locale === normalized) + const exactMatch = Object.keys(locales).find( + locale => locale === normalized + ) if (exactMatch) { return exactMatch as SupportedLocale } diff --git a/app/locales/en.ts b/app/locales/en.ts index c2a7c46..f7a50b4 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -25,8 +25,10 @@ export const en = { 'dashboard.lockdown.message': 'Lockdown mode {{status}}', 'dashboard.lockdown.status.enabled': 'enabled', 'dashboard.lockdown.status.disabled': 'disabled', - 'dashboard.lockdown.confirmEnable': 'Are you sure you want to enable lockdown?', - 'dashboard.lockdown.confirmDisable': 'Are you sure you want to disable lockdown?', + 'dashboard.lockdown.confirmEnable': + 'Are you sure you want to enable lockdown?', + 'dashboard.lockdown.confirmDisable': + 'Are you sure you want to disable lockdown?', 'dashboard.lockdown.button.enable': 'Enable', 'dashboard.lockdown.button.disable': 'Disable', 'about.title': 'About', @@ -43,7 +45,8 @@ export const en = { 'actions.buttons.add': 'Add Action', 'actions.add.pageTitle': 'Add Action', 'actions.form.name.label': 'Name', - 'actions.form.name.helper': 'The name of the action as it will appear on the screens.', + 'actions.form.name.helper': + 'The name of the action as it will appear on the screens.', 'actions.form.icon.label': 'Icon', 'actions.form.icon.helper': 'An emoji to use as the action icon. Note that emoji render differently on the RPi screen.', @@ -131,7 +134,7 @@ export const en = { 'days.assignments.metaTitle': 'Day assignments', 'days.assignments.title': 'Day assignments', 'days.assignments.helper': - 'These assignments change the day type for the given dates to that type\'s schedule.', + "These assignments change the day type for the given dates to that type's schedule.", 'days.assignments.table.date': 'Date', 'days.assignments.table.dayType': 'Day type', 'days.assignments.addTitle': 'Add assignments', @@ -142,7 +145,8 @@ export const en = { 'days.assignments.form.to.helper': 'End date. To assign a single day, set both From and To to the same date.', 'days.assignments.form.day.label': 'Day type', - 'days.assignments.form.day.helper': 'Day type to assign to the selected dates.', + 'days.assignments.form.day.helper': + 'Day type to assign to the selected dates.', 'days.assignments.addButton': 'Add assignments', 'desktopGroups.metaTitle': 'Desktop groups', 'desktopGroups.titleWithCount': 'Desktop groups ({{count}})', @@ -193,11 +197,14 @@ export const en = { 'Whether to trigger the ringer wire on repeats to avoid ambiguity between start and end.', 'settings.pageTitle': 'Settings', 'settings.controllerUrl.label': 'Controller URL', - 'settings.controllerUrl.helper': 'Network address of the controller (without the trailing /).', + 'settings.controllerUrl.helper': + 'Network address of the controller (without the trailing /).', 'settings.ttsSpeed.label': 'Text-to-speech speed', - 'settings.ttsSpeed.helper': 'Speed factor for text-to-speech generation. Default is 1; lower is faster.', + 'settings.ttsSpeed.helper': + 'Speed factor for text-to-speech generation. Default is 1; lower is faster.', 'settings.password.label': 'Change password', - 'settings.password.helper': 'Leave fields empty to keep the current password.', + 'settings.password.helper': + 'Leave fields empty to keep the current password.', 'settings.password.placeholderNew': 'New password', 'settings.password.placeholderConfirm': 'Repeat new password', 'schedule.metaTitle': 'Schedule', @@ -210,7 +217,8 @@ export const en = { 'schedule.addButton': 'Add entry', 'schedule.add.pageTitle': 'Add schedule entry', 'schedule.form.time.label': 'Time', - 'schedule.form.time.helper': 'Time when the sound should trigger (always at 0 seconds past the minute).', + 'schedule.form.time.helper': + 'Time when the sound should trigger (always at 0 seconds past the minute).', 'schedule.form.day.label': 'Day type', 'schedule.form.day.helper': 'Day type that this schedule entry applies to.', 'schedule.form.zone.label': 'Zone', @@ -231,7 +239,8 @@ export const en = { 'sounders.form.name.label': 'Name', 'sounders.form.name.helper': 'Descriptive name of the sounder.', 'sounders.form.ip.label': 'IP address', - 'sounders.form.ip.helper': 'IP address where the controller can reach the sounder.', + 'sounders.form.ip.helper': + 'IP address where the controller can reach the sounder.', 'sounders.add.submit': 'Add sounder', 'sounders.detail.metaFallback': 'Sounder', 'sounders.detail.infoTitle': 'About', @@ -250,7 +259,8 @@ export const en = { 'sounders.edit.metaTitle': 'Edit {{name}}', 'sounders.edit.pageTitle': 'Edit sounder {{name}}', 'sounders.form.ringer.label': 'Ringer pin', - 'sounders.form.ringer.helper': 'GPIO pin number that activates the ringer wire.', + 'sounders.form.ringer.helper': + 'GPIO pin number that activates the ringer wire.', 'sounders.form.screen.label': 'Screen', 'sounders.form.screen.helper': 'Enable the screen interface on this sounder? Restart required after changing.', @@ -280,7 +290,8 @@ export const en = { 'sounds.detail.editButton': 'Edit sound', 'sounds.edit.metaTitle': 'Edit {{name}}', 'sounds.edit.pageTitle': 'Edit sound {{name}}', - 'sounds.form.file.helperEdit': 'Upload a new MP3 to replace the existing sound.', + 'sounds.form.file.helperEdit': + 'Upload a new MP3 to replace the existing sound.', 'webhooks.metaTitle': 'Webhooks', 'webhooks.inbound.titleWithCount': 'Inbound webhooks ({{count}})', 'webhooks.inbound.table.webhook': 'Webhook', @@ -293,14 +304,16 @@ export const en = { 'webhooks.form.slug.label': 'Slug (identifier)', 'webhooks.form.slug.helper': 'Name that will be shown on screens.', 'webhooks.form.action.label': 'Action', - 'webhooks.form.action.helper': 'Which action should run when the webhook fires?', + 'webhooks.form.action.helper': + 'Which action should run when the webhook fires?', 'webhooks.add.submit': 'Add', 'webhooks.outbound.add.metaTitle': 'Add outbound webhook', 'webhooks.outbound.add.pageTitle': 'Add outbound webhook', 'webhooks.outbound.form.target.label': 'Target URL', 'webhooks.outbound.form.target.helper': 'Full URL for the outbound webhook.', 'webhooks.outbound.form.event.label': 'Event type', - 'webhooks.outbound.form.event.helper': 'Which events should trigger this webhook?', + 'webhooks.outbound.form.event.helper': + 'Which events should trigger this webhook?', 'webhooks.outbound.add.submit': 'Add outbound webhook', 'webhooks.outbound.edit.metaTitle': 'Edit outbound webhook', 'webhooks.outbound.edit.pageTitle': 'Edit outbound webhook', diff --git a/app/locales/pl.ts b/app/locales/pl.ts index fccd84f..0d2e795 100644 --- a/app/locales/pl.ts +++ b/app/locales/pl.ts @@ -34,7 +34,7 @@ export const pl = { 'about.table.version': 'Wersja', 'about.table.latest': 'Najnowsza wersja', 'about.table.required': 'Wymagana wersja', - 'u': 'Urządzenie: {{name}}', + u: 'Urządzenie: {{name}}', 'actions.title': 'Akcje', 'actions.titleWithCount': 'Akcje ({{count}})', 'actions.table.action': 'Akcja', @@ -72,7 +72,8 @@ export const pl = { 'broadcast.builder.metaTitle': 'Dźwięk', 'broadcast.builder.pageTitle': 'Kreator transmisji', 'broadcast.builder.sound.label': 'Dźwięk', - 'broadcast.builder.sound.helper': 'Dźwięk, który ma zostać dodany do kolejki.', + 'broadcast.builder.sound.helper': + 'Dźwięk, który ma zostać dodany do kolejki.', 'broadcast.builder.totalDuration': 'Łączny czas {{duration}}', 'broadcast.builder.createTts': 'Utwórz nowy TTS', 'button.next': 'Dalej', @@ -80,7 +81,8 @@ export const pl = { 'broadcast.zone.metaTitle': 'Strefa', 'broadcast.zone.pageTitle': 'Nadawanie (strefa)', 'broadcast.zone.field.zone.label': 'Strefa', - 'broadcast.zone.field.zone.helper': 'Strefa urządzeń, do której ma zostać nadany dźwięk.', + 'broadcast.zone.field.zone.helper': + 'Strefa urządzeń, do której ma zostać nadany dźwięk.', 'broadcast.zone.noneOption': 'Brak', 'broadcast.zone.submit': 'Nadaj!', 'broadcast.finish.metaTitle': 'Zakończono', @@ -180,23 +182,28 @@ export const pl = { 'lockdown.field.entrySound.helper': 'Dźwięk odtwarzany przy rozpoczęciu blokady oraz podczas powtórzeń.', 'lockdown.field.exitSound.label': 'Dźwięk zakończenia blokady', - 'lockdown.field.exitSound.helper': 'Dźwięk odtwarzany przy zakończeniu blokady.', + 'lockdown.field.exitSound.helper': + 'Dźwięk odtwarzany przy zakończeniu blokady.', 'lockdown.field.startCount.label': 'Liczba powtórzeń startu', 'lockdown.field.startCount.helper': 'Ile razy odtworzyć dźwięk rozpoczęcia blokady – zarówno przy starcie, jak i przy powtórzeniach.', 'lockdown.field.exitCount.label': 'Liczba powtórzeń zakończenia', - 'lockdown.field.exitCount.helper': 'Ile razy odtworzyć dźwięk zakończenia blokady.', + 'lockdown.field.exitCount.helper': + 'Ile razy odtworzyć dźwięk zakończenia blokady.', 'lockdown.field.repeatInterval.label': 'Interwał powtórzeń blokady (minuty)', 'lockdown.field.repeatInterval.helper': 'Co ile minut powtarzać dźwięk rozpoczęcia blokady.', - 'lockdown.field.repeatRinger.label': 'Aktywować przewód dzwonka przy powtórzeniach?', + 'lockdown.field.repeatRinger.label': + 'Aktywować przewód dzwonka przy powtórzeniach?', 'lockdown.field.repeatRinger.helper': 'Czy uruchamiać przewód dzwonka przy powtórzeniach, aby uniknąć niejednoznaczności co do liczby dzwonków oznaczających start lub koniec blokady.', 'settings.pageTitle': 'Ustawienia', 'settings.controllerUrl.label': 'Adres kontrolera', - 'settings.controllerUrl.helper': 'Adres kontrolera w sieci (bez końcowego ukośnika).', + 'settings.controllerUrl.helper': + 'Adres kontrolera w sieci (bez końcowego ukośnika).', 'settings.ttsSpeed.label': 'Prędkość syntezy mowy', - 'settings.ttsSpeed.helper': 'Współczynnik prędkości generowania mowy. Domyślnie 1; mniejsza wartość oznacza szybsze odtwarzanie.', + 'settings.ttsSpeed.helper': + 'Współczynnik prędkości generowania mowy. Domyślnie 1; mniejsza wartość oznacza szybsze odtwarzanie.', 'settings.password.label': 'Zmień hasło', 'settings.password.helper': 'Pozostaw pola puste, aby nie zmieniać hasła.', 'settings.password.placeholderNew': 'Nowe hasło', @@ -211,9 +218,11 @@ export const pl = { 'schedule.addButton': 'Dodaj wpis', 'schedule.add.pageTitle': 'Dodaj wpis harmonogramu', 'schedule.form.time.label': 'Godzina', - 'schedule.form.time.helper': 'Godzina uruchomienia dźwięku (zawsze o 0 sekund w danej minucie).', + 'schedule.form.time.helper': + 'Godzina uruchomienia dźwięku (zawsze o 0 sekund w danej minucie).', 'schedule.form.day.label': 'Dzień', - 'schedule.form.day.helper': 'Typ dnia, którego dotyczy ten wpis harmonogramu.', + 'schedule.form.day.helper': + 'Typ dnia, którego dotyczy ten wpis harmonogramu.', 'schedule.form.zone.label': 'Strefa', 'schedule.form.zone.helper': 'Strefa, której dotyczy ten wpis.', 'schedule.form.sound.label': 'Dźwięk', @@ -232,7 +241,8 @@ export const pl = { 'sounders.form.name.label': 'Nazwa', 'sounders.form.name.helper': 'Opisowa nazwa urządzenia.', 'sounders.form.ip.label': 'Adres IP', - 'sounders.form.ip.helper': 'Adres IP, pod którym kontroler łączy się z urządzeniem.', + 'sounders.form.ip.helper': + 'Adres IP, pod którym kontroler łączy się z urządzeniem.', 'sounders.add.submit': 'Dodaj urządzenie', 'sounders.detail.metaFallback': 'Urządzenie', 'sounders.detail.infoTitle': 'Informacje', @@ -251,7 +261,8 @@ export const pl = { 'sounders.edit.metaTitle': 'Edytuj {{name}}', 'sounders.edit.pageTitle': 'Edytuj urządzenie {{name}}', 'sounders.form.ringer.label': 'PIN dzwonka', - 'sounders.form.ringer.helper': 'Numer pinu GPIO aktywującego przewód dzwonka.', + 'sounders.form.ringer.helper': + 'Numer pinu GPIO aktywującego przewód dzwonka.', 'sounders.form.screen.label': 'Ekran', 'sounders.form.screen.helper': 'Włączyć interfejs ekranowy na tym urządzeniu? Po zmianie opcji należy zrestartować urządzenie.', @@ -294,14 +305,16 @@ export const pl = { 'webhooks.form.slug.label': 'Slug (identyfikator)', 'webhooks.form.slug.helper': 'Nazwa akcji wyświetlana na ekranach.', 'webhooks.form.action.label': 'Akcja', - 'webhooks.form.action.helper': 'Jaka akcja ma zostać uruchomiona po wywołaniu webhooka?', + 'webhooks.form.action.helper': + 'Jaka akcja ma zostać uruchomiona po wywołaniu webhooka?', 'webhooks.add.submit': 'Dodaj', 'webhooks.outbound.add.metaTitle': 'Dodaj webhook wychodzący', 'webhooks.outbound.add.pageTitle': 'Dodaj webhook wychodzący', 'webhooks.outbound.form.target.label': 'Adres docelowy', 'webhooks.outbound.form.target.helper': 'Pełny adres URL webhooka.', 'webhooks.outbound.form.event.label': 'Typ zdarzenia', - 'webhooks.outbound.form.event.helper': 'Które zdarzenia mają uruchamiać ten webhook?', + 'webhooks.outbound.form.event.helper': + 'Które zdarzenia mają uruchamiać ten webhook?', 'webhooks.outbound.add.submit': 'Dodaj webhook wychodzący', 'webhooks.outbound.edit.metaTitle': 'Edytuj webhook wychodzący', 'webhooks.outbound.edit.pageTitle': 'Edytuj webhook wychodzący', diff --git a/app/root.tsx b/app/root.tsx index af17258..d0f2b97 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,5 +1,17 @@ -import {Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData, useRouteLoaderData} from '@remix-run/react' -import {json, type LinksFunction, type LoaderFunctionArgs} from '@remix-run/node' +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useRouteLoaderData +} from '@remix-run/react' +import { + json, + type LinksFunction, + type LoaderFunctionArgs +} from '@remix-run/node' import BellIcon from '@heroicons/react/24/outline/BellIcon' import BellAlertIcon from '@heroicons/react/24/outline/BellAlertIcon' import Square3StackIcon from '@heroicons/react/24/outline/Square3Stack3DIcon' @@ -40,7 +52,8 @@ export const loader = async ({request}: LoaderFunctionArgs) => { } export function Layout({children}: {children: React.ReactNode}) { - const data = useRouteLoaderData('root') ?? FALLBACK_TRANSLATIONS + const data = + useRouteLoaderData('root') ?? FALLBACK_TRANSLATIONS const locale = data.locale ?? FALLBACK_LOCALE return ( @@ -76,13 +89,15 @@ const AppContent = () => { {t('nav.dashboard')} - {t('nav.broadcast')} + {' '} + {t('nav.broadcast')} {t('nav.schedule')} - {t('nav.calendar')} + {' '} + {t('nav.calendar')} {t('nav.sounders')} @@ -91,7 +106,8 @@ const AppContent = () => { {t('nav.sounds')} - {t('nav.desktopGroups')} + {' '} + {t('nav.desktopGroups')} {t('nav.actions')} @@ -100,10 +116,12 @@ const AppContent = () => { {t('nav.webhooks')} - {t('nav.zones')} + {' '} + {t('nav.zones')} - {t('nav.lockdown')} + {' '} + {t('nav.lockdown')} diff --git a/app/routes/broadcast._index.tsx b/app/routes/broadcast._index.tsx index 95830d7..ab8e85d 100644 --- a/app/routes/broadcast._index.tsx +++ b/app/routes/broadcast._index.tsx @@ -41,9 +41,7 @@ const Broadcast = () => { />
-
- {t('broadcast.description')} -
+
{t('broadcast.description')}
{ const weekdays = weekdayKeys.map(key => t(key)) return ( - +
ZoneSoundersSchedules{t('zones.table.zone')}{t('zones.table.sounders')}{t('zones.table.schedules')}
{format(time, 'dd/MM/yy HH:mm')} {message}{translateLogMessage(message, t)}
diff --git a/app/routes/desktop-groups.add.tsx b/app/routes/desktop-groups.add.tsx index 64ed4ae..b0f37b3 100644 --- a/app/routes/desktop-groups.add.tsx +++ b/app/routes/desktop-groups.add.tsx @@ -17,7 +17,14 @@ import {getRootI18n} from '~/lib/i18n.meta' export const meta: MetaFunction = ({matches}) => { const {messages} = getRootI18n(matches) - return [{title: pageTitle(translate(messages, 'desktopGroups.metaTitle'), translate(messages, 'desktopGroups.add.pageTitle'))}] + return [ + { + title: pageTitle( + translate(messages, 'desktopGroups.metaTitle'), + translate(messages, 'desktopGroups.add.pageTitle') + ) + } + ] } export const loader = async ({request}: LoaderFunctionArgs) => { diff --git a/app/routes/login.tsx b/app/routes/login.tsx index abb1eb1..a182170 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -52,7 +52,9 @@ const Login = () => { - + ) diff --git a/app/routes/schedule._index.tsx b/app/routes/schedule._index.tsx index c96d6bb..9270db3 100644 --- a/app/routes/schedule._index.tsx +++ b/app/routes/schedule._index.tsx @@ -66,7 +66,7 @@ const Schedule = () => {
- + diff --git a/app/routes/sounders.$sounder._index.tsx b/app/routes/sounders.$sounder._index.tsx index 6e745ec..9e76bad 100644 --- a/app/routes/sounders.$sounder._index.tsx +++ b/app/routes/sounders.$sounder._index.tsx @@ -17,11 +17,11 @@ import {getRootI18n} from '~/lib/i18n.meta' export const meta: MetaFunction = ({data, matches}) => { const {messages} = getRootI18n(matches) - const name = data ? data.sounder.name : translate(messages, 'sounders.detail.metaFallback') + const name = data + ? data.sounder.name + : translate(messages, 'sounders.detail.metaFallback') - return [ - {title: pageTitle(translate(messages, 'sounders.metaTitle'), name)} - ] + return [{title: pageTitle(translate(messages, 'sounders.metaTitle'), name)}] } export const loader = async ({request, params}: LoaderFunctionArgs) => { diff --git a/app/routes/sounds.$sound._index.tsx b/app/routes/sounds.$sound._index.tsx index 77e5310..e46becb 100644 --- a/app/routes/sounds.$sound._index.tsx +++ b/app/routes/sounds.$sound._index.tsx @@ -17,7 +17,9 @@ import {getRootI18n} from '~/lib/i18n.meta' export const meta: MetaFunction = ({data, matches}) => { const {messages} = getRootI18n(matches) - const name = data ? data.sound.name : translate(messages, 'sounds.detail.metaFallback') + const name = data + ? data.sound.name + : translate(messages, 'sounds.detail.metaFallback') return [{title: pageTitle(translate(messages, 'sounds.metaTitle'), name)}] } diff --git a/app/routes/sounds.$sound.edit.tsx b/app/routes/sounds.$sound.edit.tsx index cf19b11..1ee14f1 100644 --- a/app/routes/sounds.$sound.edit.tsx +++ b/app/routes/sounds.$sound.edit.tsx @@ -25,7 +25,9 @@ const {rename} = fs.promises export const meta: MetaFunction = ({data, matches}) => { const {messages} = getRootI18n(matches) - const name = data ? data.sound.name : translate(messages, 'sounds.detail.metaFallback') + const name = data + ? data.sound.name + : translate(messages, 'sounds.detail.metaFallback') return [ { title: pageTitle( diff --git a/app/routes/webhooks.$webhook._index.tsx b/app/routes/webhooks.$webhook._index.tsx index 62e5efb..6135ed4 100644 --- a/app/routes/webhooks.$webhook._index.tsx +++ b/app/routes/webhooks.$webhook._index.tsx @@ -56,9 +56,7 @@ const Webhook = () => {

{`curl -H 'Content-Type: application/json' -d '{"key": "${webhook.key}"}' -X POST ${controllerUrl}/hook/${webhook.slug}`}

-

- {t('webhooks.detail.broadcastNotice')}{' '} -

+

{t('webhooks.detail.broadcastNotice')}

= ({data, matches}) => { const {messages} = getRootI18n(matches) - const name = data ? data.zone.name : translate(messages, 'zones.detail.metaFallback') + const name = data + ? data.zone.name + : translate(messages, 'zones.detail.metaFallback') return [{title: pageTitle(translate(messages, 'zones.metaTitle'), name)}] } diff --git a/app/routes/zones.$zone.edit.tsx b/app/routes/zones.$zone.edit.tsx index 725b09e..07cfbaf 100644 --- a/app/routes/zones.$zone.edit.tsx +++ b/app/routes/zones.$zone.edit.tsx @@ -17,7 +17,8 @@ import {getRootI18n} from '~/lib/i18n.meta' export const meta: MetaFunction = ({data, matches}) => { const {messages} = getRootI18n(matches) - const name = data?.zone.name ?? translate(messages, 'zones.detail.metaFallback') + const name = + data?.zone.name ?? translate(messages, 'zones.detail.metaFallback') return [ { title: pageTitle( From 97047abd49e4beddd64d0b4c9d337a72d6984491 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sun, 19 Oct 2025 19:31:04 +0100 Subject: [PATCH 5/8] fix: add missing type to meta function --- app/routes/zones.$zone.edit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/zones.$zone.edit.tsx b/app/routes/zones.$zone.edit.tsx index 07cfbaf..c2bf8a3 100644 --- a/app/routes/zones.$zone.edit.tsx +++ b/app/routes/zones.$zone.edit.tsx @@ -15,7 +15,7 @@ import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' -export const meta: MetaFunction = ({data, matches}) => { +export const meta: MetaFunction = ({data, matches}) => { const {messages} = getRootI18n(matches) const name = data?.zone.name ?? translate(messages, 'zones.detail.metaFallback') From 27e1de4ad1745c9f3c496bc59787975afbe43fbb Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Sun, 19 Oct 2025 19:48:49 +0100 Subject: [PATCH 6/8] fix(lint): address some lint warnings --- app/lib/broadcast.server.ts | 2 +- app/lib/i18n.meta.ts | 2 +- app/lib/i18n.server.ts | 4 ++-- app/lib/i18n.tsx | 7 +++++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/lib/broadcast.server.ts b/app/lib/broadcast.server.ts index f939ae2..7401813 100644 --- a/app/lib/broadcast.server.ts +++ b/app/lib/broadcast.server.ts @@ -22,7 +22,7 @@ export const broadcast = async (zone: string, sounds: string) => { try { const parsed = JSON.parse(sounds) as unknown soundQueue = Array.isArray(parsed) ? parsed : [] - } catch (error) { + } catch { soundQueue = [] } diff --git a/app/lib/i18n.meta.ts b/app/lib/i18n.meta.ts index b8ef607..fb6bef7 100644 --- a/app/lib/i18n.meta.ts +++ b/app/lib/i18n.meta.ts @@ -1,4 +1,4 @@ -import type {Messages} from './i18n.shared' +import {type Messages} from './i18n.shared' type MatchWithData = { id: string diff --git a/app/lib/i18n.server.ts b/app/lib/i18n.server.ts index ff9518c..9ab7092 100644 --- a/app/lib/i18n.server.ts +++ b/app/lib/i18n.server.ts @@ -1,7 +1,7 @@ -import type {LoaderFunctionArgs} from '@remix-run/node' +import {type LoaderFunctionArgs} from '@remix-run/node' import {locales, type SupportedLocale} from '~/locales' -import type {Messages} from './i18n.shared' +import {type Messages} from './i18n.shared' const FALLBACK_LOCALE: SupportedLocale = 'en' diff --git a/app/lib/i18n.tsx b/app/lib/i18n.tsx index f4dd538..929b096 100644 --- a/app/lib/i18n.tsx +++ b/app/lib/i18n.tsx @@ -1,7 +1,10 @@ import {createContext, useContext} from 'react' -import type {Messages, TranslateReplacements} from './i18n.shared' -import {translate as baseTranslate} from './i18n.shared' +import { + translate as baseTranslate, + type Messages, + type TranslateReplacements +} from './i18n.shared' type I18nContextValue = { locale: string From 60265baab5b37a57137ce11dcfd1b61cec9872c3 Mon Sep 17 00:00:00 2001 From: AML - A Laycock Date: Mon, 20 Oct 2025 19:44:28 +0100 Subject: [PATCH 7/8] feat: type the translation key off the fallback locale and load in any missing keys from it --- app/lib/i18n.server.ts | 17 +++++++++++++---- app/lib/i18n.tsx | 3 ++- app/locales/index.ts | 3 +++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/app/lib/i18n.server.ts b/app/lib/i18n.server.ts index 9ab7092..ac66330 100644 --- a/app/lib/i18n.server.ts +++ b/app/lib/i18n.server.ts @@ -1,10 +1,13 @@ import {type LoaderFunctionArgs} from '@remix-run/node' -import {locales, type SupportedLocale} from '~/locales' +import { + locales, + type SupportedLocale, + FALLBACK_LOCALE, + MessageKey +} from '~/locales' import {type Messages} from './i18n.shared' -const FALLBACK_LOCALE: SupportedLocale = 'en' - const resolveLocale = ( request: LoaderFunctionArgs['request'] ): SupportedLocale => { @@ -40,7 +43,13 @@ const resolveLocale = ( } const getMessages = (locale: SupportedLocale): Messages => { - return locales[locale] + const messages: Messages = {} + + ;(Object.keys(locales[FALLBACK_LOCALE]) as MessageKey[]).forEach(key => { + messages[key] = locales[locale][key] ?? locales[FALLBACK_LOCALE][key] + }) + + return messages } export const initTranslations = (request: LoaderFunctionArgs['request']) => { diff --git a/app/lib/i18n.tsx b/app/lib/i18n.tsx index 929b096..58ab350 100644 --- a/app/lib/i18n.tsx +++ b/app/lib/i18n.tsx @@ -5,6 +5,7 @@ import { type Messages, type TranslateReplacements } from './i18n.shared' +import {type MessageKey} from '~/locales' type I18nContextValue = { locale: string @@ -31,7 +32,7 @@ export const I18nProvider: React.FC<{ export const useTranslation = () => { const context = useContext(I18nContext) - const t = (key: string, replacements: TranslateReplacements = {}) => { + const t = (key: MessageKey, replacements: TranslateReplacements = {}) => { return baseTranslate(context.messages, key, replacements) } diff --git a/app/locales/index.ts b/app/locales/index.ts index 102af83..2f2d89c 100644 --- a/app/locales/index.ts +++ b/app/locales/index.ts @@ -6,4 +6,7 @@ export const locales = { pl } +export const FALLBACK_LOCALE = 'en' as const + export type SupportedLocale = keyof typeof locales +export type MessageKey = keyof (typeof locales)[typeof FALLBACK_LOCALE] From 29209f9dce4f1f69c381a88395d030de56eac3f9 Mon Sep 17 00:00:00 2001 From: Adam Laycock Date: Mon, 20 Oct 2025 20:01:26 +0100 Subject: [PATCH 8/8] fix(ts): fix typings that use MessageKey --- app/routes/calendar.tsx | 5 +++-- app/routes/log.tsx | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/routes/calendar.tsx b/app/routes/calendar.tsx index b58b98a..e49e199 100644 --- a/app/routes/calendar.tsx +++ b/app/routes/calendar.tsx @@ -15,6 +15,7 @@ import {getPrisma} from '~/lib/prisma.server' import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' +import {MessageKey} from '~/locales' export const meta: MetaFunction = ({matches}) => { const {messages} = getRootI18n(matches) @@ -41,7 +42,7 @@ export const loader = async ({request}: LoaderFunctionArgs) => { return {dayAssigments, days} } -const MONTH_KEYS: readonly string[] = [ +const MONTH_KEYS: readonly MessageKey[] = [ 'calendar.months.january', 'calendar.months.february', 'calendar.months.march', @@ -102,7 +103,7 @@ const CalendarPage = () => { const {days, dayAssigments} = useLoaderData() const navigate = useNavigate() const monthLabels = MONTH_KEYS.map(key => t(key)) - const weekdayKeys = [ + const weekdayKeys: MessageKey[] = [ 'calendar.weekdays.monday', 'calendar.weekdays.tuesday', 'calendar.weekdays.wednesday', diff --git a/app/routes/log.tsx b/app/routes/log.tsx index 3450bda..879333d 100644 --- a/app/routes/log.tsx +++ b/app/routes/log.tsx @@ -13,6 +13,7 @@ import {getPrisma} from '~/lib/prisma.server' import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' +import {MessageKey} from '~/locales' export const meta: MetaFunction = ({matches}) => { const {messages} = getRootI18n(matches) @@ -71,7 +72,7 @@ const translateLogMessage = ( ) => { const trimmedMessage = message.trim() - const staticMessages: Record = { + const staticMessages: Record = { '🔓 Logged in': 'log.messages.loggedIn', '🔒 Bad password supplied': 'log.messages.badPassword', '🔐 Lockdown Start': 'log.messages.lockdownStart',
{t('schedule.table.time')} {t('calendar.weekdays.monday')} {t('calendar.weekdays.tuesday')}