Bläddra i källkod

Project adapted for SSR.

Iñigo Valentin 10 månader sedan
förälder
incheckning
5768550885

+ 5 - 1
backend/src/routers/projectRouter.js

@@ -72,7 +72,11 @@ router.get("/:projectId/images/:imageId", cors({origin: '*', methods: 'GET'}), a
     const data
       = await projectData.getProjectImage(req.params.projectId, req.params.imageId, lang);
     if (!data) res.status(404).json({ error: "Image not found" });
-    else res.json(data);
+    else{
+        res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+        res.setHeader('Access-Control-Allow-Origin', '*');
+        res.json(data);
+    }
 });
 
 module.exports = router;

+ 14 - 0
frontend/package-lock.json

@@ -20,6 +20,7 @@
         "@ngx-translate/core": "^17.0.0",
         "@ngx-translate/http-loader": "^17.0.0",
         "express": "^5.1.0",
+        "ngx-cookie-service-ssr": "^20.1.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
         "zone.js": "~0.15.0"
@@ -6800,6 +6801,19 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/ngx-cookie-service-ssr": {
+      "version": "20.1.0",
+      "resolved": "https://registry.npmjs.org/ngx-cookie-service-ssr/-/ngx-cookie-service-ssr-20.1.0.tgz",
+      "integrity": "sha512-EarzkcsqhLJHnffhRl5JdusqI3n7KDjYyeuFI0TByuhp/goCpz4DsaJ3Y0p8lQVW9z7zqFe9k6v24sca6rIbzg==",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      },
+      "peerDependencies": {
+        "@angular/common": "^20.0.0",
+        "@angular/core": "^20.0.0",
+        "@angular/ssr": "^20.0.0"
+      }
+    },
     "node_modules/node-addon-api": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",

+ 2 - 1
frontend/package.json

@@ -43,6 +43,7 @@
     "@ngx-translate/core": "^17.0.0",
     "@ngx-translate/http-loader": "^17.0.0",
     "express": "^5.1.0",
+    "ngx-cookie-service-ssr": "^20.1.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0"
@@ -63,4 +64,4 @@
     "karma-jasmine-html-reporter": "~2.1.0",
     "typescript": "~5.9.2"
   }
-}
+}

+ 67 - 0
frontend/public/scripts/home.js

@@ -0,0 +1,67 @@
+var intervalId;
+var apiURL;
+
+/**
+ * Generates a srcset atribute from a src attribute.
+ * 
+ * @param src The original src.
+ * @return The generated srcset attribute.
+ */
+function generateSrcset(src){
+    var srcset = "";
+    for (let i = 100; i <= 1000; i += 100) srcset += src + "?w=" + i + " " + i + "w, ";
+    srcset.substring(0, srcset.length - 1);
+    return srcset;
+}
+
+/**
+ * Delays execution when called asynchronously.
+ * 
+ * @param ms Milliseconds to wait.
+ */
+function delay(ms){ return new Promise(resolve => setTimeout(resolve, ms)); }
+
+/**
+ * Swaps an imaga in the homepage
+ * 
+ * @param id Id of the project which image must be swapped.
+ * @param apiURL The api URL (host only).
+ * @param image Retrieved image data.
+ */
+async function swapImage(id, apiURL, image){
+    var img = document.getElementById("img-project-" + id);
+    var imgAlt = document.getElementById("img-project-" + id + "-alt");
+    imgAlt.setAttribute("src", apiURL + image.path);
+    imgAlt.setAttribute("srcset", generateSrcset(apiURL + image.path));
+    await delay(1000);
+    img.style.opacity = '0';
+    await delay(1000);
+    img.setAttribute("src", "" + imgAlt.getAttribute("src"));
+    img.setAttribute("srcset", "" + imgAlt.getAttribute("srcset"));
+    img.style.opacity = '1';
+    await delay(1000);
+    imgAlt.removeAttribute("src");
+    imgAlt.removeAttribute("srcset");
+}
+
+/**
+ * Starts the interval for image swapping in the home page.
+ */
+async function startInterval(){
+    while (document.getElementById("apiURL") == undefined) await delay(500);
+    apiURL = document.getElementById("apiURL").value;
+    intervalId = setInterval(() => {
+        const projects = document.getElementsByClassName('project');
+        const id = projects[Math.floor(Math.random() * projects.length)].id.substring(8);
+
+        // TODO: ADD lang
+        fetch(apiURL + "/projects/" + id + "/images/random")
+          .then(response => response.json())
+          .then(data => swapImage(id, apiURL, data))
+          .catch(error => console.error(
+            'Error rerieving image from ' + apiURL + "/projects/" + id + "/images/random : " , error
+          ));
+    }, 2000);
+}
+
+startInterval();

