Forráskód Böngészése

Merge branch 'develop'

Iñigo Valentin 10 hónapja
szülő
commit
f336616b77

+ 1 - 1
backend/.gitignore

@@ -1,5 +1,5 @@
 # Database
-data.sqlite3*
+data-dev.sqlite3
 data.sql
 
 # Environment files

+ 0 - 0
backend/data.sqlite3_INITIAL → backend/data.sqlite3


+ 3 - 0
backend/package.json

@@ -1,6 +1,9 @@
 {
   "name": "leather-backend",
   "version": "0.1.0",
+  "authorURL": "https://inigovalentin.com",
+  "sourceSite": "Github",
+  "sourceURL": "https://github.com/InigoValentin/leather.inigovalentin.com",
   "description": "",
   "main": "src/server.js",
   "scripts": {

+ 43 - 5
backend/src/data/profileData.js

@@ -2,15 +2,53 @@ const db = require("./data.js");
 
 let profileData = [];
 
-profileData.getProfile = function(lang = null) {
+profileData.retrieveProfileImages = async function(lang, max){
+    return new Promise((resolve, reject) => {
+        db.all(
+          "SELECT id, path, " + db.langQuery("title", lang)
+          + ", priority FROM profile_image WHERE visible=1 ORDER BY priority "
+          + (parseInt(max) > 0 ? " LIMIT " + parseInt(max) : ""),
+          (err, imgs) => {
+            if (err) reject(err);
+            else resolve(imgs);
+          }
+        );
+    });
+}
+
+profileData.retrieveProfileTexts = async function(lang, mod){
+    return new Promise((resolve, reject) => {
+        db.all(
+          "SELECT id, " + db.langQuery("content", lang)
+          + ", priority, home FROM profile_text WHERE visible=1 "
+          + (parseInt(mod) > 0 ? " LIMIT " + parseInt(mod) : "")
+          + (mod == "home" ? " AND home=1 " : "") + (mod == "profile" ? " AND profile=1 " : "")
+          + "ORDER BY priority "
+          ,
+          (err, texts) => {
+            if (err) reject(err);
+            else resolve(texts);
+          }
+        );
+    });
+}
+
+profileData.getProfile = function(lang = null, images = null, texts = 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",
+          "SELECT first_name, last_name, display_name FROM profile LIMIT 1",
           async (err, row) => {
             if(err) reject(err);
-            else resolve(row);
+            else if (!row) resolve("[]");
+            else{
+                if (images != false || parseInt(images) > 0){
+                    row.images = await this.retrieveProfileImages(lang, images);
+                }
+                if (texts != false || parseInt(texts) > 0){
+                    row.texts = await this.retrieveProfileTexts(lang, texts);
+                }
+                resolve(row);
+            }
           }
         );
     });

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

@@ -57,6 +57,7 @@ projectData.getProject = function(id, lang = null, images = null) {
           parseInt(id), String(id),
           async (err, row) => {
             if(err) reject(err);
+            else if (!row) resolve("[]");
             else{
                 if (images == false || parseInt(images) <= 0) resolve(row);
                 else{

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

@@ -49,7 +49,6 @@ router.get("/images/projects/:projectId/:imagePath", async (req, res) => {
 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.

+ 13 - 1
backend/src/routers/profileRouter.js

@@ -13,12 +13,24 @@ const router = express.Router();
  *          language will be used.
  */
 router.get("/", cors({origin: '*', methods: 'GET'}), async (req, res) => {
+    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 texts = true;
+    if (req.query.texts == "false") texts = false;
+    else if (parseInt(req.query.texts) != NaN && parseInt(req.query.texts) >= 0)
+        texts = parseInt(req.query.texts);
+    else if(
+      req.query.texts != null
+      && (req.query.texts.toLowerCase() == "home" || req.query.texts.toLowerCase() == "profile")
+    ) texts = req.query.texts.toLowerCase();
     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);
+    const data = await profileData.getProfile(lang, images, texts);
     res.json(data);
 });
 

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

@@ -43,7 +43,9 @@ router.get("/:id", cors({origin: '*', methods: 'GET'}), async (req, res) => {
       && 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" });
+    res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+    res.setHeader('Access-Control-Allow-Origin', '*');
+    if (!data || data == "[]") res.status(404).json({ error: "Project not found" });
     else res.json(data);
 });
 
@@ -69,12 +71,11 @@ router.get("/:projectId/images/:imageId", cors({origin: '*', methods: 'GET'}), a
       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);
+    const data = await projectData.getProjectImage(req.params.projectId, req.params.imageId, lang);
+    res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+    res.setHeader('Access-Control-Allow-Origin', '*');
     if (!data) res.status(404).json({ error: "Image not found" });
     else{
-        res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
-        res.setHeader('Access-Control-Allow-Origin', '*');
         res.json(data);
     }
 });

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

@@ -1,4 +1,5 @@
 const express = require('express');
+const cors = require('cors');
 const projectRouter = require("./projectRouter.js");
 const profileRouter = require("./profileRouter.js");
 const assetRouter = require("./assetRouter.js");
@@ -8,4 +9,19 @@ router.use("/projects", projectRouter);
 router.use("/profile", profileRouter);
 router.use("/assets", assetRouter);
 
+router.get("/", cors({origin: '*', methods: 'GET'}), async (req, res) => {
+    const p = require('../../package.json');
+    let api = {
+      api: {
+        version: p.version,
+        author: {
+          name: p.author,
+          url: p.authorURL,
+        },
+        source: p.sourceURL
+      }
+    };
+    res.json(api);
+});
+
 module.exports = router;

+ 67 - 2
frontend/public/i18n/en.json

@@ -1,10 +1,75 @@
 {
   "SITE": {
     "TITLE": "Iñigo Valentin: Leather Work",
-    "DESCRIPTION": "This is a multi-language Angular application."
+    "DESCRIPTION": "My leather projects."
   },
   "FOOTER": {
+    "PROFILE": "More about me",
+    "PRIVACY": "Privacy",
     "DEVELOPED": "Version {{ version }} Developed by <a href='{{ authorURL }}'>{{ author }}</a>",
     "SOURCE": "Source code available on <a href='{{ sourceURL }}'>{{ sourceSite }}</a> under the {{ license }}"
+  },
+  "PROFILE": {
+    "TITLE": "About me",
+    "DESCRIPTION": "About Iñigo Valentin."
+  },
+  "PRIVACY": {
+    "TITLE": "Privacy Policy",
+    "DESCRIPTION": "Privacy & Cookie Policy for Iñigo Valentin",
+    "TEXTS":{
+      "DATE": "Effective Date: September 2, 2025",
+      "LEGAL": "The legal entity operating this website is Iñigo Valentin ('Iñigo Valentin,' 'we', 'us', or 'our').",
+      "EXPLANATION": "This Privacy Policy explains the only way in which we process any data from visitors to this website, which is solely through the use of a single, functional cookie.",
+      "PRINCIPLE": {
+        "TITLE": "1. The Principle: Data Minimisation",
+        "CONTENT": "We are committed to the principle of Data Minimisation, meaning we only collect and process the absolute minimum amount of personal data necessary to provide a usable service. For this website, this means we only use one type of cookie for functional purposes. We do not use analytics, advertising, or tracking technologies."
+      },
+      "INFORMATION": {
+        "TITLE": "2. Information We Process (The Cookie)",
+        "CONTENT": "What We Collect:",
+        "COLLECT": "The only piece of data we store on your device is a single First-Party Functionality Cookie.",
+        "TABLE":{
+          "NAME_HEADER": "Cookie name:",
+          "NAME_CONTENT": "language",
+          "PURPOSE_HEADER": "Purpose:",
+          "PURPOSE_CONTENT": "To remember your preferred language setting.",
+          "DATA_HEADER": "Data Stored:",
+          "DATA_CONTENT": "A simple two-letter language code (e.g., en, es, eu).",
+          "DURATION_HEADER": "Duration:",
+          "DURATION_CONTENT": "12 months.",
+          "BASIS_HEADER": "GDPR Lawful Basis:",
+          "BASIS_CONTENT": "Legitimate Interest / Necessary Functionality."
+        },
+        "PURPOSE": {
+          "HEADER": "Purpose of Processing:",
+          "CONTENT": "The sole purpose of this cookie is to ensure the website is displayed in the language you selected on your previous visit. This improves your user experience by preventing the need to select your language preference every time you navigate the site or a new page."
+        },
+        "BASIS": {
+          "HEADER": "Lawful Basis for Processing (GDPR Article 6(1)(f)):",
+          "CONTENT": "Under the General Data Protection Regulation (GDPR), the use of this cookie is justified by our Legitimate Interest (Article 6(1)(f)) in providing an efficient, requested, and user-friendly experience on our website. Because this cookie is strictly necessary for a function requested by the user (the language display) and does not track you for advertising or profiling, it is exempt from the requirement for explicit consent."
+        }
+      },
+      "DATA": {
+        "TITLE": "3. Data Storage, Security, and Sharing",
+        "STORAGE": "Storage: The only data processing is the storage of the language code on your device (the cookie). No identifying information (like IP addresses, browsing history, or names) is collected or stored on our servers.",
+        "SECURITY": "Security: As no personal data beyond the functional language preference is collected or processed, there is virtually no security risk.",
+        "SHARING": "Data Sharing: We do not share any data with any third parties. We do not use third-party analytics, marketing, or tracking services."
+      },
+      "RIGHTS": {
+        "TITLE": "4. Your Data Protection Rights (GDPR)",
+        "COOKIE": "How to Control the Cookie",
+        "CONTROL": "You can manage or delete this (or any) cookie at any time by accessing the settings menu in your web browser (e.g., Chrome, Firefox, Safari). Please note that deleting the language preference cookie will simply cause the website to use it's default language on your next visit."
+      },
+      "AUTHORITY": {
+        "TITLE": "5. Supervisory Authority",
+        "EXPLANATION": "If you are unsatisfied with how we have handled your data protection concerns, you have the right to lodge a complaint with a supervisory authority under GDPR.",
+        "AUTHORITY": "Relevant Supervisory Authority: Spanish Data Protection Agency (AEPD)."
+      }
+    }
+  },
+  "ERROR": {
+    "TITLE": "Error",
+    "TEXT": "Requested content couldn't be found.",
+    "LINK": "Go back to Home."
   }
-}
+}

+ 67 - 2
frontend/public/i18n/es.json

@@ -1,10 +1,75 @@
 {
   "SITE": {
     "TITLE": "Iñigo Valentin: Trabajos en cuero",
-    "DESCRIPTION": "This is a multi-language Angular application."
+    "DESCRIPTION": "Proyectos de cuero hechos por mí'."
   },
   "FOOTER": {
+    "PROFILE": "Más sobre mí",
+    "PRIVACY": "Privacidad",
     "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 }}"
+  },
+  "PROFILE": {
+    "TITLE": "Sobre mí",
+    "DESCRIPTION": "Sobre Iñigo Valentin."
+  },
+  "PRIVACY": {
+    "TITLE": "Política de Privacidad",
+    "DESCRIPTION": "Política de Privacidad y Cookies de Iñigo Valentin",
+    "TEXTS":{
+      "DATE": "Fecha de entrada en vigor: 2 de septiembre de 2025",
+      "LEGAL": "La entidad legal que opera este sitio web es Iñigo Valentin («Iñigo Valentin», «nosotros», «nos» o «nuestro»).",
+      "EXPLANATION": "Esta Política de Privacidad explica la única forma en que procesamos los datos de los visitantes de este sitio web, que es mediante el uso de una única cookie funcional.",
+      "PRINCIPLE": {
+        "TITLE": "1. Principio: Minimización de Datos",
+        "CONTENT": "Nos comprometemos con el principio de Minimización de Datos, lo que significa que solo recopilamos y procesamos la cantidad mínima de datos personales necesaria para ofrecer un servicio útil. En este sitio web, esto significa que solo utilizamos un tipo de cookie con fines funcionales. No utilizamos tecnologías de análisis, publicidad ni seguimiento."
+      },
+      "INFORMATION": {
+        "TITLE": "2. Información que procesamos (la cookie)",
+        "CONTENT": "Qué recopilamos:",
+        "COLLECT": "El único dato que almacenamos en su dispositivo es una única cookie de funcionalidad propia.",
+        "TABLE":{
+          "NAME_HEADER": "Nombre de la cookie:",
+          "NAME_CONTENT": "language",
+          "PURPOSE_HEADER": "Finalidad:",
+          "PURPOSE_CONTENT": "Recordar su configuración de idioma preferida.",
+          "DATA_HEADER": "Datos almacenados:",
+          "DATA_CONTENT": "Un código de idioma simple de dos letras (p. ej., en, es, eu).",
+          "DURATION_HEADER": "Duración:",
+          "DURATION_CONTENT": "12 meses.",
+          "BASIS_HEADER": "Base legal del RGPD:",
+          "BASIS_CONTENT": "Interés legítimo / Funcionalidad necesaria."
+        },
+        "PURPOSE": {
+          "HEADER": "Finalidad del tratamiento:",
+          "CONTENT": "El único propósito de esta cookie es garantizar que el sitio web se muestre en el idioma seleccionado en su visita anterior. Esto mejora su experiencia de usuario al evitar tener que seleccionar su preferencia de idioma cada vez que navega por el sitio web o por una nueva página."
+        },
+        "BASIS": {
+          "HEADER": "Base legal del tratamiento (Artículo 6(1)(f) del RGPD):",
+          "CONTENT": "Según el Reglamento General de Protección de Datos (RGPD), el uso de esta cookie está justificado por nuestro interés legítimo (Artículo 6(1)(f)) en proporcionar una experiencia eficiente, solicitada y fácil de usar en nuestro sitio web. Dado que esta cookie es estrictamente necesaria para una función solicitada por el usuario (el idioma mostrado) y no lo rastrea con fines publicitarios ni de elaboración de perfiles, está exenta del requisito de consentimiento explícito."
+        }
+      },
+      "DATA": {
+        "TITLE": "3. Almacenamiento, seguridad y compartición de datos",
+        "STORAGE": "Almacenamiento: El único procesamiento de datos es el almacenamiento del código de idioma en su dispositivo (la cookie). No se recopila ni almacena información de identificación (como direcciones IP, historial de navegación o nombres) en nuestros servidores.",
+        "SECURITY": "Seguridad: Dado que no se recopilan ni procesan datos personales más allá de la preferencia de idioma funcional, prácticamente no existe ningún riesgo de seguridad.",
+        "SHARING": "Compartición de datos: No compartimos datos con terceros. No utilizamos servicios de análisis, marketing ni seguimiento de terceros."
+      },
+      "RIGHTS": {
+        "TITLE": "4. Sus derechos de protección de datos (RGPD)",
+        "COOKIE": "Cómo controlar las cookies",
+        "CONTROL": "Puede administrar o eliminar esta (o cualquier) cookie en cualquier momento accediendo al menú de configuración de su navegador web (por ejemplo, Chrome, Firefox, Safari). Tenga en cuenta que eliminar la cookie de preferencia de idioma simplemente hará que el sitio web utilice el idioma por defecto en su próxima visita."
+      },
+      "AUTHORITY": {
+        "TITLE": "5. Autoridad de Control",
+        "EXPLANATION": "Si no está satisfecho con la gestión de sus inquietudes sobre protección de datos, tiene derecho a presentar una reclamación ante una autoridad de control conforme al RGPD.",
+        "AUTHORITY": "Autoridad de Control Competente: Agencia Española de Protección de Datos (AEPD)."
+      }
+    }
+  },
+  "ERROR": {
+    "TITLE": "Error",
+    "TEXT": "La direccion solicitada no existe.",
+    "LINK": "Volver al inicio."
   }
-}
+}

+ 67 - 2
frontend/public/i18n/eu.json

@@ -1,10 +1,75 @@
 {
   "SITE": {
     "TITLE": "Iñigo Valentin: Trabajos en cuero",
-    "DESCRIPTION": "This is a multi-language Angular application."
+    "DESCRIPTION": "Nire larruzko proiektuak."
   },
   "FOOTER": {
+    "PROFILE": "Niri buruz",
+    "PRIVACY": "Pribatutasuna",
     "DEVELOPED": "Version {{ version }} Developed by <a href='{{ authorURL }}'>{{ author }}</a>",
     "SOURCE": "Source code available on <a href='{{ sourceURL }}'>{{ sourceSite }}</a> under the {{ license }}"
+  },
+  "PROFILE": {
+    "TITLE": "Niri buruz",
+    "DESCRIPTION": "Iñigo Valentini buruz"
+  },
+  "PRIVACY": {
+    "TITLE": "Pribatutasun Politika",
+    "DESCRIPTION": "Iñigo Valentinen Pribatutasun eta Cookie Politika",
+    "TEXTS":{
+      "DATE": "Indarraldiaren data: 2025eko irailaren 2a",
+      "LEGAL": "Webgune hau kudeatzen duen erakunde juridikoa Iñigo Valentin da ('Iñigo Valentin', 'gu', 'gure' edo 'gure').",
+      "EXPLANATION": "Pribatutasun Politika honek webgune honetara datozen bisitarien datuak prozesatzeko modu bakarra azaltzen du, hau da, cookie funtzional bakarra erabiliz soilik.",
+      "PRINCIPLE": {
+        "TITLE": "1. Printzipioa: Datuen Minimizazioa",
+        "CONTENT": "Datuen Minimizazioaren printzipioarekin konprometituta gaude, hau da, zerbitzu erabilgarri bat emateko beharrezkoak diren datu pertsonalen gutxieneko kopurua soilik biltzen eta prozesatzen dugu. Webgune honetarako, horrek esan nahi du cookie mota bakarra erabiltzen dugula helburu funtzionaletarako. Ez ditugu analisi, publizitate edo jarraipen teknologiak erabiltzen."
+      },
+      "INFORMATION": {
+        "TITLE": "2. Prozesatzen dugun informazioa (Cookiea)",
+        "CONTENT": "Zer biltzen dugun:",
+        "COLLECT": "Zure gailuan gordetzen dugun datu bakarra Lehen Aldeko Funtzionaltasun Cookie bakarra da.",
+        "TABLE":{
+          "NAME_HEADER": "Cookiearen izena:",
+          "NAME_CONTENT": "language",
+          "PURPOSE_HEADER": "Helburua:",
+          "PURPOSE_CONTENT": "Zure hizkuntza ezarpen hobetsia gogoratzea.",
+          "DATA_HEADER": "Gordetako datuak:",
+          "DATA_CONTENT": "Bi letrako hizkuntza-kode sinple bat (adibidez, en, es, eu).",
+          "DURATION_HEADER": "Iraupena:",
+          "DURATION_CONTENT": "12 hilabete.",
+          "BASIS_HEADER": "GDPR Oinarri juridikoa:",
+          "BASIS_CONTENT": "Interes legitimoa / Beharrezko funtzionalitatea."
+        },
+        "PURPOSE": {
+          "HEADER": "Prozesamenduaren helburua:",
+          "CONTENT": "Cookie honen helburu bakarra webgunea aurreko bisitan hautatu zenuen hizkuntzan bistaratzea ziurtatzea da. Horrek zure erabiltzaile-esperientzia hobetzen du, gunean edo orrialde berri batean nabigatzen duzun bakoitzean zure hizkuntza-lehentasuna hautatu beharrik ez izatea saihestuz."
+        },
+        "BASIS": {
+          "HEADER": "Prozesamenduaren oinarri juridikoa (GDPR 6(1)(f) artikulua):",
+          "CONTENT": "Datuak Babesteko Erregelamendu Orokorraren (GDPR) arabera, cookie honen erabilera gure Interes Legitimoak (6(1)(f) artikulua) justifikatzen du, gure webgunean esperientzia eraginkor, eskatu eta erabilerraza eskaintzeko. Cookie hau erabiltzaileak eskatutako funtzio baterako (hizkuntza bistaratzea) guztiz beharrezkoa denez eta ez zaituenez publizitaterako edo profilak egiteko jarraitzen, baimen esplizituaren beharretik salbuetsita dago."
+        }
+      },
+      "DATA": {
+        "TITLE": "3. Datuen Biltegiratzea, Segurtasuna eta Partekatzea.",
+        "STORAGE": "Biltegiratzea: Datuen prozesamendu bakarra zure gailuan hizkuntza-kodea (cookiea) gordetzea da. Ez da identifikazio-informaziorik (IP helbideak, nabigazio-historia edo izenak bezala) biltzen edo gordetzen gure zerbitzarietan.",
+        "SECURITY": "Segurtasuna: Hizkuntza funtzionalaren lehentasunaz gaindiko datu pertsonalik biltzen edo prozesatzen ez denez, ia ez dago segurtasun-arriskurik.",
+        "SHARING": "Datuen Partekatzea: Ez dugu daturik hirugarrenekin partekatzen. Ez dugu hirugarrenen analisi-, marketin- edo jarraipen-zerbitzurik erabiltzen."
+      },
+      "RIGHTS": {
+        "TITLE": "4. Zure Datuen Babeserako Eskubideak (GDPR)",
+        "COOKIE": "Nola Kontrolatu Cookiea",
+        "CONTROL": "Cookie hau (edo beste edozein) edozein unetan kudeatu edo ezaba dezakezu zure web arakatzailearen ezarpenen menura sartuz (adibidez, Chrome, Firefox, Safari). Kontuan izan hizkuntza-lehentasunen cookiea ezabatzeak webguneak bere hizkuntza lehenetsia erabiliko duela hurrengo bisitan."
+      },
+      "AUTHORITY": {
+        "TITLE": "5. Gainbegiratze Agintaritza",
+        "EXPLANATION": "Zure datuen babesari buruzko kezkak nola kudeatu ditugun pozik ez bazaude, GDPRren arabera gainbegiratze-agintaritza bati kexa aurkezteko eskubidea duzu.",
+        "AUTHORITY": "Dagokion gainbegiratze-agintaritza: Espainiako Datuak Babesteko Agentzia (AEPD)."
+      }
+    }
+  },
+  "ERROR": {
+    "TITLE": "Errorea",
+    "TEXT": "Eskatutako helbidea ez da existitzen.",
+    "LINK": "Hasierara itzuli."
   }
-}
+}

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

@@ -1,4 +1,5 @@
 $primary-brown: #4A2C2A;
 $background-off-white: #F8F6F4;
+$background-darker-white: #DFDFDF;
 $accent-gold: #B58E4F;
 $darker-brown: #3D211F;

+ 8 - 1
frontend/src/app/app.routes.ts

@@ -1,8 +1,15 @@
 import { Routes } from '@angular/router';
 import { Home } from './home/home';
 import { Project } from './project/project';
+import { Profile } from './profile/profile';
+import { Privacy } from './privacy/privacy';
+import { Error } from './error/error';
 
 export const routes: Routes = [
   { path: '', component: Home },
-  { path: 'projects/:id', component: Project }
+  { path: 'projects/:id', component: Project },
+  { path: 'profile', component: Profile },
+  { path: 'privacy', component: Privacy },
+  { path: 'error', component: Error },
+  { path: '**', redirectTo: '/error' }
 ];

+ 10 - 0
frontend/src/app/app.scss

@@ -8,6 +8,16 @@ body{
   white-space-collapse: collapse;
 }
 
+app-root {
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  
+  main{
+    flex-grow: 1;
+  }
+}
+
 h1, h2, h3 {
   color: variables.$primary-brown;
 }

+ 5 - 0
frontend/src/app/error/error.html

@@ -0,0 +1,5 @@
+<section id='error'>
+  <h2>{{ 'ERROR.TITLE' | translate }}</h2>
+  <p>{{ 'ERROR.TEXT' | translate }}</p>
+  <a href="/">{{ 'ERROR.LINK' | translate }}</a>
+</section>

+ 24 - 0
frontend/src/app/error/error.scss

@@ -0,0 +1,24 @@
+@use '../_variables';
+
+section#error{
+    max-width: 65em;
+    margin: 1em 4em;
+    padding:0.5em 1em;
+    background-color: variables.$background-darker-white;
+    border: 0.1em solid variables.$primary-brown;
+    border-radius: 0.4em;
+    
+    h2{
+        font-size: 200%;
+        border-bottom: 0.2em solid;
+    }
+    
+    p{margin-left: 1em;}
+    
+    a{
+        margin: 2em auto 2em 3em;
+        display: list-item;
+    }
+}
+
+

