Iñigo Valentin 10 сар өмнө
parent
commit
72df818c68
41 өөрчлөгдсөн 1062 нэмэгдсэн , 227 устгасан
  1. 1 0
      .gitignore
  2. 4 1
      backend/.gitignore
  3. 24 0
      backend/assets/generate_srcsets.sh
  4. 3 1
      backend/environment/env.example
  5. 2 2
      backend/src/data/data.js
  6. 19 0
      backend/src/data/profileData.js
  7. 38 29
      backend/src/data/projectData.js
  8. 39 0
      backend/src/routers/assetRouter.js
  9. 25 0
      backend/src/routers/profileRouter.js
  10. 56 11
      backend/src/routers/projectRouter.js
  11. 2 0
      backend/src/routers/router.js
  12. 4 2
      frontend/angular.json
  13. 97 4
      frontend/package-lock.json
  14. 5 2
      frontend/package.json
  15. 10 0
      frontend/public/i18n/en.json
  16. 10 0
      frontend/public/i18n/es.json
  17. 10 0
      frontend/public/i18n/eu.json
  18. 85 0
      frontend/public/img/lang/en.svg
  19. 60 0
      frontend/public/img/lang/es.svg
  20. 86 0
      frontend/public/img/lang/eu.svg
  21. 10 3
      frontend/src/app/app.config.ts
  22. 11 3
      frontend/src/app/app.ts
  23. 16 12
      frontend/src/app/footer/footer.html
  24. 30 0
      frontend/src/app/footer/footer.scss
  25. 27 2
      frontend/src/app/footer/footer.ts
  26. 9 3
      frontend/src/app/header/header.scss
  27. 11 0
      frontend/src/app/home/home.html
  28. 62 24
      frontend/src/app/home/home.scss
  29. 55 29
      frontend/src/app/home/home.ts
  30. 8 0
      frontend/src/app/model/profile.ts
  31. 8 8
      frontend/src/app/model/project-image.ts
  32. 43 20
      frontend/src/app/project/project.html
  33. 72 39
      frontend/src/app/project/project.scss
  34. 63 20
      frontend/src/app/project/project.ts
  35. 16 0
      frontend/src/app/service/profile-service.ts
  36. 14 5
      frontend/src/app/service/project-service.ts
  37. 22 0
      frontend/src/app/service/util-service.ts
  38. 2 0
      frontend/src/main.ts
  39. 3 3
      frontend/tsconfig.app.json
  40. 0 2
      frontend/tsconfig.json
  41. 0 2
      frontend/tsconfig.spec.json

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+deploy_to_*

+ 4 - 1
backend/.gitignore

@@ -1,11 +1,14 @@
 # Database
-data.sqlite3
+data.sqlite3*
+data.sql
 
 # Environment files
 environment/env.*
 
 # Assets
 assets/images/projects/
+assets/images/profile/
+assets/images_scaled/
 
 # Node.js
 node_modules/

+ 24 - 0
backend/assets/generate_srcsets.sh

