Procházet zdrojové kódy

Merge branch 'release/0.1.2'

Iñigo Valentin před 1 měsícem
rodič
revize
bcf55ece19

+ 1 - 0
.gitignore

@@ -1 +1,2 @@
 deploy_to_*
+run

binární
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

+ 1 - 1
backend/package.json

@@ -1,6 +1,6 @@
 {
   "name": "api.leather.inigovalentin.com",
-  "version": "0.1.1",
+  "version": "0.1.2",
   "authorURL": "https://inigovalentin.com",
   "sourceSite": "Github",
   "sourceURL": "https://github.com/InigoValentin/leather.inigovalentin.com",

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

+ 24 - 41
backend/src/routers/assetRouter.js

@@ -1,24 +1,9 @@
 const express = require("express");
-var fs = require('fs');
 var path = require('path');
-const cors = require('cors');
 const router = express.Router();
 
-var mime = {
-    html: 'text/html',
-    txt: 'text/plain',
-    css: 'text/css',
-    gif: 'image/gif',
-    jpg: 'image/jpeg',
-    png: 'image/png',
-    svg: 'image/svg+xml',
-    js: 'application/javascript',
-    mp4: 'video/mp4',
-    ogv: 'video/ogg'
-};
-
-// 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);
@@ -31,20 +16,21 @@ router.get("/images/projects/:projectId/:imagePath", async (req, res) => {
         }
         file = file.replace("/assets/images/", "/assets/images_scaled/x" + width + "/");
     }
-    var type = mime[path.extname(file).slice(1)] || 'text/plain';
-    var s = fs.createReadStream(file);
-    s.on('open', function () {
-        res.setHeader('Content-Type', type);
-        res.setHeader('Accept-Ranges', 'bytes');
-        res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
-        s.pipe(res);
-    });
-    s.on('error', function () {
-        res.setHeader('Content-Type', 'text/plain');
-        res.statusCode = 404;
-        res.end('Not found');
+    res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+    var options = {root: path.join(__dirname, "../../")}
+    res.sendFile(file, options, function(err){
+        if (err){
+            res.setHeader('Content-Type', 'text/plain');
+            res.statusCode = 404;
+            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];
@@ -60,17 +46,14 @@ router.get("/images/profile/:imagePath", async (req, res) => {
         }
         file = file.replace("/assets/images/", "/assets/images_scaled/x" + width + "/");
     }
-    var type = mime[path.extname(file).slice(1)] || 'text/plain';
-    var s = fs.createReadStream(file);
-    s.on('open', function () {
-        res.setHeader('Content-Type', type);
-        res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
-        s.pipe(res);
-    });
-    s.on('error', function () {
-        res.setHeader('Content-Type', 'text/plain');
-        res.statusCode = 404;
-        res.end('Not found');
+    res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+    var options = {root: path.join(__dirname, "../../")}
+    res.sendFile(file, options, function(err){
+        if (err){
+            res.setHeader('Content-Type', 'text/plain');
+            res.statusCode = 404;
+            res.end('Not found');
+        }
     });
 });
 

+ 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
   }
 }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 259 - 199
frontend/package-lock.json


+ 1 - 1
frontend/package.json

@@ -1,6 +1,6 @@
 {
   "name": "leather.inigovalentin.com",
-  "version": "0.1.1",
+  "version": "0.1.2",
   "author": "Iñigo Valentin",
   "authorURL": "https://inigovalentin.com",
   "sourceSite": "Github",

+ 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())

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

@@ -9,12 +9,14 @@ body{
 }
 
 app-root {
-  height: 100vh;
+  min-height: 100vh;
   display: flex;
   flex-direction: column;
   
   main{
     flex-grow: 1;
+    max-width: 80em;
+    margin: 0 auto;
   }
 }
 

+ 5 - 38
frontend/src/app/app.ts

@@ -6,63 +6,30 @@ import { Meta } from '@angular/platform-browser';
 import { author, languages} from '../../package.json';
 import { Header } from './header/header';
 import { Footer } from './footer/footer';
+import { LanguageService } from './service/language-service';
 
 
 @Component({
   selector: 'app-root',
   imports: [RouterOutlet, Header, Footer],
   templateUrl: './app.html',
-  styleUrl: './app.scss',
+  styleUrls: ['./app.scss'],
   encapsulation: ViewEncapsulation.None,
 })
 export class App {
     
     private translate = inject(TranslateService);
-    private cookieService = inject(SsrCookieService);
+    private languageService = inject(LanguageService);
     
     author: string = author;
     private languages: any = languages;
 
-    /**
-     * Selects the best language to serve the page in.
-     * 
-     * It considers available languages, browser languages, query parameters, local storage...
-     * 
-     * @return Two letter language code of the language to show the page in.
-     */
-    private selectLanguage(): string{
-        
-        let available: String[] = this.languages.available.split("|");
-        // If the language is set as a request parameter and its valid, use it.
-        // 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.
-        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: 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();
-        }*/
-        
-        // If everything else failed, return the default language.
-        return this.languages.default;
-    }
-    
     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);        
+        const lang: string = this.languageService.resolveInitialLanguage();
+        this.languageService.applyLanguage(lang);
         // Set meta tags
         this.metaService.addTag({ property: 'author', author });
 

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

@@ -1,10 +1,10 @@
 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';
+import { LanguageService } from '../service/language-service';
 
 @Component({
-    selector: 'app-footer', templateUrl: './footer.html', styleUrl: './footer.scss',
+    selector: 'app-footer', templateUrl: './footer.html', styleUrls: ['./footer.scss'],
     standalone: true, imports: [TranslatePipe]
  })
 export class Footer{
@@ -16,7 +16,7 @@ export class Footer{
     license: string = license;
     currentLanguage: string; 
     
-    private cookieService = inject(SsrCookieService)
+    private languageService = inject(LanguageService);
     
     languages = [
         { code: 'es', name: 'Español' },
@@ -27,13 +27,11 @@ export class Footer{
     private translate = inject(TranslateService)
     
     constructor(){
-        this.currentLanguage = this.translate.getCurrentLang();
+        this.currentLanguage = this.languageService.getRequestLanguage();
     }
     
     switchLanguage(languageCode: string): void {
-        this.currentLanguage = languageCode;
-        this.cookieService.set('language', languageCode);
-        this.translate.use(languageCode);
+        this.currentLanguage = this.languageService.applyLanguage(languageCode);
         setTimeout(location.reload.bind(location), 100);
     }
 

+ 16 - 16
frontend/src/app/home/home.scss

@@ -70,24 +70,34 @@ section#catalog{
         display: inline-block;
         vertical-align: top;
         width: calc(33% - 1.2em);
-        max-width: 18em;
-        aspect-ratio: 1 / 1;
+        max-width: 15em;
+        aspect-ratio: 1;
         overflow: hidden;
         border: 0.1em solid variables.$primary-brown;
         border-radius: 0.3em;
         margin: 0.5em;
         font-size: initial;
         
+        @media only screen and (max-width : 600px) {
+            width: calc(33% - 0.12em);
+            margin: 0;
+            border-radius: 0;
+        }
+        
         img.project-image, img.project-image-alt{
             object-fit: cover;
             object-position: center;
             width: 100%;
             height: 100%;
             scale: 1;
-            transition: scale 0.5s ease-in-out, opacity 1s ease-in-out;
             position: absolute;
             left: 0;
             transition: opacity 0.5s ease-in-out, scale 0.5s ease-in-out;
+            
+            @media only screen and (max-width : 600px) {
+                width: 100%;
+                height: 100%;
+            }
         }
 
         div.project_details{
@@ -110,24 +120,14 @@ section#catalog{
                 bottom: 0em;
                 color: var(--accent-gold);
                 font-size: 150%;
+                line-height: 1;
+                
+                @media only screen and (max-width : 600px){font-size: 120%;}
             }
         }
     }
 }
 
-@media only screen and (max-width : 600px) {
-    section#catalog article{
-        width: calc(33% - 0.12em);
-        margin: 0;
-        border-radius: 0;
-        
-        img{
-            width: 100%;
-            height: 100%;
-        }
-    }
-}
-
 @media (hover: hover) {
     section#catalog article:hover img{scale: 1.2;}
     section#catalog article:hover div.project_details{opacity: 0.8;}

+ 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[];
 }

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

@@ -1,10 +1,10 @@
-import { Component, OnInit, inject } from '@angular/core';
+import { Component, inject } from '@angular/core';
 import { PlatformLocation } from '@angular/common';
 import { Meta, Title } from '@angular/platform-browser';
 import { TranslateService, _, TranslatePipe } from '@ngx-translate/core';
 
 @Component({
-  selector: 'app-profile', templateUrl: './privacy.html', styleUrl: './privacy.scss',
+  selector: 'app-privacy', templateUrl: './privacy.html', styleUrl: './privacy.scss',
   standalone: true, imports: [TranslatePipe]
 })
 export class Privacy {

+ 1 - 1
frontend/src/app/profile/profile.html

@@ -9,7 +9,7 @@
       }
       <div id="profile-text">
         @for (t of profile.texts; track t) {
-          <p>{{ t.content }}</p>
+          <p [innerHTML]="t.content"></p>
         }
       </div>
     </div>

+ 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")
     }
 }

