Explorar o código

The frontend now supports i18n and language switching, backend queries are done in the appropiate language.

Iñigo Valentin hai 1 ano
pai
achega
981ada9696
Modificáronse 30 ficheiros con 420 adicións e 84 borrados
  1. 39 0
      frontend/package-lock.json
  2. 3 0
      frontend/package.json
  3. 0 0
      frontend/public/img/control/down.svg
  4. 0 0
      frontend/public/img/control/next.svg
  5. 0 0
      frontend/public/img/control/prev.svg
  6. 0 0
      frontend/public/img/control/up.svg
  7. 27 5
      frontend/src/app/app.component.ts
  8. 10 5
      frontend/src/app/app.config.ts
  9. 4 4
      frontend/src/app/components/footer/footer.component.html
  10. 17 7
      frontend/src/app/components/footer/footer.component.ts
  11. 20 22
      frontend/src/app/components/message/message.component.html
  12. 2 1
      frontend/src/app/components/message/message.component.ts
  13. 3 3
      frontend/src/app/components/navbar/navbar.component.html
  14. 2 1
      frontend/src/app/components/navbar/navbar.component.ts
  15. 1 1
      frontend/src/app/components/project-preview/project-preview.component.html
  16. 6 5
      frontend/src/app/components/project-preview/project-preview.component.ts
  17. 6 6
      frontend/src/app/pages/home/home.component.html
  18. 2 1
      frontend/src/app/pages/home/home.component.ts
  19. 3 3
      frontend/src/app/pages/profile/profile.component.html
  20. 2 1
      frontend/src/app/pages/profile/profile.component.ts
  21. 7 9
      frontend/src/app/pages/project/project.component.html
  22. 8 1
      frontend/src/app/pages/project/project.component.ts
  23. 1 1
      frontend/src/app/pages/projects/projects.component.html
  24. 2 1
      frontend/src/app/pages/projects/projects.component.ts
  25. 96 3
      frontend/src/app/services/locale.service.ts
  26. 4 1
      frontend/src/app/services/user.service.ts
  27. 45 0
      frontend/src/assets/i18n/en.json
  28. 45 0
      frontend/src/assets/i18n/es.json
  29. 45 0
      frontend/src/assets/i18n/eu.json
  30. 20 3
      frontend/src/styles/styles.scss

+ 39 - 0
frontend/package-lock.json

@@ -16,7 +16,10 @@
         "@angular/platform-browser": "^18.2.0",
         "@angular/platform-browser-dynamic": "^18.2.0",
         "@angular/router": "^18.2.0",
+        "@ngx-translate/core": "^16.0.3",
+        "@ngx-translate/http-loader": "^16.0.0",
         "ng-process-env": "^16.0.6",
+        "ngx-cookie-service": "^18.0.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
         "zone.js": "~0.14.10"
@@ -3470,6 +3473,30 @@
         "webpack": "^5.54.0"
       }
     },
+    "node_modules/@ngx-translate/core": {
+      "version": "16.0.3",
+      "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-16.0.3.tgz",
+      "integrity": "sha512-UPse66z9tRUmIpeorYodXBQY6O4foUmj9jy9cCuuja7lqdOwRBWPzCWqc+qYIXv5L2QoqZdxgHtqoUz+Q9weSA==",
+      "dependencies": {
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "@angular/common": ">=16",
+        "@angular/core": ">=16"
+      }
+    },
+    "node_modules/@ngx-translate/http-loader": {
+      "version": "16.0.0",
+      "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-16.0.0.tgz",
+      "integrity": "sha512-l3okOHGVxZ1Bm55OpakSfXvI2yYmVmhYqgwGU4aIQIRUqpkBCrSDZnmrHTcZfsGJzXKB5E2D2rko9i28gBijmA==",
+      "dependencies": {
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "@angular/common": ">=16",
+        "@angular/core": ">=16"
+      }
+    },
     "node_modules/@nodelib/fs.scandir": {
       "version": "2.1.5",
       "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -9703,6 +9730,18 @@
         "node": ">=12.20"
       }
     },
+    "node_modules/ngx-cookie-service": {
+      "version": "18.0.0",
+      "resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-18.0.0.tgz",
+      "integrity": "sha512-hkkUckzZTXXWtFgvVkT2hg6mwYMLXioXDZWBsVCOy9gYkADjsj0N5VViO7eo2izQ0VcMPd/Etog1trf/T4oZMQ==",
+      "dependencies": {
+        "tslib": "^2.6.2"
+      },
+      "peerDependencies": {
+        "@angular/common": "^18.0.0-rc.0",
+        "@angular/core": "^18.0.0-rc.0"
+      }
+    },
     "node_modules/nice-napi": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",

+ 3 - 0
frontend/package.json

@@ -18,7 +18,10 @@
     "@angular/platform-browser": "^18.2.0",
     "@angular/platform-browser-dynamic": "^18.2.0",
     "@angular/router": "^18.2.0",
+    "@ngx-translate/core": "^16.0.3",
+    "@ngx-translate/http-loader": "^16.0.0",
     "ng-process-env": "^16.0.6",
