diff --git a/Timer.md b/Timer.md new file mode 100644 index 0000000..5c8cb01 --- /dev/null +++ b/Timer.md @@ -0,0 +1,9 @@ +# Timer feature + +- Timer lives on its own page +- Timer can be started, paused and stopped +- When the timer is stopped a UI opens where the user has to select a project. After the project is selected the timer is destroyed and the time recorded with it is created as time entries assigned to the selected project +- Timers are stored in the database and can be resumed after the application is closed and reopened or if the user signs out and back in. +- For a user there exists one timer at a time. A timer can have zero or more start pause entries that record when the timer is started and paused. If a timer is running the last start pause entry has a start time but no pause time. If a timer is paused the last start pause entry has both a start and a pause time. When a timer is stopped the last start pause entry is paused if it was running. The user had to select a project to assign the stopped timer to and all the start pause entries are converted to time entries assigned to the selected project. The timer with all its start pause entries is then destroyed. +- Don't use verbs in the endpoint paths as it is a RESTful API. Use nouns instead of verbs. For example you can post to `.../start` to create a start for a timer. The method should also be named `createTimerStart` instead of `startTimer` +- If there is no start pause entry without a pause time the timer is paused. If there is a start pause entry without a pause time the timer is running. diff --git a/client/package.json b/client/package.json index b95bc5e..52f3cd5 100644 --- a/client/package.json +++ b/client/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@claas.dev/material-tailwind": "^0.6.0", + "@fluent/bundle": "^0.19.1", + "@fluent/langneg": "^0.7.0", "@tanstack/solid-query": "^5.101.0", "@tanstack/solid-router": "^1.169.1", "solid-js": "^1.9.11", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 6c76581..5aa4f4d 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@claas.dev/material-tailwind': specifier: ^0.6.0 version: 0.6.0 + '@fluent/bundle': + specifier: ^0.19.1 + version: 0.19.1 + '@fluent/langneg': + specifier: ^0.7.0 + version: 0.7.0 '@tanstack/solid-query': specifier: ^5.101.0 version: 5.101.0(solid-js@1.9.12) @@ -153,6 +159,14 @@ packages: '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@fluent/bundle@0.19.1': + resolution: {integrity: sha512-SWJLZrPamDPsJlFFOW1nkgN0j0rbPbmSdmK0XAoXlyqKieLtMVl4vzng3aR5pwKoUx0scug8+YY2oct3fdfy9A==} + engines: {node: '>=18.0.0', npm: '>=7.0.0'} + + '@fluent/langneg@0.7.0': + resolution: {integrity: sha512-StAM0vgsD1QK+nFikaKs9Rxe3JGNipiXrpmemNGwM4gWERBXPe9gjzsBoKjgBgq1Vyiy+xy/C652QIWY+MPyYw==} + engines: {node: '>=14.0.0', npm: '>=7.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -231,56 +245,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.46.0': resolution: {integrity: sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.46.0': resolution: {integrity: sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.46.0': resolution: {integrity: sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.46.0': resolution: {integrity: sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.46.0': resolution: {integrity: sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.46.0': resolution: {integrity: sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.46.0': resolution: {integrity: sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.46.0': resolution: {integrity: sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==} @@ -353,56 +359,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -463,42 +461,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -639,28 +631,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.3': resolution: {integrity: sha512-MmIA32rNEOrjh6wnevlR3OjjlCuwgZ4JMJo7Vrhk4Fk56Vxi7EeF7cekSKwvlrnfcn/ERC1LdcG3sFneU8WdoA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.3': resolution: {integrity: sha512-BiCy1YV0IKO+xbD7gyZnENU4jdwDygeGQjncJoeIE5Kp4UqWHFsKUSJ3pp7vYURrqVzwJX2xD5gQeGnoXp4xPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.3': resolution: {integrity: sha512-venvyAu0AMKdr0c1Oz23IJJdZ72zSwKyHrLvqQV1cn49vPAJk3AuVtDkJ1ayk1sYI4M4j8Jv6ZGflpaP0QVSXQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.3': resolution: {integrity: sha512-e3kColrZZCdtbwIOc07cNQ2zNf1sTPXTYLjjPlsgsaf+ttzAg/hOlDyEgHoOlBGxM88nPxeVaOGe9ThqVzPncg==} @@ -971,28 +959,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1368,6 +1352,10 @@ snapshots: tslib: 2.8.1 optional: true + '@fluent/bundle@0.19.1': {} + + '@fluent/langneg@0.7.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/client/src/ErrorDetails.tsx b/client/src/ErrorDetails.tsx index 74d4c63..6c7ce13 100644 --- a/client/src/ErrorDetails.tsx +++ b/client/src/ErrorDetails.tsx @@ -1,11 +1,14 @@ import type { ErrorComponentProps } from "@tanstack/solid-router"; import { Show } from "solid-js"; +import { useI18n } from "./i18n"; + export function ErrorDetails( properties: ErrorComponentProps & { title: string; explainer: string }, ) { // Log the error so we can view it in the console and it doesn't get swallowed console.error("ErrorDetails", properties.error); + const { t } = useI18n(); return ( <>
@@ -14,7 +17,7 @@ export function ErrorDetails(
- Technical details + {t("error-technical-details")}
{properties.error.stack}
@@ -29,7 +32,7 @@ export function ErrorDetails( onClick={properties.reset} class="text-label-lg text-on-error-container col-start-2" > - Retry + {t("error-retry")}
diff --git a/client/src/Navigation.tsx b/client/src/Navigation.tsx index d5dab90..c9e0df1 100644 --- a/client/src/Navigation.tsx +++ b/client/src/Navigation.tsx @@ -4,9 +4,11 @@ import type { JSX, VoidProps } from "solid-js"; import { useFloatingActionButton } from "@/FloatingActionButton"; import Icon from "@/Icon"; +import { useI18n } from "@/i18n"; -export default function Navigation(properties: VoidProps>) { +export default function Navigation(_properties: VoidProps>) { const floatingActionButton = useFloatingActionButton(); + const { t } = useI18n(); return (
@@ -44,7 +46,7 @@ export default function Navigation(properties: VoidProps
- Times + {t("nav-times")}
  • @@ -53,7 +55,7 @@ export default function Navigation(properties: VoidProps
  • @@ -69,7 +71,7 @@ export default function Navigation(properties: VoidProps
  • @@ -84,7 +86,7 @@ export default function Navigation(properties: VoidProps
  • diff --git a/client/src/i18n.tsx b/client/src/i18n.tsx new file mode 100644 index 0000000..6ab704e --- /dev/null +++ b/client/src/i18n.tsx @@ -0,0 +1,60 @@ +import { FluentBundle, FluentResource, type FluentVariable } from "@fluent/bundle"; +import { negotiateLanguages } from "@fluent/langneg"; +import { createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"; + +// Included in bundle even if translation is not not used. This is fine for now but needs to be changed in the future. +import deFtl from "./locales/de.ftl?raw"; +import enFtl from "./locales/en.ftl?raw"; + +const MESSAGES: Record = { + en: enFtl, + de: deFtl, +}; + +const SUPPORTED_LOCALES = ["en", "de"] as const; +const DEFAULT_LOCALE = "en"; + +function createBundle(locale: string): FluentBundle { + const bundle = new FluentBundle(locale); + const resource = new FluentResource(MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE]); + bundle.addResource(resource); + return bundle; +} + +export type TranslateArgs = Record; + +const I18nContext = createContext<{ + t: (id: string, args?: TranslateArgs) => string; + locale: () => string; + setLocale: (locale: string) => void; +}>(); + +export function I18nProvider(props: ParentProps) { + const userLocales = Array.from( + navigator.languages?.length ? navigator.languages : [navigator.language ?? DEFAULT_LOCALE], + ); + const negotiated = negotiateLanguages(userLocales, SUPPORTED_LOCALES, { + defaultLocale: DEFAULT_LOCALE, + }); + const [locale, setLocale] = createSignal(negotiated[0] ?? DEFAULT_LOCALE); + + const bundle = createMemo(() => createBundle(locale())); + + function t(id: string, args?: TranslateArgs): string { + const b = bundle(); + const message = b.getMessage(id); + if (!message?.value) return id; + const errors: Error[] = []; + return b.formatPattern(message.value, args, errors); + } + + return ( + {props.children} + ); +} + +export function useI18n() { + const ctx = useContext(I18nContext); + if (!ctx) throw new Error("useI18n must be used within I18nProvider"); + return ctx; +} diff --git a/client/src/index.tsx b/client/src/index.tsx index b3c161f..23ac84d 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -6,6 +6,7 @@ import { render } from "solid-js/web"; import { routeTree } from "./routeTree.gen"; import { FloatingActionButtonProvider } from "./FloatingActionButton"; +import { I18nProvider } from "./i18n"; import { TitleProvider } from "./Title"; import { idQuery } from "./user"; @@ -42,13 +43,15 @@ declare module "@tanstack/solid-router" { render( () => ( - - - - - - - + + + + + + + + + ), document.body, ); diff --git a/client/src/locales/de.ftl b/client/src/locales/de.ftl new file mode 100644 index 0000000..99e389a --- /dev/null +++ b/client/src/locales/de.ftl @@ -0,0 +1,135 @@ +# Navigation +nav-times = Zeiten +nav-timer = Timer +nav-projects = Projekte +nav-organizations = Organisationen + +# Top app bar +app-sign-out = Abmelden + +# Sign in +sign-in-welcome = Willkommen +sign-in-username-label = Benutzername +sign-in-password-label = Passwort +sign-in-submit = Anmelden +sign-in-go-sign-up = Konto erstellen +sign-in-error-server = Ein Fehler bei der Verbindung zum Server ist aufgetreten. Bitte später erneut versuchen. +sign-in-error-invalid-credentials = Ungültiger Benutzername oder Passwort +sign-in-error-unknown = Ein Fehler beim Anmelden ist aufgetreten. Bitte erneut versuchen. + +# Sign up +sign-up-create-account = Konto erstellen +sign-up-name-label = Name +sign-up-username-label = Benutzername +sign-up-password-label = Neues Passwort +sign-up-submit = Registrieren +sign-up-go-sign-in = Bereits ein Konto? Anmelden +sign-up-error-server = Ein Fehler bei der Verbindung zum Server ist aufgetreten. Bitte später erneut versuchen. +sign-up-error-username-taken = Benutzername ist bereits vergeben +sign-up-error-unknown = Ein Fehler bei der Registrierung ist aufgetreten. Bitte später erneut versuchen. + +# Timer +timer-title = Timer +timer-running-status = Läuft +timer-delete-entry = Eintrag löschen +timer-pause = Pausieren +timer-resume = Fortsetzen +timer-start = Starten +timer-stop = Stoppen +timer-select-project-title = Projekt auswählen +timer-select-project-body = { $duration } werden als Zeiteintrag gespeichert. +timer-loading-projects = Projekte werden geladen … +timer-discard = Verwerfen + +# Times +times-title = Zeiten +times-log-time = Zeit erfassen +times-edit = Bearbeiten +times-save = Speichern +times-cancel = Abbrechen +times-start-time-label = Startzeit +times-end-time-label = Endzeit +times-delete = Löschen + +# Projects +projects-title = Projekte +projects-create = Projekt erstellen +projects-loading = Projekte werden geladen … +projects-edit = Bearbeiten +projects-delete = Löschen + +# Organizations +organizations-title = Organisationen +organizations-create = Organisation erstellen +organizations-loading = Organisationen werden geladen … +organizations-open = Öffnen +organizations-edit = Bearbeiten +organizations-delete = Löschen + +# New organization +org-new-title = Neue Organisation +org-new-name-label = Name +org-new-cancel = Abbrechen +org-new-create = Erstellen +org-new-discard = Verwerfen + +# Edit organization +org-edit-title = Organisation bearbeiten +org-edit-name-label = Name +org-edit-cancel = Abbrechen +org-edit-save = Speichern +org-edit-discard = Verwerfen + +# Organization members +org-members-back = Zurück +org-members-error-page-title = Fehler +org-members-error-title = Fehler beim Laden der Organisation +org-members-error-body = Entschuldigung, beim Laden der Organisation ist ein Fehler aufgetreten. Bitte später erneut versuchen. +org-members-users-heading = Benutzer +org-members-delete = Löschen +org-members-add-member = Mitglied hinzufügen + +# Add member +org-add-member-title = Mitglied hinzufügen +org-add-member-username-label = Benutzername +org-add-member-cancel = Abbrechen +org-add-member-add = Hinzufügen +org-add-member-discard = Verwerfen + +# New project +project-new-title = Neues Projekt +project-new-name-label = Name +project-new-organization-label = Organisation +project-new-loading = Wird geladen … +project-new-cancel = Abbrechen +project-new-create = Erstellen +project-new-discard = Verwerfen + +# Edit project +project-edit-title = Projekt bearbeiten +project-edit-name-label = Name +project-edit-organization-label = Organisation +project-edit-loading = Wird geladen … +project-edit-cancel = Abbrechen +project-edit-save = Speichern +project-edit-discard = Verwerfen + +# Log time +time-new-title = Zeit erfassen +time-new-date-label = Datum +time-new-start-label = Start +time-new-end-label = Ende +time-new-project-label = Projekt +time-new-loading-projects = Projekte werden geladen … +time-new-cancel = Abbrechen +time-new-save = Speichern +time-new-discard = Verwerfen +time-new-validation-start-before-end = Die Startzeit muss vor der Endzeit liegen. + +# Error component +error-technical-details = Technische Details +error-retry = Wiederholen + +# Generic error page +error-title = Fehler +error-body = Entschuldigung, ein unerwarteter Fehler ist aufgetreten. Bitte später erneut versuchen. diff --git a/client/src/locales/en.ftl b/client/src/locales/en.ftl new file mode 100644 index 0000000..f009090 --- /dev/null +++ b/client/src/locales/en.ftl @@ -0,0 +1,135 @@ +# Navigation +nav-times = Times +nav-timer = Timer +nav-projects = Projects +nav-organizations = Organizations + +# Top app bar +app-sign-out = Sign out + +# Sign in +sign-in-welcome = Welcome +sign-in-username-label = Username +sign-in-password-label = Password +sign-in-submit = Sign in +sign-in-go-sign-up = Sign up for an account +sign-in-error-server = An error occurred connecting to the server. Please try again later. +sign-in-error-invalid-credentials = Invalid username or password +sign-in-error-unknown = An error occurred signing in. Please try again. + +# Sign up +sign-up-create-account = Create an account +sign-up-name-label = Name +sign-up-username-label = Username +sign-up-password-label = New password +sign-up-submit = Sign up +sign-up-go-sign-in = Already have an account? Sign in +sign-up-error-server = An error occurred connecting to the server. Please try again later. +sign-up-error-username-taken = Username is already taken +sign-up-error-unknown = An error occurred during sign up. Please try again later. + +# Timer +timer-title = Timer +timer-running-status = Running +timer-delete-entry = Delete entry +timer-pause = Pause +timer-resume = Resume +timer-start = Start +timer-stop = Stop +timer-select-project-title = Select a project +timer-select-project-body = { $duration } will be saved as a time entry. +timer-loading-projects = Loading projects... +timer-discard = Discard + +# Times +times-title = Times +times-log-time = Log time +times-edit = Edit +times-save = Save +times-cancel = Cancel +times-start-time-label = Start time +times-end-time-label = End time +times-delete = Delete + +# Projects +projects-title = Projects +projects-create = Create project +projects-loading = Loading projects... +projects-edit = Edit +projects-delete = Delete + +# Organizations +organizations-title = Organizations +organizations-create = Create organization +organizations-loading = Loading organizations... +organizations-open = Open +organizations-edit = Edit +organizations-delete = Delete + +# New organization +org-new-title = New Organization +org-new-name-label = Name +org-new-cancel = Cancel +org-new-create = Create +org-new-discard = Discard + +# Edit organization +org-edit-title = Edit Organization +org-edit-name-label = Name +org-edit-cancel = Cancel +org-edit-save = Save +org-edit-discard = Discard + +# Organization members +org-members-back = Back +org-members-error-page-title = Error +org-members-error-title = Error loading organization +org-members-error-body = Sorry, an error occurred while loading the organization. Please try again later. +org-members-users-heading = Users +org-members-delete = Delete +org-members-add-member = Add Member + +# Add member +org-add-member-title = Add member +org-add-member-username-label = Username +org-add-member-cancel = Cancel +org-add-member-add = Add +org-add-member-discard = Discard + +# New project +project-new-title = New Project +project-new-name-label = Name +project-new-organization-label = Organization +project-new-loading = Loading... +project-new-cancel = Cancel +project-new-create = Create +project-new-discard = Discard + +# Edit project +project-edit-title = Edit Project +project-edit-name-label = Name +project-edit-organization-label = Organization +project-edit-loading = Loading... +project-edit-cancel = Cancel +project-edit-save = Save +project-edit-discard = Discard + +# Log time +time-new-title = Log new time +time-new-date-label = Date +time-new-start-label = Start +time-new-end-label = End +time-new-project-label = Project +time-new-loading-projects = Loading projects... +time-new-cancel = Cancel +time-new-save = Save +time-new-discard = Discard +time-new-validation-start-before-end = Start time must be before end time. + +# Error component +error-technical-details = Technical details +error-retry = Retry + +# Generic error page +error-title = Error +error-body = Sorry, an unexpected error occurred. Please try again later. diff --git a/client/src/routes/__root.tsx b/client/src/routes/__root.tsx index 896b442..70b5a7e 100644 --- a/client/src/routes/__root.tsx +++ b/client/src/routes/__root.tsx @@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/solid-query"; import { createRootRouteWithContext, redirect } from "@tanstack/solid-router"; import { ErrorDetails } from "../ErrorDetails"; +import { useI18n } from "../i18n"; import { idQuery, UnauthenticatedError } from "../user"; import { Route as SignInRoute } from "./sign-in"; import { Route as SignUpRoute } from "./sign-up"; @@ -42,11 +43,14 @@ export const Route = createRootRoute({ }); } }, - errorComponent: (properties) => ( - - ), + errorComponent: (properties) => { + const { t } = useI18n(); + return ( + + ); + }, }); diff --git a/client/src/routes/_app/organizations/index.tsx b/client/src/routes/_app/organizations/index.tsx index ac88b64..40f9aad 100644 --- a/client/src/routes/_app/organizations/index.tsx +++ b/client/src/routes/_app/organizations/index.tsx @@ -4,6 +4,7 @@ import { For, Show } from "solid-js"; import { FloatingActionButton } from "../../../FloatingActionButton"; import Icon from "../../../Icon"; +import { useI18n } from "../../../i18n"; import { deleteOrganization, isOrganization, @@ -26,6 +27,7 @@ export const Route = createFileRoute("/_app/organizations/")({ function Organizations() { const routeData = Route.useLoaderData(); const userId = () => routeData().userId; + const { t } = useI18n(); const options = () => query(userId()); const organizationsQuery = useQuery(options); @@ -51,12 +53,16 @@ function Organizations() { return ( <> - - <FloatingActionButton to="/organizations/new" label="Create organization" icon="add" /> + <Title title={t("organizations-title")} /> + <FloatingActionButton + to="/organizations/new" + label={t("organizations-create")} + icon="add" + /> {/* TODO overflow, pagination, scrolling */} <main class="text-title-lg px-6"> <ul class="grid grid-cols-[1fr_auto_auto_auto] gap-y-4"> - <For each={organizationsQuery.data} fallback={<p>Loading organizations...</p>}> + <For each={organizationsQuery.data} fallback={<p>{t("organizations-loading")}</p>}> {(organization) => { return ( <Show when={!isOrganization(organization) || !isDeleting(organization.id)}> @@ -72,7 +78,7 @@ function Organizations() { data-variant="standard" class="icon-button" > - <span class="sr-only">Open</span> + <span class="sr-only">{t("organizations-open")}</span> <Icon name="open-in-new-window" class="fill-on-surface size-6" /> </Link> @@ -83,7 +89,7 @@ function Organizations() { data-variant="standard" class="icon-button" > - <span class="sr-only">Edit</span> + <span class="sr-only">{t("organizations-edit")}</span> <Icon name="edit" class="fill-on-surface size-6" /> </Link> <button @@ -92,7 +98,7 @@ function Organizations() { data-variant="standard" class="icon-button" > - <span class="sr-only">Delete</span> + <span class="sr-only">{t("organizations-delete")}</span> <Icon name="close" class="fill-on-surface size-6" /> </button> </> diff --git a/client/src/routes/_app/projects.tsx b/client/src/routes/_app/projects.tsx index d2b189b..6f5e702 100644 --- a/client/src/routes/_app/projects.tsx +++ b/client/src/routes/_app/projects.tsx @@ -1,10 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"; import { createFileRoute, Link } from "@tanstack/solid-router"; -import { For, Show, type VoidProps } from "solid-js"; +import { For, Show } from "solid-js"; import { FloatingActionButton } from "../../FloatingActionButton"; import Icon from "../../Icon"; -import { deleteProject, isProject, query, type Id as ProjectId, type Project } from "../../project"; +import { useI18n } from "../../i18n"; +import { deleteProject, isProject, query, type Id as ProjectId } from "../../project"; import { Title } from "../../Title"; import { idQuery } from "../../user"; @@ -21,6 +22,7 @@ export const Route = createFileRoute("/_app/projects")({ function Projects() { const routeData = Route.useLoaderData(); const userId = () => routeData().userId; + const { t } = useI18n(); const options = () => query(userId()); const projectsQuery = useQuery(options); @@ -45,12 +47,12 @@ function Projects() { return ( <> - <Title title="Projects" /> - <FloatingActionButton to="/projects/new" label="Create project" icon="add" /> + <Title title={t("projects-title")} /> + <FloatingActionButton to="/projects/new" label={t("projects-create")} icon="add" /> {/* TODO overflow, pagination, scrolling */} <main class="text-title-lg px-6"> <ul class="grid grid-cols-[1fr_auto_auto_auto] gap-y-4"> - <For each={projectsQuery.data} fallback={<p>Loading projects...</p>}> + <For each={projectsQuery.data} fallback={<p>{t("projects-loading")}</p>}> {(project) => { return ( <Show when={!isProject(project) || !isDeleting(project.id)}> @@ -67,7 +69,7 @@ function Projects() { data-variant="standard" class="icon-button" > - <span class="sr-only">Edit</span> + <span class="sr-only">{t("projects-edit")}</span> <Icon name="edit" class="fill-on-surface size-6" /> </Link> <button @@ -76,7 +78,7 @@ function Projects() { data-variant="standard" class="icon-button" > - <span class="sr-only">Delete</span> + <span class="sr-only">{t("projects-delete")}</span> <Icon name="close" class="fill-on-surface size-6" /> </button> </> @@ -92,3 +94,4 @@ function Projects() { </> ); } + diff --git a/client/src/routes/_app/route.tsx b/client/src/routes/_app/route.tsx index 751d59d..7fa6431 100644 --- a/client/src/routes/_app/route.tsx +++ b/client/src/routes/_app/route.tsx @@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/solid-query"; import { createFileRoute, Outlet } from "@tanstack/solid-router"; import Body from "../../Body"; +import { useI18n } from "../../i18n"; import Navigation from "../../Navigation"; import { useTitle } from "../../Title"; import { ActionButton, TopAppBar } from "../../TopAppBar"; @@ -14,6 +15,7 @@ export const Route = createFileRoute("/_app")({ function RouteComponent() { const queryClient = useQueryClient(); const navigate = Route.useNavigate(); + const { t } = useI18n(); async function handleSignOut() { const response = await fetch("/api/sign-outs", { @@ -40,7 +42,7 @@ function RouteComponent() { trailingAction={ <ActionButton onClick={handleSignOut} - icon={{ name: "logout", alternativeText: "Sign out" }} + icon={{ name: "logout", alternativeText: t("app-sign-out") }} position="trailing" /> } diff --git a/client/src/routes/_app/timer.tsx b/client/src/routes/_app/timer.tsx index 29b798c..cb634e6 100644 --- a/client/src/routes/_app/timer.tsx +++ b/client/src/routes/_app/timer.tsx @@ -4,6 +4,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, Show, Suspense import { FloatingActionButtonAction } from "@/FloatingActionButton"; import Icon from "@/Icon"; +import { useI18n } from "@/i18n"; import { isProject, query as projectQuery, type Id as ProjectId } from "@/project"; import { query as timesQuery } from "@/time"; import { @@ -51,6 +52,7 @@ function EntryRow(properties: { onDelete: () => void; isDeleting: boolean; }) { + const { t } = useI18n(); const startedAt = () => Temporal.Instant.from(properties.entry.startedAt); const pausedAt = () => @@ -72,7 +74,7 @@ function EntryRow(properties: { when={pausedAt()} fallback={ <span class="bg-surface-container text-primary rounded-full px-3 py-1.5 text-center"> - Running + {t("timer-running-status")} </span> } > @@ -96,7 +98,7 @@ function EntryRow(properties: { disabled={properties.isDeleting} class="icon-button fill-on-surface-variant disabled:opacity-50" > - <span class="sr-only">Delete entry</span> + <span class="sr-only">{t("timer-delete-entry")}</span> <Icon name="delete" class="size-5" /> </button> </li> @@ -106,6 +108,7 @@ function EntryRow(properties: { function TimerPage() { const { userId } = Route.useLoaderData()(); const queryClient = useQueryClient(); + const { t } = useI18n(); const timer = useQuery(() => timerQuery(userId)); @@ -187,15 +190,14 @@ function TimerPage() { const entries = () => timer.data?.entries; const floatingActionButtonLabel = createMemo(() => { - if (timer.data?.status === "RUNNING") return "Pause"; - if (timer.data?.status === "PAUSED") return "Resume"; - - return "Start"; + if (timer.data?.status === "RUNNING") return t("timer-pause"); + if (timer.data?.status === "PAUSED") return t("timer-resume"); + return t("timer-start"); }); return ( <> - <Title title="Timer" /> + <Title title={t("timer-title")} /> <Show when={!isSelectingProject()}> <FloatingActionButtonAction icon={timer.data?.status === "RUNNING" ? "pause" : "play-arrow"} @@ -222,7 +224,7 @@ function TimerPage() { class="button gap-2" > <Icon name="play-arrow" class="fill-on-primary size-6" /> - Start + {t("timer-start")} </button> </Show> @@ -234,7 +236,7 @@ function TimerPage() { class="button gap-2" > <Icon name="pause" class="fill-on-surface-variant size-6" /> - Pause + {t("timer-pause")} </button> </Show> @@ -246,7 +248,7 @@ function TimerPage() { class="button gap-2" > <Icon name="play-arrow" class="fill-on-primary size-6" /> - Resume + {t("timer-resume")} </button> <button onClick={() => setIsSelectingProject(true)} @@ -255,7 +257,7 @@ function TimerPage() { class="button gap-2" > <Icon name="stop" class="fill-on-surface-variant size-6" /> - Stop + {t("timer-stop")} </button> </Show> </div> @@ -279,13 +281,16 @@ function TimerPage() { <Show when={isSelectingProject()}> <div class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 lg:items-center"> <div class="bg-surface-container-high rounded-t-large lg:rounded-large w-full max-w-lg p-6"> - <h2 class="text-title-lg text-on-surface mb-1">Select a project</h2> + <h2 class="text-title-lg text-on-surface mb-1">{t("timer-select-project-title")}</h2> <p class="text-body-md text-on-surface-variant mb-4"> - <time datetime={elapsed().toString()}>{durationFormatter.format(elapsed())}</time> will - be saved as a time entry. + {t("timer-select-project-body", { + duration: durationFormatter.format(elapsed()), + })} </p> <Suspense - fallback={<p class="text-on-surface-variant py-4 text-center">Loading projects...</p>} + fallback={ + <p class="text-on-surface-variant py-4 text-center">{t("timer-loading-projects")}</p> + } > <ul class="flex flex-col gap-2"> <For each={selectableProjects()}> @@ -309,7 +314,7 @@ function TimerPage() { data-variant="outlined" class="button mt-6 w-full" > - Discard + {t("timer-discard")} </button> </div> </div> diff --git a/client/src/routes/_app/times.tsx b/client/src/routes/_app/times.tsx index fdcc191..92f617f 100644 --- a/client/src/routes/_app/times.tsx +++ b/client/src/routes/_app/times.tsx @@ -4,6 +4,7 @@ import { createSignal, For, type VoidProps } from "solid-js"; import { FloatingActionButton } from "@/FloatingActionButton"; import Icon from "@/Icon"; +import { useI18n } from "@/i18n"; import { deleteTimes, query, updateTimes, type Time, type TimeId } from "@/time"; import { Title } from "@/Title"; import { idQuery, type UserId } from "@/user"; @@ -69,6 +70,7 @@ const durationFormatter = new Intl.DurationFormat(undefined, { function Day(properties: VoidProps<DayProperties>) { // Could do this with a custom styled checkbox and no JS const [isEdit, setIsEdit] = createSignal(false); + const { t } = useI18n(); function cancelEdit() { setIsEdit(false); @@ -162,7 +164,7 @@ function Day(properties: VoidProps<DayProperties>) { onClick={() => setIsEdit(!isEdit())} class="icon-button fill-on-surface group-data-is-edit:hidden" > - <span class="sr-only">Edit</span> + <span class="sr-only">{t("times-edit")}</span> <Icon name="edit" class="size-6" /> </button> @@ -172,7 +174,7 @@ function Day(properties: VoidProps<DayProperties>) { disabled={updateMutation.isPending} class="icon-button fill-on-surface hidden group-data-is-edit:block" > - <span class="sr-only">Save</span> + <span class="sr-only">{t("times-save")}</span> <Icon name="save" class="size-6" /> </button> @@ -181,7 +183,7 @@ function Day(properties: VoidProps<DayProperties>) { disabled={updateMutation.isPending} class="icon-button fill-on-surface hidden group-data-is-edit:block" > - <span class="sr-only">Cancel</span> + <span class="sr-only">{t("times-cancel")}</span> <Icon name="close" class="size-6" /> </button> @@ -245,7 +247,7 @@ function Day(properties: VoidProps<DayProperties>) { > <input type="hidden" name="time-id" value={time.id} /> <label for={`start-${time.id}`} class="sr-only"> - Start time + {t("times-start-time-label")} </label> <input type="time" @@ -259,7 +261,7 @@ function Day(properties: VoidProps<DayProperties>) { /> <label for={`end-${time.id}`} class="sr-only"> - End time + {t("times-end-time-label")} </label> <input type="time" @@ -277,7 +279,7 @@ function Day(properties: VoidProps<DayProperties>) { {formattedNewDuration()} </time> <label class="icon-button group"> - <span class="sr-only">Delete</span> + <span class="sr-only">{t("times-delete")}</span> <Icon name="delete" class="size-6 group-has-checked:hidden" /> <Icon name="close" class="hidden size-6 group-has-checked:block" /> <input @@ -300,6 +302,7 @@ function Day(properties: VoidProps<DayProperties>) { function Times() { const routeData = Route.useLoaderData(); + const { t } = useI18n(); // const timesByDate = Map.groupBy(times, (time) => { // return time.start.toZonedDateTimeISO(timeZone).toPlainDate().toString(); @@ -333,8 +336,8 @@ function Times() { return ( <> - <Title title="Times" /> - <FloatingActionButton to="/times/new" label="Log time" icon="add" /> + <Title title={t("times-title")} /> + <FloatingActionButton to="/times/new" label={t("times-log-time")} icon="add" /> <main class="grid h-min gap-y-4 px-4"> <For each={times()}> {([day, timesForDay]) => ( diff --git a/client/src/routes/organizations/$id/edit.tsx b/client/src/routes/organizations/$id/edit.tsx index 980f1b7..89f01f9 100644 --- a/client/src/routes/organizations/$id/edit.tsx +++ b/client/src/routes/organizations/$id/edit.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/solid-router"; import Body from "../../../Body"; import Icon from "../../../Icon"; +import { useI18n } from "../../../i18n"; import { query, updateOrganizationName, type Id as OrganizationId } from "../../../organization"; import { idQuery, type UserId } from "../../../user"; @@ -25,6 +26,7 @@ function RouteComponent() { const search = Route.useSearch(); const loaderData = Route.useLoaderData(); const parameters = Route.useParams(); + const { t } = useI18n(); const updateNameMutation = useMutation(() => ({ async mutationFn({ userId, id, name }: { userId: UserId; id: OrganizationId; name: string }) { @@ -84,17 +86,17 @@ function RouteComponent() { <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> <header class="bg-surface-container-high text-on-surface flex py-1"> <Link to="/organizations" class="cursor-default p-4"> - <span class="sr-only">Discard</span> + <span class="sr-only">{t("org-edit-discard")}</span> <Icon name="close" class="fill-on-surface size-6" /> </Link> - <h1 class="text-title-lg content-center">Edit Organization</h1> + <h1 class="text-title-lg content-center">{t("org-edit-title")}</h1> </header> <main class="h-min"> <form id="organization" onSubmit={handleSubmit} class="grid h-full grid-cols-2 gap-x-4 p-6"> {/* TODO same day toggle */} {/* TODO form validation start < end */} <label for="name" class="text-label-lg text-on-surface-variant col-span-2 block"> - Name + {t("org-edit-name-label")} </label> <input type="text" @@ -107,11 +109,11 @@ function RouteComponent() { </main> <footer class="mt-6 grid grid-cols-2 gap-4 px-6 py-4"> <Link to="/organizations" data-variant="outlined" class="button"> - Cancel + {t("org-edit-cancel")} </Link> <button type="submit" form="organization" data-variant="filled" class="button"> - Save + {t("org-edit-save")} </button> </footer> </Body> diff --git a/client/src/routes/organizations/$id/members/add.tsx b/client/src/routes/organizations/$id/members/add.tsx index bcb38ae..814b2b5 100644 --- a/client/src/routes/organizations/$id/members/add.tsx +++ b/client/src/routes/organizations/$id/members/add.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/solid-router"; import Body from "../../../../Body"; import Icon from "../../../../Icon"; +import { useI18n } from "../../../../i18n"; export const Route = createFileRoute("/organizations/$id/members/add")({ component: RouteComponent, @@ -9,20 +10,21 @@ export const Route = createFileRoute("/organizations/$id/members/add")({ function RouteComponent() { const parameters = Route.useParams(); + const { t } = useI18n(); return ( <> <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> <header class="bg-surface-container-high text-on-surface flex py-1"> <Link to="/organizations/$id/members" params={parameters} class="cursor-default p-4"> - <span class="sr-only">Discard</span> + <span class="sr-only">{t("org-add-member-discard")}</span> <Icon name="close" class="fill-on-surface size-6" /> </Link> - <h1 class="text-title-lg content-center">Add member</h1> + <h1 class="text-title-lg content-center">{t("org-add-member-title")}</h1> </header> <main class="h-min"> <form id="time" class="grid h-full grid-cols-2 gap-x-4 p-6"> <label for="Username" class="text-label-lg text-on-surface-variant col-span-2 block"> - Username + {t("org-add-member-username-label")} </label> <input type="text" @@ -40,11 +42,11 @@ function RouteComponent() { data-variant="outlined" class="button" > - Cancel + {t("org-add-member-cancel")} </Link> <button type="submit" form="time" data-variant="filled" class="button"> - Add + {t("org-add-member-add")} </button> </footer> </Body> diff --git a/client/src/routes/organizations/$id/members/index.tsx b/client/src/routes/organizations/$id/members/index.tsx index b3f3138..9598e37 100644 --- a/client/src/routes/organizations/$id/members/index.tsx +++ b/client/src/routes/organizations/$id/members/index.tsx @@ -3,6 +3,7 @@ import { For, type ParentProps } from "solid-js"; import { ErrorDetails } from "../../../../ErrorDetails"; import Icon from "../../../../Icon"; +import { useI18n } from "../../../../i18n"; import type { Id as OrganizationId } from "../../../../organization"; import { Title } from "../../../../Title"; import { TopAppBar } from "../../../../TopAppBar"; @@ -15,6 +16,7 @@ type Organization = { }; function Page(properties: ParentProps<{ title: string }>) { + const { t } = useI18n(); return ( <> <TopAppBar @@ -22,7 +24,7 @@ function Page(properties: ParentProps<{ title: string }>) { leadingAction={ <Link to="/organizations" data-position="leading" class="action-button"> <Icon name="arrow-back" class="fill-on-surface size-6" /> - <span class="sr-only">Back</span> + <span class="sr-only">{t("org-members-back")}</span> </Link> } /> @@ -46,12 +48,13 @@ export const Route = createFileRoute("/organizations/$id/members/")({ }, // TODO error component, pending component errorComponent(properties) { + const { t } = useI18n(); return ( - <Page title="Error"> + <Page title={t("org-members-error-page-title")}> <ErrorDetails {...properties} - title="Error loading organization" - explainer="Sorry, an error occurred while loading the organization. Please try again later." + title={t("org-members-error-title")} + explainer={t("org-members-error-body")} /> </Page> ); @@ -60,6 +63,7 @@ export const Route = createFileRoute("/organizations/$id/members/")({ function RouteComponent() { const organization = Route.useLoaderData(); + const { t } = useI18n(); // TODO handle user can not delete themselves or delete all users @@ -67,7 +71,7 @@ function RouteComponent() { <> <Title title={organization().name} /> <Page title={organization().name}> - <h2 class="text-headline-md">Users</h2> + <h2 class="text-headline-md">{t("org-members-users-heading")}</h2> {/* Expect an organization to have at least one user */} <ul class="mt-4 grid grid-cols-[1fr_auto] gap-2"> <For each={organization().users}> @@ -77,7 +81,7 @@ function RouteComponent() { <span class="font-semibold">{user.name}</span> <span>({user.username})</span> </span> <button class="p-3"> - <span class="sr-only">Delete</span> + <span class="sr-only">{t("org-members-delete")}</span> <Icon name="close" class="fill-on-surface size-6" /> </button> </li> @@ -89,7 +93,7 @@ function RouteComponent() { params={{ id: organization().id }} class="floating-action-button" > - <span class="sr-only">Add Member</span> + <span class="sr-only">{t("org-members-add-member")}</span> <Icon name="add" class="fill-on-primary size-6" /> </Link> </Page> diff --git a/client/src/routes/organizations/new.tsx b/client/src/routes/organizations/new.tsx index 5ce7f9b..435e1ec 100644 --- a/client/src/routes/organizations/new.tsx +++ b/client/src/routes/organizations/new.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/solid-router"; import Body from "../../Body"; import Icon from "../../Icon"; +import { useI18n } from "../../i18n"; import { createOrganization, query } from "../../organization"; import { idQuery } from "../../user"; @@ -19,6 +20,7 @@ function RouteComponent() { const navigate = useNavigate(); const queryClient = useQueryClient(); const search = Route.useSearch(); + const { t } = useI18n(); const createMutation = useMutation(() => ({ async mutationFn(name: string, context) { @@ -68,17 +70,17 @@ function RouteComponent() { <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> <header class="bg-surface-container-high text-on-surface flex py-1"> <Link to="/organizations" class="cursor-default p-4"> - <span class="sr-only">Discard</span> + <span class="sr-only">{t("org-new-discard")}</span> <Icon name="close" class="fill-on-surface size-6" /> </Link> - <h1 class="text-title-lg content-center">New Organization</h1> + <h1 class="text-title-lg content-center">{t("org-new-title")}</h1> </header> <main class="h-min"> <form id="organization" onSubmit={handleSubmit} class="grid h-full grid-cols-2 gap-x-4 p-6"> {/* TODO same day toggle */} {/* TODO form validation start < end */} <label for="name" class="text-label-lg text-on-surface-variant col-span-2 block"> - Name + {t("org-new-name-label")} </label> <input type="text" @@ -91,11 +93,11 @@ function RouteComponent() { </main> <footer class="mt-6 grid grid-cols-2 gap-4 px-6 py-4"> <Link to="/organizations" data-variant="outlined" class="button"> - Cancel + {t("org-new-cancel")} </Link> <button type="submit" form="organization" data-variant="filled" class="button"> - Create + {t("org-new-create")} </button> </footer> </Body> diff --git a/client/src/routes/projects/$id.edit.tsx b/client/src/routes/projects/$id.edit.tsx index e0eff14..f3bd138 100644 --- a/client/src/routes/projects/$id.edit.tsx +++ b/client/src/routes/projects/$id.edit.tsx @@ -4,6 +4,7 @@ import { For, Suspense } from "solid-js"; import Body from "../../Body"; import Icon from "../../Icon"; +import { useI18n } from "../../i18n"; import { query as organizationQuery, isOrganization, @@ -39,6 +40,7 @@ function RouteComponent() { const parameters = Route.useParams(); const userIdQuery = useQuery(() => idQuery); const organizations = useQuery(() => organizationQuery(userIdQuery.data)); + const { t } = useI18n(); const updateNameMutation = useMutation(() => ({ mutationFn: updateProject, @@ -99,17 +101,17 @@ function RouteComponent() { <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> <header class="bg-surface-container-high text-on-surface flex py-1"> <Link to="/projects" class="cursor-default p-4"> - <span class="sr-only">Discard</span> + <span class="sr-only">{t("project-edit-discard")}</span> <Icon name="close" class="fill-on-surface size-6" /> </Link> - <h1 class="text-title-lg content-center">Edit Project</h1> + <h1 class="text-title-lg content-center">{t("project-edit-title")}</h1> </header> <main class="h-min"> <form id="project" onSubmit={handleSubmit} class="grid h-full grid-cols-2 gap-x-4 p-6"> {/* TODO same day toggle */} {/* TODO form validation start < end */} <label for="name" class="text-label-lg text-on-surface-variant col-span-2 block"> - Name + {t("project-edit-name-label")} </label> <input type="text" @@ -122,13 +124,13 @@ function RouteComponent() { for="organization" class="text-label-lg text-on-surface-variant row-start-3 mt-4 block" > - Organization + {t("project-edit-organization-label")} </label> <select id="organization" required class="text-field col-span-2 mt-1 w-full"> <Suspense fallback={ <option value="" disabled> - Loading... + {t("project-edit-loading")} </option> } > @@ -141,11 +143,11 @@ function RouteComponent() { </main> <footer class="mt-6 grid grid-cols-2 gap-4 px-6 py-4"> <Link to="/projects" data-variant="outlined" class="button"> - Cancel + {t("project-edit-cancel")} </Link> <button type="submit" form="project" data-variant="filled" class="button"> - Save + {t("project-edit-save")} </button> </footer> </Body> diff --git a/client/src/routes/projects/new.tsx b/client/src/routes/projects/new.tsx index 4b70318..57ced68 100644 --- a/client/src/routes/projects/new.tsx +++ b/client/src/routes/projects/new.tsx @@ -4,6 +4,7 @@ import { For, Suspense } from "solid-js"; import Body from "../../Body"; import Icon from "../../Icon"; +import { useI18n } from "../../i18n"; import { isOrganization, query } from "../../organization"; import { idQuery } from "../../user"; @@ -16,6 +17,7 @@ function RouteComponent() { const organizations = useQuery(() => query(userIdQuery.data)); const navigate = useNavigate(); const queryClient = useQueryClient(); + const { t } = useI18n(); async function handleSubmit(event: SubmitEvent) { event.preventDefault(); @@ -54,17 +56,17 @@ function RouteComponent() { <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> <header class="bg-surface-container-high text-on-surface flex py-1"> <Link to="/projects" class="cursor-default p-4"> - <span class="sr-only">Discard</span> + <span class="sr-only">{t("project-new-discard")}</span> <Icon name="close" class="fill-on-surface size-6" /> </Link> - <h1 class="text-title-lg content-center">New Project</h1> + <h1 class="text-title-lg content-center">{t("project-new-title")}</h1> </header> <main class="h-min"> <form id="project" onSubmit={handleSubmit} class="grid h-full grid-cols-2 gap-x-4 p-6"> {/* TODO same day toggle */} {/* TODO form validation start < end */} <label for="name" class="text-label-lg text-on-surface-variant col-span-2 block"> - Name + {t("project-new-name-label")} </label> <input type="text" id="name" required class="text-field col-span-2 mt-1 w-full" /> @@ -72,13 +74,13 @@ function RouteComponent() { for="organization" class="text-label-lg text-on-surface-variant row-start-3 mt-4 block" > - Organization + {t("project-new-organization-label")} </label> <select id="organization" required class="text-field col-span-2 mt-1 w-full"> <Suspense fallback={ <option value="" disabled> - Loading... + {t("project-new-loading")} </option> } > @@ -91,7 +93,7 @@ function RouteComponent() { </main> <footer class="mt-6 grid grid-cols-2 gap-4 px-6 py-4"> <Link to="/projects" data-variant="outlined" class="button"> - Cancel + {t("project-new-cancel")} </Link> <button @@ -101,7 +103,7 @@ function RouteComponent() { data-variant="filled" class="button" > - Create + {t("project-new-create")} </button> </footer> </Body> diff --git a/client/src/routes/sign-in.tsx b/client/src/routes/sign-in.tsx index 5dbd116..5835fe8 100644 --- a/client/src/routes/sign-in.tsx +++ b/client/src/routes/sign-in.tsx @@ -3,7 +3,8 @@ import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/solid-ro import { createSignal } from "solid-js"; import Body from "../Body"; -import { idQuery, UserId } from "../user"; +import { useI18n } from "../i18n"; +import { idQuery, type UserId } from "../user"; import { Route as TimesRoute } from "./_app/times"; export const Route = createFileRoute("/sign-in")({ @@ -33,6 +34,7 @@ export const Route = createFileRoute("/sign-in")({ function SignIn() { const [error, setError] = createSignal<string | undefined>(); + const { t } = useI18n(); const navigate = useNavigate(); const search = Route.useSearch(); @@ -57,18 +59,18 @@ function SignIn() { }); } catch (error) { console.error("Sign in error", error); - setError("An error occurred connecting to the server. Please try again later."); + setError(t("sign-in-error-server")); return; } if (response.status === 403) { - setError("Invalid username or password"); + setError(t("sign-in-error-invalid-credentials")); return; } if (!response.ok) { console.error("Sign in failed", response); - setError("An error occurred signing in. Please try again."); + setError(t("sign-in-error-unknown")); return; } @@ -85,13 +87,13 @@ function SignIn() { return ( <Body class="mx-auto h-dvh max-w-2xl p-6"> <main class="grid h-full items-end"> - <h1 class="text-primary text-display-lg text-center font-serif">Welcome</h1> + <h1 class="text-primary text-display-lg text-center font-serif">{t("sign-in-welcome")}</h1> <form onSubmit={handleSubmit} class="rounded-3xl text-base leading-6"> <p class="text-error text-body-lg min-h-6 text-center transition empty:opacity-0"> {error()} </p> <label for="username" class="text-label-lg text-on-surface-variant block"> - Username + {t("sign-in-username-label")} </label> <input type="text" @@ -103,7 +105,7 @@ function SignIn() { /> <label for="password" class="text-label-lg text-on-surface-variant mt-4 block"> - Password + {t("sign-in-password-label")} </label> <input type="password" @@ -116,10 +118,10 @@ function SignIn() { {/* TODO loading state */} <button type="submit" data-variant="filled" class="button mt-6 w-full"> - Sign in + {t("sign-in-submit")} </button> <Link to="/sign-up" data-variant="text" class="button mt-4 w-full"> - Sign up for an account + {t("sign-in-go-sign-up")} </Link> </form> </main> diff --git a/client/src/routes/sign-up.tsx b/client/src/routes/sign-up.tsx index 7f9090f..8498184 100644 --- a/client/src/routes/sign-up.tsx +++ b/client/src/routes/sign-up.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/solid-ro import { createSignal } from "solid-js"; import Body from "../Body"; +import { useI18n } from "../i18n"; import { idQuery, UnauthenticatedError, type UserId } from "../user"; export const Route = createFileRoute("/sign-up")({ @@ -25,6 +26,7 @@ function RouteComponent() { const [error, setError] = createSignal<string | undefined>(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const { t } = useI18n(); async function handleSubmit(event: SubmitEvent) { event.preventDefault(); @@ -52,18 +54,18 @@ function RouteComponent() { }); } catch (error) { console.error("Sign up error", error); - setError("An error occurred connecting to the server. Please try again later."); + setError(t("sign-up-error-server")); return; } if (response.status === 409) { - setError("Username is already taken"); + setError(t("sign-up-error-username-taken")); return; } if (!response.ok) { console.error("Sign up error", response.statusText); - setError("An error occurred during sign up. Please try again later."); + setError(t("sign-up-error-unknown")); return; } @@ -79,13 +81,15 @@ function RouteComponent() { return ( <Body class="mx-auto h-dvh max-w-2xl p-6"> <main class="grid h-full items-end"> - <h1 class="text-primary text-display-lg text-center font-serif">Create an account</h1> + <h1 class="text-primary text-display-lg text-center font-serif"> + {t("sign-up-create-account")} + </h1> <form onSubmit={handleSubmit} class="rounded-3xl text-base leading-6"> <p class="text-error text-body-lg min-h-6 text-center transition empty:opacity-0"> {error()} </p> <label for="name" class="text-label-lg text-on-surface-variant block"> - Name + {t("sign-up-name-label")} </label> <input type="text" @@ -96,7 +100,7 @@ function RouteComponent() { /> <label for="username" class="text-label-lg text-on-surface-variant mt-4 block"> - Username + {t("sign-up-username-label")} </label> <input type="text" @@ -108,7 +112,7 @@ function RouteComponent() { /> <label for="password" class="text-label-lg text-on-surface-variant mt-4 block"> - New password + {t("sign-up-password-label")} </label> <input type="password" @@ -121,7 +125,7 @@ function RouteComponent() { {/* TODO loading state */} <button type="submit" data-variant="filled" class="button mt-6 w-full"> - Sign up + {t("sign-up-submit")} </button> {/* Do not need to pass redirect as a user that signs up should not have been on the app before to be redirected to where they left off */} <Link @@ -130,7 +134,7 @@ function RouteComponent() { data-variant="text" class="button mt-4 w-full" > - Already have an account? Sign in + {t("sign-up-go-sign-in")} </Link> </form> </main> diff --git a/client/src/routes/times/new.tsx b/client/src/routes/times/new.tsx index cb63c0a..20e9762 100644 --- a/client/src/routes/times/new.tsx +++ b/client/src/routes/times/new.tsx @@ -4,6 +4,7 @@ import { For, Suspense } from "solid-js"; import Body from "../../Body"; import Icon from "../../Icon"; +import { useI18n } from "../../i18n"; import { isProject, query } from "../../project"; import { idQuery } from "../../user"; @@ -18,6 +19,7 @@ function RouteComponent() { const userId = useQuery(() => idQuery); const projects = useQuery(() => query(userId.data)); const queryClient = useQueryClient(); + const { t } = useI18n(); const navigate = useNavigate(); @@ -80,14 +82,14 @@ function RouteComponent() { const { start, end, startInput, endInput } = extractValues(input.form!); if (isStart && Temporal.ZonedDateTime.compare(start, end) >= 0) { - startInput.setCustomValidity("Start time must be before end time."); + startInput.setCustomValidity(t("time-new-validation-start-before-end")); return; } else { startInput.setCustomValidity(""); } if (!isStart && Temporal.ZonedDateTime.compare(end, start) >= 0) { - endInput.setCustomValidity("Start time must be before end time."); + endInput.setCustomValidity(t("time-new-validation-start-before-end")); return; } else { endInput.setCustomValidity(""); @@ -98,17 +100,17 @@ function RouteComponent() { <Body class="bg-surface-container-high text-on-surface grid h-dvh grid-rows-[auto_1fr_auto]"> <header class="bg-surface-container-high text-on-surface flex py-1"> <Link to="/times" class="cursor-default p-4"> - <span class="sr-only">Discard</span> + <span class="sr-only">{t("time-new-discard")}</span> <Icon name="close" class="fill-on-surface size-6" /> </Link> - <h1 class="text-title-lg content-center">Log new time</h1> + <h1 class="text-title-lg content-center">{t("time-new-title")}</h1> </header> <main class="h-min"> <form id="time" onSubmit={handleSubmit} class="grid h-full grid-cols-2 gap-x-4 p-6"> {/* TODO same day toggle */} {/* TODO form validation start < end */} <label for="date" class="text-label-lg text-on-surface-variant col-span-full block"> - Date + {t("time-new-date-label")} </label> <input type="date" @@ -119,8 +121,11 @@ function RouteComponent() { class="text-field col-span-full mt-1 w-full" /> - <label for="start" class="text-label-lg text-on-surface-variant row-start-3 mt-4 block"> - Start + <label + for="start" + class="text-label-lg text-on-surface-variant row-start-3 mt-4 block" + > + {t("time-new-start-label")} </label> <input type="time" @@ -132,7 +137,7 @@ function RouteComponent() { /> <label for="end" class="text-label-lg text-on-surface-variant row-start-3 mt-4 block"> - End + {t("time-new-end-label")} </label> <input type="time" @@ -147,7 +152,7 @@ function RouteComponent() { for="project" class="text-label-lg text-on-surface-variant col-span-full mt-4 block" > - Project + {t("time-new-project-label")} </label> <select id="project" @@ -155,7 +160,7 @@ function RouteComponent() { required class="text-field col-span-full mt-1 w-full" > - <Suspense fallback={<option disabled>Loading projects...</option>}> + <Suspense fallback={<option disabled>{t("time-new-loading-projects")}</option>}> <For each={selectableProjects()}> {(project) => <option value={project.id}>{project.name}</option>} </For> @@ -165,7 +170,7 @@ function RouteComponent() { </main> <footer class="mt-6 grid grid-cols-2 gap-4 px-6 py-4"> <Link to="/times" data-variant="outlined" class="button"> - Cancel + {t("time-new-cancel")} </Link> <button @@ -175,7 +180,7 @@ function RouteComponent() { disabled={projects.isLoading} class="button" > - Save + {t("time-new-save")} </button> </footer> </Body>