@@ -0,0 +1,24 @@
+#!/bin/bash
+dimensions=( 100 200 300 400 500 600 700 800 900 1000 );
+cd $(dirname "$0")
+pwd;
+rm -rf images_scaled;
+mkdir -p images_scaled;
+
+for dimension in "${dimensions[@]}";
+do
+	echo Generating x$dimension...;
+	mkdir -p images_scaled/x${dimension};
+	cp -rf images/* images_scaled/x$dimension/;
+	cd images_scaled/x$dimension/;
+	for file in $(find  -type f);
+	do
+		type=$(file -b --mime-type $file);
+		if [ "image" == "${type:0:5}" ];
+		then
+			#echo "IMAGE: ${file}";
+			convert $file -resize $dimension"x"${dimension}\> ${file}
+		fi
+	done;
+	cd ../../
+done;

+ 3 - 1
backend/environment/env.example

@@ -1,4 +1,6 @@
 HOST=localhost
 PORT=3000
 AUTH=c3499c2729730a7f807efb8676a92dcb6f8a3f8f
-DB=./data.sqlite3
+DB=./data.sqlite3
+DEFAULT_LANGUAGE=es
+AVAILABLE_LANGUAGES=es en eu

+ 2 - 2
backend/src/data/data.js

@@ -1,12 +1,12 @@
 const sqlite3 = require('sqlite3');
 
 let data = new sqlite3.Database(process.env.DB , (err) => {
-    if (err) console.log("Error Occurred - " + err.message);
+    if (err) console.log("Error connecting to database " + process.env.DB + err.message);
     else console.log("Conected to database " + process.env.DB);
 });
 
 data.langQuery = function(param, lang = null){
-    if (lang == null) lang = 'en';
+    if (lang == null) lang = process.env.DEFAULT_LANGUAGE;
     if (param != null)
         return(
           "(SELECT text FROM text WHERE id = " + param + " AND (lang IS NULL OR lang = '"

+ 19 - 0
backend/src/data/profileData.js

@@ -0,0 +1,19 @@
+const db = require("./data.js");
+
+let profileData = [];
+
+profileData.getProfile = function(lang = null) {
+    return new Promise((resolve, reject) => {
+        db.get(
+          "SELECT first_name, last_name, image, " + db.langQuery("tagline", lang)
+          + ", " + db.langQuery("bio", lang) + ", " + db.langQuery("description", lang)
+          + " FROM profile LIMIT 1",
+          async (err, row) => {
+            if(err) reject(err);
+            else resolve(row);
+          }
+        );
+    });
+}
+
+module.exports = profileData;

+ 38 - 29
backend/src/data/projectData.js

@@ -2,12 +2,13 @@ const db = require("./data.js");
 
 let projectData = [];
 
-projectData.retrieveProjectImages = async function(id){
+projectData.retrieveProjectImages = async function(id, lang, max){
     return new Promise((resolve, reject) => {
         db.all(
           "SELECT id, project, home, path, video, "
-          + db.langQuery("title") + ", " + db.langQuery("description")
-          + ", priority FROM project_image WHERE visible=1 AND project=? ORDER BY priority",
+          + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
+          + ", priority FROM project_image WHERE visible=1 AND project=? ORDER BY priority "
+          + (parseInt(max) > 0 ? " LIMIT " + parseInt(max) : ""),
           parseInt(id),
           (err, imgs) => {
             if (err) reject(err);
@@ -17,75 +18,83 @@ projectData.retrieveProjectImages = async function(id){
     });
 }
 
-projectData.getProjects = function() {
+projectData.getProjects = function(lang = null, images = null) {
     return new Promise((resolve, reject) => {
         projects = new Array();
         db.all(
-          "SELECT id, permalink, " + db.langQuery("title") + ", " + db.langQuery("description")
-          + ", priority, null AS images FROM project WHERE visible=1 ORDER BY priority",
+          "SELECT id, permalink, "
+          + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
+          + ", priority FROM project WHERE visible=1 ORDER BY priority",
           async (err, rows) => {
             if(err) reject(err);
             else{
-                for (var i = 0; i < rows.length; i ++){
-                    rows[i].images = new Array();
-                    rows[i].images = await this.retrieveProjectImages(rows[i].id);
-                    projects.push(rows[i]);
+                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);
                 }
-                resolve(projects);
             }
           }
         );
     });
 }
 
-projectData.getProject = function(id) {
+projectData.getProject = function(id, lang = null, images = null) {
     return new Promise((resolve, reject) => {
         db.get(
-          "SELECT id, permalink, " + db.langQuery("title") + ", " + db.langQuery("description")
+          "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{
-                row.images = new Array();
-                row.images.push.apply(row.images, await this.retrieveProjectImages(row.id));
-                resolve(row);
+                if (images == false || parseInt(images) <= 0) resolve(row);
+                else{
+                    row.images = await this.retrieveProjectImages(row.id, lang, images);
+                    resolve(row);
+                }
             }
           }
         );
     });
 }
 
-projectData.getProjectImages = function(id) {
+projectData.getProjectImages = function(id, lang = null, max = null) {
     return new Promise((resolve, reject) => {
-        console.log("GETTING ALL IMAGES FRO " + id);
         db.all(
           "SELECT id, project, home, path, video, "
-          + db.langQuery("title") + ", " + db.langQuery("description")
+          + 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",
+          + "ORDER BY priority " + (parseInt(max) > 0 ? " LIMIT " + parseInt(max) : ""),
           parseInt(id), String(id),
           (err, imgs) => {
             if (err) reject(err);
-            else{
-                console.log(" TOTAL : " + imgs.length);
-                resolve(imgs);
-                }
+            else resolve(imgs);
           }
         );
     });
 }
 
-projectData.getProjectImage = function(projectId, imageId) {
+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") + ", " + db.langQuery("description")
+              + 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 (id=? OR permalink=?)) "
-              + "ORDER BY RANDOM() LIMIT 1",
+              + "(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),
               async (err, row) => {
                 if(err) reject(err);
@@ -96,7 +105,7 @@ projectData.getProjectImage = function(projectId, imageId) {
         else{
             db.get(
               "SELECT id, project, home, path, video, "
-              + db.langQuery("title") + ", " + db.langQuery("description")
+              + db.langQuery("title", lang) + ", " + db.langQuery("description", lang)
               + ", priority FROM project_image WHERE id=? AND home=1 AND visible=1 AND project IN "
               + "(SELECT id FROM project WHERE visible=1 AND (id=? OR permalink=?))",
               parseInt(projectId), String(projectId),

+ 39 - 0
backend/src/routers/assetRouter.js

@@ -19,6 +19,45 @@ var mime = {
 router.get("/images/projects/:projectId/:imagePath", async (req, res) => {
     var reqpath = req.url.toString().split('?')[0];
     var file = "./assets" + reqpath.replace(/\/$/, '');
+    var scale = parseInt(req.query.w);
+    // Serve a scaled image if width has been specified.
+    if (parseInt(req.query.w) >= 0){
+        var width = 100;
+        for (var i = 100; i < 1000; i += 100){
+            width = i;
+            if (width >= scale) break;
+        }
+        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');
+    });
+});
+
+router.get("/images/profile/:imagePath", async (req, res) => {
+    var reqpath = req.url.toString().split('?')[0];
+    var file = "./assets" + reqpath.replace(/\/$/, '');
+    console.log("GET PROFILE IMAGE: " + file);
+
+    var scale = parseInt(req.query.w);
+    // Serve a scaled image if width has been specified.
+    if (parseInt(req.query.w) >= 0){
+        var width = 100;
+        for (var i = 100; i < 1000; i += 100){
+            width = i;
+            if (width >= scale) break;
+        }
+        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 () {

+ 25 - 0
backend/src/routers/profileRouter.js

@@ -0,0 +1,25 @@
+const express = require("express");
+const cors = require('cors');
+const profileData = require("../data/profileData.js");
+const router = express.Router();
+
+/**
+ * Read (GET) the user profile.
+ * 
+ * Acccpeted request parameters:
+ * 
+ *  - lang: Two letter language code, case insensitive. If the data exists in
+ *          the requested language it will be served, otherwise the default
+ *          language will be used.
+ */
+router.get("/", cors({origin: '*', methods: 'GET'}), async (req, res) => {
+    var lang = process.env.DEFAULT_LANGUAGE;
+    if (
+      req.query.lang
+      && process.env.AVAILABLE_LANGUAGES.split(" ").includes(req.query.lang.toLowerCase())
+    ) lang = req.query.lang.toLowerCase();
+    const data = await profileData.getProfile(lang);
+    res.json(data);
+});
+
+module.exports = router;

+ 56 - 11
backend/src/routers/projectRouter.js

@@ -3,31 +3,76 @@ const cors = require('cors');
 const projectData = require("../data/projectData.js");
 const router = express.Router();
 
-// Read (GET) all projects
+/**
+ * Read (GET) all projects, sorted by priority.
+ * 
+ * Acccpeted request parameters:
+ * 
+ *  - lang: Two letter language code, case insensitive.. If the data exists in
+ *          the requested language it will be served, otherwise the default
+ *          language will be used.
+ * 
+ *  - images: True, false, or a positive integer. If true, projects will
+ *            include all of their images, sorted by priority. If false, none
+ *            will be included. If a number is provided, up to that many images
+ *            will be provided. Default value is true.  
+ */
 router.get("/", cors({origin: '*', methods: 'GET'}), async (req, res) => {
-    const data = await projectData.getProjects();
+    var images = true;
+    if (req.query.images == "false") images = false;
+    else if (parseInt(req.query.images) != NaN && parseInt(req.query.images) >= 0)
+        images = parseInt(req.query.images);
+    var lang = process.env.DEFAULT_LANGUAGE;
+    if (
+      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);
     res.json(data);
 });
 
 // Read (GET) a specific project by ID or permalink
 router.get("/:id", cors({origin: '*', methods: 'GET'}), async (req, res) => {
-  const data = await projectData.getProject(req.params.id);
-  if (!data) res.status(404).json({ error: "Project not found" });
-  else res.json(data);
+    var images = true;
+    if (req.query.images == "false") images = false;
+    else if (parseInt(req.query.images) != NaN && parseInt(req.query.images) >= 0)
+        images = parseInt(req.query.images);
+    var lang = process.env.DEFAULT_LANGUAGE;
+    if (
+      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);
+    if (!data) res.status(404).json({ error: "Project not found" });
+    else res.json(data);
 });
 
 // Read (GET) a specific project images by ID or permalink
 router.get("/:id/images", cors({origin: '*', methods: 'GET'}), async (req, res) => {
-  const data = await projectData.getProjectImages(req.params.id);
-  if (!data) res.status(404).json({ error: "Project not found" });
-  else res.json(data);
+    var lang = process.env.DEFAULT_LANGUAGE;
+    if (
+      req.query.lang
+      && process.env.AVAILABLE_LANGUAGES.split(" ").includes(req.query.lang.toLowerCase())
+    ) lang = req.query.lang.toLowerCase();
+    var max = null;
+    if (parseInt(req.query.max) != NaN && parseInt(req.query.max) >= 0)
+        max = parseInt(req.query.max);
+    const data = await projectData.getProjectImages(req.params.id, lang, max);
+    if (!data) res.status(404).json({ error: "Project not found" });
+    else res.json(data);
 });
 
 // Read (GET) a specific project images by ID or permalink
 router.get("/:projectId/images/:imageId", cors({origin: '*', methods: 'GET'}), async (req, res) => {
-  const data = await projectData.getProjectImage(req.params.projectId, req.params.imageId);
-  if (!data) res.status(404).json({ error: "Image not found" });
-  else res.json(data);
+    var lang = process.env.DEFAULT_LANGUAGE;
+    if (
+      req.query.lang
+      && process.env.AVAILABLE_LANGUAGES.split(" ").includes(req.query.lang.toLowerCase())
+    ) lang = req.query.lang.toLowerCase();
+    const data
+      = await projectData.getProjectImage(req.params.projectId, req.params.imageId, lang);
+    if (!data) res.status(404).json({ error: "Image not found" });
+    else res.json(data);
 });
 
 module.exports = router;

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