+    "ngx-cookie-service": "^18.0.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.14.10"

+ 0 - 0
frontend/public/img/control/slid_d.svg → frontend/public/img/control/down.svg


+ 0 - 0
frontend/public/img/control/slid_r.svg → frontend/public/img/control/next.svg


+ 0 - 0
frontend/public/img/control/slid_l.svg → frontend/public/img/control/prev.svg


+ 0 - 0
frontend/public/img/control/slid_u.svg → frontend/public/img/control/up.svg


+ 27 - 5
frontend/src/app/app.component.ts

@@ -1,15 +1,20 @@
 import {Component, Input} from '@angular/core';
-import { RouterOutlet } from '@angular/router';
-import { NavbarComponent } from './components/navbar/navbar.component';
-import { FooterComponent } from './components/footer/footer.component';
+import {RouterOutlet} from '@angular/router';
+import {TranslatePipe, TranslateDirective, TranslateService} from "@ngx-translate/core";
+import {CookieService} from 'ngx-cookie-service';
+import {NavbarComponent} from './components/navbar/navbar.component';
+import {FooterComponent} from './components/footer/footer.component';
 import {User} from './models/user';
 import {MetaService} from './services/meta.service'
 import {UserService} from './services/user.service'
+import translationsEN from "../assets/i18n/en.json";
+import translationsES from "../assets/i18n/es.json";
+import translationsEU from "../assets/i18n/eu.json";
 
 @Component({
   selector: 'app-root',
   standalone: true,
-  imports: [RouterOutlet, NavbarComponent, FooterComponent],
+  imports: [RouterOutlet, NavbarComponent, FooterComponent, TranslatePipe, TranslateDirective],
   templateUrl: './app.component.html'
 })
 export class AppComponent {
@@ -17,7 +22,19 @@ export class AppComponent {
   user: User;
   texts: {[key: string]: string} = {};
   errorMessage!: string;
-  constructor(private metaService: MetaService, private userService: UserService){this.user = new User();}
+  constructor(private metaService: MetaService, private userService: UserService, private translateService: TranslateService, private cookieService: CookieService){
+    this.user = new User();
+
+
+    // Register translation languages
+    translateService.addLangs(['en', 'es', 'eu']);
+    translateService.setTranslation('en', translationsEN);
+    translateService.setTranslation('es', translationsES);
+    translateService.setTranslation('eu', translationsEU);
+    // Set default language
+    translateService.setDefaultLang(this.cookieService.get('lang') || 'es');
+    translateService.use(this.cookieService.get('lang') || 'es');
+  }
 
   ngOnInit(){
     this.userService.getUser().subscribe({
@@ -29,4 +46,9 @@ export class AppComponent {
       },
     });
   }
+
+  //Switch language
+  changeLanguage(lang: string) {
+    this.translateService.use(lang);
+  }
 }

+ 10 - 5
frontend/src/app/app.config.ts

@@ -1,9 +1,14 @@
-import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
-import { provideHttpClient } from '@angular/common/http';
-import { provideRouter } from '@angular/router';
+import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
+import {provideTranslateService} from "@ngx-translate/core";
+import {provideHttpClient} from '@angular/common/http';
+import {provideRouter} from '@angular/router';
 
-import { routes } from './app.routes';
+import {routes} from './app.routes';
 
 export const appConfig: ApplicationConfig = {
-  providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient()]
+  providers: [
+    provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(),
+    provideTranslateService({defaultLanguage: 'en'})
+  ]
+
 };

+ 4 - 4
frontend/src/app/components/footer/footer.component.html

@@ -1,9 +1,9 @@
 <footer>
   <hr/>
   <div id='footer_left'>
-    <span id='footer_follow' class='desktop' i18n>Follow me:</span>
+    <span id='footer_follow' class='desktop' i18n>{{'FOOTER.FOLLOW' | translate}}:</span>
     <span class='fake_a' onclick='openMessage();'>
-      <img src='./img/social/email.svg' i18n-alt alt='Send me a message' i18n-title title='Send me a message'/>
+      <img src='./img/social/email.svg' alt="{{'FOOTER.MESSAGE' | translate}}" title="{{'FOOTER.MESSAGE' | translate}}"/>
     </span>
     <a *ngFor="let url of user?.urls" target='_blank' title='{{url.description}}' href='{{url.url}}'>
       <img src='./img/social/{{url.logo}}' alt='{{url.name}}' title='{{url.description}}'/>
@@ -13,9 +13,9 @@
     <span>{{copyright}}</span>
   </div>
   <div id='footer_right'>
-    <a id='footer_help' href='/help/' i18n>Help</a>
+    <a id='footer_help' href='/help/'>{{'FOOTER.HELP' | translate}}</a>
     <div id='footer_langs'>
-      <a *ngFor="let lang of availableLangs" target='_blank' class='lang' title='{{lang.name}}' href='setLang("{{lang.code}}");'>
+      <a *ngFor="let lang of availableLangs" target='_blank' class='lang' title='{{lang.name}}' (click)="changeLanguage(lang.code)">
         <img src='./img/lang/{{lang.code}}.svg' alt='{{lang.name}}' title='{{lang.name}}'/>
       </a>
     </div>

