This commit is contained in:
Gal Podlipnik 2025-07-14 22:07:20 +02:00
parent fd276b2c07
commit 58e37620e7
27 changed files with 396 additions and 167 deletions

View File

@ -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-selector.component';
export * from './lib/language.service';
export * from './lib/translate.pipe';

View File

@ -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',
});

View File

@ -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;

View File

@ -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;

View File

@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { LanguageService } from './language.service'; import { LocaleStore } from './language.service';
@Component({ @Component({
selector: 'lib-language-selector', selector: 'lib-language-selector',
@ -19,10 +19,10 @@ import { LanguageService } from './language.service';
<mat-icon>translate</mat-icon> <mat-icon>translate</mat-icon>
</button> </button>
<mat-menu #languageMenu="matMenu"> <mat-menu #languageMenu="matMenu">
<button mat-menu-item (click)="languageService.setLanguage('en')"> <button mat-menu-item (click)="languageService.setLocale('en')">
<span>English</span> <span>English</span>
</button> </button>
<button mat-menu-item (click)="languageService.setLanguage('sl')"> <button mat-menu-item (click)="languageService.setLocale('sl')">
<span>Slovenian</span> <span>Slovenian</span>
</button> </button>
</mat-menu> </mat-menu>
@ -40,5 +40,5 @@ import { LanguageService } from './language.service';
], ],
}) })
export class LanguageSelectorComponent { export class LanguageSelectorComponent {
protected languageService = inject(LanguageService); protected languageService = inject(LocaleStore);
} }

View File

@ -1,39 +1,32 @@
import { Injectable, signal } from '@angular/core'; import { inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
export type SupportedLanguage = 'en' | 'sl';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class LanguageService { export class LocaleStore {
private readonly LANGUAGE_KEY = 'selectedLanguage'; private readonly router = inject(Router);
locale = signal('en');
currentLanguage = signal<SupportedLanguage>(this.#getInitialLanguage()); getCurrentLocale() {
return this.locale();
#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 setLocale(locale: string) {
const browserLang = navigator.language.substring(0, 2).toLowerCase(); if (this.locale() === locale) return;
if (browserLang === 'sl') {
return 'sl'; this.locale.set(locale);
const urlSegments = this.router.url.split('/');
const routePath = urlSegments.slice(2).join('/');
const newUrl = `/${locale}/${routePath}`;
window.location.href = newUrl;
} }
// Default to English toggleLocale() {
return 'en'; const newLocale = this.locale() === 'en' ? 'sl' : 'en';
} this.setLocale(newLocale);
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}`);
} }
} }

View File

@ -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<AppLocale> {}

View File

@ -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 { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import {
AppTranslatePipe,
LanguageSelectorComponent,
LocaleStore,
} from '@org/shared/i18n';
import { ThemeToggleComponent } from '@org/shared/theme'; import { ThemeToggleComponent } from '@org/shared/theme';
import { LanguageSelectorComponent } from '@org/shared/i18n';
@Component({ @Component({
selector: 'lib-navbar', selector: 'lib-navbar',
@ -15,42 +24,40 @@ import { LanguageSelectorComponent } from '@org/shared/i18n';
RouterModule, RouterModule,
ThemeToggleComponent, ThemeToggleComponent,
LanguageSelectorComponent, LanguageSelectorComponent,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<mat-toolbar color="primary" class="navbar"> <mat-toolbar color="primary" class="navbar">
<span class="logo"> <span class="logo">
<mat-icon>dashboard</mat-icon> <mat-icon>dashboard</mat-icon>
<span class="brand">TaskManager</span> <span class="brand">{{ 'app.taskManager' | translate }}</span>
</span> </span>
<span class="spacer"></span> <span class="spacer"></span>
<div class="nav-links"> <div class="nav-links">
<a <a
mat-button mat-button
routerLink="/users" [routerLink]="usersLink()"
routerLinkActive="active-link" routerLinkActive="active-link"
class="nav-button" class="nav-button"
> >
<mat-icon>people</mat-icon> <mat-icon>people</mat-icon>
<span>Users</span> <span>{{ 'app.navbar.users' | translate }}</span>
</a> </a>
<a <a
mat-button mat-button
routerLink="/tasks" [routerLink]="tasksLink()"
routerLinkActive="active-link" routerLinkActive="active-link"
class="nav-button" class="nav-button"
> >
<mat-icon>task</mat-icon> <mat-icon>task</mat-icon>
<span>Tasks</span> <span>{{ 'app.navbar.tasks' | translate }}</span>
</a> </a>
</div> </div>
<div class="action-buttons"> <div class="action-buttons">
<!-- Theme toggle button -->
<lib-theme-toggle></lib-theme-toggle> <lib-theme-toggle></lib-theme-toggle>
<!-- Language selector button -->
<lib-language-selector></lib-language-selector> <lib-language-selector></lib-language-selector>
</div> </div>
</mat-toolbar> </mat-toolbar>
@ -92,19 +99,9 @@ import { LanguageSelectorComponent } from '@org/shared/i18n';
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
.nav-button.active-link { .nav-button.active-link {
background-color: rgba(255, 255, 255, 0.15); background-color: var(--mat-sys-outline-variant);
position: relative; 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 { .action-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -114,11 +111,14 @@ import { LanguageSelectorComponent } from '@org/shared/i18n';
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} }
.action-button:hover { .action-button:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: var(--hover-color);
} }
`, `,
], ],
}) })
export class NavbarComponent { 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`);
} }

