From 79c1c375de7730f61f96ab531b110f1d147fffca Mon Sep 17 00:00:00 2001 From: KamyCoding Date: Sun, 9 Aug 2026 21:44:27 +0200 Subject: [PATCH] feat(i18n) : add EN and DE localization --- package-lock.json | 30 +++ package.json | 2 + public/i18n/de.json | 189 ++++++++++++++++++ public/i18n/en.json | 189 ++++++++++++++++++ src/app/app.config.ts | 23 ++- src/app/app.routes.ts | 4 +- src/app/core/i18n/language.service.spec.ts | 87 ++++++++ src/app/core/i18n/language.service.ts | 91 +++++++++ .../i18n/localized-title.strategy.spec.ts | 43 ++++ src/app/core/i18n/localized-title.strategy.ts | 60 ++++++ .../core/i18n/translation-resources.spec.ts | 20 ++ .../features/projects/projects.data.spec.ts | 5 +- src/app/features/projects/projects.data.ts | 30 ++- src/app/features/projects/projects.model.ts | 8 +- .../testimonials/testimonials.data.ts | 15 +- .../testimonials/testimonials.model.ts | 4 +- src/app/layout/footer/footer.html | 16 +- src/app/layout/footer/footer.ts | 3 +- src/app/layout/header/header.html | 35 ++-- src/app/layout/header/header.model.ts | 2 +- src/app/layout/header/header.spec.ts | 54 +++++ src/app/layout/header/header.ts | 17 +- src/app/pages/home/sections/about/about.html | 16 +- .../pages/home/sections/about/about.model.ts | 2 +- src/app/pages/home/sections/about/about.ts | 9 +- .../pages/home/sections/contact/contact.html | 55 ++--- .../home/sections/contact/contact.spec.ts | 14 +- .../pages/home/sections/contact/contact.ts | 38 ++-- src/app/pages/home/sections/hero/hero.html | 21 +- src/app/pages/home/sections/hero/hero.ts | 3 +- .../home/sections/projects/projects.html | 19 +- .../pages/home/sections/projects/projects.ts | 3 +- .../pages/home/sections/skills/skills.html | 27 +-- src/app/pages/home/sections/skills/skills.ts | 3 +- .../sections/testimonials/testimonials.html | 14 +- .../sections/testimonials/testimonials.ts | 3 +- .../pages/legal-notice/legal-notice.spec.ts | 5 +- src/app/pages/not-found/not-found.html | 14 +- src/app/pages/not-found/not-found.spec.ts | 5 +- src/app/pages/not-found/not-found.ts | 3 +- .../privacy-policy/privacy-policy.spec.ts | 16 +- .../pages/project-detail/project-detail.html | 49 +++-- .../project-detail/project-detail.spec.ts | 4 + .../pages/project-detail/project-detail.ts | 3 +- src/app/shared/data/social-links.ts | 16 +- .../legal-page-layout/legal-page-layout.html | 2 +- .../legal-page-layout.spec.ts | 5 +- .../legal-page-layout/legal-page-layout.ts | 3 +- src/app/testing/i18n-testing.ts | 149 ++++++++++++++ tsconfig.spec.json | 10 +- 50 files changed, 1217 insertions(+), 221 deletions(-) create mode 100644 public/i18n/de.json create mode 100644 public/i18n/en.json create mode 100644 src/app/core/i18n/language.service.spec.ts create mode 100644 src/app/core/i18n/language.service.ts create mode 100644 src/app/core/i18n/localized-title.strategy.spec.ts create mode 100644 src/app/core/i18n/localized-title.strategy.ts create mode 100644 src/app/core/i18n/translation-resources.spec.ts create mode 100644 src/app/layout/header/header.spec.ts create mode 100644 src/app/testing/i18n-testing.ts diff --git a/package-lock.json b/package-lock.json index b391038..2610cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@angular/forms": "^22.0.8", "@angular/platform-browser": "^22.0.8", "@angular/router": "^22.0.8", + "@ngx-translate/core": "^18.0.0", + "@ngx-translate/http-loader": "^18.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -3356,6 +3358,34 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@ngx-translate/core": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-18.0.0.tgz", + "integrity": "sha512-Z9u9AXaeuyeHHimivvJQvhal/LJNB+5tlH+FimSU4QQ61FrtRDqgyn6nfFbc6Cb+59NDZdsh4QeTJyPCldFUwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=18", + "@angular/core": ">=18", + "rxjs": ">=7" + } + }, + "node_modules/@ngx-translate/http-loader": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-18.0.0.tgz", + "integrity": "sha512-p6QpNcU3rkCYjHbKVIadcYtml/Njpr0aLp8neJ4uJWQ4Xm9f09bIePhOjdjAFHupTptKqKrG1J2gSi3RSwgYeA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=18", + "@angular/core": ">=18", + "@ngx-translate/core": ">=18.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", diff --git a/package.json b/package.json index 118d122..100bf69 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@angular/forms": "^22.0.8", "@angular/platform-browser": "^22.0.8", "@angular/router": "^22.0.8", + "@ngx-translate/core": "^18.0.0", + "@ngx-translate/http-loader": "^18.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/public/i18n/de.json b/public/i18n/de.json new file mode 100644 index 0000000..5678113 --- /dev/null +++ b/public/i18n/de.json @@ -0,0 +1,189 @@ +{ + "pageTitles": { + "notFound": "Seite nicht gefunden | KamyCoding" + }, + "nav": { + "about": "Über mich", + "skills": "Skills", + "projects": "Projekte", + "contact": "Kontakt", + "homeAria": "Zur Startseite", + "primaryAria": "Hauptnavigation", + "mobileAria": "Mobile Navigation", + "menuAria": "Navigationsmenü", + "openMenuAria": "Navigationsmenü öffnen", + "closeMenuAria": "Navigationsmenü schließen", + "switchToGerman": "Sprache auf Deutsch umstellen", + "switchToEnglish": "Sprache auf Englisch umstellen" + }, + "footer": { + "aria": "Fußbereich der Website", + "homeAria": "Zur Startseite", + "legalAria": "Rechtliche Seiten", + "linksAria": "Links im Fußbereich", + "legalNotice": "Impressum", + "privacyPolicy": "Datenschutzerklärung" + }, + "social": { + "navigationAria": "Social-Media-Links", + "githubLabel": "GitHub", + "linkedinLabel": "LinkedIn", + "emailLabel": "E-Mail", + "githubAria": "GitHub-Profil besuchen", + "linkedinAria": "LinkedIn-Profil besuchen", + "emailAria": "Eine E-Mail senden", + "openGithubAria": "GitHub-Profil von Kamyar Zamanfar öffnen", + "openLinkedinAria": "LinkedIn-Profil von Kamyar Zamanfar öffnen", + "emailKamyarAria": "Eine E-Mail an Kamyar Zamanfar senden" + }, + "hero": { + "greeting": "Hallo Welt", + "introduction": "Hi, ich bin Kamyar Zamanfar", + "roleAria": "Full-Stack Developer", + "portraitAlt": "Porträt von Kamyar Zamanfar", + "cta": "Kontakt aufnehmen" + }, + "about": { + "eyebrow": "Wer ist Kamyar?", + "heading": "Über mich", + "paragraphs": { + "introduction": "Hi, ich bin Kamyar, Full-Stack Developer, und verwandle Ideen gerne in barrierearme, wartbare digitale Produkte.", + "values": "Mir sind durchdachte Problemlösungen, sauberer Code und Anwendungen wichtig, die echten Menschen einen Mehrwert bieten." + }, + "cta": "Lass uns sprechen", + "availabilityAria": "Verfügbarkeit", + "availability": { + "cologne": "In Köln zu Hause", + "relocation": "Offen für einen Umzug", + "remote": "Offen für Remote-Arbeit" + } + }, + "skills": { + "eyebrow": "Mein Stack", + "heading": "Skillset", + "description": "Ich entwickle wartbare Webanwendungen mit Angular und TypeScript und erweitere meinen Stack durch praktische Projekte, durchdachte Problemlösungen und kontinuierliches Lernen.", + "listAria": "Technologien und Werkzeuge", + "peel": { + "revealAria": "Technologien anzeigen, die ich lernen möchte", + "hideAria": "Technologien ausblenden, die ich lernen möchte", + "front": "Ziehen und\naufdecken", + "back": "Außerdem möchte ich\ntiefer einsteigen in:" + } + }, + "projects": { + "eyebrow": "Mein Handwerk", + "heading": "Projekte", + "introduction": "Entdecke ausgewählte Projekte, die meinen Ansatz für responsive, benutzerfreundliche Oberflächen und wartbaren Code zeigen.", + "featured": "Ausgewähltes Projekt", + "details": "Projektdetails", + "items": { + "join": { + "description": "Ein vom Kanban-System inspirierter Task-Manager. Aufgaben per Drag-and-drop erstellen und organisieren sowie Personen und Kategorien zuweisen.", + "imageAlt": "Der Task-Manager Join auf einem Laptop", + "implementation": "Kurzer Text über meine Rolle oder den Workflow in diesem Projekt. Er zeigt, wie ich mein Wissen strukturiert und selbstständig oder im Team eingesetzt habe.", + "duration": "5 Wochen" + }, + "elPolloLoco": { + "description": "Ein objektorientiertes Jump-and-Run-Spiel. Hilf Pepe, Münzen und Tabasco-Salsa zu sammeln, um gegen das verrückte Huhn anzutreten.", + "imageAlt": "Wüstenszene aus El Pollo Loco mit Pepe", + "implementation": "Kurzer Text über meine Rolle oder den Workflow in diesem Projekt. Er zeigt, wie ich mein Wissen strukturiert und selbstständig oder im Team eingesetzt habe.", + "duration": "3 Wochen" + }, + "dabubble": { + "description": "Dieser Slack-Klon erleichtert die Kommunikation und Zusammenarbeit im Team mit einer intuitiven Oberfläche, Echtzeitnachrichten und übersichtlichen Channels.", + "imageAlt": "Team-Messaging-Oberfläche von DABubble", + "implementation": "Kurzer Text über meine Rolle oder den Workflow in diesem Projekt. Er zeigt, wie ich mein Wissen strukturiert und selbstständig oder im Team eingesetzt habe.", + "duration": "4 Wochen" + } + } + }, + "projectDetail": { + "navigationAria": "Projektnavigation", + "back": "Zurück", + "next": "Nächstes Projekt", + "nextAria": "Nächstes Projekt ansehen: {{project}}", + "description": "Beschreibung", + "implementation": "Umsetzungsdetails", + "duration": "Dauer:", + "technologiesAria": "Verwendete Technologien", + "previewAria": "Vorschau für {{project}}", + "featuredAlt": "Ausgewähltes Projekt", + "linksAria": "Projektlinks", + "liveTest": "Live testen" + }, + "testimonials": { + "eyebrow": "In ihren Worten:", + "heading": "Stimmen aus dem Team", + "profile": "Profil", + "profileAria": "LinkedIn-Profil von {{name}} ansehen", + "items": { + "tobiasLange": { + "quote": "Karl hat das Team mit seiner großartigen Organisation und klaren Kommunikation wirklich zusammengehalten. Ohne seinen Einsatz wären wir nicht so weit gekommen.", + "role": "Frontend Developer" + }, + "mariaSchaefer": { + "quote": "Die Zusammenarbeit mit Karl war eine große Freude. Er versteht es, Teammitglieder zu motivieren und zu ermutigen, ihre bestmögliche Arbeit zu zeigen, und bringt stets neue Impulse in das Brainstorming ein. Für das Wohlbefinden der Gruppe hatte er immer ein offenes Ohr, war hilfsbereit und brachte außerdem viel Humor mit.", + "role": "Frontend Developer" + }, + "davidBraun": { + "quote": "Karl war bei DA ein hervorragender Teamkollege. Sein positiver Einsatz und seine Bereitschaft, Verantwortung zu übernehmen, haben wesentlich dazu beigetragen, dass wir unsere Ziele erreicht haben.", + "role": "Frontend Developer" + } + } + }, + "contact": { + "eyebrow": "Kontakt", + "heading": "Bereit für eine\nZusammenarbeit?", + "paragraphs": { + "projects": "Ich interessiere mich für durchdachte Projekte, bei denen saubere Entwicklung, verlässliche Zusammenarbeit und eine starke User Experience zählen.", + "invitation": "Wenn du ein Projekt, eine Position oder eine Idee hast, bei der meine Full-Stack-Erfahrung helfen kann, freue ich mich darauf, mehr darüber zu erfahren." + }, + "fields": { + "name": { + "label": "Wie heißt du?", + "placeholder": "Dein Name", + "required": "Ups! Dein Name fehlt noch.", + "maxlength": "Bitte verwende höchstens 80 Zeichen für deinen Namen." + }, + "email": { + "label": "Wie lautet deine E-Mail-Adresse?", + "placeholder": "deineemail@email.de", + "required": "Ups! Deine E-Mail-Adresse fehlt noch.", + "invalid": "Bitte gib eine gültige E-Mail-Adresse ein.", + "maxlength": "Bitte verwende höchstens 254 Zeichen für deine E-Mail-Adresse." + }, + "message": { + "label": "Wie kann ich dir helfen?", + "placeholder": "Hallo Kamyar, ich interessiere mich für…", + "required": "Was möchtest du entwickeln?", + "maxlength": "Bitte beschränke deine Nachricht auf 2000 Zeichen." + }, + "honeypot": "Dieses Feld bitte leer lassen" + }, + "privacy": { + "agreement": "Ich stimme der beschriebenen Verarbeitung meiner Daten zu.", + "link": "Datenschutzerklärung lesen", + "required": "Bitte akzeptiere die Datenschutzerklärung." + }, + "actions": { + "send": "Senden", + "sending": "Wird gesendet…" + }, + "status": { + "sending": "Deine Nachricht wird gesendet…", + "success": "Vielen Dank! Deine Nachricht wurde gesendet.", + "error": "Deine Nachricht konnte nicht gesendet werden. Bitte versuche es gleich noch einmal." + } + }, + "notFound": { + "code": "Fehler 404", + "heading": "Seite nicht gefunden", + "description": "Die gesuchte Seite existiert nicht oder wurde möglicherweise verschoben.", + "navigationAria": "Navigation der Fehlerseite", + "home": "Zurück zur Startseite", + "projects": "Projekte ansehen" + }, + "legalUi": { + "back": "Zurück" + } +} diff --git a/public/i18n/en.json b/public/i18n/en.json new file mode 100644 index 0000000..544eb4e --- /dev/null +++ b/public/i18n/en.json @@ -0,0 +1,189 @@ +{ + "pageTitles": { + "notFound": "Page Not Found | KamyCoding" + }, + "nav": { + "about": "About me", + "skills": "Skills", + "projects": "Projects", + "contact": "Contact", + "homeAria": "Go to homepage", + "primaryAria": "Primary navigation", + "mobileAria": "Mobile navigation", + "menuAria": "Navigation menu", + "openMenuAria": "Open navigation menu", + "closeMenuAria": "Close navigation menu", + "switchToGerman": "Switch language to German", + "switchToEnglish": "Switch language to English" + }, + "footer": { + "aria": "Site footer", + "homeAria": "Go to the homepage", + "legalAria": "Legal pages", + "linksAria": "Footer links", + "legalNotice": "Legal Notice", + "privacyPolicy": "Privacy Policy" + }, + "social": { + "navigationAria": "Social media links", + "githubLabel": "GitHub", + "linkedinLabel": "LinkedIn", + "emailLabel": "Email", + "githubAria": "Visit GitHub profile", + "linkedinAria": "Visit LinkedIn profile", + "emailAria": "Send an email", + "openGithubAria": "Open Kamyar Zamanfar's GitHub profile", + "openLinkedinAria": "Open Kamyar Zamanfar's LinkedIn profile", + "emailKamyarAria": "Send an email to Kamyar Zamanfar" + }, + "hero": { + "greeting": "Hello world", + "introduction": "Hi, I'm Kamyar Zamanfar", + "roleAria": "Full-Stack Developer", + "portraitAlt": "Portrait of Kamyar Zamanfar", + "cta": "Get in Touch" + }, + "about": { + "eyebrow": "Who's Kamyar?", + "heading": "About me", + "paragraphs": { + "introduction": "Hi, I’m Kamyar, a full-stack developer who enjoys turning ideas into accessible, maintainable digital products.", + "values": "I care about thoughtful problem-solving, clean code, and building experiences that are useful for real people." + }, + "cta": "Let’s talk", + "availabilityAria": "Work availability", + "availability": { + "cologne": "Based in Cologne", + "relocation": "Open to relocation", + "remote": "Open to remote work" + } + }, + "skills": { + "eyebrow": "My stack", + "heading": "Skill set", + "description": "I build maintainable web applications with Angular and TypeScript, and I enjoy expanding my stack through hands-on projects, thoughtful problem-solving, and continuous learning.", + "listAria": "Technologies and tools", + "peel": { + "revealAria": "Reveal technologies I am interested in learning", + "hideAria": "Hide technologies I am interested in learning", + "front": "Pull to\npeel", + "back": "Also, I’m interested\nin diving into:" + } + }, + "projects": { + "eyebrow": "My craft", + "heading": "Projects", + "introduction": "Take a look at selected projects that show my approach to responsive, user-friendly interfaces and maintainable code.", + "featured": "Featured project", + "details": "Project details", + "items": { + "join": { + "description": "Task manager inspired by the Kanban System. Create and organize tasks using drag and drop functions, assign users and categories.", + "imageAlt": "Join task manager displayed on a laptop", + "implementation": "Short text that describes your role or the workflow for this specific project. Let a recruiter know more about your knowledge and ability to work independently or collaboratively in a structured way.", + "duration": "5 weeks" + }, + "elPolloLoco": { + "description": "Jump, run and throw game based on an object-oriented approach. Help Pepe find coins and tabasco salsa to fight the crazy hen.", + "imageAlt": "El Pollo Loco desert game scene with Pepe", + "implementation": "Short text that describes your role or the workflow for this specific project. Let a recruiter know more about your knowledge and ability to work independently or collaboratively in a structured way.", + "duration": "3 weeks" + }, + "dabubble": { + "description": "This Slack clone transforms team communication and collaboration with an intuitive interface, real-time messaging, and clear channel organization.", + "imageAlt": "DABubble team messaging interface", + "implementation": "Short text that describes your role or the workflow for this specific project. Let a recruiter know more about your knowledge and ability to work independently or collaboratively in a structured way.", + "duration": "4 weeks" + } + } + }, + "projectDetail": { + "navigationAria": "Project navigation", + "back": "Go Back", + "next": "Next Project", + "nextAria": "View next project: {{project}}", + "description": "Description", + "implementation": "Implementation Details", + "duration": "Duration:", + "technologiesAria": "Technologies used", + "previewAria": "{{project}} preview", + "featuredAlt": "Featured project", + "linksAria": "Project links", + "liveTest": "Live Test" + }, + "testimonials": { + "eyebrow": "In their words:", + "heading": "Colleagues' Thoughts", + "profile": "Profile", + "profileAria": "View {{name}}'s LinkedIn profile", + "items": { + "tobiasLange": { + "quote": "Karl really kept the team together with his great organization and clear communication. We wouldn't have got this far without his commitment.", + "role": "Frontend Developer" + }, + "mariaSchaefer": { + "quote": "It was a great pleasure to work with Karl. He knows how to push and encourage team members to present the best work possible, always adding something to brainstorm. Regarding the well-being of group members, he was always present and available to listen and help others, with a great sense of humor as well.", + "role": "Frontend Developer" + }, + "davidBraun": { + "quote": "Karl was a top team colleague at DA. His positive commitment and willingness to take on responsibility made a significant contribution to us achieving our goals.", + "role": "Frontend Developer" + } + } + }, + "contact": { + "eyebrow": "Contact me", + "heading": "Ready to work\ntogether?", + "paragraphs": { + "projects": "I’m interested in thoughtful projects where clean engineering, reliable collaboration, and a strong user experience matter.", + "invitation": "If you have a project, role, or idea where my full-stack experience could help, I’d be happy to hear more about it." + }, + "fields": { + "name": { + "label": "What’s your name?", + "placeholder": "Your name goes here", + "required": "Oops! It seems your name is missing.", + "maxlength": "Please keep your name under 80 characters." + }, + "email": { + "label": "What’s your email?", + "placeholder": "youremail@email.com", + "required": "Oops! Your email is required.", + "invalid": "Please enter a valid email address.", + "maxlength": "Please keep your email under 254 characters." + }, + "message": { + "label": "How can I help you?", + "placeholder": "Hello Kamyar, I am interested in…", + "required": "What do you need to develop?", + "maxlength": "Please keep your message under 2000 characters." + }, + "honeypot": "Leave this field empty" + }, + "privacy": { + "agreement": "I agree to the processing of my data as outlined.", + "link": "Read the privacy policy", + "required": "Please accept the privacy policy." + }, + "actions": { + "send": "Send", + "sending": "Sending…" + }, + "status": { + "sending": "Sending your message…", + "success": "Thank you! Your message has been sent.", + "error": "Your message could not be sent. Please try again in a moment." + } + }, + "notFound": { + "code": "Error 404", + "heading": "Page not found", + "description": "The page you’re looking for doesn’t exist or may have moved.", + "navigationAria": "Not found page navigation", + "home": "Back to homepage", + "projects": "View projects" + }, + "legalUi": { + "back": "Back" + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 22f5f7e..36f3ab7 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,18 +1,37 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { + ApplicationConfig, + inject, + provideAppInitializer, + provideBrowserGlobalErrorListeners, +} from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; -import { provideRouter, withInMemoryScrolling } from '@angular/router'; +import { provideRouter, TitleStrategy, withInMemoryScrolling } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { provideTranslateHttpLoader } from '@ngx-translate/http-loader'; import { routes } from './app.routes'; +import { LanguageService } from './core/i18n/language.service'; +import { LocalizedTitleStrategy } from './core/i18n/localized-title.strategy'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideHttpClient(), + provideTranslateService({ + loader: provideTranslateHttpLoader({ + prefix: '/i18n/', + suffix: '.json', + failOnError: true, + }), + fallbackLang: 'en', + }), + provideAppInitializer(() => inject(LanguageService).initialize()), provideRouter( routes, withInMemoryScrolling({ anchorScrolling: 'enabled', }), ), + { provide: TitleStrategy, useClass: LocalizedTitleStrategy }, ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 9469324..667cfa8 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -10,7 +10,7 @@ export const routes: Routes = [ { path: '', component: Home, - title: 'Kamycoding', + title: 'KamyCoding', }, { path: 'legal-notice', @@ -31,6 +31,6 @@ export const routes: Routes = [ { path: '**', component: NotFound, - title: 'Not Found - Kamycoding', + data: { titleKey: 'pageTitles.notFound' }, }, ]; diff --git a/src/app/core/i18n/language.service.spec.ts b/src/app/core/i18n/language.service.spec.ts new file mode 100644 index 0000000..6f7be27 --- /dev/null +++ b/src/app/core/i18n/language.service.spec.ts @@ -0,0 +1,87 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable, of } from 'rxjs'; + +import { LANGUAGE_STORAGE_KEY, LanguageService } from './language.service'; + +class FakeTranslateService { + readonly currentLang = signal(null); + browserLanguage: string | undefined = 'en'; + languages: string[] = []; + + addLangs(languages: string[]): void { + this.languages = languages; + } + + getBrowserLang(): string | undefined { + return this.browserLanguage; + } + + use(language: string): Observable> { + this.currentLang.set(language); + return of({}); + } +} + +describe('LanguageService', () => { + let service: LanguageService; + let translate: FakeTranslateService; + + beforeEach(() => { + localStorage.clear(); + document.documentElement.lang = ''; + translate = new FakeTranslateService(); + + TestBed.configureTestingModule({ + providers: [{ provide: TranslateService, useValue: translate }], + }); + + service = TestBed.inject(LanguageService); + }); + + it('initializes with a supported browser language', async () => { + translate.browserLanguage = 'de'; + + await service.initialize(); + TestBed.tick(); + + expect(service.currentLanguage()).toBe('de'); + expect(document.documentElement.lang).toBe('de'); + }); + + it('restores a valid stored preference before the browser language', async () => { + localStorage.setItem(LANGUAGE_STORAGE_KEY, 'de'); + translate.browserLanguage = 'en'; + + await service.initialize(); + + expect(service.currentLanguage()).toBe('de'); + }); + + it('falls back safely when both persisted and browser languages are unsupported', async () => { + localStorage.setItem(LANGUAGE_STORAGE_KEY, 'fr'); + translate.browserLanguage = 'fr'; + + await service.initialize(); + + expect(service.currentLanguage()).toBe('en'); + }); + + it('switches in both directions, persists the choice, and updates html lang', async () => { + await service.initialize(); + await service.setLanguage('de'); + TestBed.tick(); + + expect(service.currentLanguage()).toBe('de'); + expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe('de'); + expect(document.documentElement.lang).toBe('de'); + + await service.setLanguage('en'); + TestBed.tick(); + + expect(service.currentLanguage()).toBe('en'); + expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe('en'); + expect(document.documentElement.lang).toBe('en'); + }); +}); diff --git a/src/app/core/i18n/language.service.ts b/src/app/core/i18n/language.service.ts new file mode 100644 index 0000000..5f32b92 --- /dev/null +++ b/src/app/core/i18n/language.service.ts @@ -0,0 +1,91 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { PLATFORM_ID, computed, effect, inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; + +export const SUPPORTED_LANGUAGES = ['en', 'de'] as const; +export const LANGUAGE_STORAGE_KEY = 'kamycoding.language'; + +export type ApplicationLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +@Injectable({ providedIn: 'root' }) +export class LanguageService { + private readonly translate = inject(TranslateService); + private readonly document = inject(DOCUMENT); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + readonly currentLanguage = computed(() => { + const language = this.translate.currentLang(); + + return this.isSupportedLanguage(language) ? language : 'en'; + }); + + constructor() { + this.translate.addLangs([...SUPPORTED_LANGUAGES]); + + effect(() => { + this.document.documentElement.lang = this.currentLanguage(); + }); + } + + async initialize(): Promise { + await firstValueFrom(this.translate.use(this.resolveInitialLanguage())); + } + + async setLanguage(language: ApplicationLanguage): Promise { + await firstValueFrom(this.translate.use(language)); + this.persistLanguage(language); + } + + async toggleLanguage(): Promise { + await this.setLanguage(this.currentLanguage() === 'en' ? 'de' : 'en'); + } + + private resolveInitialLanguage(): ApplicationLanguage { + const storedLanguage = this.readStoredLanguage(); + + if (storedLanguage) { + return storedLanguage; + } + + if (!this.isBrowser) { + return 'en'; + } + + const browserLanguage = this.translate.getBrowserLang(); + + return this.isSupportedLanguage(browserLanguage) ? browserLanguage : 'en'; + } + + private readStoredLanguage(): ApplicationLanguage | null { + if (!this.isBrowser) { + return null; + } + + try { + const storedLanguage = this.document.defaultView?.localStorage.getItem(LANGUAGE_STORAGE_KEY); + + return this.isSupportedLanguage(storedLanguage) ? storedLanguage : null; + } catch { + return null; + } + } + + private persistLanguage(language: ApplicationLanguage): void { + if (!this.isBrowser) { + return; + } + + try { + this.document.defaultView?.localStorage.setItem(LANGUAGE_STORAGE_KEY, language); + } catch { + // Language switching still works when storage is unavailable. + } + } + + private isSupportedLanguage( + language: string | null | undefined, + ): language is ApplicationLanguage { + return language === 'en' || language === 'de'; + } +} diff --git a/src/app/core/i18n/localized-title.strategy.spec.ts b/src/app/core/i18n/localized-title.strategy.spec.ts new file mode 100644 index 0000000..0abbd7e --- /dev/null +++ b/src/app/core/i18n/localized-title.strategy.spec.ts @@ -0,0 +1,43 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Title } from '@angular/platform-browser'; +import { provideRouter, TitleStrategy } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { TranslateService } from '@ngx-translate/core'; + +import { provideTestTranslateService, setTestLanguage } from '../../testing/i18n-testing'; +import { LocalizedTitleStrategy } from './localized-title.strategy'; + +@Component({ template: '' }) +class TestPage {} + +describe('LocalizedTitleStrategy', () => { + it('updates a keyed route title when the shared language changes', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { + path: 'missing', + component: TestPage, + data: { titleKey: 'pageTitles.notFound' }, + }, + ]), + ...provideTestTranslateService(), + { provide: TitleStrategy, useClass: LocalizedTitleStrategy }, + ], + }); + + const translate = TestBed.inject(TranslateService); + await setTestLanguage(translate); + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/missing', TestPage); + TestBed.tick(); + + expect(TestBed.inject(Title).getTitle()).toBe('Page Not Found | KamyCoding'); + + await setTestLanguage(translate, 'de'); + TestBed.tick(); + + expect(TestBed.inject(Title).getTitle()).toBe('Seite nicht gefunden | KamyCoding'); + }); +}); diff --git a/src/app/core/i18n/localized-title.strategy.ts b/src/app/core/i18n/localized-title.strategy.ts new file mode 100644 index 0000000..55f4405 --- /dev/null +++ b/src/app/core/i18n/localized-title.strategy.ts @@ -0,0 +1,60 @@ +import { Injectable, effect, inject, signal } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable() +export class LocalizedTitleStrategy extends TitleStrategy { + private readonly documentTitle = inject(Title); + private readonly translate = inject(TranslateService); + private readonly titleKey = signal(null); + private readonly translatedTitle = this.translate.translate(() => this.titleKey() ?? ''); + + constructor() { + super(); + + effect(() => { + if (this.titleKey()) { + this.documentTitle.setTitle(String(this.translatedTitle())); + } + }); + } + + override updateTitle(snapshot: RouterStateSnapshot): void { + const titleKey = this.findDeepestTitleKey(snapshot); + + if (titleKey) { + this.titleKey.set(titleKey); + return; + } + + this.titleKey.set(null); + + const title = this.buildTitle(snapshot); + + if (title) { + this.documentTitle.setTitle(title); + } + } + + private findDeepestTitleKey(snapshot: RouterStateSnapshot): string | null { + let route = snapshot.root; + let titleKey: string | null = null; + + while (route) { + if (typeof route.data['titleKey'] === 'string') { + titleKey = route.data['titleKey']; + } + + const [child] = route.children; + + if (!child) { + break; + } + + route = child; + } + + return titleKey; + } +} diff --git a/src/app/core/i18n/translation-resources.spec.ts b/src/app/core/i18n/translation-resources.spec.ts new file mode 100644 index 0000000..73b79c3 --- /dev/null +++ b/src/app/core/i18n/translation-resources.spec.ts @@ -0,0 +1,20 @@ +import german from '../../../../public/i18n/de.json'; +import english from '../../../../public/i18n/en.json'; + +describe('translation resources', () => { + it('keeps English and German leaf-key structures in sync', () => { + expect(collectLeafPaths(german)).toEqual(collectLeafPaths(english)); + }); +}); + +function collectLeafPaths(value: unknown, prefix = ''): string[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return [prefix]; + } + + return Object.entries(value) + .flatMap(([key, nestedValue]) => + collectLeafPaths(nestedValue, prefix ? `${prefix}.${key}` : key), + ) + .sort(); +} diff --git a/src/app/features/projects/projects.data.spec.ts b/src/app/features/projects/projects.data.spec.ts index 6574551..74e2b10 100644 --- a/src/app/features/projects/projects.data.spec.ts +++ b/src/app/features/projects/projects.data.spec.ts @@ -11,8 +11,9 @@ describe('project data helpers', () => { for (const slug of ['join', 'el-pollo-loco', 'dabubble']) { const project = getProjectBySlug(slug); - expect(project?.implementationDetails).toBeTruthy(); - expect(project?.duration).toBeTruthy(); + expect(project?.descriptionKey).toMatch(/^projects\.items\./); + expect(project?.implementationDetailsKey).toMatch(/^projects\.items\./); + expect(project?.durationKey).toMatch(/^projects\.items\./); expect(project?.technologies.length).toBeGreaterThan(0); expect(project?.technologies.every((technology) => technology.iconSrc)).toBe(true); } diff --git a/src/app/features/projects/projects.data.ts b/src/app/features/projects/projects.data.ts index 168819f..edcc9b7 100644 --- a/src/app/features/projects/projects.data.ts +++ b/src/app/features/projects/projects.data.ts @@ -1,8 +1,5 @@ import type { PortfolioProject, ProjectSlug } from './projects.model'; -const IMPLEMENTATION_DETAILS = - 'Short text that describes your role or the workflow for this specific project. Let a recruiter know more about your knowledge and ability to work independently or collaboratively in a structured way.'; - const TECHNOLOGIES = { angular: { label: 'Angular', iconSrc: '/assets/skills/icons/Angular.svg' }, css: { label: 'CSS', iconSrc: '/assets/skills/icons/CSS.svg' }, @@ -16,12 +13,11 @@ export const PORTFOLIO_PROJECTS = [ { slug: 'join', title: 'Join', - description: - 'Task manager inspired by the Kanban System. Create and organize tasks using drag and drop functions, assign users and categories.', + descriptionKey: 'projects.items.join.description', imageSrc: '/assets/projects/join-laptop.webp', - imageAlt: 'Join task manager displayed on a laptop', - implementationDetails: IMPLEMENTATION_DETAILS, - duration: '5 weeks', + imageAltKey: 'projects.items.join.imageAlt', + implementationDetailsKey: 'projects.items.join.implementation', + durationKey: 'projects.items.join.duration', technologies: [ TECHNOLOGIES.css, TECHNOLOGIES.html, @@ -35,24 +31,22 @@ export const PORTFOLIO_PROJECTS = [ { slug: 'el-pollo-loco', title: 'El Pollo Loco', - description: - 'Jump, run and throw game based on object-oriented approach. Help Pepe to find coins and tabasco salsa to fight against the crazy hen.', + descriptionKey: 'projects.items.elPolloLoco.description', imageSrc: '/assets/projects/el-pollo-loco.webp', - imageAlt: 'El Pollo Loco desert game scene with Pepe', - implementationDetails: IMPLEMENTATION_DETAILS, - duration: '3 weeks', + imageAltKey: 'projects.items.elPolloLoco.imageAlt', + implementationDetailsKey: 'projects.items.elPolloLoco.implementation', + durationKey: 'projects.items.elPolloLoco.duration', technologies: [TECHNOLOGIES.javascript, TECHNOLOGIES.html, TECHNOLOGIES.css], variant: 'standard', }, { slug: 'dabubble', title: 'DABubble', - description: - 'This app is a Slack Clone App. It revolutionizes team communication and collaboration with its intuitive interface, real-time messaging, and robust channel organization.', + descriptionKey: 'projects.items.dabubble.description', imageSrc: '/assets/projects/dabubble.webp', - imageAlt: 'DABubble team messaging interface', - implementationDetails: IMPLEMENTATION_DETAILS, - duration: '4 weeks', + imageAltKey: 'projects.items.dabubble.imageAlt', + implementationDetailsKey: 'projects.items.dabubble.implementation', + durationKey: 'projects.items.dabubble.duration', technologies: [TECHNOLOGIES.javascript, TECHNOLOGIES.html, TECHNOLOGIES.css], variant: 'standard', }, diff --git a/src/app/features/projects/projects.model.ts b/src/app/features/projects/projects.model.ts index a7e9c5a..b9c9104 100644 --- a/src/app/features/projects/projects.model.ts +++ b/src/app/features/projects/projects.model.ts @@ -8,11 +8,11 @@ export interface ProjectTechnology { interface BaseProject { readonly slug: ProjectSlug; readonly title: string; - readonly description: string; + readonly descriptionKey: string; readonly imageSrc: string; - readonly imageAlt: string; - readonly implementationDetails: string; - readonly duration: string; + readonly imageAltKey: string; + readonly implementationDetailsKey: string; + readonly durationKey: string; readonly technologies: readonly ProjectTechnology[]; readonly githubUrl?: string; readonly liveUrl?: string; diff --git a/src/app/features/testimonials/testimonials.data.ts b/src/app/features/testimonials/testimonials.data.ts index d6733d9..5b9f52e 100644 --- a/src/app/features/testimonials/testimonials.data.ts +++ b/src/app/features/testimonials/testimonials.data.ts @@ -18,28 +18,25 @@ export const TESTIMONIAL_PAPER_ASSETS = { export const TESTIMONIALS = [ { id: 'tobias-lange', - quote: - "Karl really kept the team together with his great organization and clear communication. We wouldn't have got this far without his commitment.", + quoteKey: 'testimonials.items.tobiasLange.quote', name: 'Tobias Lange', - role: 'Frontend Developer', + roleKey: 'testimonials.items.tobiasLange.role', linkedinUrl: 'https://www.linkedin.com/', paperVariant: 'paper-01', }, { id: 'maria-schaefer', - quote: - 'It was a great pleasure to work with Karl. He knows how to push and encourage team members to present the best work possible, always adding something to brainstorm. Regarding the well-being of group members, he was always present and available to listen and help others, with a great sense of humor as well.', + quoteKey: 'testimonials.items.mariaSchaefer.quote', name: 'Maria Schäfer', - role: 'Frontend Developer', + roleKey: 'testimonials.items.mariaSchaefer.role', linkedinUrl: 'https://www.linkedin.com/', paperVariant: 'paper-02', }, { id: 'david-braun', - quote: - 'Karl was a top team colleague at DA. His positive commitment and willingness to take on responsibility made a significant contribution to us achieving our goals.', + quoteKey: 'testimonials.items.davidBraun.quote', name: 'David Braun', - role: 'Frontend Developer', + roleKey: 'testimonials.items.davidBraun.role', linkedinUrl: 'https://www.linkedin.com/', paperVariant: 'paper-01', }, diff --git a/src/app/features/testimonials/testimonials.model.ts b/src/app/features/testimonials/testimonials.model.ts index 912cd58..0b899db 100644 --- a/src/app/features/testimonials/testimonials.model.ts +++ b/src/app/features/testimonials/testimonials.model.ts @@ -4,9 +4,9 @@ export type TestimonialPaperVariant = 'paper-01' | 'paper-02'; export interface Testimonial { readonly id: TestimonialId; - readonly quote: string; + readonly quoteKey: string; readonly name: string; - readonly role: string; + readonly roleKey: string; readonly linkedinUrl: string; readonly paperVariant: TestimonialPaperVariant; } diff --git a/src/app/layout/footer/footer.html b/src/app/layout/footer/footer.html index 9bbbdcf..01f8231 100644 --- a/src/app/layout/footer/footer.html +++ b/src/app/layout/footer/footer.html @@ -1,19 +1,19 @@