+ 17 - 7
frontend/src/app/components/footer/footer.component.ts

@@ -6,6 +6,10 @@
 
 import {Component} from '@angular/core';
 import {AsyncPipe, NgFor, NgIf} from '@angular/common';
+import {TranslatePipe, TranslateDirective, TranslateService} from "@ngx-translate/core";
+import {CookieService} from 'ngx-cookie-service';
+import {TranslateModule} from "@ngx-translate/core";
+import {Navigation, Router} from '@angular/router';
 import {User} from '../../models/user';
 import {UserService} from '../../services/user.service';
 import {Lang} from '../../models/lang';
@@ -17,7 +21,7 @@ declare function closeMessage(): any;
 @Component({
   selector: 'app-footer',
   standalone: true,
-  imports: [AsyncPipe, NgFor, MessageComponent],
+  imports: [AsyncPipe, NgFor, MessageComponent, TranslateModule],
   templateUrl: './footer.component.html'
 })
 
@@ -54,7 +58,9 @@ export class FooterComponent {
    * @param userService Service that handles user information.
    * @param localeService Service that handles localizations.
    */
-  public constructor(private userService: UserService, private localeService: LocaleService){}
+  public constructor(private userService: UserService, private localeService: LocaleService, private cookieService: CookieService, private router: Router){
+    this.availableLangs = this.localeService.getAvailableLangs();
+  }
 
   /**
    * Loads on component initialization.
@@ -70,10 +76,14 @@ export class FooterComponent {
         this.copyright += new Date().getFullYear();
       },
     });
-    this.localeService.getAvailableLangs().subscribe({
-      next: (langs) => {this.availableLangs = langs;},
-      error: (error) => {this.errorMessage = error;},
-    });
-
   }
+
+  /**
+   * Switches the app language.
+   *
+   * It uses the locale service to do so. If the language is changed, the page will reload.
+   *
+   * @param lang The code of the language to set.
+   */
+  protected changeLanguage(lang: any) {this.localeService.changeLanguage(lang);}
 }

+ 20 - 22
frontend/src/app/components/message/message.component.html

@@ -1,7 +1,7 @@
 <div id='message_cover' onClick='closeMessage();'></div>
 <section id='message'>
   <h3>
-    <span i18n>Contact me</span>
+    <span>{{'MESSAGE.TITLE' | translate}}</span>
     <div id='message_close'>
       <a onClick='closeMessage();'>
         <img src='./img/control/close.svg'/>
@@ -9,50 +9,48 @@
     </div>
   </h3>
   <div id='message_form'>
-    <p i18n>
-      Something on your mind? Use this form to send me a message and I'll get back to you as soon as possible.
-    </p>
+    <p>{{'MESSAGE.INFO' | translate}}</p>
     <form #messageForm='ngForm' (ngSubmit)='onSubmit(messageForm)'>
       <div>
-        <label for='name' i18n>Your name: </label>
-        <input type='text' name='name' i18n-placeholder placeholder='Your name' ngModel required/>
+        <label for='name'>{{'MESSAGE.LABEL.NAME' | translate}}</label>
+        <input type='text' name='name' ngModel required/>
       </div>
       <div>
-        <span id='error_name' class='error' i18n>A name is required.</span>
+        <span id='error_name' class='error'>{{'MESSAGE.ERROR.NAME' | translate}}</span>
       </div>
       <div>
-        <label for='email' i18n>Your email: </label>
-        <input type='text' name='email' i18n-placeholder placeholder='Your email' ngModel required/>
+        <label for='email'>{{'MESSAGE.LABEL.EMAIL' | translate}}</label>
+        <input type='text' name='email' ngModel required/>
       </div>
       <div>
-        <span id='error_email' class='error' i18n>A valid email address is required.</span>
+        <span id='error_email' class='error'>{{'MESSAGE.ERROR.EMAIL' | translate}}</span>
       </div>
       <div>
-        <label for='email' i18n>Subject: </label>
-        <input type='text' name='subject' i18n-placeholder placeholder='Subject' ngModel required/>
+        <label for='email'>{{'MESSAGE.LABEL.SUBJECT' | translate}}</label>
+        <input type='text' name='subject' ngModel required/>
       </div>
       <div>
-        <span id='error_subject' class='error' i18n>Invalid subject.</span>
+        <span id='error_subject' class='error'>{{'MESSAGE.ERROR.SUBJECT' | translate}}</span>
       </div>
       <div>
-        <label for='message' i18n>Your message: </label>
-        <span id='error_message' class='error' i18n>The message can not be empty.</span>
-        <textarea placeholder='Your message' name='text' i18n-placeholder placeholder='Your message' ngModel required></textarea>
+        <label for='message'>{{'MESSAGE.LABEL.MESSAGE' | translate}}</label>
+        <span id='error_message' class='error'>{{'MESSAGE.ERROR.MESSAGE' | translate}}</span>
+        <textarea placeholder='Your message' name='text' ngModel required></textarea>
       </div>
       <div>
         <input type='checkbox' name='consent' ngModel required/>
