Browse Source

Implemented child projects

Iñigo Valentin 1 month ago
parent
commit
efe7775dcc

BIN
backend/data.sqlite3


+ 3 - 1
backend/environment/env.example

@@ -3,4 +3,6 @@ PORT=3000
 AUTH=c3499c2729730a7f807efb8676a92dcb6f8a3f8f
 DB=./data.sqlite3
 DEFAULT_LANGUAGE=es
-AVAILABLE_LANGUAGES=es en eu
+AVAILABLE_LANGUAGES=es en eu
+FRONTEND=http://localhost:4200
+BACKEND=http://localhost:3100

+ 47 - 17
backend/src/data/projectData.js

@@ -18,25 +18,25 @@ projectData.retrieveProjectImages = async function(id, lang, max){
     });
 }
 
-projectData.getProjects = function(lang = null, images = null) {
+projectData.getProjects = function(children = null, lang = null, images = null) {
     return new Promise((resolve, reject) => {
-        projects = new Array();
+        const projects = [];
         db.all(
-          "SELECT id, permalink, "
-          + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
-          + ", priority FROM project WHERE visible=1 ORDER BY priority",
+          "SELECT id, permalink, " + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
+          + ", priority FROM project WHERE parent IS NULL AND visible=1 ORDER BY priority",
           async (err, rows) => {
             if(err) reject(err);
             else{
                 if (images == false || parseInt(images) <= 0) resolve(rows);
                 else{
                     for (var i = 0; i < rows.length; i ++){
+                        if (children == true || parseInt(children) > 0)
+                            rows[i].children = await this.getChildrenProjects(rows[i].id, lang, images);
                         if (images == true || parseInt(images) > 0){
                             rows[i].images = new Array();
                             var max = null;
                             if (parseInt(images) > 0) max = parseInt(images);
-                            rows[i].images
-                              = await this.retrieveProjectImages(rows[i].id, lang, max);
+                            rows[i].images = await this.retrieveProjectImages(rows[i].id, lang, max);
                         }
                         projects.push(rows[i]);
                     }
@@ -48,17 +48,18 @@ projectData.getProjects = function(lang = null, images = null) {
     });
 }
 
-projectData.getProject = function(id, lang = null, images = null) {
+projectData.getProject = function(id, children = null, lang = null, images = null) {
     return new Promise((resolve, reject) => {
         db.get(
-          "SELECT id, permalink, " + db.langQuery("title", lang)
-          + ", " + db.langQuery("description", lang)
+          "SELECT id, permalink, " + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
           + ", priority, null AS images FROM project WHERE visible=1 AND (id=? OR permalink=?)",
           parseInt(id), String(id),
           async (err, row) => {
             if(err) reject(err);
             else if (!row) resolve("[]");
             else{
+                if (children == true || parseInt(children) > 0)
+                    row.children = await this.getChildrenProjects(row.id, lang, images);
                 if (images == false || parseInt(images) <= 0) resolve(row);
                 else{
                     row.images = await this.retrieveProjectImages(row.id, lang, images);
@@ -70,12 +71,41 @@ projectData.getProject = function(id, lang = null, images = null) {
     });
 }
 
-projectData.getProjectImages = function(id, lang = null, max = null) {
+projectData.getChildrenProjects = function(id, lang = null, images = null) {
     return new Promise((resolve, reject) => {
+        const projects = [];
         db.all(
-          "SELECT id, project, home, path, video, "
+          "SELECT id, permalink, "
           + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
-          + ", priority FROM project_image WHERE visible = 1 AND project IN "
+          + ", priority FROM project WHERE parent = ? AND visible=1 ORDER BY priority",
+          parseInt(id),
+          async (err, rows) => {
+            if(err) reject(err);
+            else{
+                if (images == false || parseInt(images) <= 0) resolve(rows);
+                else{
+                    for (var i = 0; i < rows.length; i ++){
+                        if (images == true || parseInt(images) > 0){
+                            rows[i].images = new Array();
+                            var max = null;
+                            if (parseInt(images) > 0) max = parseInt(images);
+                            rows[i].images = await this.retrieveProjectImages(rows[i].id, lang, max);
+                        }
+                        projects.push(rows[i]);
+                    }
+                    resolve(projects);
+                }
+            }
+          }
+        );
+    });
+}
+
+projectData.getProjectImages = function(id, lang = null, max = null) {
+    return new Promise((resolve, reject) => {
+        db.all(
+          "SELECT id, project, home, path, video, " + db.langQuery("title", lang) + ", "
+          + db.langQuery("description", lang) + ", priority FROM project_image WHERE visible = 1 AND project IN "
           + "(SELECT id FROM project WHERE visible = 1 AND (id = ? OR permalink = ?)) "
           + "ORDER BY priority " + (parseInt(max) > 0 ? " LIMIT " + parseInt(max) : ""),
           parseInt(id), String(id),
@@ -91,12 +121,12 @@ projectData.getProjectImage = function(projectId, imageId, lang = null) {
     return new Promise((resolve, reject) => {
         if (imageId.toLowerCase() == "random"){
             db.get(
-              "SELECT id, project, home, path, video, "
-              + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
+              "SELECT id, project, home, path, video, " + db.langQuery("title", lang) + ", "
+              + db.langQuery("description", lang)
               + ", priority FROM project_image WHERE visible=1 AND home=1 AND project IN "
               + "(SELECT id FROM project WHERE visible=1 AND home=1 AND video=0 "
-              + "AND (id=? OR permalink=?)) ORDER BY RANDOM() LIMIT 1",
-              parseInt(projectId), String(projectId),
+              + "AND (id=? OR permalink=? OR parent=?)) ORDER BY RANDOM() LIMIT 1",
+              parseInt(projectId), String(projectId), parseInt(projectId),
               async (err, row) => {
                 if(err) reject(err);
                 else resolve(row);

+ 68 - 0
backend/src/data/sitemapData.js

@@ -0,0 +1,68 @@
+const projectData = require("./projectData");
+const profileData = require("./profileData");
+
+let sitemapData = [];
+
+sitemapData.generateSitemap = async function(){
+    
+    const projects = await projectData.getProjects('en', true, false);
+    const profile = await profileData.getProfile('en', true, false);
+    const fUrl = process.env.FRONTEND;
+    const bUrl = process.env.BACKEND;
+
+    var sitemap = "";
+              
+              sitemap += '<?xml version="1.0" encoding="UTF-8"?>\n';
+              sitemap += '<urlset\n';
+              sitemap += '  xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n';
+              sitemap += '  xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"\n';
+              sitemap += '  xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"\n';
+              sitemap += '>\n';
+              sitemap += '  <url>\n';
+              sitemap += '    <loc>' + fUrl + '</loc>\n';
+              if (profile.images[0]){
+                  sitemap += '      <image:image>\n';
+                  sitemap += '        <image:loc>' + bUrl + profile.images[0].path + '</image:loc>\n';
+                  sitemap += '      </image:image>\n';
+              }
+              for (let project of projects){
+                  if (project.images[0]){
+                      sitemap += '      <image:image>\n';
+                      sitemap += '        <image:loc>' + bUrl + project.images[0].path + '</image:loc>\n';
+                      sitemap += '      </image:image>\n';
+                  }
+              }
+              sitemap += '  </url>\n';
+    for (let project of projects){
+      sitemap += '  <url>\n';
+      sitemap += '    <loc>' + fUrl + '/projects/' + project.permalink + '</loc>\n';
+      for (let image of project.images){
+          if (image.video == 1){
+              sitemap += '      <video:video>\n';
+              sitemap += '        <video:thumbnail_loc>' + bUrl + project.images[0].path + '?w=800</video:thumbnail_loc>\n';
+              sitemap += '        <video:title>' + image.title + '</video:title>\n';
+              sitemap += '        <video:description><![CDATA[' + image.description + ']]></video:description>\n';
+              sitemap += '        <video:content_loc>' + bUrl + image.path + '</video:content_loc>\n';
+              //sitemap += '        <video:player_loc>' + fUrl + '/projects/' + project.permalink + '</video:player_loc>\n';
+              sitemap += '        <video:family_friendly>yes</video:family_friendly>\n';
+              sitemap += '        <video:uploader info="' + fUrl + '/profile/">' + profile.display_name + '</video:uploader>\n';
+              sitemap += '        <video:live>no</video:live>\n';
+              sitemap += '      </video:video>\n';
+          }
+          else{
+              sitemap += '      <image:image>\n';
+              sitemap += '        <image:loc>' + bUrl + image.path + '</image:loc>\n';
+              sitemap += '      </image:image>\n';
+          }
+      }
+      sitemap += '    </url>\n';
+  }
+  sitemap += '  <url>\n';
+  sitemap += '    <loc>' + fUrl + '/profile/</loc>\n';
+  sitemap += '  </url>\n';
+  sitemap += '</urlset>\n';
+
+  return sitemap;
+}
+
+module.exports = sitemapData;

+ 8 - 3
backend/src/routers/assetRouter.js

@@ -2,8 +2,8 @@ const express = require("express");
 var path = require('path');
 const router = express.Router();
 
-// Read (GET) an asset
-router.get("/images/projects/:projectId/:imagePath", async (req, res) => {
+// Read (GET) project assets
+const sendProjectAsset = async (req, res) => {
     var reqpath = req.url.toString().split('?')[0];
     var file = "./assets" + reqpath.replace(/\/$/, '');
     var scale = parseInt(req.query.w);
@@ -25,7 +25,12 @@ router.get("/images/projects/:projectId/:imagePath", async (req, res) => {
             res.end('Not found');
         }
     });
-});
+};
+
+router.get([
+    "/images/projects/:projectId/:imagePath",
+    "/images/projects/:projectId/:child/:imagePath"
+], sendProjectAsset);
 
 router.get("/images/profile/:imagePath", async (req, res) => {
     var reqpath = req.url.toString().split('?')[0];

+ 10 - 2
backend/src/routers/projectRouter.js

@@ -27,7 +27,11 @@ router.get("/", cors({origin: '*', methods: 'GET'}), async (req, res) => {
       req.query.lang
       && process.env.AVAILABLE_LANGUAGES.split(" ").includes(req.query.lang.toLowerCase())
     ) lang = req.query.lang.toLowerCase();
-    const data = await projectData.getProjects(lang, images);
+    var children = true;
+    if (req.query.children == "false") children = false;
+    else if (parseInt(req.query.children) != NaN && parseInt(req.query.children) >= 0)
+        children = parseInt(req.query.children);
+    const data = await projectData.getProjects(children, lang, images);
     res.json(data);
 });
 
@@ -42,7 +46,11 @@ router.get("/:id", cors({origin: '*', methods: 'GET'}), async (req, res) => {
       req.query.lang
       && process.env.AVAILABLE_LANGUAGES.split(" ").includes(req.query.lang.toLowerCase())
     ) lang = req.query.lang.toLowerCase();
-    const data = await projectData.getProject(req.params.id, lang, images);
+    var children = true;
+    if (req.query.children == "false") children = false;
+    else if (parseInt(req.query.children) != NaN && parseInt(req.query.children) >= 0)
+        children = parseInt(req.query.children);
+    const data = await projectData.getProject(req.params.id, children, lang, images);
     res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
     res.setHeader('Access-Control-Allow-Origin', '*');
     if (!data || data == "[]") res.status(404).json({ error: "Project not found" });

+ 7 - 0
backend/src/routers/router.js

@@ -2,6 +2,7 @@ const express = require('express');
 const cors = require('cors');
 const projectRouter = require("./projectRouter.js");
 const profileRouter = require("./profileRouter.js");
+const sitemapData = require("../data/sitemapData.js");
 const assetRouter = require("./assetRouter.js");
 
 const router = express.Router();
@@ -24,4 +25,10 @@ router.get("/", cors({origin: '*', methods: 'GET'}), async (req, res) => {
     res.json(api);
 });
 
+router.get("/sitemap.xml", async (req, res) => {
+    const data = await sitemapData.generateSitemap();
+    res.set('Content-Type', 'application/xml');
+    res.send(data);
+});
+
 module.exports = router;

+ 3 - 0
frontend/angular.json

@@ -110,5 +110,8 @@
         }
       }
     }
+  },
+  "cli": {
+    "analytics": false
   }
 }

File diff suppressed because it is too large
+ 259 - 199
frontend/package-lock.json


+ 1 - 2
frontend/public/scripts/home.js

@@ -54,8 +54,7 @@ async function startInterval(){
     apiURL = document.getElementById("apiURL").value;
     intervalId = setInterval(() => {
         const projects = document.getElementsByClassName('project');
-        const id = projects[Math.floor(Math.random() * projects.length)].id.substring(8);
-
+        const id = projects[Math.floor(Math.random() * (projects.length - 1))].id.substring(8);
         // TODO: ADD lang
         fetch(apiURL + "/projects/" + id + "/images/random")
           .then(response => response.json())

+ 1 - 1
frontend/src/app/app.scss

@@ -15,7 +15,7 @@ app-root {
   
   main{
     flex-grow: 1;
-    max-width: 120em;
+    max-width: 80em;
     margin: auto;
   }
 }

+ 14 - 2
frontend/src/app/home/home.ts

@@ -8,7 +8,7 @@ import { UtilService } from '../service/util-service';
 import { ProjectModel } from '../model/project';
 import { environment } from '../../environments/environment';
 
-@Component({ selector: 'app-home', templateUrl: './home.html', styleUrl: './home.scss'})
+@Component({ selector: 'app-home', templateUrl: './home.html', styleUrls: ['./home.scss']})
 export class Home implements OnInit {
     projects: ProjectModel[] = [];
     profile: any;
@@ -42,7 +42,19 @@ export class Home implements OnInit {
     }
 
     ngOnInit(): void {
-        this.projectService.getProjects(1).subscribe(data => { this.projects = data; });
+        this.projectService.getProjects(1).subscribe(data => {
+            this.projects = data;
+
+            for (const project of this.projects) {
+                if (project.images.length > 0 || !project.children || project.children.length === 0)
+                    continue;
+
+                const firstChild = project.children[0];
+                const firstChildImage = firstChild.images.shift();
+                if (firstChildImage)
+                    project.images.push(firstChildImage);
+            }
+        });
         this.profileService.getProfile(1, "home").subscribe(data => { this.profile = data; });
     }
 }

+ 1 - 0
frontend/src/app/model/profile.ts

@@ -1,6 +1,7 @@
 export interface ProfileModel{
   first_name: string;
   last_name: string;
+  display_name: string;
   image: string;
   tagline: string;
   bio: string;

+ 1 - 0
frontend/src/app/model/project-image.ts

@@ -7,4 +7,5 @@ export interface ProjectImageModel{
     title: string;
     description: string;
     priority: number;
+    galleryIndex?: number;
 }

+ 1 - 0
frontend/src/app/model/project.ts

@@ -7,4 +7,5 @@ export interface ProjectModel{
   description: string;
   priority: number;
   images: ProjectImageModel[];
+  children: ProjectModel[];
 }

+ 4 - 1
frontend/src/app/profile/profile.ts

@@ -3,6 +3,7 @@ import { PlatformLocation } from '@angular/common';
 import { Meta, Title } from '@angular/platform-browser';
 import { TranslateService, _ } from '@ngx-translate/core';
 import { ProfileService } from '../service/profile-service';
+import { SeoService } from '../service/seo-service';
 import { UtilService } from '../service/util-service';
 import { environment } from '../../environments/environment';
 
@@ -17,7 +18,8 @@ export class Profile implements OnInit {
 
     constructor(
       private profileService: ProfileService, private titleService: Title,
-      private metaService: Meta, private platformLocation: PlatformLocation
+      private metaService: Meta, private platformLocation: PlatformLocation,
+      private seoService: SeoService
     ){
         this.utilService = new UtilService();
         
@@ -46,5 +48,6 @@ export class Profile implements OnInit {
 
     ngOnInit(): void {
         this.profileService.getProfile(true, "profile").subscribe(data => { this.profile = data; });
+        this.seoService.set("@type", "ProfilePage")
     }
 }

+ 73 - 44
frontend/src/app/project/project.html

@@ -2,70 +2,99 @@
   <section id='details'>
     @if (project.images.length > 0){
       <img
-        (click)=galleryOpen(1); id='img-1' class='img img_main'
-        src='{{ this.apiURL }}{{ project.images[0].path }}' alt='{{ project.images[0].title }}'
-        srcset="{{ utilService.generateSrcset(apiURL + project.images[0].path) }}"
-        attr.data-description='{{ project.images[0].description }}'
+        (click)='galleryOpen(getGalleryIndexForProjectImage(project.images[0].id))'
+        id='img-{{ getGalleryIndexForProjectImage(project.images[0].id) }}' class='img img_main'
+        src='{{ this.apiURL }}{{ project.images[0].path }}'
+        alt='{{ project.images[0].title }}' srcset="{{ utilService.generateSrcset(apiURL + project.images[0].path) }}"
+        attr.data-description='{{ project.images[0].description }}' attr.data-child-title=''
       />
     }
       <h2 id='project_title'>{{ project.title }}</h2>
       <p [innerHTML]='project.description'></p>
   </section>
   <section id='images'>
-    @for (image of project.images; track image.id) {
-      @if (image.id > 1){
+    @for (image of project.images; track image.id; let imageIndex = $index) {
+      @if (imageIndex > 0){
         @if (image.video == 0){
           <img
-            (click)='galleryOpen(image.id)' id='img-{{ image.id }}' class='img'
-            src='{{ this.apiURL }}{{ image.path }}' alt='{{ image.title }}'
-            srcset="{{ utilService.generateSrcset(apiURL + image.path) }}"
-            attr.data-description='{{ image.description }}' data-video='false'
+            (click)='galleryOpen(getGalleryIndexForProjectImage(image.id))'
+            id='img-{{ getGalleryIndexForProjectImage(image.id) }}' class='img'
+            src='{{ this.apiURL }}{{ image.path }}'
+            alt='{{ image.title }}' srcset="{{ utilService.generateSrcset(apiURL + image.path) }}"
+            attr.data-description='{{ image.description }}' data-video='false' attr.data-child-title=''
           />
         }
         @else{
           <video
-            muted (click)='galleryOpen(image.id)' class='img img_vid' id='img-{{ image.id }}'
-            src='{{ this.apiURL }}{{ image.path }}' playsinline
-            data-video='true' attr.data-description='{{ image.description }}'
+            muted (click)='galleryOpen(getGalleryIndexForProjectImage(image.id))'
+            class='img img_vid' id='img-{{ getGalleryIndexForProjectImage(image.id) }}' playsinline
+            src='{{ this.apiURL }}{{ image.path }}' data-video='true' attr.data-description='{{ image.description }}' attr.data-child-title=''
           >
           </video>
-          <img (click)='galleryOpen(image.id)' class='video_play' type="video/mp4"
+          <img
+            (click)='galleryOpen(getGalleryIndexForProjectImage(image.id))' class='video_play' type="video/mp4"
             src='/img/icon/video.png' alt='{{ image.title }}'
           />
         }
       }
     }
   </section>
-  <div id='gallery-cover' onClick='galleryClose();'></div>
-    <div
-      id='gallery'
-      (touchstart)="gallerySwipe($event, 'start')" (touchend)="gallerySwipe($event, 'end')"
-    >
-      <div id='gallery-fade'></div>
-      <h3>
-        <span>{{ project.title }}</span>
-        <span id='gallery-title'></span>
-        <input
-          type='button' id='gallery-next' class='gallery-control'
-          (click)='galleryClose();' value='X'
-        />
-      </h3>
-      <div id='gallery-content'>
-        <div id='gallery-flex'>
-          <div id='gallery-flex-img-container'>
-            <img id='gallery-img'/>
-            <video controls autoplay playsinline type="video/mp4" id='gallery-video'></video>
-          </div>
-          <p id='gallery-text'></p>
-        </div>
-        <div id='gallery-controls'>
-          <input
-            type='button' id='gallery-prev' class='gallery-control' (click)='galleryPrev();' value='<'
-          />
-          <input
-            type='button' id='gallery-next' class='gallery-control' (click)='galleryNext();' value='>'
-          />
+  @if ((project.children?.length ?? 0) > 0){
+    @for (child of project.children; track child.id) {
+      <section class='children'>
+        <section class='children-details'>
+            <h3 id='project_title'>{{ child.title }}</h3>
+            <p [innerHTML]='child.description'></p>
+        </section>
+        <section class='children-images'>
+          @for (image of child.images; track image.id) {
+            @if (image.video == 0){
+              <img
+                (click)='galleryOpen(getGalleryIndexForChildImage(child.id, image.id))'
+                id='img-{{ getGalleryIndexForChildImage(child.id, image.id) }}' class='img'
+                src='{{ this.apiURL }}{{ image.path }}' alt='{{ image.title }}'
+                srcset="{{ utilService.generateSrcset(apiURL + image.path) }}"
+                attr.data-description='{{ image.description }}' data-video='false' attr.data-child-title='{{ child.title }}'
+              />
+            }
+            @else{
+              <video
+                muted (click)='galleryOpen(getGalleryIndexForChildImage(child.id, image.id))'
+                class='img img_vid' id='img-{{ getGalleryIndexForChildImage(child.id, image.id) }}'
+                src='{{ this.apiURL }}{{ image.path }}' playsinline
+                data-video='true' attr.data-description='{{ image.description }}' attr.data-child-title='{{ child.title }}'
+              >
+              </video>
+              <img
+                (click)='galleryOpen(getGalleryIndexForChildImage(child.id, image.id))' class='video_play' type="video/mp4"
+                src='/img/icon/video.png' alt='{{ image.title }}'
+              />
+            }
+          }
+        </section>
+      </section>
+    }
+  }
+  <div id='gallery-cover' (click)='galleryClose();'></div>
+  <div id='gallery' (touchstart)="gallerySwipe($event, 'start')" (touchend)="gallerySwipe($event, 'end')">
+    <div id='gallery-fade'></div>
+    <h3>
+      <span>{{ project.title }}</span>
+      <span id='gallery-title'></span>
+      <input type='button' id='gallery-next' class='gallery-control' (click)='galleryClose();' value='X'/>
+    </h3>
+    <div id='gallery-content'>
+      <div id='gallery-flex'>
+        <div id='gallery-flex-img-container'>
+          <img id='gallery-img'/>
+          <video controls autoplay playsinline type="video/mp4" id='gallery-video'></video>
         </div>
+        <p id='gallery-text'></p>
+      </div>
+      <div id='gallery-controls'>
+        <input type='button' id='gallery-prev' class='gallery-control' (click)='galleryPrev();' value='<'/>
+        <input type='button' id='gallery-next' class='gallery-control' (click)='galleryNext();' value='>'/>
       </div>
     </div>
-  }
+  </div>
+}

+ 22 - 0
frontend/src/app/project/project.scss

@@ -26,6 +26,28 @@ section#details{
     @media only screen and (max-width : 600px) {margin: auto 1em;}
 }
 
+section.children{
+    margin-left: 4em;
+    margin-right: 4em;
+    h3{
+        font-size: 150%;
+        border-bottom: 0.2em solid;
+        margin-left: -2em;
+        margin-right: -2em;
+        
+        @media only screen and (max-width : 800px){
+            margin-left: 0;
+            margin-right: 0;
+            margin-bottom: 0.3em;
+            border-bottom: 0.03em solid;
+            text-align: center;
+        }
+    }
+    section.children-images{
+        text-align: center;
+    }
+}
+
 section#images{text-align: center;}
 
 img.img, video.img{

+ 74 - 47
frontend/src/app/project/project.ts

@@ -9,13 +9,14 @@ import { ProjectModel } from '../model/project';
 import { environment } from '../../environments/environment';
 
 @Component({
-  selector: 'app-project', templateUrl: './project.html', styleUrl: './project.scss'
+    selector: 'app-project', templateUrl: './project.html', styleUrls: ['./project.scss']
 })
 export class Project {
     
     project: ProjectModel | undefined;
     apiURL: string = environment.apiUrl;
     utilService: UtilService;
+    private galleryIndexMap = new Map<string, number>();
     
     private translate = inject(TranslateService)
     
@@ -43,61 +44,94 @@ export class Project {
         this.projectService.getProject("" + id).subscribe(
           data => {
             this.project = data;
-            this.maxIndex = this.project.images.length;
+
+              if (this.project.images.length === 0) {
+                  for (const child of this.project.children) {
+                      const childFirstImage = child.images.shift();
+                      if (childFirstImage) this.project.images.push(childFirstImage);
+                  }
+              }
+
+            this.buildGalleryIndexMap();
             
             // Set meta tags
-            const siteUrl: string
-              = this.platformLocation.protocol + "//" + this.platformLocation.hostname;
+            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 });
-            this.metaService.addTag(
-              { property: 'og:description', content: this.project.description}
-            );
-            this.metaService.addTag(
-              { property: 'description', content: this.project.description}
-            );
-            if (this.project.images.length > 0){
-                this.metaService.addTag(
-                  { property: 'og:image', content: this.apiURL + this.project.images[0].path }
-                );
-            }
-            else{
-                this.metaService.addTag(
-                  { property: 'og:image', content: siteUrl + '/img/logo/leather.png' }
-                );
-            }
+            this.metaService.addTag({ property: 'og:description', content: this.project.description});
+            this.metaService.addTag({ property: 'description', content: this.project.description});
+            if (this.project.images.length > 0)
+                this.metaService.addTag({ property: 'og:image', content: this.apiURL + this.project.images[0].path });
+            else this.metaService.addTag({ property: 'og:image', content: siteUrl + '/img/logo/leather.png' });
             this.translate.get(_('SITE.TITLE')).subscribe((res: string) => {
                 this.titleService.setTitle(this.project?.title + " - " + res);
-                this.metaService.addTag(
-                  { property: 'title', content: this.project?.title + " - " + res }
-                );
-                this.metaService.addTag(
-                  { property: 'og:title', content: this.project?.title + " - " + res }
-                );
+                this.metaService.addTag({ property: 'title', content: this.project?.title + " - " + res });
+                this.metaService.addTag({ property: 'og:title', content: this.project?.title + " - " + res });
             });
         },
         err => { this.router.navigate(['/error']); }
       );
     }
 
+    private buildGalleryIndexMap(): void {
+        this.galleryIndexMap.clear();
+        let index = 1;
+
+        if (!this.project) {
+            this.maxIndex = 0;
+            return;
+        }
+
+        for (const image of this.project.images)
+            this.galleryIndexMap.set(this.getProjectImageKey(image.id), index++);
+
+        for (const child of this.project.children ?? []) {
+            for (const image of child.images ?? [])
+                this.galleryIndexMap.set(this.getChildImageKey(child.id, image.id), index++);
+        }
+
+        this.maxIndex = index - 1;
+    }
+
+    private getProjectImageKey(imageId: number): string {
+        return 'project:' + imageId;
+    }
+
+    private getChildImageKey(childId: number, imageId: number): string {
+        return 'child:' + childId + ':' + imageId;
+    }
+
+    getGalleryIndexForProjectImage(imageId: number): number {
+        return this.galleryIndexMap.get(this.getProjectImageKey(imageId)) ?? -1;
+    }
+
+    getGalleryIndexForChildImage(childId: number, imageId: number): number {
+        return this.galleryIndexMap.get(this.getChildImageKey(childId, imageId)) ?? -1;
+    }
+
     /**
      * Opens the gallery.
      *
      * @param index Index of the photo to display
      */
     galleryOpen(index: any){
+        const parsedIndex = Number(index);
         let cover: HTMLDivElement = <HTMLDivElement> document.getElementById('gallery-cover');
         let gallery: HTMLDivElement = <HTMLDivElement> document.getElementById('gallery');
+        if (Number.isNaN(parsedIndex)) {
+            console.error('Invalid image index: ' + index + '/' + this.maxIndex);
+            return;
+        }
         cover.style.display = 'block';
         cover.style.opacity = '0.6';
         gallery.style.display = 'block';
         gallery.style.opacity = '1';
-        if (index >= 0 && index <= this.maxIndex){
-            this.curIndex = index;
+        if (parsedIndex >= 1 && parsedIndex <= this.maxIndex){
+            this.curIndex = parsedIndex;
             this.gallerySet();
         }
-        else console.error('Invalid image index: ' + index + '/' + this.maxIndex);
+        else console.error('Invalid image index: ' + parsedIndex + '/' + this.maxIndex);
     }
 
     /**
@@ -125,22 +159,21 @@ export class Project {
         fade.style.display = 'block';
         fade.style.opacity = '1';
         await this.delay(200);
-        let titleText = document.getElementById('img-' + this.curIndex)?.getAttribute("alt") || "";
-        if (titleText != null && titleText.length > 0 && titleText != "" + this.project?.title)
-            titleText = ': ' + titleText;
-        else titleText = '';
+        const activeImage = document.getElementById('img-' + this.curIndex);
+        const imageTitle = activeImage?.getAttribute('alt') || '';
+        const childTitle = activeImage?.dataset['childTitle'] || '';
+        let titleText = '';
+        if (childTitle.length > 0) titleText += ' - ' + childTitle;
+        if (imageTitle.length > 0) titleText += ': ' + imageTitle;
         let title: HTMLSpanElement = <HTMLSpanElement> document.getElementById('gallery-title');
-        let text: HTMLParagraphElement
-          = <HTMLParagraphElement> document.getElementById('gallery-text');
+        let text: HTMLParagraphElement = <HTMLParagraphElement> document.getElementById('gallery-text');
         let image: HTMLImageElement = <HTMLImageElement> document.getElementById('gallery-img');
         let video: HTMLVideoElement = <HTMLVideoElement> document.getElementById('gallery-video');
         title.innerHTML = titleText;
         if (document.getElementById('img-' + this.curIndex)?.dataset["video"] == 'true'){
             image.style.display = 'none';
             video.style.display = 'block';
-            video.setAttribute(
-              "src", document.getElementById('img-' + this.curIndex)?.getAttribute("src") || ""
-            );
+            video.setAttribute("src", document.getElementById('img-' + this.curIndex)?.getAttribute("src") || "");
         }
         else{
             video.pause();
@@ -148,11 +181,9 @@ export class Project {
             video.style.display = 'none';
             image.style.display = 'block';
             image.src = document.getElementById('img-' + this.curIndex)?.getAttribute("src") || "";
-            image.srcset
-              = document.getElementById('img-' + this.curIndex)?.getAttribute("srcset") || "";
+            image.srcset = document.getElementById('img-' + this.curIndex)?.getAttribute("srcset") || "";
         }
-        let description: string
-          = document.getElementById('img-' + this.curIndex)?.dataset["description"] || "";
+        let description: string = document.getElementById('img-' + this.curIndex)?.dataset["description"] || "";
         text.innerHTML = description;
         if (description.length > 0) text.style.display = 'block';
         else text.style.display = 'none';
@@ -183,7 +214,6 @@ export class Project {
     private swipeTime?: number;
     
     gallerySwipe(e: TouchEvent, when: string): void {
-        console.log("SWIPE");
         const coord: [number, number] = [e.changedTouches[0].pageX, e.changedTouches[0].pageY];
         const time = new Date().getTime();
         if (when === 'start') {
@@ -193,10 +223,7 @@ export class Project {
         else if (this.swipeCoord && this.swipeTime && when === 'end') {
             const direction = [coord[0] - this.swipeCoord[0], coord[1] - this.swipeCoord[1]];
             const duration = time - this.swipeTime;
-            if (
-              duration < 1000
-              && Math.abs(direction[0]) > 30 && Math.abs(direction[0]) > Math.abs(direction[1] * 3)
-            ){ 
+            if (duration < 1000 && Math.abs(direction[0]) > 30 && Math.abs(direction[0]) > Math.abs(direction[1] * 3)){ 
                 if (direction[0] < 0) this.galleryNext();
                 else this.galleryPrev();
             }

+ 48 - 0
frontend/src/app/service/seo-service.ts

@@ -0,0 +1,48 @@
+import { Injectable, Renderer2, RendererFactory2 } from '@angular/core';
+
+@Injectable({providedIn: 'root'})
+
+export class SeoService {
+  private renderer: Renderer2;
+  
+  private data: Object = {};
+  private modeSet: boolean = false;
+  private modeSingle: boolean = true;
+
+  constructor(private rendererFactory: RendererFactory2) {
+    this.renderer = this.rendererFactory.createRenderer(null, null);
+    this.set("@context", "https://schema.org/");
+  }
+  
+  /**
+   * Sets the service to insert 
+   */
+  setModeSingle(){
+    if (this.modeSet){
+        console.log("The mode is already set to " + (this.modeSingle ? "SINGLE" : "MULTIPLE"));
+    }
+    this.modeSingle = true;
+    this.modeSet = true;
+    Object.defineProperty(this.data, 'mainEntity', { value: {}, writable: true });
+  }
+  
+  setModeMultiple(){
+      if (this.modeSet){
+          console.log("The mode is already set to " + (this.modeSingle ? "SINGLE" : "MULTIPLE"));
+      }
+      this.modeSingle = false;
+      this.modeSet = true;
+      Object.defineProperty(this.data, 'mainEntity', { value: {}, writable: true });
+    }
+  
+  set(key: string, value: string){
+    Object.defineProperty(this.data, key, { value: value, writable: true });
+  }
+
+  render() {
+    const script = this.renderer.createElement('script');
+    script.type = 'application/ld+json';
+    script.text = JSON.stringify(this.data);
+    this.renderer.appendChild(document.head, script);
+  }
+}

Some files were not shown because too many files changed in this diff