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

All main site sections are working.

Iñigo Valentin 1 éve
szülő
commit
681ee8c13a
96 módosított fájl, 6354 hozzáadás és 2675 törlés
  1. 3 0
      backend/inigovalentin/.gitignore
  2. 3 1
      backend/inigovalentin/app/controllers/lang.controller.js
  3. 56 0
      backend/inigovalentin/app/controllers/message.controller.js
  4. 27 8
      backend/inigovalentin/app/controllers/project.controller.js
  5. 125 109
      backend/inigovalentin/app/controllers/text.controller.js
  6. 93 149
      backend/inigovalentin/app/controllers/user.controller.js
  7. 22 5
      backend/inigovalentin/app/models/index.js
  8. 7 7
      backend/inigovalentin/app/models/lang.model.js
  9. 8 8
      backend/inigovalentin/app/models/license.model.js
  10. 19 0
      backend/inigovalentin/app/models/message.model.js
  11. 14 10
      backend/inigovalentin/app/models/project-image.model.js
  12. 13 9
      backend/inigovalentin/app/models/project-type.model.js
  13. 13 9
      backend/inigovalentin/app/models/project-url-type.model.js
  14. 13 9
      backend/inigovalentin/app/models/project-url.model.js
  15. 15 15
      backend/inigovalentin/app/models/project.model.js
  16. 12 8
      backend/inigovalentin/app/models/tag.model.js
  17. 14 10
      backend/inigovalentin/app/models/text.model.js
  18. 16 0
      backend/inigovalentin/app/models/user-text.model.js
  19. 17 0
      backend/inigovalentin/app/models/user-url.model.js
  20. 15 16
      backend/inigovalentin/app/models/user.model.js
  21. 6 6
      backend/inigovalentin/app/routes/access.routes.js
  22. 19 24
      backend/inigovalentin/app/routes/lang.routes.js
  23. 13 0
      backend/inigovalentin/app/routes/message.routes.js
  24. 13 16
      backend/inigovalentin/app/routes/project.routes.js
  25. 19 24
      backend/inigovalentin/app/routes/text.routes.js
  26. 23 0
      backend/inigovalentin/app/routes/user.routes.js
  27. 18 21
      backend/inigovalentin/app/services/auth.service.js
  28. 211 0
      backend/inigovalentin/app/services/auth.service.spec.js
  29. 61 41
      backend/inigovalentin/app/services/locale.service.js
  30. 319 0
      backend/inigovalentin/app/services/locale.service.spec.ts
  31. 6 0
      backend/inigovalentin/config/db.config.js
  32. 20 0
      backend/inigovalentin/env/test.env
  33. 3327 378
      backend/inigovalentin/package-lock.json
  34. 7 1
      backend/inigovalentin/package.json
  35. 2 0
      backend/inigovalentin/server.js
  36. 11 5
      frontend/inigovalentin/angular.json
  37. 66 0
      frontend/inigovalentin/package-lock.json
  38. 1 0
      frontend/inigovalentin/package.json
  39. BIN
      frontend/inigovalentin/public/font/Handwritten.ttf
  40. BIN
      frontend/inigovalentin/public/font/NotoMono-Regular.ttf
  41. BIN
      frontend/inigovalentin/public/font/Nunito-Bold.ttf
  42. BIN
      frontend/inigovalentin/public/font/Nunito-Light.ttf
  43. BIN
      frontend/inigovalentin/public/font/Nunito-Regular.ttf
  44. 61 0
      frontend/inigovalentin/public/img/control/close.svg
  45. 56 0
      frontend/inigovalentin/public/img/control/slid_d.svg
  46. 56 0
      frontend/inigovalentin/public/img/control/slid_l.svg
  47. 56 0
      frontend/inigovalentin/public/img/control/slid_r.svg
  48. 56 0
      frontend/inigovalentin/public/img/control/slid_u.svg
  49. 0 84
      frontend/inigovalentin/public/img/license/CC-BY-ND.svg
  50. 5 0
      frontend/inigovalentin/public/img/license/CC-BY-SA.svg
  51. 41 10
      frontend/inigovalentin/public/js/project.js
  52. 14 10
      frontend/inigovalentin/public/js/ui.js
  53. 0 0
      frontend/inigovalentin/src/app/app.component.css
  54. 4 4
      frontend/inigovalentin/src/app/app.component.html
  55. 4 3
      frontend/inigovalentin/src/app/app.component.ts
  56. 9 17
      frontend/inigovalentin/src/app/app.routes.ts
  57. 0 0
      frontend/inigovalentin/src/app/components/footer/footer.component.css
  58. 29 3
      frontend/inigovalentin/src/app/components/footer/footer.component.html
  59. 16 3
      frontend/inigovalentin/src/app/components/footer/footer.component.ts
  60. 51 0
      frontend/inigovalentin/src/app/components/message/message.component.html
  61. 23 0
      frontend/inigovalentin/src/app/components/message/message.component.spec.ts
  62. 64 0
      frontend/inigovalentin/src/app/components/message/message.component.ts
  63. 0 74
      frontend/inigovalentin/src/app/components/navbar/navbar.component.css
  64. 1 2
      frontend/inigovalentin/src/app/components/navbar/navbar.component.ts
  65. 0 73
      frontend/inigovalentin/src/app/components/project-preview/project-preview.component.css
  66. 20 20
      frontend/inigovalentin/src/app/components/project-preview/project-preview.component.html
  67. 1 2
      frontend/inigovalentin/src/app/components/project-preview/project-preview.component.ts
  68. BIN
      frontend/inigovalentin/src/app/components/project/.project.component.css.kate-swp
  69. 0 340
      frontend/inigovalentin/src/app/components/project/project.component.css
  70. 9 13
      frontend/inigovalentin/src/app/components/project/project.component.html
  71. 1 2
      frontend/inigovalentin/src/app/components/project/project.component.ts
  72. 12 0
      frontend/inigovalentin/src/app/models/message.ts
  73. 10 0
      frontend/inigovalentin/src/app/models/user.ts
  74. 0 0
      frontend/inigovalentin/src/app/pages/home/home.component.css
  75. 26 1
      frontend/inigovalentin/src/app/pages/home/home.component.html
  76. 34 4
      frontend/inigovalentin/src/app/pages/home/home.component.ts
  77. 24 0
      frontend/inigovalentin/src/app/pages/profile/profile.component.html
  78. 23 0
      frontend/inigovalentin/src/app/pages/profile/profile.component.spec.ts
  79. 31 0
      frontend/inigovalentin/src/app/pages/profile/profile.component.ts
  80. 0 339
      frontend/inigovalentin/src/app/pages/project/project.component.css
  81. 37 42
      frontend/inigovalentin/src/app/pages/project/project.component.html
  82. 1 2
      frontend/inigovalentin/src/app/pages/project/project.component.ts
  83. 0 0
      frontend/inigovalentin/src/app/pages/projects/projects.component.css
  84. 4 1
      frontend/inigovalentin/src/app/pages/projects/projects.component.html
  85. 2 7
      frontend/inigovalentin/src/app/pages/projects/projects.component.ts
  86. 4 0
      frontend/inigovalentin/src/app/services/projects.service.ts
  87. 16 0
      frontend/inigovalentin/src/app/services/user.service.spec.ts
  88. 11 0
      frontend/inigovalentin/src/app/services/user.service.ts
  89. 2 1
      frontend/inigovalentin/src/index.html
  90. 5 6
      frontend/inigovalentin/src/main.ts
  91. 0 721
      frontend/inigovalentin/src/styles.css
  92. 49 0
      frontend/inigovalentin/src/styles/_color.scss
  93. 11 0
      frontend/inigovalentin/src/styles/_font.scss
  94. 823 0
      frontend/inigovalentin/src/styles/styles.scss
  95. 3 1
      frontend/inigovalentin/tsconfig.app.json
  96. 2 1
      frontend/inigovalentin/tsconfig.spec.json

+ 3 - 0
backend/inigovalentin/.gitignore

@@ -28,3 +28,6 @@ logs
 
 # SQL files
 sql/
+
+# Testing
+coverage/

+ 3 - 1
backend/inigovalentin/app/controllers/lang.controller.js