@@ -1,9 +1,11 @@
 const express = require('express');
 const projectRouter = require("./projectRouter.js");
+const profileRouter = require("./profileRouter.js");
 const assetRouter = require("./assetRouter.js");
 
 const router = express.Router();
 router.use("/projects", projectRouter);
+router.use("/profile", profileRouter);
 router.use("/assets", assetRouter);
 
 module.exports = router;

+ 4 - 2
frontend/angular.json

@@ -19,7 +19,8 @@
           "options": {
             "browser": "src/main.ts",
             "polyfills": [
-              "zone.js"
+              "zone.js",
+              "@angular/localize/init"
             ],
             "tsConfig": "tsconfig.app.json",
             "inlineStyleLanguage": "scss",
@@ -86,7 +87,8 @@
           "options": {
             "polyfills": [
               "zone.js",
-              "zone.js/testing"
+              "zone.js/testing",
+              "@angular/localize/init"
             ],
             "tsConfig": "tsconfig.spec.json",
             "inlineStyleLanguage": "scss",

+ 97 - 4
frontend/package-lock.json

@@ -1,12 +1,13 @@
 {
-  "name": "frontend",
-  "version": "0.0.0",
+  "name": "leather-frontend",
+  "version": "0.0.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "frontend",
-      "version": "0.0.0",
+      "name": "leather-frontend",
+      "version": "0.0.1",
+      "license": "GPLv3",
       "dependencies": {
         "@angular/common": "^20.3.0",
         "@angular/compiler": "^20.3.0",
@@ -14,6 +15,8 @@
         "@angular/forms": "^20.3.0",
         "@angular/platform-browser": "^20.3.0",
         "@angular/router": "^20.3.0",
+        "@ngx-translate/core": "^17.0.0",
+        "@ngx-translate/http-loader": "^17.0.0",
         "rxjs": "~7.8.0",
         "tslib": "^2.3.0",
         "zone.js": "~0.15.0"
@@ -22,6 +25,7 @@
         "@angular/build": "^20.3.5",
         "@angular/cli": "^20.3.5",
         "@angular/compiler-cli": "^20.3.0",
+        "@angular/localize": "^20.3.4",
         "@types/jasmine": "~5.1.0",
         "jasmine-core": "~5.9.0",
         "karma": "~6.4.0",
@@ -531,6 +535,30 @@
         "rxjs": "^6.5.3 || ^7.4.0"
       }
     },
+    "node_modules/@angular/localize": {
+      "version": "20.3.4",
+      "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-20.3.4.tgz",
+      "integrity": "sha512-OaHS0qOFdngKX4T4CEi7LYaISY8k2fPbPHoB+Q0MzqqmJpG+OEu8prOB4jMsZKTe5QpZey7opOJQ6nmL+Maa1Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "7.28.3",
+        "@types/babel__core": "7.20.5",
+        "tinyglobby": "^0.2.12",
+        "yargs": "^18.0.0"
+      },
+      "bin": {
+        "localize-extract": "tools/bundles/src/extract/cli.js",
+        "localize-migrate": "tools/bundles/src/migrate/cli.js",
+        "localize-translate": "tools/bundles/src/translate/cli.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+      },
+      "peerDependencies": {
+        "@angular/compiler": "20.3.4",
+        "@angular/compiler-cli": "20.3.4"
+      }
+    },
     "node_modules/@angular/platform-browser": {
       "version": "20.3.4",
       "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.4.tgz",
@@ -2269,6 +2297,30 @@
         "node": ">= 10"
       }
     },
+    "node_modules/@ngx-translate/core": {
+      "version": "17.0.0",
+      "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz",
+      "integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==",
+      "dependencies": {
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "@angular/common": ">=16",
+        "@angular/core": ">=16"
+      }
+    },
+    "node_modules/@ngx-translate/http-loader": {
+      "version": "17.0.0",
+      "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-17.0.0.tgz",
+      "integrity": "sha512-hgS8sa0ARjH9ll3PhkLTufeVXNI2DNR2uFKDhBgq13siUXzzVr/a31M6zgecrtwbA34iaBV01hsTMbMS8V7iIw==",
+      "dependencies": {
+        "tslib": "^2.3.0"
+      },
+      "peerDependencies": {
+        "@angular/common": ">=16",
+        "@angular/core": ">=16"
+      }
+    },
     "node_modules/@npmcli/agent": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz",
@@ -3297,6 +3349,47 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/@types/babel__core": {
+      "version": "7.20.5",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+      "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.27.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+      "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+      "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.28.2"
+      }
+    },
     "node_modules/@types/cors": {
       "version": "2.8.19",
       "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",

+ 5 - 2
frontend/package.json

@@ -1,6 +1,6 @@
 {
   "name": "leather-frontend",
-  "version": "0.0.1",
+  "version": "0.0.4",
   "author": "Iñigo Valentin",
   "authorURL": "https://inigovalentin.com",
   "sourceSite": "Github",
@@ -33,6 +33,8 @@
     "@angular/forms": "^20.3.0",
     "@angular/platform-browser": "^20.3.0",
     "@angular/router": "^20.3.0",
+    "@ngx-translate/core": "^17.0.0",
+    "@ngx-translate/http-loader": "^17.0.0",
     "rxjs": "~7.8.0",
     "tslib": "^2.3.0",
     "zone.js": "~0.15.0"
@@ -41,6 +43,7 @@
     "@angular/build": "^20.3.5",
     "@angular/cli": "^20.3.5",
     "@angular/compiler-cli": "^20.3.0",
+    "@angular/localize": "^20.3.4",
     "@types/jasmine": "~5.1.0",
     "jasmine-core": "~5.9.0",
     "karma": "~6.4.0",
@@ -50,4 +53,4 @@
     "karma-jasmine-html-reporter": "~2.1.0",
     "typescript": "~5.9.2"
   }
-}
+}