+ 23 - 0
frontend/src/app/error/error.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { Error } from './error';
+
+describe('Error', () => {
+  let component: Error;
+  let fixture: ComponentFixture<Error>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [Error]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(Error);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 33 - 0
frontend/src/app/error/error.ts

@@ -0,0 +1,33 @@
+import { Component, inject } from '@angular/core';
+import { Meta, Title } from '@angular/platform-browser';
+import { TranslateService, _, TranslatePipe } from '@ngx-translate/core';
+
+@Component({
+  selector: 'app-eror', templateUrl: './error.html', styleUrl: './error.scss',
+  standalone: true, imports: [TranslatePipe]
+})
+export class Error {
+    
+    private translate = inject(TranslateService)
+    
+    constructor(private titleService: Title, private metaService: Meta){
+        
+        // Set meta tags
+        this.metaService.addTag({ property: 'robots', content: 'noindex, nofollow' });
+        this.translate.get(_('ERROR.TITLE')).subscribe((resError: string) => {
+            this.translate.get(_('SITE.TITLE')).subscribe((resTitle: string) => {
+                this.titleService.setTitle(resError + " - " + resTitle);
+                this.metaService.addTag(
+                  { property: 'title', content: resError + " - " + resTitle }
+                );
+                this.metaService.addTag(
+                  { property: 'og:title', content: resError + " - " + resTitle }
+                );
+            });
+        });
+        this.translate.get(_('ERROR.DESCRIPTION')).subscribe((res: string) => {
+            this.metaService.addTag({ property: 'og:description', content: res});
+            this.metaService.addTag({ property: 'description', content: res});
+        });
+    }
+}

+ 2 - 2
frontend/src/app/footer/footer.html

@@ -1,7 +1,7 @@
 <footer>
   <div id='footer-left'>
-    <span innerHTML="{{ 'FOOTER.DEVELOPED' | translate:{version, authorURL, author} }}"></span>
-    <span innerHTML="{{ 'FOOTER.SOURCE' | translate:{sourceURL, sourceSite, license} }}"></span>
+    <a href="/profile">{{ 'FOOTER.PROFILE' | translate }}</a>
+    <a href="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a>
   </div>
   <div id='footer-right'>
     <div class="language-switcher">

+ 2 - 2
frontend/src/app/footer/footer.scss

@@ -31,8 +31,8 @@ footer{
         }
     }
     
-    div#footer-left span{
-        font-size: 70%;
+    div#footer-left a{
+        display: block;
         margin: 0.3em auto;
     }
     

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