@@ -80,7 +80,9 @@ exports.findOne = (req, res) => {
     Lang.findByPk(code)
     .then(data => {
         if (data) res.send(data);
-        else res.status(404).send(`No language with code ${code}.`});
+        else res.status(404).send(`No language with code ${code}.`
+            
+        );
     })
     .catch(err => {
         logger.error("Error retrieving language with code " + code + ": " + err);

+ 56 - 0
backend/inigovalentin/app/controllers/message.controller.js

@@ -0,0 +1,56 @@
+/**
+ * @file Provides the model and operation for messages.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+const logger = require('pino')();
+const db = require("../models");
+const User = db.users;
+const Message = db.messages;
+const Op = db.Sequelize.Op;
+
+/**
+ * Create and save a new message.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
+exports.create = async (req, res) => {
+    // Validate request
+    let missing = [];
+    if (!req.body.name) missing.push("name");
+    if (!req.body.email) missing.push("email");
+    if (!req.body.text) missing.push("text");
+    if (missing.length > 0){
+        let message = "Missing required fields: [";
+        for (let i = 0; i < missing.length; i ++){
+            message += missing[i];
+            if (i < missing.length - 1) message += ", ";
+        }
+        message += "].";
+        res.status(400).send(message);
+        return;
+    }
+    const name = req.body.name;
+    const email = req.body.email;
+    const subject = req.body.subject;
+    const text = req.body.text;
+    const lang = req.body.lang;
+    //const user = User.findActive().id;
+    const user = (await User.findOne({where: {active: true}})).id;
+    console.log("ACTIVES: " + user);
+    
+    // Create a message
+    const message = {
+      userId: user, name: name, email: email, subject: subject, text: text, lang: lang
+    };
+    
+    // Save Project in the database
+    Message.create(message)
+    .then(data => {res.send(data);})
+    .catch(err => {
+        logger.error("Error creating message: " + err);
+        res.status(500).send("Error creating message.");
+    });
+};

+ 27 - 8
backend/inigovalentin/app/controllers/project.controller.js

@@ -4,7 +4,7 @@
  * @since 4.0.0
  */
 
-const logger = require('pino')()
+const logger = require('pino')();
 const db = require("../models");
 const Project = db.projects;
 const Op = db.Sequelize.Op;
@@ -88,7 +88,7 @@ exports.create = (req, res) => {
     .then(data => {res.send(data);})
     .catch(err => {
         logger.error("Error creating project: " + err);
-        res.status(500).send({"Error creating project."});
+        res.status(500).send("Error creating project.");
     });
 };
 
@@ -100,9 +100,9 @@ exports.create = (req, res) => {
  */
 exports.findAll = (req, res) => {
     Project.findAll({
-        where: {visible: true,}, 
+        where: {visible: true},
         include: ["license", "type", "tags", {model: ProjectUrl, include: "type"}],
-        attributes: { exclude: ['licenseId', 'projectTypeId'] },
+        attributes: {exclude: ['licenseId', 'projectTypeId']},
         order: [['idx', 'ASC'], ['id', 'DESC']]
     })
     .then(async data => {
@@ -115,6 +115,25 @@ exports.findAll = (req, res) => {
     });
 };
 
+exports.findTop = (req, res) => {
+    const limit = !isNaN(req.params.total) ? Number(req.params.total) : 3;
+    Project.findAll({
+        where: {visible: true},
+        include: ["license", "type", "tags", {model: ProjectUrl, include: "type"}],
+        attributes: {exclude: ['licenseId', 'projectTypeId']},
+        order: [['idx', 'ASC'], ['id', 'DESC']],
+        limit: limit
+    })
+    .then(async data => {
+        data = await localeService.localizeProjects(data, req);
+        res.send(data);
+    })
+    .catch(err => {
+        logger.error("Error retrieving projects: " + err);
+        res.status(500).send("Error retrieving projects.");
+    });
+};
+
 /**
  * Find a single project by it's ID or permalink.
  * 
@@ -133,7 +152,7 @@ exports.findOne = (req, res) => {
             data = await localeService.localizeProject(data, req);
             res.send(data);
         }
-        else res.status(404).send(`No project with id or permalink ${id}.`});
+        else res.status(404).send(`No project with id or permalink ${id}.`);
     })
     .catch(err => {
         logger.error("Error retrieving project with id or permalink " + id + ": " + err);
@@ -155,7 +174,7 @@ exports.update = (req, res) => {
         return;
     }
     const id = req.params.id;
-    Project.update(req.body, {where: {[Op.or]: {permalink: id, id: id }}, user: user}})
+    Project.update(req.body, {where: {[Op.or]: {permalink: id, id: id }}, user: user})
     .then(num => {
         if (num == 1) res.send("Project updated successfully.");
         else res.send(`Cannot update project with id or permalink ${id}.`);
@@ -181,10 +200,10 @@ exports.delete = (req, res) => {
     }
     const user = validation.user;
     const id = req.params.id;
-    Project.destroy({where: {[Op.or]: {permalink: id, id: id }}, user: user}})
+    Project.destroy({where: {[Op.or]: {permalink: id, id: id }}, user: user})
     .then(num => {
         if (num == 1) res.send({message: "Project deleted successfully."});
-        else  res.send({message: `Cannot delete project with id or permalink ${id}.`});
+        else res.send(`Cannot delete project with id or permalink ${id}.`);
     })
     .catch(err => {
         logger.error("Error deleting project with id or permalink " + id + ": " + err);

+ 125 - 109
backend/inigovalentin/app/controllers/text.controller.js

@@ -1,153 +1,169 @@
+/**
+ * @file Provides the model and operation for texts.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+const logger = require('pino')();
 const db = require("../models");
-const Text = db.texts;
+const Text = db.text;
 const Op = db.Sequelize.Op;
+const AuthService = require("../services/auth.service.js");
+const authService = new AuthService(db);
 
-// Create and Save a new Text
+/**
+ * Create and save a new text.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
 exports.create = (req, res) => {
-    // Validate request
-    if (!req.body.title) {
-        res.status(400).send({
-            message: "Content can not be empty!"
-        });
+    // Check authentication
+    if (authService.validateToken(req, res).success === false){
+        res.status(validation.code).send(validation.message);
         return;
     }
     
+    // Validate request
+    let missing = [];
+    if (!req.body.id) missing.push("id");
+    if (!req.body.lang) missing.push("lang");
+    if (!req.body.section) missing.push("section");
+    if (!req.body.text && !req.body.file) missing.push("text or file");
+    if (missing.length > 0){
+        let message = "Missing required fields: [";
+        for (let i = 0; i < missing.length; i ++){
+            message += missing[i];
+            if (i < missing.length - 1) message += ", ";
+        }
+        message += "].";
+        res.status(400).send(message);
+        return;
+    }
+    // TODO: Validate
+    // TODO: Accept only text or file
+
     // Create a Text
-    const Text = {
-        title: req.body.title,
-        description: req.body.description,
-        published: req.body.published ? req.body.published : false
-    };
+    const text = {id: id, lang: lang, section: section, text: text, file: file};
     
-    // Save Text in the database
-    Text.create(Text)
-    .then(data => {
-        res.send(data);
-    })
+    // Save text in the database
+    Text.create(text)
+    .then(data => {res.send(data);})
     .catch(err => {
-        res.status(500).send({
-            message:
-            err.message || "Some error occurred while creating the Text."
-        });
+        logger.error("Error creating text: " + err);
+        res.status(500).send("Error creating text.");
     });
 };
 
-// Retrieve all Texts from the database.
+/**
+ * Retrieve all texts from the database.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
 exports.findAll = (req, res) => {
-    //const title = req.query.title;
-    //var condition = title ? { title: { [Op.like]: `%${title}%` } } : null;
-    
-    //Text.findAll({ where: condition })
+    // TODO: There is very little reason for this to exist
+    // Check authentication
+    if (authService.validateToken(req, res).success === false){
+        res.status(validation.code).send(validation.message);
+        return;
+    }
     Text.findAll()
-    .then(data => {
-        res.send(data);
-    })
+    .then(async data => {res.send(data);})
     .catch(err => {
-        res.status(500).send({
-            message:
-            err.message || "Some error occurred while retrieving Texts."
-        });
+        logger.error("Error retrieving texts: " + err);
+        res.status(500).send("Error retrieving texts.");
     });
 };
 
-// Find a single Text with an id
+/**
+ * Find a single project.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
 exports.findOne = (req, res) => {
     const id = req.params.id;
-    
-    Text.findByPk(id)
-    .then(data => {
-        if (data) {
-            res.send(data);
-        } else {
-            res.status(404).send({
-                message: `Cannot find Text with id=${id}.`
-            });
-        }
+    const lang = req.params.lang;
+    // TODO: Validate id and lang
+    Text.findOne({where: {id: id, lang: lang}})
+    .then(async data => {
+        if (data) res.send(data);
+        else res.status(404).send(`No text with id ${id} nd lang ${lang}.`);
     })
     .catch(err => {
-        res.status(500).send({
-            message: "Error retrieving Text with id=" + id
-        });
+        logger.error("Error retrieving text with id " + id + " in lang " + lang + ": " + err);
+        res.status(500).send("Error retrieving text with id " + id + " in lang " + lang + ".");
     });
 };
 
-// Update a Text by the id in the request
+/**
+ * Update a single text.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
 exports.update = (req, res) => {
-    const id = req.params.id;
-    
-    Text.update(req.body, {
-        where: { id: id }
-    })
+    // Check authentication
+    if (authService.validateToken(req, res).success === false){
+        res.status(validation.code).send(validation.message);
+        return;
+    }
+    const id = req.body.id;
+    const lang = req.body.lang;
+    Text.update(req.body, {where: {id: id, lang: lang}})
     .then(num => {
-        if (num == 1) {
-            res.send({
-                message: "Text was updated successfully."
-            });
-        } else {
-            res.send({
-                message: `Cannot update Text with id=${id}. Maybe Text was not found or req.body is empty!`
-            });
-        }
+        if (num == 1) res.send("Text updated successfully.");
+        else res.send(`Cannot update text with id ${id} nd lang ${lang}.`);
     })
     .catch(err => {
-        res.status(500).send({
-            message: "Error updating Text with id=" + id
-        });
+        logger.error("Error updating text with id " + id + " in lang " + lang + ": " + err);
+        res.status(500).send("Error updating text with id " + id + " in lang " + lang + ".");
     });
 };
 
-// Delete a Text with the specified id in the request
+/**
+ * Delete a single text.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
 exports.delete = (req, res) => {
-    const id = req.params.id;
-    
-    Text.destroy({
-        where: { id: id }
-    })
+    // Check authentication
+    if (authService.validateToken(req, res).success === false){
+        res.status(validation.code).send(validation.message);
+        return;
+    }
+    const id = req.body.id;
+    const lang = req.body.lang;
+    Project.destroy({where: {id: id, lang: lang}})
     .then(num => {
-        if (num == 1) {
-            res.send({
-                message: "Text was deleted successfully!"
-            });
-        } else {
-            res.send({
-                message: `Cannot delete Text with id=${id}. Maybe Text was not found!`
-            });
-        }
+        if (num == 1) res.send("Text deleted successfully.");
+        else res.send(`Cannot delete text with id ${id} nd lang ${lang}.`);
     })
     .catch(err => {
-        res.status(500).send({
-            message: "Could not delete Text with id=" + id
-        });
+        logger.error("Error deleting text with id " + id + " in lang " + lang + ": " + err);
+        res.status(500).send("Error deleting text with id " + id + " in lang " + lang + ".");
     });
 };
 
-// Delete all Texts from the database.
+/**
+ * Delete all texts.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
 exports.deleteAll = (req, res) => {
-    Text.destroy({
-        where: {},
-        truncate: false
-    })
-    .then(nums => {
-        res.send({ message: `${nums} Texts were deleted successfully!` });
-    })
-    .catch(err => {
-        res.status(500).send({
-            message:
-            err.message || "Some error occurred while removing all Texts."
-        });
-    });
-};
-
-// Find all published Texts
-exports.findAllPublished = (req, res) => {
-    Text.findAll({ where: { published: true } })
-    .then(data => {
-        res.send(data);
-    })
+    // TODO: There is very little reason for this to exist
+    // Check authentication
+    if (authService.validateToken(req, res).success === false){
+        res.status(validation.code).send(validation.message);
+        return;
+    }
+    Text.destroy({truncate: false})
+    .then(nums => {res.send(`${nums} texts deleted successfully.`);})
     .catch(err => {
-        res.status(500).send({
-            message:
-            err.message || "Some error occurred while retrieving Texts."
-        });
+        logger.error("Error deleting texts: " + err);
+        res.status(500).send("Error deleting texts.");
     });
 };

+ 93 - 149
backend/inigovalentin/app/controllers/user.controller.js

@@ -4,11 +4,37 @@
  * @since 4.0.0
  */
 
-const sha1 = require('sha1');
-const jwt = require('jsonwebtoken');
-const db = require("../models");
-const User = db.users;
-const Op = db.Sequelize.Op;
+const logger = require('pino')();
+const LocaleService = require('../services/locale.service.js');
+const localeService = new LocaleService();
+//const AuthService = require("../services/auth.service.js");
+//const authService = new AuthService(db);
+
+/**
+ * Retrieve the active user info.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
+exports.findActive = (req, res) => {
+    User.findAll({
+      where: {active: true},
+      include: [
+        {model: UserUrl, as: "urls", attributes: {exclude: ['id', 'userId', 'priority'], order: [['priority', 'ASC']]}},
+        {model: UserText, as: "texts", attributes: {exclude: ['id', 'userId', 'section', 'global']}}
+      ],
+      attributes: {exclude: ['id', 'password', 'email', 'salt', 'active', 'admin', 'createdAt', 'updatedAt']},
+    })
+    .then(async data => {
+        data = await localeService.localizeUser(data[0], req);
+        res.send(data);
+    })
+    .catch(err => {
+        logger.error("Error retrieving active user: " + err);
+        res.status(500).send("Error retrieving active user.");
+    });
+};
+
 
 /**
  * Retrieve all users from the database.
@@ -17,12 +43,21 @@ const Op = db.Sequelize.Op;
  * @param res The request to be sent by the server.
  */
 exports.findAll = (req, res) => {
+    // Check authentication
+    const validation = authService.validateToken(req, res);
+    if (validation.success === false){
+        res.status(validation.code).send(validation.message);
+        return;
+    }
     User.findAll()
     .then(async data => {
         data = await localeService.localizeUsers(data, req);
         res.send(data);
     })
-    .catch(err => {res.status(500).send({message: err.message || "Some error occurred while retrieving users."});});
+    .catch(err => {
+        logger.error("Error retrieving users: " + err);
+        res.status(500).send("Error retrieving users.");
+    });
 };
 
 /**
@@ -32,6 +67,12 @@ exports.findAll = (req, res) => {
  * @param res The request to be sent by the server.
  */
 exports.findOne = (req, res) => {
+    // Check authentication
+    const validation = authService.validateToken(req, res);
+    if (validation.success === false){
+        res.status(validation.code).send(validation.message);
+        return;
+    }
     const id = req.params.id;
     User.findByPk(id)
     .then(async data => {
@@ -39,156 +80,59 @@ exports.findOne = (req, res) => {
             data = await localeService.localizeUser(data, req);
             res.send(data);
         }
-        else res.status(404).send({message: `Cannot find user with id=${id}.`})
+        else res.status(404).send(`No user with id ${id}.`)
     })
-    .catch(err => {res.status(500).send({message: "Error retrieving user with id=" + id});});
+    .catch(err => {
+        logger.error("Error retrieving user with id " + id + ": " + err);
+        res.status(500).send("Error retrieving user with id " + id + ".");
+    });
 };
 
 /**
- * Find a single user by it's username or email.
+ * Update a single user by it's ID.
  * 
  * @param req The received request by the server.
  * @param res The request to be sent by the server.
  */
-exports.findByUsernameOrEmail = (req, res) => {
-    const username = req.method === 'POST' ? req.body.username : req.params.username;
-    const email = req.method === 'POST' ? req.body.email : req.params.email;
-    if (!email && !username) res.status(400).send("Username or email required");
-    User.findOne({where: {[Op.or]: {username: username, email: email}}})
-    .then(async data => {
-        if (data) {
-            data = await localeService.localizeUser(data, req);
-            res.send(data);
-        }
-        else res.status(404).send({message: `Cannot find user with username ${username} or email ${email}.`})
+exports.update = (req, res) => {
+    // Check authentication
+    const validation = authService.validateToken(req, res);
+    if (validation.success === false){
+        res.status(validation.code).send(validation.message);
+        return;
+    }
+    const id = req.params.id;
+    User.update(req.body, {where: {id: id}})
+    .then(num => {
+        if (num == 1) res.send({message: "License was updated successfully."});
+        else res.send(`Cannot update user with id=${id}.`);
     })
-    .catch(err => {res.status(500).send({message: "Error retrieving user."});});
+    .catch(err => {
+        logger.error("Error updateing user: " + err);
+        res.status(500).send("Error updating user.");
+    });
 };
-    
-    /**
-     * Update a single license by it's ID.
-     * 
-     * @param req The received request by the server.
-     * @param res The request to be sent by the server.
-     */
-    exports.update = (req, res) => {
-        const id = req.params.id;
-        License.update(req.body, {where: {id: id}})
-        .then(num => {
-            if (num == 1) res.send({message: "License was updated successfully."});
-            else res.send({message: `Cannot update License with id=${id}. Maybe License was not found or req.body is empty!`});
-        })
-        .catch(err => {res.status(500).send({message: "Error updating License with id=" + id});});
-    };
-    
-    /**
-     * Delete a single license by it's ID.
-     * 
-     * @param req The received request by the server.
-     * @param res The request to be sent by the server.
-     */
-    exports.delete = (req, res) => {
-        const id = req.params.id;
-        License.destroy({where: {id: id}})
-        .then(num => {
-            if (num == 1) res.send({message: "License was deleted successfully!"});
-            else res.send({message: `Cannot delete License with id=${id}. Maybe License was not found!`});
-        })
-        .catch(err => {res.status(500).send({message: "Could not delete License with id=" + id});});
-    };
-    
-    /**
-     * Delete all licenses.
-     * 
-     * @param req The received request by the server.
-     * @param res The request to be sent by the server.
-     */
-    exports.deleteAll = (req, res) => {
-        License.destroy({
-            where: {},
-            truncate: false
-        })
-        .then(nums => {res.send({ message: `${nums} Licenses were deleted successfully!` });})
-        .catch(err => {res.status(500).send({message: err.message || "Some error occurred while removing all Licenses."});});
-    };
-    
-    /**
-     * Create and save a new user.
-     * 
-     * @param req The received request by the server.
-     * @param res The request to be sent by the server.
-     */
-    exports.create = async (req, res) => {
-        return res.status(405).send("User creation is disabled.");
-        try {
-            // Get user input
-            const {username, firstName, lastName, email, password} = req.body;
-            
-            // Validate user input
-            if (!(email && password && username)) {res.status(400).send("Credentials required.");}
-            
-            // check if user already exist
-            // Validate if user exist in our database
-            let existing = await User.findByUsernameOrEmail({username}, {email});
-            if (existing) {return res.status(409).send("User already exists. Please login.");}
-            existing = await User.findByEmail({email});
-            if (existing) {return res.status(409).send("User already exists. Please login.");}
-            
-            //Encrypt user password
-            encryptedUserPassword = await bcrypt.hash(password, 10);
-            
-            // Create user in our database
-            const user = await User.create({
-                first_name: firstName,
-                last_name: lastName,
-                email: email.toLowerCase(),
-                password: encryptedUserPassword,
-            });
-            
-            // Create token
-            const token = jwt.sign({user_id: user.id}, 'process.env.TOKEN_KEY', {expiresIn: "5h"});
-            // save user token
-            user.token = token;
-            User.update({token: token}, {where: {id: user.id }})
-            return res.status(200).json({token: token});
-        }
-        catch (err) {
-            console.log("Error registering user:" + err);
-        }
-    };
-    
-    /**
-     * Log a user in.
-     * 
-     * @param req The received request by the server.
-     * @param res The request to be sent by the server.
-     */
-    exports.login = async (req, res) => {
-        try {
-            // Get user input
-            const email = req.body.email ? req.body.email : "";
-            const username = req.body.username ? req.body.username : "";
-            const password = req.body.password;
-            // Validate user input
-            if (!(password && (email || username))) res.status(400).send("Credentials required");
-            
-            // Validate if user exist in our database
-            const user = await User.findOne({where: {[Op.or]: {username: username, email: email}}});
-            //encryptedUserPassword = await bcrypt.hash(password, salt);
-            //dbUserPassword = await bcrypt.hash(user.password, salt);
-            //if (user && (await bcrypt.compare(password, user.password))) {
-            //if (user && (await bcrypt.hash(user.password, salt) == await bcrypt.hash(password, salt))) {
-            if (user && user.password == sha1(user.hash + password)){
-                // Create token
-                const token = jwt.sign({user_id: user.id}, 'process.env.TOKEN_KEY', {expiresIn: "5h"});
-                // Save user token
-                user.token = token;
-                User.update({token: token}, {where: {id: user.id }})
-                return res.status(200).json({token: token});
-            }
-            return res.status(400).send("Invalid Credentials");
-        }
-        catch (err) {
-            console.log("Error logging in: " + err);
-        }
-    };
+
+/**
+ * Delete a single user by it's ID. Currently disabled.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
+exports.delete = (req, res) => {return res.status(405).send("User deletion is disabled.");};
+
+/**
+ * Delete all users. Currently disabled.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
+exports.deleteAll = (req, res) => {return res.status(405).send("User deletion is disabled.");};
+
+/**
+ * Create and save a new user. Currently disabled.
+ * 
+ * @param req The received request by the server.
+ * @param res The request to be sent by the server.
+ */
+exports.create = async (req, res) => {return res.status(405).send("User creation is disabled.");};

+ 22 - 5
backend/inigovalentin/app/models/index.js

@@ -1,3 +1,9 @@
+/**
+ * @file The index for the models. Model relations are also defined here.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 const dbConfig = require("../../config/db.config.js");
 
 const Sequelize = require("sequelize");
@@ -15,9 +21,9 @@ const db = {};
 db.Sequelize = Sequelize;
 db.sequelize = sequelize;
 
-db.users = require("./user.model.js")(sequelize, Sequelize);
 db.langs = require("./lang.model.js")(sequelize, Sequelize);
 db.texts = require("./text.model.js")(sequelize, Sequelize);
+db.users = require("./user.model.js")(sequelize, Sequelize);
 db.licenses = require("./license.model.js")(sequelize, Sequelize);
 db.projects = require("./project.model.js")(sequelize, Sequelize);
 db.projectTypes = require("./project-type.model.js")(sequelize, Sequelize);
@@ -25,6 +31,9 @@ db.projectUrlTypes = require("./project-url-type.model.js")(sequelize, Sequelize
 db.projectUrls = require("./project-url.model.js")(sequelize, Sequelize);
 db.projectImages = require("./project-image.model.js")(sequelize, Sequelize);
 db.tags = require("./tag.model.js")(sequelize, Sequelize);
+db.userTexts = require("./user-text.model.js")(sequelize, Sequelize);
+db.userUrls = require("./user-url.model.js")(sequelize, Sequelize);
+db.messages = require("./message.model.js")(sequelize, Sequelize);
 
 
 db.projects.belongsTo(db.licenses, {foreignKey: 'licenseId', as: "license"});
@@ -33,9 +42,9 @@ db.licenses.hasMany(db.projects);
 db.projects.belongsTo(db.projectTypes, {foreignKey: 'projectTypeId', as: "type"});
 db.projectTypes.hasMany(db.projects);
 
-const ProjectTag = sequelize.define("project-tag",{},{ timestamps: false });
-db.projects.belongsToMany(db.tags, { through: ProjectTag });
-db.tags.belongsToMany(db.projects, { through: ProjectTag });
+const ProjectTag = sequelize.define("project-tag", {}, {timestamps: false});
+db.projects.belongsToMany(db.tags, {through: ProjectTag});
+db.tags.belongsToMany(db.projects, {through: ProjectTag});
 
 db.projectUrls.belongsTo(db.projectUrlTypes, {foreignKey: 'projectUrlTypeId', as: "type"});
 db.projectUrlTypes.hasMany(db.projectUrls);
@@ -43,8 +52,16 @@ db.projectUrlTypes.hasMany(db.projectUrls);
 db.projectUrls.belongsTo(db.projects, {foreignKey: 'projectId', as: "project"});
 db.projects.hasMany(db.projectUrls);
 
-
 db.projectImages.belongsTo(db.projects, {foreignKey: 'projectId', as: "project"});
 db.projects.hasMany(db.projectImages);
 
+db.userTexts.belongsTo(db.users, {foreignKey: 'userId'});
+db.users.hasMany(db.userTexts, {as: "texts"});
+
+db.userUrls.belongsTo(db.users, {foreignKey: 'userId'});
+db.users.hasMany(db.userUrls, {as: "urls"});
+
+db.messages.belongsTo(db.users, {foreignKey: 'userId'});
+db.users.hasMany(db.messages, {as: "messages"});
+
 module.exports = db;

+ 7 - 7
backend/inigovalentin/app/models/lang.model.js

@@ -5,11 +5,11 @@
  */
 
 module.exports = (sequelize, Sequelize) => {
-    const Lang = sequelize.define("lang", {
-      code: {allowNull: false, primaryKey: true, type: Sequelize.STRING(8)},
-      name: {allowNull: false, type: Sequelize.STRING(64)},
-      priority: {allowNull: false, type: Sequelize.INTEGER},
-      active: {allowNull: false, type: Sequelize.BOOLEAN}
-    }, {timestamps: false});
-    return Lang;
+  const Lang = sequelize.define("lang", {
+    code: {allowNull: false, primaryKey: true, type: Sequelize.STRING(8)},
+    name: {allowNull: false, type: Sequelize.STRING(64)},
+    priority: {allowNull: false, type: Sequelize.INTEGER},
+    active: {allowNull: false, type: Sequelize.BOOLEAN}
+  }, {timestamps: false});
+  return Lang;
 }

+ 8 - 8
backend/inigovalentin/app/models/license.model.js

@@ -5,12 +5,12 @@
  */
 
 module.exports = (sequelize, Sequelize) => {
-    const License = sequelize.define("license", {
-      id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
-      name: {allowNull: false, unique: true, type: Sequelize.STRING(32)},
-      summary: {allowNull: false, type: Sequelize.STRING(64)},
-      legal: {allowNull: false, type: Sequelize.STRING(64)},
-      icon: {type: Sequelize.STRING(64)},
-    }, {timestamps: false});
-    return License;
+  const License = sequelize.define("license", {
+    id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
+    name: {allowNull: false, unique: true, type: Sequelize.STRING(32)},
+    summary: {allowNull: false, type: Sequelize.STRING(64)},
+    legal: {allowNull: false, type: Sequelize.STRING(64)},
+    icon: {type: Sequelize.STRING(64)},
+  }, {timestamps: false});
+  return License;
 }

+ 19 - 0
backend/inigovalentin/app/models/message.model.js

@@ -0,0 +1,19 @@
+/**
+ * @file Provides the model for projects.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+module.exports = (sequelize, Sequelize) => {
+    Message = sequelize.define("message", {
+        id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
+        userId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "users", key: "id"}},
+        name: {allowNull: false, type: Sequelize.STRING(127)},
+        email: {allowNull: false, type: Sequelize.STRING(127)},
+        subject: {type: Sequelize.STRING(127)},
+        text: {allowNull: false, type: Sequelize.STRING(5000)},
+        lang: {type: Sequelize.STRING(8)},
+        read: {allowNull: false, defaultValue: false, type: Sequelize.BOOLEAN},
+    });
+    return Message;
+}

+ 14 - 10
backend/inigovalentin/app/models/project-image.model.js

@@ -1,12 +1,16 @@
+/**
+ * @file Provides the model for project images.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = (sequelize, Sequelize) => {
-    const ProjectImage = sequelize.define("project-image", {
-        id: {allowNull: false, primaryKey: true, type: Sequelize.STRING(32)},
-        projectId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "projects", key: "id"}},
-        idx: {type: Sequelize.SMALLINT}, // TODO REFERENCE
-        image: {type: Sequelize.STRING(128)},
-        alt: {type: Sequelize.STRING(64)},
-    },
-    {timestamps: false}
-    );
-    return ProjectImage;
+  const ProjectImage = sequelize.define("project-image", {
+    id: {allowNull: false, primaryKey: true, type: Sequelize.STRING(32)},
+    projectId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "projects", key: "id"}},
+    idx: {type: Sequelize.SMALLINT}, // TODO REFERENCE
+    image: {type: Sequelize.STRING(128)},
+    alt: {type: Sequelize.STRING(64)},
+  }, {timestamps: false});
+  return ProjectImage;
 }

+ 13 - 9
backend/inigovalentin/app/models/project-type.model.js

@@ -1,11 +1,15 @@
+/**
+ * @file Provides the model for project types.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = (sequelize, Sequelize) => {
-    ProjectType = sequelize.define("project-type", {
-        id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
-        title: {allowNull: false, type: Sequelize.STRING(64), references: {model: "texts", key: "id"}},
-        summary: {type: Sequelize.STRING(512), references: {model: "texts", key: "id"}},
-        icon: {type: Sequelize.STRING(64)},
-    },
-    {timestamps: false}
-    );
-    return ProjectType;
+  ProjectType = sequelize.define("project-type", {
+    id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
+    title: {allowNull: false, type: Sequelize.STRING(64), references: {model: "texts", key: "id"}},
+    summary: {type: Sequelize.STRING(512), references: {model: "texts", key: "id"}},
+    icon: {type: Sequelize.STRING(64)},
+  }, {timestamps: false});
+  return ProjectType;
 }

+ 13 - 9
backend/inigovalentin/app/models/project-url-type.model.js

@@ -1,11 +1,15 @@
+/**
+ * @file Provides the model for project url types.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = (sequelize, Sequelize) => {
-    ProjectUrlType = sequelize.define("project-url-type", {
-        id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
-        title: {allowNull: false, type: Sequelize.STRING(64), references: {model: "texts", key: "id"}},
-        summary: {type: Sequelize.STRING(512), references: {model: "texts", key: "id"}},
-        logo: {type: Sequelize.STRING(64)},
-    },
-    {timestamps: false}
-    );
-    return ProjectUrlType;
+  ProjectUrlType = sequelize.define("project-url-type", {
+    id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
+    title: {allowNull: false, type: Sequelize.STRING(64), references: {model: "texts", key: "id"}},
+    summary: {type: Sequelize.STRING(512), references: {model: "texts", key: "id"}},
+    logo: {type: Sequelize.STRING(64)},
+  }, {timestamps: false});
+  return ProjectUrlType;
 }

+ 13 - 9
backend/inigovalentin/app/models/project-url.model.js

@@ -1,11 +1,15 @@
+/**
+ * @file Provides the model for project urls.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = (sequelize, Sequelize) => {
-    ProjectUrl = sequelize.define("project-url", {
-        id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
-        projectId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "projects", key: "id"}},
-        projectUrlTypeId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "project-url-types", key: "id"}},
-        url: {type: Sequelize.STRING(512)},
-    },
-    {timestamps: false}
-    );
-    return ProjectUrl;
+  ProjectUrl = sequelize.define("project-url", {
+    id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
+    projectId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "projects", key: "id"}},
+    projectUrlTypeId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "project-url-types", key: "id"}},
+    url: {type: Sequelize.STRING(512)},
+  }, {timestamps: false});
+  return ProjectUrl;
 }

+ 15 - 15
backend/inigovalentin/app/models/project.model.js

@@ -5,19 +5,19 @@
  */
 
 module.exports = (sequelize, Sequelize) => {
-    Project = sequelize.define("project", {
-      id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
-      permalink: {allowNull: false, type: Sequelize.STRING(64)},
-      user: {allowNull: false, type: Sequelize.INTEGER}, // TODO REFERENCE
-      idx: {allowNull: false, type: Sequelize.SMALLINT},
-      projectTypeId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "project-types", key: "id"}},
-      title: {allowNull: false, type: Sequelize.STRING(64), references: {model: "texts", key: "id"}},
-      logo: {type: Sequelize.STRING(64)},
-      header: {allowNull: false, type: Sequelize.STRING(64)},//, references: {model: "texts", key: "id"}},
-      text: {type: Sequelize.STRING(64)},//, references: {model: "texts", key: "id"}},
-      comment: {type: Sequelize.STRING(64)},//, references: {model: "texts", key: "id"}},
-      licenseId: {allowNull: false, type: Sequelize.STRING(32), references: {model: "licenses", key: "id"}},
-      visible: {allowNull: false, type: Sequelize.BOOLEAN},
-    });
-    return Project;
+  Project = sequelize.define("project", {
+    id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
+    permalink: {allowNull: false, type: Sequelize.STRING(64)},
+    user: {allowNull: false, type: Sequelize.INTEGER, references: {model: "users", key: "id"}},
+    idx: {allowNull: false, type: Sequelize.SMALLINT},
+    projectTypeId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "project-types", key: "id"}},
+    title: {allowNull: false, type: Sequelize.STRING(64)},
+    logo: {type: Sequelize.STRING(64)},
+    header: {allowNull: false, type: Sequelize.STRING(64)},//, references: {model: "texts", key: "id"}},
+    text: {type: Sequelize.STRING(64)},//, references: {model: "texts", key: "id"}},
+    comment: {type: Sequelize.STRING(64)},//, references: {model: "texts", key: "id"}},
+    licenseId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "licenses", key: "id"}},
+    visible: {allowNull: false, type: Sequelize.BOOLEAN},
+  });
+  return Project;
 }

+ 12 - 8
backend/inigovalentin/app/models/tag.model.js

@@ -1,10 +1,14 @@
+/**
+ * @file Provides the model for tags.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = (sequelize, Sequelize) => {
-    const Tag = sequelize.define("tag", {
-        id: {allowNull: false, primaryKey: true, type: Sequelize.STRING(32)},
-        tag: {allowNull: false, type: Sequelize.STRING(64)},
-        icon: {type: Sequelize.STRING(64)},
-    },
-    {timestamps: false}
-    );
-    return Tag;
+  const Tag = sequelize.define("tag", {
+    id: {allowNull: false, primaryKey: true, type: Sequelize.STRING(32)},
+    tag: {allowNull: false, type: Sequelize.STRING(64)},
+    icon: {type: Sequelize.STRING(64)},
+  }, {timestamps: false});
+  return Tag;
 }

+ 14 - 10
backend/inigovalentin/app/models/text.model.js

@@ -1,12 +1,16 @@
+/**
+ * @file Provides the model for texts.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = (sequelize, Sequelize) => {
-    const Text = sequelize.define("text", {
-      id: {allowNull: false, primaryKey: true, type: Sequelize.STRING(64)},
-      lang: {allowNull: false, primaryKey: true, references: {model: "langs", key: "code"}, type: Sequelize.STRING(8)},
-      section: {type: Sequelize.STRING(32)},
-      text: {type: Sequelize.STRING(5000)},
-      file: {type: Sequelize.STRING(32)},
-    },
-    { timestamps: false }
-    );
-    return Text;
+  const Text = sequelize.define("text", {
+    id: {allowNull: false, primaryKey: true, type: Sequelize.STRING(64)},
+    lang: {allowNull: false, primaryKey: true, references: {model: "langs", key: "code"}, type: Sequelize.STRING(8)},
+    section: {type: Sequelize.STRING(32)},
+    text: {type: Sequelize.STRING(5000)},
+    file: {type: Sequelize.STRING(32)},
+  }, {timestamps: false});
+  return Text;
 }

+ 16 - 0
backend/inigovalentin/app/models/user-text.model.js

@@ -0,0 +1,16 @@
+/**
+ * @file Provides the model for user texts.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+module.exports = (sequelize, Sequelize) => {
+    UserText = sequelize.define("user-text", {
+      userId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "users", key: "id"}},
+      key: {allowNull: false, type: Sequelize.STRING(32)},
+      section: {type: Sequelize.STRING(32)},
+      global: {allowNull: false, false: true, type: Sequelize.BOOLEAN},
+      text: {allowNull: false, unique: true, type: Sequelize.STRING(64)}
+    }, {timestamps: false});
+    return UserText;
+}

+ 17 - 0
backend/inigovalentin/app/models/user-url.model.js

@@ -0,0 +1,17 @@
+/**
+ * @file Provides the model for user urls.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+module.exports = (sequelize, Sequelize) => {
+    UserUrl = sequelize.define("user-url", {
+        userId: {allowNull: false, type: Sequelize.INTEGER, references: {model: "users", key: "id"}},
+        priority: {allowNull: false, type: Sequelize.INTEGER},
+        name: {allowNull: false, type: Sequelize.STRING(64)},
+        description: {allowNull: false, type: Sequelize.STRING(64)},
+        logo: {type: Sequelize.STRING(32)},
+        url: {allowNull: false, type: Sequelize.STRING(256)}
+    }, {timestamps: false});
+    return UserUrl;
+}

+ 15 - 16
backend/inigovalentin/app/models/user.model.js

@@ -1,23 +1,22 @@
 /**
- * @file Provides the user model.
+ * @file Provides the model for users.
  * @author Inigo Valentin
  * @since 4.0.0
  */
 
 module.exports = (sequelize, Sequelize) => {
-    User = sequelize.define("user", {
-        id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
-        username: {allowNull: false, unique: true, type: Sequelize.STRING(32)},
-        displayname: {allowNull: false, unique: true, type: Sequelize.STRING(32)},
-        tagline: {allowNull: false, unique: true, type: Sequelize.STRING(64)},
-        email: {allowNull: false, unique: true, type: Sequelize.STRING(128)},
-        firstName: {type: Sequelize.STRING(128)},
-        lastName: {type: Sequelize.STRING(128)},
-        image: {type: Sequelize.STRING(64)},
-        password: {allowNull: false, type: Sequelize.STRING(256)},
-        salt: {allowNull: false, type: Sequelize.STRING(64)},
-        active: {allowNull: false, default: true, type: Sequelize.BOOLEAN},
-        admin: {allowNull: false, default: false, type: Sequelize.BOOLEAN},
-    });
-    return User;
+  User = sequelize.define("user", {
+    id: {allowNull: false, autoIncrement: true, primaryKey: true, type: Sequelize.INTEGER},
+    username: {allowNull: false, unique: true, type: Sequelize.STRING(32)},
+    displayname: {allowNull: false, unique: true, type: Sequelize.STRING(32)},
+    email: {allowNull: false, unique: true, type: Sequelize.STRING(128)},
+    firstName: {type: Sequelize.STRING(128)},
+    lastName: {type: Sequelize.STRING(128)},
+    image: {type: Sequelize.STRING(64)},
+    password: {allowNull: false, type: Sequelize.STRING(256)},
+    salt: {allowNull: false, type: Sequelize.STRING(64)},
+    active: {allowNull: false, default: true, type: Sequelize.BOOLEAN},
+    admin: {allowNull: false, default: false, type: Sequelize.BOOLEAN},
+  });
+  return User;
 }

+ 6 - 6
backend/inigovalentin/app/routes/access.routes.js

@@ -1,20 +1,20 @@
+/**
+ * @file Provides the routes for user access.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 const db = require("../models");
 const AuthService = require('../services/auth.service.js');
 
-
 module.exports = app => {
-    
-    
     const users = require("../controllers/user.controller.js");
     var router = require("express").Router();
-
     router.post("/register", users.create);
-
     router.post("/login", function(req, res) {
         const authService = new AuthService(db);
         authService.login(req, res);
     });
-
     app.use('/', router);
 
 }

+ 19 - 24
backend/inigovalentin/app/routes/lang.routes.js

@@ -1,28 +1,23 @@
+/**
+ * @file Provides the routes for language management.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = app => {
-    const projects = require("../controllers/lang.controller.js");
-    
+    const langs = require("../controllers/lang.controller.js");
     var router = require("express").Router();
-    
-    // Create a new Lang
-    router.post("/", projects.create);
-    
-    // Retrieve all Langs
-    router.get("/", projects.findAll);
-    
-    // Retrieve all published Langs
-    router.get("/published", projects.findAllPublished);
-    
-    // Retrieve a single Lang with id
-    router.get("/:id", projects.findOne);
-    
-    // Update a Lang with id
-    router.put("/:id", projects.update);
-    
-    // Delete a Lang with id
-    router.delete("/:id", projects.delete);
-    
-    // Delete all Langs
-    router.delete("/", projects.deleteAll);
-    
+    // Create a new language
+    router.post("/", langs.create);
+    // Retrieve all languages
+    router.get("/", langs.findAll);
+    // Retrieve a single language by its code
+    router.get("/:code", langs.findOne);
+    // Update a language by its code.
+    router.put("/:code", langs.update);
+    // Delete a language by its code.
+    router.delete("/:code", langs.delete);
+    // Delete all languages.
+    router.delete("/", langs.deleteAll);
     app.use('/api/langs', router);
 };

+ 13 - 0
backend/inigovalentin/app/routes/message.routes.js

@@ -0,0 +1,13 @@
+/**
+ * @file Provides the routes for messages.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+module.exports = app => {
+    const messages = require("../controllers/message.controller.js");
+    var router = require("express").Router();
+    // Create a new message
+    router.post("/", messages.create);
+    app.use('/api/messages', router);
+};

+ 13 - 16
backend/inigovalentin/app/routes/project.routes.js

@@ -1,28 +1,25 @@
-const db = require("../models");
-const AuthService = require('../services/auth.service.js');
+/**
+ * @file Provides the routes for project access and management.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
 
 module.exports = app => {
     const projects = require("../controllers/project.controller.js");
-    
     var router = require("express").Router();
-    
-    // Create a new Project
+    // Create a new project
     router.post("/", projects.create);
-    
-    // Retrieve all Projects
+    // Retrieve all projects
     router.get("/", projects.findAll);
-    
-    // Retrieve a single Project with id
+    // Retrieve top projects
+    router.get("/top/:total", projects.findTop);
+    // Retrieve a single project with id or permalink
     router.get("/:id", projects.findOne);
-    
-    // Update a Project with id
+    // Update a project with id or permalink
     router.put("/:id", projects.update);
-    
-    // Delete a Project with id
+    // Delete a project with id or permalink
     router.delete("/:id", projects.delete);
-    
-    // Delete all Projects
+    // Delete all projects
     router.delete("/", projects.deleteAll);
-    
     app.use('/api/projects', router);
 };

+ 19 - 24
backend/inigovalentin/app/routes/text.routes.js

@@ -1,28 +1,23 @@
+/**
+ * @file Provides the routes for localized text access and management.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 module.exports = app => {
-    const projects = require("../controllers/text.controller.js");
-    
+    const texts = require("../controllers/text.controller.js");
     var router = require("express").Router();
-    
-    // Create a new Lang
-    router.post("/", projects.create);
-    
-    // Retrieve all Langs
-    router.get("/", projects.findAll);
-    
-    // Retrieve all published Langs
-    router.get("/published", projects.findAllPublished);
-    
-    // Retrieve a single Lang with id
-    router.get("/:id", projects.findOne);
-    
-    // Update a Lang with id
-    router.put("/:id", projects.update);
-    
-    // Delete a Lang with id
-    router.delete("/:id", projects.delete);
-    
-    // Delete all Langs
-    router.delete("/", projects.deleteAll);
-    
+    // Create a new text
+    router.post("/", texts.create);
+    // Retrieve all texts
+    router.get("/", texts.findAll);
+    // Retrieve a single text with id
+    router.get("/:id", texts.findOne);
+    // Update a text with id
+    router.put("/:id", texts.update);
+    // Delete a text with id
+    router.delete("/:id", texts.delete);
+    // Delete all texts
+    router.delete("/", texts.deleteAll);
     app.use('/api/texts', router);
 };

+ 23 - 0
backend/inigovalentin/app/routes/user.routes.js

@@ -0,0 +1,23 @@
+/**
+ * @file Provides the routes for user data.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+module.exports = app => {
+    const users = require("../controllers/user.controller.js");
+    var router = require("express").Router();
+    // Create a new text
+    //router.post("/", texts.create);
+    // Retrieve all texts
+    router.get("/", users.findActive);
+    // Retrieve a single text with id
+    //router.get("/:id", texts.findOne);
+    // Update a text with id
+    //router.put("/:id", texts.update);
+    // Delete a text with id
+    //router.delete("/:id", texts.delete);
+    // Delete all texts
+    //router.delete("/", texts.deleteAll);
+    app.use('/api/user', router);
+};

+ 18 - 21
backend/inigovalentin/app/services/auth.service.js

@@ -1,39 +1,35 @@
 /**
- * @file Provides a service to handle languages and localizations.
+ * @file Provides a service to handle user access and authentication.
  * @author Inigo Valentin
  * @since 4.0.0
  */
 
+const logger = require('pino')();
+const Sequelize = require("sequelize");
 const sha1 = require('sha1');
 const jwt = require('jsonwebtoken');
-const users = require("../controllers/user.controller.js");
-
 const config = process.env;
+const User = require("../models").users;
 
 /**
- * Handles localization of elements.
+ * Handles user access and authentication.
  */
 class AuthService {
-    
-    /**
-     * Database connection.
-     */
-    #db;
-    
+
     /**
      * Constructor.
      *
      * @param db Database connection.
      * @constructor
      */
-    constructor(db){
-        this.#db = db;
-    }
+    constructor(){}
     
     /**
      * Logs a user in.
      * 
      * @param req The request received by the server.
+     * @param res The response to be sent by the server.
+     * @return The response.
      */
     async login(req, res){
         try {
@@ -42,20 +38,22 @@ class AuthService {
             const username = req.body.username ? req.body.username : "";
             const password = req.body.password;
             // Validate user input
-            if (!(password && (email || username))) res.status(401).send("Credentials required");
+            if (!(password && (email || username))) return res.status(401).send("Credentials required");
             
             // Validate if user exist in our database
-            const user = await User.findOne({where: {[this.#db.Sequelize.Op.or]: {username: username, email: email}}});
-            if (user && user.password == sha1(user.hash + password)){
+            let user;
+            if (username != "" && email != "") user = await User.findOne({where: {username: username, email: email}});
+            else user = await User.findOne({where: {[Sequelize.Op.or]: {username: username, email: email}}});
+            if (user && user.password == sha1(user.salt + password)){
                 require('dotenv').config()
                 const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
                 const token = jwt.sign({user: user.id}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '5h', issuer: TOKEN_ISSUER, subject: user.username})
-                res.send({token});
+                return res.send({token});
             }
             return res.status(403).send("Invalid Credentials");
         }
         catch (err) {
-            console.log("Error logging in: " + err);
+            logger.error("Error logging in: " + err);
             return res.status(500).send("Error logging in");
         }
     }
@@ -72,7 +70,6 @@ class AuthService {
      */
     validateToken(req, res) {
         const token = req.body.token || req.query.token || req.headers["x-access-token"];
-        console.log("TOKEN: " + token);
         if (!token) return {success: false, user: null, code: 401, message: "A token is required for authentication."};
         try {
             require('dotenv').config()
@@ -82,8 +79,8 @@ class AuthService {
             return {success: false, user: null, code: 403, message: "Invalid token."};;
         }
         catch (err) {
-            console.log("Error validating token: " + err);
-            return {success: false, user: null, code: 500, message: "Authentication error."};;
+            logger.error("Error validating token: " + err);
+            return {success: false, user: null, code: 403, message: "Authentication error."};;
         }
     }
 }

+ 211 - 0
backend/inigovalentin/app/services/auth.service.spec.js

@@ -0,0 +1,211 @@
+/**
+ * @file Tests for the authentication service.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+//require('../../node_modules/mysql2/node_modules/iconv-lite/lib').encodingExists('foo');
+
+const httpMocks = require('node-mocks-http');
+const jwt = require('jsonwebtoken');
+const dotenv = require('dotenv');
+dotenv.config({path: './env/test.env'});
+
+const AuthService = require("./auth.service.js");
+const authService = new AuthService();
+
+
+test('Valid login - correct username, email and password', () => {
+    const req = {body: {email: "email@example.com", username: "username", password: "password"}};
+    const res = httpMocks.createResponse();
+    const testUser = User.build({
+        'email': 'email@example.com',
+        'username': 'username',
+        'password': '59b3e8d637cf97edbe2384cf59cb7453dfe30789',
+        'salt': 'salt'
+    });
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(200);});
+});
+
+test('Valid login - correct username and password, no email', () => {
+    const req = {body: {username: "username", password: "password"}};
+    const res = httpMocks.createResponse();
+    const testUser = User.build({
+        'email': 'email@example.com',
+        'username': 'username',
+        'password': '59b3e8d637cf97edbe2384cf59cb7453dfe30789',
+        'salt': 'salt'
+    });
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(200);});
+});
+
+test('Valid login - correct email and password, no username', () => {
+    const req = {body: {email: "email@example.com", password: "password"}};
+    const res = httpMocks.createResponse();
+    const testUser = User.build({
+        'email': 'email@example.com',
+        'username': 'username',
+        'password': '59b3e8d637cf97edbe2384cf59cb7453dfe30789',
+        'salt': 'salt'
+    });
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(200);});
+});
+
+test('Invalid login - no data', () => {
+    const req = {body: {}};
+    const res = httpMocks.createResponse();
+    const testUser = User.build({
+        'email': 'email@example.com',
+        'username': 'username',
+        'password': '59b3e8d637cf97edbe2384cf59cb7453dfe30789',
+        'salt': 'salt'
+    });
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(401);});
+});
+
+test('Invalid login - correct username only', () => {
+    const req = {body: {username: "username"}};
+    const res = httpMocks.createResponse();
+    const testUser = User.build({
+        'email': 'email@example.com',
+        'username': 'username',
+        'password': '59b3e8d637cf97edbe2384cf59cb7453dfe30789',
+        'salt': 'salt'
+    });
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(401);});
+});
+
+test('Invalid login - correct email only', () => {
+    const req = {body: {email: "email@example.com"}};
+    const res = httpMocks.createResponse();
+    const testUser = User.build({
+        'email': 'email@example.com',
+        'username': 'username',
+        'password': '59b3e8d637cf97edbe2384cf59cb7453dfe30789',
+        'salt': 'salt'
+    });
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(401);});
+});
+
+test('Invalid login - corect password only', () => {
+    const req = {body: {password: "password"}};
+    const res = httpMocks.createResponse();
+    const testUser = null;
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(401);});
+});
+
+test('Invalid login - Correct username and email, wrong password', () => {
+    const req = {body: {email: "email@example.com", username: "username", password: "BADpassword"}};
+    const res = httpMocks.createResponse();
+    const testUser = User.build({
+        'email': 'email@example.com',
+        'username': 'username',
+        'password': '59b3e8d637cf97edbe2384cf59cb7453dfe30789',
+        'salt': 'salt'
+    });
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(403);});
+});
+
+test('Valid login - Correct username and password, wrong email', () => {
+    const req = {body: {email: "BADemail@example.com", username: "username", password: "password"}};
+    const res = httpMocks.createResponse();
+    const testUser = null;
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(403);});
+});
+
+test('Valid login - Correct mail and password, wrong username', () => {
+    const req = {body: {email: "email@example.com", username: "BADusername", password: "password"}};
+    const res = httpMocks.createResponse();
+    const testUser = null;
+    jest.spyOn(User, 'findOne').mockImplementation((options) => Promise.resolve(testUser));
+    authService.login(req, res).then(data => {expect(data.statusCode).toBe(403);});
+});
+
+test('Valid token in body', () => {
+    const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
+    const token = jwt.sign({user: 99}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '10s', issuer: TOKEN_ISSUER, subject: "testuser"})
+    const req = {body: {token: token}, query: {}, headers: {}};
+    const res = httpMocks.createResponse();
+    const validation = authService.validateToken(req, res);
+    expect(validation.success).toBe(true);
+    expect(validation.user).toBe(99);
+    expect(validation.code).toBe(200);
+    expect(validation.message).toBe("OK");
+});
+
+test('Valid token in request', () => {
+    const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
+    const token = jwt.sign({user: 99}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '10s', issuer: TOKEN_ISSUER, subject: "testuser"})
+    const req = {body: {}, query: {token: token}, headers: {}};
+    const res = httpMocks.createResponse();
+    const validation = authService.validateToken(req, res);
+    expect(validation.success).toBe(true);
+    expect(validation.user).toBe(99);
+    expect(validation.code).toBe(200);
+    expect(validation.message).toBe("OK");
+});
+
+test('Valid token in headers', () => {
+    const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
+    const token = jwt.sign({user: 99}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '10s', issuer: TOKEN_ISSUER, subject: "testuser"})
+    const req = {body: {}, query: {}, headers: {"x-access-token": token}};
+    const res = httpMocks.createResponse();
+    const validation = authService.validateToken(req, res);
+    expect(validation.success).toBe(true);
+    expect(validation.user).toBe(99);
+    expect(validation.code).toBe(200);
+    expect(validation.message).toBe("OK");
+});
+
+test('Invalid token in body', () => {
+    const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
+    const token = jwt.sign({user: 99}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '10s', issuer: TOKEN_ISSUER, subject: "testuser"})
+    const req = {body: {token: token + "BAD"}, query: {}, headers: {}};
+    const res = httpMocks.createResponse();
+    const validation = authService.validateToken(req, res);
+    expect(validation.success).toBe(false);
+    expect(validation.user).toBe(null);
+    expect(validation.code).toBe(403);
+});
+
+test('Invalid token in request', () => {
+    const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
+    const token = jwt.sign({user: 99}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '10s', issuer: TOKEN_ISSUER, subject: "testuser"})
+    const req = {body: {}, query: {token: token+ "BAD"}, headers: {}};
+    const res = httpMocks.createResponse();
+    const validation = authService.validateToken(req, res);
+    expect(validation.success).toBe(false);
+    expect(validation.user).toBe(null);
+    expect(validation.code).toBe(403);
+});
+
+test('Invalid token in headers', () => {
+    const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
+    const token = jwt.sign({user: 99}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '10s', issuer: TOKEN_ISSUER, subject: "testuser"})
+    const req = {body: {}, query: {}, headers: {"x-access-token": token + "BAD"}};
+    const res = httpMocks.createResponse();
+    const validation = authService.validateToken(req, res);
+    expect(validation.success).toBe(false);
+    expect(validation.user).toBe(null);
+    expect(validation.code).toBe(403);
+});
+
+test('No token', () => {
+    const {TOKEN_SECRET, TOKEN_ISSUER} = process.env;
+    const token = jwt.sign({user: 99}, TOKEN_SECRET, {algorithm: 'HS256', expiresIn: '10s', issuer: TOKEN_ISSUER, subject: "testuser"})
+    const req = {body: {}, query: {}, headers: {}};
+    const res = httpMocks.createResponse();
+    const validation = authService.validateToken(req, res);
+    expect(validation.success).toBe(false);
+    expect(validation.user).toBe(null);
+    expect(validation.code).toBe(401);
+});

+ 61 - 41
backend/inigovalentin/app/services/locale.service.js

@@ -4,16 +4,15 @@
  * @since 4.0.0
  */
 
+const logger = require('pino')();
+const Lang = require("../models").langs;
+const Text = require("../models").texts;
+
 /**
  * Handles localization of elements.
  */
 class LocaleService {
 
-    /**
-     * Database connection.
-     */
-    #db;
-
     /**
      * List of available languages.
      *
@@ -24,22 +23,24 @@ class LocaleService {
     /**
      * Constructor.
      *
-     * @param db Database connection.
      * @constructor
      */
-    constructor(db){
-        this.#db = db;
+    constructor(){
         this.#init();
     }
 
     /**
      * Initializes the service.
      *
-     * Called automatically from the constructor.
+     * Called automatically from the constructor, retrieves available languages.
      */
     async #init(){
-        var result = await this.#db.sequelize.query('SELECT code FROM langs WHERE active = 1 ORDER by PRIORITY ASC ', { type: this.#db.sequelize.QueryTypes.SELECT })
-        for (const r of result) this.#availableLanguages.push(r.code.toLowerCase().substring(0, 2));
+        const data = await Lang.findAll({where: {active: true}, order: [['priority', 'ASC']]})
+        if (data) for (const r of data) this.#availableLanguages.push(r.code.toLowerCase().substring(0, 2));
+        if (this.#availableLanguages.length == 0){
+            logger.error("No languages retrieved from database, defaulting to 'en'.");
+            this.#availableLanguages.push("en");
+        }
     }
 
     /**
@@ -52,9 +53,23 @@ class LocaleService {
      * @return Two letter language code to use.
      */
     selectLanguage(req){
-        if (!req.query.lang) return this.#availableLanguages[0];
-        var requestedLang = req.query.lang.toLowerCase().substring(0, 2)
-        if (this.#availableLanguages.indexOf(requestedLang) != -1) return requestedLang;
+        if (!req.query.lang && !req.body.lang) return this.#availableLanguages[0];
+        let requestedLang = this.#availableLanguages[0];
+        if (req.query.lang && req.body.lang && (req.query.lang != req.body.lang)){
+            // Different languages in query and body, body has priority.
+            requestedLang = req.body.lang.toLowerCase().substring(0, 2)
+            if (this.#availableLanguages.indexOf(requestedLang) != -1) return requestedLang;
+            else{
+                requestedLang = req.query.lang.toLowerCase().substring(0, 2);
+                if (this.#availableLanguages.indexOf(requestedLang) != -1) return requestedLang;
+            }
+        }
+        else{
+            if (req.body.lang) requestedLang = req.body.lang.toLowerCase().substring(0, 2)
+            else if (req.query.lang) requestedLang = req.query.lang.toLowerCase().substring(0, 2)
+            if (this.#availableLanguages.indexOf(requestedLang) != -1) return requestedLang;
+            else return this.#availableLanguages[0];
+        }
         return this.#availableLanguages[0];
     }
 
@@ -65,11 +80,11 @@ class LocaleService {
      * @return The text.
      */
     async #decodeText(key, lang){
-        var result = await this.#db.sequelize.query('SELECT text, file FROM texts WHERE lang = ? AND id = ?', { replacements: [lang, key], type: this.#db.sequelize.QueryTypes.SELECT })
-        for (const r of result) {
-            if (r.text) return "" + r.text;
-            if (r.file) return "" + r.file; // TODO: Return file CONTENTS
-        }
+        
+        const t = await Text.findOne({where: {id: key, lang: lang}});
+        if (!t) return "";
+        if (t.text) return "" + t.text;
+        if (t.file) return "" + t.file; // TODO: Return file CONTENTS
         return "";
     }
     
@@ -86,12 +101,7 @@ class LocaleService {
      *   - texts{}: An array for the text in different languages. Language codes are keys.
      */
     generateLocalizedObject(obj, key, section){
-        let l = {
-            valid: true,
-            key: key,
-            section: section,
-            texts: {}
-        };
+        let l = {valid: true, key: key, section: section, texts: {}};
         if (!key) l.valid = false;
         if (!section) l.valid = false;
         l.key = key;
@@ -101,7 +111,7 @@ class LocaleService {
             body = JSON.parse(obj);
             for (let i = 0; i < this.#availableLanguages.length; i ++)
                 if (body[this.#availableLanguages[i]] != undefined) l.texts[this.#availableLanguages[i]] = body[this.#availableLanguages[i]];
-            if (l.texts.length == 0) l.valid = false;
+            if (Reflect.ownKeys(l.texts).length == 0) l.valid = false;
         }
         catch(err){
             l.valid = false;
@@ -115,10 +125,13 @@ class LocaleService {
      * @param obj The object, as provided by {@see generateLocalizedObject}.
      * @return True on success, false on error.
      */
-    async saveLocalizedObject(obj, key, section){
+    async saveLocalizedObject(obj){
         if (!obj.valid || obj.valid != true || !obj.texts || obj.texts.length < 1) return false;
-        for (const [lang, text] of Object.entries(obj.texts))
-            let res = await this.#db.sequelize.query('INSERT INTO texts (id, lang, section, text, file) VALUES (?, ?, ?, ?, ?)', {replacements: [obj.key, lang, obj.section, text, null], type: this.#db.sequelize.QueryTypes.INSERT});
+        for (const [lang, text] of Object.entries(obj.texts)){
+            const t = {id: obj.key, lang: lang, section: obj.section, text: text, file: null};
+            await Text.create(t)
+            .catch(err => {logger.error("Error creating text with key " + id + " in lang " + lang + ": " + err);});
+        }
         return true;
     }
 
@@ -146,15 +159,17 @@ class LocaleService {
             data.dataValues.license.summary = await this.#decodeText(data.dataValues.license.summary, lang);
             data.dataValues.license.legal = await this.#decodeText(data.dataValues.license.legal, lang);
         }
-        for (var i = 0; i < data.dataValues.tags.length; i ++)
-            data.dataValues.tags[i].tag = await this.#decodeText(data.dataValues.tags[i].tag, lang);
-        for (var i = 0; i < data.dataValues["project-urls"].length; i ++){
-            data.dataValues["project-urls"][i].dataValues.type.dataValues.title = await this.#decodeText(data.dataValues["project-urls"][i].dataValues.type.dataValues.title, lang);
-            data.dataValues["project-urls"][i].dataValues.type.dataValues.summary = await this.#decodeText(data.dataValues["project-urls"][i].dataValues.type.dataValues.summary, lang);
-        }
-        for (var i = 0; i < data.dataValues["project-images"].length; i ++)
-            data.dataValues["project-images"][i].dataValues.alt = await this.#decodeText(data.dataValues["project-images"][i].dataValues.alt, lang);
-        //console.log(data.dataValues.projectUrls)
+        if (data.dataValues.tags)
+            for (var i = 0; i < data.dataValues.tags.length; i ++)
+                data.dataValues.tags[i].tag = await this.#decodeText(data.dataValues.tags[i].tag, lang);
+        if (data.dataValues["project-urls"])
+            for (var i = 0; i < data.dataValues["project-urls"].length; i ++){
+                data.dataValues["project-urls"][i].dataValues.type.dataValues.title = await this.#decodeText(data.dataValues["project-urls"][i].dataValues.type.dataValues.title, lang);
+                data.dataValues["project-urls"][i].dataValues.type.dataValues.summary = await this.#decodeText(data.dataValues["project-urls"][i].dataValues.type.dataValues.summary, lang);
+            }
+        if (data.dataValues["project-images"])
+            for (var i = 0; i < data.dataValues["project-images"].length; i ++)
+                data.dataValues["project-images"][i].dataValues.alt = await this.#decodeText(data.dataValues["project-images"][i].dataValues.alt, lang);
         return data;
     }
 
@@ -249,9 +264,14 @@ class LocaleService {
      */
     async localizeUser(data, req){
         var lang = this.selectLanguage(req);
-        // TODO
-        //data.dataValues.title = await this.#decodeText(data.dataValues.title, lang);
-        //data.dataValues.summary = await this.#decodeText(data.dataValues.summary, lang);
+        if (data.dataValues.texts)
+            for (var i = 0; i < data.dataValues.texts.length; i ++)
+                data.dataValues.texts[i].text = await this.#decodeText(data.dataValues.texts[i].text, lang);
+        if (data.dataValues.urls)
+            for (var i = 0; i < data.dataValues.urls.length; i ++){
+                data.dataValues.urls[i].name = await this.#decodeText(data.dataValues.urls[i].name, lang);
+                data.dataValues.urls[i].description = await this.#decodeText(data.dataValues.urls[i].description, lang);
+            }
         return data;
     }
     

+ 319 - 0
backend/inigovalentin/app/services/locale.service.spec.ts

@@ -0,0 +1,319 @@
+/**
+ * @file Tests for the locale service.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
+// This fixes some encoding errors with Jest
+require('../../node_modules/mysql2/node_modules/iconv-lite/lib').encodingExists('foo');
+
+const httpMocks = require('node-mocks-http');
+const dotenv = require('dotenv');
+dotenv.config({path: './env/test.env'});
+
+const Lang = require("../models").langs;
+const Text = require("../models").texts;
+const LocaleService = require("./locale.service.js");
+
+
+
+test('Select language - empty request, english exists', async () => {
+    const req = {query: {}, body: {}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("en");
+});
+
+test('Select language - empty request, english doesn\'t exists', async () => {
+    const req = {query: {}, body: {}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("eu");
+});
+
+test('Select language - empty request, no languages defined', async () => {
+    const req = {query: {}, body: {}};
+    var testLangs = []
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("en");
+});
+
+test('Select language - existing language in query', async () => {
+    const req = {query: {lang: "es"}, body: {}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("es");
+});
+
+test('Select language - existing language in body', async () => {
+    const req = {query: {}, body: {lang: "es"}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("es");
+});
+
+test('Select language - non-existing language in query', async () => {
+    const req = {query: {lang: "eu"}, body: {}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("en");
+});
+
+test('Select language - existing language in body', async () => {
+    const req = {query: {}, body: {lang: "eu"}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("en");
+});
+
+test('Select language - existing but conflicting languages in in query and body', async () => {
+    const req = {query: {lang: "es"}, body: {lang: "eu"}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("eu");
+});
+
+test('Select language - non existing and conflicting languages in in query and body', async () => {
+    const req = {query: {lang: "ru"}, body: {lang: "ja"}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("en");
+});
+
+test('Select language - existing language in query, non existing language in body', async () => {
+    const req = {query: {lang: "es"}, body: {lang: "ru"}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("es");
+});
+
+test('Select language - non-existing language in query, existing language in body', async () => {
+    const req = {query: {lang: "ru"}, body: {lang: "es"}};
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const lang = localeService.selectLanguage(req);
+    expect(lang).toBe("es");
+});
+
+test('Generate localized object - all available languages included', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "en": "testen", "eu": "testeu"}', "key", "section");
+    expect(obj.valid).toBe(true);
+    expect(obj.key).toBe("key");
+    expect(obj.section).toBe("section");
+    expect(Reflect.ownKeys(obj.texts).length).toBe(3);
+    expect(obj.texts.es).toBe("testes");
+    expect(obj.texts.en).toBe("testen");
+    expect(obj.texts.eu).toBe("testeu");
+});
+
+test('Generate localized object - some available languages included', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "en": "testen"}', "key", "section");
+    expect(obj.valid).toBe(true);
+    expect(obj.key).toBe("key");
+    expect(obj.section).toBe("section");
+    expect(Reflect.ownKeys(obj.texts).length).toBe(2);
+    expect(obj.texts.es).toBe("testes");
+    expect(obj.texts.en).toBe("testen");
+    expect(obj.texts.eu).toBe(undefined);
+});
+
+test('Generate localized object - available and unavailablelanguages included', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "en": "testen", "ru": "testru"}', "key", "section");
+    expect(obj.valid).toBe(true);
+    expect(obj.key).toBe("key");
+    expect(obj.section).toBe("section");
+    expect(Reflect.ownKeys(obj.texts).length).toBe(2);
+    expect(obj.texts.es).toBe("testes");
+    expect(obj.texts.en).toBe("testen");
+    expect(obj.texts.eu).toBe(undefined);
+    expect(obj.texts.ru).toBe(undefined);
+});
+
+test('Generate localized object - no available languages included', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"ru": "testru", "ja": "testja"}', "key", "section");
+    expect(obj.valid).toBe(false);
+});
+
+test('Generate localized object - available languages included, duplicated, both the same', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "es": "testes"}', "key", "section");
+    expect(obj.valid).toBe(true);
+    expect(obj.key).toBe("key");
+    expect(obj.section).toBe("section");
+    expect(Reflect.ownKeys(obj.texts).length).toBe(1);
+    expect(obj.texts.es).toBe("testes");
+    expect(obj.texts.en).toBe(undefined);
+    expect(obj.texts.eu).toBe(undefined);
+});
+
+test('Generate localized object - available languages included, duplicated, both different', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes1", "es": "testes2"}', "key", "section");
+    expect(obj.valid).toBe(true);
+    expect(obj.key).toBe("key");
+    expect(obj.section).toBe("section");
+    expect(Reflect.ownKeys(obj.texts).length).toBe(1);
+    expect(obj.texts.es).toBe("testes2");
+    expect(obj.texts.en).toBe(undefined);
+    expect(obj.texts.eu).toBe(undefined);
+});
+
+test('Generate localized object - valid object, no key', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "en": "testen", "eu": "testeu"}', null, "section");
+    expect(obj.valid).toBe(false);
+});
+
+test('Generate localized object - valid object, no section', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "en": "testen", "eu": "testeu"}', "key", null);
+    expect(obj.valid).toBe(false);
+});
+
+test('Generate localized object - bad object', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject("bad", "key", "section");
+    expect(obj.valid).toBe(false);
+});
+
+test('Save localized object - valid object', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    jest.spyOn(Text, 'create').mockImplementation((options) => Promise.resolve(true));
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "en": "testen", "eu": "testeu"}', "key", "section");
+    expect(await localeService.saveLocalizedObject(obj)).toBe(true);
+});
+
+test('Save localized object - invalid object', async () => {
+    var testLangs = []
+    testLangs.push(Lang.build({'code': 'en', 'name': 'English', 'priority': 1, 'active': true}));
+    testLangs.push(Lang.build({'code': 'es', 'name': 'Espanol', 'priority': 2, 'active': true}));
+    testLangs.push(Lang.build({'code': 'eu', 'name': 'Euskara', 'priority': 3, 'active': true}));
+    jest.spyOn(Lang, 'findAll').mockImplementation(() => testLangs);
+    jest.spyOn(Text, 'create').mockImplementation((options) => Promise.resolve(true));
+    const localeService = new LocaleService();
+    await new Promise((r) => setTimeout(r, 30));
+    const obj = localeService.generateLocalizedObject('{"es": "testes", "en": "testen", "eu": "testeu"}', "key", null);
+    expect(await localeService.saveLocalizedObject(obj)).toBe(false);
+});
+
+// TODO: Implement test for localization functions once they are complete

+ 6 - 0
backend/inigovalentin/config/db.config.js

@@ -1,3 +1,9 @@
+/**
+ * @file Provides configuration for the database connection.
+ * @author Inigo Valentin
+ * @since 4.0.0
+ */
+
 require('dotenv').config()
 const {DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME, DB_TYPE} = process.env;
 module.exports = {

+ 20 - 0
backend/inigovalentin/env/test.env

@@ -0,0 +1,20 @@
+# This file is an example of an .env file with all the values needed by the application.
+# Copy and rename this file, set the values to the ones needed by your application, and run the
+# server with 'node --env-file=<your_file>.env app.js'.
+# Never add the modified file to version control.
+
+# Database connection parameters
+DB_HOST = "localhost"
+DB_PORT = 3306
+DB_USER = "testuser"
+DB_PASS = "testpassword"
+DB_NAME = "testname"
+DB_TYPE = "mysql"
+
+# Server settings
+SRV_MODE = "TEST"
+SRV_PORT = 8087
+
+# Encryption secret
+SECRET = "TEST"
+TOKEN_ISSUER = "TEST"

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 3327 - 378
backend/inigovalentin/package-lock.json


+ 7 - 1
backend/inigovalentin/package.json

@@ -4,7 +4,9 @@
   "description": "Iñigo Valentin's Website",
   "main": "index.js",
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "start": "nodejs --env-file=env/pro.env server.js",
+    "start-dev": "nodejs --env-file=env/dev.env server.js",
+    "test": "jest"
   },
   "author": "Iñigo Valentin",
   "license": "GPL-3.0",
@@ -13,10 +15,14 @@
     "cors": "^2.8.5",
     "dotenv": "^16.4.5",
     "express": "^4.21.1",
+    "jest": "^29.7.0",
     "jsonwebtoken": "^9.0.2",
     "mysql2": "^3.11.3",
+    "node-mocks-http": "^1.16.1",
     "pine": "^1.1.1",
+    "pino": "^9.5.0",
     "sequelize": "^6.37.4",
+    "sequelize-mock": "^0.10.2",
     "sha1": "^1.1.1"
   }
 }

+ 2 - 0
backend/inigovalentin/server.js

@@ -25,6 +25,8 @@ require("./app/routes/access.routes")(app);
 require("./app/routes/lang.routes")(app);
 require("./app/routes/text.routes")(app);
 require("./app/routes/project.routes")(app);
+require("./app/routes/user.routes")(app);
+require("./app/routes/message.routes")(app);
 
 const {SRV_PORT, SRV_MODE} = process.env;
 app.listen(SRV_PORT, () => {console.log("Server running in " + SRV_MODE + " mode in port " + SRV_PORT + ".")});

+ 11 - 5
frontend/inigovalentin/angular.json

@@ -5,7 +5,11 @@
   "projects": {
     "inigovalentin": {
       "projectType": "application",
-      "schematics": {},
+      "schematics": {
+        "@schematics/angular:component": {
+          "style": "scss"
+        }
+      },
       "root": "",
       "sourceRoot": "src",
       "prefix": "app",
@@ -17,7 +21,8 @@
             "index": "src/index.html",
             "browser": "src/main.ts",
             "polyfills": [
-              "zone.js"
+              "zone.js",
+              "@angular/localize/init"
             ],
             "tsConfig": "tsconfig.app.json",
             "assets": [
@@ -27,7 +32,7 @@
               }
             ],
             "styles": [
-              "src/styles.css"
+              "src/styles/styles.scss"
             ],
             "scripts": [
               "public/js/project.js"
@@ -87,7 +92,8 @@
           "options": {
             "polyfills": [
               "zone.js",
-              "zone.js/testing"
+              "zone.js/testing",
+              "@angular/localize/init"
             ],
             "tsConfig": "tsconfig.spec.json",
             "assets": [
@@ -97,7 +103,7 @@
               }
             ],
             "styles": [
-              "src/styles.css"
+              "src/styles/styles.scss"
             ],
             "scripts": []
           }

+ 66 - 0
frontend/inigovalentin/package-lock.json

@@ -24,6 +24,7 @@
         "@angular-devkit/build-angular": "^18.2.8",
         "@angular/cli": "^18.2.8",
         "@angular/compiler-cli": "^18.2.0",
+        "@angular/localize": "^18.2.8",
         "@types/jasmine": "~5.1.0",
         "autoprefixer": "^10.4.20",
         "jasmine-core": "~5.2.0",
@@ -540,6 +541,30 @@
         "rxjs": "^6.5.3 || ^7.4.0"
       }
     },
+    "node_modules/@angular/localize": {
+      "version": "18.2.8",
+      "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.2.8.tgz",
+      "integrity": "sha512-1T7aXEdgVyeYnHOfQUuIDO8Lsamg1ZLrJrA5zUv61asPJp6HCcMjXy9vDQ1XvHm5+CdDjKk/rczlN4lSMZ0QRw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "7.25.2",
+        "@types/babel__core": "7.20.5",
+        "fast-glob": "3.3.2",
+        "yargs": "^17.2.1"
+      },
+      "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": "^18.19.1 || ^20.11.1 || >=22.0.0"
+      },
+      "peerDependencies": {
+        "@angular/compiler": "18.2.8",
+        "@angular/compiler-cli": "18.2.8"
+      }
+    },
     "node_modules/@angular/platform-browser": {
       "version": "18.2.8",
       "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.2.8.tgz",
@@ -4109,6 +4134,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.6.8",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
+      "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+      "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.20.6",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
+      "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.20.7"
+      }
+    },
     "node_modules/@types/body-parser": {
       "version": "1.19.5",
       "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",

+ 1 - 0
frontend/inigovalentin/package.json

@@ -26,6 +26,7 @@
     "@angular-devkit/build-angular": "^18.2.8",
     "@angular/cli": "^18.2.8",
     "@angular/compiler-cli": "^18.2.0",
+    "@angular/localize": "^18.2.8",
     "@types/jasmine": "~5.1.0",
     "autoprefixer": "^10.4.20",
     "jasmine-core": "~5.2.0",

BIN
frontend/inigovalentin/public/font/Handwritten.ttf


BIN
frontend/inigovalentin/public/font/NotoMono-Regular.ttf


BIN
frontend/inigovalentin/public/font/Nunito-Bold.ttf


BIN
frontend/inigovalentin/public/font/Nunito-Light.ttf


BIN
frontend/inigovalentin/public/font/Nunito-Regular.ttf


+ 61 - 0
frontend/inigovalentin/public/img/control/close.svg

@@ -0,0 +1,61 @@
+<?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 300 300"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="close.svg"
+   width="300"
+   height="300"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata10">
+    <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="defs8" />
+  <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="namedview6"
+     showgrid="false"
+     inkscape:zoom="1.2616667"
+     inkscape:cx="195.06917"
+     inkscape:cy="62.644724"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4" />
+  <path
+     style="fill:none;stroke:#000000;stroke-width:22.47567749;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     d="M 37.737464,37.779859 262.26254,261.85018"
+     id="path928"
+     inkscape:connector-curvature="0" />
+  <path
+     style="fill:none;stroke:#000000;stroke-width:22.44807053;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     d="M 37.918082,262.04494 262.46264,38.544112"
+     id="path928-3"
+     inkscape:connector-curvature="0" />
+</svg>

+ 56 - 0
frontend/inigovalentin/public/img/control/slid_d.svg

@@ -0,0 +1,56 @@
+<?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 300 300"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="slid_d.svg"
+   width="300"
+   height="300"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata10">
+    <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="defs8" />
+  <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="namedview6"
+     showgrid="false"
+     inkscape:zoom="0.98333333"
+     inkscape:cx="135"
+     inkscape:cy="120"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4" />
+  <path
+     d="M 33.474582,43.644062 H 266.01694 L 149.74577,254.40678"
+     id="path2"
+     style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:13.4551115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     inkscape:connector-curvature="0" />
+</svg>

+ 56 - 0
frontend/inigovalentin/public/img/control/slid_l.svg

@@ -0,0 +1,56 @@
+<?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 300 300"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="slid_l.svg"
+   width="300"
+   height="300"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata10">
+    <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="defs8" />
+  <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="namedview6"
+     showgrid="false"
+     inkscape:zoom="0.98333333"
+     inkscape:cx="135"
+     inkscape:cy="120"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4" />
+  <path
+     d="M 255.12712,32.754242 V 265.2966 L 44.364402,149.02543"
+     id="path2"
+     style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:13.4551115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     inkscape:connector-curvature="0" />
+</svg>

+ 56 - 0
frontend/inigovalentin/public/img/control/slid_r.svg

@@ -0,0 +1,56 @@
+<?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 300 300"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="slid_r.svg"
+   width="300"
+   height="300"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata10">
+    <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="defs8" />
+  <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="namedview6"
+     showgrid="false"
+     inkscape:zoom="0.98333333"
+     inkscape:cx="135"
+     inkscape:cy="120"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4" />
+  <path
+     d="M 44.364402,265.2966 V 32.754242 L 255.12712,149.02541"
+     id="path2"
+     style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:13.4551115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     inkscape:connector-curvature="0" />
+</svg>

+ 56 - 0
frontend/inigovalentin/public/img/control/slid_u.svg

@@ -0,0 +1,56 @@
+<?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 300 300"
+   version="1.1"
+   id="svg4"
+   sodipodi:docname="slid_u.svg"
+   width="300"
+   height="300"
+   inkscape:version="0.92.3 (2405546, 2018-03-11)">
+  <metadata
+     id="metadata10">
+    <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="defs8" />
+  <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="namedview6"
+     showgrid="false"
+     inkscape:zoom="0.98333333"
+     inkscape:cx="135"
+     inkscape:cy="120"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="1"
+     inkscape:current-layer="svg4" />
+  <path
+     d="M 266.01694,254.40678 H 33.474582 L 149.74575,43.644062"
+     id="path2"
+     style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:13.4551115;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+     inkscape:connector-curvature="0" />
+</svg>

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 84
frontend/inigovalentin/public/img/license/CC-BY-ND.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 5 - 0
frontend/inigovalentin/public/img/license/CC-BY-SA.svg


+ 41 - 10
frontend/inigovalentin/public/js/project.js

@@ -8,6 +8,11 @@ let curImage = 0;
  */
 let totalImages = -1;
 
+/**
+ * Indicates if the image viewer is open.
+ */
+let viewer_open = false;
+
 /**
  * Shows an image in the image viewer.
  *
@@ -17,24 +22,22 @@ let totalImages = -1;
  */
 function showImage(element){
   curImage = Number(element.id.substring(4));
-  document.getElementById('image_viewer_cover').style.display = 'block';
-  document.getElementById('image_viewer_cover').style.opacity = '1';
+  document.getElementById('cover').style.display = 'block';
+  document.getElementById('cover').style.opacity = '1';
   loadImage(element);
   document.getElementById('image_viewer').style.display = 'block';
-  if (totalImages == -1){
-    console.log("RECALC TOTAL");
-    totalImages = document.getElementById("image_reel").children.length;
-    console.log("RECALC TOTAL: " + totalImages);
-  }
+  if (totalImages == -1) totalImages = document.getElementById("image_reel").children.length;
+  viewer_open = true;
 }
 
 /**
  * Closes the image viewew.
  */
 function closeImage(){
-    document.getElementById('image_viewer_cover').style.display = 'none';
-    document.getElementById('image_viewer_cover').style.opacity = '0';
+    document.getElementById('cover').style.display = 'none';
+    document.getElementById('cover').style.opacity = '0';
     document.getElementById('image_viewer').style.display = 'none';
+    viewer_open = false;
 }
 
 /**
@@ -68,5 +71,33 @@ function loadImage(element){
     document.getElementById('image_viewer_image').srcset = element.srcset;
     document.getElementById('image_viewer_image').alt = element.alt;
     document.getElementById('image_viewer_image').title = element.title;
-    console.log("LOADED: " + curImage + "/" + totalImages);
+}
+
+/**
+ * Handles keys for the image viewer.
+ *
+ * Left, Right and ESC keys are handled, and only when the viewer is opened.
+ *
+ * @param e Key event.
+ */
+function keyCapt(e){
+  if(typeof window.event!="undefined") e = window.event; //IE stuff
+  if(e.type == "keydown"){
+    switch(e.keyCode){
+      case 37: if (viewer_open === true) prevImage(); break; // Left
+      case 39: if (viewer_open === true) nextImage(); break; // Right
+      case 27: if (viewer_open === true) closeImage(); break; // ESC
+    }
+  }
+}
+
+if (document.addEventListener){ // Listen to keypresses, standart
+  document.addEventListener("keydown",keyCapt,false);
+  document.addEventListener("keyup",keyCapt,false);
+  document.addEventListener("keypress",keyCapt,false);
+}
+else{ // Listen to keypresses, IE
+  document.attachEvent("onkeydown",keyCapt);
+  document.attachEvent("onkeyup",keyCapt);
+  document.attachEvent("onkeypress",keyCapt);
 }

+ 14 - 10
frontend/inigovalentin/public/js/ui.js

@@ -1,11 +1,15 @@
-function showCover(visible){
-    var cover = document.getElementById('cover');
-    if (visible){
-        cover.style.display = 'block';
-        cover.style.opacity = 1;
-    }
-    else{
-        cover.style.display = 'none';
-        cover.style.opacity = 0;
-    }
+function openMessage(){
+  var cover = document.getElementById('message_cover');
+  var message = document.getElementById('message');
+  cover.style.display = 'block';
+  cover.style.opacity = 1;
+  message.style.display = 'block';
+}
+
+function closeMessage(){
+  var cover = document.getElementById('message_cover');
+  var message = document.getElementById('message');
+  cover.style.display = 'none';
+  cover.style.opacity = 0;
+  message.style.display = 'none';
 }

+ 0 - 0
frontend/inigovalentin/src/app/app.component.css


+ 4 - 4
frontend/inigovalentin/src/app/app.component.html

@@ -1,5 +1,5 @@
-<div>
-  <app-navbar class="bg-black"></app-navbar>
+<app-navbar></app-navbar>
+<main>
   <router-outlet></router-outlet>
-  <app-footer></app-footer>
-</div>
+</main>
+<app-footer></app-footer>

+ 4 - 3
frontend/inigovalentin/src/app/app.component.ts

@@ -8,9 +8,10 @@ import { ProjectPreviewComponent } from './components/project-preview/project-pr
   selector: 'app-root',
   standalone: true,
   imports: [RouterOutlet, NavbarComponent, FooterComponent],
-  templateUrl: './app.component.html',
-  styleUrl: './app.component.css'
+  templateUrl: './app.component.html'
 })
 export class AppComponent {
-  title = 'inigovalentin';
+  title = 'Inigo V';
+  errorMessage!: string;
+  constructor() {}
 }

+ 9 - 17
frontend/inigovalentin/src/app/app.routes.ts

@@ -1,21 +1,13 @@
-import { Routes } from '@angular/router';
-import { HomeComponent } from './pages/home/home.component';
-import { ProjectsComponent } from './pages/projects/projects.component';
-import { ProjectComponent } from './pages/project/project.component';
+import {Routes} from '@angular/router';
+import {HomeComponent} from './pages/home/home.component';
+import {ProfileComponent} from './pages/profile/profile.component';
+import {ProjectsComponent} from './pages/projects/projects.component';
+import {ProjectComponent} from './pages/project/project.component';
 
 export const routes: Routes = [
-  {
-    path: 'projects',
-    component: ProjectsComponent,
-  },
-  {
-    path: 'projects/:projectId',
-    component: ProjectComponent,
-  },
-  {
-    path: '',
-    component: HomeComponent,
-    pathMatch: 'full',
-  },
+  {path: 'profile', component: ProfileComponent},
+  {path: 'projects', component: ProjectsComponent},
+  {path: 'projects/:projectId', component: ProjectComponent},
+  {path: '', component: HomeComponent, pathMatch: 'full',},
 ];
 

+ 0 - 0
frontend/inigovalentin/src/app/components/footer/footer.component.css


+ 29 - 3
frontend/inigovalentin/src/app/components/footer/footer.component.html

@@ -1,5 +1,31 @@
 <footer>
-  <p class="bg-slate-700 text-white text-center mt-20">
-    2016 Inigo Valentin
-  </p>
+  <hr/>
+  <div id='footer_left'>
+    <span id='footer_follow' class='desktop' i18n>Follow me:</span>
+    <span class='fake_a' onclick='openMessageForm();'>
+      <img src='./img/social/email.svg' i18n-alt alt='Send me a message' i18n-title title='Send me a message'/>
+    </span>
+    @if(user?.urls){
+      <a *ngFor="let url of user?.urls" target='_blank' title='{{url.description}}' href='{{url.url}}'>
+        <img src='./img/social/{{url.logo}}' alt='{{url.name}}' title='{{url.description}}'/>
+      </a>
+    }
+  </div>
+  <div id='footer_center'>
+
+  </div>
+  <div id='footer_right'>
+    <a id='footer_help' href='https://inigovalentin.com/help/' i18n>Help</a>
+    <div id='footer_langs'>
+      <a class='lang' href='https://inigovalentin.com/en/'>
+          <img src='https://inigovalentin.com/img/layout/lang/en.svg' alt='English' title='English'/>
+      </a>
+      <a class='lang' href='https://inigovalentin.com/es/'>
+          <img src='https://inigovalentin.com/img/layout/lang/es.svg' alt='Castellano' title='Castellano'/>
+      </a>
+      <a class='lang' href='https://inigovalentin.com/eu/'>
+          <img src='https://inigovalentin.com/img/layout/lang/eu.svg' alt='Euskara' title='Euskara'/>
+      </a>
+    </div>
+  </div>
 </footer>

+ 16 - 3
frontend/inigovalentin/src/app/components/footer/footer.component.ts

@@ -1,12 +1,25 @@
 import { Component } from '@angular/core';
+import {AsyncPipe, NgFor} from '@angular/common';
+import {UserService} from '../../services/user.service';
+import {User} from '../../models/user';
+import {AppComponent} from '../../app.component';
 
 @Component({
   selector: 'app-footer',
   standalone: true,
-  imports: [],
-  templateUrl: './footer.component.html',
-  styleUrl: './footer.component.css'
+  imports: [AsyncPipe, NgFor],
+  templateUrl: './footer.component.html'
 })
 export class FooterComponent {
+  user: User | undefined;
+  errorMessage!: string;
+  constructor(private user_service: UserService){}
+  ngOnInit(){
+    //this.user = this.app.guser;
+    this.user_service.getUser().subscribe({
+      next: (user) => {this.user = user;},
+      error: (error) => {this.errorMessage = error;},
+    });
 
+  }
 }

+ 51 - 0
frontend/inigovalentin/src/app/components/message/message.component.html

@@ -0,0 +1,51 @@
+<div id='message_cover' onClick='closeMessage();'></div>
+<section id='message'>
+  <h3>
+    <span i18n>Contact me</span>
+    <div id='message_close'>
+      <a onClick='closeMessage();'>
+        <img src='./img/control/close.svg'/>
+      </a>
+    </div>
+  </h3>
+  <p i18n>
+    Something on your mind? Use this form to send me a message and I'll get back to you as soon as possible.
+  </p>
+  <form #messageForm='ngForm' (ngSubmit)='onSubmit(messageForm)'>
+    <div>
+      <label for='name' i18n>Your name: </label>
+      <input type='text' name='name' i18n-placeholder placeholder='Your name' ngModel required/>
+    </div>
+    <div>
+      <span id='error_name' class='error' i18n>A name is required.</span>
+    </div>
+    <div>
+      <label for='email' i18n>Your email: </label>
+      <input type='text' name='email' i18n-placeholder placeholder='Your email' ngModel required/>
+    </div>
+    <div>
+      <span id='error_email' class='error' i18n>A valid email address is required.</span>
+    </div>
+    <div>
+      <label for='email' i18n>Subject: </label>
+      <input type='text' name='subject' i18n-placeholder placeholder='Subject' ngModel required/>
+    </div>
+    <div>
+      <span id='error_subject' class='error' i18n>Invalid subject.</span>
+    </div>
+    <div>
+      <label for='message' i18n>Your message: </label>
+      <span id='error_message' class='error' i18n>The message can not be empty.</span>
+      <textarea placeholder='Your message' name='text' i18n-placeholder placeholder='Your message' ngModel required></textarea>
+    </div>
+    <div>
+      <input type='checkbox' name='consent' ngModel required/>
+      <label for='consent' class='long' i18n>I accept the data processing policy</label>
+    </div>
+    <div>
+      <span id='error_consent' class='error' i18n>The message can't be sent unless the policy is accepted.</span>
+    </div>
+    <div><input type='submit' i18n-value value='Send'/></div>
+  </form>
+</section>
+

+ 23 - 0
frontend/inigovalentin/src/app/components/message/message.component.spec.ts

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

+ 64 - 0
frontend/inigovalentin/src/app/components/message/message.component.ts

@@ -0,0 +1,64 @@
+import {Component} from '@angular/core';
+import {FormsModule, NgForm} from '@angular/forms';
+import {HttpClient} from '@angular/common/http';
+import {Message} from '../../models/message';
+
+@Component({
+  selector: 'app-message',
+  standalone: true,
+  imports: [FormsModule],
+  templateUrl: './message.component.html'
+})
+export class MessageComponent {
+
+  validateEmail(email: string){
+      return email && email.match(
+        /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
+      );
+  }
+
+  constructor(private http: HttpClient) {}
+
+  onSubmit(form: NgForm) {
+    // Clear errors
+    document.getElementById("error_name")?.setAttribute("style", "visibility:hidden;")
+    document.getElementById("error_email")?.setAttribute("style", "visibility:hidden;");
+    document.getElementById("error_message")?.setAttribute("style", "visibility:hidden;");
+    document.getElementById("error_consent")?.setAttribute("style", "visibility:hidden;");
+
+    var valid = true;
+    const name = form.value.name;
+    const email = form.value.email;
+    const subject = form.value.subject;
+    const text = form.value.text;
+    const consent = form.value.consent === true;
+    if (!name || name == ""){
+      valid = false;
+      document.getElementById("error_name")?.setAttribute("style", "visibility:visible;")
+    }
+    if (!this.validateEmail(email)){
+      valid = false;
+      document.getElementById("error_email")?.setAttribute("style", "visibility:visible;")
+    }
+    if (!text || text == ""){
+      valid = false;
+      document.getElementById("error_message")?.setAttribute("style", "visibility:visible;")
+    }
+    if (!consent){
+      valid = false;
+      document.getElementById("error_consent")?.setAttribute("style", "visibility:visible;")
+    }
+    if (!valid) return;
+
+    const apiUrl = 'http://localhost:8080/api/messages/';
+    const message = new Message(name, email, subject, text);
+    this.http.post<Message>(apiUrl, message).subscribe(data => {
+      console.log("SENT: " + JSON.stringify(data));
+      //if (data.status === 200) console.log("SENT OK");
+      //else console.log("NOT SENT: status " + data.status + " - " + data.statusMessage);
+    })
+
+    console.log("VALUE: " + JSON.stringify(form.value)); // { first: '', last: '' }
+    console.log("VALID: " + form.valid); // false
+  }
+}

+ 0 - 74
frontend/inigovalentin/src/app/components/navbar/navbar.component.css

@@ -1,74 +0,0 @@
-header{
-  background-color: #000000;
-  width: 100%;
-  text-align: center;
-}
-
-header div#logo{
-  color: #ffffff;
-  font-size: 4em;
-  font-family: 'Mono';
-}
-header nav{
-  width: 100%;
-}
-header nav a{
-  font-size: 1.5em;
-  margin: 0.5em;
-  color: #ffffff;
-  text-decoration: none;
-  position: relative;
-}
-header nav a:hover{
-  color: #ffffff;
-}
-header nav a:before {
-  content: " ";
-  position: absolute;
-  width: 100%;
-  height: 0.1em;
-  bottom: -0.1em;
-  left: 0;
-  background-color: #ffffff;
-  visibility: hidden;
-  transform: scaleX(0);
-  transition: all 0.3s ease-in-out 0s;
-}
-header nav a:hover:before {
-  visibility: visible;
-  transform: scaleX(1);
-}
-header nav a img{
-  height: 1.5em;
-  vertical-align: bottom;
-}
-@media screen and (max-width : 800px){
-  header div#logo{
-    /* Adjusted for the text 'inigoValentin' */
-    font-size: 12vw;
-  }
-  header nav{
-    overflow: hidden;
-    white-space: nowrap;
-    width: 95%;
-    margin: auto;
-  }
-  header nav a{
-    display: inline-block;
-    width: 30%;
-    margin: 0;
-    padding: 0.5em 0;
-    text-align: center;
-    color: #ffffff;
-    text-decoration: none;
-    border: 0.1em solid #aaaaaa;
-    background-color: #222222;
-    font-size: 4vw;
-  }
-  header nav a img{
-    height: 1em;
-    width: 1em;
-    vertical-align: middle;
-    margin-right: 0.2em;
-  }
-}

+ 1 - 2
frontend/inigovalentin/src/app/components/navbar/navbar.component.ts

@@ -5,8 +5,7 @@ import { RouterModule } from '@angular/router';
   selector: 'app-navbar',
   standalone: true,
   imports: [RouterModule],
-  templateUrl: './navbar.component.html',
-  styleUrl: './navbar.component.css'
+  templateUrl: './navbar.component.html'
 })
 export class NavbarComponent {
 

+ 0 - 73
frontend/inigovalentin/src/app/components/project-preview/project-preview.component.css

@@ -1,73 +0,0 @@
-article.project{
-  position: relative;
-  margin: 1em;
-  text-align: left;
-  border-radius: 0.6em;
-  padding: 1.5em 1em;
-  border: 0.1em solid #000000;
-  border-radius: 1em;
-  background-color: #dddddd;
-}
-@media screen and (max-width : 800px){
-  article.project{
-    margin: 0.3em auto;
-    padding: 0.3em;
-  }
-}
-
-@media screen and (max-width : 800px){
-  article.project table td.logo{
-    vertical-align: top;
-  }
-}
-
-article.project h3{
-  font-size: 200%;
-  margin-top: 0;
-}
-
-article.project td.project_details{
-  vertical-align: top;
-  padding-left: 1em;
-}
-@media screen and (max-width : 800px){
-  article.project td.project_details{
-    padding-left: 0.3em;
-  }
-  article.project td.project_details h4{
-    margin-bottom: 0.2em;
-  }
-}
-
-article.project table td{
-  vertical-align: top;
-}
-
-article.project img.logo{
-  padding: 0.5em;
-  max-width: 9em;
-  max-height: 9em;
-  background-color: #ffffff;
-  box-shadow: 0 0 0 0.1em #000000, 0 0 0 0.3em #66bf38, 0 0 0 0.4em #000000, inset 0 0 0.5em #00220077;
-  border-radius: 1em;
-}
-@media screen and (max-width : 1000px){
-  article.project img.logo{
-    padding: 0.2em;
-    max-width: 13vw;
-    max-height: 13vw;
-    border-radius: 0.5em;
-    box-shadow: 0 0 0 0.05em #000000, 0 0 0 0.15em #66bf38, 0 0 0 0.2em #000000, inset 0 0 0.25em #00220077;
-  }
-}
-
-article.project td.project_details div.tags{
-  text-align: right;
-}
-
-article.project td.project_details div.tags img{
-  max-width: 3em;
-  max-height: 3em;
-  border: 0.15em solid #000000;
-  border-radius: 50%;
-}

+ 20 - 20
frontend/inigovalentin/src/app/components/project-preview/project-preview.component.html

@@ -1,21 +1,21 @@
-<article class='project'>
-  <table>
-    <tr>
-      <td ngIf="project.logo" class='logo'>
-        <a routerLink="/projects/{{project.permalink}}">
-          <img class="logo" src="./img/project/{{project.logo}}" alt={{project.title}} />
-        </a>
-      </td>
-      <td class='project_details'>
-        <h4 class='project_name'>{{project.title}}</h4>
-        <h5 class='project_header'>{{project.header}}</h5>
-        <div class='project_text' [innerHTML]=project.text></div>
-        <div class='tags'>
-          <span *ngFor="let tag of project.tags">
-            <img src="./img/tag/{{tag.icon}}" alt="{{tag.tag}}" title="{{tag.tag}}"/>
-          </span>
-        </div>
-      </td>
-    </tr>
-  </table>
+<article class='project_preview'>
+  <a ngIf="project.logo" class='logo' routerLink="/projects/{{project.permalink}}">
+    <img class="logo" src="./img/project/{{project.logo}}" alt={{project.title}} />
+  </a>
+  <div class='details'>
+    <h4 class='title'>{{project.title}}</h4>
+    <h5 class='header'>{{project.header}}</h5>
+    <div class='text' [innerHTML]=project.text></div>
+    <div class='details_cover'></div>
+  </div>
+  <div class='tags'>
+    <span *ngFor="let tag of project.tags">
+      <img src="./img/tag/{{tag.icon}}" alt="{{tag.tag}}" title="{{tag.tag}}"/>
+    </span>
+  </div>
+  <div class='button'>
+    <a class='a_button' routerLink="/projects/{{project.permalink}}" i18n>
+      See more
+    </a>
+  </div>
 </article>

+ 1 - 2
frontend/inigovalentin/src/app/components/project-preview/project-preview.component.ts

@@ -7,8 +7,7 @@ import { Project } from '../../models/project';
   selector: 'app-project-preview',
   standalone: true,
   imports: [SlicePipe, NgFor, RouterLink, RouterOutlet],
-  templateUrl: './project-preview.component.html',
-  styleUrl: './project-preview.component.css'
+  templateUrl: './project-preview.component.html'
 })
 
 export class ProjectPreviewComponent {

BIN
frontend/inigovalentin/src/app/components/project/.project.component.css.kate-swp


+ 0 - 340
frontend/inigovalentin/src/app/components/project/project.component.css

@@ -1,340 +0,0 @@
-section{
-  text-align: center;
-  width: 90%;
-  margin: auto;
-}
-section h3{
-  text-align: left;
-}
-div.info_summary{
-  display: inline-block;
-  width: 72%;
-  margin: 0;
-  vertical-align: top;
-  padding: 0;
-  min-height: 13em;
-  text-align: left;
-}
-@media screen and (max-width : 800px){
-  div.info_summary{
-    display: block;
-    width: 100%;
-    margin: 0;
-    padding: 0;
-  }
-}
-
-div.info_summary img.logo{
-  padding: 0.5em;
-  max-width: 100%;
-  max-height: 18em;
-  border-radius: 1em;
-  float: left;
-  margin: 0 2em 1em 0;
-}
-@media screen and (max-width : 800px){
-  div.info_summary img.logo{
-    margin: 0 2em 1em 0.4em;
-    padding: 0.2em;
-    width: 20vw;
-    height: 20vw;
-  }
-}
-
-div.info_summary h4{
-  margin: 0.5em;
-  font-style: italic;
-}
-
-div.info_summary div.text{
-  margin: 2em;
-  min-height: 7em;
-}
-@media screen and (max-width : 800px){
-  div.info_summary div.text{
-    margin: 0;
-    min-height: 20vw;
-  }
-}
-div.comment{
-  font-style: italic;
-  border: 0.1em solid .333;
-  padding: 1.5em;
-  box-shadow: 0 0 0.5em .111;
-  margin: 1em auto;
-  max-width: 55em;
-  background: .dbffdb;
-  rotate: -1deg;
-  border-radius: 0.2em;
-  line-height: 1.6em;
-  text-align: left;
-  display: inline-block;
-}
-@media screen and (max-width : 800px){
-  div.comment{
-    rotate: 0deg;
-    margin: 0.5em;
-    padding: 0.5em;
-    box-shadow: 0 0 0;
-  }
-}
-div.comment div.comment_title{
-  font-family: 'Handwritten';
-  display: block;
-  width: 3em;
-  font-size: 180%;
-  float: left;
-  line-height: 1;
-  padding: 0.5em 0 0 0.5em;
-  margin: 0 0.2em 0 -0.5em;
-  rotate: -36deg;
-}
-@media screen and (max-width : 800px){
-  div.comment div.comment_title{
-    font-size: 110%;
-    margin: 0 0.5em 0 0;
-    line-height: 1;
-  }
-}
-div.info_details{
-  display: inline-block;
-  width: 22%;
-  vertical-align: top;
-  margin: 0;
-  border-left: 0.1em solid .aacaaa;
-  /*background: linear-gradient(to right, .e5f0e500 0%, .e5f0e5ff 20%, .e5f0e5ff 80%, .e5f0e500 100%); */
-  background: linear-gradient(to right, .e5f0e5ff 0%, .e5f0e5cc 30%, .e5f0e500 100%);
-  padding: 1em;
-  text-align: center;
-}
-@media screen and (max-width : 800px){
-  div.info_details{
-    text-align: center;
-    display: block;
-    width: 85%;
-    margin: 0.5em auto;
-    border-left: 0.1em solid .aacaaa;
-    border-right: 0.1em solid .aacaaa;
-    border-top: 0.1em solid .aacaaa;
-    border-bottom: 0.1em solid .aacaaa;
-    border-radius: 0.5em;
-    /*background: linear-gradient(to right, .e5f0e500 0%, .e5f0e5ff 20%, .e5f0e5ff 80%, .e5f0e500 100%);*/
-    padding: 0.5em;
-  }
-}
-
-div.info_details div.details{
-  margin: 0.5em 1.5em 0.5em 1.5em;
-}
-@media screen and (max-width : 800px){
-  div.info_details div.details{
-    margin: 0.5em 0;
-    text-align: left;
-  }
-}
-
-div.info_details div.details hr.details_separator{
-  margin: 0.7em -0.5em 0.7em -0.5em;
-  border: 0;
-  color: .aacaaa;
-  background-color: .aacaaa;
-  border-top: 0.1em solid .aacaaa;
-}
-@media screen and (max-width : 800px){
-  div.info_details div.details hr.details_separator{
-    margin: 0.5em;
-  }
-}
-
-div.info_details div.links{
-  padding-top: 0.5em;
-  text-align: center;
-}
-
-div.info_details div.links a.link{
-  display: block;
-  margin-bottom: 1em;
-  text-align: left;
-}
-div.info_details div.links a.link img.link{
-  vertical-align: middle;
-  width: 1.5em;
-  height: 1.5em;
-  margin-right: 0.5em;
-}
-
-div.info_details div.tags{
-  text-align: left;
-  color: .00000088;
-  font-style: italic;
-  font-size: 85%;
-}
-
-div.info_details div.tags span{
-  font-weight: bold;
-}
-
-div.info_details div.license{
-  text-align: left;
-  color: .00000088;
-  font-style: italic;
-  font-size: 85%;
-  font-weight: bold;
-}
-
-
-div.info_details div.license img{
-  max-width: 8em;
-  max-height: 1.5em;
-  vertical-align: middle;
-  text-align: center;
-  margin-left: 1em;
-}
-
-
-/* The image reel */
-div.image_reel{
-  display: block;
-  margin: auto;
-}
-
-div.image_reel div.img{
-  display:inline-block;
-  vertical-align: top;
-  max-width: 20%;
-  max-height: 25em;
-  text-align: center;
-  background: .00000020;
-  border: 0.1em solid .aacaaa;
-  border-radius: 0.5em;
-  padding: 0.1em;
-  margin: 0.3em;
-  box-shadow: inset 0 0 0.5em .aacaaa;
-}
-
-div.image_reel div.img img{
-  display: block;
-  cursor: pointer;
-  margin: 0.5em auto 0.2em auto;
-  max-width: calc(100% - 0.5em);
-  max-height: 22em;
-  border: 0.1em solid .000000;
-  background: .000000;
-}
-div.image_reel div.img h5{
-  display: block;
-  margin: 0 auto 0.3em auto;
-}
-
-/* The image viewer. */
-div.image_viewer_cover{
-  position: fixed;
-  opacity: 0;
-  transition: all 0.7s;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  z-index: 99;
-  background-color: .00000077;
-  display: none;
-}
-
-section.image_viewer{
-  position: fixed;
-  top: 15%;
-  left: 50%;
-  transform: translateX(-50%);
-  width: 70em;
-  height: 53em;
-  z-index: 100;
-  display: none;
-}
-
-@media screen and (max-width : 800px){
-  section.image_viewer{
-    width: 85%;
-    height: 80vh;
-    left: 5%;
-    top: 10vh;
-    transform: translateX(0%);
-  }
-}
-
-section.image_viewer h3 div.image_viewer_close{
-  float: right;
-  cursor: pointer;
-}
-
-section.image_viewer h3 div.image_viewer_close img{
-  height: 1.5em;
-  width: 1.5em;
-}
-section.image_viewer h3 div.image_viewer_close img:hover{
-  filter: invert(70%);
-}
-
-section.image_viewer table.image_viewer_container{
-  width: 100%;
-  height: calc(100% - 4em);
-  border-collapse: collapse;
-}
-@media screen and (max-width : 800px){
-  section.image_viewer table.image_viewer_container{
-    max-height: 60vh;
-  }
-}
-
-section.image_viewer table.image_viewer_container td.control{
-  width: 4em;
-  text-align: center;
-}
-
-@media screen and (max-width : 900px){
-  section.image_viewer table.image_viewer_container td.control{
-    display: none;
-  }
-}
-
-section.image_viewer table.image_viewer_container td.control img{
-  width: 4em;
-  cursor: pointer;
-  transition: all 0.5s;
-}
-section.image_viewer table.image_viewer_container td.control img:hover{
-  filter: invert(70%);
-}
-
-section.image_viewer table.image_viewer_container td.image{
-  text-align: center;
-}
-
-section.image_viewer table.image_viewer_container td.image img{
-  max-width: 100%;
-  max-height: 50em;
-  border: 0.1em solid .000000;
-  background: .000000;
-}
-
-@media screen and (max-width : 800px){
-  section.image_viewer table.image_viewer_container td.image img{
-    max-width: 100%;
-    max-height: 60vh;
-  }
-}
-
-section.image_viewer div.secondary_controls{
-  display: none;
-}
-@media screen and (max-width : 900px){
-  section.image_viewer div.secondary_controls{
-    display: block;
-  }
-}
-
-section.image_viewer div.secondary_controls img{
-  width: 5em;
-  height: 5em;
-  margin: 0 3em;
-  cursor: pointer;
-}

+ 9 - 13
frontend/inigovalentin/src/app/components/project/project.component.html

@@ -1,16 +1,12 @@
-<a
-  routerLink="/show-project/{{ project.id }}"
-  class="w-full min-w-[230px] h-90 overflow-hidden block rounded relative hover:scale-105 transition-all">
-
-  <div class="absolute bottom-0 h-30 bg-black/60 p-2 w-full text-white">
-    <h2 class="text-ellipsis text-lg font-semibold">
-      {{ project.title }}
-    </h2>
-
-    <div class="flex justify-between bottom-0">
-      <p>
+<article class="project_preview">
+  <a routerLink="/show-project/{{project.id}}">
+    <div >
+      <h2 class="text-ellipsis text-lg font-semibold">
+        {{ project.title }}
+      </h2>
+      <p class="text">
         {{ project.header }}
       </p>
     </div>
-  </div>
-</a>
+  </a>
+</article>

+ 1 - 2
frontend/inigovalentin/src/app/components/project/project.component.ts

@@ -9,8 +9,7 @@ import { RouterModule } from '@angular/router';
   selector: 'app-project',
   standalone: true,
   imports: [DatePipe, RouterModule],
-  templateUrl: './project.component.html',
-  styleUrl: './project.component.css'
+  templateUrl: './project.component.html'
 })
 export class ProjectComponent {
   @Input() project!: Project;

+ 12 - 0
frontend/inigovalentin/src/app/models/message.ts

@@ -0,0 +1,12 @@
+export class Message {
+  name: string;
+  email: string;
+  subject: string;
+  text: string;
+  constructor(name: string, email: string, subject: string, text: string){
+    this.name = name;
+    this.email = email;
+    this.subject = subject;
+    this.text = text;
+  };
+}

+ 10 - 0
frontend/inigovalentin/src/app/models/user.ts

@@ -0,0 +1,10 @@
+export class User {
+  username?: string;
+  displayname?: string;
+  firstName?: string;
+  lastName?: string;
+  image?: string;
+  texts?: any[];
+  urls?: any[];
+  constructor(){};
+}

+ 0 - 0
frontend/inigovalentin/src/app/pages/home/home.component.css


+ 26 - 1
frontend/inigovalentin/src/app/pages/home/home.component.html

@@ -1 +1,26 @@
-<p>home works!</p>
+<section id='home_profile'>
+  <h3>{{user?.firstName}} {{user?.lastName}}</h3>
+  <article>
+    <h4 id='tagline'>{{texts["TAGLINE"]}}</h4>
+    <img id='profile_image' src="./img/profile/{{user?.image}}" alt="{{user?.firstName}} {{user?.lastName}}"/>
+    <div id='profile_social'>
+      <span class='fake_a' onclick='openMessage();'>
+        <img src='./img/social/email.svg' i18n-alt alt='Send me a message' i18n-title title='Send me a message'/>
+      </span>
+      @if(user?.urls){
+        <a *ngFor="let url of user?.urls" target='_blank' title='{{url.description}}' href='{{url.url}}'>
+            <img src='./img/social/{{url.logo}}' alt='{{url.name}}' title='{{url.description}}'/>
+        </a>
+      }
+    </div>
+    <div [innerHTML]="texts['BIO_SHORT']" id='bio'></div>
+  </article>
+</section>
+<section id='home_projects'>
+  <h3 i18n>Projects</h3>
+  <app-project-preview *ngFor="let project of projects" [project]="project"></app-project-preview>
+  <a id='all_projects' class='a_button' href="/projects/" i18n>
+    See all projects
+  </a>
+</section>
+<app-message></app-message>

+ 34 - 4
frontend/inigovalentin/src/app/pages/home/home.component.ts

@@ -1,12 +1,42 @@
-import { Component } from '@angular/core';
+import {Component, inject} from '@angular/core';
+import {AsyncPipe, NgFor} from '@angular/common';
+import {UserService} from '../../services/user.service';
+import {Observable} from 'rxjs';
+import {User} from '../../models/user';
+import {Project} from '../../models/project';
+import {ProjectsService} from '../../services/projects.service';
+import {ProjectPreviewComponent} from '../../components/project-preview/project-preview.component';
+import {MessageComponent} from '../../components/message/message.component';
+declare function openMessage(): any;
+declare function closeMessage(): any;
 
 @Component({
   selector: 'app-home',
   standalone: true,
-  imports: [],
-  templateUrl: './home.component.html',
-  styleUrl: './home.component.css'
+  imports: [AsyncPipe, NgFor, ProjectPreviewComponent, MessageComponent],
+  templateUrl: './home.component.html'
 })
+
 export class HomeComponent {
+  title = 'home';
+  user: User | undefined;
+  texts: {[key: string]: string} = {};
+  projects: Project[] = [];
+  errorMessage!: string;
+  constructor(private user_service: UserService, private projects_service: ProjectsService){}
+  ngOnInit(){
+    this.user_service.getUser().subscribe({
+      next: (user) => {
+        this.user = user;
+        this.title = this.user.firstName + " " + this.user.lastName;
+        this.user.texts?.forEach((t) => {this.texts[t.key] = t.text;});
+      },
+      error: (error) => {this.errorMessage = error;},
+    });
+    this.projects_service.getTopProjects(3).subscribe({
+      next: (projects) => {this.projects = projects;},
+      error: (error) => {this.errorMessage = error;},
+    });
 
+  }
 }

+ 24 - 0
frontend/inigovalentin/src/app/pages/profile/profile.component.html

@@ -0,0 +1,24 @@
+<section id='profile_details'>
+  <h3>{{user?.firstName}} {{user?.lastName}}</h3>
+  <article>
+    <img id='image' src="./img/profile/{{user?.image}}" alt="{{user?.firstName}} {{user?.lastName}}"/>
+    <h4 id='tagline'>{{texts["TAGLINE"]}}</h4>
+    <div id='social'>
+      <span class='fake_a' onclick='openMessage();'>
+        <img src='./img/social/email.svg' i18n-alt alt='Send me a message' i18n-title title='Send me a message'/>
+        <span>Send me a message</span>
+      </span>
+      @if(user?.urls){
+        <a *ngFor="let url of user?.urls" target='_blank' title='{{url.description}}' href='{{url.url}}'>
+          <img src='./img/social/{{url.logo}}' alt='{{url.name}}' title='{{url.description}}'/>
+          <span>{{url.description}}</span>
+        </a>
+      }
+    </div>
+  </article>
+</section>
+<section id='profile_bio'>
+  <h3 i18n>About me</h3>
+  <div id='bio' [innerHTML]="texts['BIO_SHORT']"></div>
+</section>
+<app-message></app-message>

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

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

+ 31 - 0
frontend/inigovalentin/src/app/pages/profile/profile.component.ts

@@ -0,0 +1,31 @@
+import {Component} from '@angular/core';
+import {NgFor} from '@angular/common';
+import {UserService} from '../../services/user.service';
+import {User} from '../../models/user';
+import {MessageComponent} from '../../components/message/message.component';
+declare function openMessage(): any;
+declare function closeMessage(): any;
+
+@Component({
+  selector: 'app-profile',
+  standalone: true,
+  imports: [NgFor, MessageComponent],
+  templateUrl: './profile.component.html'
+})
+export class ProfileComponent {
+  title = 'profile';
+  user: User | undefined;
+  texts: {[key: string]: string} = {};
+  errorMessage!: string;
+  constructor(private user_service: UserService){}
+  ngOnInit(){
+    this.user_service.getUser().subscribe({
+      next: (user) => {
+        this.user = user;
+        this.title = this.user.firstName + " " + this.user.lastName;
+        this.user.texts?.forEach((t) => {this.texts[t.key] = t.text;});
+      },
+      error: (error) => {this.errorMessage = error;},
+    });
+  }
+}

+ 0 - 339
frontend/inigovalentin/src/app/pages/project/project.component.css

@@ -1,339 +0,0 @@
-section{
-  text-align: center;
-  width: 90%;
-  margin: auto;
-}
-section h3{
-  text-align: left;
-}
-div.info_summary{
-  display: inline-block;
-  width: 72%;
-  margin: 0;
-  vertical-align: top;
-  padding: 0;
-  min-height: 13em;
-  text-align: left;
-}
-@media screen and (max-width : 800px){
-  div.info_summary{
-    display: block;
-    width: 100%;
-    margin: 0;
-    padding: 0;
-  }
-}
-
-div.info_summary img.logo{
-  padding: 0.5em;
-  max-width: 100%;
-  max-height: 18em;
-  border-radius: 1em;
-  float: left;
-  margin: 0 2em 1em 0;
-}
-@media screen and (max-width : 800px){
-  div.info_summary img.logo{
-    margin: 0 2em 1em 0.4em;
-    padding: 0.2em;
-    width: 20vw;
-    height: 20vw;
-  }
-}
-
-div.info_summary h4{
-  margin: 0.5em;
-  font-style: italic;
-}
-
-div.info_summary div.text{
-  margin: 2em;
-  min-height: 7em;
-}
-@media screen and (max-width : 800px){
-  div.info_summary div.text{
-    margin: 0;
-    min-height: 20vw;
-  }
-}
-div.comment{
-  font-style: italic;
-  border: 0.1em solid .333;
-  padding: 1.5em;
-  box-shadow: 0 0 0.5em .111;
-  margin: 1em auto;
-  max-width: 55em;
-  background: .dbffdb;
-  rotate: -1deg;
-  border-radius: 0.2em;
-  line-height: 1.6em;
-  text-align: left;
-  display: inline-block;
-}
-@media screen and (max-width : 800px){
-  div.comment{
-    rotate: 0deg;
-    margin: 0.5em;
-    padding: 0.5em;
-    box-shadow: 0 0 0;
-  }
-}
-div.comment div.comment_title{
-  font-family: 'Handwritten';
-  display: block;
-  width: 3em;
-  font-size: 180%;
-  float: left;
-  line-height: 1;
-  padding: 0.5em 0 0 0.5em;
-  margin: 0 0.2em 0 -0.5em;
-  rotate: -36deg;
-}
-@media screen and (max-width : 800px){
-  div.comment div.comment_title{
-    font-size: 110%;
-    margin: 0 0.5em 0 0;
-    line-height: 1;
-  }
-}
-div.info_details{
-  display: inline-block;
-  width: 22%;
-  vertical-align: top;
-  margin: 0;
-  border-left: 0.1em solid .aacaaa;
-  /*background: linear-gradient(to right, .e5f0e500 0%, .e5f0e5ff 20%, .e5f0e5ff 80%, .e5f0e500 100%); */
-  background: linear-gradient(to right, .e5f0e5ff 0%, .e5f0e5cc 30%, .e5f0e500 100%);
-  padding: 1em;
-  text-align: center;
-}
-@media screen and (max-width : 800px){
-  div.info_details{
-    text-align: center;
-    display: block;
-    width: 85%;
-    margin: 0.5em auto;
-    border-left: 0.1em solid .aacaaa;
-    border-right: 0.1em solid .aacaaa;
-    border-top: 0.1em solid .aacaaa;
-    border-bottom: 0.1em solid .aacaaa;
-    border-radius: 0.5em;
-    /*background: linear-gradient(to right, .e5f0e500 0%, .e5f0e5ff 20%, .e5f0e5ff 80%, .e5f0e500 100%);*/
-    padding: 0.5em;
-  }
-}
-
-div.info_details div.details{
-  margin: 0.5em 1.5em 0.5em 1.5em;
-}
-@media screen and (max-width : 800px){
-  div.info_details div.details{
-    margin: 0.5em 0;
-    text-align: left;
-  }
-}
-
-div.info_details div.details hr.details_separator{
-  margin: 0.7em -0.5em 0.7em -0.5em;
-  border: 0;
-  color: .aacaaa;
-  background-color: .aacaaa;
-  border-top: 0.1em solid .aacaaa;
-}
-@media screen and (max-width : 800px){
-  div.info_details div.details hr.details_separator{
-    margin: 0.5em;
-  }
-}
-
-div.info_details ul.urls{
-  padding-top: 0.5em;
-  text-align: center;
-  border-bottom: 0.1em solid #000000;
-  list-style-type: none;
-}
-
-div.info_details ul.urls a.url{
-  display: block;
-  margin-bottom: 1em;
-  text-align: left;
-}
-div.info_details ul.urls a.url img.url{
-  vertical-align: middle;
-  width: 1.8em;
-  height: 1.8em;
-  margin-right: 0.5em;
-}
-
-div.info_details div.tags{
-  border-bottom: 0.1em solid #000000;
-}
-
-div.info_details div.tags img{
-  max-width: 3em;
-  max-height: 3em;
-  border: 0.15em solid #000000;
-  border-radius: 50%;
-  margin: auto 0.3em;
-}
-
-div.info_details div.license{
-  color: #00000088;
-}
-
-
-div.info_details div.license img{
-  max-width: 8em;
-  max-height: 1.5em;
-  vertical-align: middle;
-  text-align: center;
-  margin-left: 1em;
-}
-
-
-/* The image reel */
-div#image_reel{
-  display: block;
-  margin: auto;
-}
-
-div#image_reel div.img{
-  display:inline-block;
-  vertical-align: top;
-  max-width: 20%;
-  max-height: 25em;
-  text-align: center;
-  background: .00000020;
-  border: 0.1em solid .aacaaa;
-  border-radius: 0.5em;
-  padding: 0.1em;
-  margin: 0.3em;
-  box-shadow: inset 0 0 0.5em .aacaaa;
-}
-
-div#image_reel div.img img{
-  display: block;
-  cursor: pointer;
-  margin: 0.5em auto 0.2em auto;
-  max-width: calc(100% - 0.5em);
-  max-height: 22em;
-  border: 0.1em solid .000000;
-  background: .000000;
-}
-div#image_reel div.img h5{
-  display: block;
-  margin: 0 auto 0.3em auto;
-}
-
-/* The image viewer. */
-div#image_viewer_cover{
-  position: fixed;
-  opacity: 0;
-  transition: all 0.7s;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  z-index: 99;
-  background-color: .00000077;
-  display: none;
-}
-
-section#image_viewer{
-  position: fixed;
-  top: 15%;
-  left: 50%;
-  transform: translateX(-50%);
-  width: 70em;
-  height: 53em;
-  z-index: 100;
-  display: none;
-}
-
-@media screen and (max-width : 800px){
-  section#image_viewer{
-    width: 85%;
-    height: 80vh;
-    left: 5%;
-    top: 10vh;
-    transform: translateX(0%);
-  }
-}
-
-section#image_viewer h3 div#image_viewer_close{
-  float: right;
-  cursor: pointer;
-}
-
-section#image_viewer h3 div#image_viewer_close img{
-  height: 1.5em;
-  width: 1.5em;
-}
-section#image_viewer h3 div#image_viewer_close img:hover{
-  filter: invert(70%);
-}
-
-section#image_viewer table#image_viewer_container{
-  width: 100%;
-  height: calc(100% - 4em);
-  border-collapse: collapse;
-}
-@media screen and (max-width : 800px){
-  section#image_viewer table#image_viewer_container{
-    max-height: 60vh;
-  }
-}
-
-section#image_viewer table#image_viewer_container td.control{
-  width: 4em;
-  text-align: center;
-}
-
-@media screen and (max-width : 900px){
-  section#image_viewer table#image_viewer_container td.control{
-    display: none;
-  }
-}
-
-section#image_viewer table#image_viewer_container td.control img{
-  width: 4em;
-  cursor: pointer;
-  transition: all 0.5s;
-}
-section#image_viewer table#image_viewer_container td.control img:hover{
-  filter: invert(70%);
-}
-
-section#image_viewer table#image_viewer_container td.image{
-  text-align: center;
-}
-
-section#image_viewer table#image_viewer_container td.image img{
-  max-width: 100%;
-  max-height: 50em;
-  border: 0.1em solid .000000;
-  background: .000000;
-}
-
-@media screen and (max-width : 800px){
-  section#image_viewer table#image_viewer_container td.image img{
-    max-width: 100%;
-    max-height: 60vh;
-  }
-}
-
-section#image_viewer div#secondary_controls{
-  display: none;
-}
-@media screen and (max-width : 900px){
-  section#image_viewer div#secondary_controls{
-    display: block;
-  }
-}
-
-section#image_viewer div#secondary_controls img{
-  width: 5em;
-  height: 5em;
-  margin: 0 3em;
-  cursor: pointer;
-}