+ 10 - 0
frontend/public/i18n/en.json

@@ -0,0 +1,10 @@
+{
+  "SITE": {
+    "TITLE": "Iñigo Valentin: Leather Work",
+    "DESCRIPTION": "This is a multi-language Angular application."
+  },
+  "FOOTER": {
+    "DEVELOPED": "Version {{ version }} Developed by <a href='{{ authorURL }}'>{{ author }}</a>",
+    "SOURCE": "Source code available on <a href='{{ sourceURL }}'>{{ sourceSite }}</a> under the {{ license }}"
+  }
+}

+ 10 - 0
frontend/public/i18n/es.json

@@ -0,0 +1,10 @@
+{
+  "SITE": {
+    "TITLE": "Iñigo Valentin: Trabajos en cuero",
+    "DESCRIPTION": "This is a multi-language Angular application."
+  },
+  "FOOTER": {
+    "DEVELOPED": "Versión {{ version }}. Desarrollado por <a href='{{ authorURL }}'>{{ author }}</a>",
+    "SOURCE": "Código fuende disponible en <a href='{{ sourceURL }}'>{{ sourceSite }}</a> bajo licencia {{ license }}"
+  }
+}

+ 10 - 0
frontend/public/i18n/eu.json

@@ -0,0 +1,10 @@
+{
+  "SITE": {
+    "TITLE": "Iñigo Valentin: Trabajos en cuero",
+    "DESCRIPTION": "This is a multi-language Angular application."
+  },
+  "FOOTER": {
+    "DEVELOPED": "Version {{ version }} Developed by <a href='{{ authorURL }}'>{{ author }}</a>",
+    "SOURCE": "Source code available on <a href='{{ sourceURL }}'>{{ sourceSite }}</a> under the {{ license }}"
+  }
+}

+ 85 - 0
frontend/public/img/lang/en.svg

@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   viewBox="0 0 750 500"
+   version="1.1"
+   id="svg15"
+   sodipodi:docname="en.svg"
+   width="750"
+   height="500"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata21">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs19" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1680"
+     inkscape:window-height="964"
+     id="namedview17"
+     showgrid="false"
+     inkscape:zoom="2.192031"
+     inkscape:cx="316.78481"
+     inkscape:cy="7.8655457"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg15" />
+  <clipPath
+     id="t">
+    <path
+       d="m 30,15 h 30 v 15 z m 0,0 V 30 H 0 Z m 0,0 H 0 V 0 Z m 0,0 V 0 h 30 z"
+       id="path2"
+       inkscape:connector-curvature="0" />
+  </clipPath>
+  <path
+     d="M -1.5363247,-3.0395545 V 501.64287 H 754.29643 V -3.0395545 Z"
+     id="path5"
+     inkscape:connector-curvature="0"
+     style="fill:#00247d;stroke-width:14.55746269" />
+  <path
+     d="M -1.5363247,-3.0395545 754.29643,501.64287 m 0,-504.6824245 L -1.5363247,501.64287"
+     id="path7"
+     inkscape:connector-curvature="0"
+     style="stroke:#ffffff;stroke-width:87.3447876" />
+  <path
+     d="M 0,0 60,30 M 60,0 0,30"
+     clip-path="url(#t)"
+     id="path9"
+     inkscape:connector-curvature="0"
+     style="stroke:#cf142b;stroke-width:4"
+     transform="matrix(12.597213,0,0,16.822748,-1.5363247,-3.0395545)" />
+  <path
+     d="M 376.38005,-3.0395545 V 501.64287 M -1.5363247,249.30166 H 754.29643"
+     id="path11"
+     inkscape:connector-curvature="0"
+     style="stroke:#ffffff;stroke-width:145.574646" />
+  <path
+     d="M 376.38005,-3.0395545 V 501.64287 M -1.5363247,249.30166 H 754.29643"
+     id="path13"
+     inkscape:connector-curvature="0"
+     style="stroke:#cf142b;stroke-width:87.3447876" />
+</svg>

+ 60 - 0
frontend/public/img/lang/es.svg

@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   height="500"
+   width="750"
+   version="1.1"
+   id="svg1160"
+   sodipodi:docname="es.svg"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata1166">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs1164" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1680"
+     inkscape:window-height="964"
+     id="namedview1162"
+     showgrid="false"
+     inkscape:zoom="0.6675088"
+     inkscape:cx="350.79908"
+     inkscape:cy="526.30887"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg1160" />
+  <path
+     d="M 0,0 H 750 V 500 H 0 Z"
+     id="path2"
+     inkscape:connector-curvature="0"
+     style="fill:#c60b1e" />
+  <path
+     d="M 0,125 H 750 V 375 H 0 Z"
+     id="path4"
+     inkscape:connector-curvature="0"
+     style="fill:#ffc400" />
+</svg>

+ 86 - 0
frontend/public/img/lang/eu.svg

@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="750mm"
+   height="500mm"
+   viewBox="0 300 300.00001 200"
+   version="1.1"
+   id="svg8"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)"
+   sodipodi:docname="eu.svg">
+  <defs
+     id="defs2" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="0.24748737"
+     inkscape:cx="1732.787"
+     inkscape:cy="869.60957"
+     inkscape:document-units="mm"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     inkscape:window-width="1680"
+     inkscape:window-height="964"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     scale-x="0.4"
+     viewbox-y="300" />
+  <metadata
+     id="metadata5">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Capa 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,203)">
+    <rect
+       style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:0.18539347;stroke-linecap:butt;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       id="rect12"
+       width="330.13379"
+       height="211.98421"
+       x="-12.973061"
+       y="89.058723" />
+    <path
+       style="fill:#ffffff;stroke:#00c500;stroke-width:18.53934669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M -3.7388884,298.44664 304.7922,94.445605"
+       id="path826"
+       inkscape:connector-curvature="0" />
+    <path
+       style="fill:#008000;stroke:#00c500;stroke-width:18.53934669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M -12.97306,89.058724 320.40004,312.39179"
+       id="path843"
+       inkscape:connector-curvature="0" />
+    <path
+       style="fill:none;stroke:#ffffff;stroke-width:18.53934669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M -1.3002199,196.97106 H 299.6469"
+       id="path845"
+       inkscape:connector-curvature="0" />
+    <path
+       style="fill:none;stroke:#ffffff;stroke-width:18.53934669;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+       d="M 148.84649,96.625909 V 298.01091"
+       id="path877"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>

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

@@ -1,14 +1,21 @@
-import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
+import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
+import { provideTranslateService, provideTranslateLoader } from '@ngx-translate/core';
+import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
 import { provideRouter } from '@angular/router';
-import { provideHttpClient } from '@angular/common/http';
+import { HttpClient, provideHttpClient } from '@angular/common/http';
 
 import { routes } from './app.routes';
 