@@ -1,12 +1,17 @@
 @if (profile){
   <section id='profile'>
-    <img
-      src="{{ apiURL }}{{ profile.image }}"
-      srcset="{{ utilService.generateSrcset(apiURL + profile.image) }}"
-    />
+    @if (profile.images[0]){
+      <img
+        src="{{ apiURL }}{{ profile.images[0].path }}"
+        srcset="{{ utilService.generateSrcset(apiURL + profile.images[0].path) }}"
+      />
+    }
     <div id="profile-text">
-      <p>{{ profile.bio }}</p>
-      <p>{{ profile.description }}</p>
+      @for (t of profile.texts; track t) {
+        @if (t.home == 1){
+          <p>{{ t.content }}</p>
+        }
+      }
     </div>
   </section>
 }

+ 4 - 0
frontend/src/app/home/home.scss

@@ -54,6 +54,10 @@ section#profile{
             height: initial;
             max-height: initial;
         }
+        
+        p{margin: 0.3em 0;}
+        
+        p:first-child{font-weight: bold;}
     }
 }
 

+ 1 - 1
frontend/src/app/home/home.ts

@@ -43,6 +43,6 @@ export class Home implements OnInit {
 
     ngOnInit(): void {
         this.projectService.getProjects(1).subscribe(data => { this.projects = data; });
-        this.profileService.getProfile().subscribe(data => { this.profile = data; });
+        this.profileService.getProfile(1, "home").subscribe(data => { this.profile = data; });
     }
 }