-        <label for='consent' class='long' i18n>I accept the data processing policy</label>
+        <label for='consent' class='long'>{{'MESSAGE.LABEL.CONSENT' | translate}}</label>
       </div>
       <div>
-        <span id='error_consent' class='error' i18n>The message can't be sent unless the policy is accepted.</span>
+        <span id='error_consent' class='error'>{{'MESSAGE.ERROR.CONSENT' | translate}}.</span>
       </div>
-      <div><input type='submit' i18n-value value='Send'/></div>
+      <div><input type='submit' value="{{'MESSAGE.SEND' | translate}}"/></div>
     </form>
   </div>
   <div id='message_confirmation'>
-    <h4 i18n>Sent!</h4>
-    <p>I'll get back to you shortly</p>
-    <input type='button' onclick='closeMessage();' i18n-value value='Close'/>
+    <h4>{{'MESSAGE.SENT' | translate}}</h4>
+    <p>{{'MESSAGE.SUCCESS' | translate}}</p>
+    <input type='button' onclick='closeMessage();' value="{{'CLOSE' | translate}}"/>
   </div>
 </section>
 

+ 2 - 1
frontend/src/app/components/message/message.component.ts

@@ -6,6 +6,7 @@
 
 import {Component} from '@angular/core';
 import {FormsModule, NgForm} from '@angular/forms';
+import {TranslateModule} from "@ngx-translate/core";
 import {HttpClient, HttpHeaders} from '@angular/common/http';
 import {Message} from '../../models/message';
 import {environment} from '../../../environments/environment';
@@ -13,7 +14,7 @@ import {environment} from '../../../environments/environment';
 @Component({
   selector: 'app-message',
   standalone: true,
-  imports: [FormsModule],
+  imports: [FormsModule, TranslateModule],
   templateUrl: './message.component.html'
 })
 

+ 3 - 3
frontend/src/app/components/navbar/navbar.component.html

@@ -1,8 +1,8 @@
 <header>
     <div id='logo' *ngIf="user != undefined">{{user.displayname}}</div>
     <nav>
-        <a routerLink="/" i18n>Home</a>
-        <a routerLink="/profile" i18n>Me</a>
-        <a routerLink="/projects" i18n>Projects</a>
+        <a routerLink="/">{{'SECTION.HOME' | translate}}</a>
+        <a routerLink="/profile">{{'SECTION.PROFILE' | translate}}</a>
+        <a routerLink="/projects">{{'SECTION.PROJECTS' | translate}}</a>
     </nav>
 </header>

+ 2 - 1
frontend/src/app/components/navbar/navbar.component.ts

@@ -6,6 +6,7 @@
 
 import {Component} from '@angular/core';
 import {NgIf} from '@angular/common';
+import {TranslateModule} from "@ngx-translate/core";
 import {RouterModule} from '@angular/router';
 import {User} from '../../models/user';
 import {UserService} from '../../services/user.service';
@@ -13,7 +14,7 @@ import {UserService} from '../../services/user.service';
 @Component({
   selector: 'app-navbar',
   standalone: true,
-  imports: [RouterModule, NgIf],
+  imports: [RouterModule, NgIf, TranslateModule],
   templateUrl: './navbar.component.html'
 })
 

+ 1 - 1
frontend/src/app/components/project-preview/project-preview.component.html

@@ -15,7 +15,7 @@
   </div>
   <div class='button'>
     <a class='a_button' routerLink="/projects/{{project.permalink}}" i18n>
-      See more
+      {{'SEE_MORE' | translate}}
     </a>
   </div>
 </article>

+ 6 - 5
frontend/src/app/components/project-preview/project-preview.component.ts

@@ -4,15 +4,16 @@
  * @since 4.0.0
  */
 
-import { Component, Injectable, inject, Input } from '@angular/core';
-import { RouterLink, RouterOutlet } from '@angular/router';
-import { CommonModule, SlicePipe, NgFor } from '@angular/common';
-import { Project } from '../../models/project';
+import {Component, Injectable, inject, Input} from '@angular/core';
+import {RouterLink, RouterOutlet} from '@angular/router';
+import {CommonModule, SlicePipe, NgFor} from '@angular/common';
+import {TranslateModule} from "@ngx-translate/core";
+import {Project} from '../../models/project';
 
 @Component({
   selector: 'app-project-preview',
   standalone: true,
-  imports: [SlicePipe, NgFor, RouterLink, RouterOutlet],
+  imports: [SlicePipe, NgFor, RouterLink, RouterOutlet, TranslateModule],
   templateUrl: './project-preview.component.html'
 })
 

+ 6 - 6
frontend/src/app/pages/home/home.component.html

@@ -5,7 +5,7 @@
     <img id='profile_image' src="./img/user/{{user?.image}}" alt="{{user?.firstName}} {{user?.lastName}}"/>
     <div id='profile_social'>
       <span class='fake_a' onclick='openMessage();'>