+
 export const appConfig: ApplicationConfig = {
   providers: [
     provideBrowserGlobalErrorListeners(),
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideRouter(routes),
-    provideHttpClient()
+    provideHttpClient(),
+    provideTranslateService({
+      loader: provideTranslateHttpLoader({prefix: './i18n/', suffix: '.json' }),
+      fallbackLang: 'en', lang: 'en'
+    })
   ]
 };

+ 11 - 3
frontend/src/app/app.ts

@@ -1,6 +1,6 @@
-import { Component, signal } from '@angular/core';
+import { Component, ViewEncapsulation, inject } from '@angular/core';
 import { RouterOutlet } from '@angular/router';
-import { ViewEncapsulation } from '@angular/core';
+import { TranslateService, TranslatePipe, TranslateDirective } from '@ngx-translate/core';
 import { Header } from './header/header';
 import { Footer } from './footer/footer';
 
@@ -12,5 +12,13 @@ import { Footer } from './footer/footer';
   encapsulation: ViewEncapsulation.None,
 })
 export class App {
-  protected readonly title = signal('frontend');
+    
+    private translate = inject(TranslateService);
+    
+    constructor(){
+        this.translate.addLangs(['en', 'eu']);
+        this.translate.setFallbackLang('es');
+        // TODO: Get language from browset if not in storage
+        this.translate.use(localStorage.getItem('language') || this.translate.getFallbackLang() || 'es');
+    }
 }

+ 16 - 12
frontend/src/app/footer/footer.html

@@ -1,15 +1,19 @@
 <footer>
-  <table id='footer_table'>
-    <tr>
-      <td id='footer_left'>
-        Version {{ version }} Developed by <a href='{{ authorURL }}'>{{ author }}</a>.
-        <br/><br/>
-        Source code available on <a href='{{ sourceURL }}'>{{ sourceSite }}</a>
-        under the {{ license }}.
-      </td>
-      <td id='footer_right'>
-      </td>
-    </tr>
-  </table>
+  <div id='footer-left'>
+    <span innerHTML="{{ 'FOOTER.DEVELOPED' | translate:{version, authorURL, author} }}"></span>
+    <span innerHTML="{{ 'FOOTER.SOURCE' | translate:{sourceURL, sourceSite, license} }}"></span>
+  </div>
+  <div id='footer-right'>
+    <div class="language-switcher">
+      @for (language of languages; track language){
+        <img
+          class="lang"
+          [class.active]="language.code === currentLanguage"
+          (click)="switchLanguage(language.code)"
+          alt="{{ language.name }}" src="/img/lang/{{language.code}}.svg"
+        >
+      }
+    </div>
+  </div>
 </footer>
 

+ 30 - 0
frontend/src/app/footer/footer.scss

@@ -2,7 +2,37 @@
 
 footer{
     margin: 2em 0 0 0;
+    text-align: center;
+    padding: 0;
     border-top: 0.2em solid variables.$darker-brown;
     background-color: variables.$primary-brown;
     color: variables.$background-off-white;
+    
+    div#footer-left, div#footer-right{
+        display: inline-block;
+        vertical-align: middle;
+        text-align: center;
+        width: calc(44% - 3em);
+        margin: 0;
+        padding: 0.5em 2em;
+        span{display: block;}
+    }
+    
+    div#footer-left span{
+        font-size: 70%;
+        margin: 0.3em auto;
+    }
+    
+    div#footer-right img.lang{
+        max-width: 1.5em;
+        margin: auto 0.3em;
+        border: 0.2em solid variables.$darker-brown;
+        border-radius: 0.4em;
+        cursor: pointer;
+        box-shadow: 0 0 0.1em #fff;
+    }
+    
+    div#footer-right img.lang.active{
+        border: 0.2em solid variables.$accent-gold;
+    }
 }

+ 27 - 2
frontend/src/app/footer/footer.ts

@@ -1,7 +1,11 @@
-import { Component } from '@angular/core';
+import { Component, inject } from '@angular/core';
 import { version, author, authorURL, sourceSite, sourceURL, license } from '../../../package.json';
+import { TranslateService, TranslatePipe } from '@ngx-translate/core';
 
-@Component({ selector: 'app-footer', templateUrl: './footer.html', styleUrl: './footer.scss' })
+@Component({
+    selector: 'app-footer', templateUrl: './footer.html', styleUrl: './footer.scss',
+    standalone: true, imports: [TranslatePipe]
+ })
 export class Footer{
     version: string = version;
     author: string = author;
@@ -9,4 +13,25 @@ export class Footer{
     sourceSite: string = sourceSite;
     sourceURL: string = sourceURL;
     license: string = license;
+    
+    languages = [
+        { code: 'en', name: 'English' },
+        { code: 'es', name: 'Español' },
+        { code: 'eu', name: 'Euskara' },
+    ];
+
+    currentLanguage = localStorage.getItem('language');
+    
+    private translate = inject(TranslateService)
+    
+    constructor(){
+    }
+    
+    switchLanguage(languageCode: string): void {
+        this.currentLanguage = languageCode;
+        localStorage.setItem('language', languageCode);
+        this.translate.use(languageCode);
+        window.location.reload();
+    }
+
 }

+ 9 - 3
frontend/src/app/header/header.scss

@@ -1,8 +1,14 @@
 header{
     text-align: center;
+    
+    h1{
+        margin: 0.2em;
 
-    img{
-        max-height: 7em;
-        max-width: 90%;
+        img{
+            max-height: 7em;
+            max-width: 90%;
+            
+            @media screen and (max-width: 800px){max-height: 5.6em;}
+        }
     }
 }

+ 11 - 0
frontend/src/app/home/home.html

@@ -1,3 +1,13 @@
+<section id='profile'>
+  <img
+    src="{{ apiURL }}{{ profile.image }}"
+    srcset="{{ utilService.generateSrcset(apiURL + profile.image) }}"
+  />
+  <div id="profile-text">
+    <p>{{ profile.bio }}</p>
+    <p>{{ profile.description }}</p>
+  </div>
+</section>
 <section id='catalog'>
   @for (project of projects; track project) {
     <article class="project">
@@ -6,6 +16,7 @@
         <img
           class="project-image" id="img-project-{{project.id}}"
           src="{{ apiURL }}{{ project.images[0].path }}"
+          srcset="{{ utilService.generateSrcset(apiURL + project.images[0].path) }}"
         />
         <div class='project_details'>
           <h3>{{ project.title }}</h3>

+ 62 - 24
frontend/src/app/home/home.scss

@@ -1,5 +1,62 @@
 @use '../_variables';
 
+section#profile{
+    
+    max-width: 65em;
+    margin: 1em auto;
+    padding:0.5em 1em;
+    
+    img{
+        max-width: 12em;
+        height: 12em;
+        border-radius: 50%;
+        border: 0.3em solid variables.$primary-brown;
+        margin-right: 2em;
+        display: inline-block;
+        vertical-align: middle;
+        z-index: 1;
+        position: relative;
+        
+        @media screen and (max-width: 800px){
+            height: initial;
+            max-height: initial;
+            max-width: calc(30% - 2em);
+            margin-right: 1em;
+            margin-right: 0;
+        }
+    }
+    
+    div#profile-text{
+        max-width: calc(100% - 18em);
+        display: inline-block;
+        height: 10em;
+        max-height: 10em;
+        vertical-align: middle;
+        border-bottom: 0.2em solid variables.$primary-brown;
+        border-top: 0.2em solid variables.$primary-brown;
+        border-top-right-radius: 1em;
+        border-bottom-right-radius: 1em;
+        margin-left: -7em;
+        padding: 0.5em 3em 0.5em 7em;
+        z-index:0;
+        position: relative;
+        background: linear-gradient(90deg, #eeeeee 99%, #ffffff00 100%);
+        
+        @media screen and (max-width: 800px){
+            max-width: 65%;
+            font-size: 90%;
+            border-bottom: 0;
+            border-top: 0;
+            background: initial;
+            margin-left: initial;
+            padding: 0.3em 0.3em 0.3em 0.8em;
+            margin: 0;
+            height: initial;
+            max-height: initial;
+        }
+    }
+}
+
 section#catalog{
     text-align: center;
     font-size: 0;
@@ -17,7 +74,7 @@ section#catalog{
         margin: 0.5em;
         font-size: initial;
         
-        img.project-image{
+        img.project-image, img.project-image-alt{
             object-fit: cover;
             object-position: center;
             width: 100%;
@@ -28,20 +85,7 @@ section#catalog{
             left: 0;
             transition: opacity 0.5s ease-in-out, scale 0.5s ease-in-out;
         }
-        
-        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;
-            background-color: red;
-        }
-        
+
         div.project_details{
             position: absolute;
             bottom: 0em;
@@ -81,18 +125,12 @@ section#catalog{
 }
 
 @media (hover: hover) {
-    section#catalog article:hover img{
-        scale: 1.2;
-    }
-    section#catalog article:hover div.project_details{
-        opacity: 0.8;
-    }
+    section#catalog article:hover img{scale: 1.2;}
+    section#catalog article:hover div.project_details{opacity: 0.8;}
 }
 
 @media (hover: none) {
-    section#catalog article div.project_details{
-        opacity: 0.8;
-    }
+    section#catalog article div.project_details{opacity: 0.8;}
 }
 
 