+ 2 - 2
frontend/src/app/app.config.ts

@@ -2,7 +2,7 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChang
 import { provideTranslateService, provideTranslateLoader } from '@ngx-translate/core';
 import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
 import { provideRouter } from '@angular/router';
-import { HttpClient, provideHttpClient } from '@angular/common/http';
+import { HttpClient, provideHttpClient, withFetch } from '@angular/common/http';
 
 import { routes } from './app.routes';
 import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
@@ -13,7 +13,7 @@ export const appConfig: ApplicationConfig = {
     provideBrowserGlobalErrorListeners(),
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(routes),
-    provideHttpClient(),
+    provideHttpClient(withFetch()),
     provideTranslateService({
       loader: provideTranslateHttpLoader({prefix: './i18n/', suffix: '.json' }),
       fallbackLang: 'en', lang: 'en'

+ 14 - 15
frontend/src/app/app.ts

@@ -1,6 +1,7 @@
 import { Component, ViewEncapsulation, inject } from '@angular/core';
 import { RouterOutlet, ActivatedRoute } from '@angular/router';
 import { TranslateService, TranslatePipe, TranslateDirective } from '@ngx-translate/core';
+import { SsrCookieService } from 'ngx-cookie-service-ssr';
 import { Meta } from '@angular/platform-browser';
 import { author, languages} from '../../package.json';
 import { Header } from './header/header';
@@ -17,6 +18,7 @@ import { Footer } from './footer/footer';
 export class App {
     
     private translate = inject(TranslateService);
+    private cookieService = inject(SsrCookieService);
     
     author: string = author;
     private languages: any = languages;
@@ -32,20 +34,21 @@ export class App {
         
         let available: String[] = this.languages.available.split("|");
         // If the language is set as a request parameter and its valid, use it.
-        // TODO: Disabled in SSR
-        /*const queryLang = new URLSearchParams(window.location.search).get('lang');
-        console.log("QUERY LANG: " + queryLang);
-        if (queryLang && available.indexOf(queryLang.substring(0, 2).toLowerCase()) != -1)
-            return queryLang.substring(0, 2).toLowerCase();*/
+        // TODO: Reimplement in SSR
+        //const queryLang = new URLSearchParams(window.location.search).get('lang');
+        //if (queryLang && available.indexOf(queryLang.substring(0, 2).toLowerCase()) != -1)
+        //    return queryLang.substring(0, 2).toLowerCase();
+        
 
         // If the language has been previously set and a valid one is its in local storage, done.
-        // TODO: Disabled in SSR
-        //if (available.indexOf("" + localStorage.getItem('language')) != -1)
-        //    return "" + localStorage.getItem('language');
+        if (
+          this.cookieService.check('language')
+          && available.indexOf(this.cookieService.get('language')) != -1
+        )return "" + this.cookieService.get('language');
         
         // If the language is not set, loop the browser accepted languages.
         // When there is a match with the app available languages, return it.
-        // TODO: Disabled in SSR
+        // TODO: Reimplement in SSR
         /*for (let l of navigator.languages){
             if ( l.length >= 2 && available.indexOf(l.substring(0, 2).toLowerCase()) != -1)
                 return l.substring(0, 2).toLowerCase();
@@ -56,17 +59,13 @@ export class App {
     }
     
     constructor(private metaService: Meta, private route: ActivatedRoute){
-        
         this.translate.addLangs(this.languages.available.split("|"));
         this.translate.setFallbackLang(this.languages.default);
         // Detect the best language.
         const lang: string = this.selectLanguage()
-        this.translate.use(lang);
-        // TODO: Disabled in SSR
-        //localStorage.setItem('language', lang)
-        
+        this.translate.use(lang);        
         // Set meta tags
         this.metaService.addTag({ property: 'author', author });
 
     }
-}
+}

+ 7 - 6
frontend/src/app/footer/footer.ts

@@ -1,6 +1,7 @@
 import { Component, inject } from '@angular/core';
 import { version, author, authorURL, sourceSite, sourceURL, license } from '../../../package.json';
 import { TranslateService, TranslatePipe } from '@ngx-translate/core';
+import { SsrCookieService } from 'ngx-cookie-service-ssr';
 
 @Component({
     selector: 'app-footer', templateUrl: './footer.html', styleUrl: './footer.scss',
@@ -13,6 +14,9 @@ export class Footer{
     sourceSite: string = sourceSite;
     sourceURL: string = sourceURL;
     license: string = license;
+    currentLanguage: string; 
+    
+    private cookieService = inject(SsrCookieService)
     
     languages = [
         { code: 'es', name: 'Español' },
@@ -20,20 +24,17 @@ export class Footer{
         { code: 'eu', name: 'Euskara' },
     ];
 
-    // TODO: Disabled in SSR
-    currentLanguage = 'es';//localStorage.getItem('language');
-    
     private translate = inject(TranslateService)
     
     constructor(){
+        this.currentLanguage = this.translate.getCurrentLang();
     }
     
     switchLanguage(languageCode: string): void {
         this.currentLanguage = languageCode;
-        // TODO: Disabled in SSR
-        //localStorage.setItem('language', languageCode);
+        this.cookieService.set('language', languageCode);
         this.translate.use(languageCode);
-        window.location.reload();
+        setTimeout(location.reload.bind(location), 100);
     }
 
 }

+ 15 - 12
frontend/src/app/home/home.html

@@ -1,17 +1,19 @@
-<section id='profile'>
-  <img
-    src="{{ apiURL }}{{ profile.image }}"
-    srcset="{{ utilService.generateSrcset(apiURL + profile.image) }}"
-  />
-  <div id="profile-text">
-    <p>{{ profile.bio }}</p>
-    <p>{{ profile.description }}</p>
-  </div>
-</section>
+@if (profile){
+  <section id='profile'>
+    <img
+      src="{{ apiURL }}{{ profile.image }}"
+      srcset="{{ utilService.generateSrcset(apiURL + profile.image) }}"
+    />
+    <div id="profile-text">
+      <p>{{ profile.bio }}</p>
+      <p>{{ profile.description }}</p>
+    </div>
+  </section>
+}
 <section id='catalog'>
   @for (project of projects; track project) {
-    <article class="project">
-      <a href="/projects/{{ project.permalink }}" class='project'>
+    <article class="project" id="project-{{ project.id }}">
+      <a href="/projects/{{ project.permalink }}">
         <img class="project-image-alt" id="img-project-{{project.id}}-alt"/>
         <img
           class="project-image" id="img-project-{{project.id}}"
@@ -25,3 +27,4 @@
     </article>
   }
 </section>
+<input id='apiURL' type='hidden' value='{{ apiURL }}'>

+ 6 - 40
frontend/src/app/home/home.ts

@@ -1,36 +1,32 @@
-import { Component, OnInit, inject, PLATFORM_ID } from '@angular/core';
-import { isPlatformBrowser, isPlatformServer } from '@angular/common';
+import { Component, OnInit, inject } from '@angular/core';
+import { PlatformLocation } from '@angular/common';
 import { Meta, Title } from '@angular/platform-browser';
 import { TranslateService, _ } from '@ngx-translate/core';
 import { ProjectService } from '../service/project-service';
 import { ProfileService } from '../service/profile-service';
 import { UtilService } from '../service/util-service';
 import { ProjectModel } from '../model/project';
-import { ProfileModel } from '../model/profile';
 import { environment } from '../../environments/environment';
 
 @Component({ selector: 'app-home', templateUrl: './home.html', styleUrl: './home.scss'})
 export class Home implements OnInit {
     projects: ProjectModel[] = [];
     profile: any;
-    private intervalId: any;
     apiURL: string = environment.apiUrl;
     utilService: UtilService;
     
     private translate = inject(TranslateService)
-    private platform = inject(PLATFORM_ID)
+    
 
     constructor(
       private projectService: ProjectService, private profileService: ProfileService,
-      private titleService: Title, private metaService: Meta
+      private titleService: Title, private metaService: Meta,
+      private platformLocation: PlatformLocation
     ){
         this.utilService = new UtilService();
         
         // Set meta tags
-        console.log("TEST");
-        //const url: string = this.document.location.protocol + "//" + this.document.location.host;
-        const url = "TODO";
-        
+        const url = this.platformLocation.protocol + "//" + this.platformLocation.hostname;
         this.metaService.addTag({ property: 'canonical', content: url });
         this.metaService.addTag({ property: 'og:url', content: url });
         this.metaService.addTag({ property: 'og:image', content: url + '/img/logo/leather.png' });
@@ -48,35 +44,5 @@ export class Home implements OnInit {
     ngOnInit(): void {
         this.projectService.getProjects(1).subscribe(data => { this.projects = data; });
         this.profileService.getProfile().subscribe(data => { this.profile = data; });
-        this.startInterval();
-        
-    }
-  
-    private delay(ms: number){ return new Promise(resolve => setTimeout(resolve, ms)); }
-  
-    startInterval() {
-        this.intervalId = setInterval(() => {
-            if (!isPlatformBrowser(this.platform)) return;
-            const id: number = this.projects[Math.floor(Math.random() * this.projects.length)].id;
-            this.projectService.getProjectRandomImage(id.toString()).subscribe(async image => {
-                var img: HTMLImageElement
-                  = <HTMLImageElement>document.getElementById("img-project-" + id);
-                var imgAlt: HTMLImageElement
-                  = <HTMLImageElement>document.getElementById("img-project-" + id + "-alt");
-                imgAlt.setAttribute("src", this.apiURL + image.path);
-                imgAlt.setAttribute(
-                  "srcset", this.utilService.generateSrcset(this.apiURL + image.path)
-                );
-                await this.delay(1000);
-                img.style.opacity = '0';
-                await this.delay(1000);
-                img.setAttribute("src", "" + imgAlt.getAttribute("src"));
-                img.setAttribute("srcset", "" + imgAlt.getAttribute("srcset"));
-                img.style.opacity = '1';
-                await this.delay(1000);
-                imgAlt.removeAttribute("src");
-                imgAlt.removeAttribute("srcset");
-            });
-        }, 2000);
     }
 }

+ 6 - 3
frontend/src/app/project/project.ts

@@ -1,5 +1,6 @@
 import { Component, inject } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
+import { PlatformLocation } from '@angular/common';
 import { Title, Meta } from '@angular/platform-browser';
 import { ProjectService } from '../service/project-service';
 import { TranslateService, _ } from '@ngx-translate/core';
@@ -29,8 +30,9 @@ export class Project {
     private maxIndex: number = -1;
 
     constructor(
-      private route: ActivatedRoute, private projectService:
-      ProjectService, private titleService: Title, private metaService: Meta
+      private route: ActivatedRoute, private projectService: ProjectService,
+      private titleService: Title, private metaService: Meta,
+      private platformLocation: PlatformLocation
     ){
         this.utilService = new UtilService();
     }
@@ -43,7 +45,8 @@ export class Project {
             this.maxIndex = this.project.images.length;
             
             // Set meta tags
-            const siteUrl: string = window.location.protocol + "//" + window.location.host;
+            const siteUrl: string
+              = this.platformLocation.protocol + "//" + this.platformLocation.hostname;
             const projectUrl: string = siteUrl + '/projects' + this.project.permalink;
             this.metaService.addTag({ property: 'canonical', content: projectUrl });
             this.metaService.addTag({ property: 'og:url', content: projectUrl });

+ 4 - 2
frontend/src/app/service/profile-service.ts

@@ -1,4 +1,5 @@
-import { Injectable } from '@angular/core';
+import { Injectable, inject } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
 import { HttpClient } from '@angular/common/http';
 import { Observable } from 'rxjs';
 import { ProfileModel } from '../model/profile';
@@ -8,9 +9,10 @@ import { environment } from '../../environments/environment';
 
 export class ProfileService {
   private apiUrl = environment.apiUrl + '/profile';
+  private translate = inject(TranslateService);
   constructor(private http: HttpClient) { }
 
   getProfile(): Observable<ProfileModel[]> {
-    return this.http.get<ProfileModel[]>(this.apiUrl + "?lang=" + 'es'/* TODO: localStorage.getItem('language')*/);
+    return this.http.get<ProfileModel[]>(this.apiUrl + "?lang=" + this.translate.getCurrentLang());
   }
 }

+ 7 - 6
frontend/src/app/service/project-service.ts

@@ -1,6 +1,7 @@
-import { Injectable } from '@angular/core';
+import { Injectable, inject } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
 import { Observable } from 'rxjs';
+import { TranslateService } from '@ngx-translate/core';
 import { ProjectModel } from '../model/project';
 import { ProjectImageModel } from '../model/project-image';
 import { environment } from '../../environments/environment';
@@ -10,25 +11,25 @@ import { environment } from '../../environments/environment';
 export class ProjectService {
   private apiUrl = environment.apiUrl + '/projects';
   constructor(private http: HttpClient) { }
+  
+  private translate = inject(TranslateService);
 
   getProjects(images: any): Observable<ProjectModel[]> {
     var paramImages: string = "";
     if (images == true || parseInt(images) >= 0)
         paramImages = "&images=" + images;
-    return this.http.get<ProjectModel[]>(
-      this.apiUrl + "?lang=" + 'es'/* TODO: localStorage.getItem('language')*/ + paramImages
-    );
+    return this.http.get<ProjectModel[]>(this.apiUrl + "?lang=" + this.translate.getCurrentLang());
   }
 
   getProject(id: string): Observable<ProjectModel> {
     return this.http.get<ProjectModel>(
-      `${this.apiUrl}/${id}` + "?lang=" + 'es'/* TODO: localStorage.getItem('language')*/
+      `${this.apiUrl}/${id}` + "?lang=" + this.translate.getCurrentLang()
     );
   }
   
   getProjectRandomImage(id: string): Observable<ProjectImageModel> {
     return this.http.get<ProjectImageModel>(
-      `${this.apiUrl}/${id}/images/random` + "?lang=" + 'es'/* TODO: localStorage.getItem('language')*/
+      `${this.apiUrl}/${id}/images/random` + "?lang=" + this.translate.getCurrentLang()
     );
   }
   

+ 1 - 1
frontend/src/app/service/util-service.ts

@@ -6,7 +6,7 @@ import { Injectable } from '@angular/core';
  * Provides utilities to use across the app.
  */
 export class UtilService {
-    
+
     /**
      * Generates a srcset attribute for an image from it's src.
      * 

+ 1 - 0
frontend/src/index.html

@@ -5,6 +5,7 @@
   <base href="/">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
+  <script src="/scripts/home.js"></script>
 </head>
 <body>
   <app-root></app-root>

+ 2 - 4
frontend/src/server.ts

@@ -1,8 +1,5 @@
 import {
-  AngularNodeAppEngine,
-  createNodeRequestHandler,
-  isMainModule,
-  writeResponseToNodeResponse,
+  AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse,
 } from '@angular/ssr/node';
 import express from 'express';
 import { join } from 'node:path';
@@ -12,6 +9,7 @@ const browserDistFolder = join(import.meta.dirname, '../browser');
 const app = express();
 const angularApp = new AngularNodeAppEngine();
 
+
 /**
  * Example Express Rest API endpoints can be defined here.
  * Uncomment and define endpoints as necessary.

+ 2 - 1
frontend/tsconfig.json

@@ -11,7 +11,8 @@
     "experimentalDecorators": true,
     "importHelpers": true,
     "target": "ES2022",
-    "module": "preserve"
+    "module": "preserve",
+    "allowJs": true
   },
   "angularCompilerOptions": {
     "enableI18nLegacyMessageIdFormat": false,