+ 37 - 42
frontend/inigovalentin/src/app/pages/project/project.component.html

@@ -1,46 +1,49 @@
 <script type="text/javascript" src="./js/project.js"></script>
 @if(projectObs$ | async; as project){
-  <section>
+  <section id='project'>
     <h3>{{ project.title }}</h3>
     <article>
-      <div class='info_summary'>
+      <div id='summary'>
         @if(project.logo){
-          <img class='logo' src="./img/project/{{project.logo}}" alt={{project.title}}/>
+          <img id='project_logo' src="./img/project/{{project.logo}}" alt={{project.title}}/>
         }
-        <h4>{{ project.header }}</h4>
-        <div class='text' [innerHTML]=project.text></div>
+        <div id='texts'>
+          <h4>{{ project.header }}</h4>
+          <div id='text' [innerHTML]=project.text></div>
+        </div>
       </div>
-      <div class='info_details'>
+      <div id='details'>
         @if(project['project-urls']){
-          <ul class='urls'>
+          <ul id='urls'>
             <li *ngFor="let url of project['project-urls']">
-              <a href="{{ url.url }}" title="{{ url.type.summary }}" target="_blank" class="url">
-                <img src="./img/social/{{url.type.logo}}" alt="{{url.type.title}}" class="url"/>
-                {{ url.type.title }}
+              <a href="{{ url.url }}" title="{{ url.type.summary }}" target="_blank">
+                <img src="./img/social/{{url.type.logo}}" alt="{{url.type.title}}"/>
+                {{url.type.title}}
               </a>
             </li>
           </ul>
         }
+        @if(project.license){
+          <div id='license'>
+            License:
+            <img src="./img/license/{{project.license.icon}}" alt="{{project.license.legal}}" title="{{project.license.legal}}"/>
+          </div>
+        }
         @if(project.tags){
-          <div class='tags'>
+          <div id='tags'>
+            Tags:
             <span *ngFor="let tag of project.tags">
               <img src="./img/tag/{{tag.icon}}" alt="{{tag.tag}}" title="{{tag.tag}}"/>
             </span>
           </div>
         }
-        @if(project.license){
-          <div class='license'>
-            License:
-            <img src="./img/license/{{project.license.logo}}" alt="{{project.license.legal}}" title="{{project.license.legal}}"/>
-          </div>
-        }
       </div>
       @if(project.comment){
-        <div class='comment'>
-          <div class='comment_title'>
+        <div id='comment'>
+          <div id='comment_title'>
               TODO NOTES
           </div>
-          <div class="comment_text" [innerHTML]=project.comment></div>
+          <div id="comment_text" [innerHTML]=project.comment></div>
         </div>
       }
       @if(project['project-images']){
@@ -53,37 +56,29 @@
       }
     </article>
   </section>
-  <div id='image_viewer_cover' onclick='closeImage();'></div>
+  <div id='cover' onclick='closeImage();'></div>
   <section id='image_viewer'>
     <h3>
       <span id='image_viewer_title'></span>
       <div id='image_viewer_close'>
-          <a onClick='closeImage();'>
-              <img src='https://v-gears.inigovalentin.com/img/close.svg'/>
-          </a>
+        <a onClick='closeImage();'>
+          <img src='./img/control/close.svg'/>
+        </a>
       </div>
     </h3>
-    <table id='image_viewer_container'>
-        <td class='control'>
-            <a onClick='prevImage();'>
-                <img src='https://v-gears.inigovalentin.com/img/prev.svg'/>
-            </a>
-        </td>
-        <td class='image'>
-            <img id='image_viewer_image'/>
-        </td>
-        <td class='control'>
-            <a onClick='nextImage();'>
-                <img src='https://v-gears.inigovalentin.com/img/next.svg'/>
-            </a>
-        </td>
-    </table>
-    <div id='secondary_controls'>
+    <div class='control desktop a_button' onClick='prevImage();'>
+      <img src='./img/control/prev.svg'/>
+    </div>
+    <img id='image_viewer_image'/>
+    <div class='control desktop a_button' onClick='nextImage();'>
+      <img src='./img/control/next.svg'/>
+    </div>
+    <div id='secondary_controls' class='mobile'>
         <a onClick='prevImage();'>
-            <img src='https://v-gears.inigovalentin.com/img/prev.svg'/>
+            <img src='./img/control/prev.svg'/>
         </a>
         <a onClick='nextImage();'>
-            <img src='https://v-gears.inigovalentin.com/img/next.svg'/>
+            <img src='./img/control/next.svg'/>
         </a>
     </div>
   </section>

+ 1 - 2
frontend/inigovalentin/src/app/pages/project/project.component.ts

@@ -16,8 +16,7 @@ declare var totalImages: number;
   selector: 'app-project',
   standalone: true,
   imports: [AsyncPipe, DatePipe, CurrencyPipe, NgIf, NgFor],
-  templateUrl: './project.component.html',
-  styleUrl: './project.component.css'
+  templateUrl: './project.component.html'
 })
 export class ProjectComponent {
 

+ 0 - 0
frontend/inigovalentin/src/app/pages/projects/projects.component.css


+ 4 - 1
frontend/inigovalentin/src/app/pages/projects/projects.component.html

@@ -1 +1,4 @@
-<app-project-preview *ngFor="let project of projects" [project]="project"></app-project-preview>
+<section id='projects'>
+  <h3 i18n>Projects</h3>
+  <app-project-preview *ngFor="let project of projects" [project]="project"></app-project-preview>
+</section>

+ 2 - 7
frontend/inigovalentin/src/app/pages/projects/projects.component.ts

@@ -12,8 +12,7 @@ declare function showImage(element: any): any
   selector: 'app-projects',
   standalone: true,
   imports: [ProjectComponent, NgFor, ProjectPreviewComponent],
-  templateUrl: './projects.component.html',
-  styleUrl: './projects.component.css'
+  templateUrl: './projects.component.html'
 })
 
 export class ProjectsComponent {
@@ -25,10 +24,7 @@ export class ProjectsComponent {
 
   ngOnInit() {
     this.projects_service.getAllProjects().subscribe({
-      next: (projects) => {
-        this.projects = projects;
-        console.log(this.projects);
-      },
+      next: (projects) => {this.projects = projects;},
       error: (error) => {this.errorMessage = error;},
     });
     let node = document.createElement('script');
@@ -37,6 +33,5 @@ export class ProjectsComponent {
     node.async = true;
     node.charset = 'utf-8';
     document.getElementsByTagName('head')[0].appendChild(node);
-    console.log("LOAD!");
   }
 }

+ 4 - 0
frontend/inigovalentin/src/app/services/projects.service.ts

@@ -21,5 +21,9 @@ export class ProjectsService {
     return this.http.get<Project>(this.apiUrl + permalink);
   }
 
+  getTopProjects(total: Number): Observable<Project[]> {
+    return this.http.get<Project[]>(this.apiUrl + "/top/" + total);
+  }
+
 
 }

+ 16 - 0
frontend/inigovalentin/src/app/services/user.service.spec.ts

@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { UserService } from './user.service';
+
+describe('UserService', () => {
+  let service: UserService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.inject(UserService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+});

+ 11 - 0
frontend/inigovalentin/src/app/services/user.service.ts

@@ -0,0 +1,11 @@
+import {Injectable} from '@angular/core';
+import {HttpClient} from '@angular/common/http';
+import {Observable} from 'rxjs';
+import {User} from '../models/user';
+
+@Injectable({providedIn: 'root'})
+export class UserService {
+  apiUrl = 'http://localhost:8080/api/user/';
+  constructor(private http: HttpClient) {}
+  getUser(): Observable<User> {return this.http.get<User>(this.apiUrl);}
+}

+ 2 - 1
frontend/inigovalentin/src/index.html

@@ -2,10 +2,11 @@
 <html lang="en">
 <head>
   <meta charset="utf-8">
-  <title>Inigovalentin</title>
+  <title>{{title}}</title>
   <base href="/">
   <meta name="viewport" content="width=device-width, initial-scale=1">
   <link rel="icon" type="image/x-icon" href="favicon.ico">
+  <script type="text/javascript" src="./js/ui.js"></script>
 </head>
 <body>
   <app-root></app-root>

+ 5 - 6
frontend/inigovalentin/src/main.ts

@@ -1,7 +1,6 @@
-import { bootstrapApplication } from '@angular/platform-browser';
-import { appConfig } from './app/app.config';
-import { AppComponent } from './app/app.component';
+/// <reference types="@angular/localize" />
 
-console.log("STARTING!");
-bootstrapApplication(AppComponent, appConfig)
-  .catch((err) => console.error(err));
+import {bootstrapApplication} from '@angular/platform-browser';
+import {appConfig} from './app/app.config';
+import {AppComponent} from './app/app.component';
+bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err));

