From 58e37620e72f86147398064da95aa6547cf8aba6 Mon Sep 17 00:00:00 2001 From: Gal Podlipnik Date: Mon, 14 Jul 2025 22:07:20 +0200 Subject: [PATCH] locale --- client/libs/shared/i18n/src/index.ts | 6 +- .../shared/i18n/src/lib/app-sl.translation.ts | 52 +++++++++++++++++ .../libs/shared/i18n/src/lib/app.namespace.ts | 58 +++++++++++++++++++ client/libs/shared/i18n/src/lib/app.t.ts | 12 ++++ .../lib/language-selector.component.spec.ts | 0 .../src/lib/language-selector.component.ts | 8 +-- .../i18n/src/lib/language.service.spec.ts | 0 .../shared/i18n/src/lib/language.service.ts | 53 ++++++++--------- .../shared/i18n/src/lib/translate.pipe.ts | 9 +++ .../shared/navbar/src/lib/navbar.component.ts | 46 +++++++-------- .../src/lib/add-task-dialog.component.ts | 22 ++++--- .../web/tasks/src/lib/task-card.component.ts | 32 +++++----- .../tasks/src/lib/task-comment.component.ts | 51 ++++++++++------ .../web/tasks/src/lib/task-form.component.ts | 12 ++-- .../web/tasks/src/lib/task-list.component.ts | 9 +-- .../web/users/src/lib/user-card.component.ts | 18 ++++-- .../users/src/lib/user-details.component.ts | 20 +++++-- .../web/users/src/lib/user-list.component.ts | 8 ++- .../users/src/lib/user-task-list.component.ts | 16 +++-- client/package-lock.json | 8 +-- client/package.json | 1 + client/src/app/app.config.ts | 5 +- client/src/app/app.routes.ts | 46 ++++++++++----- client/src/app/app.ts | 20 +------ client/src/app/locale-shell.component.ts | 25 ++++++++ client/src/app/locale-shell.routes.ts | 24 ++++++++ client/tsconfig.base.json | 2 +- 27 files changed, 396 insertions(+), 167 deletions(-) create mode 100644 client/libs/shared/i18n/src/lib/app-sl.translation.ts create mode 100644 client/libs/shared/i18n/src/lib/app.namespace.ts create mode 100644 client/libs/shared/i18n/src/lib/app.t.ts delete mode 100644 client/libs/shared/i18n/src/lib/language-selector.component.spec.ts delete mode 100644 client/libs/shared/i18n/src/lib/language.service.spec.ts create mode 100644 client/libs/shared/i18n/src/lib/translate.pipe.ts create mode 100644 client/src/app/locale-shell.component.ts create mode 100644 client/src/app/locale-shell.routes.ts diff --git a/client/libs/shared/i18n/src/index.ts b/client/libs/shared/i18n/src/index.ts index 962253d..bbb190f 100644 --- a/client/libs/shared/i18n/src/index.ts +++ b/client/libs/shared/i18n/src/index.ts @@ -1,2 +1,6 @@ -export * from './lib/language.service'; +export * from './lib/app-sl.translation'; +export * from './lib/app.namespace'; +export * from './lib/app.t'; export * from './lib/language-selector.component'; +export * from './lib/language.service'; +export * from './lib/translate.pipe'; diff --git a/client/libs/shared/i18n/src/lib/app-sl.translation.ts b/client/libs/shared/i18n/src/lib/app-sl.translation.ts new file mode 100644 index 0000000..4bd4adb --- /dev/null +++ b/client/libs/shared/i18n/src/lib/app-sl.translation.ts @@ -0,0 +1,52 @@ +import { createAppTranslation } from './app.namespace'; + +export default createAppTranslation('sl', { + taskManager: 'Upravljanje nalog', + navbar: { + users: 'Uporabniki', + tasks: 'Naloge', + }, + task: '{count, plural, =0 {Ni nalog} one {# naloga} two {# nalogi} other {# nalog}}', + comment: + '{count, plural, =0 {Ni komentarjev} one {# komentar} two {# komentarja} other {# komentarji}}', + searchUser: { + title: 'Iskanje uporabnikov', + placeholder: 'Vnesite ime ali e-pošto uporabnika', + }, + addUser: 'Dodaj uporabnika', + userDetails: 'Podrobnosti uporabnika', + userId: 'ID uporabnika', + addNewTask: 'Dodaj novo nalogo', + title: 'Naslov', + description: 'Opis', + addTask: 'Dodaj nalogo', + userTaskList: 'Seznam nalog uporabnika', + backToUser: 'Nazaj na seznam uporabnikov', + userNotFound: 'Uporabnik ni najden', + loading: 'Nalaganje...', + titleIsRequired: 'Naslov je obvezen', + descriptionIsRequired: 'Opis je obvezen', + taskAdded: 'Naloga uspešno dodana', + taskDeleted: 'Naloga uspešno izbrisana', + userAdded: 'Uporabnik uspešno dodan', + noTasksFound: 'Ni najdenih nalog za tega uporabnika', + noTaskFound: 'Ni najdenih nalog', + noDescription: 'Brez opisa', + unknownUser: 'Neznan uporabnik', + unknownDate: 'Neznan datum', + todayAt: 'Danes ob {time}', + yesterdayAt: 'Včeraj ob {time}', + daysAgo: '{count} dni nazaj', + noComments: 'Še ni komentarjev', + addComment: 'Dodaj komentar', + yourComment: 'Vaš komentar', + shareYourThoughts: 'Delite svoje misli', + commentIsRequired: 'Komentar je obvezen', + postAs: 'Objavi kot', + selectUser: 'Izberite uporabnika', + postComment: 'Objavi komentar', + posting: 'Objavljanje...', + taskTitle: 'Naslov naloge', + taskDescription: 'Opis naloge', + cancel: 'Prekliči', +}); diff --git a/client/libs/shared/i18n/src/lib/app.namespace.ts b/client/libs/shared/i18n/src/lib/app.namespace.ts new file mode 100644 index 0000000..dcb2acf --- /dev/null +++ b/client/libs/shared/i18n/src/lib/app.namespace.ts @@ -0,0 +1,58 @@ +import { createNamespace } from '@mmstack/translate'; + +const ns = createNamespace('app', { + taskManager: 'Task Manager', + navbar: { + users: 'Users', + tasks: 'Tasks', + }, + task: '{count, plural, =0 {No tasks} one {# task} other {# tasks}}', + comment: + '{count, plural, =0 {No comments} one {# comment} other {# comments}}', + searchUser: { + title: 'Search Users', + placeholder: 'Enter user name or email', + }, + addUser: 'Add User', + userDetails: 'User Details', + userId: 'User Id', + addNewTask: 'Add New Task', + title: 'Title', + description: 'Description', + addTask: 'Add Task', + userTaskList: 'User Task List', + backToUser: 'Back to Users', + userNotFound: 'User not found', + loading: 'Loading...', + titleIsRequired: 'Title is required', + descriptionIsRequired: 'Description is required', + taskAdded: 'Task added successfully', + taskDeleted: 'Task deleted successfully', + userAdded: 'User added successfully', + noTasksFound: 'No tasks found for this user', + noTaskFound: 'No tasks found', + noDescription: 'No description', + unknownUser: 'Unknown User', + unknownDate: 'Unknown date', + todayAt: 'Today at {time}', + yesterdayAt: 'Yesterday at {time}', + daysAgo: '{count} days ago', + noComments: 'No comments yet', + addComment: 'Add Comment', + yourComment: 'Your comment', + shareYourThoughts: 'Share your thoughts', + commentIsRequired: 'Comment is required', + postAs: 'Post as', + selectUser: 'Select user', + postComment: 'Post Comment', + posting: 'Posting...', + taskTitle: 'Task Title', + taskDescription: 'Task Description', + cancel: 'Cancel', +}); + +export default ns.translation; + +export type AppLocale = (typeof ns)['translation']; + +export const createAppTranslation = ns.createTranslation; diff --git a/client/libs/shared/i18n/src/lib/app.t.ts b/client/libs/shared/i18n/src/lib/app.t.ts new file mode 100644 index 0000000..fbb46ce --- /dev/null +++ b/client/libs/shared/i18n/src/lib/app.t.ts @@ -0,0 +1,12 @@ +import { registerNamespace } from '@mmstack/translate'; + +const r = registerNamespace( + () => import('./app.namespace').then((m) => m.default), + { + sl: () => import('./app-sl.translation').then((m) => m.default), + } +); + +export const injectAppT = r.injectNamespaceT; +export const resolveAppTranslations = r.resolveNamespaceTranslation; + diff --git a/client/libs/shared/i18n/src/lib/language-selector.component.spec.ts b/client/libs/shared/i18n/src/lib/language-selector.component.spec.ts deleted file mode 100644 index e69de29..0000000 diff --git a/client/libs/shared/i18n/src/lib/language-selector.component.ts b/client/libs/shared/i18n/src/lib/language-selector.component.ts index aeaca86..ccdd0cc 100644 --- a/client/libs/shared/i18n/src/lib/language-selector.component.ts +++ b/client/libs/shared/i18n/src/lib/language-selector.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; -import { LanguageService } from './language.service'; +import { LocaleStore } from './language.service'; @Component({ selector: 'lib-language-selector', @@ -19,10 +19,10 @@ import { LanguageService } from './language.service'; translate - - @@ -40,5 +40,5 @@ import { LanguageService } from './language.service'; ], }) export class LanguageSelectorComponent { - protected languageService = inject(LanguageService); + protected languageService = inject(LocaleStore); } diff --git a/client/libs/shared/i18n/src/lib/language.service.spec.ts b/client/libs/shared/i18n/src/lib/language.service.spec.ts deleted file mode 100644 index e69de29..0000000 diff --git a/client/libs/shared/i18n/src/lib/language.service.ts b/client/libs/shared/i18n/src/lib/language.service.ts index cd38453..389be25 100644 --- a/client/libs/shared/i18n/src/lib/language.service.ts +++ b/client/libs/shared/i18n/src/lib/language.service.ts @@ -1,39 +1,32 @@ -import { Injectable, signal } from '@angular/core'; - -export type SupportedLanguage = 'en' | 'sl'; +import { inject, Injectable, signal } from '@angular/core'; +import { Router } from '@angular/router'; @Injectable({ providedIn: 'root', }) -export class LanguageService { - private readonly LANGUAGE_KEY = 'selectedLanguage'; +export class LocaleStore { + private readonly router = inject(Router); + locale = signal('en'); - currentLanguage = signal(this.#getInitialLanguage()); - - #getInitialLanguage(): SupportedLanguage { - // Try to get language from localStorage - const storedLang = localStorage.getItem( - this.LANGUAGE_KEY - ) as SupportedLanguage | null; - if (storedLang && (storedLang === 'en' || storedLang === 'sl')) { - return storedLang; - } - - // Try to detect from browser settings - const browserLang = navigator.language.substring(0, 2).toLowerCase(); - if (browserLang === 'sl') { - return 'sl'; - } - - // Default to English - return 'en'; + getCurrentLocale() { + return this.locale(); } - setLanguage(lang: SupportedLanguage): void { - this.currentLanguage.set(lang); - localStorage.setItem(this.LANGUAGE_KEY, lang); - // This is where you would typically trigger translation changes - // in a real implementation - console.log(`Language set to: ${lang}`); + setLocale(locale: string) { + if (this.locale() === locale) return; + + this.locale.set(locale); + + const urlSegments = this.router.url.split('/'); + const routePath = urlSegments.slice(2).join('/'); + + const newUrl = `/${locale}/${routePath}`; + + window.location.href = newUrl; + } + + toggleLocale() { + const newLocale = this.locale() === 'en' ? 'sl' : 'en'; + this.setLocale(newLocale); } } diff --git a/client/libs/shared/i18n/src/lib/translate.pipe.ts b/client/libs/shared/i18n/src/lib/translate.pipe.ts new file mode 100644 index 0000000..d411115 --- /dev/null +++ b/client/libs/shared/i18n/src/lib/translate.pipe.ts @@ -0,0 +1,9 @@ +import { Pipe } from '@angular/core'; +import { BaseTranslatePipe } from '@mmstack/translate'; +import { type AppLocale } from './app.namespace'; + +@Pipe({ + name: 'translate', +}) +export class AppTranslatePipe extends BaseTranslatePipe {} + diff --git a/client/libs/shared/navbar/src/lib/navbar.component.ts b/client/libs/shared/navbar/src/lib/navbar.component.ts index dbc4fe7..1187313 100644 --- a/client/libs/shared/navbar/src/lib/navbar.component.ts +++ b/client/libs/shared/navbar/src/lib/navbar.component.ts @@ -1,10 +1,19 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { RouterModule } from '@angular/router'; +import { + AppTranslatePipe, + LanguageSelectorComponent, + LocaleStore, +} from '@org/shared/i18n'; import { ThemeToggleComponent } from '@org/shared/theme'; -import { LanguageSelectorComponent } from '@org/shared/i18n'; @Component({ selector: 'lib-navbar', @@ -15,42 +24,40 @@ import { LanguageSelectorComponent } from '@org/shared/i18n'; RouterModule, ThemeToggleComponent, LanguageSelectorComponent, + AppTranslatePipe, ], changeDetection: ChangeDetectionStrategy.OnPush, template: `
- - -
@@ -92,19 +99,9 @@ import { LanguageSelectorComponent } from '@org/shared/i18n'; transition: background-color 0.2s ease; } .nav-button.active-link { - background-color: rgba(255, 255, 255, 0.15); + background-color: var(--mat-sys-outline-variant); position: relative; } - .nav-button.active-link::after { - content: ''; - position: absolute; - bottom: 0; - left: 10%; - right: 10%; - height: 3px; - background-color: white; - border-radius: 3px 3px 0 0; - } .action-buttons { display: flex; gap: 0.5rem; @@ -114,11 +111,14 @@ import { LanguageSelectorComponent } from '@org/shared/i18n'; transition: background-color 0.2s ease; } .action-button:hover { - background-color: rgba(255, 255, 255, 0.1); + background-color: var(--hover-color); } `, ], }) export class NavbarComponent { - // All functionality has been moved to separate components + readonly #localeStore = inject(LocaleStore); + + readonly usersLink = computed(() => `/${this.#localeStore.locale()}/users`); + readonly tasksLink = computed(() => `/${this.#localeStore.locale()}/tasks`); } diff --git a/client/libs/web/tasks/src/lib/add-task-dialog.component.ts b/client/libs/web/tasks/src/lib/add-task-dialog.component.ts index 9d3d61c..3a73ad9 100644 --- a/client/libs/web/tasks/src/lib/add-task-dialog.component.ts +++ b/client/libs/web/tasks/src/lib/add-task-dialog.component.ts @@ -23,16 +23,19 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { TasksService, User, UsersService } from '@org/shared/api'; +import { AppTranslatePipe } from '@org/shared/i18n'; import { ToastService } from '@org/shared/toast'; @Component({ selector: 'lib-add-task-dialog', template: ` -

Add New Task

+

+ {{ 'app.addNewTask' | translate }} +

- Assigned to + {{ 'app.selectUser' | translate }} @for (user of users(); track user.id) { {{ user.name }} @@ -40,32 +43,32 @@ import { ToastService } from '@org/shared/toast'; @if (taskForm.get('userId')?.hasError('required') && taskForm.get('userId')?.touched) { - Please select a user + {{ 'app.userNotFound' | translate }} } - Title + {{ 'app.taskTitle' | translate }} @if (taskForm.get('title')?.hasError('required') && taskForm.get('title')?.touched) { - Title is required + {{ 'app.titleIsRequired' | translate }} } - Description + {{ 'app.taskDescription' | translate }}
@@ -120,6 +123,7 @@ import { ToastService } from '@org/shared/toast'; MatIconModule, MatSelectModule, ReactiveFormsModule, + AppTranslatePipe, ], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/client/libs/web/tasks/src/lib/task-card.component.ts b/client/libs/web/tasks/src/lib/task-card.component.ts index 8e18c69..e5f92f0 100644 --- a/client/libs/web/tasks/src/lib/task-card.component.ts +++ b/client/libs/web/tasks/src/lib/task-card.component.ts @@ -13,6 +13,7 @@ import { MatDividerModule } from '@angular/material/divider'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatIconModule } from '@angular/material/icon'; import { CommentResponse, TaskResponse, User } from '@org/shared/api'; +import { AppTranslatePipe, injectAppT } from '@org/shared/i18n'; import { TaskCommentComponent } from './task-comment.component'; @Component({ @@ -25,6 +26,7 @@ import { TaskCommentComponent } from './task-comment.component'; MatBadgeModule, MatButtonModule, TaskCommentComponent, + AppTranslatePipe, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -69,7 +71,7 @@ import { TaskCommentComponent } from './task-comment.component';

forum - Comments + {{ 'app.comment' | translate : { count: taskComments().length } }}

(); readonly users = input([]); readonly comments = input([]); @@ -205,7 +208,6 @@ export class TaskCardComponent { readonly taskComments = signal([]); constructor() { - // Update task comments whenever comments or task changes effect(() => { const taskId = this.task()?.tasks?.id; if (taskId && this.comments()) { @@ -220,11 +222,11 @@ export class TaskCardComponent { getUserName(userId: number): string { const user = this.users().find((u) => u.id === userId); - return user ? user.name : 'Unknown User'; + return user ? user.name : this.t('app.unknownUser'); } formatDate(timestamp?: string): string { - if (!timestamp) return 'Unknown date'; + if (!timestamp) return this.t('app.unknownDate'); const date = new Date(timestamp); const now = new Date(); @@ -232,17 +234,21 @@ export class TaskCardComponent { const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) { - return ( - 'Today at ' + - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - ); + return this.t('app.todayAt', { + time: date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + }); } else if (diffDays === 1) { - return ( - 'Yesterday at ' + - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - ); + return this.t('app.yesterdayAt', { + time: date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + }); } else if (diffDays < 7) { - return `${diffDays} days ago`; + return this.t('app.daysAgo', { count: diffDays.toString() }); } else { return date.toLocaleDateString([], { day: 'numeric', diff --git a/client/libs/web/tasks/src/lib/task-comment.component.ts b/client/libs/web/tasks/src/lib/task-comment.component.ts index 043ef9b..44ac499 100644 --- a/client/libs/web/tasks/src/lib/task-comment.component.ts +++ b/client/libs/web/tasks/src/lib/task-comment.component.ts @@ -18,6 +18,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { CommentResponse, User } from '@org/shared/api'; +import { AppTranslatePipe, injectAppT } from '@org/shared/i18n'; @Component({ selector: 'lib-task-comment', @@ -29,6 +30,7 @@ import { CommentResponse, User } from '@org/shared/api'; MatButtonModule, MatIconModule, MatProgressSpinnerModule, + AppTranslatePipe, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -47,7 +49,7 @@ import { CommentResponse, User } from '@org/shared/api'; }
} @else { -

No comments yet.

+

{{ 'app.noComments' | translate }}

}
-

Add a comment

+

{{ 'app.addComment' | translate }}

- Your comment + {{ 'app.yourComment' | translate }} @if (commentForm.get('content')?.hasError('required') && commentForm.get('content')?.touched) { - Comment text is required + {{ 'app.commentIsRequired' | translate }} }
- Post as + {{ 'app.postAs' | translate }} @if (commentForm.get('userId')?.hasError('required') && commentForm.get('userId')?.touched) { - User is required + {{ 'app.selectUser' | translate }} } @@ -98,7 +102,11 @@ import { CommentResponse, User } from '@org/shared/api'; } @else { send } - {{ submitting() ? 'Posting...' : 'Post Comment' }} + {{ + submitting() + ? ('app.posting' | translate) + : ('app.postComment' | translate) + }}
@@ -214,6 +222,7 @@ import { CommentResponse, User } from '@org/shared/api'; ], }) export class TaskCommentComponent { + readonly t = injectAppT(); readonly taskId = input.required(); readonly comments = input([]); readonly users = input([]); @@ -240,7 +249,7 @@ export class TaskCommentComponent { } formatDate(timestamp?: string): string { - if (!timestamp) return 'Unknown date'; + if (!timestamp) return this.t('app.unknownDate'); const date = new Date(timestamp); const now = new Date(); @@ -248,17 +257,21 @@ export class TaskCommentComponent { const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) { - return ( - 'Today at ' + - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - ); + return this.t('app.todayAt', { + time: date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + }); } else if (diffDays === 1) { - return ( - 'Yesterday at ' + - date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - ); + return this.t('app.yesterdayAt', { + time: date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + }); } else if (diffDays < 7) { - return `${diffDays} days ago`; + return this.t('app.daysAgo', { count: diffDays.toString() }); } else { return date.toLocaleDateString([], { day: 'numeric', diff --git a/client/libs/web/tasks/src/lib/task-form.component.ts b/client/libs/web/tasks/src/lib/task-form.component.ts index 45fbbf0..bfb077b 100644 --- a/client/libs/web/tasks/src/lib/task-form.component.ts +++ b/client/libs/web/tasks/src/lib/task-form.component.ts @@ -18,6 +18,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TasksService } from '@org/shared/api'; +import { AppTranslatePipe } from '@org/shared/i18n'; import { ToastService } from '@org/shared/toast'; @Component({ @@ -30,17 +31,20 @@ import { ToastService } from '@org/shared/toast'; MatInputModule, MatButtonModule, MatCardModule, + AppTranslatePipe, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - Add New Task + {{ + 'app.addNewTask' | translate + }}
- Title + {{ 'app.title' | translate }} @if (taskForm.get('title')?.hasError('required') && taskForm.get('title')?.touched) { @@ -49,7 +53,7 @@ import { ToastService } from '@org/shared/toast'; - Description + {{ 'app.description' | translate }}