+ 77 - 48
frontend/src/app/project/project.html

@@ -1,71 +1,100 @@
 @if (project){
-  <div id='details'>
+  <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>
-  </div>
-  <div id='images'>
-    @for (image of project.images; track image.id) {
-      @if (image.id > 1){
+  </section>
+  <section id='images'>
+    @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 }}'
           />
         }
       }
     }
-  </div>
-  <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='>'
-          />
+  </section>
+  @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>
+}

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

@@ -5,9 +5,8 @@ main{
     margin: auto;
 }
 
-div#details{
+section#details{
     min-height: 24em;
-    margin: auto 4em 0 4em;
     
     h2{
         font-size: 300%;
@@ -27,7 +26,34 @@ div#details{
     @media only screen and (max-width : 600px) {margin: auto 1em;}
 }
 
-div#images{text-align: center;}
+section.children{
+    margin-left: 4em;
+    margin-right: 4em;
+
+    @media only screen and (max-width : 600px) {margin: auto 1em;}
+
+    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{
     vertical-align: middle;
@@ -39,6 +65,11 @@ img.img, video.img{
     cursor: pointer;
     position: relative;
     display: inline-block;
+    @media only screen and (max-width : 600px) {
+        max-width: calc(50% - 1.5em);
+        margin: 0.2em;
+        border-radius: 0.2em;
+    }
 }
 
 img.video_play{
@@ -82,6 +113,7 @@ div#gallery-cover{
 div#gallery{
     display: none;
     position: fixed;
+    flex-direction: column;
     top: 5%;
     left: 10%;
     right: 10%;
@@ -91,6 +123,7 @@ div#gallery{
     border-radius: 0.4em;
     transition: opacity 1s ease-in-out;
     text-align: center;
+    overflow: hidden;
     z-index: 3;
     
     @media only screen and (max-width : 800px) {
@@ -104,7 +137,7 @@ div#gallery{
         background-color: variables.$background-off-white;
         display: none;
         display: block;
-        height: calc(100% - 4em);
+        height: calc(100% - 3.7em);
         width: 100%;
         position: absolute;
         z-index: 6;
@@ -113,29 +146,40 @@ div#gallery{
     }
 
     h3{
+        flex: 0 0 auto;
         text-align: left;
         font-size: 140%;
         margin: 0.6em;
-        max-height: 4em;
         border-bottom: 0.05em solid variables.$primary-brown;
 
         input[type='button']{
-            float: right;
-            padding: 0.3em 0.6em;
-            margin: -0.3em;
+            position: absolute;
+            right: 0.2em;
+            top: 0.2em;
+            font-size: 130%;
+            @media only screen and (max-width : 800px) {
+                right: 0.15em;
+                top: 0.15em;
+                font-size: 90%;
+            }
         }
+        
     }
     
     div#gallery-content{
-        position: absolute;
-        height: calc(100% - 4em);
+        display: flex;
+        flex-direction: column;
+        flex: 1 1 auto;
+        min-height: 0;
         width: 100%;
     }
     
     div#gallery-flex{
         display: flex;
         flex-direction: row;
-        height: calc(100% - 4em);
+        flex: 1 1 auto;
+        min-height: 0;
+        width: 100%;
         
         @media only screen and (max-width : 800px) {
             flex-direction: column;
@@ -145,20 +189,33 @@ div#gallery{
             flex: 70%;
             padding: 1em;
             text-align: center;
+            min-height: 0;
+            display: flex;
+            align-items: center;
+            justify-content: center;
             
-            @media only screen and (max-width : 800px) {max-height: 60%;}
+            @media only screen and (max-width : 800px) {
+                flex: 1 1 auto;
+                max-height: none;
+                padding: 0.35em 1em 0.2em;
+                align-items: stretch;
+                justify-content: stretch;
+            }
     
             img#gallery-img, video#gallery-video{
                 
-                border: 0.1em solid variables.$primary-brown;
                 border-radius: 0.3em;
-                max-width: 95%;
+                max-width: 100%;
                 max-height: 100%;
-                margin: auto;
+                margin: 0;
+                display: block;
                 
                 @media only screen and (max-width : 800px) {
-                    max-width: 95%;
-                    margin: 0.2em auto;
+                    width: 100%;
+                    height: 100%;
+                    max-width: none;
+                    max-height: none;
+                    object-fit: contain;
                 }
             }
         }
@@ -174,26 +231,29 @@ div#gallery{
             overflow-y: scroll;
             
             @media only screen and (max-width : 800px) {
-                margin: auto 2em;
+                margin: 0.2em 1em 0;
+                flex: 0 0 auto;
+                max-height: none;
+                overflow-y: visible;
             }
         }
     }
     div#gallery-controls{