+ 55 - 29
frontend/src/app/home/home.ts

@@ -1,40 +1,66 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, OnInit, inject } from '@angular/core';
+import { Title } from '@angular/platform-browser';
+import { TranslateService, _ } from '@ngx-translate/core';
 import { ProjectService } from '../service/project-service';
+import { ProfileService } from '../service/profile-service';
+import { UtilService } from '../service/util-service';
 import { ProjectModel } from '../model/project';
+import { ProfileModel } from '../model/profile';
 import { environment } from '../../environments/environment';
 
 @Component({ selector: 'app-home', templateUrl: './home.html', styleUrl: './home.scss'})
 export class Home implements OnInit {
-  projects: ProjectModel[] = [];
-  private intervalId: any;
-  apiURL: string = environment.apiUrl;
+    projects: ProjectModel[] = [];
+    profile: any;
+    private intervalId: any;
+    apiURL: string = environment.apiUrl;
+    utilService: UtilService;
+    
+    private translate = inject(TranslateService)
 
-  constructor(private projectService: ProjectService){}
+    constructor(
+      private projectService: ProjectService, private profileService: ProfileService,
+      private titleService: Title
+    ){
+        this.utilService = new UtilService()
+        this.translate.use("" + localStorage.getItem("language"));
+    }
 
-  ngOnInit(): void {
-    this.projectService.getProjects().subscribe(data => { this.projects = data; });
-    this.startInterval()
-  }
+    ngOnInit(): void {
+        this.projectService.getProjects(1).subscribe(data => { this.projects = data; });
+        this.profileService.getProfile().subscribe(data => { this.profile = data; });
+        this.startInterval();
+        this.translate.use("" + localStorage.getItem("language"));
+        //this.translate.use
+        this.translate.get(_('SITE.TITLE')).subscribe((res: string) => {
+            this.titleService.setTitle(res)
+        });
+    }
   
-  private delay(ms: number){ return new Promise(resolve => setTimeout(resolve, ms)); }
+    private delay(ms: number){ return new Promise(resolve => setTimeout(resolve, ms)); }
   
-  startInterval() {
-      this.intervalId = setInterval(() => {        
-        const id: number = this.projects[Math.floor(Math.random() * this.projects.length)].id;
-        this.projectService.getProjectRandomImage(id.toString()).subscribe(async image => {
-            var img: HTMLImageElement
-              = <HTMLImageElement>document.getElementById("img-project-" + id);
-            var imgAlt: HTMLImageElement
-              = <HTMLImageElement>document.getElementById("img-project-" + id + "-alt");
-            imgAlt.setAttribute("src", this.apiURL + image.path);
-            await this.delay(1000);
-            img.style.opacity = '0';
-            await this.delay(1000);
-            img.setAttribute("src", "" + imgAlt.getAttribute("src"));
-            img.style.opacity = '1';
-            await this.delay(1000);
-            imgAlt.removeAttribute("src");
-        });
-      }, 2000);
+    startInterval() {
+        this.intervalId = setInterval(() => {
+            const id: number = this.projects[Math.floor(Math.random() * this.projects.length)].id;
+            this.projectService.getProjectRandomImage(id.toString()).subscribe(async image => {
+                var img: HTMLImageElement
+                  = <HTMLImageElement>document.getElementById("img-project-" + id);
+                var imgAlt: HTMLImageElement
+                  = <HTMLImageElement>document.getElementById("img-project-" + id + "-alt");
+                imgAlt.setAttribute("src", this.apiURL + image.path);
+                imgAlt.setAttribute(
+                  "srcset", this.utilService.generateSrcset(this.apiURL + image.path)
+                );
+                await this.delay(1000);
+                img.style.opacity = '0';
+                await this.delay(1000);
+                img.setAttribute("src", "" + imgAlt.getAttribute("src"));
+                img.setAttribute("srcset", "" + imgAlt.getAttribute("srcset"));
+                img.style.opacity = '1';
+                await this.delay(1000);
+                imgAlt.removeAttribute("src");
+                imgAlt.removeAttribute("srcset");
+            });
+        }, 2000);
     }
-}
+}

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

@@ -0,0 +1,8 @@
+export interface ProfileModel{
+  first_name: string;
+  last_name: string;
+  image: string;
+  tagline: string;
+  bio: string;
+  description: string;
+}

+ 8 - 8
frontend/src/app/model/project-image.ts

@@ -1,10 +1,10 @@
 export interface ProjectImageModel{
-  id: number;
-  project: number;
-  home: number;
-  path: string;
-  video: number;
-  title: string;
-  description: string;
-  priority: number;
+    id: number;
+    project: number;
+    home: number;
+    path: string;
+    video: number;
+    title: string;
+    description: string;
+    priority: number;
 }

+ 43 - 20
frontend/src/app/project/project.html

@@ -2,8 +2,9 @@
   <div id='details'>
     @if (project.images.length > 0){
       <img
-        (click)=galleryOpen(0); id='img-0' class='img img_main'
+        (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 }}'
       />
     }
@@ -12,15 +13,35 @@
   </div>
   <div id='images'>
     @for (image of project.images; track image.id) {
-       <img
-         (click)='galleryOpen(image.id)' id='img-{{ image.id }}' class='img'
-         src='{{ this.apiURL }}{{ image.path }}' alt='{{ image.title }}'
-         attr.data-description='{{ image.description }}'
-       />
+      @if (image.id > 1){
+        @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'
+          />
+        }
+        @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 }}'
+          >
+          </video>
+          <img (click)='galleryOpen(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'>
+    <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>
@@ -29,20 +50,22 @@
           (click)='galleryClose();' value='X'
         />
       </h3>
-      <div id='gallery-flex'>
-        <div id='gallery-flex-img-container'>
-          <img id='gallery-img'/>
-          <video controls autoplay id='gallery-video'></video>
+      <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>
-        <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>
   }

+ 72 - 39
frontend/src/app/project/project.scss

@@ -7,13 +7,24 @@ main{
 
 div#details{
     min-height: 24em;
+    margin: auto 4em 0 4em;
+    
+    h2{
+        font-size: 300%;
+        border-bottom: 0.2em solid;
+        margin-left: 2em;
+        
+        @media only screen and (max-width : 800px){
+            margin-left: initial;
+            margin-bottom: 0.5em;
+            border-bottom: 0.05em solid;
+            text-align: center;
+        }
+    }
    
     p{margin: auto 0.2em;}
-}
 
-@media only screen and (max-width : 600px) {
-    div#details{margin: auto 1em;}
-    div#details h2{text-align: center;}
+    @media only screen and (max-width : 600px) {margin: auto 1em;}
 }
 
 div#images{text-align: center;}
@@ -35,6 +46,7 @@ img.video_play{
     height: 3em;
     margin: 0 4.5em 0 -8em;
     z-index: 1;
+    position: relative;
 }
 
 video.img::-webkit-media-controls{display: none;}
@@ -44,15 +56,13 @@ img.img_main{
     max-height: 21em;
     float: left;
     margin: 0.5em 3em 0.5em 0.5em;
-}
 
-@media only screen and (max-width : 600px){
-    img.img_main{
+    @media only screen and (max-width : 800px){
         max-width: 100%;
         max-height: 24em;
         float: none;
         margin: 0.5em auto;
-        display: block;;
+        display: block;
     }
 }
 
@@ -66,6 +76,7 @@ div#gallery-cover{
     background-color: variables.$darker-brown;
     opacity: 0.4;
     transition: opacity 1s ease-in-out;
+    z-index: 2;
 }
 
 div#gallery{
@@ -80,11 +91,32 @@ div#gallery{
     border-radius: 0.4em;
     transition: opacity 1s ease-in-out;
     text-align: center;
+    z-index: 3;
+    
+    @media only screen and (max-width : 800px) {
+        top: 4%;
+        bottom: 2%;
+        left: 2%;
+        right: 2%;
+    }
+    
+    div#gallery-fade{
+        background-color: variables.$background-off-white;
+        display: none;
+        display: block;
+        height: calc(100% - 4em);
+        width: 100%;
+        position: absolute;
+        z-index: 6;
+        opacity: 0.5;
+        transition: opacity 0.2s;
+    }
 
     h3{
         text-align: left;
         font-size: 140%;
         margin: 0.6em;
+        max-height: 4em;
         border-bottom: 0.05em solid variables.$primary-brown;
 
         input[type='button']{
@@ -94,14 +126,27 @@ div#gallery{
         }
     }
     
+    div#gallery-content{
+        position: absolute;
+        height: calc(100% - 4em);
+        width: 100%;
+    }
+    
     div#gallery-flex{
         display: flex;
-        max-height: calc(100% - 8em);
+        flex-direction: row;
+        height: calc(100% - 4em);
+        
+        @media only screen and (max-width : 800px) {
+            flex-direction: column;
+        }
         
         div#gallery-flex-img-container{
             flex: 70%;
             padding: 1em;
             text-align: center;
+            
+            @media only screen and (max-width : 800px) {max-height: 60%;}
     
             img#gallery-img, video#gallery-video{
                 
@@ -110,57 +155,45 @@ div#gallery{
                 max-width: 95%;
                 max-height: 100%;
                 margin: auto;
+                
+                @media only screen and (max-width : 800px) {
+                    max-width: 95%;
+                    margin: 0.2em auto;
+                }
             }
         }
         
         p#gallery-text{
             flex: 25%;
-            margin: 2%;
+            margin: 2% 2% 2% auto;
             background-color: #ddd;
             padding: 0.7em;
             text-align: left;
             border: 0.1em solid variables.$primary-brown;
             border-radius: 0.3em;
             overflow-y: scroll;
+            
+            @media only screen and (max-width : 800px) {
+                margin: auto 2em;
+            }
         }
     }
     div#gallery-controls{
         text-align: center;
         margin: 0;
         
+        @media only screen and (max-width : 800px) {
+            position: absolute;
+            bottom: 0;
+            width: 100%;
+            @media only screen and (max-width : 800px){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 : 600px) {
-
-    div#gallery{
-        top: 4%;
-        bottom: 2%;
-        left: 2%;
-        right: 2%;
-    }
-
-    div#gallery img#gallery-img{
-        display: block;
-        max-height: calc(60% - 2em);
-        max-width: 95%;
-        margin: 0.2em auto;
-    }
-    div#gallery p#gallery-text{
-        display: block;
-        max-width: 100%;
-        margin: auto 0.3em;
-        max-height: calc(40% - 2em);
-    }
-
-    div#gallery div#gallery-controls{
-        position: absolute;
-        bottom: 0;
-        width: 100%;
-    }
-}

+ 63 - 20
frontend/src/app/project/project.ts

@@ -1,6 +1,9 @@
-import { Component } from '@angular/core';
+import { Component, inject } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
+import { Title } from '@angular/platform-browser';
 import { ProjectService } from '../service/project-service';
+import { TranslateService } from '@ngx-translate/core';
+import { UtilService } from '../service/util-service';
 import { ProjectModel } from '../model/project';
 import { environment } from '../../environments/environment';
 