+ 0 - 721
frontend/inigovalentin/src/styles.css

@@ -1,721 +0,0 @@
-/* You can add global styles to this file, and also import other style files */
-/* Website fonts. */
-@font-face {
-  font-family: 'Nunito';
-  src:  url('/fonts/Nunito-Regular.ttf');
-}
-@font-face {
-  font-family: 'Mono';
-  src:  url('/fonts/NotoSansMono-Regular.ttf');
-}
-@font-face {
-  font-family: 'Mono';
-  src:  url('/fonts/NotoSansMono-Bold.ttf');
-  font-weight: bold;
-}
-@font-face {
-  font-family: 'Handwritten';
-  src:  url('/fonts/Handwritten.ttf');
-}
-
-/* Elements to be shown only in large screens. */
-.desktop{
-  display: initial;
-}
-@media screen and (max-width : 800px){
-  .desktop{
-    display: none;
-  }
-}
-
-/* Elements to be shown only in small screens. */
-.mobile{
-  display: none;
-}
-@media screen and (max-width : 800px){
-  .mobile{
-    display: initial !important;
-  }
-}
-
-/* Styles for the body of the page. */
-html {
-  height: 100%;
-}
-
-body{
-  height: 100%;
-  padding: 0;
-  margin: 0;
-  background-color: #000000;
-  font-size: 110%;
-  min-height: 100vh;
-  flex-direction: column;
-  display: flex;
-}
-@media screen and (max-width : 800px){
-  body{
-    font-size: 120%;
-  }
-}
-
-/* Style for all links. */
-a{
-  color: #555555;
-  text-shadow: 0 0 0.5em #7777ff;
-  text-decoration: none;
-}
-
-p{
-  text-indent: 1em;
-}
-
-/* Styles for elements that look and act as links. */
-.fake_a{
-  color: #555555;
-  text-shadow: 0 0 0.5em #7777ff;
-  text-decoration: none;
-  cursor: pointer;
-}
-
-
-/* Bold elements. */
-.bold{
-  font-weight: bold;
-}
-
-/* Elements that look and act as buttons or links.*/
-.pointer {
-  cursor: pointer;
-}
-
-/* Hidden elements. */
-.hidden{
-  display: none;
-}
-
-img.slider{
-  width: 1em;
-  height: 1em;
-  transition: all .3s ease-in-out;
-}
-
-div#cover{
-  display: none;
-  background-color: #000000bb;
-  opacity: 0;
-  transition: opacity .3s ease-in-out;
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-}
-
-/* The contact form */
-div#contact_bg{
-  display: none;
-  background: #000000;
-  opacity: 0.6;
-  transition: all .4s ease-in-out;
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-}
-section#contact{
-  display: none;
-  position: fixed;
-  top: 20%;
-  width: 60%;
-  margin: auto;
-  left: 20%;
-}
-@media screen and (max-width : 800px){
-  section#contact{
-    width: 90%;
-    top: 15%;
-    left: 5%;
-  }
-}
-section#contact h3 img#contact_close{
-  float:right;
-  width: 1em;
-  height: 1em;
-}
-section#contact div.contact_buttons{
-  text-align: center;
-}
-section#contact div.contact_buttons input{
-  margin: 1em;
-}
-@media screen and (max-width : 800px){
-  section#contact div.contact_buttons input{
-    margin: 0.3em;
-  }
-}
-section#contact div#contact_menu{
-  height: 18em;
-}
-section#contact div#contact_menu table{
-  width: 100%;
-}
-section#contact div#contact_menu table td.label{
-  width: 8em;
-  text-align: right;
-  padding-right: 1em;
-  vertical-align: top;
-}
-section#contact div#contact_menu table input[type=text]{
-  width: 18em;
-}
-@media screen and (max-width : 800px){
-  section#contact div#contact_menu table td.label{
-    width: initial;
-    padding-right: 0.5em;
-    padding-bottom: 1em;
-  }
-  section#contact div#contact_menu table input[type=text]{
-    width: 90%;
-  }
-}
-section#contact div#contact_menu table textarea{
-  width: 90%;
-  height: 5em;
-  margin: auto;
-}
-section#contact div#contact_menu span.contact_error {
-  font-weight: bold;
-  color: #581010;
-  background-color: #E2AAAA;
-  border: 0.2em solid #E02323;
-  border-radius: 0.429em;
-  padding: 0.2em 0.5em;
-  text-shadow: 0 0 0.15em #0003;
-  cursor: pointer;
-  font-size: 80%;
-  margin-top: -0.5em;
-  margin-left: 1em;
-  transition: all .2s ease-in-out;
-}
-section#contact div#contact_progress{
-  height: 18em;
-  text-align: center;
-}
-section#contact div#contact_progress img{
-  margin-top: 6em;
-  width: 3em;
-  height: 3em;
-  animation: contact_progress 4s infinite linear;
-}
-@keyframes contact_progress {
-  from {
-    transform: rotate(0deg);
-  }
-  to {
-    transform: rotate(359deg);
-  }
-}
-section#contact div#contact_confirmation{
-  height: 18em;
-  text-align: center;
-}
-section#contact div#contact_confirmation p{
-  padding-top: 2em;
-  padding-bottom: 2em;
-  font-size: 150%;
-  font-weight: bold;
-}
-main{
-  background-color: #000000;
-  width: 100%;
-  max-width: 90em;
-  margin: 2em auto;
-  padding: 0;
-  text-align: center;
-  flex: 1;
-}
-@media screen and (max-width : 800px){
-  main{
-    width: 100%;
-    margin: 1em auto;
-  }
-}
-
-section{
-  text-align: left;
-  margin: 0.5em;
-  padding: 1em;
-  background-color: #f0f0f0;
-  border: 0.1em solid #003300;
-  overflow: hidden;
-  border-top-left-radius: 0.5em;
-  border-top-right-radius: 0.5em;
-  border-bottom-left-radius: 1.5em;
-  border-bottom-right-radius: 1.5em;
-}
-
-h3{
-  position: relative;
-  font-weight: bold;
-  color: #ffffff;
-  background-color: #60ae29;
-  border-bottom: 0.1em solid #003300;
-  font-size: 150%;
-  padding: 0.5em 2em 0.2em 3em;
-  margin: -0.8em -1em 1em -1em;
-  font-variant: small-caps;
-  font-family: 'Mono'
-}
-
-h3:before {
-  content: " /";
-  opacity: 0.3;
-}
-
-@media screen and (max-width : 800px){
-  h3{
-    font-size: 120%;
-    padding: 1em 2em 0.2em 1.5em;
-    margin-top: -1.6em;
-  }
-}
-
-/* .entry is a style only class to show content. Must be allways children os a .lighted element */
-section article{
-  border: 0.1em solid #aaffaa00;
-  border-radius: 0.3em;
-  padding: 0.5em;
-  margin: 0.5em;
-  transition: all .4s ease-in-out;
-}
-@media screen and (max-width : 800px){
-  section article{
-    border-radius: 0.3em;
-    padding: 0.2em;
-    margin: 0.2em;
-  }
-}
-h4{
-  font-variant: small-caps;
-  font-weight: bold;
-  font-family: 'Mono';
-  text-decoration-color: #33553377;
-  font-size: 140%;
-  margin: 0.2em auto 1em auto;
-}
-@media screen and (max-width : 800px){
-  h4{
-    font-size: 120%;
-  }
-}
-
-section section_list:hover{
-  background-color: #efffef;
-  border: 0.1em solid #aaffaaff;
-}
-
-
-
-/* Style for the mathces in search results */
-span.result_counter{
-  margin-left: 1.2em;
-  margin-bottom: 0.5em;
-  font-weight: bold;
-  font-style: italic;
-}
-
-/* Shown when no records on database */
-div.unavailable{
-  margin-left: 4em;
-  font-style: italic;
-}
-
-
-/**********************************
- * Styles to be applied to some   *
- * horizontally scrollable        *
- * elements                       *
- **********************************/
-
-/* Horizontall scrollbar that apears when a project has lots of renders */
-.slider::-webkit-scrollbar{
-  width: 0.5em;
-  height: 0.25em;
-  background-color: #444444;
-}
-.slider::-webkit-scrollbar-thumb{
-  background-color: #cccccc;
-  border-radius: 0.714em;
-}
-.slider::-webkit-scrollbar-thumb:hover{
-  background-color: #cccccc;
-  border: 0.143em solid #000000;
-}
-.slider::-webkit-scrollbar-thumb:active{
-  background-color: #cccccc;
-  border: 0.143em solid #000000;
-}
-
-/* Icons for sharing page in a social network */
-img.social_icon{
-  width: 3em;
-  height: 3em;
-}
-
-/* Elements that should have the cursor as pointer */
-.pointer{
-  cursor: pointer;
-}
-
-
-
-/**********************************
- * Styles for diverse input       *
- * elements. Type-assigned        *
- **********************************/
-
-/* Font for all the input elements */
-input{
-  font-family: 'Mono';
-}
-
-/* Style for the "submit" type elements in forms */
-input[type=submit]{
-  font-weight: bold;
-  color: #cccccc;
-  background-color: #555555;
-  border: 0.143em solid #bbbbbb;
-  border-radius: 0.429em;
-  padding: 0.5em;
-  text-shadow: 0 0 0.75em #000000;
-  cursor: pointer;
-}
-
-/* Style forr buttons */
-input[type=button], input[type=submit]{
-  cursor: pointer;
-  display: inline-block;
-  color: #dddddd;
-  background-color: #278c3a;
-  border: 0.143em solid #66bf38;
-  border-radius: 0.429em;
-  padding: 0.5em 0.8em;
-  text-shadow: 0 0 0.75em #000000;
-  text-decoration: none;
-  font-family: 'Nunito';
-  font-weight: bold;
-  font-variant: small-caps;
-  letter-spacing: 0.07em;
-  font-size: 110%;
-  box-shadow: inset 0 0 0.2em #082400;
-  text-transform: capitalize;
-}
-@media screen and (max-width : 800px){
-  input[type=button], input[type=submit]{
-    /*font-size: 120%;
-     *        padding: 0.9em 1.4em;*/
-  }
-}
-input[type=button]:hover{
-  color: #ffffff;
-  background-color: #34b94d;
-  box-shadow: inset 0 0 0.1em #000;
-  border: 0.143em solid #285710;
-}
-
-/* Style for textboxes */
-input[type=text]{
-  font-weight: bold;
-  color: #224422;
-  background-color: #bbccbb;
-  border: 0.286em solid #ddeedd;
-  border-color: #dddddd;
-  border-radius: 0.429em;
-  padding: 0.25em;
-  text-shadow: 0 0 0.5em #ffffff;
-}
-
-input[type=checkbox]{
-  width: 1em;
-  height: 1em;
-  border-radius: 0.2em;
-  border: 0.063em solid #005500;
-}
-
-/* Style for textareas */
-textarea{
-  width: 80%;
-  color: #224422;
-  background-color: #bbccbb;
-  border: 0.286em solid #ddeedd;
-  border-color: #dddddd;
-  border-radius: 0.429em;
-  padding: 0.25em;
-  text-shadow: 0 0 0.5em #ffffff;
-  resize: none;
-}
-
-/* Style for selects */
-select{
-  font-weight: bold;
-  color: #444444;
-  background-color: #bbbbbb;
-  border: 0.286em solid #dddddd;
-  border-radius: 0.286em;
-  padding: 0.25em;
-  text-shadow: 0 0 0.5em #ffffff;
-}
-
-/* Style for placeholders in input fields */
-::-webkit-input-placeholder { /* WebKit browsers */
-  color: #777777;
-}
-:-moz-placeholder { /* Mozilla Firefox 4 to 18 */
-  color: #777777;
-}
-::-moz-placeholder { /* Mozilla Firefox 19+ */
-  color: #777777;
-}
-:-ms-input-placeholder { /* Internet Explorer 10+ */
-  color: #777777;
-}
-
-/* Style-only class user to indicate that a field is incorrect. Ususally this class is added to an element with js */
-.error{
-  border-color: #ffaaaa;
-}
-
-/* Style for the textbox input when the entered text is not the expected one */
-input[type=text].error{
-  border-color: #ffaaaa;
-}
-
-/* Style for the textared input when the entered text is not the expected one */
-textarea.error{
-  border-color: #ffaaaa;
-}
-
-/* Style for buttons that act as links */
-a.a_button{
-  display: inline-block;
-  color: #dddddd;
-  background-color: #278c3a;
-  border: 0.143em solid #66bf38;
-  border-radius: 0.429em;
-  padding: 0.5em 0.8em;
-  text-shadow: 0 0 0.75em #000000;
-  text-decoration: none;
-  font-family: 'Mono';
-  font-weight: bold;
-  font-variant: small-caps;
-  letter-spacing: 0.07em;
-  font-size: 110%;
-  box-shadow: inset 0 0 0.2em #082400;
-  text-transform: capitalize;
-  transition: all .3s ease-in-out;
-}
-@media screen and (max-width : 800px){
-  a.a_button{
-    font-size: 100%;
-    padding: 0.3em 0.2em;
-  }
-}
-a.a_button:hover{
-  color: #ffffff;
-  background-color: #34b94d;
-  box-shadow: inset 0 0 0.1em #000;
-  border: 0.143em solid #285710;
-}
-
-/**********************************
- * Styles for comment-related     *
- * elements                       *
- **********************************/
-
-/* Contains fields to identify the user before the comment can be posted. Hidden by css, must be displayed with js */
-div#identification_form{
-  display: none;
-}
-
-/* Contain a comment, with the details and the text */
-div.comment{
-  background-color: #efffef;
-  border: 0.1em solid #aaffaa;
-  border-radius: 0.3em;
-  padding: 0.5em;
-  margin: 0.5em;
-}
-
-/* User who posted the coment */
-div.comment h4.comment_user{
-  margin-top: 0.417em;
-  margin-bottom: 0;
-}
-
-div.comment h4.comment_user span.comment_user_registered{
-  font-weight: bold;
-  font-size: 110%;
-  color: #005500;
-}
-
-/* Style for the comment's images */
-img.comment_image{
-  border: 0.077em solid black;
-  max-height: 6em;
-  max-width: 60%;
-  display: block;
-  border: 0.133em solid black;
-  margin-left: 0.938em;
-  margin-bottom: 0.938em;
-  box-shadow: 0 0 1em #444444;
-}
-
-/* Paragraph with the comment text */
-p.comment_text{
-  width: auto;
-  display: block;
-  margin-top: 0.938em;
-  margin-left: 0.938em;
-}
-
-span.comment_date{
-  font-size: 80%;
-  font-style: italic;
-}
-
-/* Input field for the comment text */
-textarea#new_comment_text{
-  width: 98%;
-}
-
-/* Input field for the comment user */
-input#new_comment_user{
-  width: 47%;
-}
-
-/* Input field for the comment email */
-input#new_comment_email{
-  width: 47%;
-}
-
-/* Styles so the input field to upload an image with the comment looks like a button */
-label.image_upload {
-  font-weight: bold;
-  color: #cccccc;
-  background-color: #555555;
-  border: 0.067em solid #bbbbbb;
-  border-radius: 0.2em;
-  padding: 0.5em;
-  text-shadow: 0 0 0.750em #000000;
-  font-size: 0.813em; /*Same as buttons*/
-  font-family: 'Nunito';
-}
-
-label.image_upload input {
-  width: 0 !important;
-  opacity: 0 !important;
-  overflow: hidden !important;
-}
-
-/* Previsualization for the upload image in a comment */
-img#new_comment_image_preview{
-  max-height: 7.692em;
-  max-width: 11.538em;
-}
-
-
-/**********************************
- * Styles for the social buttons. *
- **********************************/
-
-a.share img.share{
-  display: inline-block;
-  vertical-align: top;
-  overflow: hidden;
-  width: 1.6em;
-  height: 1.6em;
-  border: 0.1em solid #003300;
-  border-radius: 2em;
-  box-shadow: 0 0 0.3em #005500;
-  margin: 0.2em;
-  transition: all .3s ease-in-out;
-}
-
-a.share img.share:hover{
-  box-shadow: 0 0 0.7em #005500;
-  border-radius: 0.7em;
-}
-
-span.technology{
-  display: inline-block;
-}
-
-span.technology img.technology_logo{
-  display: inline-block;
-  position:relative;
-  vertical-align: middle;
-  border-radius: 50%;
-  border-width: 0.2em;
-  z-index: 2;
-  background-color: #ffffff;
-  border-style: solid;
-  border-color: #000000;
-  height: 1.5em;
-  width: 1.5em;
-}
-span.technology span.technology_name{
-  display: inline-block;
-  position:relative;
-  vertical-align: middle;
-  border-radius: 15%;
-  border-width: 0.2em;
-  border-style: solid;
-  padding: 0.1em 0.2em 0.1em 1.1em;
-  z-index: 1;
-  background-color: #ffffff;
-  border-color: #000000;
-  margin-left: -1.3em;
-  font-size: 70%;
-  font-weight: bold;
-}
-
-span.technology_1 span.technology_name{
-  background-color: #f4a4a4;
-}
-span.technology_2 span.technology_name{
-  background-color: #ffc898;
-}
-span.technology_3 span.technology_name{
-  background-color: #a4c3f4;
-}
-span.technology_4 span.technology_name{
-  background-color: #f4a4e4;
-}
-span.technology_5 span.technology_name{
-  background-color: #a4f4a9;
-}
-span.technology_6 span.technology_name{
-  background-color: #f4e8a4;
-}
-
-
-/* Project icons */
-img.project_image{
-  padding: 0.5em;
-  max-width: 9em;
-  max-height: 9em;
-  background-color: #ffffff;
-  box-shadow: 0 0 0 0.1em #000000, 0 0 0 0.3em #66bf38, 0 0 0 0.4em #000000, inset 0 0 0.5em #00220077;
-  border-radius: 1em;
-}
-@media screen and (max-width : 1000px){
-  img.project_image{
-    padding: 0.2em;
-    max-width: 13vw;
-    max-height: 13vw;
-    border-radius: 0.5em;
-    box-shadow: 0 0 0 0.05em #000000, 0 0 0 0.15em #66bf38, 0 0 0 0.2em #000000, inset 0 0 0.25em #00220077;
-  }
-}