-        <img src='./img/social/email.svg' i18n-alt alt='Send me a message' i18n-title title='Send me a message'/>
+        <img src='./img/social/email.svg' alt="{{'FOOTER.MESSAGE' | translate}}" title="{{'FOOTER.MESSAGE' | translate}}"/>
       </span>
       @if(user?.urls){
         <a *ngFor="let url of user?.urls" target='_blank' title='{{url.description}}' href='{{url.url}}'>
@@ -14,15 +14,15 @@
       }
     </div>
     <div [innerHTML]="texts['BIO_SHORT']" id='bio'></div>
-    <a id='more' class='a_button' href="/profile/" i18n>
-      More about me
+    <a id='more' class='a_button' href="/profile/">
+      {{'PROFILE.MORE' | translate}}
     </a>
   </article>
 </section>
 <section id='home_projects'>
-  <h3 i18n>Projects</h3>
+  <h3>{{'SECTION.PROJECTS' | translate}}</h3>
   <app-project-preview *ngFor="let project of projects" [project]="project"></app-project-preview>
-  <a id='all_projects' class='a_button' href="/projects/" i18n>
-    See all projects
+  <a id='all_projects' class='a_button' href="/projects/">
+    {{'PROJECT.ALL' | translate}}
   </a>
 </section>

+ 2 - 1
frontend/src/app/pages/home/home.component.ts

@@ -6,6 +6,7 @@
 
 import {Component} from '@angular/core';
 import {AsyncPipe, NgFor} from '@angular/common';
+import {TranslateModule} from "@ngx-translate/core";
 import {User} from '../../models/user';
 import {UserService} from '../../services/user.service';
 import {MetaService} from '../../services/meta.service';
@@ -16,7 +17,7 @@ import {ProjectPreviewComponent} from '../../components/project-preview/project-
 @Component({
   selector: 'app-home',
   standalone: true,
-  imports: [AsyncPipe, NgFor, ProjectPreviewComponent],
+  imports: [AsyncPipe, NgFor, ProjectPreviewComponent, TranslateModule],
   templateUrl: './home.component.html'
 })
 

+ 3 - 3
frontend/src/app/pages/profile/profile.component.html

@@ -5,8 +5,8 @@
     <h4 id='tagline'>{{texts["TAGLINE"]}}</h4>
     <div id='social'>
       <span class='fake_a' onclick='openMessage();'>
-        <img src='./img/social/email.svg' i18n-alt alt='Send me a message' i18n-title title='Send me a message'/>
-        <span>Send me a message</span>
+        <img src='./img/social/email.svg' alt="{{'FOOTER.MESSAGE' | translate}}" title="{{'FOOTER.MESSAGE' | translate}}"/>
+        <span>{{'FOOTER.MESSAGE' | translate}}</span>
       </span>
       <a *ngFor="let url of user?.urls" target='_blank' title='{{url.description}}' href='{{url.url}}'>
         <img src='./img/social/{{url.logo}}' alt='{{url.name}}' title='{{url.description}}'/>
@@ -16,7 +16,7 @@
   </article>
 </section>
 <section id='profile_bio'>
-  <h3 i18n>About me</h3>
+  <h3>{{'PROFILE.ABOUT' | translate}}</h3>
   <article>
     <div id='bio' [innerHTML]="texts['BIO']"></div>
   </article>

+ 2 - 1
frontend/src/app/pages/profile/profile.component.ts

@@ -6,6 +6,7 @@
 
 import {Component} from '@angular/core';
 import {NgFor} from '@angular/common';
+import {TranslateModule} from "@ngx-translate/core";
 import {User} from '../../models/user';
 import {UserService} from '../../services/user.service';
 import {MetaService} from '../../services/meta.service';
@@ -13,7 +14,7 @@ import {MetaService} from '../../services/meta.service';
 @Component({
   selector: 'app-profile',
   standalone: true,
-  imports: [NgFor],
+  imports: [NgFor, TranslateModule],
   templateUrl: './profile.component.html'
 })
 

+ 7 - 9
frontend/src/app/pages/project/project.component.html