View File

@ -23,16 +23,19 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { TasksService, User, UsersService } from '@org/shared/api'; import { TasksService, User, UsersService } from '@org/shared/api';
import { AppTranslatePipe } from '@org/shared/i18n';
import { ToastService } from '@org/shared/toast'; import { ToastService } from '@org/shared/toast';
@Component({ @Component({
selector: 'lib-add-task-dialog', selector: 'lib-add-task-dialog',
template: ` template: `
<h2 mat-dialog-title class="dialog-title">Add New Task</h2> <h2 mat-dialog-title class="dialog-title">
{{ 'app.addNewTask' | translate }}
</h2>
<mat-dialog-content class="dialog-content"> <mat-dialog-content class="dialog-content">
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()"> <form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Assigned to</mat-label> <mat-label>{{ 'app.selectUser' | translate }}</mat-label>
<mat-select formControlName="userId" required> <mat-select formControlName="userId" required>
@for (user of users(); track user.id) { @for (user of users(); track user.id) {
<mat-option [value]="user.id">{{ user.name }}</mat-option> <mat-option [value]="user.id">{{ user.name }}</mat-option>
@ -40,32 +43,32 @@ import { ToastService } from '@org/shared/toast';
</mat-select> </mat-select>
@if (taskForm.get('userId')?.hasError('required') && @if (taskForm.get('userId')?.hasError('required') &&
taskForm.get('userId')?.touched) { taskForm.get('userId')?.touched) {
<mat-error>Please select a user</mat-error> <mat-error>{{ 'app.userNotFound' | translate }}</mat-error>
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Title</mat-label> <mat-label>{{ 'app.taskTitle' | translate }}</mat-label>
<input matInput formControlName="title" placeholder="Task title" /> <input matInput formControlName="title" placeholder="Task title" />
@if (taskForm.get('title')?.hasError('required') && @if (taskForm.get('title')?.hasError('required') &&
taskForm.get('title')?.touched) { taskForm.get('title')?.touched) {
<mat-error>Title is required</mat-error> <mat-error>{{ 'app.titleIsRequired' | translate }}</mat-error>
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label> <mat-label>{{ 'app.taskDescription' | translate }}</mat-label>
<textarea <textarea
matInput matInput
formControlName="description" formControlName="description"
placeholder="Task description" placeholder="{{ 'app.noDescription' | translate }}"
rows="3" rows="3"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
<mat-dialog-actions align="end" class="dialog-actions"> <mat-dialog-actions align="end" class="dialog-actions">
<button mat-stroked-button mat-dialog-close type="button"> <button mat-stroked-button mat-dialog-close type="button">
Cancel {{ 'app.cancel' | translate }}
</button> </button>
<button <button
mat-raised-button mat-raised-button
@ -74,7 +77,7 @@ import { ToastService } from '@org/shared/toast';
[disabled]="taskForm.invalid" [disabled]="taskForm.invalid"
> >
<mat-icon>add_task</mat-icon> <mat-icon>add_task</mat-icon>
<span class="button-text">Add Task</span> <span class="button-text">{{ 'app.addTask' | translate }}</span>
</button> </button>
</mat-dialog-actions> </mat-dialog-actions>
</form> </form>
@ -120,6 +123,7 @@ import { ToastService } from '@org/shared/toast';
MatIconModule, MatIconModule,
MatSelectModule, MatSelectModule,
ReactiveFormsModule, ReactiveFormsModule,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })

View File

@ -13,6 +13,7 @@ import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion'; import { MatExpansionModule } from '@angular/material/expansion';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { CommentResponse, TaskResponse, User } from '@org/shared/api'; import { CommentResponse, TaskResponse, User } from '@org/shared/api';
import { AppTranslatePipe, injectAppT } from '@org/shared/i18n';
import { TaskCommentComponent } from './task-comment.component'; import { TaskCommentComponent } from './task-comment.component';
@Component({ @Component({
@ -25,6 +26,7 @@ import { TaskCommentComponent } from './task-comment.component';
MatBadgeModule, MatBadgeModule,
MatButtonModule, MatButtonModule,
TaskCommentComponent, TaskCommentComponent,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
@ -69,7 +71,7 @@ import { TaskCommentComponent } from './task-comment.component';
<div class="comments-section"> <div class="comments-section">
<h3> <h3>
<mat-icon>forum</mat-icon> <mat-icon>forum</mat-icon>
Comments {{ 'app.comment' | translate : { count: taskComments().length } }}
</h3> </h3>
<lib-task-comment <lib-task-comment
@ -192,6 +194,7 @@ import { TaskCommentComponent } from './task-comment.component';
], ],
}) })
export class TaskCardComponent { export class TaskCardComponent {
readonly t = injectAppT();
readonly task = input.required<TaskResponse>(); readonly task = input.required<TaskResponse>();
readonly users = input<User[]>([]); readonly users = input<User[]>([]);
readonly comments = input<CommentResponse[]>([]); readonly comments = input<CommentResponse[]>([]);
@ -205,7 +208,6 @@ export class TaskCardComponent {
readonly taskComments = signal<CommentResponse[]>([]); readonly taskComments = signal<CommentResponse[]>([]);
constructor() { constructor() {
// Update task comments whenever comments or task changes
effect(() => { effect(() => {
const taskId = this.task()?.tasks?.id; const taskId = this.task()?.tasks?.id;
if (taskId && this.comments()) { if (taskId && this.comments()) {
@ -220,11 +222,11 @@ export class TaskCardComponent {
getUserName(userId: number): string { getUserName(userId: number): string {
const user = this.users().find((u) => u.id === userId); 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 { formatDate(timestamp?: string): string {
if (!timestamp) return 'Unknown date'; if (!timestamp) return this.t('app.unknownDate');
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();
@ -232,17 +234,21 @@ export class TaskCardComponent {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) { if (diffDays === 0) {
return ( return this.t('app.todayAt', {
'Today at ' + time: date.toLocaleTimeString([], {
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) hour: '2-digit',
); minute: '2-digit',
}),
});
} else if (diffDays === 1) { } else if (diffDays === 1) {
return ( return this.t('app.yesterdayAt', {
'Yesterday at ' + time: date.toLocaleTimeString([], {
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) hour: '2-digit',
); minute: '2-digit',
}),
});
} else if (diffDays < 7) { } else if (diffDays < 7) {
return `${diffDays} days ago`; return this.t('app.daysAgo', { count: diffDays.toString() });
} else { } else {
return date.toLocaleDateString([], { return date.toLocaleDateString([], {
day: 'numeric', day: 'numeric',

View File

@ -18,6 +18,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommentResponse, User } from '@org/shared/api'; import { CommentResponse, User } from '@org/shared/api';
import { AppTranslatePipe, injectAppT } from '@org/shared/i18n';
@Component({ @Component({
selector: 'lib-task-comment', selector: 'lib-task-comment',
@ -29,6 +30,7 @@ import { CommentResponse, User } from '@org/shared/api';
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatProgressSpinnerModule, MatProgressSpinnerModule,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
@ -47,7 +49,7 @@ import { CommentResponse, User } from '@org/shared/api';
} }
</div> </div>
} @else { } @else {
<p class="no-comments">No comments yet.</p> <p class="no-comments">{{ 'app.noComments' | translate }}</p>
} }
<form <form
@ -55,35 +57,37 @@ import { CommentResponse, User } from '@org/shared/api';
(ngSubmit)="submitComment()" (ngSubmit)="submitComment()"
class="comment-form" class="comment-form"
> >
<h4>Add a comment</h4> <h4>{{ 'app.addComment' | translate }}</h4>
<div class="form-row"> <div class="form-row">
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Your comment</mat-label> <mat-label>{{ 'app.yourComment' | translate }}</mat-label>
<textarea <textarea
matInput matInput
formControlName="content" formControlName="content"
placeholder="Share your thoughts..." placeholder="{{ 'app.shareYourThoughts' | translate }}"
rows="2" rows="2"
></textarea> ></textarea>
@if (commentForm.get('content')?.hasError('required') && @if (commentForm.get('content')?.hasError('required') &&
commentForm.get('content')?.touched) { commentForm.get('content')?.touched) {
<mat-error>Comment text is required</mat-error> <mat-error>{{ 'app.commentIsRequired' | translate }}</mat-error>
} }
</mat-form-field> </mat-form-field>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Post as</mat-label> <mat-label>{{ 'app.postAs' | translate }}</mat-label>
<select matNativeControl formControlName="userId" required> <select matNativeControl formControlName="userId" required>
<option value="" disabled selected>Select user</option> <option value="" disabled selected>
{{ 'app.selectUser' | translate }}
</option>
@for (user of users(); track user.id) { @for (user of users(); track user.id) {
<option [value]="user.id">{{ user.name }}</option> <option [value]="user.id">{{ user.name }}</option>
} }
</select> </select>
@if (commentForm.get('userId')?.hasError('required') && @if (commentForm.get('userId')?.hasError('required') &&
commentForm.get('userId')?.touched) { commentForm.get('userId')?.touched) {
<mat-error>User is required</mat-error> <mat-error>{{ 'app.selectUser' | translate }}</mat-error>
} }
</mat-form-field> </mat-form-field>
@ -98,7 +102,11 @@ import { CommentResponse, User } from '@org/shared/api';
} @else { } @else {
<mat-icon>send</mat-icon> <mat-icon>send</mat-icon>
} }
<span>{{ submitting() ? 'Posting...' : 'Post Comment' }}</span> <span>{{
submitting()
? ('app.posting' | translate)
: ('app.postComment' | translate)
}}</span>
</button> </button>
</div> </div>
</form> </form>
@ -214,6 +222,7 @@ import { CommentResponse, User } from '@org/shared/api';
], ],
}) })
export class TaskCommentComponent { export class TaskCommentComponent {
readonly t = injectAppT();
readonly taskId = input.required<number>(); readonly taskId = input.required<number>();
readonly comments = input<CommentResponse[]>([]); readonly comments = input<CommentResponse[]>([]);
readonly users = input<User[]>([]); readonly users = input<User[]>([]);
@ -240,7 +249,7 @@ export class TaskCommentComponent {
} }
formatDate(timestamp?: string): string { formatDate(timestamp?: string): string {
if (!timestamp) return 'Unknown date'; if (!timestamp) return this.t('app.unknownDate');
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();
@ -248,17 +257,21 @@ export class TaskCommentComponent {
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) { if (diffDays === 0) {
return ( return this.t('app.todayAt', {
'Today at ' + time: date.toLocaleTimeString([], {
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) hour: '2-digit',
); minute: '2-digit',
}),
});
} else if (diffDays === 1) { } else if (diffDays === 1) {
return ( return this.t('app.yesterdayAt', {
'Yesterday at ' + time: date.toLocaleTimeString([], {
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) hour: '2-digit',
); minute: '2-digit',
}),
});
} else if (diffDays < 7) { } else if (diffDays < 7) {
return `${diffDays} days ago`; return this.t('app.daysAgo', { count: diffDays.toString() });
} else { } else {
return date.toLocaleDateString([], { return date.toLocaleDateString([], {
day: 'numeric', day: 'numeric',

View File

@ -18,6 +18,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { TasksService } from '@org/shared/api'; import { TasksService } from '@org/shared/api';
import { AppTranslatePipe } from '@org/shared/i18n';
import { ToastService } from '@org/shared/toast'; import { ToastService } from '@org/shared/toast';
@Component({ @Component({
@ -30,17 +31,20 @@ import { ToastService } from '@org/shared/toast';
MatInputModule, MatInputModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<mat-card class="task-form-card"> <mat-card class="task-form-card">
<mat-card-header> <mat-card-header>
<mat-card-title class="form-title">Add New Task</mat-card-title> <mat-card-title class="form-title">{{
'app.addNewTask' | translate
}}</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()"> <form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Title</mat-label> <mat-label>{{ 'app.title' | translate }}</mat-label>
<input matInput formControlName="title" placeholder="Task title" /> <input matInput formControlName="title" placeholder="Task title" />
@if (taskForm.get('title')?.hasError('required') && @if (taskForm.get('title')?.hasError('required') &&
taskForm.get('title')?.touched) { taskForm.get('title')?.touched) {
@ -49,7 +53,7 @@ import { ToastService } from '@org/shared/toast';
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label> <mat-label>{{ 'app.description' | translate }}</mat-label>
<textarea <textarea
matInput matInput
formControlName="description" formControlName="description"
@ -66,7 +70,7 @@ import { ToastService } from '@org/shared/toast';
[disabled]="taskForm.invalid" [disabled]="taskForm.invalid"
> >
<mat-icon>add_task</mat-icon> <mat-icon>add_task</mat-icon>
<span class="button-text">Add Task</span> <span class="button-text">{{ 'app.addTask' | translate }}</span>
</button> </button>
</div> </div>
</form> </form>

View File

@ -20,6 +20,7 @@ import {
User, User,
UsersService, UsersService,
} from '@org/shared/api'; } from '@org/shared/api';
import { AppTranslatePipe } from '@org/shared/i18n';
import { ToastService } from '@org/shared/toast'; import { ToastService } from '@org/shared/toast';
import { catchError, finalize, forkJoin, of } from 'rxjs'; import { catchError, finalize, forkJoin, of } from 'rxjs';
import { AddTaskDialogComponent } from './add-task-dialog.component'; import { AddTaskDialogComponent } from './add-task-dialog.component';
@ -36,12 +37,13 @@ import { TaskCardComponent } from './task-card.component';
MatButtonModule, MatButtonModule,
MatTooltipModule, MatTooltipModule,
TaskCardComponent, TaskCardComponent,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="page-container"> <div class="page-container">
<mat-toolbar color="primary" class="page-header"> <mat-toolbar color="primary" class="page-header">
<span>Tasks</span> <span>{{ 'app.navbar.tasks' | translate }}</span>
<span class="spacer"></span> <span class="spacer"></span>
<button mat-icon-button matTooltip="Refresh data" (click)="loadData()"> <button mat-icon-button matTooltip="Refresh data" (click)="loadData()">
<mat-icon>refresh</mat-icon> <mat-icon>refresh</mat-icon>
@ -55,13 +57,12 @@ import { TaskCardComponent } from './task-card.component';
@if (loading()) { @if (loading()) {
<div class="loading-container"> <div class="loading-container">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner> <mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
<p>Loading tasks...</p> <p>{{ 'app.loading' | translate }}</p>
</div> </div>
} @else if (tasks().length === 0) { } @else if (tasks().length === 0) {
<div class="empty-state"> <div class="empty-state">
<mat-icon class="empty-icon">assignment</mat-icon> <mat-icon class="empty-icon">assignment</mat-icon>
<h2>No Tasks Found</h2> <h2>{{ 'app.noTaskFound' | translate }}</h2>
<p>There are currently no tasks in the system.</p>
</div> </div>
} @else { } @else {
<div class="task-container"> <div class="task-container">

View File

@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
inject,
input, input,
output, output,
} from '@angular/core'; } from '@angular/core';
@ -9,6 +11,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AppTranslatePipe, LocaleStore } from '@org/shared/i18n';
@Component({ @Component({
selector: 'lib-user-card', selector: 'lib-user-card',
@ -18,6 +21,7 @@ import { RouterModule } from '@angular/router';
MatCardModule, MatCardModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
@ -32,9 +36,7 @@ import { RouterModule } from '@angular/router';
</button> </button>
<mat-card-header> <mat-card-header>
<mat-card-title> <mat-card-title>
<a [routerLink]="['/users', user().id]" class="user-link">{{ <a [routerLink]="userLink()" class="user-link">{{ user().name }}</a>
user().name
}}</a>
</mat-card-title> </mat-card-title>
<mat-card-subtitle>{{ user().email }}</mat-card-subtitle> <mat-card-subtitle>{{ user().email }}</mat-card-subtitle>
</mat-card-header> </mat-card-header>
@ -42,11 +44,11 @@ import { RouterModule } from '@angular/router';
<div class="user-meta"> <div class="user-meta">
<span> <span>
<mat-icon class="meta-icon">assignment</mat-icon> <mat-icon class="meta-icon">assignment</mat-icon>
{{ taskCount() }} Tasks {{ 'app.task' | translate : { count: taskCount() } }}
</span> </span>
<span> <span>
<mat-icon class="meta-icon">comment</mat-icon> <mat-icon class="meta-icon">comment</mat-icon>
{{ commentCount() }} Comments {{ 'app.comment' | translate : { count: commentCount() } }}
</span> </span>
</div> </div>
</mat-card-content> </mat-card-content>
@ -98,8 +100,14 @@ import { RouterModule } from '@angular/router';
], ],
}) })
export class UserCardComponent { export class UserCardComponent {
private readonly localeStore = inject(LocaleStore);
readonly user = input.required<{ id: number; name: string; email: string }>(); readonly user = input.required<{ id: number; name: string; email: string }>();
readonly taskCount = input<number>(0); readonly taskCount = input<number>(0);
readonly commentCount = input<number>(0); readonly commentCount = input<number>(0);
readonly deleteUser = output<number>(); readonly deleteUser = output<number>();
readonly userLink = computed(
() => `/${this.localeStore.locale()}/users/${this.user().id}`
);
} }

View File

@ -18,6 +18,7 @@ import {
User, User,
UsersService, UsersService,
} from '@org/shared/api'; } from '@org/shared/api';
import { AppTranslatePipe } from '@org/shared/i18n';
import { UserTaskListComponent } from './user-task-list.component'; import { UserTaskListComponent } from './user-task-list.component';
@Component({ @Component({
@ -31,11 +32,12 @@ import { UserTaskListComponent } from './user-task-list.component';
MatProgressSpinnerModule, MatProgressSpinnerModule,
MatToolbarModule, MatToolbarModule,
UserTaskListComponent, UserTaskListComponent,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<mat-toolbar color="primary"> <mat-toolbar color="primary">
<span>User Details</span> <span>{{ 'app.userDetails' | translate }}</span>
</mat-toolbar> </mat-toolbar>
@if (loading()) { @if (loading()) {
@ -57,8 +59,14 @@ import { UserTaskListComponent } from './user-task-list.component';
</div> </div>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<p><strong>User ID:</strong> {{ user()?.id }}</p> <p>
<p><strong>Tasks:</strong> {{ userTasks().length }}</p> <strong>{{ 'app.userId' | translate }}:</strong> {{ user()?.id }}
</p>
<p>
<strong>{{
'app.task' | translate : { count: userTasks().length }
}}</strong>
</p>
</mat-card-content> </mat-card-content>
<mat-card-actions> <mat-card-actions>
<button <button
@ -68,7 +76,7 @@ import { UserTaskListComponent } from './user-task-list.component';
class="back-button" class="back-button"
> >
<mat-icon aria-hidden="true">arrow_back</mat-icon> <mat-icon aria-hidden="true">arrow_back</mat-icon>
<span>Back to Users</span> <span>{{ 'app.backToUser' | translate }}</span>
</button> </button>
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
@ -86,7 +94,7 @@ import { UserTaskListComponent } from './user-task-list.component';
aria-hidden="true" aria-hidden="true"
>error_outline</mat-icon >error_outline</mat-icon
> >
<p>User not found</p> <p>{{ 'app.userNotFound' | translate }}</p>
<button <button
mat-raised-button mat-raised-button
color="primary" color="primary"
@ -94,7 +102,7 @@ import { UserTaskListComponent } from './user-task-list.component';
class="back-button" class="back-button"
> >
<mat-icon aria-hidden="true">arrow_back</mat-icon> <mat-icon aria-hidden="true">arrow_back</mat-icon>
<span>Back to Users</span> <span> {{ 'app.backToUser' | translate }} </span>
</button> </button>
</div> </div>
} }

View File

@ -21,6 +21,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { CommentResponse, TaskResponse, User } from '@org/shared/api'; import { CommentResponse, TaskResponse, User } from '@org/shared/api';
import { AppTranslatePipe } from '@org/shared/i18n';
import { import {
CommentStoreService, CommentStoreService,
TasksStoreService, TasksStoreService,
@ -43,23 +44,24 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
UserCardComponent, UserCardComponent,
AppTranslatePipe,
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="container"> <div class="container">
<div class="search-container"> <div class="search-container">
<mat-form-field appearance="outline" class="full-width"> <mat-form-field appearance="outline" class="full-width">
<mat-label>Search Users</mat-label> <mat-label>{{ 'app.searchUser.title' | translate }}</mat-label>
<input <input
matInput matInput
[formControl]="searchControl" [formControl]="searchControl"
placeholder="type name or email" placeholder="{{ 'app.searchUser.placeholder' | translate }}"
autocomplete="off" autocomplete="off"
/> />
<mat-icon matSuffix>search</mat-icon> <mat-icon matSuffix>search</mat-icon>
</mat-form-field> </mat-form-field>
<button mat-raised-button color="primary" (click)="openAddUserDialog()"> <button mat-raised-button color="primary" (click)="openAddUserDialog()">
<mat-icon>person_add</mat-icon> Add User <mat-icon>person_add</mat-icon> {{ 'app.addUser' | translate }}
</button> </button>
</div> </div>

View File

@ -8,17 +8,24 @@ import {
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list'; import { MatListModule } from '@angular/material/list';
import { TaskResponse } from '@org/shared/api'; import { TaskResponse } from '@org/shared/api';
import { AppTranslatePipe } from '@org/shared/i18n';
import { TaskFormComponent } from '@org/web/tasks'; import { TaskFormComponent } from '@org/web/tasks';
@Component({ @Component({
selector: 'lib-user-task-list', selector: 'lib-user-task-list',
imports: [CommonModule, MatIconModule, MatListModule, TaskFormComponent], imports: [
CommonModule,
MatIconModule,
MatListModule,
TaskFormComponent,
AppTranslatePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="tasks-section"> <div class="tasks-section">
<h2> <h2>
<mat-icon aria-hidden="true">assignment</mat-icon> <mat-icon aria-hidden="true">assignment</mat-icon>
<span>User Tasks</span> <span>{{ 'app.userTaskList' | translate }}</span>
</h2> </h2>
@if (tasks().length) { @if (tasks().length) {
@ -32,7 +39,8 @@ import { TaskFormComponent } from '@org/web/tasks';
<h3 class="task-title">{{ task.tasks.title }}</h3> <h3 class="task-title">{{ task.tasks.title }}</h3>
</div> </div>
<div matListItemLine class="task-description"> <div matListItemLine class="task-description">
{{ task.tasks.description || 'No description' }} {{ task.tasks.description }} ||
{{ 'app.noDescription' | translate }}
</div> </div>
</mat-list-item> </mat-list-item>
} }
@ -40,7 +48,7 @@ import { TaskFormComponent } from '@org/web/tasks';
} @else { } @else {
<div class="no-tasks"> <div class="no-tasks">
<mat-icon aria-hidden="true">assignment_late</mat-icon> <mat-icon aria-hidden="true">assignment_late</mat-icon>
<p>No tasks found for this user.</p> <p>{{ 'app.noTasksFound' | translate }}</p>
</div> </div>
} }

View File

@ -19,6 +19,7 @@
"@angular/platform-browser": "~20.0.0", "@angular/platform-browser": "~20.0.0",
"@angular/platform-browser-dynamic": "~20.0.0", "@angular/platform-browser-dynamic": "~20.0.0",
"@angular/router": "~20.0.0", "@angular/router": "~20.0.0",
"@formatjs/intl": "^3.1.6",
"@mmstack/primitives": "^20.0.1", "@mmstack/primitives": "^20.0.1",
"@mmstack/translate": "^20.0.1", "@mmstack/translate": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@ -2692,7 +2693,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
"integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==", "integrity": "sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/fast-memoize": "2.2.7", "@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.1", "@formatjs/intl-localematcher": "0.6.1",
@ -2705,7 +2705,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@ -2715,7 +2714,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz", "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.2.tgz",
"integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==", "integrity": "sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.4", "@formatjs/ecma402-abstract": "2.3.4",
"@formatjs/icu-skeleton-parser": "1.8.14", "@formatjs/icu-skeleton-parser": "1.8.14",
@ -2727,7 +2725,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz", "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.14.tgz",
"integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==", "integrity": "sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.4", "@formatjs/ecma402-abstract": "2.3.4",
"tslib": "^2.8.0" "tslib": "^2.8.0"
@ -2738,7 +2735,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-3.1.6.tgz", "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-3.1.6.tgz",
"integrity": "sha512-tDkXnA4qpIFcDWac8CyVJq6oW8DR7W44QDUBsfXWIIJD/FYYen0QoH46W7XsVMFfPOVKkvbufjboZrrWbEfmww==", "integrity": "sha512-tDkXnA4qpIFcDWac8CyVJq6oW8DR7W44QDUBsfXWIIJD/FYYen0QoH46W7XsVMFfPOVKkvbufjboZrrWbEfmww==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.4", "@formatjs/ecma402-abstract": "2.3.4",
"@formatjs/fast-memoize": "2.2.7", "@formatjs/fast-memoize": "2.2.7",
@ -2760,7 +2756,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz",
"integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==", "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@ -11282,7 +11277,6 @@
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.16.tgz",
"integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==", "integrity": "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.4", "@formatjs/ecma402-abstract": "2.3.4",
"@formatjs/fast-memoize": "2.2.7", "@formatjs/fast-memoize": "2.2.7",

View File

@ -19,6 +19,7 @@
"@angular/platform-browser": "~20.0.0", "@angular/platform-browser": "~20.0.0",
"@angular/platform-browser-dynamic": "~20.0.0", "@angular/platform-browser-dynamic": "~20.0.0",
"@angular/router": "~20.0.0", "@angular/router": "~20.0.0",
"@formatjs/intl": "^3.1.6",
"@mmstack/primitives": "^20.0.1", "@mmstack/primitives": "^20.0.1",
"@mmstack/translate": "^20.0.1", "@mmstack/translate": "^20.0.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",

View File

@ -7,6 +7,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideIntlConfig } from '@mmstack/translate';
import { API, apiUrls } from '@org/shared/api'; import { API, apiUrls } from '@org/shared/api';
import { appRoutes } from './app.routes'; import { appRoutes } from './app.routes';
@ -21,6 +22,8 @@ export const appConfig: ApplicationConfig = {
provide: API, provide: API,
useValue: apiUrls, useValue: apiUrls,
}, },
// No need to provide ThemeService and LanguageService as they use providedIn: 'root' provideIntlConfig({
defaultLocale: 'en',
}),
], ],
}; };

View File

@ -1,23 +1,39 @@
import { Route } from '@angular/router'; import { inject, LOCALE_ID } from '@angular/core';
import { TaskListComponent } from '@org/web/tasks'; import { ActivatedRouteSnapshot, Route } from '@angular/router';
import { UserDetailsComponent, UserListComponent } from '@org/web/users'; import { LocaleStore, resolveAppTranslations } from '@org/shared/i18n';
export const appRoutes: Route[] = [ export const appRoutes: Route[] = [
{ {
path: 'users', path: ':locale',
component: UserListComponent, resolve: {
localeId: (route: ActivatedRouteSnapshot) => {
const store = inject(LocaleStore);
const locale = route.params['locale'] || 'en';
store.locale.set(locale);
return locale;
},
resolveAppTranslations,
},
providers: [
{
provide: LOCALE_ID,
useFactory: (store: LocaleStore) => {
return store.getCurrentLocale();
},
deps: [LocaleStore],
},
],
loadComponent: () =>
import('./locale-shell.component').then((m) => m.LocaleShellComponent),
loadChildren: () =>
import('./locale-shell.routes').then((m) => m.shellRoutes),
}, },
{ {
path: 'users/:id', path: '',
component: UserDetailsComponent, redirectTo: '/en',
},
{
path: 'tasks',
component: TaskListComponent,
},
{
path: '**',
redirectTo: 'users',
pathMatch: 'full', pathMatch: 'full',
}, },
]; ];

View File

@ -1,25 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NavbarComponent } from '@org/shared/navbar';
@Component({ @Component({
imports: [RouterModule, NavbarComponent], imports: [RouterModule],
selector: 'app-root', selector: 'app-root',
template: ` template: `<router-outlet />`,
<lib-navbar />
<div class="content-container">
<router-outlet />
</div>
`,
styles: [
`
.content-container {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
min-height: calc(100vh - 64px);
}
`,
],
}) })
export class App {} export class App {}

View File

@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { NavbarComponent } from '@org/shared/navbar';
@Component({
selector: 'app-locale-shell',
imports: [RouterModule, NavbarComponent],
template: `
<lib-navbar />
<div class="content-container">
<router-outlet />
</div>
`,
styles: [
`
.content-container {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
min-height: calc(100vh - 64px);
}
`,
],
})
export class LocaleShellComponent {}

View File

@ -0,0 +1,24 @@
import { Route } from '@angular/router';
export const shellRoutes: Route[] = [
{
path: 'users',
loadComponent: () =>
import('@org/web/users').then((m) => m.UserListComponent),
},
{
path: 'users/:id',
loadComponent: () =>
import('@org/web/users').then((m) => m.UserDetailsComponent),
},
{
path: 'tasks',
loadComponent: () =>
import('@org/web/tasks').then((m) => m.TaskListComponent),
},
{
path: '**',
redirectTo: 'users',
pathMatch: 'full',
},
];

View File

@ -5,7 +5,7 @@
"emitDecoratorMetadata": false, "emitDecoratorMetadata": false,
"target": "es2022", "target": "es2022",
"module": "preserve", "module": "preserve",
"lib": ["es2020", "dom"], "lib": ["es2022", "dom"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@org/shared/api": ["libs/shared/api/src/index.ts"], "@org/shared/api": ["libs/shared/api/src/index.ts"],