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..7401813 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 { + 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..fb6bef7 --- /dev/null +++ b/app/lib/i18n.meta.ts @@ -0,0 +1,28 @@ +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..ac66330 --- /dev/null +++ b/app/lib/i18n.server.ts @@ -0,0 +1,62 @@ +import {type LoaderFunctionArgs} from '@remix-run/node' + +import { + locales, + type SupportedLocale, + FALLBACK_LOCALE, + MessageKey +} from '~/locales' +import {type Messages} from './i18n.shared' + +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 => { + 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']) => { + 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..58ab350 --- /dev/null +++ b/app/lib/i18n.tsx @@ -0,0 +1,42 @@ +import {createContext, useContext} from 'react' + +import { + translate as baseTranslate, + type Messages, + type TranslateReplacements +} from './i18n.shared' +import {type MessageKey} from '~/locales' + +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: MessageKey, 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..f7a50b4 --- /dev/null +++ b/app/locales/en.ts @@ -0,0 +1,349 @@ +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.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', + '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', + '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', + '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..2f2d89c --- /dev/null +++ b/app/locales/index.ts @@ -0,0 +1,12 @@ +import {en} from './en' +import {pl} from './pl' + +export const locales = { + en, + pl +} + +export const FALLBACK_LOCALE = 'en' as const + +export type SupportedLocale = keyof typeof locales +export type MessageKey = keyof (typeof locales)[typeof FALLBACK_LOCALE] diff --git a/app/locales/pl.ts b/app/locales/pl.ts new file mode 100644 index 0000000..0d2e795 --- /dev/null +++ b/app/locales/pl.ts @@ -0,0 +1,350 @@ +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.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', + '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', + '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', + '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..d0f2b97 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,5 +1,17 @@ -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 +32,31 @@ 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 +72,73 @@ 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 +149,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..ab8e85d 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 +220,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 +57,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..b0f37b3 100644 --- a/app/routes/desktop-groups.add.tsx +++ b/app/routes/desktop-groups.add.tsx @@ -1,15 +1,31 @@ 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 +63,21 @@ export const action = async ({request}: ActionFunctionArgs) => { const AddDay = () => { const navigate = useNavigate() + const {t} = useTranslation() return ( - + - + { e.preventDefault() @@ -65,7 +85,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..879333d 100644 --- a/app/routes/log.tsx +++ b/app/routes/log.tsx @@ -10,9 +10,14 @@ 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' +import {MessageKey} from '~/locales' -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 +36,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')}
- - + + @@ -48,7 +54,7 @@ const Log = () => { - + ) })} @@ -59,3 +65,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 +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx index a819f9b..a182170 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,16 @@ 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..9270db3 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')}
{format(time, 'dd/MM/yy HH:mm')} {message}{translateLogMessage(message, t)}
- - - - - - - - - - - + + + + + + + + + + + @@ -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..9e76bad 100644 --- a/app/routes/sounders.$sounder._index.tsx +++ b/app/routes/sounders.$sounder._index.tsx @@ -11,11 +11,17 @@ 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}) => { - return [ - {title: pageTitle('Sounders', data ? data.sounder.name : 'View Sounder')} - ] +export const meta: MetaFunction = ({data, matches}) => { + const {messages} = getRootI18n(matches) + const name = data + ? data.sounder.name + : translate(messages, 'sounders.detail.metaFallback') + + return [{title: pageTitle(translate(messages, 'sounders.metaTitle'), name)}] } export const loader = async ({request, params}: LoaderFunctionArgs) => { @@ -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..e46becb 100644 --- a/app/routes/sounds.$sound._index.tsx +++ b/app/routes/sounds.$sound._index.tsx @@ -11,9 +11,16 @@ 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 +60,7 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { const Sound = () => { const {sound} = useLoaderData() const navigate = useNavigate() + const {t} = useTranslation() return ( @@ -60,19 +68,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..1ee14f1 100644 --- a/app/routes/sounds.$sound.edit.tsx +++ b/app/routes/sounds.$sound.edit.tsx @@ -17,12 +17,24 @@ 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 +117,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..6135ed4 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,32 @@ 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} -

-

- If this webhook uses an action that has the type Broadcast, your - request JSON must include "zone": "ZONE ID" + {`curl -H 'Content-Type: application/json' -d '{"key": "${webhook.key}"}' -X POST ${controllerUrl}/hook/${webhook.slug}`}

+

{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'} ]} />
ZoneSoundersSchedules{t('zones.table.zone')}{t('zones.table.sounders')}{t('zones.table.schedules')}