@@ -1,14 +1,12 @@
 <script type="text/javascript" src="./js/project.js"></script>
 @if(project){
   <section id='project'>
-    <h3>{{ project.title }}</h3>
+    <h3>{{project.title}}</h3>
     <article>
       <div id='summary'>
-        @if(project.logo){
-          <img id='project_logo' src="./img/project/{{project.logo}}" alt={{project.title}}/>
-        }
+        @if(project.logo){<img id='project_logo' src="./img/project/{{project.logo}}" alt={{project.title}}/>}
         <div id='texts'>
-          <h4>{{ project.header }}</h4>
+          <h4>{{project.header}}</h4>
           <div id='text' [innerHTML]=project.text></div>
         </div>
       </div>
@@ -25,13 +23,13 @@
         }
         @if(project.license){
           <div id='license'>
-            License:
+            {{'PROJECT.LICENSE' | translate}}
             <img src="./img/license/{{project.license.icon}}" alt="{{project.license.legal}}" title="{{project.license.legal}}"/>
           </div>
         }
         @if(project.tags){
           <div id='tags'>
-            Tags:
+            {{'PROJECT.TAGS' | translate}}
             <span *ngFor="let tag of project.tags">
               <img src="./img/tag/{{tag.icon}}" alt="{{tag.tag}}" title="{{tag.tag}}"/>
             </span>
@@ -40,8 +38,8 @@
       </div>
       @if(project.comment){
         <div id='comment'>
-          <div id='comment_title'>
-              TODO NOTES
+          <div id='comment_title' translate [translateParams]="{name: firstName}">
+            PROJECT.COMMENT
           </div>
           <div id="comment_text" [innerHTML]=project.comment></div>
         </div>

+ 8 - 1
frontend/src/app/pages/project/project.component.ts

@@ -9,6 +9,7 @@ import {AsyncPipe, NgFor, NgIf} from '@angular/common';
 import {ActivatedRoute} from '@angular/router';
 import {map} from 'rxjs/operators';
 import {Observable} from 'rxjs';
+import {TranslateModule} from "@ngx-translate/core";
 import {UserService} from '../../services/user.service';
 import {MetaService} from '../../services/meta.service';
 import {Project} from '../../models/project';
@@ -24,7 +25,7 @@ declare function closeImage(): any;
 @Component({
   selector: 'app-project',
   standalone: true,
-  imports: [AsyncPipe, NgIf, NgFor],
+  imports: [AsyncPipe, NgIf, NgFor, TranslateModule],
   templateUrl: './project.component.html'
 })
 
@@ -47,6 +48,11 @@ export class ProjectComponent {
    */
   protected errorMessage!: string;
 
+  /**
+   * Active user first name, for the comment title.
+   */
+  protected firstName: string = "";
+
   /**
    * The constructor.
    *
@@ -65,6 +71,7 @@ export class ProjectComponent {
     this.userService.getUser().subscribe({
       error: (error) => {this.errorMessage = error;},
       next: (user) => {
+        this.firstName += user.firstName;
         title = title.replace("<NAME>", user.firstName + " " + user.lastName);
         description = description.replace("<NAME>", user.firstName + " " + user.lastName);
         this.metaService.setTitle(title);

+ 1 - 1
frontend/src/app/pages/projects/projects.component.html

@@ -1,4 +1,4 @@
 <section id='projects'>
-  <h3 i18n>Projects</h3>
+  <h3>{{'SECTION.PROJECTS' | translate}}</h3>
   <app-project-preview *ngFor="let project of projects" [project]="project"></app-project-preview>
 </section>

+ 2 - 1
frontend/src/app/pages/projects/projects.component.ts

@@ -6,6 +6,7 @@
 
 import {Component} from '@angular/core';
 import {NgFor} from '@angular/common';
+import {TranslateModule} from "@ngx-translate/core";
 import {UserService} from '../../services/user.service';
 import {MetaService} from '../../services/meta.service';
 import {Project} from '../../models/project';
@@ -15,7 +16,7 @@ import {ProjectPreviewComponent} from '../../components/project-preview/project-
 @Component({
   selector: 'app-projects',
   standalone: true,
-  imports: [NgFor, ProjectPreviewComponent],
+  imports: [NgFor, ProjectPreviewComponent, TranslateModule],
   templateUrl: './projects.component.html'
 })
 

+ 96 - 3
frontend/src/app/services/locale.service.ts

@@ -8,6 +8,8 @@ import {environment} from '../../environments/environment';
 import {Injectable} from '@angular/core';
 import {HttpClient} from '@angular/common/http';
 import {Observable} from 'rxjs';
+import {TranslateService} from "@ngx-translate/core";
+import {CookieService} from 'ngx-cookie-service';
 import {Lang} from '../models/lang';
 
 @Injectable({providedIn: 'root'})
@@ -22,10 +24,15 @@ export class LocaleService {
    */
   private apiUrl: string = environment.API_URL + 'langs/';
 
+  /**
+   * The error message.
+   */
+  private errorMessage!: string;
+
   /**
    * A promise for the active user.
    */
-  private availableLangs: Observable<Lang[]>;
+  private availableLangs: Lang[] = [];
 
   /**
    * Constructor.
@@ -34,12 +41,98 @@ export class LocaleService {
    *
    * @param http The HTTP client.
    */
-  public constructor(private http: HttpClient){this.availableLangs = this.http.get<Lang[]>(this.apiUrl);}
+  public constructor(
+    private http: HttpClient, private translateService: TranslateService,
+    private cookieService: CookieService
+  ){
+    // Initialize default available languages.
+    for (const l of environment.LANG.ACCEPTED){
+      const lng: Lang = {code: l, name: ""};
+      this.availableLangs.push(lng);
+    }
+    this.http.get<Lang[]>(this.apiUrl).subscribe({
+      error: (error) => {this.errorMessage = error;},
+      next: async (langs) => {
+        this.availableLangs = [];
+        for (const lang of langs){
+          if (environment.LANG.ACCEPTED.indexOf(lang.code!) !== -1){
+            this.availableLangs.push(lang);
+            this.translateService.addLangs([lang.code!]);
+            this.translateService.setTranslation(
+              lang.code!, await import("../../assets/i18n/" + lang.code + ".json")
+            );
+          }
+        }
+      }
+    });
+  }
+
+  /**
+   * Sets a language for the application.
+   *
+   * If the language is set, the page will be reloaded.
+   *
+   * @param code Code of the language to set.
+   * @return True if the language is accepted and it was set (the page will be reloaded), of false
+   * if an invalid language was specified.
+   */
+  private setLanguage(code: string): boolean{
+    // Check valid language
+    var found: boolean = false;
+    for (const lang of this.availableLangs)
+      if (lang.code === code){
+        found = true;
+        break;
+      }
+    if (!found) return false;
+    this.cookieService.set('lang', code);
+    this.translateService.setDefaultLang(code);
+    this.translateService.use(code);
+    location.reload();
+    return true;
+  }
+
+  /**
+   * Obtains the most appropiate language for the application.
+   *
+   * The criteria is, in this order:
+   * - The language defined in the cookie lang, if it exists and its valid.
+   * - The first supported language that the browser preffers.
+   * - The default language retrieved from the backend.
+   * - The default language defined in environment.
+   *
+   * @return The most appropiate language code.
+   */
+  public getLanguage(): string{
+    // Check if cookie language is a valid language
+    var validCookie: boolean = false;
+    if (this.cookieService.check('lang')){
+      for (const lang of this.availableLangs){
+        if (lang.code === this.cookieService.get('lang')){
+          validCookie = true;
+          break;
+        }
+      }
+    }
+    const lang = "" +
+      (validCookie ? this.cookieService.get('lang') : false
+      || this.availableLangs.length > 0 ? this.availableLangs[0].code : false
+      || environment.LANG.DEFAULT);
+    return lang;
+  }
+
+  /**
+   * Changes the language.
+   *
+   * @param code The code of the language to set.
+   * @return True if the language was changed, false otherwise.
+   */
+  public changeLanguage(code: string): boolean{return this.setLanguage(code)}
 
   /**
    * Retrieves a user to the active user.
    *
    * @return A promise to the active user.
    */
-  public getAvailableLangs(): Observable<Lang[]>{return this.availableLangs;}
+  public getAvailableLangs(): Lang[]{return this.availableLangs;}
 }

+ 4 - 1
frontend/src/app/services/user.service.ts

@@ -8,6 +8,7 @@ import {Injectable} from '@angular/core';
 import {HttpClient} from '@angular/common/http';
 import {Observable} from 'rxjs';
 import {User} from '../models/user';
+import {LocaleService} from './locale.service';
 import {environment} from '../../environments/environment';
 
 @Injectable({providedIn: 'root'})
@@ -34,7 +35,9 @@ export class UserService {
    *
    * @param http The HTTP client.
    */
-  public constructor(private http: HttpClient){this.user = this.http.get<User>(this.apiUrl);}
+  public constructor(private http: HttpClient, private localeService: LocaleService){
+    this.user = this.http.get<User>(this.apiUrl + "?lang=" + this.localeService.getLanguage());
+  }
 
   /**
    * Retrieves a user to the active user.

+ 45 - 0
frontend/src/assets/i18n/en.json

@@ -0,0 +1,45 @@
+{
+  "CLOSE": "Close",
+  "SEE_MORE": "See more",
+  "SECTION": {
+    "HOME": "Home",
+    "PROFILE": "Me",
+    "PROJECTS": "Projects"
+  },
+  "FOOTER": {
+    "FOLLOW": "Follow me",
+    "MESSAGE": "Send me a message",
+    "HELP": "Help"
+  },
+  "MESSAGE": {
+    "TITLE:": "Contact me",
+    "INFO": "Something on your mind? Use this form to send me a message and I'll get back to you as soon as possible.",
+    "SEND": "Send",
+    "SENT": "Message sent!",
+    "SUCCESS": "I'll get back to you shortly.",
+    "LABEL": {
+      "NAME": "Your name:",
+      "EMAIL": "Your email:",
+      "SUBJECT": "Subject:",
+      "MESSAGE": "Message:",
+      "CONSENT": "I accept the data processing policy."
+    },
+    "ERROR": {
+      "NAME": "A name is required.",
+      "EMAIL": "A valid email address is required.",
+      "SUBJECT": "The subject can ot be empty.",
+      "MESSAGE": "The message can not be empty.",
+      "CONSENT": "The message can't be sent unless the policy is accepted."
+    }
+  },
+  "PROFILE": {
+    "MORE": "More about me",
+    "ABOUT": "About me"
+  },
+  "PROJECT": {
+    "ALL": "See all projects",
+    "LICENSE": "License",
+    "TAGS": "Tags",
+    "COMMENT": "{{name}}'s notes:"
+  }
+}

+ 45 - 0
frontend/src/assets/i18n/es.json

@@ -0,0 +1,45 @@
+{
+  "CLOSE": "Cerrar",
+  "SEE_MORE": "Ver más",
+  "SECTION": {
+    "HOME": "Inicio",
+    "PROFILE": "Yo",
+    "PROJECTS": "Proyectos"
+  },
+  "FOOTER": {
+    "FOLLOW": "Sobre mi",
+    "MESSAGE": "Envíame un mensaje",
+    "HELP": "Ayuda"
+  },
+  "MESSAGE": {
+    "TITLE:": "Contacto",
+    "INFO": "¿Quieres contactar conmigo? Utiliza este formulario para enviarme un mensaje, y te responderé enseguida.",
+    "SEND": "Enviar",
+    "SENT": "¡Enviado!",
+    "SUCCESS": "Enseguida te responderé con un email.",
+    "LABEL": {
+      "NAME": "Tu nombre:",
+      "EMAIL": "Tu email:",
+      "SUBJECT": "Asunto:",
+      "MESSAGE": "Mensaje:",
+      "CONSENT": "Acepto la política de tratamiento de datos."
+    },
+    "ERROR": {
+      "NAME": "El nombre es obligatorio.",
+      "EMAIL": "Es necesaria una dirección email válida.",
+      "SUBJECT": "El asunto no puede estar vacío.",
+      "MESSAGE": "El mensaje no puede estar vacío.",
+      "CONSENT": "No se puede enviar el mensaje si no se acepta la política."
+    }
+  },
+  "PROFILE": {
+    "MORE": "Más sobre mí",
+    "ABOUT": "Sobre mí"
+  },
+  "PROJECT": {
+    "ALL": "Todos los proyectos",
+    "LICENSE": "Licencia",
+    "TAGS": "Etiquetas",
+    "COMMENT": "Notas de {{name}}:"
+  }
+}

+ 45 - 0
frontend/src/assets/i18n/eu.json

@@ -0,0 +1,45 @@
+{
+  "CLOSE": "Itxi",
+  "SEE_MORE": "Gehiago",
+  "SECTION": {
+    "HOME": "Hasiera",
+    "PROFILE": "Ni",
+    "PROJECTS": "Proiektuak"
+  },
+  "FOOTER": {
+    "FOLLOW": "Niri buruz",
+    "MESSAGE": "Bidali mezu bat",
+    "HELP": "Laguntza"
+  },
+  "MESSAGE": {
+    "TITLE:": "Kontaktua",
+    "INFO": "Nirekin hitz egin nahi al duzu? Erabili inprimaki hau mezu bat bidaltzeko, eta berehala erantzungo dizut.",
+    "SEND": "Bidali",
+    "SENT": "Bidali!",
+    "SUCCESS": "Berehala erantzungo dizut mezu elektroniko batekin.",
+    "LABEL": {
+      "NAME": "Zure izena:",
+      "EMAIL": "Zure email:",
+      "SUBJECT": "Gaiz:",
+      "MESSAGE": "Mezua:",
+      "CONSENT": "Datu tratatzeko politika onartzen dut."
+    },
+    "ERROR": {
+      "NAME": "Izena nahitaezkoa da.",
+      "EMAIL": "Es necesaria una dirección email válida.",
+      "SUBJECT": "Baliozko helbide elektronikoa behar da.",
+      "MESSAGE": "Mezua ezin da hutsik egon.",
+      "CONSENT": "Ezin da mezua bidali politika onartzen ez bada."
+    }
+  },
+  "PROFILE": {
+    "MORE": "Gehiago niri buruz",
+    "ABOUT": "Niri buruz"
+  },
+  "PROJECT": {
+    "ALL": "Proiectu denak",
+    "LICENSE": "Lizentzia",
+    "TAGS": "Etiketak",
+    "COMMENT": "{{name}}ren oharrak:"
+  }
+}

+ 20 - 3
frontend/src/styles/styles.scss

@@ -30,9 +30,13 @@
  * || COMMON
  * Styles for common elements.
  */
+
+* {box-sizing: border-box;}
+
 html {height: 100%;}
 
 body{
+  box-sizing: border-box;
   height: 100%;
   padding: 0;
   margin: 0;
@@ -40,7 +44,11 @@ body{
   font-size: 110%;
   min-height: 100vh;
   flex-direction: column;
+}
+app-root{
   display: flex;
+  flex-direction: column;
+  min-height: 95vh;
 }
 
 main{
@@ -49,6 +57,15 @@ main{
   max-width: 130em;
   margin: 0 auto;
   text-align: center;
+  flex-grow: 1;
+}
+app-navbar{
+  flex-grow: 0;
+  flex-shrink: 0;
+}
+app-footer{
+  flex-grow: 0;
+  flex-shrink: 0;
 }
 
 /* Style for all links. */
@@ -632,6 +649,7 @@ section#home_projects{
 
 #projects{
   text-align: center;
+  h3{text-align: left;}
   article{
     text-align: left;
     display: inline-block;
@@ -775,10 +793,9 @@ section#project{
     margin: auto;
     text-align: center;
     div.img{
-      display:inline-block;
+      display: inline-block;
       vertical-align: top;
       max-width: 12em;
-      max-height: 15em;
       text-align: center;
       background: #00000020;
       border: 0.1em solid #aacaaa;
@@ -791,7 +808,7 @@ section#project{
         cursor: pointer;
         margin: 0.5em auto 0.2em auto;
         max-width: calc(100% - 0.5em);
-        max-height: 22em;
+        max-height: 15em;
         border: 0.1em solid #000000;
         background: #000000;
       }