+ 49 - 0
frontend/inigovalentin/src/styles/_color.scss

@@ -0,0 +1,49 @@
+/*
+ * Colors across the site.
+ */
+$bg: #000000;
+$a-base: #555555;
+$a-shadow: #7777ff;
+$a-hover: #777777;
+$a-visited: #555555;
+$cover: #000000bb;
+$section-bg: #f0f0f0;
+$section-border: #003300;
+$section-title: #ffffff;
+$section-title-bg: #60ae29;
+$section-title-border: #003300;
+$article-bg: #fafafa;
+$article-cover: linear-gradient(to top, #fafafaff 0%, #fafafaee 30%, #fafafa00 100%);
+$article-border: #aaffaa;
+$button: #dddddd;
+$button-bg: #278c3a;
+$button-border: #66bf38;
+$button-shadow: #000000;
+$button-hover: #ffffff;
+$button-hover-bg: #34b94d;
+$button-hover-border: #285710;
+$button-hover-shadow: #000000;
+$input: #224422;
+$input-bg: #bbccbb;
+$input-border: #66bf38;
+$input-border-error: #ff8c05;
+$input-shadow: #000000;
+$input-placeholder: #333333;
+$icon-border: #60ae29;
+
+/*
+ * Header and footer
+ */
+$header: #ffffff;
+$footer-separator: #444444;
+$footer-icon-bg: #ffffff;
+$footer-icon-border: #60ae29;
+$footer-icon-shadow: #111111;
+
+/*
+ * Project view
+ */
+$project-details: #000000aa;
+$project-details-gradient: linear-gradient(to right, #e5f0e5ff 0%, #e5f0e5cc 30%, #e5f0e500 100%);
+$project-details-border: #aacaaa;
+

+ 11 - 0
frontend/inigovalentin/src/styles/_font.scss

@@ -0,0 +1,11 @@
+/*
+ * Font definitions.
+ */
+$nunito: 'Nunito';
+$nunito-src: url('/font/Nunito-Regular.ttf');
+$mono: 'Mono';
+$mono-src: url('/font/NotoMono-Regular.ttf');
+$mono-src-bold: url('/font/NotoMono-Regular.ttf');
+$handwritten: 'Handwritten';
+$handwritten-src: url('/font/Handwritten.ttf');
+

+ 823 - 0
frontend/inigovalentin/src/styles/styles.scss

@@ -0,0 +1,823 @@
+@use 'font';
+@use 'color';
+
+@font-face {
+  font-family: font.$nunito;
+  src: font.$nunito-src;
+}
+@font-face {
+  font-family: font.$mono;
+  src: font.$mono-src;
+}
+@font-face {
+  font-family: font.$mono;
+  src: font.$mono-src-bold;
+}
+@font-face {
+  font-family: font.$handwritten;
+  src: font.$handwritten-src;
+}
+
+/* Elements to be shown only in large screens. */
+.desktop{display: initial;}
+@media screen and (max-width : 800px){.desktop{display: none;}}
+
+/* Elements to be shown only in small screens. */
+.mobile{display: none;}
+@media screen and (max-width : 800px){.mobile{display: initial !important;}}
+
+/*
+ * || COMMON
+ * Styles for common elements.
+ */
+html {height: 100%;}
+
+body{
+  height: 100%;
+  padding: 0;
+  margin: 0;
+  background-color: color.$bg;
+  font-size: 110%;
+  min-height: 100vh;
+  flex-direction: column;
+  display: flex;
+}
+
+main{
+  width: 100%;
+  min-height: calc(100% - 12em);
+  max-width: 130em;
+  margin: 0 auto;
+  text-align: center;
+}
+
+/* Style for all links. */
+a, .fake_a{
+  color: color.$a-base;
+  text-shadow: 0 0 0.5em color.$a-shadow;
+  text-decoration: none;
+  transition: all 0.2s ease-in-out;
+  cursor: pointer;
+  &:hover{
+    color: color.$a-hover;
+    text-shadow: 0 0 0.1em color.$a-shadow;
+  }
+}
+
+p{text-indent: 1em;}
+
+.bold{font-weight: bold;}
+.pointer {cursor: pointer;}
+.hidden{display: none;}
+
+img.slider{
+  width: 1em;
+  height: 1em;
+  transition: all .3s ease-in-out;
+}
+
+#cover{
+  display: none;
+  background-color: color.$cover;
+  opacity: 0;
+  transition: opacity .3s ease-in-out;
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}
+
+section{
+  text-align: left;
+  margin: 0.5em;
+  padding: 1em;
+  background-color: color.$section-bg;
+  border: 0.1em solid color.$section-border;
+  overflow: hidden;
+  border-radius: 0.5em 0.5em 1em 1em;
+
+  h3{
+    position: relative;
+    font-weight: bold;
+    color: color.$section-title;
+    background-color: color.$section-title-bg;
+    border-bottom: 0.1em solid color.$section-title-border;
+    font-size: 150%;
+    padding: 0.5em 2em 0.2em 3em;
+    margin: -0.8em -1em 1em -1em;
+    font-variant: small-caps;
+    font-family: font.$mono;
+  }
+
+  article{
+    background-color: color.$article-bg;
+    border: 0.1em solid color.$article-border;
+    border-radius: 0.3em;
+    padding: 0.5em;
+    margin: 0.5em;
+    transition: all .4s ease-in-out;
+  }
+
+}
+
+h4{
+  font-variant: small-caps;
+  font-weight: bold;
+  font-family: 'Mono';
+  font-size: 140%;
+  margin: 0.2em auto 1em auto;
+}
+
+input{
+  font-family: font.$mono;
+  font-weight: bold;
+
+  &[type=button], &[type=submit]{
+    cursor: pointer;
+    display: inline-block;
+    color: color.$button;
+    background-color: color.$button-bg;
+    border: 0.143em solid color.$button-border;
+    border-radius: 0.429em;
+    padding: 0.5em 0.8em;
+    text-shadow: 0 0 0.75em color.$button-shadow;
+    text-decoration: none;
+    font-family: font.$nunito;
+    font-variant: small-caps;
+    letter-spacing: 0.07em;
+    box-shadow: inset 0 0 0.2em color.$button-shadow;
+    text-transform: capitalize;
+
+    &:hover{
+      color: color.$button-hover;
+      background-color: color.$button-hover-bg;
+      box-shadow: inset 0 0 0.1em color.$button-hover-shadow;
+      border: 0.143em solid color.$button-hover-border;
+    }
+  }
+
+  &[type=text]{
+    font-weight: bold;
+    color: color.$input;
+    background-color: color.$input-bg;
+    border: 0.286em solid ;
+    border-color: color.$input-border;
+    border-radius: 0.429em;
+    padding: 0.25em;
+
+    &::placeholder{color: color.$input-placeholder;}
+
+    &.error{border-color: color.$input-border-error;}
+  }
+
+  &[type=checkbox]{
+    width: 1.5em;
+    height: 1.5em;
+    border-radius: 0.4em;
+    background-color: color.$button-bg;
+    border: 0.143em solid color.$button-border;
+  }
+}
+
+textarea{
+  color: color.$input;
+  background-color: color.$input-bg;
+  border: 0.286em solid ;
+  border-color: color.$input-border;
+  border-radius: 0.429em;
+  padding: 0.25em;
+
+  &::placeholder{color: color.$input-placeholder;}
+
+  &.error{border-color: color.$input-border-error;}
+}
+
+select{
+  color: color.$button;
+  background-color: color.$button-bg;
+  border: 0.143em solid color.$button-border;
+  border-radius: 0.429em;
+  padding: 0.5em 0.8em;
+  text-shadow: 0 0 0.75em color.$button-shadow;
+  text-decoration: none;
+  font-family: font.$nunito;
+  font-variant: small-caps;
+  letter-spacing: 0.07em;
+  box-shadow: inset 0 0 0.2em color.$button-shadow;
+  text-transform: capitalize;
+}
+
+.a_button{
+  font-family: font.$mono;
+  font-weight: bold;
+  cursor: pointer;
+  display: inline-block;
+  color: color.$button;
+  background-color: color.$button-bg;
+  border: 0.143em solid color.$button-border;
+  border-radius: 0.429em;
+  padding: 0.5em 0.8em;
+  text-shadow: 0 0 0.75em color.$button-shadow;
+  text-decoration: none;
+  font-family: font.$nunito;
+  font-variant: small-caps;
+  letter-spacing: 0.07em;
+  box-shadow: inset 0 0 0.2em color.$button-shadow;
+  text-transform: capitalize;
+
+  &:hover{
+    color: color.$button-hover;
+    background-color: color.$button-hover-bg;
+    box-shadow: inset 0 0 0.1em color.$button-hover-shadow;
+    border: 0.143em solid color.$button-hover-border;
+  }
+}
+
+
+/*
+ * || HEADER
+ * Styles for the header.
+ */
+
+header{
+  width: 100%;
+  text-align: center;
+  margin-bottom: 1.5em;
+  font-family: font.$mono;
+
+  #logo{
+    color: color.$header;
+    font-size: 5em;
+    margin-bottom: 0.3em;
+  }
+
+  nav{
+    width: 100%;
+
+    a{
+      font-size: 1.5em;
+      margin: 0.5em;
+      color: color.$header;
+      text-decoration: none;
+      position: relative;
+
+      &:hover{
+        &:before{
+          visibility: visible;
+          transform: scaleX(1);
+        }
+      }
+
+      &:before {
+        content: " ";
+        position: absolute;
+        width: 100%;
+        height: 0.1em;
+        bottom: -0.1em;
+        left: 0;
+        background-color: color.$header;
+        visibility: hidden;
+        transform: scaleX(0);
+        transition: all 0.3s ease-in-out 0s;
+      }
+    }
+  }
+}
+
+
+/*
+ * || FOOTER
+ * Styles for the header.
+ */
+
+footer{
+  text-align: center;
+  height: 3.5em;
+  margin-left: auto;
+  margin-right: auto;
+  margin-top: 3em;
+  margin-bottom: 3.5em;
+  color: color.$header;
+  bottom: 0;
+  left: 0;
+  padding: 1em 2em 3em 2em;
+
+  hr{
+    border: 0;
+    background-color: color.$footer-separator;
+    height: 0.1em;
+  }
+
+  #footer_left, #footer_right, #footer_center{
+    display: inline-block;
+    vertical-align: top;
+    width: 33%;
+    text-align: center;
+  }
+
+  #footer_left{
+
+    #footer_follow{
+      display: block;
+      margin: 0 0 1em 0;
+    }
+
+    img{
+      width: 2em;
+      margin: 0.2em;
+      vertical-align: middle;
+      background-color: color.$footer-icon-bg;
+      border: 0.2em solid color.$footer-icon-border;
+      border-radius: 0.3em;
+      transition: all .3s ease-in-out;
+      &:hover{border-radius: 40%;}
+    }
+  }
+
+  #footer_help{
+    display: block;
+    margin: 0 0 2em 0;
+    color: color.$header;
+    &:hover{color: color.$a-hover}
+  }
+
+  a.lang img{
+    height: 1em;
+    margin: 0.5em;
+    border-radius: 0.2em;
+    background-color: color.$footer-icon-bg;
+    border: 0.1em solid color.$footer-icon-border;
+    cursor: pointer;
+    box-shadow: 0 0 0.7em color.$footer-icon-bg;
+    transition: all .3s ease-in-out;
+
+    &:hover{
+      box-shadow: 0 0 1em color.$footer-icon-shadow;
+      border: 0;
+    }
+  }
+}
+
+/*
+ * || MESSAGE FORM
+ * Form to send messages to users.
+ */
+#message_cover{
+  display: none;
+  background-color: color.$cover;
+  opacity: 0;
+  transition: opacity .3s ease-in-out;
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+}
+#message{
+  position: fixed;
+  top: 15%;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 40em;
+  height: 36em;
+  z-index: 100;
+  display: none;
+  text-align: left;
+  h3{text-align: left;}
+  #message_close{
+    float: right;
+    cursor: pointer;
+    img{
+      height: 1.5em;
+      width: 1.5em;
+      &:hover{filter: invert(70%);}
+    }
+  }
+  label{
+    display: inline-block;
+    width: 8em;
+    text-align: left;
+  }
+  label.long{
+    width: revert;
+  }
+  form div{
+    width: 75%;
+    margin: auto;
+    margin: 0.3em auto;
+  }
+  input[type='text']{width: 16em;}
+  textarea{
+    display: block;
+    width: 100%;
+    height: 8em;
+    resize: none;
+  }
+  span.error{
+    visibility: hidden;
+    font-style: italic;
+    padding-left: 11em;
+    font-size: 80%;
+    margin: -0.5em auto 1em auto;
+    color: #cc2200;
+    display: block;
+  }
+}
+
+/*
+ * || PROJECT PREVIEW
+ * Project preview fragment
+ */
+.project_preview{
+  a.logo{
+    display: inline-block;
+    vertical-align: top;
+    img{
+      max-width: 8em;
+      max-height: 8em;
+      margin: 1em 1em auto 1em;
+      padding: 0.5em;
+      background-color: #ffffff;
+      box-shadow: 0 0 0 0.1em #000000, 0 0 0 0.3em #66bf38, 0 0 0 0.4em #000000, inset 0 0 0.5em #00220077;
+      border-radius: 1em;
+    }
+  }
+  div.details{
+    display: inline-block;
+    vertical-align: top;
+    width: calc(100% - 11em);
+    height: 13em;
+    overflow-y: hidden;
+    position: relative;
+    div.details_cover{
+      width: 100%;
+      position: absolute;
+      bottom: 0;
+      height: 4em;
+      background: color.$article-cover;
+    }
+  }
+  div.tags{
+    display: inline-block;
+    vertical-align: middle;
+    text-align: right;
+    width: calc(100% - 11em);
+    margin: 0;
+    img{
+      display: inline-block;
+      vertical-align: middle;
+      width: 2em;
+      height: 2em;
+      border: 0.15em solid color.$icon-border;
+      border-radius: 50%;
+      margin: auto 0.3em;
+    }
+  }
+  div.button{
+    display: inline-block;
+    vertical-align: middle;
+    text-align: right;
+    width: 10em;
+    margin: 0;
+  }
+}
+
+/*
+ * || HOME
+ * Styles for the home page.
+ */
+
+section#home_profile{
+  display: inline-block;
+  vertical-align: top;
+  width: 40%;
+  margin: auto 2em auto auto;
+  #tagline{
+    display: block;
+    vertical-align: middle;
+  }
+  #profile_image{
+    display: inline-block;
+    vertical-align: middle;
+    max-width: calc(50% - 2em);
+    max-height: 12em;
+    border-radius: 50%;
+    box-shadow: 0 0 0 0.1em #000000, 0 0 0 0.3em #66bf38, 0 0 0 0.4em #000000;
+    margin: auto 1.5em;
+  }
+  #profile_social{
+    display: inline-block;
+    vertical-align: middle;
+    max-width: calc(50% - 2em);
+    max-height: 12em;
+    margin-left: 1.5em;
+    img{
+      width: 4em;
+      height: 4em;
+      margin-right: 0.5em;
+      background-color: #ffffff;
+      border: 0.2em solid #ffffff;
+      border-radius: 0.4em;
+      box-shadow: 0 0 0.3em #003300;
+      transition: all .2s ease-in-out;
+      &:hover{
+        border-radius: 50%;
+        box-shadow: 0 0 0.5em #55aa55;
+      }
+    }
+  }
+}
+
+section#home_projects{
+  display: inline-block;
+  vertical-align: top;
+  width: 40%;
+  margin: auto 2em auto auto;
+  #all_projects{
+    float: right;
+    margin-top: 1.5em;
+  }
+}
+
+/*
+ * || PROFILE
+ * Styles for the profile page.
+ */
+
+#profile_details{
+  display: inline-block;
+  vertical-align: top;
+  width: 30%;
+  #image{
+    display: block;
+    max-width: 12em;
+    max-height: 12em;
+    border-radius: 50%;
+    box-shadow: 0 0 0 0.1em #000000, 0 0 0 0.3em #66bf38, 0 0 0 0.4em #000000;
+    margin: auto;
+  }
+  h4{
+    margin-top: 1.5em;
+    text-align: center;
+    font-size: 110%;
+    padding: 0.5em;
+  }
+  #social{
+    text-align: left;
+    margin: auto;
+    a, span.fake_a{
+      display: block;
+      img{
+        width: 60px;
+        height: 60px;
+        margin: 10px;
+        display: inline-block;
+        vertical-align: middle;
+        background-color: #ffffff;
+        border: 0.2em solid #ffffff;
+        border-radius: 0.4em;
+        box-shadow: 0 0 0.3em #003300;
+        transition: all .2s ease-in-out;
+      }
+      span{
+        display: inline-block;
+        vertical-align: middle;
+      }
+      &:hover{
+        img{
+          width: 70px;
+          height: 70px;
+          margin: 5px;
+          border-radius: 50%;
+          box-shadow: 0 0 0.5em #55aa55;
+        }
+      }
+    }
+  }
+}
+
+#profile_bio{
+  text-align: left;
+  display: inline-block;
+  vertical-align: top;
+  width: 50%;
+}
+
+/*
+ * || PROJECT LIST
+ * Styles for the project list page.
+ */
+
+#projects{
+  text-align: center;
+  article{
+    text-align: left;
+    display: inline-block;
+    width: 45%;
+    vertical-align: top;
+  }
+}
+
+/*
+ * || PROJECT VIEW
+ * Styles for the project view page.
+ */
+
+section#project{
+  width: 90%;
+  margin: auto;
+
+
+  #summary{
+    display: inline-block;
+    width: 72%;
+    margin: 0;
+    vertical-align: top;
+    padding: 0;
+    min-height: 13em;
+
+    #project_logo{
+      display: inline-block;
+      max-width: calc(100% - 16em);
+      max-height: 16em;
+      float: left;
+      margin: 2em 2em auto 2em;
+      padding: 0.5em;
+      background-color: #ffffff;
+      box-shadow: 0 0 0 0.1em #000000, 0 0 0 0.3em #66bf38, 0 0 0 0.4em #000000, inset 0 0 0.5em #00220077;
+      border-radius: 1em;
+    }
+
+    #texts{
+      max-width: calc(100% - 20em);
+      h4{
+        margin: 0.5em;
+        font-style: italic;
+      }
+
+      #text{
+        margin: 2em;
+        min-height: 7em;
+      }
+    }
+
+  }
+
+  #details{
+    display: inline-block;
+    width: 22%;
+    vertical-align: top;
+    margin: 0;
+    border-left: 0.1em solid color.$project-details-border;
+    background: color.$project-details-gradient;
+    padding: 1em;
+    text-align: left;
+    color: color.$project-details;
+
+    #urls{
+      text-align: left;
+      border-bottom: 0.1em solid color.$project-details-border;
+      list-style-type: none;
+
+      a{
+        display: block;
+        margin-bottom: 1em;
+        text-align: left;
+
+        img{
+          vertical-align: middle;
+          width: 1.8em;
+          height: 1.8em;
+          margin-right: 0.5em;
+        }
+      }
+    }
+
+    #license{
+      height: 3em;
+      border-bottom: 0.1em solid color.$project-details-border;
+      img{
+        max-width: 8em;
+        max-height: 1.5em;
+        vertical-align: middle;
+        text-align: center;
+        margin-left: 1em;
+      }
+    }
+
+    #tags{
+      height: 4em;
+      img{
+        display: inline-block;
+        vertical-align: middle;
+        width: 3em;
+        height: 3em;
+        border: 0.15em solid color.$icon-border;
+        border-radius: 50%;
+        margin: 0.3em auto;
+        margin-left: 0.5em;
+      }
+    }
+
+
+  }
+
+  #comment{
+    font-style: italic;
+    border: 0.1em solid .333;
+    padding: 1.5em;
+    box-shadow: 0 0 0.5em .111;
+    margin: 1em auto;
+    max-width: 55em;
+    background: #dbffdb;
+    rotate: -1deg;
+    border-radius: 0.2em;
+    line-height: 1.6em;
+    text-align: left;
+
+    #comment_title{
+      font-family: 'Handwritten';
+      display: block;
+      width: 3em;
+      font-size: 180%;
+      float: left;
+      line-height: 1;
+      padding: 0.5em 0 0 0.5em;
+      margin: 0 0.2em 0 -0.5em;
+      rotate: -36deg;
+    }
+  }
+
+  #image_reel{
+    display: block;
+    margin: auto;
+    text-align: center;
+    div.img{
+      display:inline-block;
+      vertical-align: top;
+      max-width: 12em;
+      max-height: 15em;
+      text-align: center;
+      background: #00000020;
+      border: 0.1em solid #aacaaa;
+      border-radius: 0.5em;
+      padding: 0.1em;
+      margin: 0.3em;
+      box-shadow: inset 0 0 0.5em #aacaaa;
+      img{
+        display: block;
+        cursor: pointer;
+        margin: 0.5em auto 0.2em auto;
+        max-width: calc(100% - 0.5em);
+        max-height: 22em;
+        border: 0.1em solid #000000;
+        background: #000000;
+      }
+      h5{
+        display: block;
+        margin: 0 auto 0.3em auto;
+      }
+    }
+  }
+}
+
+section#image_viewer{
+  position: fixed;
+  top: 15%;
+  left: 50%;
+  transform: translateX(-50%);
+  width: 70em;
+  height: 53em;
+  z-index: 100;
+  display: none;
+  text-align: center;
+  h3{text-align: left;}
+  #image_viewer_close{
+    float: right;
+    cursor: pointer;
+    img{
+      height: 1.5em;
+      width: 1.5em;
+      &:hover{filter: invert(70%);}
+    }
+  }
+  #image_viewer_image{
+    display: inline-block;
+    vertical-align: middle;
+    max-width: 60em;
+    max-height: 50em;
+    border: 0.1em solid .000000;
+    background: .000000;
+  }
+  .control{
+    display: inline-block;
+    vertical-align: middle;
+    width: 3em;
+    img{
+      width: 100%;
+      margin: 4.5em auto 4em auto;
+    }
+    &:hover img{filter: invert(70%);}
+  }
+}

+ 3 - 1
frontend/inigovalentin/tsconfig.app.json

@@ -4,7 +4,9 @@
   "extends": "./tsconfig.json",
   "compilerOptions": {
     "outDir": "./out-tsc/app",
-    "types": []
+    "types": [
+      "@angular/localize"
+    ]
   },
   "files": [
     "src/main.ts"

+ 2 - 1
frontend/inigovalentin/tsconfig.spec.json

@@ -5,7 +5,8 @@
   "compilerOptions": {
     "outDir": "./out-tsc/spec",
     "types": [
-      "jasmine"
+      "jasmine",
+      "@angular/localize"
     ]
   },
   "include": [

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott