Преглед изворни кода

The app is now multilanguage.

Iñigo Valentin пре 10 месеци
родитељ
комит
da7f426f53

+ 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

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

@@ -6,7 +6,7 @@ let data = new sqlite3.Database(process.env.DB , (err) => {
 });
 
 data.langQuery = function(param, lang = null){
-    if (lang == null) lang = 'es';
+    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 = '"

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

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

+ 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.2",
   "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'
+    })
   ]
 };

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

@@ -1,6 +1,7 @@
-import { Component, signal } from '@angular/core';
+import { Component, ViewEncapsulation, inject } from '@angular/core';
 import { RouterOutlet } from '@angular/router';
-import { ViewEncapsulation } from '@angular/core';
+import {  } from '@angular/core';
+import { TranslateService, TranslatePipe, TranslateDirective } from '@ngx-translate/core';
 import { Header } from './header/header';
 import { Footer } from './footer/footer';
 
@@ -12,5 +13,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: 44%;
+        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();
+    }
+
 }

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

@@ -1,4 +1,6 @@
-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 { UtilService } from '../service/util-service';
 import { ProjectModel } from '../model/project';
@@ -10,14 +12,23 @@ export class Home implements OnInit {
     private intervalId: any;
     apiURL: string = environment.apiUrl;
     utilService: UtilService;
+    
+    private translate = inject(TranslateService)
 
-    constructor(private projectService: ProjectService){
+    constructor(private projectService: ProjectService, 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()
+        this.startInterval();
+        this.translate.use("" + localStorage.getItem("language"));
+        //this.translate.use
+        console.log("GETTING TITLE IN " + this.translate.getCurrentLang()  + " OF " + localStorage.getItem("language"));
+        this.translate.get(_('SITE.TITLE')).subscribe((res: string) => {
+            this.titleService.setTitle(res)
+        });
     }
   
     private delay(ms: number){ return new Promise(resolve => setTimeout(resolve, ms)); }

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

@@ -8,6 +8,12 @@ main{
 div#details{
     min-height: 24em;
     margin: auto 4em 0 4em;
+    
+    h2{
+        font-size: 300%;
+        border-bottom: 0.2em solid;
+        margin-left: 2em;
+    }
    
     p{margin: auto 0.2em;}
 }

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

@@ -1,6 +1,8 @@
-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';
@@ -14,6 +16,8 @@ export class Project {
     apiURL: string = environment.apiUrl;
     utilService: UtilService;
     
+    private translate = inject(TranslateService)
+    
     /**
      * Index of the photo currently on the gallery.
      */
@@ -24,7 +28,10 @@ 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();
     }
     
@@ -33,7 +40,10 @@ export class Project {
         // 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.maxIndex = this.project.images.length;
+            this.titleService.setTitle(
+              this.project.title + " - " + this.translate.instant('SITE.TITLE')
+            );
         });
     }
 

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

@@ -12,16 +12,19 @@ export class ProjectService {
   constructor(private http: HttpClient) { }
 
   getProjects(): Observable<ProjectModel[]> {
-    console.log("GETTING ALL PROJECTS: " + this.apiUrl);
-    return this.http.get<ProjectModel[]>(this.apiUrl);
+    return this.http.get<ProjectModel[]>(this.apiUrl + "?lang=" + localStorage.getItem('language'));
   }
 
   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')
+    );
   }
   
 }

+ 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": {