+ 59 - 0
frontend/src/app/privacy/privacy.html

@@ -0,0 +1,59 @@
+<section>
+  <h2>{{ 'PRIVACY.DESCRIPTION' | translate }}</h2>
+  <article>
+    <p>{{ 'PRIVACY.TEXTS.DATE' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.LEGAL' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.EXPLANATION' | translate }}</p>
+  </article>
+  <article>
+    <h3>{{ 'PRIVACY.TEXTS.PRINCIPLE.TITLE' | translate }}</h3>
+    <p>{{ 'PRIVACY.TEXTS.PRINCIPLE.CONTENT' | translate }}</p>
+  </article>
+  <article>
+    <h3>{{ 'PRIVACY.TEXTS.INFORMATION.TITLE' | translate }}</h3>
+    <p>{{ 'PRIVACY.TEXTS.INFORMATION.CONTENT' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.INFORMATION.COLLECT' | translate }}</p>
+    <table>
+      <tr>
+        <th>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.NAME_HEADER' | translate }}</th>
+        <td>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.NAME_CONTENT' | translate }}</td>
+      </tr>
+      <tr>
+        <th>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.PURPOSE_HEADER' | translate }}</th>
+        <td>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.PURPOSE_CONTENT' | translate }}</td>
+      </tr>
+      <tr>
+        <th>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.DATA_HEADER' | translate }}</th>
+        <td>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.DATA_CONTENT' | translate }}</td>
+      </tr>
+      <tr>
+        <th>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.DURATION_HEADER' | translate }}</th>
+        <td>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.DURATION_CONTENT' | translate }}</td>
+      </tr>
+      <tr>
+        <th>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.BASIS_HEADER' | translate }}</th>
+        <td>{{ 'PRIVACY.TEXTS.INFORMATION.TABLE.BASIS_CONTENT' | translate }}</td>
+      </tr>
+    </table>
+    <p>{{ 'PRIVACY.TEXTS.INFORMATION.PURPOSE.HEADER' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.INFORMATION.PURPOSE.CONTENT' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.INFORMATION.BASIS.HEADER' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.INFORMATION.BASIS.CONTENT' | translate }}</p>
+  </article>
+  <article>
+    <h3>{{ 'PRIVACY.TEXTS.DATA.TITLE' | translate }}</h3>
+    <p>{{ 'PRIVACY.TEXTS.DATA.STORAGE' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.DATA.SECURITY' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.DATA.SHARING' | translate }}</p>
+  </article>
+  <article>
+    <h3>{{ 'PRIVACY.TEXTS.RIGHTS.TITLE' | translate }}</h3>
+    <p>{{ 'PRIVACY.TEXTS.RIGHTS.COOKIE' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.RIGHTS.CONTROL' | translate }}</p>
+  </article>
+  <article>
+    <h3>{{ 'PRIVACY.TEXTS.AUTHORITY.TITLE' | translate }}</h3>
+    <p>{{ 'PRIVACY.TEXTS.AUTHORITY.EXPLANATION' | translate }}</p>
+    <p>{{ 'PRIVACY.TEXTS.AUTHORITY.AUTHORITY' | translate }}</p>
+  </article>
+</section>

+ 29 - 0
frontend/src/app/privacy/privacy.scss

@@ -0,0 +1,29 @@
+@use '../_variables';
+
+section{
+    max-width: 70em;
+    margin: auto auto;
+    padding: 0 2em;
+
+    article{
+        margin-left: 2em;
+        
+        h3{ margin-left: -1em;}
+        
+        table{
+            
+            border-collapse: collapse;
+            
+            td, th{
+                border: 1px solid black;
+                text-align: left;
+                padding: 0.1em 0.5em;
+            }
+            
+            th{font-weight: bold;}
+        }
+    }
+}
+
+
+

+ 23 - 0
frontend/src/app/privacy/privacy.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { Privacy } from './privacy';
+
+describe('Privacy', () => {
+  let component: Privacy;
+  let fixture: ComponentFixture<Privacy>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [Profile]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(Privacy);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 41 - 0
frontend/src/app/privacy/privacy.ts

@@ -0,0 +1,41 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { PlatformLocation } from '@angular/common';
+import { Meta, Title } from '@angular/platform-browser';
+import { TranslateService, _, TranslatePipe } from '@ngx-translate/core';
+
+@Component({
+  selector: 'app-profile', templateUrl: './privacy.html', styleUrl: './privacy.scss',
+  standalone: true, imports: [TranslatePipe]
+})
+export class Privacy {
+    
+    private translate = inject(TranslateService)
+
+    constructor(
+      private titleService: Title, private metaService: Meta,
+      private platformLocation: PlatformLocation
+    ){
+        
+        // Set meta tags
+        const url
+          = this.platformLocation.protocol + "//" + this.platformLocation.hostname + "/privacy";
+        this.metaService.addTag({ property: 'canonical', content: url });
+        this.metaService.addTag({ property: 'og:url', content: url });
+        this.metaService.addTag({ property: 'og:image', content: url + '/img/logo/leather.png' });
+        this.translate.get(_('PRIVACY.TITLE')).subscribe((resSite: string) => {
+            this.translate.get(_('PROFILE.TITLE')).subscribe((resProfile: string) => {
+                this.titleService.setTitle(resProfile + " - " + resSite);
+                this.metaService.addTag({
+                  property: 'og:title', content: resProfile + " - " + resSite
+                });
+                this.metaService.addTag({
+                  property: 'title', content: resProfile + " - " + resSite
+                });
+            });
+        });
+        this.translate.get(_('PRIVACY.DESCRIPTION')).subscribe((res: string) => {
+            this.metaService.addTag({ property: 'og:description', content: res});
+            this.metaService.addTag({ property: 'description', content: res});
+        });
+    }
+}

+ 28 - 0
frontend/src/app/profile/profile.html

@@ -0,0 +1,28 @@
+@if (profile){
+  <section id='profile'>
+    <div id='top'>
+      @if (profile.images[0]){
+        <img
+          id="img-1" src="{{ apiURL }}{{ profile.images[0].path }}"
+          srcset="{{ utilService.generateSrcset(apiURL + profile.images[0].path) }}"
+        />
+      }
+      <div id="profile-text">
+        @for (t of profile.texts; track t) {
+          <p>{{ t.content }}</p>
+        }
+      </div>
+    </div>
+    <div id="profile-images">
+      @for (i of profile.images; track i) {
+        @if (i.id != 1){
+          <img
+	        id="img-{{ i.id }}" src="{{ apiURL }}{{ i.path }}" alt="{{ i.title }}"
+	        srcset="{{ utilService.generateSrcset(apiURL + i.path) }}"
+	      />
+        }
+      }
+    </div>
+  </section>
+}
+

+ 52 - 0
frontend/src/app/profile/profile.scss

@@ -0,0 +1,52 @@
+@use '../_variables';
+
+section#profile{
+    
+    max-width: 65em;
+    margin: 1em auto;
+    padding:0.5em 1em;
+    
+    img{
+        border-radius: 1em;
+        border: 0.1em solid variables.$primary-brown;
+        display: inline-block;
+        vertical-align: top;
+        position: relative;
+    }
+    
+    div#top{
+        display: block;
+        
+        img#img-1{
+            float: left;
+            margin: 0 2em 1em 1em;
+            max-width: 18em;
+            max-height: 22em;
+            
+            @media screen and (max-width: 800px){max-width: 30%;}
+        }
+    
+        div#profile-text{
+            position: relative;
+            
+            p{margin: 0.3em 0;}
+            
+            p:first-child{font-weight: bold;}
+        }
+    }
+    
+    div#profile-images{
+        text-align: center;
+        
+        img{
+            margin: 0.5em;
+            max-width: 16em;
+            max-height: 16em;
+            
+            @media screen and (max-width: 800px){max-width: 28%;}
+        }
+    }
+}
+
+
+

+ 23 - 0
frontend/src/app/profile/profile.spec.ts

@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { Profile } from './profile';
+
+describe('Profile', () => {
+  let component: Profile;
+  let fixture: ComponentFixture<Profile>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [Profile]
+    })
+    .compileComponents();
+
+    fixture = TestBed.createComponent(Profile);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 50 - 0
frontend/src/app/profile/profile.ts

@@ -0,0 +1,50 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { PlatformLocation } from '@angular/common';
+import { Meta, Title } from '@angular/platform-browser';
+import { TranslateService, _ } from '@ngx-translate/core';
+import { ProfileService } from '../service/profile-service';
+import { UtilService } from '../service/util-service';
+import { environment } from '../../environments/environment';
+
+@Component({ selector: 'app-profile', templateUrl: './profile.html', styleUrl: './profile.scss'})
+export class Profile implements OnInit {
+    profile: any;
+    apiURL: string = environment.apiUrl;
+    utilService: UtilService;
+    
+    private translate = inject(TranslateService)
+    
+
+    constructor(
+      private profileService: ProfileService, private titleService: Title,
+      private metaService: Meta, private platformLocation: PlatformLocation
+    ){
+        this.utilService = new UtilService();
+        
+        // Set meta tags
+        const url
+          = this.platformLocation.protocol + "//" + this.platformLocation.hostname + "/profile";
+        this.metaService.addTag({ property: 'canonical', content: url });
+        this.metaService.addTag({ property: 'og:url', content: url });
+        this.metaService.addTag({ property: 'og:image', content: url + '/img/logo/leather.png' });
+        this.translate.get(_('SITE.TITLE')).subscribe((resSite: string) => {
+            this.translate.get(_('PROFILE.TITLE')).subscribe((resProfile: string) => {
+                this.titleService.setTitle(resProfile + " - " + resSite);
+                this.metaService.addTag({
+                  property: 'og:title', content: resProfile + " - " + resSite
+                });
+                this.metaService.addTag({
+                  property: 'title', content: resProfile + " - " + resSite
+                });
+            });
+        });
+        this.translate.get(_('PROFILE.DESCRIPTION')).subscribe((res: string) => {
+            this.metaService.addTag({ property: 'og:description', content: res});
+            this.metaService.addTag({ property: 'description', content: res});
+        });
+    }
+
+    ngOnInit(): void {
+        this.profileService.getProfile(true, "profile").subscribe(data => { this.profile = data; });
+    }
+}

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

@@ -1,5 +1,5 @@
 import { Component, inject } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
+import { Router, ActivatedRoute } from '@angular/router';
 import { PlatformLocation } from '@angular/common';
 import { Title, Meta } from '@angular/platform-browser';
 import { ProjectService } from '../service/project-service';
@@ -30,7 +30,7 @@ export class Project {
     private maxIndex: number = -1;
 
     constructor(
-      private route: ActivatedRoute, private projectService: ProjectService,
+      private route: ActivatedRoute, private projectService: ProjectService, private router: Router,
       private titleService: Title, private metaService: Meta,
       private platformLocation: PlatformLocation
     ){
@@ -40,7 +40,8 @@ export class Project {
     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.projectService.getProject("" + id).subscribe(
+          data => {
             this.project = data;
             this.maxIndex = this.project.images.length;
             
@@ -75,7 +76,9 @@ export class Project {
                   { property: 'og:title', content: this.project?.title + " - " + res }
                 );
             });
-        });
+        },
+        err => { this.router.navigate(['/error']); }
+      );
     }
 
     /**

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

@@ -12,7 +12,10 @@ export class ProfileService {
   private translate = inject(TranslateService);
   constructor(private http: HttpClient) { }
 
-  getProfile(): Observable<ProfileModel[]> {
-    return this.http.get<ProfileModel[]>(this.apiUrl + "?lang=" + this.translate.getCurrentLang());
+  getProfile(images: any, texts: string): Observable<ProfileModel[]> {
+    return this.http.get<ProfileModel[]>(
+      this.apiUrl + "?lang=" + this.translate.getCurrentLang()
+      + "&images=" + images + "&texts=" + texts
+    );
   }
 }