@@ -11,6 +14,9 @@ export class Project {
     
     project: ProjectModel | undefined;
     apiURL: string = environment.apiUrl;
+    utilService: UtilService;
+    
+    private translate = inject(TranslateService)
     
     /**
      * Index of the photo currently on the gallery.
@@ -22,15 +28,23 @@ export class Project {
      */
     private maxIndex: number = -1;
 
-    constructor(private route: ActivatedRoute, private projectService: ProjectService) { }
+    constructor(
+      private route: ActivatedRoute, private projectService:
+      ProjectService, private titleService: Title
+    ){
+        this.utilService = new UtilService();
+    }
     
     ngOnInit(): void {
-      const id = this.route.snapshot.paramMap.get('id');
-      // Use the id to fetch data or perform actions
-      this.projectService.getProject("" + id).subscribe(data => {
-        this.project = data;
-        this.maxIndex = this.project.images.length; 
-      });
+        const id = this.route.snapshot.paramMap.get('id');
+        // Use the id to fetch data or perform actions
+        this.projectService.getProject("" + id).subscribe(data => {
+            this.project = data;
+            this.maxIndex = this.project.images.length;
+            this.titleService.setTitle(
+              this.project.title + " - " + this.translate.instant('SITE.TITLE')
+            );
+        });
     }
 
     /**
@@ -39,19 +53,12 @@ export class Project {
      * @param index Index of the photo to display
      */
     galleryOpen(index: any){
-        console.log("GALLERY OPEN" + index);
         let cover: HTMLDivElement = <HTMLDivElement> document.getElementById('gallery-cover');
         let gallery: HTMLDivElement = <HTMLDivElement> document.getElementById('gallery');
         cover.style.display = 'block';
-        cover.style.opacity = '0.4';
+        cover.style.opacity = '0.6';
         gallery.style.display = 'block';
         gallery.style.opacity = '1';
-        // Check index:
-        let i = 0;
-        while (document.getElementById('img-' + i) != null){
-            this.maxIndex = i;
-            i ++
-        }
         if (index >= 0 && index <= this.maxIndex){
             this.curIndex = index;
             this.gallerySet();
@@ -74,12 +81,19 @@ export class Project {
         video.currentTime = 0;
     }
     
+    private delay(ms: number){ return new Promise(resolve => setTimeout(resolve, ms)); }
+    
     /**
      * Pupulates the data in the gallery.
      */
-    gallerySet(){
+    async gallerySet(){
+        var fade = <HTMLDivElement> document.getElementById('gallery-fade');
+        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 = ': ' + titleText;
+        if (titleText != null && titleText.length > 0 && titleText != "" + this.project?.title)
+            titleText = ': ' + titleText;
         else titleText = '';
         let title: HTMLSpanElement = <HTMLSpanElement> document.getElementById('gallery-title');
         let text: HTMLParagraphElement
@@ -100,12 +114,17 @@ 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") || "";
         }
         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';
+        fade.style.opacity = '0';
+        await this.delay(200);
+        fade.style.display = 'none';
     }
 
     /**
@@ -113,7 +132,7 @@ export class Project {
      */
     galleryNext(){
         this.curIndex ++;
-        if (this.curIndex > this.maxIndex) this.curIndex = 0;
+        if (this.curIndex >= this.maxIndex) this.curIndex = 1;
         this.gallerySet();
     }
 
@@ -122,8 +141,32 @@ export class Project {
      */
     galleryPrev(){
         this.curIndex --;
-        if (this.curIndex < 0) this.curIndex = this.maxIndex;
+        if (this.curIndex < 1) this.curIndex = this.maxIndex;
         this.gallerySet();
     }
+    
+    private swipeCoord?: [number, number];
+    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') {
+            this.swipeCoord = coord;
+            this.swipeTime = time;
+        }
+        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 (direction[0] < 0) this.galleryNext();
+                else this.galleryPrev();
+            }
+        }
+    }
 }
 

+ 16 - 0
frontend/src/app/service/profile-service.ts

@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { ProfileModel } from '../model/profile';
+import { environment } from '../../environments/environment';
+
+@Injectable({ providedIn: 'root' })
+
+export class ProfileService {
+  private apiUrl = environment.apiUrl + '/profile';
+  constructor(private http: HttpClient) { }
+
+  getProfile(): Observable<ProfileModel[]> {
+    return this.http.get<ProfileModel[]>(this.apiUrl + "?lang=" + localStorage.getItem('language'));
+  }
+}

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

@@ -11,16 +11,25 @@ export class ProjectService {
   private apiUrl = environment.apiUrl + '/projects';
   constructor(private http: HttpClient) { }
 
-  getProjects(): Observable<ProjectModel[]> {
-    console.log("GETTING ALL PROJECTS: " + this.apiUrl);
-    return this.http.get<ProjectModel[]>(this.apiUrl);
+  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=" + localStorage.getItem('language') + paramImages
+    );
   }
 
   getProject(id: string): Observable<ProjectModel> {
-    return this.http.get<ProjectModel>(`${this.apiUrl}/${id}`);
+    return this.http.get<ProjectModel>(
+      `${this.apiUrl}/${id}` + "?lang=" + localStorage.getItem('language')
+    );
   }
   
   getProjectRandomImage(id: string): Observable<ProjectImageModel> {
-    return this.http.get<ProjectImageModel>(`${this.apiUrl}/${id}/images/random`);
+    return this.http.get<ProjectImageModel>(
+      `${this.apiUrl}/${id}/images/random` + "?lang=" + localStorage.getItem('language')
+    );
   }
+  
 }

+ 22 - 0
frontend/src/app/service/util-service.ts

@@ -0,0 +1,22 @@
+import { Injectable } from '@angular/core';
+
+@Injectable({ providedIn: 'root' })
+
+/**
+ * Provides utilities to use across the app.
+ */
+export class UtilService {
+    
+    /**
+     * Generates a srcset attribute for an image from it's src.
+     * 
+     * @param src The image src attribute.
+     * @return The computed srcset attribute.
+     */
+    generateSrcset(src: string): string{
+        var srcset: string = "";
+        for (let i = 100; i <= 1000; i += 100) srcset += src + "?w=" + i + " " + i + "w, ";
+        srcset.substring(0, srcset.length - 1);
+        return srcset;
+      }
+}

+ 2 - 0
frontend/src/main.ts

@@ -1,3 +1,5 @@
+/// <reference types="@angular/localize" />
+
 import { bootstrapApplication } from '@angular/platform-browser';
 import { appConfig } from './app/app.config';
 import { App } from './app/app';

+ 3 - 3
frontend/tsconfig.app.json

@@ -1,10 +1,10 @@
-/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
-/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
 {
   "extends": "./tsconfig.json",
   "compilerOptions": {
     "outDir": "./out-tsc/app",
-    "types": []
+    "types": [
+      "@angular/localize"
+    ]
   },
   "include": [
     "src/**/*.ts"

+ 0 - 2
frontend/tsconfig.json

@@ -1,5 +1,3 @@
-/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
-/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
 {
   "compileOnSave": false,
   "compilerOptions": {

+ 0 - 2
frontend/tsconfig.spec.json

@@ -1,5 +1,3 @@
-/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
-/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
 {
   "extends": "./tsconfig.json",
   "compilerOptions": {