+        flex: 0 0 auto;
         text-align: center;
         margin: 0;
+        padding-bottom: 0.2em;
         
         @media only screen and (max-width : 800px) {
-            position: absolute;
-            bottom: 0;
             width: 100%;
-            @media only screen and (max-width : 800px){display: flex}
+            display: flex;
         }
         
         input[type='button']{
             display: inline-block;
             padding: 1em 1.5em;
             margin: 0.3em 1em;
-            @media only screen and (max-width : 800px){flex: 50%}
+            @media only screen and (max-width : 800px){flex: 1 1 50%}
         }
     }
 }

+ 75 - 48
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[0];
+                      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.display = 'flex';
         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();
             }

+ 56 - 0
frontend/src/app/service/language-service.ts

@@ -0,0 +1,56 @@
+import { Injectable, inject } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { SsrCookieService } from 'ngx-cookie-service-ssr';
+import { languages } from '../../../package.json';
+
+@Injectable({ providedIn: 'root' })
+export class LanguageService {
+  private translate = inject(TranslateService);
+  private cookieService = inject(SsrCookieService);
+
+  private availableLanguages: string[] = languages.available.split('|');
+  private defaultLanguage: string = languages.default;
+
+  private isAvailableLanguage(lang: string | null | undefined): lang is string {
+    return !!lang && this.availableLanguages.indexOf(lang) !== -1;
+  }
+
+  resolveInitialLanguage(): string {
+    const cookieLanguage = this.cookieService.get('language');
+    if (this.isAvailableLanguage(cookieLanguage)) {
+      return cookieLanguage;
+    }
+
+    return this.defaultLanguage;
+  }
+
+  getRequestLanguage(): string {
+    const cookieLanguage = this.cookieService.get('language');
+    if (this.isAvailableLanguage(cookieLanguage)) {
+      return cookieLanguage;
+    }
+
+    const currentLanguage = this.translate.getCurrentLang();
+    if (this.isAvailableLanguage(currentLanguage)) {
+      return currentLanguage;
+    }
+
+    const fallbackLanguage = this.translate.getFallbackLang();
+    if (this.isAvailableLanguage(fallbackLanguage)) {
+      return fallbackLanguage;
+    }
+
+    return this.defaultLanguage;
+  }
+
+  applyLanguage(language: string): string {
+    const selectedLanguage = this.isAvailableLanguage(language)
+      ? language
+      : this.defaultLanguage;
+
+    this.cookieService.set('language', selectedLanguage);
+    this.translate.use(selectedLanguage);
+
+    return selectedLanguage;
+  }
+}

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

@@ -1,20 +1,21 @@
 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';
 import { environment } from '../../environments/environment';
+import { LanguageService } from './language-service';
 
 @Injectable({ providedIn: 'root' })
 
 export class ProfileService {
   private apiUrl = environment.apiUrl + '/profile';
-  private translate = inject(TranslateService);
+  private languageService = inject(LanguageService);
   constructor(private http: HttpClient) { }
 
   getProfile(images: any, texts: string): Observable<ProfileModel[]> {
+    const lang = this.languageService.getRequestLanguage();
     return this.http.get<ProfileModel[]>(
-      this.apiUrl + "?lang=" + this.translate.getCurrentLang()
+      this.apiUrl + "?lang=" + lang
       + "&images=" + images + "&texts=" + texts
     );
   }

+ 14 - 9
frontend/src/app/service/project-service.ts

@@ -1,35 +1,40 @@
 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';
+import { LanguageService } from './language-service';
 
 @Injectable({ providedIn: 'root' })
 
 export class ProjectService {
   private apiUrl = environment.apiUrl + '/projects';
   constructor(private http: HttpClient) { }
-  
-  private translate = inject(TranslateService);
+  private languageService = inject(LanguageService);
 
   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=" + this.translate.getCurrentLang());
+    const lang = this.languageService.getRequestLanguage();
+    let url = this.apiUrl + "?lang=" + lang;
+
+    if (images === true || parseInt(images) >= 0) {
+      url += "&images=" + images;
+    }
+
+    return this.http.get<ProjectModel[]>(url);
   }
 
   getProject(id: string): Observable<ProjectModel> {
+    const lang = this.languageService.getRequestLanguage();
     return this.http.get<ProjectModel>(
-      `${this.apiUrl}/${id}` + "?lang=" + this.translate.getCurrentLang()
+      `${this.apiUrl}/${id}` + "?lang=" + lang
     );
   }
   
   getProjectRandomImage(id: string): Observable<ProjectImageModel> {
+    const lang = this.languageService.getRequestLanguage();
     return this.http.get<ProjectImageModel>(
-      `${this.apiUrl}/${id}/images/random` + "?lang=" + this.translate.getCurrentLang()
+      `${this.apiUrl}/${id}/images/random` + "?lang=" + lang
     );
   }
   

+ 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);
+  }
+}

+ 6 - 0
package-lock.json

@@ -0,0 +1,6 @@
+{
+  "name": "leather-web",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {}
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů