Compare commits

...

139 commits
v1.0 ... master

Author SHA1 Message Date
Damien Broqua
478b54da53 {FOOTER} Added Love Music, Hate Facism logo 2025-03-08 14:02:34 +01:00
Damien Broqua
c94bfb570a Minor css changes 2025-02-17 15:03:08 +01:00
Damien Broqua
3d3228f12d Updated 'contributeurs' section 2025-02-12 10:19:11 +01:00
Damien Broqua
f5d49d27b4 git migration 2025-01-25 17:48:52 +01:00
Damien Broqua
14f46ae112 Added current year and last year in statistics page 2024-12-29 14:50:51 +01:00
Damien Broqua
48287f2bae {BUGFIX} Espace double quote on artists 2024-10-07 20:23:37 +02:00
Damien Broqua
8289ed7116 Added /data for mongo 2024-10-06 11:43:43 +02:00
Damien Broqua
e3f47a7bf7 Set # for share on fediverse 2024-08-05 17:54:11 +02:00
Damien Broqua
948ccf9419 {BUGFIX} Export 2024-08-02 22:38:21 +02:00
Damien Broqua
47a8935ec5 {BUGFIX} Link to list on details 2024-07-07 09:20:52 +02:00
Damien Broqua
ad8b8f4767 Added feature for delete user account 2024-06-19 11:22:21 +02:00
Damien Broqua
6ee117b583 {NAVBAR} Rewrote 2024-06-19 10:36:41 +02:00
Damien Broqua
4f707ece88 {BUGFIX} For navbar 2024-06-16 10:08:32 +02:00
Damien Broqua
a4a3933c6d @93 - Wantlist 2024-06-15 10:13:22 +02:00
Damien Broqua
bed5139a27 Wording 2024-04-03 18:10:03 +02:00
Damien Broqua
5b2758afca Allow {genres} and {styles} for sharing album on fediverse 2024-03-16 15:42:19 +01:00
Damien Broqua
68414e3e71 Added link to artists for extra info 2024-02-04 15:40:01 +01:00
Damien Broqua
d692090022 Added Escape keydown gesture 2024-02-04 15:37:42 +01:00
Damien Broqua
061e72c459 Updated statistics 2024-02-02 09:38:35 +01:00
Damien Broqua
bf2e9be3b7 Added pagination size 2024-02-01 08:47:33 +01:00
Damien Broqua
d4e6d23459 Updated navbar 2024-02-01 08:25:09 +01:00
Damien Broqua
0ea6a21b90 Updated pagination size 2024-01-31 14:14:19 +01:00
Damien Broqua
6b2f7b61cb Rewrote theme switcher 2024-01-31 14:12:35 +01:00
Damien Broqua
f1220fc05a {DESIGN} Added statistics page 2024-01-31 10:51:00 +01:00
Damien Broqua
8d22435b90 Added statistics page 2024-01-31 10:43:50 +01:00
Damien Broqua
30bd3ebdf9 {BUGFIX} On update my account 2024-01-28 18:14:30 +01:00
Damien Broqua
5a7d9d707f Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2024-01-28 17:27:50 +01:00
Damien Broqua
041e24e26f {BUGFIX} On share my collection 2024-01-28 17:17:07 +01:00
Damien Broqua
71c120564a {BUGFIX} On album update when no day is set for released field 2024-01-19 08:04:21 +01:00
Damien Broqua
1a9728fce6 {BUGFIX} On album update when no day is set for released field 2024-01-18 20:46:40 +01:00
Damien Broqua
2eb22bb3d6 {BUGFIX} On album update when no day is set for released field 2024-01-18 08:28:25 +01:00
Damien Broqua
abcbd0f8f7 {AWS} Migration to v3 2024-01-15 21:28:15 +01:00
Damien Broqua
f73d4a3093 Updated close modal button 2024-01-13 19:05:20 +01:00
Damien Broqua
0a2d5029b5 {BUGFIX} Updated css theme 2024-01-13 18:44:19 +01:00
Damien Broqua
fcb527aa5e Updated css theme 2024-01-13 18:30:45 +01:00
Damien Broqua
c79f1c5a74 {BUGFIX} For modal 2024-01-11 08:11:32 +01:00
Damien Broqua
960f53ab54 {BUGFIX} For image in modal 2024-01-05 12:30:54 +01:00
Damien Broqua
6994170a04 Added on-air feature 2023-12-31 18:02:02 +01:00
Damien Broqua
8e0947ed4b Updated session max age 2023-12-15 08:36:06 +01:00
Damien Broqua
736a0afa44 {WIP} Component for album details 2023-12-15 08:30:41 +01:00
Damien Broqua
209ba0f5f0 Updated navbar size 2023-12-15 08:29:55 +01:00
77de7d54ca Amélioration du rendu en mobile 2023-10-27 21:22:23 +02:00
00bb8647e1 {BUGFIX} Correction d'un bug sur l'ajout d'album 2023-10-11 07:57:55 +02:00
c32b182151 Correction orthographique 2023-10-08 15:04:21 +02:00
85752c537d Import d'une collection depuis Discogs 2023-10-08 15:02:08 +02:00
3b3a4cf779 Possibilité de ne pas partager un album sur le fediverse 2023-10-07 18:52:52 +02:00
1931bd9eda www.darkou.fr => www.darkou.link 2023-09-25 09:28:53 +02:00
7b525d3e43 Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-09-24 14:57:03 +02:00
81c61a0529 Info lors d'un ajout d'album déjà en collection 2023-09-24 14:53:04 +02:00
e01dbd5c31 Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-09-22 21:52:26 +02:00
205474a701 Possibilité de partager un album sur le fédiverse 2023-09-22 21:52:03 +02:00
e28f382c6c {BUGFIX} Suppression d'un album depuis la liste 2023-09-22 08:46:43 +02:00
3626b074bd Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-09-18 14:47:37 +02:00
4ea7b42d52 {BUGFIX} For getting files from discogs 2023-09-18 14:41:01 +02:00
fd0a9df724 {DEBUG} Get images 2023-09-18 14:31:51 +02:00
97b8bab2f4 Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-08-11 23:18:11 +02:00
2f988798df {BUGFIX} On publish toot 2023-08-11 23:13:42 +02:00
15eb2c2dad Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-08-02 18:11:37 +02:00
6862afda5c {BUGFIX} Default values 2023-08-02 16:16:27 +02:00
4109186a47 develop (#91)
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/91
Co-authored-by: dbroqua <contact@darkou.fr>
Co-committed-by: dbroqua <contact@darkou.fr>
2023-08-02 16:11:56 +02:00
ec5e43889f #88 - Améliorer le switch sur le thème 2023-08-02 16:05:08 +02:00
c2ff54ecf2 @issue-86 (#90)
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/90
Co-authored-by: dbroqua <contact@darkou.fr>
Co-committed-by: dbroqua <contact@darkou.fr>
2023-08-02 15:34:41 +02:00
bfdb19eec1 #87 - Utiliser la police Luciole (#89)
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/89
Co-authored-by: dbroqua <contact@darkou.fr>
Co-committed-by: dbroqua <contact@darkou.fr>
2023-07-27 14:52:30 +02:00
1df39410c3 Added compat with Node 18 (#85)
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/85
Co-authored-by: dbroqua <contact@darkou.fr>
Co-committed-by: dbroqua <contact@darkou.fr>
2023-07-22 18:19:21 +02:00
Damien Broqua
e0f227af08 1.4.4 (#84)
Fonctionnalités :
- #82 - Utilisateur artists plutôt que artists_sort

Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/84
2023-03-23 14:34:18 +01:00
13209a9b1d Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-03-23 14:34:05 +01:00
b630e73c79 #82 - Utilisateur artists plutôt que artists_sort 2023-03-23 14:30:40 +01:00
Damien Broqua
fbeb1a67c5 Version 1.4.3 (#83)
Fonctionnalités :

    #80 - Ajout des boutons pages de début et de fin sur la pagination

Correction de bugs :

    #79 - Correction d'un bug empêchant de filtrer sur un artiste contenant un "+" dans son nom

Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/83
2023-03-22 15:01:27 +01:00
c743f0d3a4 Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-03-22 15:01:04 +01:00
68004646f1 #80 - Ajout des boutons début/fin sur la pagination 2023-03-22 14:56:37 +01:00
55a9656c42 #79 - Soucis de paramètres dans les filtres 2023-03-22 14:34:14 +01:00
Damien Broqua
2389d7d731 1.4.2 (#78)
- #68 - Unifier les vues pour la liste des albums
- #77 - Je suis capable de trier ma collection par date d'ajout
- #74 - Lors du changement de page on connait l'ordre de tri
- #73 - Savoir sur quelle page on est
- #76 - Avoir plus de détails sur le support physique sur la modale d'ajout
- #75 - Numérotation de la tracklist

Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/78
2023-03-19 10:41:59 +01:00
4c442edf21 Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2023-03-19 10:41:41 +01:00
50f01805d4 #68 - Unifier les vues pour la liste des albums 2023-03-19 10:37:20 +01:00
663eb586cf #77 - Je suis capable de trier ma collecion par date d'ajout 2023-03-19 10:03:10 +01:00
c1b01ea4c0 #74 - Lors du changement de page on connait l'ordre de tri 2023-01-17 17:21:28 +01:00
fe3ed3e91f #73 - Savoir sur quelle page on est 2023-01-17 17:08:41 +01:00
8822056c1f #76 - Avoir plus de détails sur le support physique sur la modale d'ajout 2023-01-17 16:54:58 +01:00
dff1d2baf0 #75 - Numérotation de la tracklist 2023-01-17 16:37:13 +01:00
d446735450 Utilisation de NPX 2023-01-17 16:25:15 +01:00
Damien Broqua
9fe49eca27 Version 1.4.1 (#70)
- #69 Partager ma collection

Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/70
2022-11-02 09:56:59 +01:00
a7e41949dc Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2022-11-02 09:56:23 +01:00
a56db99a81 #69 - Partager ma collection 2022-11-02 09:48:05 +01:00
Damien Broqua
1d59ee3b71 Version 1.4 (#67)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/67
2022-10-30 21:48:49 +01:00
e01f01337c Lint 2022-10-28 22:56:04 +02:00
980586d8eb Correction mineure sur le refresh d'un album 2022-10-28 22:45:38 +02:00
8f9e902587 #66 - Compiler le JS avant de l'envoyer au client 2022-10-28 22:40:02 +02:00
a74c67e241 #64 - Depuis un album pouvoir voir tous les albums de cet artiste 2022-10-28 22:06:56 +02:00
eac7c1aa84 #65 - Afficher les notes discogs d'un album 2022-10-28 22:01:36 +02:00
748edc9cc4 #63 - Suppression d'un album 2022-10-28 21:55:31 +02:00
d03394bee7 #60 - Bug au refresh d'un album 2022-09-14 14:45:22 +02:00
4da4dd9423 #62 - Échaper les & dans les urls des pages 2022-09-14 14:30:27 +02:00
6454f5f8d6 Correction wording 2022-09-03 17:11:17 +02:00
bc3bb3b554 Merge branch 'develop' of git.darkou.fr:dbroqua/MusicTopus 2022-09-01 12:14:10 +02:00
Damien Broqua
da08aa0222 #41 - Ajouter une sécurité sur la page nous contacter (#59)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/59
2022-09-01 11:37:57 +02:00
Damien Broqua
2da6afa06d #40 - Formulaire de contact via SMTP (#58)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/58
2022-09-01 10:20:13 +02:00
b8b3df2932 #52 - Afficher toute les infos du format d'un album 2022-08-30 15:30:16 +02:00
adea857666 #49 - Mise à jour d'une fiche 2022-08-30 15:17:14 +02:00
Damien Broqua
6320764743 #56 (#57)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/57
2022-08-29 23:22:28 +02:00
Damien Broqua
d473899b20 Update 'README.md' 2022-08-29 14:00:35 +02:00
Damien Broqua
827dcb9ccc #50 - Sur ma collection les filtres génèrent une nouvelle url (#55)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/55
2022-08-29 08:36:43 +02:00
Damien Broqua
1377b4c0c1 #51 - Avoir une animation au chargement de la liste (#54)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/54
2022-08-29 08:13:06 +02:00
Damien Broqua
080471eb37 #47 - Changer la couleur du burger en fonction du thème (#53)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/53
2022-08-29 07:40:14 +02:00
befdfa35a6 Correction d'un wording 2022-08-19 14:21:27 +02:00
cc25b83b2e Merge branch 'develop' 2022-04-16 18:37:30 +02:00
fe3bdafb63 Correction d'un bug sur les filtres de recherches 2022-04-16 17:22:58 +02:00
a3c03a1569 Correction d'un bug sur les dates vides 2022-04-16 17:21:07 +02:00
06752ebcec Merge branch 'develop' 2022-04-10 17:32:23 +02:00
12ca71e643 #39 - Être capable de désactiver les inscriptions + maj footer 2022-04-10 17:27:26 +02:00
9dd7a35f22 #45 - Ajouter de nouveaux filtres sur la liste 2022-04-10 16:42:21 +02:00
f5196edfb8 #38 - Remplacer momentjs 2022-04-10 16:04:50 +02:00
e8f91288fc Correction orthographique 2022-04-10 15:27:51 +02:00
Damien Broqua
48c997ae10 #35 - Je peux accéder à mon compte (#44)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/44
2022-04-10 15:21:15 +02:00
Damien Broqua
6d0405d129 Version 1.1
Correction de bugs :
* Avoir un logo pour les pages d'erreurs #32
* Stocker localement les assets d'un album #37

Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/43
2022-04-09 00:42:24 +02:00
226a9ef1d1 Resolve conflicts 2022-04-09 00:41:48 +02:00
36b33124bc Amélioration de la visibilité des éléments d'une liste 2022-04-09 00:36:37 +02:00
0d90e0da20 issue/1 (#31)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/31
2022-04-09 00:36:37 +02:00
62a3bcd8ee Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/25
Co-authored-by: Damien Broqua <dbroqua@noreply.localhost>
Co-committed-by: Damien Broqua <dbroqua@noreply.localhost>
2022-04-09 00:36:37 +02:00
Damien Broqua
ae4b7b6de0 issue/37 (#42)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/42
2022-04-09 00:07:22 +02:00
182aa7a6a6 Modification du logo d'un erreur #32 2022-04-08 15:16:33 +02:00
7d7ee080ed #32 - Avoir un logo pour les pages d'erreurs 2022-04-08 15:01:09 +02:00
5b9d6c94b8 Correction d'un bug sur Matomo 2022-03-14 16:28:08 +01:00
7578d9b63f Correction des couleurs sur les boutons 2022-03-14 16:27:50 +01:00
9e7743e16d Correction d'un bug sur le thème sombre 2022-03-08 09:56:23 +01:00
e0a8fa42c2 Ajout d'une nouvelle couleur pour les listes 2022-03-08 09:42:20 +01:00
b0e6964205 Ajout du composant toastr succès 2022-03-07 17:00:48 +01:00
b27cbd467b Ajout d'une classe pour le hover des éléments d'une liste 2022-03-07 16:58:02 +01:00
b05bed9a00 Amélioration de la visibilité des éléments d'une liste 2022-03-07 16:35:28 +01:00
3aeb172dbf Ajout d'un espace final (test webhook) 2022-03-07 10:33:35 +01:00
4068df3cba Correction d'un bug sur l'affichage des images verticales 2022-03-07 07:56:00 +01:00
Damien Broqua
9a088b9b5f #29 - Avoir un titre distinct par page (#34)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/34
2022-03-06 15:11:42 +01:00
Damien Broqua
4886e9cc48 #30 - Avoir des statistiques avec Matomo (#33)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/33
2022-03-06 15:00:24 +01:00
73716335e2 Suppression des anciens logos 2022-03-06 14:39:08 +01:00
Damien Broqua
aeb5df067c issue/1 (#31)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/31
2022-03-06 14:38:26 +01:00
ac72c1c13c Ajout du nom du fichier lors de l'export 2022-03-05 19:43:26 +01:00
95ab6f5f42 Merge branch 'master' of git.darkou.fr:dbroqua/MusicTopus into develop 2022-03-04 19:02:04 +01:00
Damien Broqua
0b8b4b21bd Ajout du logo 2022-03-04 08:23:23 +01:00
30d0713f79 Corrections pour l'issue 3 2022-03-04 08:14:29 +01:00
Damien Broqua
1251ca1e02 #3 (#25)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: https://git.darkou.fr/dbroqua/MusicTopus/pulls/25
Co-authored-by: Damien Broqua <dbroqua@noreply.localhost>
Co-committed-by: Damien Broqua <dbroqua@noreply.localhost>
2022-03-03 17:03:18 +01:00
103 changed files with 24378 additions and 2303 deletions

View file

@ -1,36 +1,49 @@
module.exports = {
env: {
browser: true,
es2020: true,
node: true,
jquery: true,
},
extends: ['airbnb-base', 'prettier'],
plugins: ['prettier'],
parserOptions: {
ecmaVersion: 11,
sourceType: 'module',
},
rules: {
'prettier/prettier': ['error'],
'no-underscore-dangle': [
'error',
{
allow: ['_id', 'artists_sort', 'type_'],
},
],
'camelcase': [
'error',
{
allow: ['artists_sort',]
},
],
},
ignorePatterns: ['public/libs/**/*.js', 'public/js/main.js', 'dist/**'],
overrides: [
{
files: ['**/*.js'],
excludedFiles: '*.ejs',
env: {
browser: true,
es2020: true,
node: true,
jquery: true,
},
extends: ["airbnb-base", "prettier"],
plugins: ["prettier"],
parserOptions: {
ecmaVersion: 11,
sourceType: "module",
},
rules: {
"prettier/prettier": ["error"],
"no-underscore-dangle": [
"error",
{
allow: ["_id", "artists_sort", "type_"],
},
],
camelcase: [
"error",
{
allow: [
"artists_sort",
"access_token",
"api_url",
"media_ids",
"release_id",
],
},
],
},
ignorePatterns: ["public/libs/**/*.js", "public/js/main.js", "dist/**"],
overrides: [
{
files: ["**/*.js"],
excludedFiles: "*.ejs",
},
],
globals: {
Vue: true,
axios: true,
showToastr: true,
protocol: true,
host: true,
},
],
};

3
.gitignore vendored
View file

@ -121,6 +121,7 @@ dist
dist
yarn.lock
public/css
public/css
public/js
docker-compose.yml
dump
data

View file

@ -4,7 +4,7 @@
MusicTopus est une application Web (que vous pouvez auto-héberger) et un site Web (sur lequel vous pouvez créer un compte) permettant de gérer votre liste des CDs et Vinyles et de l'utiliser facilement n'importe où.
Le code source est publié sous licence libre [GNU GPL-3.0-or-later](LICENSE) et est disponible sur [git.darkou.fr](https://git.darkou.fr/dbroqua/MusicTopus).
Le code source est publié sous licence libre [GNU GPL-3.0-or-later](LICENSE) et est disponible sur [forge.darkou.fr](https://forge.darkou.fr/contact/MusicTopus).
## Utilisation
@ -18,7 +18,9 @@ Vous pouvez, si vous le souhaitez héberger l'application sur votre propre serve
### Prérequis
Il existe 2 méthodes d'installation, soit via docker soit en mode standalone. Peu importe la méthode il vous faudra un compte sur [https://formspree.io/](https://formspree.io/) afin d'avoir une page nous-contacter fonctionnelle.
Il existe 2 méthodes d'installation, soit via docker soit en mode standalone.
Peu importe la méthode il vous faudra un compte sur [https://formspree.io/](https://formspree.io/) afin d'avoir une page nous-contacter fonctionnelle ou configurer le SMTP tel que défini dans la section [variables d'environnements](#env-file).
Pour la méthode docker il ne vous faudra rien de plus que `docker` et `docker-compose`.
@ -31,7 +33,7 @@ En mode standalone il vous faudra :
Quelque que soit la méthode, la première étape est de cloner le projet :
```bash
git clone https://git.darkou.fr/dbroqua/MusicTopus.git
git clone https://forge.darkou.fr/contact/MusicTopus.git
```
### Installation
@ -62,7 +64,7 @@ Le site est accessible sur [http://localhost:PORT](http://localhost:PORT).
#### Standalone
Pour la version standalone je vous conseille de faire un script embarquant les variables d'environnement que vous souhaitez modifier :
Pour la version standalone je vous conseille de faire un script embarquant les variables d'environnement que vous souhaitez modifier ([voir à la fin pour la liste des variables](#env-file)) :
```bash
#! /bin/bash
@ -90,7 +92,7 @@ C'est terminé !
Le site est accessible sur [http://localhost:3001](http://localhost:3001).
:information_source: Information : Vous pouvez, et vous dreviez, également regarder du côté de `systemd`, `pm2` ou encore `supervisor` pour que le service démarre en même temps que votre serveur.
:information_source: Information : Vous pouvez, et vous devriez, également regarder du côté de `systemd`, `pm2` ou encore `supervisor` pour que le service démarre en même temps que votre serveur.
### Aller plus loin
@ -184,24 +186,59 @@ server {
Une fois le vhost activé (lien symbolique dans le dossier site-enable) et nginx rechargé votre site sera alors accessible en https.
### Jobs
Par défaut toute les images des albums sont affichées depuis Discogs. Cependant avec les temps les urls deviennent invalides. Pour éviter cela lors de l'ajout d'un album à votre collection un job est créé. Ce job a pour rôle de stocker les images sur un bucket s3.
Pour lancer les jobs il faut mettre en place une tâche cron qui sera éxécutée toute les heures (par exemple).
Exemple de crontab :
```crontab
0 * * * * curl 'http://localhost:3001/jobs' \
-H 'JOBS_HEADER_KEY: JOBS_HEADER_VALUE' \
-H 'Accept: application/json'
30 * * * * curl 'http://localhost:3001/jobs?state=ERROR' \
-H 'JOBS_HEADER_KEY: JOBS_HEADER_VALUE' \
-H 'Accept: application/json'
```
N'oubliez pas de remplacer `localhost:30001`, `JOBS_HEADER_KEY` et `JOBS_HEADER_VALUE` par les bonnes valeurs.
La première ligne permet de parcourir tous les nouveaux jobs alors que la seconde permet de relancer les jobs en erreurs (après 5 tentatives le job est marqué comme définitivement perdu).
### Fichier .env {#env-file}
Voici la liste des variables configurables :
```
NODE_ENV # Environnement dans lequel exécuter le projet (development ou production)
PORT # Port sur lequel éxécuter le serveur (par défaut 3001)
MONGODB_URI # Url du serveur mongo (par défaut mongodb://musictopus-db/musictopus)
SECRET # Hash utilisé pour pour sauvegardé les dessions (par défaut waemaeMe5ahc6ce1chaeKohKa6Io8Eik)
PORT # Port sur lequel éxécuter le serveur (3001 par défaut)
MONGODB_URI # Url du serveur mongo (mongodb://musictopus-db/musictopus par défaut)
SECRET # Hash utilisé pour pour sauvegardé les dessions (waemaeMe5ahc6ce1chaeKohKa6Io8Eik par défault)
DISCOGS_TOKEN # Token Discogs (vous devez créer un compte sur discogs afin d'en obtenir un gratuitement)
FORMSPREE_ID # Id du formulaire formspree pour la page "nous-contacter"
MATOMO_URL # Url vers l'instance matomo (exemple: https://analytics.darkou.fr/)
MATOMO_ID # Id du site sur votre instance matomo (exemple: 1)
SITE_NAME # Nom du site (utilisé dans le titre des pages)
SITE_NAME # Nom du site utilisé dans le titre des pages (MusicTopus par défaut)
AWS_ACCESS_KEY_ID # Clé d'accès AWS
AWS_SECRET_ACCESS_KEY # Clé secrète AWS
S3_ENDPOINT # Url de l'instance aws (s3.fr-par.scw.cloud par défaut)
S3_SIGNATURE # Version de la signature AWS (s3v4 par défaut)
S3_BASEFOLDER # Nom du sous dossier dans lequel seront mis les pochettes des albums (dev par défaut)
S3_BUCKET # Nom du bucket (musictopus par défaut, à changer impérativement si vous voulez que cela fonctionne)
JOBS_HEADER_KEY # Nom du header utilisé pour l'identification des tâches cron (musictopus par défaut)
JOBS_HEADER_VALUE # Valeur de la clé (ooYee9xok7eigo2shiePohyoGh1eepew par défaut)
REGISTRATION_OPEN # true/false en fonction de si vous souhaitez activer ou non l'inscription à votre instance (true par défaut)
MAIL_METHOD # permet de définir la façon dont les mails de la page contact sont envoyés (formspree ou smtp)
MAIL_HOST # Adresse du server mail (dams le cas ou MAIL_METHOD est défini sur smtp)
MAIL_PORT # Port d'écoute du serveur smtp (dams le cas ou MAIL_METHOD est défini sur smtp)
MAIL_USER # Adresse mail du compte permettant d'envoyer les mails (dams le cas ou MAIL_METHOD est défini sur smtp)
MAIL_PASSWORD # Mot de passe du compte email (dams le cas ou MAIL_METHOD est défini sur smtp)
MAIL_TO # Adresse mail du contact qui recevra les messages de la page "nous contacter" (dams le cas ou MAIL_METHOD est défini sur smtp)
```
## Contributeurs
- Damien Broqua (développeur principal du projet)
- Brunus (Logo et fournisseur d'idées :wink: )
- [DarKou](https://darkou.link/) (développeur principal du projet)
- [Brunus](https://www.brunuslab.net/) (Logo et fournisseur d'idées 😉 )

View file

@ -1,46 +1,62 @@
version: "2.4"
services:
musictopus-www:
container_name: musictopus-www
image: "node:16"
restart: always
user: "node"
working_dir: /home/node/app
command: >
bash -c "
yarn install &&
yarn watch"
volumes:
- ./:/home/node/app
- /home/node/node_modules
ports:
- 3001:3001
depends_on:
- musictopus-db
environment:
NODE_ENV: ${NODE_ENV}
PORT: ${PORT}
MONGODB_URI: ${MONGODB_URI}
SECRET: ${SECRET}
DISCOGS_TOKEN: ${DISCOGS_TOKEN}
FORMSPREE_ID: ${FORMSPREE_ID}
MATOMO_URL: ${MATOMO_URL}
MATOMO_ID: ${MATOMO_ID}
SITE_NAME: ${SITE_NAME}
networks:
- musictopus
musictopus-db:
container_name: musictopus-db
image: mongo:4.4
restart: always
ports:
- 27617:27017
networks:
- musictopus
volumes:
- ./dump:/dump
musictopus-www:
container_name: musictopus-www
image: "node:18"
restart: always
user: "node"
working_dir: /home/node/app
command: >
bash -c "
yarn install &&
yarn watch"
volumes:
- ./:/home/node/app
- /home/node/node_modules
ports:
- 3001:3001
depends_on:
- musictopus-db
environment:
NODE_ENV: ${NODE_ENV}
PORT: ${PORT}
MONGODB_URI: ${MONGODB_URI}
SECRET: ${SECRET}
DISCOGS_TOKEN: ${DISCOGS_TOKEN}
FORMSPREE_ID: ${FORMSPREE_ID}
MATOMO_URL: ${MATOMO_URL}
MATOMO_ID: ${MATOMO_ID}
SITE_NAME: ${SITE_NAME}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
S3_BASEFOLDER: ${S3_BASEFOLDER}
S3_BUCKET: ${S3_BUCKET}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_SIGNATURE: ${S3_SIGNATURE}
JOBS_HEADER_KEY: ${JOBS_HEADER_KEY}
JOBS_HEADER_VALUE: ${JOBS_HEADER_VALUE}
REGISTRATION_OPEN: ${REGISTRATION_OPEN}
MAIL_METHOD: ${MAIL_METHOD}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT}
MAIL_USER: ${MAIL_USER}
MAIL_PASSWORD: ${MAIL_PASSWORD}
MAIL_TO: ${MAIL_TO}
networks:
- musictopus
musictopus-db:
container_name: musictopus-db
image: mongo:4.4
restart: always
ports:
- 27617:27017
networks:
- musictopus
volumes:
- ./dump:/dump
- ./data:/data/db
networks:
musictopus:
driver: bridge
musictopus:
driver: bridge

View file

@ -3,7 +3,7 @@ version: "2.4"
services:
musictopus-www:
container_name: musictopus-www
image: "node:16"
image: "node:18"
restart: always
user: "node"
working_dir: /home/node/app
@ -28,6 +28,15 @@ services:
MATOMO_URL: ${MATOMO_URL}
MATOMO_ID: ${MATOMO_ID}
SITE_NAME: ${SITE_NAME}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
S3_BASEFOLDER: ${S3_BASEFOLDER}
S3_BUCKET: ${S3_BUCKET}
S3_ENDPOINT: ${S3_ENDPOINT}
S3_SIGNATURE: ${S3_SIGNATURE}
JOBS_HEADER_KEY: ${JOBS_HEADER_KEY}
JOBS_HEADER_VALUE: ${JOBS_HEADER_VALUE}
REGISTRATION_OPEN: ${REGISTRATION_OPEN}
networks:
- musictopus
musictopus-db:

124
fontello.json Normal file
View file

@ -0,0 +1,124 @@
{
"name": "icon",
"css_prefix_text": "icon-",
"css_use_suffix": false,
"hinting": true,
"units_per_em": 1000,
"ascent": 850,
"glyphs": [
{
"uid": "ca90da02d2c6a3183f2458e4dc416285",
"css": "adjust",
"code": 59408,
"src": "fontawesome"
},
{
"uid": "44e04715aecbca7f266a17d5a7863c68",
"css": "plus",
"code": 59392,
"src": "fontawesome"
},
{
"uid": "8b80d36d4ef43889db10bc1f0dc9a862",
"css": "user",
"code": 59393,
"src": "fontawesome"
},
{
"uid": "9dd9e835aebe1060ba7190ad2b2ed951",
"css": "search",
"code": 59394,
"src": "fontawesome"
},
{
"uid": "bf882b30900da12fca090d9796bc3030",
"css": "mail",
"code": 59395,
"src": "fontawesome"
},
{
"uid": "0ddd3e8201ccc7d41f7b7c9d27eca6c1",
"css": "link",
"code": 59396,
"src": "fontawesome"
},
{
"uid": "e15f0d620a7897e2035c18c80142f6d9",
"css": "link-ext",
"code": 61582,
"src": "fontawesome"
},
{
"uid": "9bc2902722abb366a213a052ade360bc",
"css": "spin",
"code": 59449,
"src": "fontelico"
},
{
"uid": "bbfb51903f40597f0b70fd75bc7b5cac",
"css": "trash",
"code": 61944,
"src": "fontawesome"
},
{
"uid": "d73eceadda1f594cec0536087539afbf",
"css": "heart",
"code": 59397,
"src": "fontawesome"
},
{
"uid": "cce5e05853d0798a4d10077ef613387c",
"css": "blind",
"code": 62109,
"src": "fontawesome"
},
{
"uid": "567e3e257f2cc8fba2c12bf691c9f2d8",
"css": "moon",
"code": 61830,
"src": "fontawesome"
},
{
"uid": "aa035df0908c4665c269b7b09a5596f3",
"css": "sun",
"code": 61829,
"src": "fontawesome"
},
{
"uid": "c5fd349cbd3d23e4ade333789c29c729",
"css": "eye",
"code": 59398,
"src": "fontawesome"
},
{
"uid": "d870630ff8f81e6de3958ecaeac532f2",
"css": "left-open",
"code": 59399,
"src": "fontawesome"
},
{
"uid": "399ef63b1e23ab1b761dfbb5591fa4da",
"css": "right-open",
"code": 59400,
"src": "fontawesome"
},
{
"uid": "895405dfac8a3b7b2f23b183c6608ee6",
"css": "export",
"code": 59401,
"src": "fontawesome"
},
{
"uid": "4aad6bb50b02c18508aae9cbe14e784e",
"css": "share",
"code": 61920,
"src": "fontawesome"
},
{
"uid": "a73c5deb486c8d66249811642e5d719a",
"css": "refresh",
"code": 59402,
"src": "fontawesome"
}
]
}

47
gulpfile.js Normal file
View file

@ -0,0 +1,47 @@
const { parallel, src, dest } = require("gulp");
const sourcemaps = require("gulp-sourcemaps");
const concat = require("gulp-concat");
const gulp = require("gulp");
const uglify = require("gulp-uglify");
const babel = require("gulp-babel");
const sourceJs = "javascripts/**/*.js";
const sourceRemoteJS = [
"./node_modules/vue/dist/vue.global.prod.js",
"./node_modules/chart.js/dist/chart.umd.js",
"./node_modules/axios/dist/axios.min.js",
];
const destination = "public/js";
// TASKS ----------------------------------------------------------------------
const compileJs = function () {
return gulp
.src(sourceJs)
.pipe(sourcemaps.init())
.pipe(concat("main.js"))
.pipe(
babel({
presets: ["@babel/env"],
})
)
.pipe(uglify())
.pipe(sourcemaps.write("."))
.pipe(gulp.dest(destination));
};
const compileRemoteJs = function () {
return gulp
.src(sourceRemoteJS)
.pipe(sourcemaps.init())
.pipe(concat("libs.js"))
.pipe(sourcemaps.write("."))
.pipe(gulp.dest(destination));
};
// ----------------------------------------------------------------------------
// COMMANDS -------------------------------------------------------------------
exports.default = parallel(compileJs, compileRemoteJs);
// ----------------------------------------------------------------------------

View file

@ -0,0 +1,211 @@
/* eslint-disable no-undef */
Vue.createApp({
data() {
return {
// eslint-disable-next-line no-undef
share: canPublish,
q: "",
year: "",
country: "",
format: "",
loading: false,
items: [],
details: {},
modalIsVisible: false,
submitting: false,
formats: [
"Vinyl",
"Acetate",
"Flexi-disc",
"Lathe Cut",
"Mighty Tiny",
"Shellac",
"Sopic",
"Pathé Disc",
"Edison Disc",
"Cylinder",
"CD",
"CDr",
"CDV",
"DVD",
"DVDr",
"HD DVD",
"HD DVD-R",
"Blu-ray",
"Blu-ray-R",
"Ultra HD Blu-ray",
"SACD",
"4-Track Cartridge",
"8-Track Cartridge",
"Cassette",
"DC-International",
"Elcaset",
"PlayTape",
"RCA Tape Cartridge",
"DAT",
"DCC",
"Microcassette",
"NT Cassette",
"Pocket Rocker",
"Revere Magnetic Stereo Tape Ca",
"Tefifon",
"Reel-To-Reel",
"Sabamobil",
"Betacam",
"Betacam SP",
"Betamax",
"Cartrivision",
"MiniDV",
"Super VHS",
"U-matic",
"VHS",
"Video 2000",
"Video8",
"Film Reel",
"HitClips",
"Laserdisc",
"SelectaVision",
"VHD",
"Wire Recording",
"Minidisc",
"MVD",
"UMD",
"Floppy Disk",
"File",
"Memory Stick",
"Hybrid",
"All Media",
"Box Set",
],
};
},
created() {
window.addEventListener("keydown", this.keyDown);
},
destroyed() {
window.removeEventListener("keydown", this.keyDown);
},
methods: {
search(event) {
event.preventDefault();
if (this.loading) {
return false;
}
this.loading = true;
let url = `/api/v1/search?q=${this.q}`;
if (this.year) {
url += `&year=${this.year}`;
}
if (this.country) {
url += `&country=${this.country}`;
}
if (this.format) {
url += `&format=${this.format}`;
}
return axios
.get(url)
.then((response) => {
const { results } = response.data;
const items = [];
for (let i = 0; i < results.length; i += 1) {
const {
id,
title,
thumb,
year,
country,
format,
genre,
style,
inCollection,
} = results[i];
items.push({
id,
title,
thumb,
year,
country,
format,
genre,
style,
inCollection,
});
}
this.items = items;
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Aucun résultat trouvé :/"
);
})
.finally(() => {
this.loading = false;
});
},
toggleModal() {
this.modalIsVisible = !this.modalIsVisible;
},
loadDetails(discogsId) {
axios
.get(`/api/v1/search/${discogsId}`)
.then((response) => {
const { data } = response;
this.details = data;
this.toggleModal();
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible de charger les détails de cet album"
);
})
.finally(() => {
this.loading = false;
});
},
add() {
if (this.submitting) {
return true;
}
this.submitting = true;
return axios
.post(`/api/v1/${action}`, {
album: this.details,
share: this.share,
})
.then(() => {
window.location.href =
action === "albums"
? "/ma-collection"
: "/ma-liste-de-souhaits";
})
.catch((err) => {
this.submitting = false;
showToastr(
err.response?.data?.message ||
"Impossible d'ajouter cet album pour le moment…"
);
});
},
orderedItems(items) {
return items.sort();
},
keyDown(event) {
const keycode = event.code;
if (this.modalIsVisible && keycode === "Escape") {
event.preventDefault();
this.modalIsVisible = false;
}
},
},
}).mount("#ajouter-album");

265
javascripts/collection.js Normal file
View file

@ -0,0 +1,265 @@
/* eslint-disable no-undef */
Vue.createApp({
data() {
return {
loading: false,
moreFilters: false,
items: [],
total: 0,
// eslint-disable-next-line no-undef
page: query.page || 1,
limit: 16,
totalPages: 1,
artist: "",
format: "",
year: "",
genre: "",
style: "",
sortOrder: "artists_sort-asc",
sort: "artists_sort",
order: "asc",
itemId: null,
showModalDelete: false,
showModalShare: false,
// eslint-disable-next-line no-undef
shareLink: `/collection/${userId}`,
// eslint-disable-next-line no-undef
isPublicCollection,
// eslint-disable-next-line no-undef
userId,
// eslint-disable-next-line no-undef
vueType,
// eslint-disable-next-line no-undef
query,
};
},
created() {
this.fetch();
window.addEventListener("keydown", this.keyDown);
},
destroyed() {
window.removeEventListener("keydown", this.keyDown);
},
methods: {
formatParams(param) {
return param
.replace("&", "%26")
.replace("+", "%2B")
.replace('"', "%22");
},
fetch() {
this.loading = true;
this.total = 0;
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const entries = urlParams.entries();
const sortOrder = {
sort: "artists_sort",
order: "asc",
};
// eslint-disable-next-line no-restricted-syntax
for (const entry of entries) {
const [key, value] = entry;
switch (key) {
case "artists_sort":
this.artist = value.replaceAll('"', "%22");
break;
default:
if (["order", "sort"].indexOf(key) !== -1) {
sortOrder[key] = value;
}
this[key] = value;
}
}
this.sortOrder = `${sortOrder.sort}-${sortOrder.order}`;
let url = `/api/v1/${action}?page=${this.page}&sort=${this.sort}&order=${this.order}`;
if (this.artist) {
url += `&artist=${this.formatParams(this.artist)}`;
}
if (this.format) {
url += `&format=${this.formatParams(this.format)}`;
}
if (this.year) {
url += `&year=${this.year}`;
}
if (this.genre) {
url += `&genre=${this.formatParams(this.genre)}`;
}
if (this.style) {
url += `&style=${this.formatParams(this.style)}`;
}
// INFO: Cas d'une collection partagée
if (this.vueType === "public" && this.userId) {
url += `&userId=${this.userId}`;
}
axios
.get(url)
.then((response) => {
this.items = response.data.rows;
this.limit = response.data.limit;
this.total = response.data.count || 0;
this.totalPages =
parseInt(response.data.count / this.limit, 10) +
(response.data.count % this.limit > 0 ? 1 : 0);
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible de charger votre collection"
);
})
.finally(() => {
this.loading = false;
});
},
changeUrl() {
let url = `?page=${this.page}&limit=${this.limit}&sort=${this.sort}&order=${this.order}`;
if (this.artist) {
url += `&artists_sort=${this.formatParams(this.artist)}`;
}
if (this.format) {
url += `&format=${this.formatParams(this.format)}`;
}
if (this.year) {
url += `&year=${this.year}`;
}
if (this.genre) {
url += `&genre=${this.formatParams(this.genre)}`;
}
if (this.style) {
url += `&style=${this.formatParams(this.style)}`;
}
window.location.href = url;
},
next(event) {
event.preventDefault();
this.page += 1;
this.changeUrl();
},
previous(event) {
event.preventDefault();
this.page -= 1;
this.changeUrl();
},
goTo(page) {
this.page = page;
this.changeUrl();
},
changeSort() {
const [sort, order] = this.sortOrder.split("-");
this.sort = sort;
this.order = order;
this.page = 1;
this.changeUrl();
},
changeFilter() {
this.page = 1;
this.changeUrl();
},
showMoreFilters() {
this.moreFilters = !this.moreFilters;
},
toggleModal() {
this.showModalDelete = !this.showModalDelete;
},
toggleModalShare() {
this.showModalShare = !this.showModalShare;
},
showConfirmDelete(itemId) {
this.itemId = itemId;
this.toggleModal();
},
deleteItem() {
// eslint-disable-next-line no-undef
if (vueType !== "private") {
return false;
}
return axios
.delete(`/api/v1/${action}/${this.itemId}`)
.then(() => {
this.fetch();
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible de supprimer cet album"
);
})
.finally(() => {
this.toggleModal();
});
},
shareCollection() {
// eslint-disable-next-line no-undef
if (vueType !== "private") {
return false;
}
return axios
.patch(`/api/v1/me`, {
isPublicCollection: !this.isPublicCollection,
})
.then((res) => {
this.isPublicCollection = res.data.isPublicCollection;
if (this.isPublicCollection) {
showToastr(
"Votre collection est désormais publique",
true
);
} else {
showToastr(
"Votre collection n'est plus partagée",
true
);
}
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible de supprimer cet album"
);
})
.finally(() => {
this.toggleModalShare();
});
},
renderAlbumTitle(item) {
let render = "";
for (let i = 0; i < item.artists.length; i += 1) {
const { name, join } = item.artists[i];
render += `${name} ${join ? `${join} ` : ""}`;
}
render += `- ${item.title}`;
return render;
},
keyDown(event) {
const keycode = event.code;
if (this.showModalDelete && keycode === "Escape") {
event.preventDefault();
this.showModalDelete = false;
}
if (this.showModalShare && keycode === "Escape") {
event.preventDefault();
this.showModalShare = false;
}
},
},
}).mount("#collection");

43
javascripts/conctact.js Normal file
View file

@ -0,0 +1,43 @@
// eslint-disable-next-line no-undef
if (typeof contactMethod !== "undefined" && contactMethod === "smtp") {
Vue.createApp({
data() {
return {
email: "",
name: "",
message: "",
captcha: "",
loading: false,
};
},
methods: {
send(event) {
event.preventDefault();
if (this.loading) {
return false;
}
this.loading = true;
const { email, message, name, captcha } = this;
return axios
.post("/api/v1/contact", { email, name, message, captcha })
.then(() => {
showToastr("Message correctement envoyé", true);
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible d'envoyer votre message",
false
);
})
.finally(() => {
this.loading = false;
});
},
},
}).mount("#contact");
}

65
javascripts/main.js Normal file
View file

@ -0,0 +1,65 @@
/* eslint-disable no-unused-vars */
const { protocol, host } = window.location;
let timeout = null;
/**
* Fonction permettant d'afficher un message dans un toastr
* @param {String} message
*/
function showToastr(message, success = false) {
const x = document.getElementById("toastr");
if (message) {
x.getElementsByTagName("SPAN")[0].innerHTML = message;
}
if (timeout) {
clearTimeout(timeout);
x.classList.remove("show");
}
x.classList.remove("success");
x.classList.remove("error");
if (success) {
x.classList.add("success");
} else {
x.classList.add("error");
}
x.classList.add("show");
timeout = setTimeout(() => {
x.classList.remove("show");
}, 3000);
}
/**
* Fonction permettant de masquer le toastr
*/
function hideToastr() {
const x = document.getElementById("toastr");
x.className = x.className.replace("show", "");
x.getElementsByTagName("SPAN")[0].innerHTML = "";
}
/**
* Ensemble d'actions effectuées au chargement de la page
*/
document.addEventListener("DOMContentLoaded", () => {
const $navbarBurgers = Array.prototype.slice.call(
document.querySelectorAll(".navbar-burger"),
0
);
if ($navbarBurgers.length > 0) {
$navbarBurgers.forEach((el) => {
el.addEventListener("click", () => {
const { target } = el.dataset;
const $target = document.getElementById(target);
el.classList.toggle("is-active");
$target.classList.toggle("is-active");
});
});
}
});

View file

@ -0,0 +1,129 @@
if (typeof email !== "undefined" && typeof username !== "undefined") {
Vue.createApp({
data() {
return {
formData: {
// eslint-disable-next-line no-undef
email,
// eslint-disable-next-line no-undef
username,
oldPassword: "",
password: "",
passwordConfirm: "",
// eslint-disable-next-line no-undef
pagination,
// eslint-disable-next-line no-undef
mastodon: mastodon || {
publish: false,
url: "",
token: "",
message:
"Je viens d'ajouter {artist} - {album} à ma collection !",
wantlist:
"Je viens d'ajouter {artist} - {album} à ma liste de souhaits !",
},
delete: false,
},
loading: false,
deleting: false,
errors: [],
};
},
methods: {
// eslint-disable-next-line no-unused-vars
async testMastodon() {
const { url, token } = this.formData.mastodon;
if (!url) {
this.errors.push("emptyUrl");
}
if (!token) {
this.errors.push("emptyToken");
}
if (this.errors.length > 0) {
return false;
}
try {
await axios.post(`/api/v1/mastodon`, { url, token });
showToastr("Configuration valide !", true);
} catch (err) {
showToastr(
err.response?.data?.message ||
"Impossible de tester cette configuration",
false
);
}
return true;
},
// eslint-disable-next-line no-unused-vars
async updateProfil() {
this.errors = [];
const {
oldPassword,
password,
passwordConfirm,
mastodon,
pagination,
} = this.formData;
if (password && !oldPassword) {
this.errors.push("emptyPassword");
}
if (password !== passwordConfirm) {
this.errors.push("passwordsDiffer");
}
if (this.errors.length > 0) {
return false;
}
this.loading = true;
const data = {
mastodon,
};
if (password) {
data.password = password;
data.oldPassword = oldPassword;
}
data.pagination = pagination;
try {
await axios.patch(`/api/v1/me`, data);
showToastr("Profil mis à jour", true);
} catch (err) {
showToastr(
err.response?.data?.message ||
"Impossible de mettre à jour votre profil"
);
}
this.loading = false;
return true;
},
async deleteAccount() {
try {
await axios.delete(`/api/v1/me`);
showToastr("Compte supprimé", true);
window.location.href = "/se-deconnecter";
} catch (err) {
showToastr(
err.response?.data?.message ||
"Impossible de mettre à jour votre profil"
);
}
},
},
}).mount("#mon-compte");
}

View file

@ -0,0 +1,268 @@
/* eslint-disable no-undef */
if (typeof item !== "undefined") {
Vue.createApp({
data() {
return {
// eslint-disable-next-line no-undef
item,
// eslint-disable-next-line no-undef
canShareItem,
tracklist: [],
identifiers: [],
modalIsVisible: false,
identifiersMode: "preview",
identifiersPreviewLength: 16,
preview: null,
index: null,
showModalDelete: false,
showModalShare: false,
shareMessage: "",
shareMessageTransformed: "",
shareMessageLength: 0,
shareSubmiting: false,
};
},
created() {
this.setTrackList();
this.setIdentifiers();
window.addEventListener("keydown", this.keyDown);
},
destroyed() {
window.removeEventListener("keydown", this.keyDown);
},
watch: {
shareMessage(message) {
const video =
this.item.videos && this.item.videos.length > 0
? this.item.videos[0].uri
: "";
this.shareMessageTransformed = message
.replaceAll("{artist}", this.item.artists[0].name)
.replaceAll("{format}", this.item.formats[0].name)
.replaceAll("{genres}", this.item.genres.join(", "))
.replaceAll("{styles}", this.item.styles.join(", "))
.replaceAll("{year}", this.item.year)
.replaceAll("{video}", video)
.replaceAll("{album}", this.item.title);
this.shareMessageLength = this.shareMessageTransformed.replace(
video,
new Array(36).join("#")
).length;
},
},
methods: {
setIdentifiers() {
this.identifiers = [];
const max =
this.identifiersMode === "preview" &&
this.item.identifiers.length > this.identifiersPreviewLength
? this.identifiersPreviewLength
: this.item.identifiers.length;
for (let i = 0; i < max; i += 1) {
this.identifiers.push(this.item.identifiers[i]);
}
},
setTrackList() {
this.tracklist = [];
let subTrack = {
type: null,
title: null,
tracks: [],
};
for (let i = 0; i < this.item.tracklist.length; i += 1) {
const {
type_,
title,
position,
duration,
artists,
extraartists,
} = this.item.tracklist[i];
if (type_ === "heading") {
if (subTrack.type) {
this.tracklist.push(subTrack);
subTrack = {
type: null,
title: null,
tracks: [],
};
}
subTrack.type = type_;
subTrack.title = title;
} else {
subTrack.tracks.push({
title,
position,
duration,
extraartists,
artists,
});
}
}
this.tracklist.push(subTrack);
},
setImage() {
this.preview = this.item.images[this.index].uri;
},
showGallery(event) {
const item =
event.target.tagName === "IMG"
? event.target.parentElement
: event.target;
const { index } = item.dataset;
this.index = Number(index);
this.modalIsVisible = true;
this.setImage();
},
toggleModal() {
this.modalIsVisible = !this.modalIsVisible;
},
previous() {
this.index =
this.index > 0
? this.index - 1
: this.item.images.length - 1;
this.setImage();
},
next() {
this.index =
this.index + 1 === this.item.images.length
? 0
: this.index + 1;
this.setImage();
},
changeImage(event) {
event.preventDefault();
const direction = event.code;
if (
["ArrowRight", "ArrowLeft", "Escape"].indexOf(direction) !==
-1
) {
switch (direction) {
case "ArrowRight":
return this.next();
case "ArrowLeft":
return this.previous();
default:
this.modalIsVisible = false;
return true;
}
}
return true;
},
keyDown(event) {
const keycode = event.code;
if (this.modalIsVisible) {
this.changeImage(event);
}
if (this.showModalDelete && keycode === "Escape") {
event.preventDefault();
this.showModalDelete = false;
}
if (this.showModalShare && keycode === "Escape") {
event.preventDefault();
this.showModalShare = false;
}
},
showAllIdentifiers() {
this.identifiersMode = "all";
this.setIdentifiers();
},
showLessIdentifiers() {
this.identifiersMode = "preview";
this.setIdentifiers();
document
.querySelector("#identifiers")
.scrollIntoView({ behavior: "smooth" });
},
showConfirmDelete() {
this.toggleModalDelete();
},
toggleModalDelete() {
this.showModalDelete = !this.showModalDelete;
},
updateItem() {
showToastr("Mise à jour en cours…", true);
axios
.patch(`/api/v1/${action}/${this.item._id}`)
.then((res) => {
showToastr("Mise à jour réalisée avec succès", true);
this.item = res.data;
this.setTrackList();
this.setIdentifiers();
this.showLessIdentifiers();
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible de mettre à jour cet album",
false
);
});
},
deleteItem() {
axios
.delete(`/api/v1/${action}/${this.item._id}`)
.then(() => {
window.location.href =
action === "albums"
? "/ma-collection"
: "/ma-liste-de-souhaits";
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible de supprimer cet album"
);
})
.finally(() => {
this.toggleModalDelete();
});
},
goToArtist() {
return "";
},
shareAlbum() {
if (this.shareSubmiting) {
return false;
}
this.shareSubmiting = true;
axios
.post(`/api/v1/${action}/${this.item._id}/share`, {
message: this.shareMessageTransformed,
})
.then(() => {
showToastr("Album partagé", true);
this.shareMessage = "";
this.showModalShare = false;
})
.catch((err) => {
showToastr(
err.response?.data?.message ||
"Impossible de partager cet album",
false
);
})
.finally(() => {
this.shareSubmiting = false;
});
return true;
},
},
}).mount("#ma-collection-details");
}

View file

@ -0,0 +1,20 @@
/* eslint-disable no-undef */
Vue.createApp({
data() {
return {
format: "xml",
};
},
created() {},
destroyed() {},
methods: {
exportCollection(event) {
event.preventDefault();
window.open(
`/api/v1/${action}?exportFormat=${this.format}`,
"_blank"
);
},
},
}).mount("#exporter");

View file

@ -0,0 +1,107 @@
/* eslint-disable no-undef */
Vue.createApp({
data() {
return {
file: "",
content: [],
parsed: false,
imported: 0,
disabled: true,
state: "default",
};
},
created() {},
destroyed() {},
methods: {
handleFileUpload(event) {
const { files } = event.target;
const [csv] = files;
this.file = csv;
this.file = csv;
// this.parseFile();
const reader = new FileReader();
reader.onload = (content) => {
this.content = [];
this.state = "parse";
const lines = content.target.result.split(/\r\n|\n/);
for (let line = 1; line < lines.length - 1; line += 1) {
this.parseLine(lines[0], lines[line]);
}
this.state = "default";
this.disabled = false;
};
reader.readAsText(csv);
},
parseLine(header, line) {
const row = {};
let currentHeaderIndex = 0;
let separant = ",";
let value = "";
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
if (char !== separant) {
if (char === '"') {
separant = '"';
} else {
value += char;
}
} else if (char === '"') {
separant = ",";
} else {
row[header.split(",")[currentHeaderIndex]] = value;
currentHeaderIndex += 1;
value = "";
}
}
this.content.push(row);
},
async addOne(index) {
const { Artist, Title, release_id } = this.content[index];
try {
const res = await axios.get(
`/api/v1/${action}?discogsId=${release_id}`
);
if (res.status === 204) {
await axios.post(`/api/v1/${action}`, {
discogsId: release_id,
share: false,
});
}
this.imported += 1;
if (this.content.length > index + 1) {
await this.addOne(index + 1);
}
} catch (err) {
showToastr(
`Impossible d'ajouter l'album ${Title} de ${Artist}`
);
return false;
}
return true;
},
async importCollection(event) {
event.preventDefault();
this.disabled = true;
this.state = "submit";
this.imported = 0;
const imported = await this.addOne(0);
this.disabled = false;
this.state = imported ? "done" : "default";
},
},
}).mount("#importer");

87
javascripts/theme.js Normal file
View file

@ -0,0 +1,87 @@
/**
* Fonction permettant de récupérer le thème du système
* @return {String}
*/
function getPreferredColorScheme() {
if (window.matchMedia) {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
return "light";
}
/**
* @param {String} scheme
*/
function setPictoOnMenu(scheme) {
document.querySelectorAll(".icon-theme").forEach((item) => {
item.classList.add("hidden");
});
document
.querySelector(`.icon-theme.theme-${scheme}`)
.classList.remove("hidden");
}
/**
* Fonction permettant de sauvegarder dans le stockage local le choix du thème
* @param {String} scheme
*/
function saveColorScheme(scheme) {
localStorage.setItem("theme", scheme);
}
/**
* Fonction permettant de changer le thème du site
* @param {String} scheme
*/
function setColorScheme(scheme) {
document.documentElement.setAttribute(
"data-theme",
scheme === "system" ? getPreferredColorScheme() : scheme
);
setPictoOnMenu(scheme);
}
/**
* Fonction déclenchée lorsqu'un utilisateur clique sur un bouton dans le menu déroulant
* @param {Object} e
*/
function changeTheme(e) {
e.preventDefault();
const scheme = this.dataset.value;
saveColorScheme(scheme);
setColorScheme(scheme);
}
// INFO: On place un event sur le bouton
const buttonsTheme = document.getElementsByClassName("theme");
// INFO: On récupère du local storage (ou des préférences navigateur) le thème actuel
const currentTheme = localStorage.getItem("theme") || getPreferredColorScheme();
/**
* Event permettant de détecter les changements de thème du système
*/
if (window.matchMedia) {
const colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
colorSchemeQuery.addEventListener("change", () => {
const selectedColorScheme = localStorage.getItem("theme") || "system";
if (selectedColorScheme === "system") {
const preferedColorScheme = getPreferredColorScheme();
setColorScheme(preferedColorScheme);
}
});
}
// INFO: Au chargement de la page on détecte le thème à charger
setColorScheme(currentTheme);
// INFO: On place un event au click sur chacun des boutons du menu
for (let i = 0; i < buttonsTheme.length; i += 1) {
buttonsTheme[i].addEventListener("click", changeTheme, false);
}

18192
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,26 +4,27 @@
"description": "Simple application to manage your CD/Vinyl collection",
"scripts": {
"start": "node ./dist/bin/www",
"run:all": "npm-run-all build sass start",
"watch": "nodemon -e js,scss",
"run:all": "npm-run-all build sass uglify start",
"watch": "npx nodemon -e js,scss",
"sass": "npx sass sass/index.scss public/css/main.css -s compressed --color",
"uglify": "npx gulp",
"prebuild": "rimraf dist",
"build": "babel ./src --out-dir dist --copy-files",
"build": "npx babel ./src --out-dir dist --copy-files",
"test": "jest",
"prepare": "husky install"
"prepare": "npx husky install"
},
"engines": {
"node": "16.x",
"node": "16.x || 18.x",
"yarn": "1.x"
},
"repository": {
"type": "git",
"url": "git@git.darkou.fr:dbroqua/MusicTopus.git"
"url": "git@forge.darkou.fr:contact/MusicTopus.git"
},
"author": {
"name": "Damien Broqua",
"email": "contact@darkou.fr",
"url": "https://www.darkou.fr"
"url": "https://www.darkou.link"
},
"license": "GPL-3.0-or-later",
"devDependencies": {
@ -38,39 +39,53 @@
"prettier": "^2.5.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.490.0",
"@aws-sdk/lib-storage": "^3.490.0",
"@babel/cli": "^7.17.0",
"@babel/core": "^7.17.2",
"@babel/preset-env": "^7.16.11",
"axios": "^0.26.0",
"chart.js": "^4.4.1",
"connect-ensure-login": "^0.1.1",
"connect-flash": "^0.1.1",
"connect-mongo": "^4.6.0",
"cookie-parser": "^1.4.6",
"date-fns": "^2.28.0",
"date-fns-tz": "^1.3.3",
"debug": "^4.3.3",
"disconnect": "^1.2.2",
"ejs": "^3.1.6",
"excel4node": "^1.7.2",
"express": "^4.17.2",
"express-session": "^1.17.2",
"gulp": "^4.0.2",
"gulp-babel": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-sourcemaps": "^3.0.0",
"gulp-uglify": "^3.0.2",
"joi": "^17.6.0",
"knacss": "^8.0.4",
"moment": "^2.29.1",
"moment-timezone": "^0.5.34",
"mastodon": "^1.2.2",
"mongoose": "^6.2.1",
"mongoose-unique-validator": "^3.0.0",
"nodemailer": "^6.7.8",
"npm-run-all": "^4.1.5",
"passport": "^0.5.2",
"passport-custom": "^1.1.1",
"passport-http": "^0.3.0",
"passport-local": "^1.0.0",
"rimraf": "^3.0.2",
"sass": "^1.49.7",
"svg-captcha": "^1.4.0",
"uuid": "^8.3.2",
"vue": "^3.2.31"
},
"nodemonConfig": {
"exec": "yarn run:all",
"exec": "npm run run:all",
"watch": [
"src/*",
"sass/*"
"sass/*",
"javascripts/*"
],
"ignore": [
"**/__tests__/**",

View file

@ -17,7 +17,7 @@
<link href="/css/main.css" rel="stylesheet" />
<script src="/js/main.js"></script>
</head>
<body class="body-500">
<body class="body-500 flex flex-col align-items-center justify-center">
<img src="/img/404.svg" alt="Image représentant la mascotte tenant un vinyle cassé" />
Nous sommes désolé mais quelque chose a mal tourné de notre côté.
<br />

Binary file not shown.

View file

@ -1,7 +1,7 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2022 by original authors @ fontello.com</metadata>
<metadata>Copyright (C) 2024 by original authors @ fontello.com</metadata>
<defs>
<font id="icon" horiz-adv-x="1000" >
<font-face font-family="icon" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
@ -26,6 +26,10 @@
<glyph glyph-name="export" unicode="&#xe809;" d="M786 298v-144q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h142q7 0 13-6t5-12q0-15-15-18-43-15-74-34-5-2-9-2h-62q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v119q0 11 10 16 16 7 31 21 8 9 19 4 12-5 12-16z m132 277l-214-214q-10-11-25-11-7 0-14 3-22 9-22 33v107h-89q-181 0-245-73-66-77-41-264 2-13-11-19-5-1-7-1-9 0-14 7-6 8-12 17t-22 39-28 55-21 64-10 68q0 27 2 51t8 50 15 49 27 45 38 42 52 34 70 27 89 17 110 6h89v107q0 24 22 33 7 3 14 3 14 0 25-11l214-214q11-10 11-25t-11-25z" horiz-adv-x="928.6" />
<glyph glyph-name="refresh" unicode="&#xe80a;" d="M843 261q0-3 0-4-36-150-150-243t-267-93q-81 0-157 31t-136 88l-72-72q-11-11-25-11t-25 11-11 25v250q0 14 11 25t25 11h250q14 0 25-11t10-25-10-25l-77-77q40-36 90-57t105-20q74 0 139 37t104 99q6 10 30 66 4 13 16 13h107q8 0 13-6t5-12z m14 446v-250q0-14-10-25t-26-11h-250q-14 0-25 11t-10 25 10 25l77 77q-82 77-194 77-75 0-140-37t-104-99q-6-10-29-66-5-13-17-13h-111q-7 0-13 6t-5 12v4q36 150 151 243t268 93q81 0 158-31t137-88l72 72q11 11 25 11t26-11 10-25z" horiz-adv-x="857.1" />
<glyph glyph-name="adjust" unicode="&#xe810;" d="M429 46v608q-83 0-153-41t-110-111-41-152 41-152 110-111 153-41z m428 304q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="spin" unicode="&#xe839;" d="M855 9c-189-190-520-172-705 13-190 190-200 494-28 695 11 13 21 26 35 34 36 23 85 18 117-13 30-31 35-76 16-112-5-9-9-15-16-22-140-151-145-379-8-516 153-153 407-121 542 34 106 122 142 297 77 451-83 198-305 291-510 222l0 1c236 82 492-24 588-252 71-167 37-355-72-493-11-15-23-29-36-42z" horiz-adv-x="1000" />
<glyph glyph-name="link-ext" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" />

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -6,50 +6,50 @@
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 168.85766 133.4734"
version="1.1"
id="MusicTopus"
width="100%"
height="100%"
width="100%">
id="MusicTopus"
version="1.1"
viewBox="0 0 168.85766 133.4734">
<defs
id="defs2">
<linearGradient
id="linearGradient3016">
<stop
offset="0"
id="stop3018-4"
stop-color="#949494"
id="stop3018-4" />
offset="0" />
<stop
offset="1"
stop-opacity="0"
id="stop3020-0"
stop-color="#949494"
id="stop3020-0" />
stop-opacity="0"
offset="1" />
</linearGradient>
<linearGradient
x1="57.074001"
y1="27.309999"
gradientTransform="translate(-19.041285,-22.505715)"
x2="103.29"
gradientUnits="userSpaceOnUse"
xlink:href="#linearGradient3016"
id="linearGradient1417"
y2="104.59"
id="linearGradient1417" />
<radialGradient
r="7.395"
gradientTransform="matrix(2.2777,1.8145,-1.5547,2.3139,262.42,-105.22857)"
cx="16.073999"
cy="98.385002"
xlink:href="#linearGradient3016"
gradientUnits="userSpaceOnUse"
id="radialGradient10835">
x2="103.29"
gradientTransform="translate(-19.041285,-22.505715)"
y1="27.309999"
x1="57.074001" />
<radialGradient
id="radialGradient10835"
gradientUnits="userSpaceOnUse"
cy="98.385002"
cx="16.073999"
gradientTransform="matrix(2.2777,1.8145,-1.5547,2.3139,262.42,-105.22857)"
r="7.395">
<stop
offset="0"
id="stop10767-64"
stop-color="#989898"
id="stop10767-64" />
offset="0" />
<stop
offset="1"
id="stop10769-6"
stop-color="#989898"
stop-opacity="0"
stop-color="#989898"
id="stop10769-6" />
offset="1" />
</radialGradient>
</defs>
<metadata
@ -65,279 +65,276 @@
</rdf:RDF>
</metadata>
<g
id="layer1"
transform="translate(-4.0461145,-24.740973)">
transform="translate(-4.0461145,-24.740973)"
id="layer1">
<g
transform="matrix(-1,0,0,1,16.909353,13.841748)"
id="g4845">
id="g4845"
transform="matrix(-1,0,0,1,16.909353,13.841748)">
<path
style="fill:#301818;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -124.03737,64.054339 c -0.67869,5.653701 -1.79458,14.69263 1.57781,21.418531 0.16987,0.339747 -10.7216,2.427294 -11.91019,-4.293016 -1.11922,-6.328076 -2.52943,-10.954215 -1.33635,-13.964832 3.07095,-7.749247 11.86215,-3.651666 11.66873,-3.160683 z"
id="path4839" />
<path
style="fill:#7b2121;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -125.27588,63.26029 c 0.41675,0.233807 1.34252,0.316004 1.74941,0.697875 -0.40534,4.680582 -0.98591,7.419536 -0.0655,11.187673 1.56849,6.421216 1.99919,7.121733 2.51886,9.876016 -0.60349,0.201385 -1.88006,0.796452 -2.50566,0.776752 -0.78768,-7.838385 -2.58856,-15.620579 -1.69713,-22.538316 z"
id="path4841" />
<path
style="fill:#f3e8d4;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -135.88244,67.615699 c 0,0 3.35454,1.181174 3.82701,2.787573 0.47247,1.606399 0.70871,6.000372 0.37798,8.031993 -0.33073,2.031624 -2.74033,2.882069 -2.74033,2.882069 -0.81232,-5.382945 -2.2983,-10.046051 -1.46466,-13.701635 z"
id="path4843" />
</g>
<g
id="g4837"
transform="translate(195.33147,13.841748)">
<path
id="path4828"
id="path4839"
d="m -124.03737,64.054339 c -0.67869,5.653701 -1.79458,14.69263 1.57781,21.418531 0.16987,0.339747 -10.7216,2.427294 -11.91019,-4.293016 -1.11922,-6.328076 -2.52943,-10.954215 -1.33635,-13.964832 3.07095,-7.749247 11.86215,-3.651666 11.66873,-3.160683 z"
style="fill:#301818;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
id="path4830"
d="m -125.27588,63.26029 c 0.41675,0.233807 0.84139,0.349413 1.24828,0.731284 -0.40534,4.680582 -0.23721,7.396829 0.16838,11.25449 0.56623,5.385547 0.8967,7.522637 1.41637,10.27692 -0.60349,0.201385 -1.17847,0.395548 -1.80407,0.375848 -0.90324,-7.658049 -3.18063,-15.595778 -1.02896,-22.638542 z"
id="path4841"
d="m -125.27588,63.26029 c 0.41675,0.233807 1.34252,0.316004 1.74941,0.697875 -0.40534,4.680582 -0.98591,7.419536 -0.0655,11.187673 1.56849,6.421216 1.99919,7.121733 2.51886,9.876016 -0.60349,0.201385 -1.88006,0.796452 -2.50566,0.776752 -0.78768,-7.838385 -2.58856,-15.620579 -1.69713,-22.538316 z"
style="fill:#7b2121;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
id="path4832"
id="path4843"
d="m -135.88244,67.615699 c 0,0 3.35454,1.181174 3.82701,2.787573 0.47247,1.606399 0.70871,6.000372 0.37798,8.031993 -0.33073,2.031624 -2.74033,2.882069 -2.74033,2.882069 -0.81232,-5.382945 -2.2983,-10.046051 -1.46466,-13.701635 z"
style="fill:#f3e8d4;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
</g>
<g
transform="translate(195.33147,13.841748)"
id="g4837">
<path
style="fill:#301818;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -124.03737,64.054339 c -0.67869,5.653701 -1.79458,14.69263 1.57781,21.418531 0.16987,0.339747 -10.7216,2.427294 -11.91019,-4.293016 -1.11922,-6.328076 -2.52943,-10.954215 -1.33635,-13.964832 3.07095,-7.749247 11.86215,-3.651666 11.66873,-3.160683 z"
id="path4828" />
<path
style="fill:#7b2121;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -125.27588,63.26029 c 0.41675,0.233807 0.84139,0.349413 1.24828,0.731284 -0.40534,4.680582 -0.23721,7.396829 0.16838,11.25449 0.56623,5.385547 0.8967,7.522637 1.41637,10.27692 -0.60349,0.201385 -1.17847,0.395548 -1.80407,0.375848 -0.90324,-7.658049 -3.18063,-15.595778 -1.02896,-22.638542 z"
id="path4830" />
<path
style="fill:#f3e8d4;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m -135.88244,67.615699 c 0,0 3.35454,1.181174 3.82701,2.787573 0.47247,1.606399 0.70871,6.000372 0.37798,8.031993 -0.33073,2.031624 -2.74033,2.882069 -2.74033,2.882069 -0.81232,-5.382945 -2.2983,-10.046051 -1.46466,-13.701635 z"
id="path4832" />
</g>
<path
style="fill:#ec8479;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4668"
d="m 104.6994,52.638393 c -15.180178,-0.07451 -33.140948,8.951637 -33.639877,27.78125 -0.50786,19.166666 9.223449,34.250207 -6.425596,34.301337 -10.643849,0.0348 -6.80357,-16.158483 -6.80357,-16.158483 0.443803,-1.702342 -1.552627,-2.254759 -2.693082,-0.897692 -11.388255,13.593915 3.527003,26.318385 11.722934,37.071045 11.135404,14.60911 24.729611,20.33094 37.083241,-6.31323 16.89051,50.78843 72.75864,-14.34852 42.71131,-27.44716 -0.79429,-0.21911 -2.98222,-0.45948 -1.5119,1.74478 0,0 7.46421,11.18075 -3.02381,13.22916 -7.74851,0.37797 -6.46564,-8.79054 -1.88989,-25.702378 5.34881,-19.769046 -13.75388,-37.501741 -35.52976,-37.608629 z"
id="path4668" />
style="fill:#ec8479;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#fbb9b8;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4723"
d="m 149.1438,102.30834 c 0,0 -0.30808,0.3199 -0.0742,0.7208 0.23386,0.4009 2.13815,1.20271 1.90429,2.03793 -0.23386,0.83522 -0.23386,1.46998 0.46773,2.03793 0.70158,0.56795 3.00678,1.43657 3.07359,2.23838 0.65095,0.94785 0.11036,4.73821 -1.10248,5.34539 -0.30068,0.83522 -0.26727,1.5368 -0.26727,1.5368 l 2.00452,4.47677 c 2.67736,-5.97229 4.5819,-11.72353 -6.00616,-18.394 z"
id="path4723" />
style="fill:#fbb9b8;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4689"
d="m 54.790252,98.184879 c 0,0 -8.485807,6.113791 -6.147199,15.134141 2.338609,9.02034 7.015826,10.95805 7.015826,10.95805 -0.248403,-2.16366 -0.08184,-4.23511 0.801809,-6.1472 0,0 -0.200453,-2.07134 -1.336349,-3.00678 -1.135896,-0.93545 -3.073599,-1.67044 -2.672694,-3.07361 0.400905,-1.40316 1.403165,-3.07359 0.935442,-4.14267 -0.467722,-1.06908 -1.069078,-1.73725 -0.801809,-2.87315 0.267272,-1.1359 2.204974,-6.848781 2.204974,-6.848781 z"
id="path4689" />
<ellipse
ry="20.250488"
rx="22.683598"
cy="99.451027"
cx="39.641644"
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
id="circle4790"
d="m 62.325241,99.451027 c 0,11.184033 -10.15579,20.250493 -22.683597,20.250493 -7.402258,0 -0.829184,-8.01428 -4.969517,-12.91144 -0.612097,-0.72399 -1.770309,-5.66385 -3.213537,-6.11122 -5.316459,-1.647971 -14.500545,2.37098 -14.500544,-1.227833 3e-6,-11.184036 6.023828,-20.350467 18.551633,-20.350467 0.220732,3.973948 2.052425,13.960818 7.154907,14.623465 7.156786,-4.199604 19.410793,-0.618035 19.660655,5.727002 z"
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:8.20355606;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
<path
id="path4"
transform="translate(4.0461145,24.740973)"
style="fill:#212121;fill-opacity:1;stroke-width:0.205;stroke:none;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="m 34.146484,51.925781 c -0.314077,0.0092 -0.628478,0.01217 -0.941406,0.02734 -11.059699,0.788242 -20.839327,8.72416 -22.753906,18.482422 -1.0222988,4.666022 -0.263288,9.571297 1.953125,13.871094 l 2.441406,-2.441407 4.525391,2.404297 2.121094,-3.251953 5.230468,1.13086 0.707032,-4.09961 1.414062,4.382813 0.566406,2.402343 -5.65625,-0.564453 -1.414062,4.351719 -4.013672,2.913906 c 0.239784,0.192385 0.473561,0.390466 0.722656,0.574219 7.713021,6.01634 19.591819,7.137217 28.550781,2.716797 C 57.055648,90.480286 62.646095,80.45375 60.816406,71.099609 59.52397,63.325904 53.292903,56.50211 45.197266,53.552734 c 1.309899,2.835554 1.897379,5.928925 2.693359,8.935547 l -10.398437,2 0.90039,3.699219 -3.90039,-4 6.199218,-3.498047 -8.498047,-4.099609 3.998047,-1.900391 z m -9.673828,6.271485 c 0.23821,0.241845 0.400025,0.530577 0.587891,0.802734 -0.185522,2.23358 -3.870804,4.435587 -3.755859,4.089844 -1.319654,1.186159 -2.291211,3.893629 -4.216797,1.74414 1.76144,-2.731738 4.374999,-5.003791 7.384765,-6.636718 z m 2.291016,3.501953 c 0.217609,0.03588 0.400611,0.22504 0.52539,0.664062 1.260066,1.740193 -2.311306,4.335474 -3.519531,4.785157 -0.880279,0.143799 -1.50846,0.89274 -2.666015,-0.138672 0.261264,-1.586273 5.126947,-5.38799 3.720703,-4.21875 0.526069,-0.238877 1.182742,-0.95689 1.710937,-1.080078 0.07923,-0.01848 0.15598,-0.02368 0.228516,-0.01172 z m 2.261719,3.511719 c 0.318835,0.04398 0.591476,0.29958 0.78125,0.880859 0.691211,2.140581 -1.931888,4.415457 -2.626953,4.083984 -1.770649,-0.510364 -3.306383,-1.512291 -1.222657,-2.802734 0.746208,-0.529496 2.111854,-2.294057 3.06836,-2.162109 z m 6.597656,6.009765 c 0.500623,0.0012 1.007104,0.09327 1.488281,0.292969 3.025279,0.986468 3.11205,5.198119 0.179688,6.324219 -2.297247,1.06592 -5.580236,-0.439849 -5.564454,-2.841797 -0.353584,-2.07507 1.72712,-3.780425 3.896485,-3.775391 z m 7.677734,8.976563 c 0.454001,-0.07081 0.897599,0.528293 1.34375,0.736328 1.285766,0.439342 -0.203248,1.889914 -0.646484,2.283203 -1.277789,1.157639 -3.468693,-2.140236 -1.15625,-2.685547 0.154247,-0.211427 0.307651,-0.310382 0.458984,-0.333984 z m 4.345703,2.757812 c 0.225859,0.03343 0.481027,0.198816 0.785157,0.564453 1.747014,0.72605 -1.303778,2.574316 -1.595703,2.697266 -1.103201,1.48933 -3.167305,-1.432728 -1.03125,-1.835938 0.74365,-0.437265 1.164221,-1.526065 1.841796,-1.425781 z m 3.269532,2.306641 c 0.03893,0.0024 0.06028,0.01926 0.0625,0.05078 0.307212,0.15856 0.582857,0.359282 0.86914,0.544922 1.634832,1.36857 -2.961621,4.630292 -3.164062,4.070312 -2.704111,-1.615931 1.64845,-4.702094 2.232422,-4.666015 z"
style="fill:#212121;fill-opacity:1;stroke-width:0.00521397" />
transform="translate(4.0461145,24.740973)"
id="path4" />
<ellipse
ry="5.6584005"
rx="6.7384396"
style="opacity:1;fill:#ffaf62;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.41468528;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
cy="99.451027"
cx="39.641644"
id="path4786"
cx="39.641644"
cy="99.451027" />
style="opacity:1;fill:#ffaf62;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.41468528;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
rx="6.7384396"
ry="5.6584005" />
<ellipse
ry="0.96285915"
rx="1.1466434"
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.41468525;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="path4788"
cy="99.451027"
cx="39.641644"
cy="99.451027" />
id="path4788"
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.41468525;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
rx="1.1466434"
ry="0.96285915" />
<path
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4693"
d="m 65.146946,109.76994 c -0.178541,-0.31245 -7.366014,3.88706 -8.686258,8.35993 -2.169132,7.34882 1.269529,10.75759 2.271789,11.89349 1.002261,1.1359 8.753078,5.41221 8.753078,5.41221 0,0 -2.53906,-8.01809 -2.472242,-12.22758 0.06682,-4.2095 1.336347,-12.2944 1.336347,-12.2944 0.214328,-1.45042 -0.351567,-1.51081 -1.202714,-1.14365 z"
id="path4693" />
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4695"
d="m 62.177083,111.27195 c 0,0 1.370163,-0.28348 1.700892,1.13393 0.330729,1.41741 -1.748139,2.92931 -1.559153,4.86644 0.18899,1.93713 0.944941,3.68527 0.803201,5.00818 -0.141742,1.32292 -1.228423,2.4096 -0.897694,4.15774 0.330729,1.74814 1.370163,3.73252 1.748139,4.96094 0.377979,1.22842 0.472472,2.22061 0.472472,2.22061 l -5.575149,-3.35454 c 0,0 -4.771948,-4.77195 -2.787573,-10.91406 1.984375,-6.14211 6.094865,-8.07924 6.094865,-8.07924 z"
id="path4695" />
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#feb6b9;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4697"
d="m 70.30357,122.75298 c -1.514956,0.94925 -3.443343,1.9309 -5.197172,1.98437 l 0.07087,-2.10249 c 1.584545,0.45556 3.723038,0.23999 5.126302,0.11812 z"
id="path4697" />
style="fill:#feb6b9;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4699"
d="m 78.146576,107.06696 c 1.636436,10.62428 -0.93564,16.56205 -12.449591,21.04855 l -0.590587,-3.37816 c 7.160654,-1.083 13.649769,-7.8735 13.040178,-17.67039 z"
id="path4699" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4701"
d="m 81.637986,123.01159 c -12.670965,0.95958 -14.152431,12.42398 -10.076572,21.37751 7.724779,4.9815 14.580842,3.99696 17.238885,3.0736 0,0 -9.888972,-5.07812 -9.354434,-11.55941 0.53454,-6.48129 3.741774,-10.89123 3.741774,-10.89123 0.428014,-1.07406 -0.282581,-1.80534 -1.549653,-2.00047 z"
id="path4701" />
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4703"
d="m 79.448737,123.30205 c 0.992925,-0.19905 1.78017,0.29602 1.107439,1.0337 0,0 -1.819011,0.56696 -2.031622,2.52772 -0.212614,1.96075 0.354351,2.36235 0,3.47265 -0.354354,1.11031 -2.362353,3.85063 -2.315104,5.14993 0.04725,1.29929 2.244233,3.56715 2.433219,4.70108 0.18899,1.13393 -0.377975,2.81119 0.566965,4.11049 0.944941,1.29929 2.14974,3.73251 2.14974,3.73251 -4.734539,-0.4794 -8.074618,-2.55807 -9.79796,-3.64103 -1.841663,-3.17287 -5.803388,-17.66538 7.887323,-21.08705 z"
id="path4703" />
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4705"
d="m 96.150212,124.61115 c -4.01876,5.52151 -8.794257,9.92116 -16.711757,11.36671 -0.08036,0.88765 0.137909,1.87224 0.41237,2.79858 11.745993,-2.60459 14.877081,-9.93974 16.299387,-14.16529 z"
id="path4705" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#eea6a7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4707"
d="m 86.603794,146.14025 2.196505,1.32245 c 6.701159,-2.41232 11.511951,-7.21119 15.190401,-13.27595 1.66173,6.03294 8.2499,13.42904 14.22135,13.46541 l 1.87506,-1.76484 c 0,0 0.0148,0.77265 -2.15854,-0.59751 -2.17336,-1.37017 -0.75595,-2.74033 -2.17336,-2.83483 -1.41741,-0.0945 -1.9286,0.12534 -4.20499,-0.7087 -1.44801,-0.53053 -0.75609,-4.10885 -1.74814,-5.05543 -0.99131,-0.94586 -2.3151,-0.56697 -2.92931,-2.5041 -0.61422,-1.93712 0.33073,-2.64583 -0.56697,-3.96875 -0.89178,-1.83082 -2.3092,-2.24423 -2.3092,-2.24423 -0.1266,-0.0643 -1.51607,0.38315 -2.36825,1.96075 -0.37793,0.96857 -0.8032,4.48847 -1.748143,4.77195 -0.944941,0.28348 -1.700892,0.28348 -2.69308,2.03162 -0.992187,1.74814 -0.944941,3.77976 -2.173364,4.25224 -1.228421,0.47247 -2.83482,0.33073 -3.638021,1.46465 -0.803198,1.13393 -4.771948,3.68527 -4.771948,3.68527 z"
id="path4707" />
style="fill:#eea6a7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4709"
d="m 123.59821,125.20982 c -1.3987,-1.65054 -0.85807,-3.15445 2.88207,-2.78757 12.13782,1.66374 11.02212,16.63634 10.25261,17.85937 -2.85371,8.22299 -13.58843,8.19381 -18.52084,7.37054 0.96034,-1.98472 15.90292,-9.54748 5.38616,-22.44234 z"
id="path4709" />
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4711"
d="m 127.42522,122.75298 c 0,0 -1.93712,-0.23624 -1.65364,1.13392 0.28348,1.37017 2.3151,1.46466 2.26785,2.69308 -0.0472,1.22843 -0.51971,4.06325 0.33073,4.63021 0.85045,0.56697 2.55134,2.97656 1.55915,5.71689 -0.99218,2.74033 -1.98437,1.08668 -2.40959,3.54353 -0.42523,2.45684 -0.75596,3.77976 -1.46466,4.53571 -0.70871,0.75595 -1.22842,2.88207 -1.22842,2.88207 0,0 6.75631,0.37798 8.97693,-3.30729 9.56355,-9.24479 -1.05337,-21.95234 -6.37835,-21.82812 z"
id="path4711" />
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4713"
d="m 115.7277,126.3484 c 2.71026,4.74824 6.41201,7.84401 11.526,8.58604 0.0146,0.75716 -0.10568,1.5973 -0.4009,2.22168 -5.69579,-1.24493 -9.34848,-5.1092 -11.1251,-10.80772 z"
id="path4713" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4715"
d="m 143.05601,138.17508 c 8.45644,-12.2696 1.58622,-19.08645 -2.40542,-26.9274 -1.17246,-1.73452 0.56242,-1.73337 1.26953,-1.5368 4.83863,1.10659 18.77671,5.73353 11.35895,22.98518 -3.1826,2.72671 -6.38288,4.87014 -10.22306,5.47902 z"
id="path4715" />
style="fill:#feb6b7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4717"
d="m 145.33184,110.65773 c -1.44409,-0.17245 -1.87623,0.88971 -1.74814,1.55916 0.12809,0.66945 1.37019,0.51971 1.41742,2.07887 0.0472,1.55916 1.74813,4.91369 2.03162,5.57515 0.28348,0.66145 1.88988,1.65364 2.07887,4.06324 0.18898,2.4096 -0.36512,6.46405 -0.99219,7.46503 -0.73995,1.18117 -0.10965,4.58296 0.33073,4.58296 1.94348,-0.70588 3.22695,-2.18177 4.82892,-3.28608 3.76978,-8.80185 3.25139,-17.5402 -7.94723,-22.03833 z"
id="path4717" />
style="fill:#ffd7d7;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#feb6b9;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4719"
d="m 145.66189,120.68563 c 0.50608,1.04701 0.81356,2.13795 1.05237,3.27405 0,0 -1.26953,0.21716 -3.54132,0.15034 -2.27179,-0.0668 -5.028,-1.1526 -5.028,-1.1526 0,0 2.12145,0.33409 4.07585,-0.3842 1.95441,-0.71829 3.4411,-1.88759 3.4411,-1.88759 z"
id="path4719" />
style="fill:#feb6b9;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4721"
d="m 131.34627,111.26438 c -0.46387,6.60804 -0.23431,15.14869 15.71879,15.3513 l -0.3508,-2.656 c -5.36818,0.47928 -14.93993,-0.59196 -15.36799,-12.6953 z"
id="path4721" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<g
id="g4740"
transform="rotate(19.617168,87.590538,52.720911)">
transform="rotate(19.617168,87.590538,52.720911)"
id="g4740">
<path
id="path4725"
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.39299998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
d="m 108.72438,96.054369 c -1.67459,2.099573 -6.51237,1.558255 -9.762082,-0.889359 -3.249707,-2.447614 -5.286913,-7.54075 -1.925906,-10.583607 2.406544,-2.178749 7.101418,-0.79705 9.431368,1.36184 2.53306,2.34709 4.6065,7.164892 2.25662,10.111126 z"
id="path4725" />
<path
id="path4730"
d="m 108.84598,96.203312 c -1.50822,1.890992 -5.8654,1.403451 -8.79227,-0.801006 -2.926867,-2.204458 -4.761688,-6.79162 -1.734578,-9.532187 2.167468,-1.962302 6.395928,-0.717867 8.494418,1.226549 2.28141,2.11392 4.14887,6.453101 2.03243,9.106644 z"
style="opacity:1;fill:#f0f0f0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.35395768;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
<circle
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.46482563;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="path4732"
cx="101.24133"
cy="91.80127"
r="3.4367433" />
<circle
r="1.0470117"
cy="91.470535"
cx="100.24915"
id="circle4734"
style="opacity:1;fill:#ffffff;fill-opacity:0.94117647;fill-rule:nonzero;stroke:none;stroke-width:0.14161019;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
<circle
style="opacity:1;fill:#ffffff;fill-opacity:0.94117647;fill-rule:nonzero;stroke:none;stroke-width:0.14161019;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="circle4657"
cx="102.3987"
cy="93.070198"
r="0.6970861" />
</g>
<g
id="g4750"
transform="matrix(-0.84292907,0.5380247,0.5380247,0.84292907,153.98869,-37.720137)">
<path
id="path4742"
d="m 108.72438,96.054369 c -1.67459,2.099573 -6.51237,1.558255 -9.762082,-0.889359 -3.249707,-2.447614 -5.286913,-7.54075 -1.925906,-10.583607 2.406544,-2.178749 7.101418,-0.79705 9.431368,1.36184 2.53306,2.34709 4.6065,7.164892 2.25662,10.111126 z"
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.39299998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
<path
style="opacity:1;fill:#f0f0f0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.35395768;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
d="m 108.84598,96.203312 c -1.50822,1.890992 -5.8654,1.403451 -8.79227,-0.801006 -2.926867,-2.204458 -4.761688,-6.79162 -1.734578,-9.532187 2.167468,-1.962302 6.395928,-0.717867 8.494418,1.226549 2.28141,2.11392 4.14887,6.453101 2.03243,9.106644 z"
id="path4730" />
id="path4744" />
<circle
r="3.4367433"
cy="91.80127"
cx="101.24133"
id="path4732"
cy="90.845963"
cx="105.85611"
id="circle4746"
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.46482563;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
<circle
style="opacity:1;fill:#ffffff;fill-opacity:0.94117647;fill-rule:nonzero;stroke:none;stroke-width:0.14161019;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="circle4734"
cx="100.24915"
cy="91.470535"
id="circle4748"
cx="106.96864"
cy="89.537895"
r="1.0470117" />
<circle
r="0.6970861"
cy="93.070198"
cx="102.3987"
id="circle4657"
style="opacity:1;fill:#ffffff;fill-opacity:0.94117647;fill-rule:nonzero;stroke:none;stroke-width:0.14161019;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
</g>
<g
transform="matrix(-0.84292907,0.5380247,0.5380247,0.84292907,153.98869,-37.720137)"
id="g4750">
<path
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.39299998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
d="m 108.72438,96.054369 c -1.67459,2.099573 -6.51237,1.558255 -9.762082,-0.889359 -3.249707,-2.447614 -5.286913,-7.54075 -1.925906,-10.583607 2.406544,-2.178749 7.101418,-0.79705 9.431368,1.36184 2.53306,2.34709 4.6065,7.164892 2.25662,10.111126 z"
id="path4742" />
<path
id="path4744"
d="m 108.84598,96.203312 c -1.50822,1.890992 -5.8654,1.403451 -8.79227,-0.801006 -2.926867,-2.204458 -4.761688,-6.79162 -1.734578,-9.532187 2.167468,-1.962302 6.395928,-0.717867 8.494418,1.226549 2.28141,2.11392 4.14887,6.453101 2.03243,9.106644 z"
style="opacity:1;fill:#f0f0f0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.35395768;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
<circle
style="opacity:1;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.46482563;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="circle4746"
cx="105.85611"
cy="90.845963"
r="3.4367433" />
<circle
r="1.0470117"
cy="89.537895"
cx="106.96864"
id="circle4748"
style="opacity:1;fill:#ffffff;fill-opacity:0.94117647;fill-rule:nonzero;stroke:none;stroke-width:0.14161019;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
<circle
style="opacity:1;fill:#ffffff;fill-opacity:0.94117647;fill-rule:nonzero;stroke:none;stroke-width:0.14161019;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke"
id="circle4655"
cx="105.70718"
r="0.59710735"
cy="92.300102"
r="0.59710735" />
cx="105.70718"
id="circle4655"
style="opacity:1;fill:#ffffff;fill-opacity:0.94117647;fill-rule:nonzero;stroke:none;stroke-width:0.14161019;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:fill markers stroke" />
</g>
<path
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4752"
d="m 79.440842,83.663452 c 2.401026,-4.843277 7.683432,-2.978854 9.070822,-3.016481 -2.556226,0.763727 -4.708947,2.955988 -6.701589,4.398539 -0.740592,0.536143 -2.442986,-0.394305 -2.369233,-1.382058 z"
id="path4752" />
<path
id="path4754"
d="m 125.44005,85.24075 c -2.30199,-5.105822 -7.76722,-3.941999 -8.60332,-4.166742 2.43567,1.088609 4.28608,3.541392 6.07494,5.23005 0.66486,0.627613 2.47348,-0.07434 2.52838,-1.063308 z"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 125.44005,85.24075 c -2.30199,-5.105822 -7.76722,-3.941999 -8.60332,-4.166742 2.43567,1.088609 4.28608,3.541392 6.07494,5.23005 0.66486,0.627613 2.47348,-0.07434 2.52838,-1.063308 z"
id="path4754" />
<path
id="path4756"
d="m 70.96503,82.262275 c 0,0 6.331101,3.071056 7.606771,-0.519718 1.275667,-3.590772 -1.27567,-7.9375 -1.27567,-7.9375 2.892685,-1.049875 3.091067,-3.4469 2.220608,-6.378347 2.152776,-0.348696 4.444174,-0.539426 1.322917,-6.898066 -4.769498,4.415313 -9.420477,9.057506 -9.874626,21.733631 z"
id="path4756" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4758"
d="m 88.132125,56.156746 c 0,0 3.254408,16.386084 14.566195,13.79779 7.88444,-1.80407 6.28083,-7.617182 6.28083,-7.617182 3.56518,5.081814 12.40153,4.310975 11.29214,-2.539061 2.41655,0.835207 3.97125,0.06982 5.14493,-1.403165 -13.2372,-7.82638 -25.466402,-6.83272 -37.284095,-2.238382 z"
id="path4758" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4760"
d="m 131.36297,62.871892 c -1.12784,1.399585 -4.16412,7.545045 2.00452,7.650591 -0.56702,5.505473 1.00425,9.251589 7.45014,8.986938 -1.43088,-7.033989 -4.7288,-12.453251 -9.45466,-16.637529 z"
id="path4760" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4762"
d="m 140.95127,86.391611 c -1.79338,-0.414357 -3.99759,-0.910879 -6.68174,-1.503391 -2.67445,-0.05943 -3.63783,1.364072 -2.3052,4.777443 2.82115,3.939599 4.73867,3.511676 7.08264,5.144937 0.70035,-2.780098 1.61025,-5.476371 1.9043,-8.418989 z"
id="path4762" />
style="fill:#dd6258;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:#7f2625;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4764"
d="m 93.342168,119.42814 c 0.529236,-0.73806 0.604365,-0.3573 1.136405,-1.09204 0.364677,-0.50361 0.199924,-1.06153 0.644573,-1.46533 0.309321,-0.28091 0.977351,-0.124 1.362201,-0.31657 0.345479,-0.17287 0.405453,-0.70096 0.732931,-0.84737 0.789296,-0.35288 1.800707,0.26944 2.651021,0.0936 1.078571,-0.22307 2.076941,-1.09961 3.190181,-1.10946 0.97926,-0.009 1.7824,0.64931 2.75826,0.86661 0.76717,0.17085 1.97838,-0.33367 2.73467,-0.0767 0.43365,0.14737 1.07369,-0.0937 1.4783,0.11451 0.30157,0.15517 0.33276,0.75525 0.61169,0.95375 1.72336,1.22644 1.31075,0.33606 2.60238,2.06475 0,0 0.28975,-0.0348 -2.07105,-2.47226 -0.32347,-0.33398 -0.51448,-1.08044 -0.86627,-1.38846 -0.45582,-0.39911 -1.04178,-0.39938 -1.53149,-0.74298 -0.6807,-0.4776 -1.9258,0.26924 -2.60859,-0.0841 -1.25954,-0.65171 -2.00993,-2.31042 -3.18147,-2.02677 -1.01838,0.24657 -2.19748,1.18524 -3.446552,1.56394 -0.794617,0.24091 -1.705404,-0.22114 -2.337848,0.30385 -0.769592,0.63883 -0.5723,0.83835 -1.171115,1.59816 -0.306698,0.38915 -0.985411,0.37691 -1.287999,0.78707 -0.398797,0.54057 -0.388125,1.15748 -0.697902,1.70652 -0.81359,1.44197 -0.702326,1.56918 -0.702326,1.56918 z"
id="path4764" />
style="fill:#7f2625;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
style="fill:none;stroke:#301818;stroke-width:4.46500015;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path4847"
d="m 66.416477,77.838987 c 0,0 2.248303,-29.702875 39.355443,-29.132379 37.10713,0.570497 40.69178,29.399648 40.69178,29.399648"
id="path4847" />
<path
id="path4651"
d="m 82.283169,89.859621 c 1.901054,-0.826347 5.017206,-1.87303 7.190809,-1.209662 2.299291,0.701728 3.661648,3.157157 5.855653,3.784919 0,0 -0.675634,-7.540694 -7.907825,-7.911193 -3.749793,-0.192098 -5.138637,5.335936 -5.138637,5.335936 z"
style="opacity:1;vector-effect:none;fill:#7f2625;fill-opacity:1;stroke:none;stroke-width:1.48535442;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
style="fill:none;stroke:#301818;stroke-width:4.46500015;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="opacity:1;vector-effect:none;fill:#7f2625;fill-opacity:1;stroke:none;stroke-width:1.48535442;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 82.283169,89.859621 c 1.901054,-0.826347 5.017206,-1.87303 7.190809,-1.209662 2.299291,0.701728 3.661648,3.157157 5.855653,3.784919 0,0 -0.675634,-7.540694 -7.907825,-7.911193 -3.749793,-0.192098 -5.138637,5.335936 -5.138637,5.335936 z"
id="path4651" />
<path
id="path4653"
d="m 122.49651,94.411543 c -3.78678,-6.81696 -8.0759,-7.480996 -11.84594,-3.933836 0,0 3.20445,-7.27537 10.03419,-4.867774 4.12152,1.452904 1.81175,8.80161 1.81175,8.80161 z"
id="path4653" />
style="opacity:1;vector-effect:none;fill:#7f2625;fill-opacity:1;stroke:none;stroke-width:1.48535442;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<g
style="stroke-width:42.36951447"
transform="matrix(0.02360188,0,0,0.02360188,113.18825,105.15104)"
id="g6"
transform="matrix(0.02360188,0,0,0.02360188,113.18825,105.15104)">
style="stroke-width:42.36951447">
<path
id="path2"
style="fill:#29abe2;stroke-width:42.36951447"
d="m 117.16,11.645 c 0,0 -31.496,38.028 -68.31,85.296 -47.195,60.599 -70.131,146.52 31.843,165.81 113.27,21.429 132.5,-118.17 65.274,-157.01 -49.223,-28.424 -28.81,-94.097 -28.81,-94.095 z"
style="fill:#29abe2;stroke-width:42.36951447" />
id="path2" />
<path
id="path4-9"
style="fill:#ffffff;stroke-width:42.36951447"
d="m 51.844,134.31 c 0,0 -56.287,70.6 25.797,108.12 0,0.01 -44.304,-31.59 -25.797,-108.12 z"
style="fill:#ffffff;stroke-width:42.36951447" />
id="path4-9" />
</g>
<g
transform="matrix(-0.0125514,0,0,0.0125514,96.697579,101.08859)"
style="stroke-width:79.67237854"
id="g4688"
style="stroke-width:79.67237854">
transform="matrix(-0.0125514,0,0,0.0125514,96.697579,101.08859)">
<path
style="fill:#29abe2;stroke-width:79.67237854"
id="path4684"
d="m 117.16,11.645 c 0,0 -31.496,38.028 -68.31,85.296 -47.195,60.599 -70.131,146.52 31.843,165.81 113.27,21.429 132.5,-118.17 65.274,-157.01 -49.223,-28.424 -28.81,-94.097 -28.81,-94.095 z"
id="path4684" />
style="fill:#29abe2;stroke-width:79.67237854" />
<path
style="fill:#ffffff;stroke-width:79.67237854"
id="path4686"
d="m 51.844,134.31 c 0,0 -56.287,70.6 25.797,108.12 0,0.01 -44.304,-31.59 -25.797,-108.12 z"
id="path4686" />
style="fill:#ffffff;stroke-width:79.67237854" />
</g>
<path
id="path4692"
style="opacity:1;vector-effect:none;fill:#29abe2;fill-opacity:1;stroke:none;stroke-width:1.48535442;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 85.947033,99.042108 0.671609,-0.0707 c 0,0 0.49487,-0.636261 0.777652,-0.353479 0.282783,0.282783 0.424174,0.777652 1.343218,0.565565 0.919043,-0.212086 0.353478,0.212087 1.343217,0.49487 0.989739,0.282783 0.777653,0 1.484609,-0.282783 0.706957,-0.282782 0.212087,-0.777652 1.060435,-0.565565 0.848348,0.212087 0.282782,0.212087 1.272521,-0.212087 0.989739,-0.424174 1.334642,-0.665544 1.334642,-0.665544 0,0 -0.5002,2.353665 -3.049011,3.334305 -4.895674,0.33581 -6.238892,-2.244587 -6.238892,-2.244587 z"
id="path4692" />
<path
id="path4694"
d="m 90.215634,100.78237 c 0,0 0.47109,0.17648 1.084764,0.10067 0.31585,-0.039 0.649984,-0.31716 1.017863,-0.42775 0.326516,-0.0982 0.666764,-0.12133 0.976323,-0.31443 0.19834,-0.12373 0.30993,-0.330545 0.48732,-0.500317 0.276456,-0.264584 0.578906,-0.54704 0.757961,-0.950004 2.09e-4,-7.1e-5 -0.344817,0.269525 -1.022134,0.718841 -0.188018,0.124727 -0.295615,0.385643 -0.557188,0.510044 -0.286058,0.136046 -0.727267,0.150626 -1.096851,0.284556 -0.199876,0.0724 -0.248589,0.33662 -0.45906,0.42367 -0.308425,0.12756 -0.791836,0.0775 -1.188998,0.15472 z"
style="fill:#29ebe2;fill-opacity:1;stroke-width:1.00000024" />
<path
id="path4698"
d="m 119.99047,99.106433 -0.65394,0.265104 c 0,0 -0.42418,-0.459522 -0.70696,-0.17674 -0.28278,0.282783 -0.26511,0.848353 -1.18415,0.636261 -0.91904,-0.212086 -0.35348,0.212092 -1.34322,0.494872 -0.98974,0.28278 -0.77765,0 -1.48461,-0.28278 -0.70695,-0.282787 -0.21208,-0.777657 -1.06043,-0.56557 -0.84835,0.212087 -0.28278,0.212087 -1.27252,-0.212087 -0.98974,-0.424174 -1.51138,-0.479968 -1.51138,-0.479968 0,0 0.9067,2.486225 3.22575,3.148735 2.34542,0.68368 4.48756,-0.65364 5.92076,-2.54505 z"
style="opacity:1;vector-effect:none;fill:#29abe2;fill-opacity:1;stroke:none;stroke-width:1.48535442;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<path
style="fill:#29ebe2;fill-opacity:1;stroke-width:1.00000024"
d="m 90.215634,100.78237 c 0,0 0.47109,0.17648 1.084764,0.10067 0.31585,-0.039 0.649984,-0.31716 1.017863,-0.42775 0.326516,-0.0982 0.666764,-0.12133 0.976323,-0.31443 0.19834,-0.12373 0.30993,-0.330545 0.48732,-0.500317 0.276456,-0.264584 0.578906,-0.54704 0.757961,-0.950004 2.09e-4,-7.1e-5 -0.344817,0.269525 -1.022134,0.718841 -0.188018,0.124727 -0.295615,0.385643 -0.557188,0.510044 -0.286058,0.136046 -0.727267,0.150626 -1.096851,0.284556 -0.199876,0.0724 -0.248589,0.33662 -0.45906,0.42367 -0.308425,0.12756 -0.791836,0.0775 -1.188998,0.15472 z"
id="path4694" />
<path
style="opacity:1;vector-effect:none;fill:#29abe2;fill-opacity:1;stroke:none;stroke-width:1.48535442;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 119.99047,99.106433 -0.65394,0.265104 c 0,0 -0.42418,-0.459522 -0.70696,-0.17674 -0.28278,0.282783 -0.26511,0.848353 -1.18415,0.636261 -0.91904,-0.212086 -0.35348,0.212092 -1.34322,0.494872 -0.98974,0.28278 -0.77765,0 -1.48461,-0.28278 -0.70695,-0.282787 -0.21208,-0.777657 -1.06043,-0.56557 -0.84835,0.212087 -0.28278,0.212087 -1.27252,-0.212087 -0.98974,-0.424174 -1.51138,-0.479968 -1.51138,-0.479968 0,0 0.9067,2.486225 3.22575,3.148735 2.34542,0.68368 4.48756,-0.65364 5.92076,-2.54505 z"
id="path4698" />
<path
id="path4700"
d="m 115.9693,101.42994 c 0,0 -0.47109,0.17648 -1.08477,0.10067 -0.31585,-0.039 -0.64998,-0.31716 -1.01786,-0.42775 -0.32651,-0.0982 -0.66676,-0.12133 -0.97632,-0.31443 -0.19834,-0.12373 -0.30993,-0.33055 -0.48732,-0.50032 -0.27646,-0.26458 -0.57891,-0.547043 -0.75796,-0.950007 -2.1e-4,-7.1e-5 0.34481,0.269525 1.02213,0.718847 0.18802,0.12472 0.29562,0.38564 0.55719,0.51004 0.28606,0.13605 0.72727,0.15063 1.09685,0.28456 0.19988,0.0724 0.24859,0.33662 0.45906,0.42367 0.30843,0.12756 0.79184,0.0775 1.189,0.15472 z"
style="fill:#29ebe2;fill-opacity:1;stroke-width:1.00000024" />
id="path4700" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

52
public/img/emoji-lmhf.svg Normal file
View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="101.99548mm"
height="105.96579mm"
viewBox="0 0 101.99548 105.96579"
version="1.1"
id="svg1"
sodipodi:docname="emoji-lmhf.svg"
inkscape:version="1.3 (0e150ed, 2023-07-21)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="1.1893044"
inkscape:cx="119.39752"
inkscape:cy="159.33684"
inkscape:window-width="1680"
inkscape:window-height="1050"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-63.500001,-183.88551)">
<path
id="path3"
style="stroke-width:0.264583"
d="M 140.00179 183.88654 C 139.72084 183.87717 139.44206 183.94473 139.04836 184.1134 C 136.28797 185.85595 133.21443 186.98396 130.07992 187.85788 L 129.46032 188.02427 C 128.87929 188.18222 128.29871 188.32762 127.71107 188.45991 L 126.94677 188.61959 C 123.89269 189.25644 120.958 189.51422 117.83777 189.49602 C 117.08741 189.48762 116.33633 189.4799 115.58571 189.4769 C 115.08432 189.4759 114.58332 189.47373 114.08193 189.47173 L 113.36104 189.46708 C 109.28222 189.44618 105.4276 188.68676 101.52817 187.51836 C 99.942257 187.03814 98.350432 186.57803 96.758436 186.11845 L 96.255107 185.97375 C 94.072297 185.34325 91.89001 184.72889 89.640524 184.38625 C 84.279277 183.86476 80.278726 184.58056 76.071327 187.98707 L 75.617607 188.355 C 70.996664 191.93005 66.675167 196.44447 63.581133 201.42604 C 63.44646 202.11343 63.446708 202.11343 63.835381 202.91432 C 64.211883 203.49826 64.563589 204.07506 64.890614 204.68889 L 65.307126 205.50434 L 65.75361 206.37147 C 66.941853 208.68472 68.134949 210.99349 69.388014 213.27234 A 270.71584 270.71584 0 0 1 73.226022 220.49516 C 74.541529 223.05977 75.863803 225.6168 77.254717 228.14172 A 231.14582 231.14582 0 0 1 80.035426 233.33417 C 81.342466 235.86306 82.661834 238.38438 84.008827 240.89236 C 87.107874 246.66004 90.121993 252.4691 93.115764 258.29182 A 15.884278 19.809179 74.412015 0 0 82.061142 257.98899 A 15.884278 19.809179 74.412015 0 0 65.505563 277.7883 A 15.884278 19.809179 74.412015 0 0 87.653049 289.31421 A 15.884278 19.809179 74.412015 0 0 92.464641 287.74997 C 92.614415 287.72841 92.764119 287.70888 92.915776 287.6678 C 93.68189 287.25802 94.424731 286.81279 95.154399 286.35057 A 15.884278 19.809179 74.412015 0 0 97.914954 284.38532 C 97.996889 284.31871 98.075774 284.24926 98.1568 284.18171 A 15.884278 19.809179 74.412015 0 0 98.692168 283.70422 C 100.96899 281.69105 102.85559 279.28691 104.04378 276.3455 C 105.03914 273.0898 104.84133 269.15631 103.29034 266.13372 C 102.38282 264.4007 101.52217 262.64555 100.67603 260.88184 L 100.27347 260.04055 C 99.609102 258.65414 98.944791 257.26759 98.286508 255.87853 A 453.37994 453.37994 0 0 0 93.67387 246.42536 C 92.275548 243.63877 90.875763 240.85317 89.512367 238.04964 L 89.094821 237.18716 L 88.698463 236.37636 L 88.341379 235.63377 C 87.928365 234.77917 87.680755 234.00673 87.523857 233.06959 C 90.747802 230.29437 94.362898 227.87812 98.136646 225.91808 L 98.636357 225.66125 C 104.14763 222.77334 110.39629 221.64425 116.55154 223.19629 C 120.15702 224.21652 123.89156 224.44269 127.62219 224.45409 L 128.23662 224.45409 C 133.97357 224.46176 139.46335 223.62026 144.8754 221.64858 L 145.46761 221.42792 C 151.57153 219.16864 159.69875 215.41182 163.72386 210.05084 C 163.74608 208.8827 163.27854 208.01251 162.71048 207.00709 L 162.40094 206.47896 C 162.07207 205.90905 161.74116 205.34111 161.40514 204.77571 L 161.07802 204.23 C 158.47638 199.88079 155.43722 195.98977 151.75766 192.50204 L 151.28844 192.05917 C 148.75453 189.6303 146.10197 187.42905 143.17886 185.48438 L 142.68948 185.16347 A 24.645408 24.645408 0 0 0 140.96969 184.12167 L 140.96969 184.12115 C 140.56607 183.98195 140.28275 183.89591 140.00179 183.88654 z " />
<path
fill="#fb161f"
d="m 164.78205,211.63826 c 0.99828,1.87642 0.82392,3.22395 0.23892,5.22737 -1.40414,4.37356 -3.45546,8.0862 -6.58892,11.44138 l -0.49715,0.57044 c -4.91728,5.36839 -12.63412,8.33914 -19.61091,9.74831 l -0.71676,0.14605 c -3.04139,0.5416 -6.10844,0.47757 -9.18872,0.48366 -2.86411,0.01 -5.42766,0.0212 -8.08646,1.22237 l -0.63235,0.26379 c -0.56488,0.24342 -1.11495,0.50535 -1.66608,0.77788 l -0.56171,0.25796 c -1.03214,0.48684 -1.03214,0.48684 -1.59332,1.42875 -0.054,0.86704 0.13097,1.28588 0.52361,2.05714 l 0.39661,0.79296 0.44,0.8509 0.44873,0.88926 c 0.39529,0.78237 0.79375,1.56289 1.19407,2.34262 0.39846,0.77814 0.79269,1.5584 1.18745,2.33839 a 1296.0032,1296.0032 0 0 0 1.82509,3.58907 c 0.68263,1.33879 1.35732,2.68102 2.02645,4.02643 a 123.3342,123.3342 0 0 0 2.11667,4.06506 c 0.79375,1.48167 1.53564,2.97709 2.21588,4.51432 l 0.22648,0.50535 c 1.33721,3.09483 1.48511,5.88301 0.40482,9.08156 -1.76001,4.25741 -5.17287,7.43744 -9.34615,9.28132 a 48.934687,48.934687 0 0 1 -1.5875,0.56224 l -0.72125,0.25506 c -4.4069,1.28878 -9.81816,1.12606 -13.90835,-0.9652 -1.26233,-0.70115 -2.40401,-1.36763 -3.362062,-2.46486 -0.0164,-0.52917 -0.0164,-0.52917 0.264582,-1.05833 l 0.60484,-0.47043 c 2.09629,-1.71635 3.4753,-4.1701 4.42224,-6.67332 l 0.25533,-0.63051 c 1.00145,-2.79664 0.81994,-5.7142 -0.26247,-8.4492 -0.27675,-0.76332 -0.4101,-1.4949 -0.52202,-2.29738 1.90103,-1.00938 3.73168,-1.85578 5.82083,-2.38125 l 0.94668,-0.2831 c 1.397,-0.34819 2.78183,-0.30374 4.2127,-0.27914 l 0.81227,0.007 c 0.65537,0.006 1.31074,0.0148 1.96585,0.0259 -0.34581,-1.11443 -0.76941,-2.12196 -1.3073,-3.15648 l -0.46435,-0.89588 -0.49371,-0.94192 -0.50615,-0.97102 a 174.52181,174.52181 0 0 0 -3.27263,-5.97376 c -0.55351,-0.98187 -1.05833,-1.98305 -1.55522,-2.99456 -1.00145,-2.0193 -2.04258,-4.01584 -3.10092,-6.00604 -0.30559,-0.5752 -0.61039,-1.15093 -0.91493,-1.72667 a 459.3672,459.3672 0 0 0 -3.0607,-5.69277 l -0.82153,-1.5076 a 230.40975,230.40975 0 0 0 -0.77258,-1.39541 c -0.76835,-1.4097 -1.39912,-2.57175 -1.45706,-4.19206 3.48721,-2.60271 7.94914,-3.37344 12.23856,-2.794 0.89006,0.15743 1.77536,0.33047 2.66092,0.51197 4.24127,0.84852 8.29892,1.33323 12.61718,1.32292 l 0.6866,-5.3e-4 c 3.66051,-0.0106 7.12602,-0.27649 10.69049,-1.15729 l 0.72496,-0.16748 c 8.95694,-2.08809 16.36395,-6.75217 23.31402,-12.63386 0.56727,-0.42783 0.56727,-0.42783 1.09643,-0.42783 z"
id="path4"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

1
public/img/lmhf.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/img/loading-dark.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View file

@ -1,138 +0,0 @@
/**
* Fonction permettant d'afficher un message dans un toastr
* @param {String} message
*/
function showToastr(message, success = false) {
let x = document.getElementById("toastr");
if ( message ) {
x.getElementsByTagName("SPAN")[0].innerHTML = message;
}
x.className = `${x.className} show`.replace("sucess", "");
if ( success ) {
x.className = `${x.className} success`;
}
setTimeout(function(){ x.className = x.className.replace("show", ""); }, 3000);
};
/**
* Fonction permettant de masquer le toastr
*/
function hideToastr() {
let x = document.getElementById("toastr");
x.className = x.className.replace("show", "");
x.getElementsByTagName("SPAN")[0].innerHTML = "";
}
/**
* Fonction permettant de récupérer la valeur d'un cookie
* @param {String} cname
* @param {String} defaultValue
*
* @return {String}
*/
function getCookie(cname, defaultValue = 'false') {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for(let i = 0; i < ca.length; i+=1) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return defaultValue;
}
/**
* Fonction permettant de créer un cookie
* @param {String} cname
* @param {String} cvalue
* @param {Number} exdays
*/
function setCookie(cname, cvalue, exdays = 30) {
const d = new Date();
d.setTime(d.getTime() + (exdays*24*60*60*1000));
let expires = "expires="+ d.toUTCString();
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
}
/**
* Fonction de ()charger le thème accessible
* @param {String} value
*/
function setAriaTheme(value) {
let body = document.body;
if ( value === 'true' ) {
let classesString = body.className || "";
if (classesString.indexOf("is-accessible") === -1) {
body.classList.add("is-accessible");
}
} else {
body.classList.remove("is-accessible");
}
}
/**
* Fonction de ()charger le thème accessible
*/
function switchAriaTheme() {
let body = document.body;
body.classList.toggle("is-accessible");
setCookie('ariatheme', body.classList.contains("is-accessible"));
}
/**
* Fonction permettant de switcher de thème clair/sombre
* @param {Object} e
*/
function switchTheme(e) {
const theme = e.target.checked ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
setCookie('theme', theme);
}
/**
* Ensemble d'actions effectuées au chargement de la page
*/
document.addEventListener('DOMContentLoaded', () => {
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
if ($navbarBurgers.length > 0) {
$navbarBurgers.forEach( el => {
el.addEventListener('click', () => {
const target = el.dataset.target;
const $target = document.getElementById(target);
el.classList.toggle('is-active');
$target.classList.toggle('is-active');
});
});
}
const switchAriaThemeBtn = document.querySelector("#switchAriaTheme");
if ( switchAriaThemeBtn ) {
switchAriaThemeBtn.addEventListener("click", switchAriaTheme);
}
setAriaTheme(getCookie('ariatheme'));
const toggleSwitch = document.querySelector('.theme-switch input[type="checkbox"]');
if ( toggleSwitch ) {
toggleSwitch.addEventListener('change', switchTheme, false);
}
let currentThemeIsDark = getCookie('theme');
if ( currentThemeIsDark === 'false' && window.matchMedia ) {
currentThemeIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
switchTheme({target: {checked: currentThemeIsDark === 'dark'}});
if ( toggleSwitch) {
toggleSwitch.checked = currentThemeIsDark === 'dark';
}
});

0
public/robots.txt Normal file
View file

View file

@ -1,16 +1,9 @@
.body-500 {
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-weight: 400;
font-size: larger;
line-height: 200%;
text-align: center;
img {
max-width: 60%;
margin-bottom: 32px;

View file

@ -7,10 +7,18 @@
.list {
margin-top: 2rem;
.item{
.item {
img {
cursor: pointer;
}
&.in-collection {
opacity: 0.6;
small {
font-style: italic;
}
}
}
}
}

View file

@ -3,17 +3,20 @@
border-radius: 6px;
box-shadow: var(--box-shadow-color) 0px 3px 6px 0px;
color: var(--font-color);
display: block;
padding: 1.25rem;
width: calc(100% - 2rem);
margin: auto;
@include transition() {}
@include respond-to("small-up") {
width: 65%;
}
@include respond-to("medium-up") {
width: 35%;
&.mini {
margin: auto;
width: calc(100% - 2rem);
@include respond-to("small-up") {
width: 65%;
}
@include respond-to("medium-up") {
width: 35%;
}
}
h1 {

View file

@ -4,27 +4,15 @@
cursor: pointer;
}
}
.filters {
display: flex;
justify-content: end;
padding: 0.5rem 0;
.field {
padding: 0 0.5rem;
.showMoreFilters {
cursor: pointer;
select {
width: auto;
}
@include respond-to("small-up") {
&:last-child {
padding-right: 0;
}
}
.up::before {
transform: rotate(90deg);
}
@include respond-to("small") {
flex-direction: column;
.down::before {
transform: rotate(270deg);
}
}
@ -41,8 +29,4 @@
}
}
}
.total {
margin: 0.75rem 0;
}
}

View file

@ -22,10 +22,12 @@ $nord15: #b48ead;
$primary-color: $nord8;
$danger-color: $nord11;
$error-color: $nord12;
$warning-color: $nord13;
$success-color: $nord14;
$primary-color-hl: darken($primary-color, $hoverAmount);
$danger-color-hl: darken($danger-color, $hoverAmount);
$error-color-hl: darken($error-color, $hoverAmount);
$warning-color-hl: darken($warning-color, $hoverAmount);
$success-color-hl: darken($success-color, $hoverAmount);
@ -35,11 +37,15 @@ $button-alternate-color: #01103C;
$pagination-border-color: $nord3;
$pagination-hover-color: rgb(115, 151, 186);
$close-background: rgba(10,10,10,.6);
$close-background-dark: rgba(240,240,240,.6);
:root {
--default-color: #{$white};
--bg-color: #{darken($white, 5%)};
--bg-alternate-color: #{darken($white, 8%)};
--font-color: #{$nord3};
--hover-font-color: #{lighten($nord3, 16%)};
--footer-color: #{$darken-white};
--link-color: #{$nord1};
@ -56,6 +62,10 @@ $pagination-hover-color: rgb(115, 151, 186);
--button-link-text-color: #2C364A;
--close-background: #{$close-background};
--loader-img: url('/img/loading-light.gif');
--nord0: #{$nord0};
--nord1: #{$nord1};
--nord2: #{$nord2};
@ -79,6 +89,7 @@ $pagination-hover-color: rgb(115, 151, 186);
--bg-color: #{lighten($nord0, 2%)};
--bg-alternate-color: #{lighten($nord3, 8%)};
--font-color: #{$nord6};
--hover-font-color: #{darken($nord6, 16%)};
--footer-color: #{$nord1};
--link-color: #{$nord4};
@ -94,4 +105,8 @@ $pagination-hover-color: rgb(115, 151, 186);
--border-color: #{$nord1};
--button-link-text-color: #{$white};
--close-background: #{$nord3};
--loader-img: url('/img/loading-dark.gif');
}

View file

@ -1,4 +1,6 @@
.error {
min-height: calc(100vh - 3.25rem - 100px);
padding-top: 4rem;
main {
&.error {
min-height: calc(100vh - 3.25rem - 100px);
padding-top: 4rem;
}
}

View file

@ -9,6 +9,14 @@
margin: 2rem auto;
.header {
font-weight: 800;
font-weight: 700;
}
&.info {
background-color: $warning-color;
}
&.success {
background-color: $success-color;
}
}

File diff suppressed because one or more lines are too long

View file

@ -24,9 +24,6 @@
}
}
label {
font-weight: 800;
}
input,
textarea,
select {
@ -34,13 +31,12 @@
max-width: 100%;
width: 100%;
background-color: var(--input-color);
border: 1px solid transparent !important;
border: 1px solid var(--input-active-color) !important;
color: var(--input-font-color);
@include transition() {}
&:focus-visible {
outline: unset;
border-color: var(--input-active-color) !important;
}
}
@ -74,78 +70,4 @@
background-size: 1.2rem;
padding-right: 2.4rem;
}
}
.theme-switch-wrapper {
display: flex;
align-items: center;
em {
margin-left: 10px;
font-size: 1rem;
}
}
.theme-switch {
display: inline-block;
height: 34px;
position: relative;
width: 60px;
}
.theme-switch input {
display:none;
}
.slider {
background-color: #ccc;
bottom: 0;
cursor: pointer;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: .4s;
@include transition() {}
}
.slider:before {
background-color: #fff;
bottom: 4px;
content: '\f185';
height: 26px;
left: 4px;
position: absolute;
transition: .4s;
width: 26px;
padding: 0;
font-family: "icon";
font-style: normal;
font-weight: normal;
display: inline-block;
text-decoration: inherit;
text-align: center;
font-variant: normal;
text-transform: none;
}
input:checked + .slider {
background-color: $primary-color;
@include transition() {}
}
input:checked + .slider:before {
transform: translateX(26px);
content: '\f186';
background-color: var(--input-active-color);
@include transition() {}
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
}

View file

@ -7,19 +7,10 @@ html {
display: flex;
flex-direction: column;
padding-top: 3.5rem;
font-family: 'open_sansregular';
font-weight: 400;
font-family: 'lucioleregular';
min-height: 100vh;
color: var(--font-color);
@include transition() {}
&.is-accessible {
font-family: 'lucioleregular';
.text-justify {
text-align: left;
}
}
@include transition();
footer.footer {
margin-top: auto;

View file

@ -1,65 +1,79 @@
@font-face {
font-family: 'icon';
src: url('/font/icon.eot?80770511');
src: url('/font/icon.eot?80770511#iefix') format('embedded-opentype'),
url('/font/icon.woff2?80770511') format('woff2'),
url('/font/icon.woff?80770511') format('woff'),
url('/font/icon.ttf?80770511') format('truetype'),
url('/font/icon.svg?80770511#icon') format('svg');
src: url('/font/icon.eot?15219908');
src: url('/font/icon.eot?15219908#iefix') format('embedded-opentype'),
url('/font/icon.woff2?15219908') format('woff2'),
url('/font/icon.woff?15219908') format('woff'),
url('/font/icon.ttf?15219908') format('truetype'),
url('/font/icon.svg?15219908#icon') format('svg');
font-weight: normal;
font-style: normal;
}
[class^="icon-"]:before, [class*=" icon-"]:before {
}
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
/*
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'icon';
src: url('../font/icon.svg?15219908#icon') format('svg');
}
}
*/
[class^="icon-"]:before, [class*=" icon-"]:before {
font-family: "icon";
font-style: normal;
font-weight: normal;
speak: never;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
/* you can be more comfortable with increased icons size */
/* font-size: 120%; */
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-plus:before { content: '\e800'; } /* '' */
.icon-user:before { content: '\e801'; } /* '' */
.icon-search:before { content: '\e802'; } /* '' */
.icon-mail:before { content: '\e803'; } /* '' */
.icon-link:before { content: '\e804'; } /* '' */
.icon-heart:before { content: '\e805'; } /* '' */
.icon-eye:before { content: '\e806'; } /* '' */
.icon-left-open:before { content: '\e807'; } /* '' */
.icon-right-open:before { content: '\e808'; } /* '' */
.icon-export:before { content: '\e809'; } /* '' */
.icon-spin:before { content: '\e839'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */
.icon-sun:before { content: '\f185'; } /* '' */
.icon-moon:before { content: '\f186'; } /* '' */
.icon-share:before { content: '\f1e0'; } /* '' */
.icon-trash:before { content: '\f1f8'; } /* '' */
.icon-blind:before { content: '\f29d'; } /* '' */
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.animate-spin {
animation: spin 2s infinite linear;
display: inline-block;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
.icon-plus:before { content: '\e800'; } /* '' */
.icon-user:before { content: '\e801'; } /* '' */
.icon-search:before { content: '\e802'; } /* '' */
.icon-mail:before { content: '\e803'; } /* '' */
.icon-link:before { content: '\e804'; } /* '' */
.icon-heart:before { content: '\e805'; } /* '' */
.icon-eye:before { content: '\e806'; } /* '' */
.icon-left-open:before { content: '\e807'; } /* '' */
.icon-right-open:before { content: '\e808'; } /* '' */
.icon-export:before { content: '\e809'; } /* '' */
.icon-refresh:before { content: '\e80a'; } /* '' */
.icon-adjust:before { content: '\e810'; } /* '' */
.icon-spin:before { content: '\e839'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */
.icon-sun:before { content: '\f185'; } /* '' */
.icon-moon:before { content: '\f186'; } /* '' */
.icon-share:before { content: '\f1e0'; } /* '' */
.icon-trash:before { content: '\f1f8'; } /* '' */
.icon-blind:before { content: '\f29d'; } /* '' */
100% {
transform: rotate(359deg);
}
}
.lmhf {
height: 16px;
}

View file

@ -1,28 +1,4 @@
// @use '../node_modules/knacss/sass/knacss.scss';
// NOYAU
@import "../node_modules/knacss/sass/abstracts/variables-sass";
@import "../node_modules/knacss/sass/abstracts/mixins-sass";
@import "../node_modules/knacss/sass/base/reset-base";
@import "../node_modules/knacss/sass/base/reset-accessibility";
@import "../node_modules/knacss/sass/base/reset-forms";
@import "../node_modules/knacss/sass/base/reset-print";
@import "../node_modules/knacss/sass/base/layout";
// UTILITAIRES
@import "../node_modules/knacss/sass/utils/utils-global";
@import "../node_modules/knacss/sass/utils/utils-font-sizes";
@import "../node_modules/knacss/sass/utils/utils-spacers";
@import "../node_modules/knacss/sass/utils/grillade";
// COMPOSANTS (à ajouter au besoin)
// @import "../node_modules/knacss/sass/components/button";
// @import "components/burger";
// @import "../node_modules/knacss/sass/components/checkbox";
@import "../node_modules/knacss/sass/components/radio";
// @import "../node_modules/knacss/sass/components/select";
// @import "components/quote";
@import '../node_modules/knacss/sass/knacss.scss';
// SPÉCIFIQUE AU SITE
@import './fonts';
@ -39,11 +15,14 @@
@import './icons';
@import './list';
@import './box';
@import './loader';
@import './table';
@import './error';
@import './messages.scss';
@import './500';
@import './home';
@import './ajouter-un-album';
@import './collection';
@import './ma-collection-details';
@import './composants';
@import './composants';

View file

@ -23,6 +23,12 @@
background-color: var(--default-color);
}
&:nth-child(4n),
&:nth-child(4n-1)
{
background-color: var(--default-color);
}
&:first-child,
&:nth-child(2) {
border-top: 2px solid var(--border-color);
@ -36,7 +42,6 @@
}
.title {
font-weight: 800;
font-size: 1.4rem;
}

13
sass/loader.scss Normal file
View file

@ -0,0 +1,13 @@
.loader {
display: flex;
flex-direction: column;
align-items: center;
.animation {
background-image: var(--loader-img);
background-repeat: no-repeat;
background-position: center center;
width: 64px;
height: 64px;
}
}

View file

@ -1,4 +1,26 @@
.ma-collection-details {
h1 {
i {
cursor: pointer;
&.icon-trash {
color: $danger-color;
@include transition() {}
&:hover {
color: $danger-color-hl;
}
}
&.icon-refresh {
color: $primary-color;
@include transition() {}
&:hover {
color: $primary-color-hl;
}
}
}
}
.galerie {
display: flex;
flex-wrap: wrap;
@ -23,33 +45,44 @@
.modal {
button.close {
height: 36px;
max-height: 36px;
max-width: 36px;
min-height: 36px;
min-width: 36px;
width: 36px;
height: 42px;
max-height: 42px;
max-width: 42px;
min-height: 42px;
min-width: 42px;
width: 42px;
position: absolute;
background-color: rgba(10,10,10,.6);
background-color: var(--close-background);
right: 12px;
top: 12px;
&::before,
&::after {
background-color: $white;
}
}
.carousel {
display: grid;
grid-template-columns: auto 80vw auto;
z-index: 1;
text-align: center;
img {
max-width: 100%;
max-height: 80vh;
}
}
.navigation {
position: absolute;
top: 50%;
cursor: pointer;
z-index: 10;
&.previous {
left: 12px;
}
&.next {
right: 12px;
}
i {
font-size: 2rem;
font-size: 1rem;
color: $nord4;
@include respond-to("small-up") {
font-size: 2rem;
}
}
}
}

9
sass/messages.scss Normal file
View file

@ -0,0 +1,9 @@
.message {
margin: 8px 0;
padding: 0;
font-size: 0.8rem;
&.error {
color: $error-color-hl;
}
}

View file

@ -9,7 +9,7 @@
justify-content: center;
overflow: hidden;
position: fixed;
z-index: 40;
z-index: 2;
&.is-visible {
display: flex;
@ -84,6 +84,11 @@
width: 1200;
}
&.for-image {
display: initial;
text-align: center;
}
header,
footer {
align-items: center;
@ -116,10 +121,25 @@
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-top: 1px solid var(--border-color);
justify-content: end;
align-items: baseline;
.field {
flex-direction: row;
padding: 6px;
span {
padding-left: 6px;
}
}
.button:not(:last-child) {
margin-right: .5em;
}
}
img {
max-width: 100%;
max-height: 80vh;
}
}
}

View file

@ -1,21 +1,25 @@
.navbar {
min-height: 3.25rem;
min-height: 3.5rem;
background-color: var(--navbar-color);
box-shadow: rgba(216, 222, 233, 0.15) 0px 5px 10px 0px;
color: rgba(0,0,0,.7);
position: fixed;
z-index: 30;
z-index: 1;
top: 0;
right: 0;
left: 0;
@include transition() {}
@include respond-to("medium-up") {
min-height: 3.25rem;
align-items: stretch;
display: flex;
}
&.container {
max-width: 1330px;
margin: 0 auto;
}
.navbar-brand {
align-items: stretch;
display: flex;
@ -33,7 +37,6 @@
word-break: break-word;
color: var(--font-color);
font-size: 2rem;
font-weight: 600;
line-height: 1.125;
margin-left: .5rem !important;
@include transition() {}
@ -54,7 +57,7 @@
position: relative;
width: 3.25rem;
margin-left: auto;
color: rgba(0,0,0,.7);
color: var(--font-color);
@include respond-to("medium-up") {
display: none;
@ -100,7 +103,7 @@
.navbar-item {
line-height: 1.5;
padding: .5rem .75rem;
padding: .5rem;
position: relative;
flex-grow: 0;
flex-shrink: 0;
@ -114,7 +117,6 @@
@include respond-to("medium-up") {
display: flex;
align-items: stretch;
color: rgba(0,0,0,.7);
.navbar-dropdown {
background-color: var(--default-color);
@ -124,17 +126,17 @@
box-shadow: 0 8px 8px rgba(10,10,10,.1);
display: none;
font-size: .875rem;
left: 0;
min-width: 100%;
min-width: 280px;
position: absolute;
top: 100%;
z-index: 20;
}
&:hover {
background-color: var(--default-color);
.navbar-link {
background-color: var(--default-hl-color);
color: rgba(0,0,0,.7);
color: var(--hover-font-color);
}
.navbar-dropdown {
@ -163,6 +165,29 @@
@include respond-to("medium-up") {
display: flex;
align-items: center;
&::after {
border: 3px solid transparent;
border-radius: 2px;
border-right: 0;
border-top: 0;
content: " ";
display: block;
height: .625em;
pointer-events: none;
position: absolute;
top: 50%;
transform: rotate(-45deg);
transform-origin: center;
width: .625em;
border-color: var(--secondary-color);
margin-top: -0.375em;
right: 1.125em;
@include respond-to("medium-up") {
border-color: var(--font-color);
}
}
}
.icon {
@ -173,28 +198,7 @@
width: 1.5rem;
}
&::after {
border: 3px solid transparent;
border-radius: 2px;
border-right: 0;
border-top: 0;
content: " ";
display: block;
height: .625em;
pointer-events: none;
position: absolute;
top: 50%;
transform: rotate(-45deg);
transform-origin: center;
width: .625em;
border-color: var(--secondary-color);
margin-top: -0.375em;
right: 1.125em;
@include respond-to("medium-up") {
border-color: rgba(0,0,0,.7);
}
}
}
.navbar-menu {
@ -252,10 +256,11 @@
padding-bottom: .5rem;
padding-top: .5rem;
.navbar-item {
cursor: pointer;
padding-left: 1.5rem;
padding-right: 1.5rem;
hr {
background-color: var(--font-color);
border: none;
height: 2px;
margin: .5rem 0 0 1.5rem;
}
@include respond-to("medium-up") {
@ -266,17 +271,19 @@
box-shadow: 0 8px 8px rgba(10,10,10,.1);
display: none;
font-size: .875rem;
left: 0;
right: 0;
min-width: 100%;
position: absolute;
top: 100%;
z-index: 20;
.navbar-item {
white-space: nowrap;
padding: .375rem 1rem;
padding-right: 3rem;
hr {
margin: 0.5rem 0;
}
// .navbar-item {
// white-space: nowrap;
// padding-block: .375rem;
// }
}
}
@ -310,4 +317,10 @@
.navbar {
box-shadow: none;
}
}
nav {
ul {
padding-inline: 16px;
}
}

View file

@ -1,25 +1,8 @@
.pagination {
font-size: 1rem;
align-items: center;
display: flex;
text-align: center;
justify-content: space-between;
margin: 0.75rem 0;
.pagination-list {
align-items: center;
display: flex;
text-align: center;
flex-wrap: wrap;
flex-grow: 1;
flex-shrink: 1;
justify-content: flex-end;
@include respond-to("small") {
justify-content: center;
}
.pagination-link {
align-items: center;
border: 1px solid transparent;

23
sass/table.scss Normal file
View file

@ -0,0 +1,23 @@
table {
th,
td {
padding: 0.75rem;
text-align: left;
}
thead {
tr {
border-bottom: 2px solid var(--font-color);
}
}
tbody {
tr {
background-color: var(--default-color);
&:nth-child(2n) {
background-color: var(--bg-alternate-color);
}
}
}
}

View file

@ -3,16 +3,19 @@
min-width: 250px;
max-width: 360px;
position: fixed;
z-index: 31;
z-index: 10;
right: 30px;
top: 30px;
font-size: 17px;
padding: 1.25rem 2.5rem 1.25rem 1.5rem;
background-color: $danger-color;
color: $button-alternate-color;
border-radius: 6px;
&.error {
background-color: $danger-color;
color: $button-alternate-color;
}
&.success {
background-color: $success-color;
color: $button-font-color;

View file

@ -7,24 +7,30 @@ import flash from "connect-flash";
import session from "express-session";
import MongoStore from "connect-mongo";
import config, { env, mongoDbUri, secret } from "./config";
import passportConfig from "./libs/passport";
import config, { env, mongoDbUri, port, secret } from "./config";
import { isXhr } from "./helpers";
import indexRouter from "./routes";
import maCollectionRouter from "./routes/ma-collection";
import wantlistRouter from "./routes/wantlist";
import monCompteRouter from "./routes/mon-compte";
import collectionRouter from "./routes/collection";
import importJobsRouter from "./routes/jobs";
import importAlbumRouterApiV1 from "./routes/api/v1/albums";
import importWantlistRouterApiV1 from "./routes/api/v1/wantlist";
import importSearchRouterApiV1 from "./routes/api/v1/search";
import importMastodonRouterApiV1 from "./routes/api/v1/mastodon";
import importMeRouterApiV1 from "./routes/api/v1/me";
import importContactRouterApiV1 from "./routes/api/v1/contact";
// Mongoose schema init
require("./models/users");
require("./models/albums");
require("./libs/passport")(passport);
passportConfig(passport);
mongoose.set("strictQuery", false);
mongoose
.connect(mongoDbUri, { useNewUrlParser: true, useUnifiedTopology: true })
.catch(() => {
@ -33,7 +39,7 @@ mongoose
const sess = {
cookie: {
maxAge: 86400000,
maxAge: 604800000, // INFO: 7 jours
},
secret,
saveUninitialized: false,
@ -46,10 +52,10 @@ const sess = {
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(flash());
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: false, limit: "50mb" }));
app.use(session(sess));
@ -73,21 +79,19 @@ app.set("views", path.join(__dirname, "../views"));
app.set("view engine", "ejs");
app.use(express.static(path.join(__dirname, "../public")));
app.use(
"/libs/vue",
express.static(path.join(__dirname, "../node_modules/vue/dist"))
);
app.use(
"/libs/axios",
express.static(path.join(__dirname, "../node_modules/axios/dist"))
);
app.use("/", indexRouter);
app.use("/mon-compte", monCompteRouter);
app.use("/ma-collection", maCollectionRouter);
app.use("/ma-liste-de-souhaits", wantlistRouter);
app.use("/collection", collectionRouter);
app.use("/jobs", importJobsRouter);
app.use("/api/v1/albums", importAlbumRouterApiV1);
app.use("/api/v1/wantlist", importWantlistRouterApiV1);
app.use("/api/v1/search", importSearchRouterApiV1);
app.use("/api/v1/mastodon", importMastodonRouterApiV1);
app.use("/api/v1/me", importMeRouterApiV1);
app.use("/api/v1/contact", importContactRouterApiV1);
// Handle 404
app.use((req, res) => {
@ -96,15 +100,22 @@ app.use((req, res) => {
} else {
res.status(404).render("index", {
page: { title: `404: Cette page n'existe pas.` },
errorCode: 404,
viewname: "error",
user: req.user || null,
config,
session: req.session || null,
flashInfo: null,
query: null,
params: null,
error: null,
flash: {
info: req.flash("info"),
error: [
...req.flash("error"),
...(req.session?.flash?.error || []),
],
success: req.flash("success"),
},
query: req.query,
params: req.params,
user: req.user,
config,
getBaseUrl: null,
errorCode: 404,
});
}
});
@ -121,19 +132,28 @@ app.use((error, req, res, next) => {
title: error.title || "500: Oups… le serveur a crashé !",
error,
},
errorCode: error.errorCode || 500,
viewname: "error",
user: req.user || null,
config,
session: req.session || null,
flashInfo: null,
query: null,
params: null,
error: null,
flash: {
info: req.flash("info"),
error: [
...req.flash("error"),
...(req.session?.flash?.error || []),
],
success: req.flash("success"),
},
query: req.query,
params: req.params,
user: req.user,
config,
getBaseUrl: null,
errorCode: error.errorCode || 500,
});
next();
}
});
console.log(`Server listening on port ${port}!`);
export default app;

View file

@ -8,4 +8,25 @@ module.exports = {
matomoUrl: process.env.MATOMO_URL || "",
matomoId: process.env.MATOMO_ID || "",
siteName: process.env.SITE_NAME || "MusicTopus",
awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID,
awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3BaseFolder: process.env.S3_BASEFOLDER || "dev",
s3Bucket: process.env.S3_BUCKET || "musictopus",
s3Endpoint: process.env.S3_ENDPOINT || "s3.fr-par.scw.cloud",
s3Signature: process.env.S3_SIGNATURE || "s3v4",
jobsHeaderKey: process.env.JOBS_HEADER_KEY || "musictopus",
jobsHeaderValue:
process.env.JOBS_HEADER_VALUE || "ooYee9xok7eigo2shiePohyoGh1eepew",
registrationOpen:
(process.env.REGISTRATION_OPEN || "true").toLowerCase() === "true",
mailMethod: process.env.MAIL_METHOD || "formspree",
smtpConfig: {
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
},
mailTo: process.env.MAIL_TO,
};

View file

@ -5,13 +5,25 @@ import { discogsToken } from "../config";
export const getBaseUrl = (req) => `${req.protocol}://${req.get("host")}`;
export const searchSong = async (q) => {
export const searchSong = async (q, format, year, country) => {
const dis = new Discogs({ userToken: discogsToken }).database();
const res = await dis.search({
const params = {
q,
type: "release",
});
};
if (format) {
params.format = format;
}
if (year) {
params.year = year;
}
if (country) {
params.country = country;
}
const res = await dis.search(params);
return res;
};
@ -21,6 +33,11 @@ export const getAlbumDetails = async (id) => {
const res = await dis.getRelease(id);
if (res.released && res.released.includes("-00")) {
const [year, month] = res.released.split("-");
res.released = new Date(year, parseInt(month, 10) - 1);
}
return res;
};
@ -36,3 +53,28 @@ export const isXhr = (req) => {
return is;
};
/**
* Méthode permettant de récupérer les éléments distincts d'une collection
* @param {Object} model
* @param {String} field
* @param {import("mongoose").ObjectId} user
* @returns
*/
export const getAllDistincts = async (model, field, user) => {
const distincts = await model
.find(
{
User: user,
},
[],
{
sort: {
[field]: 1,
},
}
)
.distinct(field);
return distincts;
};

78
src/libs/aws.js Normal file
View file

@ -0,0 +1,78 @@
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import fs from "fs";
import path from "path";
import axios from "axios";
import { v4 as uuid } from "uuid";
import {
awsAccessKeyId,
awsSecretAccessKey,
s3BaseFolder,
s3Endpoint,
s3Bucket,
// s3Signature,
} from "../config";
/**
* Fonction permettant de stocker un fichier local sur S3
* @param {String} filename
* @param {String} file
* @param {Boolean} deleteFile
*
* @return {String}
*/
export const uploadFromFile = async (filename, file, deleteFile = false) => {
const data = await fs.readFileSync(file);
const base64data = Buffer.from(data, "binary");
const dest = path.join(s3BaseFolder, filename);
const multipartUpload = new Upload({
client: new S3Client({
region: "fr-par",
endpoint: `https://${s3Endpoint}`,
credentials: {
accessKeyId: awsAccessKeyId,
secretAccessKey: awsSecretAccessKey,
},
}),
params: {
Bucket: s3Bucket,
Key: dest,
Body: base64data,
ACL: "public-read",
endpoint: s3Endpoint,
},
});
await multipartUpload.done();
if (deleteFile) {
fs.unlinkSync(file);
}
return `https://${s3Bucket}.${s3Endpoint}/${dest}`;
};
/**
* Fonction permettant de stocker un fichier provenant d'une URL sur S3
* @param {String} url
*
* @return {String}
*/
export const uploadFromUrl = async (url) => {
const filename = `${uuid()}.jpg`;
const file = `/tmp/${filename}`;
const { data } = await axios.get(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
},
responseType: "arraybuffer",
});
fs.writeFileSync(file, data);
return uploadFromFile(filename, file, true);
};

View file

@ -34,6 +34,23 @@ export const sendResponse = (req, res, data) => {
}
};
export const setHashTags = (arr) => {
let hashTags = "";
for (let i = 0; i < arr.length; i += 1) {
let currentHash = `${hashTags ? ", " : ""} #`;
const currentItem = arr[i].split(" ");
for (let j = 0; j < currentItem.length; j += 1) {
currentHash +=
currentItem[j].toLowerCase().charAt(0).toUpperCase() +
currentItem[j].toLowerCase().slice(1);
}
hashTags += currentHash;
}
return hashTags;
};
export default (res, page) => {
res.status(200).render("index", page.render());
};

View file

@ -1,11 +1,13 @@
/* eslint-disable func-names */
const mongoose = require("mongoose");
const LocalStrategy = require("passport-local").Strategy;
const { BasicStrategy } = require("passport-http");
import { Strategy as LocalStrategy } from "passport-local";
import { BasicStrategy } from "passport-http";
import { Strategy as CustomStrategy } from "passport-custom";
const Users = mongoose.model("Users");
import Users from "../models/users";
module.exports = function (passport) {
import { jobsHeaderKey, jobsHeaderValue } from "../config";
export default (passport) => {
passport.serializeUser((user, done) => {
done(null, user);
});
@ -55,4 +57,17 @@ module.exports = function (passport) {
.catch(done);
})
);
passport.use(
"jobs",
new CustomStrategy((req, next) => {
const apiKey = req.headers[jobsHeaderKey];
if (apiKey === jobsHeaderValue) {
return next(null, {
username: "jobs",
});
}
return next(null, false, "Oops! Identifiants incorrects");
})
);
};

File diff suppressed because it is too large Load diff

456
src/middleware/Export.js Normal file
View file

@ -0,0 +1,456 @@
import { utcToZonedTime } from "date-fns-tz";
import setHours from "date-fns/setHours";
import xl from "excel4node";
class Export {
/**
* Méthode permettant de remplacer certains cartactères par leur équivalents html
* @param {String} str
*
* @return {String}
*/
static replaceSpecialChars(str) {
if (!str) {
return "";
}
let final = str.toString();
const find = ["&", "<", ">"];
const replace = ["&amp;", "&lt;", "&gt;"];
for (let i = 0; i < find.length; i += 1) {
final = final.replace(new RegExp(find[i], "g"), replace[i]);
}
return final;
}
/**
* Méthode permettant de convertir les rows en csv
* @param {Array} rows
*
* @return {string}
*/
static async convertToCsv(rows) {
let data =
"Artiste;Titre;Genre;Styles;Pays;Année;Date de sortie;Format\n\r";
for (let i = 0; i < rows.length; i += 1) {
const {
artists_sort,
title,
genres,
styles,
country,
year,
released,
formats,
} = rows[i];
let format = "";
for (let j = 0; j < formats.length; j += 1) {
format += `${format !== "" ? ", " : ""}${formats[j].name}`;
}
data += `${artists_sort};${title};${genres.join()};${styles.join()};${country};${year};${released};${format}\n\r`;
}
return data;
}
/**
* Méthode permettant de convertir les rows en fichier xls
* @param {Array} rows
*
* @return {Object}
*/
static async convertToXls(rows) {
const wb = new xl.Workbook();
const ws = wb.addWorksheet("MusicTopus");
const headerStyle = wb.createStyle({
font: {
color: "#FFFFFF",
size: 11,
},
fill: {
type: "pattern",
patternType: "solid",
bgColor: "#595959",
fgColor: "#595959",
},
});
const style = wb.createStyle({
font: {
color: "#000000",
size: 11,
},
numberFormat: "0000",
});
const header = [
"Artiste",
"Titre",
"Genre",
"Styles",
"Pays",
"Année",
"Date de sortie",
"Format",
];
for (let i = 0; i < header.length; i += 1) {
ws.cell(1, i + 1)
.string(header[i])
.style(headerStyle);
}
for (let i = 0; i < rows.length; i += 1) {
const currentRow = i + 2;
const {
artists_sort,
title,
genres,
styles,
country,
year,
released,
formats,
} = rows[i];
let format = "";
for (let j = 0; j < formats.length; j += 1) {
format += `${format !== "" ? ", " : ""}${formats[j].name}`;
}
ws.cell(currentRow, 1).string(artists_sort).style(style);
ws.cell(currentRow, 2).string(title).style(style);
ws.cell(currentRow, 3).string(genres.join(", ")).style(style);
ws.cell(currentRow, 4).string(styles.join(", ")).style(style);
if (country) {
ws.cell(currentRow, 5).string(country).style(style);
}
if (year) {
ws.cell(currentRow, 6).number(year).style(style);
}
if (released) {
ws.cell(currentRow, 7)
.date(
setHours(utcToZonedTime(released, "Europe/Paris"), 12)
)
.style({ numberFormat: "dd/mm/yyyy" });
}
ws.cell(currentRow, 8).string(format).style(style);
}
return wb;
}
/**
* Méthode permettant de convertir les rows en csv pour importer dans MusicTopus
* @param {Array} rows
*
* @return {string}
*/
static async convertToXml(rows) {
let data = '<?xml version="1.0" encoding="UTF-8"?>\n\r<albums>';
for (let i = 0; i < rows.length; i += 1) {
const {
discogsId,
year,
released,
uri,
artists,
artists_sort,
labels,
series,
companies,
formats,
title,
country,
notes,
identifiers,
videos,
genres,
styles,
tracklist,
extraartists,
images,
thumb,
} = rows[i];
let artistsList = "";
let labelList = "";
let serieList = "";
let companiesList = "";
let formatsList = "";
let identifiersList = "";
let videosList = "";
let genresList = "";
let stylesList = "";
let tracklistList = "";
let extraartistsList = "";
let imagesList = "";
for (let j = 0; j < artists.length; j += 1) {
artistsList += `<artist>
<name>${Export.replaceSpecialChars(artists[j].name)}</name>
<anv>${Export.replaceSpecialChars(artists[j].anv)}</anv>
<join>${Export.replaceSpecialChars(artists[j].join)}</join>
<role>${Export.replaceSpecialChars(artists[j].role)}</role>
<tracks>${Export.replaceSpecialChars(artists[j].tracks)}</tracks>
<id>${Export.replaceSpecialChars(artists[j].id)}</id>
<resource_url>${Export.replaceSpecialChars(
artists[j].resource_url
)}</resource_url>
<thumbnail_url>${Export.replaceSpecialChars(
artists[j].thumbnail_url
)}</thumbnail_url>
</artist>`;
}
for (let j = 0; j < labels.length; j += 1) {
labelList += `<label>
<name>${Export.replaceSpecialChars(labels[j].name)}</name>
<catno>${Export.replaceSpecialChars(labels[j].catno)}</catno>
<entity_type>${Export.replaceSpecialChars(
labels[j].entity_type
)}</entity_type>
<entity_type_name>${Export.replaceSpecialChars(
labels[j].entity_type
)}</entity_type_name>
<id>${Export.replaceSpecialChars(labels[j].id)}</id>
<resource_url>${Export.replaceSpecialChars(
labels[j].resource_url
)}</resource_url>
<thumbnail_url>${Export.replaceSpecialChars(
labels[j].thumbnail_url
)}</thumbnail_url>
</label>
`;
}
for (let j = 0; j < series.length; j += 1) {
serieList += `<serie>
<name>${Export.replaceSpecialChars(series[j].name)}</name>
<catno>${Export.replaceSpecialChars(series[j].catno)}</catno>
<entity_type>${Export.replaceSpecialChars(
series[j].entity_type
)}</entity_type>
<entity_type_name>${Export.replaceSpecialChars(
series[j].entity_type_name
)}</entity_type_name>
<id>${Export.replaceSpecialChars(series[j].id)}</id>
<resource_url>${Export.replaceSpecialChars(
series[j].resource_url
)}</resource_url>
<thumbnail_url>${Export.replaceSpecialChars(
series[j].thumbnail_url
)}</thumbnail_url>
</serie>
`;
}
for (let j = 0; j < companies.length; j += 1) {
companiesList += `<company>
<name>${Export.replaceSpecialChars(companies[j].name)}</name>
<catno>${Export.replaceSpecialChars(companies[j].catno)}</catno>
<entity_type>${Export.replaceSpecialChars(
companies[j].entity_type
)}</entity_type>
<entity_type_name>${Export.replaceSpecialChars(
companies[j].entity_type_name
)}</entity_type_name>
<id>${Export.replaceSpecialChars(companies[j].id)}</id>
<resource_url>${Export.replaceSpecialChars(
companies[j].resource_url
)}</resource_url>
<thumbnail_url>${Export.replaceSpecialChars(
companies[j].thumbnail_url
)}</thumbnail_url>
</company>
`;
}
for (let j = 0; j < formats.length; j += 1) {
let descriptions = "";
if (formats[j].descriptions) {
for (
let k = 0;
k < formats[j].descriptions.length;
k += 1
) {
descriptions += `<description>${formats[j].descriptions[k]}</description>
`;
}
}
formatsList += `<format>
<name>${Export.replaceSpecialChars(formats[j].name)}</name>
<qte>${Export.replaceSpecialChars(formats[j].qty)}</qte>
<text>${Export.replaceSpecialChars(formats[j].text)}</text>
<descriptions>
${descriptions}
</descriptions>
</format>
`;
}
for (let j = 0; j < identifiers.length; j += 1) {
identifiersList += `<identifier>
<type>${Export.replaceSpecialChars(identifiers[j].type)}</type>
<value>${Export.replaceSpecialChars(identifiers[j].value)}</value>
<description>${Export.replaceSpecialChars(
identifiers[j].description
)}</description>
</identifier>
`;
}
for (let j = 0; j < videos.length; j += 1) {
videosList += `<video embed="${videos[j].embed}">
<uri>${Export.replaceSpecialChars(videos[j].uri)}</uri>
<title>${Export.replaceSpecialChars(videos[j].title)}</title>
<description>${Export.replaceSpecialChars(
videos[j].description
)}</description>
<duration>${Export.replaceSpecialChars(
videos[j].duration
)}</duration>
</video>
`;
}
for (let j = 0; j < genres.length; j += 1) {
genresList += `<genre>${Export.replaceSpecialChars(
genres[j]
)}</genre>
`;
}
for (let j = 0; j < styles.length; j += 1) {
stylesList += `<style>${Export.replaceSpecialChars(
styles[j]
)}</style>
`;
}
for (let j = 0; j < tracklist.length; j += 1) {
tracklistList += `<tracklist position="${
tracklist[j].position
}" type="${tracklist[j].type_}" duration="${
tracklist[j].duration
}">
${Export.replaceSpecialChars(tracklist[j].title)}
</tracklist>
`;
}
for (let j = 0; j < extraartists.length; j += 1) {
extraartistsList += `<extraartist>
<name>${Export.replaceSpecialChars(extraartists[j].name)}</name>
<anv>${Export.replaceSpecialChars(extraartists[j].anv)}</anv>
<join>${Export.replaceSpecialChars(extraartists[j].join)}</join>
<role>${Export.replaceSpecialChars(extraartists[j].role)}</role>
<tracks>${Export.replaceSpecialChars(
extraartists[j].tracks
)}</tracks>
<id>${Export.replaceSpecialChars(extraartists[j].id)}</id>
<resource_url>${Export.replaceSpecialChars(
extraartists[j].resource_url
)}</resource_url>
<thumbnail_url>${Export.replaceSpecialChars(
extraartists[j].thumbnail_url
)}</thumbnail_url>
</extraartist>
`;
}
for (let j = 0; j < images.length; j += 1) {
imagesList += `<image type="${images[j].type}" width="${
images[j].width
}" height="${images[j].height}">
<uri>${Export.replaceSpecialChars(images[j].uri)}</uri>
<resource_url>${Export.replaceSpecialChars(
images[j].resource_url
)}</resource_url>
<uri150>${Export.replaceSpecialChars(
images[j].resource_url
)}</uri150>
</image>
`;
}
data += `
<album>
<discogId>${discogsId}</discogId>
<title>${Export.replaceSpecialChars(title)}</title>
<artists_sort>${Export.replaceSpecialChars(artists_sort)}</artists_sort>
<artists>
${artistsList}
</artists>
<year>${year}</year>
<country>${Export.replaceSpecialChars(country)}</country>
<released>${released}</released>
<uri>${uri}</uri>
<thumb>${thumb}</thumb>
<labels>
${labelList}
</labels>
<series>
${serieList}
</series>
<companies>
${companiesList}
</companies>
<formats>
${formatsList}
</formats>
<notes>${Export.replaceSpecialChars(notes)}</notes>
<identifiers>
${identifiersList}
</identifiers>
<videos>
${videosList}
</videos>
<genres>
${genresList}
</genres>
<styles>
${stylesList}
</styles>
<tracklist>
${tracklistList}
</tracklist>
<extraartists>
${extraartistsList}
</extraartists>
<images>
${imagesList}
</images>
</album>`;
}
return `${data}</albums>`;
}
/**
* Méthode permettant de convertir les rows en csv pour importer dans MusicTopus
* @param {Array} rows
*
* @return {string}
*/
static async convertToMusicTopus(rows) {
let data = "itemId;createdAt;updatedAt\n\r";
for (let i = 0; i < rows.length; i += 1) {
const { discogsId, createdAt, updatedAt } = rows[i];
data += `${discogsId};${createdAt};${updatedAt}\n\r`;
}
data += "v1.0";
return data;
}
}
export default Export;

176
src/middleware/Jobs.js Normal file
View file

@ -0,0 +1,176 @@
/* eslint-disable no-await-in-loop */
import ErrorEvent from "../libs/error";
import { uploadFromUrl } from "../libs/aws";
import { getAlbumDetails } from "../helpers";
import JobsModel from "../models/jobs";
import AlbumsModel from "../models/albums";
import WantListModel from "../models/wantlist";
class Jobs {
/**
* Méthode permettant de télécharger toute les images d'un album
* @param {ObjectId} itemId
*/
static async importAlbumAssets(itemId) {
const album = await AlbumsModel.findById(itemId);
if (!album) {
throw new ErrorEvent(
404,
"Item non trouvé",
`L'album avec l'id ${itemId} n'existe plus dans la collection`
);
}
const item = await getAlbumDetails(album.discogsId);
if (!item) {
throw new ErrorEvent(
404,
"Erreur de communication",
"Erreur lors de la récupération des informations sur Discogs"
);
}
if (item.thumb) {
album.thumb = await uploadFromUrl(item.thumb);
album.thumbType = "local";
}
const { images } = item;
if (images && images.length > 0) {
for (let i = 0; i < images.length; i += 1) {
images[i].uri150 = await uploadFromUrl(images[i].uri150);
images[i].uri = await uploadFromUrl(images[i].uri);
}
}
album.images = images;
await album.save();
return true;
}
/**
* Méthode permettant de télécharger toute les images d'un album
* @param {ObjectId} itemId
*/
static async importAlbumForWantListAssets(itemId) {
const album = await WantListModel.findById(itemId);
if (!album) {
throw new ErrorEvent(
404,
"Item non trouvé",
`L'album avec l'id ${itemId} n'existe plus dans la collection`
);
}
const item = await getAlbumDetails(album.discogsId);
if (!item) {
throw new ErrorEvent(
404,
"Erreur de communication",
"Erreur lors de la récupération des informations sur Discogs"
);
}
if (item.thumb) {
album.thumb = await uploadFromUrl(item.thumb);
album.thumbType = "local";
}
const { images } = item;
if (images && images.length > 0) {
for (let i = 0; i < images.length; i += 1) {
images[i].uri150 = await uploadFromUrl(images[i].uri150);
images[i].uri = await uploadFromUrl(images[i].uri);
}
}
album.images = images;
await album.save();
return true;
}
/**
* Point d'entrée
* @param {String} state
*
* @return {Object}
*/
async run(state = "NEW") {
const job = await JobsModel.findOne({
state,
tries: {
$lte: 5,
},
});
if (!job) {
return { message: "All jobs done" };
}
job.state = "IN-PROGRESS";
await job.save();
try {
switch (job.model) {
case "Albums":
await Jobs.importAlbumAssets(job.id);
break;
case "WantList":
await Jobs.importAlbumForWantListAssets(job.id);
break;
default:
throw new ErrorEvent(
500,
"Job inconnu",
`Le job avec l'id ${job._id} n'est pas un job valide`
);
}
job.state = "SUCCESS";
await job.save();
return this.run(state);
} catch (err) {
job.state = "ERROR";
job.lastTry = new Date();
job.lastErrorMessage = err.message;
job.tries += 1;
await job.save();
throw err;
}
}
/**
* Méthode permettant de créer tous les jobs
*
* @return {Object}
*/
static async populate() {
const albums = await AlbumsModel.find();
for (let i = 0; i < albums.length; i += 1) {
const jobData = {
model: "Albums",
id: albums[i]._id,
};
const job = new JobsModel(jobData);
await job.save();
}
return { message: `${albums.length} jobs ajouté à la file d'attente` };
}
}
export default Jobs;

View file

@ -1,35 +1,66 @@
import Joi from "joi";
import UsersModel from "../models/users";
import AlbumsModel from "../models/albums";
import WantlistModel from "../models/wantlist";
import Pages from "./Pages";
/**
* Classe permettant la gestion de l'utilisateur connecté
*/
class Me {
constructor(req) {
this.req = req;
}
class Me extends Pages {
/**
* Méthode permettant de modifier le profil d'un utilisateur
* @return {Object}
*/
async patchMe() {
const { body, user } = this.req;
const { body } = this.req;
const { _id } = this.req.user;
const schema = Joi.object({
pagination: Joi.number(),
isPublicCollection: Joi.boolean(),
oldPassword: Joi.string(),
password: Joi.string(),
passwordConfirm: Joi.ref("password"),
mastodon: {
publish: Joi.boolean(),
url: Joi.string().uri().allow(null, ""),
token: Joi.string().allow(null, ""),
message: Joi.string().allow(null, ""),
wantlist: Joi.string().allow(null, ""),
},
});
const value = await schema.validateAsync(body);
const update = await UsersModel.findByIdAndUpdate(
user._id,
{ $set: value },
{ new: true }
);
const user = await UsersModel.findById(_id);
if (value.oldPassword) {
if (!user.validPassword(value.oldPassword)) {
throw new Error("Votre ancien mot de passe n'est pas valide");
}
}
if (value.mastodon !== undefined) {
user.mastodon = value.mastodon;
}
if (value.password) {
user.salt = value.password;
}
if (value.pagination) {
user.pagination = value.pagination;
}
if (value.isPublicCollection !== undefined) {
user.isPublicCollection = value.isPublicCollection;
}
user.save();
await new Promise((resolve, reject) => {
this.req.login(update, (err) => {
this.req.login(user, (err) => {
if (err) {
return reject(err);
}
@ -38,7 +69,24 @@ class Me {
});
});
return update;
return user;
}
/**
* Méthode permettant de supprimer un utilisateur et toutes ses données
*
* @return {Object}
*/
async deleteMe() {
const { _id } = this.req.user;
await AlbumsModel.deleteMany({ User: _id });
await WantlistModel.deleteMany({ User: _id });
await UsersModel.deleteOne({ _id });
return {
deleted: true,
};
}
}

View file

@ -52,21 +52,20 @@ class Pages {
*/
render() {
this.pageContent.session = this.req.session;
this.pageContent.flashInfo = this.req.flash("info");
this.pageContent.error = this.req.flash("error") || null;
this.pageContent.flash = {
info: this.req.flash("info"),
error: [
...this.req.flash("error"),
...(this.req.session?.flash?.error || []),
],
success: this.req.flash("success"),
};
this.pageContent.query = this.req.query;
this.pageContent.params = this.req.params;
this.pageContent.user = this.user;
this.pageContent.config = config;
this.pageContent.getBaseUrl = getBaseUrl(this.req);
if (this.req.session.flash && this.req.session.flash.error) {
// eslint-disable-next-line prefer-destructuring
this.pageContent.page.failureFlash =
this.req.session.flash.error[0];
this.req.session.flash = null;
}
return this.pageContent;
}
}

690
src/middleware/Wantlist.js Normal file
View file

@ -0,0 +1,690 @@
import { format as formatDate } from "date-fns";
import fs from "fs";
import Mastodon from "mastodon";
import { v4 } from "uuid";
import axios from "axios";
import Pages from "./Pages";
import Export from "./Export";
import WantListModel from "../models/wantlist";
import JobsModel from "../models/jobs";
import UsersModel from "../models/users";
import ErrorEvent from "../libs/error";
import { getAlbumDetails, getAllDistincts } from "../helpers";
import { setHashTags } from "../libs/format";
/**
* Classe permettant la gestion da la liste de souhaits d'un utilisateur
*/
class Wantlist extends Pages {
/**
* Méthode permettant d'ajouter un album dans une collection
* @param {Object} req
* @return {Object}
*/
static async postAddOne(req) {
const { body, user } = req;
const { share, discogsId } = body;
let albumDetails = body.album;
if (discogsId) {
albumDetails = await getAlbumDetails(discogsId);
body.id = discogsId;
}
if (!albumDetails) {
throw new ErrorEvent(406, "Aucun album à ajouter");
}
const data = {
...albumDetails,
discogsId: albumDetails.id,
User: user._id,
};
// eslint-disable-next-line no-nested-ternary
data.released = data.released
? typeof data.released === "string"
? new Date(data.released.replace("-00", "-01"))
: data.released
: null;
delete data.id;
const album = new WantListModel(data);
await album.save();
const jobData = {
model: "WantList",
id: album._id,
};
const job = new JobsModel(jobData);
job.save();
try {
const User = await UsersModel.findOne({ _id: user._id });
const { mastodon: mastodonConfig } = User;
const { publish, token, url, wantlist } = mastodonConfig;
if (share && publish && url && token) {
const M = new Mastodon({
access_token: token,
api_url: url,
});
const video =
data.videos && data.videos.length > 0
? data.videos[0].uri
: "";
const status = `${(
wantlist ||
"Je viens d'ajouter {artist} - {album} à ma liste de souhaits !"
)
.replaceAll("{artist}", data.artists[0].name)
.replaceAll("{format}", data.formats[0].name)
.replaceAll("{genres}", data.genres.join(", "))
.replaceAll("{#genres}", setHashTags(data.genres))
.replaceAll("{styles}", data.styles.join(", "))
.replaceAll("{#styles}", setHashTags(data.styles))
.replaceAll("{year}", data.year)
.replaceAll("{video}", video)
.replaceAll("{album}", data.title)}
Publié automatiquement via #musictopus`;
const media_ids = [];
if (data.images.length > 0) {
for (let i = 0; i < data.images.length; i += 1) {
if (media_ids.length === 4) {
break;
}
const filename = `${v4()}.jpg`;
const file = `/tmp/${filename}`;
// eslint-disable-next-line no-await-in-loop
const { data: buff } = await axios.get(
data.images[i].uri,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
},
responseType: "arraybuffer",
}
);
fs.writeFileSync(file, buff);
// eslint-disable-next-line no-await-in-loop
const { data: media } = await M.post("media", {
file: fs.createReadStream(file),
});
const { id } = media;
media_ids.push(id);
fs.unlinkSync(file);
}
}
await M.post("statuses", { status, media_ids });
}
} catch (err) {
throw new ErrorEvent(
500,
"Mastodon",
"Album ajouté à votre collection mais impossible de publier sur Mastodon"
);
}
return album;
}
constructor(req, viewname) {
super(req, viewname);
this.colors = [
"#2e3440",
"#d8dee9",
"#8fbcbb",
"#5e81ac",
"#d08770",
"#bf616a",
"#ebcb8b",
"#a3be8c",
"#b48ead",
];
this.setPageContent("action", "wantlist");
}
/**
* Méthode permettant de récupérer la liste des albums d'une collection
* @return {Object}
*/
async getAll() {
const {
page,
exportFormat = "json",
sort = "artists_sort",
order = "asc",
artist,
format,
year,
genre,
style,
userId: collectionUserId,
discogsIds,
discogsId,
} = this.req.query;
const limit = this.req.user?.pagination || 16;
let userId = this.req.user?._id;
const where = {};
if (artist) {
where["artists.name"] = artist;
}
if (format) {
where["formats.name"] = format;
}
if (year) {
where.year = year;
}
if (genre) {
where.genres = genre;
}
if (style) {
where.styles = style;
}
if (!this.req.user && !collectionUserId) {
throw new ErrorEvent(
401,
"Collection",
"Cette collection n'est pas publique"
);
}
if (collectionUserId) {
const userIsSharingCollection = await UsersModel.findById(
collectionUserId
);
if (
!userIsSharingCollection ||
!userIsSharingCollection.isPublicCollection
) {
throw new ErrorEvent(
401,
"Collection",
"Cette collection n'est pas publique"
);
}
userId = userIsSharingCollection._id;
}
if (discogsIds) {
where.discogsId = { $in: discogsIds };
}
if (discogsId) {
where.discogsId = Number(discogsId);
}
const count = await WantListModel.count({
User: userId,
...where,
});
let params = {
sort: {
[sort]: order.toLowerCase() === "asc" ? 1 : -1,
},
};
if (exportFormat === "json" && page && limit) {
const skip = (page - 1) * limit;
params = {
...params,
skip,
limit,
};
}
const rows = await WantListModel.find(
{
User: userId,
...where,
},
[],
params
);
switch (exportFormat) {
case "csv":
return Export.convertToCsv(rows);
case "xls":
return Export.convertToXls(rows);
case "xml":
return Export.convertToXml(rows);
case "musictopus":
return Export.convertToMusicTopus(rows);
case "json":
default:
return {
rows,
limit,
count,
};
}
}
/**
* Méthode permettant de récupérer le détails d'un album
*
* @return {Object}
*/
async getOne() {
const { itemId: _id } = this.req.params;
const { _id: User } = this.req.user;
const album = await WantListModel.findOne({
_id,
User,
});
return {
...album.toJSON(),
released: album.released
? formatDate(album.released, "MM/dd/yyyy")
: null,
};
}
/**
* Méthode permettant de mettre à jour un album
*
* @return {Object}
*/
async patchOne() {
const { itemId: _id } = this.req.params;
const { _id: User } = this.req.user;
const query = {
_id,
User,
};
const album = await WantListModel.findOne(query);
if (!album) {
throw new ErrorEvent(
404,
"Mise à jour",
"Impossible de trouver cet album"
);
}
const values = await getAlbumDetails(album.discogsId);
await WantListModel.findOneAndUpdate(query, values, { new: true });
return this.getOne();
}
/**
* Méthode permettant de supprimer un élément d'une collection
* @return {Boolean}
*/
async deleteOne() {
const res = await WantListModel.findOneAndDelete({
User: this.req.user._id,
_id: this.req.params.itemId,
});
if (res) {
return true;
}
throw new ErrorEvent(
404,
"Suppression",
"Impossible de trouver cet album"
);
}
async shareOne() {
const { message: status } = this.req.body;
const { itemId: _id } = this.req.params;
const { _id: User } = this.req.user;
const query = {
_id,
User,
};
const album = await WantListModel.findOne(query);
if (!album) {
throw new ErrorEvent(
404,
"Mise à jour",
"Impossible de trouver cet album"
);
}
const { mastodon: mastodonConfig } = this.req.user;
const { publish, token, url } = mastodonConfig;
if (publish && url && token) {
const M = new Mastodon({
access_token: token,
api_url: url,
});
const media_ids = [];
if (album.images.length > 0) {
for (let i = 0; i < album.images.length; i += 1) {
if (media_ids.length === 4) {
break;
}
const filename = `${v4()}.jpg`;
const file = `/tmp/${filename}`;
// eslint-disable-next-line no-await-in-loop
const { data: buff } = await axios.get(
album.images[i].uri,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
},
responseType: "arraybuffer",
}
);
fs.writeFileSync(file, buff);
// eslint-disable-next-line no-await-in-loop
const { data: media } = await M.post("media", {
file: fs.createReadStream(file),
});
const { id } = media;
media_ids.push(id);
fs.unlinkSync(file);
}
}
await M.post("statuses", { status, media_ids });
} else {
throw new ErrorEvent(
406,
`Vous n'avez pas configuré vos options de partage sur votre compte`
);
}
return true;
}
/**
* Méthode permettant de créer la page "ma-collection"
*/
async loadMyCollection() {
const artists = await getAllDistincts(
WantListModel,
"artists.name",
this.req.user._id
);
const formats = await getAllDistincts(
WantListModel,
"formats.name",
this.req.user._id
);
const years = await getAllDistincts(
WantListModel,
"year",
this.req.user._id
);
const genres = await getAllDistincts(
WantListModel,
"genres",
this.req.user._id
);
const styles = await getAllDistincts(
WantListModel,
"styles",
this.req.user._id
);
this.setPageContent("artists", artists);
this.setPageContent("formats", formats);
this.setPageContent("years", years);
this.setPageContent("genres", genres);
this.setPageContent("styles", styles);
this.setPageTitle("Ma collection");
}
/**
* Méthode permettant d'afficher le détails d'un album
*/
async loadItem() {
const item = await this.getOne();
this.setPageContent("item", item);
this.setPageTitle(
`Détails de l'album ${item.title} de ${item.artists_sort}`
);
}
/**
* Méthode permettant de choisir un album de manière aléatoire dans la collection d'un utilisateur
*/
async onAir() {
const { _id: User } = this.req.user;
const count = await WantListModel.count({
User,
});
const items = await WantListModel.find(
{
User,
},
[],
{
skip: Math.floor(Math.random() * (count + 1)),
limit: 1,
}
);
this.req.params.itemId = items[0]._id;
await this.loadItem();
}
/**
* Méthode permettant d'afficher des statistiques au sujet de ma collection
*/
async statistics() {
const { _id: User } = this.req.user;
const top = {};
const byGenres = {};
const byStyles = {};
const byFormats = {};
const top10 = [];
let byStyles10 = [];
const max = this.colors.length - 1;
const colorsCount = this.colors.length;
const albums = await WantListModel.find({
User,
artists: { $exists: true, $not: { $size: 0 } },
});
for (let i = 0; i < albums.length; i += 1) {
const currentFormats = [];
const { artists, genres, styles, formats } = albums[i];
// INFO: On regroupe les artistes par nom pour en faire le top10
for (let j = 0; j < artists.length; j += 1) {
const { name } = artists[j];
if (!top[name]) {
top[name] = {
name,
count: 0,
};
}
top[name].count += 1;
}
// INFO: On regroupe les genres
for (let j = 0; j < genres.length; j += 1) {
const name = genres[j];
if (!byGenres[name]) {
byGenres[name] = {
name,
count: 0,
color: this.colors[
Object.keys(byGenres).length % colorsCount
],
};
}
byGenres[name].count += 1;
}
// INFO: On regroupe les styles
for (let j = 0; j < styles.length; j += 1) {
const name = styles[j];
if (!byStyles[name]) {
byStyles[name] = {
name,
count: 0,
color: this.colors[
Object.keys(byStyles).length % colorsCount
],
};
}
byStyles[name].count += 1;
}
// INFO: On regroupe les formats
for (let j = 0; j < formats.length; j += 1) {
const { name } = formats[j];
// INFO: On évite qu'un album avec 2 vinyles soit compté 2x
if (!currentFormats.includes(name)) {
if (!byFormats[name]) {
byFormats[name] = {
name,
count: 0,
color: this.colors[
Object.keys(byFormats).length % colorsCount
],
};
}
byFormats[name].count += 1;
currentFormats.push(name);
}
}
}
// INFO: On convertit le top en tableau
Object.keys(top).forEach((index) => {
top10.push(top[index]);
});
// INFO: On convertit les styles en tableau
Object.keys(byStyles).forEach((index) => {
byStyles10.push(byStyles[index]);
});
// INFO: On ordonne les artistes par quantité d'albums
top10.sort((a, b) => (a.count > b.count ? -1 : 1));
// INFO: On ordonne les styles par quantité
byStyles10.sort((a, b) => (a.count > b.count ? -1 : 1));
const tmp = [];
// INFO: On recupère le top N des styles et on mets le reste dans le label "autre"
for (let i = 0; i < byStyles10.length; i += 1) {
if (i < max) {
tmp.push({
...byStyles10[i],
color: this.colors[max - i],
});
} else if (i === max) {
tmp.push({
name: "Autre",
count: 0,
color: this.colors[0],
});
tmp[max].count += byStyles10[i].count;
} else {
tmp[max].count += byStyles10[i].count;
}
}
byStyles10 = tmp;
this.setPageTitle("Mes statistiques");
this.setPageContent("top10", top10.splice(0, 10));
this.setPageContent("byGenres", byGenres);
this.setPageContent("byStyles", byStyles10);
this.setPageContent("byFormats", byFormats);
}
/**
* Méthode permettant de créer la page "collection/:userId"
*/
async loadPublicCollection() {
const { userId } = this.req.params;
const user = await UsersModel.findById(userId);
if (!user || !user.isPublicCollection) {
throw new ErrorEvent(
401,
"Collection non partagée",
"Cet utilisateur ne souhaite pas partager sa collection"
);
}
const artists = await getAllDistincts(
WantListModel,
"artists.name",
userId
);
const formats = await getAllDistincts(
WantListModel,
"formats.name",
userId
);
const years = await getAllDistincts(WantListModel, "year", userId);
const genres = await getAllDistincts(WantListModel, "genres", userId);
const styles = await getAllDistincts(WantListModel, "styles", userId);
this.setPageTitle(`Collection publique de ${user.username}`);
this.setPageContent("username", user.username);
this.setPageContent("artists", artists);
this.setPageContent("formats", formats);
this.setPageContent("years", years);
this.setPageContent("genres", genres);
this.setPageContent("styles", styles);
}
}
export default Wantlist;

View file

@ -29,6 +29,7 @@ const AlbumSchema = new mongoose.Schema(
extraartists: Array,
images: Array,
thumb: String,
thumbType: String,
},
{ timestamps: true }
);

24
src/models/jobs.js Normal file
View file

@ -0,0 +1,24 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const JobSchema = new mongoose.Schema(
{
model: String,
id: Schema.Types.ObjectId,
state: {
type: String,
enum: ["NEW", "IN-PROGRESS", "ERROR", "SUCCESS"],
default: "NEW",
},
lastTry: Date,
lastErrorMessage: String,
tries: {
type: Number,
default: 0,
},
},
{ timestamps: true }
);
export default mongoose.model("Jobs", JobSchema);

View file

@ -25,10 +25,21 @@ const UserSchema = new mongoose.Schema(
},
hash: String,
salt: String,
pagination: {
type: Number,
default: 16,
},
isPublicCollection: {
type: Boolean,
default: false,
},
mastodon: {
publish: Boolean,
token: String,
url: String,
message: String,
wantlist: String,
},
},
{
timestamps: true,

37
src/models/wantlist.js Normal file
View file

@ -0,0 +1,37 @@
import mongoose from "mongoose";
const { Schema } = mongoose;
const WantListSchema = new mongoose.Schema(
{
User: {
type: Schema.Types.ObjectId,
ref: "Users",
},
discogsId: Number,
year: Number,
released: Date,
uri: String,
artists: Array,
artists_sort: String,
labels: Array,
series: Array,
companies: Array,
formats: Array,
title: String,
country: String,
notes: String,
identifiers: Array,
videos: Array,
genres: Array,
styles: Array,
tracklist: Array,
extraartists: Array,
images: Array,
thumb: String,
thumbType: String,
},
{ timestamps: true }
);
export default mongoose.model("WantList", WantListSchema);

View file

@ -47,6 +47,16 @@ router
router
.route("/:itemId")
.patch(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const albums = new Albums(req);
const data = await albums.patchOne();
sendResponse(req, res, data);
} catch (err) {
next(err);
}
})
.delete(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const albums = new Albums(req);
@ -58,4 +68,17 @@ router
}
});
router
.route("/:itemId/share")
.post(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const albums = new Albums(req);
const data = await albums.shareOne();
sendResponse(req, res, data);
} catch (err) {
next(err);
}
});
export default router;

View file

@ -0,0 +1,77 @@
import express from "express";
import nodemailer from "nodemailer";
import svgCaptcha from "svg-captcha";
import { sendResponse } from "../../../libs/format";
import { mailMethod, smtpConfig, mailTo, siteName } from "../../../config";
import ErrorEvent from "../../../libs/error";
// eslint-disable-next-line new-cap
const router = express.Router();
router
.route("/")
.get(async (req, res, next) => {
try {
const captcha = svgCaptcha.create({
size: 4,
noise: 2,
color: true,
});
req.session.captcha = captcha.text;
res.type("svg");
return res.status(200).send(captcha.data);
} catch (err) {
return next(err);
}
})
.post(async (req, res, next) => {
try {
if (mailMethod === "smtp") {
const { email, name, message, captcha } = req.body;
if (!captcha || captcha !== req.session.captcha) {
throw new ErrorEvent(
406,
"Captcha",
"Le captcha n'est pas valide"
);
}
if (!email || !message) {
throw new ErrorEvent(
406,
"Erreur de saisie",
"Le formulaire n'est pas correctement saisi"
);
}
const transporter = nodemailer.createTransport(smtpConfig);
const text = `Bonjour,
Vous venez de recevoir un nouveau message de ${name} (${email}) :
${message}
`;
const data = await transporter.sendMail({
from: smtpConfig.auth.user,
to: mailTo,
subject: `${siteName} : Nouveau message`,
text,
});
const { messageId, response } = data;
return sendResponse(req, res, { messageId, response });
}
throw new ErrorEvent(500, "Routeur", "Méthode non configurée");
} catch (err) {
return next(err);
}
});
export default router;

View file

@ -0,0 +1,28 @@
import express from "express";
import { ensureLoggedIn } from "connect-ensure-login";
import Mastodon from "mastodon";
import { sendResponse } from "../../../libs/format";
// eslint-disable-next-line new-cap
const router = express.Router();
router.route("/").post(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const { url, token } = req.body;
const M = new Mastodon({
access_token: token,
api_url: url,
});
const data = await M.post("statuses", {
status: "Test d'intégration de Mastodon sur mon compte #musictopus 👌",
});
return sendResponse(req, res, data);
} catch (err) {
return next(err);
}
});
export default router;

View file

@ -15,6 +15,16 @@ router
const me = new Me(req);
const data = await me.patchMe();
return sendResponse(req, res, data);
} catch (err) {
return next(err);
}
})
.delete(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const me = new Me(req);
const data = await me.deleteMe();
return sendResponse(req, res, data);
} catch (err) {
return next(err);

View file

@ -3,13 +3,43 @@ import { ensureLoggedIn } from "connect-ensure-login";
import { sendResponse } from "../../../libs/format";
import { searchSong, getAlbumDetails } from "../../../helpers";
import Albums from "../../../middleware/Albums";
// eslint-disable-next-line new-cap
const router = express.Router();
router.route("/").get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const data = await searchSong(req.query.q);
const data = await searchSong(
req.query.q,
req.query.format || null,
req.query.year || null,
req.query.country || null
);
const discogsIds = [];
const foundIds = [];
for (let i = 0; i < data.results.length; i += 1) {
discogsIds.push(data.results[i].id);
}
req.query.discogsIds = discogsIds;
const albums = new Albums(req);
const myAlbums = await albums.getAll();
if (myAlbums.rows) {
for (let i = 0; i < myAlbums.rows.length; i += 1) {
foundIds.push(myAlbums.rows[i].discogsId);
}
}
for (let i = 0; i < data.results.length; i += 1) {
data.results[i].inCollection = foundIds.includes(
data.results[i].id
);
}
sendResponse(req, res, data);
} catch (err) {

View file

@ -0,0 +1,84 @@
import express from "express";
import { ensureLoggedIn } from "connect-ensure-login";
import { sendResponse } from "../../../libs/format";
import Albums from "../../../middleware/Wantlist";
// eslint-disable-next-line new-cap
const router = express.Router();
router
.route("/")
.get(async (req, res, next) => {
try {
const albums = new Albums(req, null);
const data = await albums.getAll();
const { exportFormat = "json" } = req.query;
switch (exportFormat) {
case "csv":
case "musictopus":
res.header("Content-Type", "text/csv");
res.attachment("export-musictopus.csv");
return res.status(200).send(data);
case "xml":
res.header("Content-type", "text/xml");
res.attachment("export-musictopus.xml");
return res.status(200).send(data);
case "xls":
return data.write("musictopus.xls", res);
case "json":
default:
return sendResponse(req, res, data);
}
} catch (err) {
return next(err);
}
})
.post(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const data = await Albums.postAddOne(req);
sendResponse(req, res, data);
} catch (err) {
next(err);
}
});
router
.route("/:itemId")
.patch(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const albums = new Albums(req, null);
const data = await albums.patchOne();
sendResponse(req, res, data);
} catch (err) {
next(err);
}
})
.delete(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const albums = new Albums(req, null);
const data = await albums.deleteOne();
sendResponse(req, res, data);
} catch (err) {
next(err);
}
});
router
.route("/:itemId/share")
.post(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const albums = new Albums(req, null);
const data = await albums.shareOne();
sendResponse(req, res, data);
} catch (err) {
next(err);
}
});
export default router;

View file

@ -7,6 +7,8 @@ import Auth from "../middleware/Auth";
import render from "../libs/format";
import { registrationOpen } from "../config";
// eslint-disable-next-line new-cap
const router = express.Router();
@ -59,11 +61,33 @@ router
}
);
router
.route("/inscription")
.get((req, res, next) => {
if (registrationOpen) {
router
.route("/inscription")
.get((req, res, next) => {
try {
const page = new Pages(req, "inscription/index");
page.setPageTitle("Inscription");
render(res, page);
} catch (err) {
next(err);
}
})
.post(async (req, res) => {
try {
await Auth.register(req);
res.redirect("/");
} catch (err) {
res.redirect("/inscription");
}
});
} else {
router.route("/inscription").get((req, res, next) => {
try {
const page = new Pages(req, "inscription");
const page = new Pages(req, "inscription/desactivee");
page.setPageTitle("Inscription");
@ -71,16 +95,8 @@ router
} catch (err) {
next(err);
}
})
.post(async (req, res) => {
try {
await Auth.register(req);
res.redirect("/");
} catch (err) {
res.redirect("/inscription");
}
});
}
router
.route("/ajouter-un-album")
@ -88,8 +104,23 @@ router
try {
const page = new Pages(req, "ajouter-un-album");
page.setPageContent("action", "albums");
page.setPageTitle("Ajouter un album");
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/ajouter-a-ma-liste-de-souhaits")
.get(ensureLoggedIn("/connexion"), (req, res, next) => {
try {
const page = new Pages(req, "ajouter-un-album");
page.setPageContent("action", "wantlist");
page.setPageTitle("Ajouter un album à ma liste de souhaits");
render(res, page);
} catch (err) {
next(err);

40
src/routes/jobs.js Normal file
View file

@ -0,0 +1,40 @@
import express from "express";
import passport from "passport";
import Jobs from "../middleware/Jobs";
// eslint-disable-next-line new-cap
const router = express.Router();
router.route("/").get(
passport.authenticate(["jobs"], {
session: false,
}),
async (req, res, next) => {
try {
const job = new Jobs();
const data = await job.run(req.query.state);
return res.status(200).json(data).end();
} catch (err) {
return next(err);
}
}
);
router.route("/populate").get(
passport.authenticate(["jobs"], {
session: false,
}),
async (req, res, next) => {
try {
const data = await Jobs.populate();
return res.status(200).json(data).end();
} catch (err) {
return next(err);
}
}
);
export default router;

View file

@ -10,7 +10,7 @@ const router = express.Router();
router.route("/").get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "mon-compte/ma-collection/index");
const page = new Albums(req, "collection");
await page.loadMyCollection();
@ -24,6 +24,37 @@ router.route("/").get(ensureLoggedIn("/connexion"), async (req, res, next) => {
}
});
router
.route("/on-air")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "mon-compte/ma-collection/details");
await page.onAir();
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/statistiques")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(
req,
"mon-compte/ma-collection/statistiques"
);
await page.statistics();
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/exporter")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
@ -32,6 +63,19 @@ router
page.setPageTitle("Exporter ma collection");
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/importer")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "mon-compte/ma-collection/importer");
page.setPageTitle("Importer une collection");
render(res, page);
} catch (err) {
next(err);

23
src/routes/mon-compte.js Normal file
View file

@ -0,0 +1,23 @@
import express from "express";
import { ensureLoggedIn } from "connect-ensure-login";
import Me from "../middleware/Me";
import render from "../libs/format";
// eslint-disable-next-line new-cap
const router = express.Router();
router.route("/").get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Me(req, "mon-compte/index");
page.setPageTitle("Mon compte");
render(res, page);
} catch (err) {
next(err);
}
});
export default router;

99
src/routes/wantlist.js Normal file
View file

@ -0,0 +1,99 @@
import express from "express";
import { ensureLoggedIn } from "connect-ensure-login";
import Albums from "../middleware/Wantlist";
import render from "../libs/format";
// eslint-disable-next-line new-cap
const router = express.Router();
router.route("/").get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "collection");
await page.loadMyCollection();
if (page.getPageContent("artists").length > 0) {
render(res, page);
} else {
res.redirect("/ajouter-a-ma-liste-de-souhaits");
}
} catch (err) {
next(err);
}
});
router
.route("/on-air")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "mon-compte/ma-collection/details");
await page.onAir();
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/statistiques")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(
req,
"mon-compte/ma-collection/statistiques"
);
await page.statistics();
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/exporter")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "mon-compte/ma-collection/exporter");
page.setPageTitle("Exporter ma collection");
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/importer")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "mon-compte/ma-collection/importer");
page.setPageTitle("Importer une collection");
render(res, page);
} catch (err) {
next(err);
}
});
router
.route("/:itemId")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Albums(req, "mon-compte/ma-collection/details");
await page.loadItem();
render(res, page);
} catch (err) {
next(err);
}
});
export default router;

133
views/components/album.ejs Normal file
View file

@ -0,0 +1,133 @@
<div class="grid md:grid-cols-3 gap-16">
<div>
<template v-for="album in tracklist">
<strong v-if="album.title">{{album.title}}</strong>
<ul>
<li v-for="(track, index) in album.tracks" class="ml-4">
{{track.position || (index+1)}} - {{ track.title }} <template v-if="track.duration">({{track.duration}})</template>
<ul v-if="track.artists && track.artists.length > 0" class="sm-hidden">
<li v-for="extra in track.artists" class=" ml-4">
<small>{{extra.name}}</small>
</li>
</ul>
<ul v-if="track.extraartists && track.extraartists.length > 0" class="sm-hidden">
<li v-for="extra in track.extraartists" class=" ml-4">
<small>{{extra.role}} : <a :href="`/ma-collection?page=1&limit=16&sort=year&order=asc&artist=${extra.name}`">{{extra.name}}</a></small>
</li>
</ul>
</li>
</ul>
</template>
</div>
<div class="md:col-span-2">
<div class="grid grid-cols-2 gap-10">
<div>
<strong>Genres</strong>
<br />
<template v-for="(genre, index) in item.genres">
{{genre}}<template v-if="index < item.genres.length - 1">, </template>
</template>
</div>
<div>
<strong>Styles</strong>
<br />
<span v-for="(style, index) in item.styles">
{{style}}<template v-if="index < item.styles.length - 1">, </template>
</span>
</div>
</div>
<hr />
<div class="grid grid-cols-3 gap-10">
<div>
<strong>Pays</strong>
<br />
<span>{{item.country}}</span>
</div>
<div>
<strong>Année</strong>
<br />
<span>{{item.year}}</span>
</div>
<div>
<strong>Date de sortie</strong>
<br />
<span>{{item.released}}</span>
</div>
</div>
<hr />
<div class="grid gap-10">
<div>
<strong>Format<template v-if="item.formats.length > 1">s</template></strong>
<ul class="ml-4">
<li v-for="(format) in item.formats">
{{format.name}}
<template v-if="format.text">
- <i>{{format.text}}</i>
</template>
<template v-if="format.descriptions && format.descriptions.length > 0">
(<span v-for="(description, index) in format.descriptions">
{{description}}<template v-if="index < format.descriptions.length - 1">, </template>
</span>)
</template>
</li>
</ul>
</div>
</div>
<hr />
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
<div>
<strong id="identifiers">Code<template v-if="item.identifiers.length > 1">s</template> barre<template v-if="item.identifiers.length > 1">s</template></strong>
<ol class="ml-4">
<li v-for="identifier in identifiers">
{{identifier.value}} ({{identifier.type}})
</li>
</ol>
<template v-if="item.identifiers.length > identifiersPreviewLength">
<button type="button" class="button is-link" v-if="identifiersMode === 'preview'" @click="showAllIdentifiers">
Voir la suite
</button>
<button type="button" class="button is-link" v-if="identifiersMode === 'all'" @click="showLessIdentifiers">
Voir moins
</button>
</template>
</div>
<div>
<strong>Label<template v-if="item.labels.length > 1">s</template></strong>
<ol class="ml-4">
<li v-for="label in item.labels">
{{label.name}} {{label.catno}}
</li>
</ol>
<strong>Société<template v-if="item.companies.length > 1">s</template></strong>
<ol class="ml-4">
<li v-for="company in item.companies">
<strong>{{company.entity_type_name}}</strong> {{company.name}}
</li>
</ol>
</div>
</div>
<hr />
<div class="grid gap-10">
<div>
<strong>Note</strong>
<div v-html="(item.notes || '').replaceAll('\n', '<br />')"></div>
</div>
</div>
<hr />
<div class="grid gap-10">
<div>
<strong>Vidéos</strong>
<dl>
<template v-for="video in item.videos">
<dt>
<a :href="video.uri" target="_blank" rel="noopener noreferrer">{{video.title}}</a>
</dt>
<dd>
{{video.description}}
</dd>
</template>
</dl>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<div class="field">
<label for="artist">Artiste</label>
<select id="artist" v-model="artist" @change="changeFilter">
<option value="">Tous</option>
<%
for (let i = 0; i < page.artists.length; i += 1 ) {
__append(`<option value="${page.artists[i].replaceAll('"','%22')}">${page.artists[i]}</option>`);
}
%>
</select>
</div>

View file

@ -0,0 +1,11 @@
<div class="field">
<label for="format">Format</label>
<select id="format" v-model="format" @change="changeFilter">
<option value="">Tous</option>
<%
for (let i = 0; i < page.formats.length; i += 1 ) {
__append(`<option value="${page.formats[i]}">${page.formats[i]}</option>`);
}
%>
</select>
</div>

View file

@ -0,0 +1,12 @@
<div class="field">
<label for="genre">Genre</label>
<select id="genre" v-model="genre" @change="changeFilter">
<option value="">Tous</option>
<%
for (let i = 0; i < page.genres.length; i += 1 ) {
__append(`<option value="${page.genres[i]}">${page.genres[i]}</option>`);
}
%>
</select>
</div>

View file

@ -0,0 +1,18 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
<%- include('./artist') %>
<%- include('./format') %>
<%- include('./sort') %>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-10" v-if="moreFilters">
<%- include('./year') %>
<%- include('./genre') %>
<%- include('./style') %>
</div>
<span @click="showMoreFilters" class="showMoreFilters mt-10">
<template v-if="!moreFilters">Voir plus de filtres</template>
<template v-if="moreFilters">Voir moins de filtres</template>
<i class="icon-left-open down" v-if="!moreFilters"></i>
<i class="icon-left-open up" v-if="moreFilters"></i>
</span>

View file

@ -0,0 +1,15 @@
<div class="field">
<label for="sortOrder">Trier par</label>
<select id="sortOrder" v-model="sortOrder" @change="changeSort">
<option value="artists_sort-asc">Artiste (A-Z)</option>
<option value="artists_sort-desc">Artiste (Z-A)</option>
<option value="year-asc">Année (1-9)</option>
<option value="year-desc">Année (9-1)</option>
<option value="country-asc">Pays (A-Z)</option>
<option value="country-desc">Pays (Z-A)</option>
<option value="formats.name-asc">Format (A-Z)</option>
<option value="formats.name-desc">Format (Z-A)</option>
<option value="createdAt-asc">Date d'ajout (1-9)</option>
<option value="createdAt-desc">Date d'ajout (9-1)</option>
</select>
</div>

View file

@ -0,0 +1,11 @@
<div class="field">
<label for="style">Style</label>
<select id="style" v-model="style" @change="changeFilter">
<option value="">Tous</option>
<%
for (let i = 0; i < page.styles.length; i += 1 ) {
__append(`<option value="${page.styles[i]}">${page.styles[i]}</option>`);
}
%>
</select>
</div>

View file

@ -0,0 +1,11 @@
<div class="field">
<label for="format">Année</label>
<select id="format" v-model="year" @change="changeFilter">
<option value="">Toutes</option>
<%
for (let i = 0; i < page.years.length; i += 1 ) {
__append(`<option value="${page.years[i]}">${page.years[i]}</option>`);
}
%>
</select>
</div>

View file

@ -1,10 +1,8 @@
<main class="layout-maxed error">
<h1><%= page.title %></h1>
<% if ( errorCode && errorCode === 404 ) { %>
<p class="text-center">
<img src="/img/404.svg" alt="Erreur 404" style="max-height: 400px;" />
</p>
<% } %>
<% if ( process.env.NODE_ENV !== 'production' ) { %>
<div>
<pre><%= page.error %></pre>

View file

@ -16,9 +16,8 @@
<link href="/css/main.css" rel="stylesheet" />
<script src="/libs/axios/axios.min.js"></script>
<script src="/libs/vue/vue.global.prod.js"></script>
<script src="/js/main.js"></script>
<script src="/js/libs.js"></script>
<script defer src="/js/main.js"></script>
<% if ( config.matomoUrl ) { %>
<!-- Matomo -->
@ -39,85 +38,165 @@
<% } %>
</head>
<body>
<nav class="navbar" aria-label="Navigation principale">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img src="/img/logo.png" alt="Logo MusicTopus">
<span>MusicTopus</span>
</a>
<a role="button" class="navbar-burger" aria-label="Afficher le menu" aria-expanded="false" data-target="navbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbar" class="navbar-menu">
<% if ( user ) { %>
<div class="navbar-start">
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary" href="/ajouter-un-album">
<i class="icon-plus"></i>
<span>
Ajouter un album
</span>
</a>
</div>
</div>
</div>
<% } %>
<div class="navbar-end">
<a class="navbar-item" href="/nous-contacter">
Nous contacter
<nav class="navbar">
<nav class="navbar container" aria-label="Navigation principale">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img src="/img/logo.png" alt="Logo MusicTopus">
<span>MusicTopus</span>
</a>
<% if ( user ) { %>
<div class="navbar-item has-dropdown">
<a class="navbar-link">
<i class="icon-user"></i>
<span>
<%= user.username %>
</span>
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="/ma-collection">
Ma collection
</a>
<a class="navbar-item" href="/ma-collection/exporter">
Exporter ma collection
</a>
<a role="button" class="navbar-burger" aria-label="Afficher le menu" aria-expanded="false" data-target="navbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbar" class="navbar-menu">
<% if ( user ) { %>
<div class="navbar-start">
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary" href="/ajouter-un-album">
<i class="icon-plus"></i>
<span>
Ajouter un album
</span>
</a>
</div>
</div>
</div>
<% } %>
<div class="navbar-item apparence">
<div class="theme-switch-wrapper">
<label class="theme-switch" for="checkbox" aria-label="Passer du thème clair au thème sombre et inversement">
<input type="checkbox" id="checkbox" />
<div class="slider round"></div>
</label>
</div>
</div>
<div class="navbar-item">
<div class="buttons">
<button type="button" class="button is-primary" id="switchAriaTheme" aria-label="Renforcer la visibilité de ce site" title="Renforcer la visibilité de ce site">
<i class="icon-eye"></i>
</button>
<% if ( !user ) { %>
<a class="button is-primary" href="/connexion">
<strong>Connexion</strong>
<ul class="navbar-end">
<li class="navbar-item">
<a href="/nous-contacter">
Nous contacter
</a>
<% } else { %>
<a class="button is-danger" href="/se-deconnecter">
Déconnexion
</li>
<li class="navbar-item has-dropdown">
<a class="navbar-link">
<i class="icon-adjust theme-system icon-theme hidden"></i>
<i class="icon-sun theme-light icon-theme hidden"></i>
<i class="icon-moon theme-dark icon-theme hidden"></i>
<span>
Thème
</span>
</a>
<% } %>
</div>
</div>
<ul class="navbar-dropdown">
<li>
<button class="navbar-item theme" data-value="system">
<i class="icon-adjust"></i>
<span>
Système
</span>
</button>
</li>
<li>
<button class="navbar-item theme" data-value="light">
<i class="icon-sun"></i>
<span>
Clair
</span>
</button>
</li>
<li>
<button class="navbar-item theme" data-value="dark">
<i class="icon-moon"></i>
<span>
Sombre
</span>
</button>
</li>
</ul>
</li>
<% if ( user ) { %>
<li class="navbar-item has-dropdown">
<a class="navbar-link">
<i class="icon-user"></i>
<span>
<%= user.username %>
</span>
</a>
<ul class="navbar-dropdown">
<li>
<a class="navbar-item" href="/mon-compte">
Mon compte
</a>
</li>
<li><hr /></li>
<li>
<a class="navbar-item" href="/ma-collection">Ma collection</a>
<ul>
<li>
<a class="navbar-item" href="/ma-collection/on-air">
On air
</a>
</li>
<li>
<a class="navbar-item" href="/ma-collection/statistiques">
Statistiques
</a>
</li>
<li>
<a class="navbar-item" href="/ma-collection/exporter">
Exporter
</a>
</li>
<li>
<a class="navbar-item" href="/ma-collection/importer">
Importer
</a>
</li>
</ul>
</li>
<li><hr /></li>
<li>
<a class="navbar-item" href="/ma-liste-de-souhaits">
Ma liste de souhaits
</a>
<ul>
<li>
<a class="navbar-item" href="/ajouter-a-ma-liste-de-souhaits">
Ajouter
</a>
</li>
<li>
<a class="navbar-item" href="/ma-liste-de-souhaits/exporter">
Exporter
</a>
</li>
<li>
<a class="navbar-item" href="/ma-liste-de-souhaits/importer">
Importer
</a>
</li>
</ul>
</li>
<li><hr /></li>
<li>
<a class="navbar-item is-danger" href="/se-deconnecter">
Déconnexion
</a>
</li>
</ul>
</li>
<% } %>
<% if ( !user ) { %>
<li class="navbar-item">
<div class="buttons">
<a class="button is-primary" href="/connexion">
<strong>Connexion</strong>
</a>
</div>
</li>
<% } %>
</ul>
</div>
</div>
</nav>
</nav>
<div id="toastr">
@ -125,41 +204,62 @@
<span></span>
</div>
<% if ( page.failureFlash || (error && error.length > 0 ) ) {%>
<div class="flash">
<% if ( page.failureFlash ) {%>
<div class="header">
Erreur
<%
if ( flash.error.length > 0 ) {
for ( let i = 0 ; i < flash.error.length ; i += 1 ) {
%>
<div class="flash">
<div class="header">
Erreur
</div>
<div class="body">
<%= flash.error[i].replace('Error: ', '') %>
</div>
</div>
<div class="body">
<%= page.failureFlash %>
<%
}
}
if ( flash.info.length > 0 ) {
for ( let i = 0 ; i < flash.info.length ; i += 1 ) {
%>
<div class="flash info">
<div class="header">
Information
</div>
<div class="body">
<%= flash.info[i] %>
</div>
</div>
<% } %>
<%
if (error && error.length > 0) {
for( let i = 0 ; i < error.length ; i += 1 ) {
%>
<div class="header">
Erreur
</div>
<div class="body">
<%= error %>
</div>
<%
}
}
%>
</div>
<% } %>
<%
}
}
if ( flash.success.length > 0 ) {
for ( let i = 0 ; i < flash.success.length ; i += 1 ) {
%>
<div class="flash success">
<div class="header">
Succès
</div>
<div class="body">
<%= flash.success[i] %>
</div>
</div>
<%
}
}
%>
<%- include(viewname) %>
<footer class="footer layout-hero">
<p>
<strong title="Merci Brunus ! 😜">MusicTopus</strong> par <a href="https://www.darkou.fr" target="_blank" rel="noopener noreferrer">Damien Broqua <i class="icon-link"></i></a>.
Fait avec ❤️ à Bordeaux.
Le code source est sous licence <a href="https://www.gnu.org/licenses/gpl-3.0-standalone.html" target="_blank" rel="noopener noreferrer">GNU GPL-3.0-or-later <i class="icon-link"></i></a>.
<strong title="Merci Brunus ! 😜">MusicTopus</strong> par <a href="https://www.darkou.link" target="_blank" rel="noopener noreferrer">Damien Broqua <i class="icon-link"></i></a>.
Logo réalisé par Brunus avec <a href="https://inkscape.org/fr/" target="_blank" rel="noopener noreferrer">Inkscape <i class="icon-link"></i></a>.
<br />
Le code source est sous licence <a href="https://www.gnu.org/licenses/gpl-3.0-standalone.html" target="_blank" rel="noopener noreferrer">GNU GPL-3.0-or-later <i class="icon-link"></i></a> et disponible sur <a href="https://forge.darkou.fr/contact/MusicTopus" target="_blank">forge.darkou.fr <i class="icon-link"></i></a>.
<br />
Fait avec <span role="img" aria-label="amour">❤️</span> à Bordeaux. <img src="/img/emoji-lmhf.svg" class="lmhf" alt="lmhf" alt="Love Music, Hate Fascism" title="Love Music, Hate Fascism" />
</p>
</footer>
</body>
</html>
</html>

View file

@ -1,23 +1,42 @@
<main class="layout-maxed ajouter-un-album" id="app">
<main class="layout-maxed ajouter-un-album" id="ajouter-album">
<h1>Ajouter un album</h1>
<div class="grid sm:grid-cols-2">
<div>
<form @submit="search">
<form @submit="search">
<div class="grid grid-cols-1 md:grid-cols-2">
<div>
<label for="q">Nom de l'album ou code barre</label>
<div class="field has-addons">
<input type="text" name="q" id="q" v-model="q" placeholder="ex : Hybrid Theory" autofocus>
<input type="text" name="q" id="q" v-model="q" placeholder="ex : Iron Maiden - Powerslave" autofocus>
<button class="button is-primary" :disabled="loading" aria-label="Chercher">
<i class="icon-search" v-if="!loading"></i>
<i class="icon-spin animate-spin" v-if="loading"></i>
</button>
</div>
</form>
<div class="grid sm:grid-cols-3 gap-5">
<div class="field">
<label for="format">Format</label>
<select id="format" v-model="format">
<option value="">Tous</option>
<option v-for="format in orderedItems(formats)" :value="format">{{format}}</option>
</select>
</div>
<div class="field">
<label for="year">Année</label>
<input type="number" name="year" v-model="year" id="year" placeholder="1984">
</div>
<div class="field">
<label for="country">Pays</label>
<input type="string" name="country" v-model="country" id="country" placeholder="France">
</div>
</div>
</div>
</div>
</div>
</form>
<div class="grid grid-cols-1 md:grid-cols-2 list hover">
<div class="item" v-if="!loading" v-for="item in items">
<div v-if="!loading" v-for="item in items" class="item" :class="{'in-collection': item.inCollection}">
<a @click="loadDetails(item.id)" class="title">{{ item.artists_sort }} {{ item.title }}</a>
<small v-if="item.inCollection"> (Dans ma collection)</small>
<div class="grid grid-cols-2 md:grid-cols-4">
<div>
<img :src="item.thumb" :alt="item.title" @click="loadDetails(item.id)"/>
@ -60,7 +79,7 @@
<button aria-label="Fermer" class="close" @click="toggleModal"></button>
</header>
<section>
<div class="grid grid-cols-2 gap-16">
<div class="grid grid-cols-1 md:grid-cols-3 gap-16">
<div>
<div class="text-center">
<img :src="details.thumb %>" :alt="`Miniature pour l'album ${details.title}`" />
@ -68,75 +87,93 @@
<img v-for="image in details.images" :src="image.uri150" :alt="`Miniature de type ${image.type}`" style="max-width: 60px;" />
<hr />
</div>
<ol class="ml-4">
<li v-for="track in details.tracklist">{{ track.title }} ({{track.duration}})</li>
</ol>
<ul class="is-unstyled">
<li v-for="track in details.tracklist" :class="{'ml-4': track.type_ === 'track'}">
<strong v-if="track.type_ === 'heading'">
{{track.title}}
</strong>
<template v-else>
{{ track.position }}
{{ track.title }} <span v-if="track.duration">({{track.duration}})</span>
<ul v-if="track.artists && track.artists.length > 0" class="sm-hidden">
<li v-for="extra in track.artists" class=" ml-4">
<small>{{extra.role}} : {{extra.name}}</small>
</li>
</ul>
</template>
</li>
</ul>
</div>
<div>
<div class="grid grid-cols-2 gap-10">
<div>
<div class="md:col-span-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
<div class="grid grid-cols-1">
<strong>Genres</strong>
<br />
<template v-for="(genre, index) in details.genres">
{{genre}}<template v-if="index < details.genres.length - 1">, </template>
</template>
</div>
<div>
<div class="grid grid-cols-1">
<strong>Styles</strong>
<br />
<span v-for="(style, index) in details.styles">
{{style}}<template v-if="index < details.styles.length - 1">, </template>
</span>
</div>
</div>
<hr />
<div class="grid grid-cols-3 gap-10">
<div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
<div class="grid grid-cols-2 md:grid-cols-1">
<strong>Pays</strong>
<br />
<span>{{details.country}}</span>
</div>
<div>
<div class="grid grid-cols-2 md:grid-cols-1">
<strong>Année</strong>
<br />
<span>{{details.year}}</span>
</div>
<div>
<div class="grid grid-cols-2 md:grid-cols-1">
<strong>Date de sortie</strong>
<br />
<span>{{details.released}}</span>
</div>
</div>
<hr />
<div class="grid grid-cols-2 gap-10">
<div class="grid grid-cols-1 gap-10">
<div>
<strong>Format</strong>
<br />
<span v-for="(format, index) in details.formats">
{{format.name}}<template v-if="index < details.formats.length - 1">, </template>
</span>
<strong>Format<template v-if="details?.formats?.length > 1">s</template></strong>
<ul class="ml-4">
<li v-for="(format) in details.formats">
{{format.name}}
<template v-if="format.text">
- <i>{{format.text}}</i>
</template>
<template v-if="format.descriptions && format.descriptions.length > 0">
(<span v-for="(description, index) in format.descriptions">
{{description}}<template v-if="index < format.descriptions.length - 1">, </template>
</span>)
</template>
</li>
</ul>
</div>
</div>
<hr />
<div class="grid grid-cols-2 gap-10">
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
<div>
<strong>Codes barres</strong>
<ol>
<strong>Code<template v-if="details?.identifiers?.length > 1">s</template> barre<template v-if="details?.identifiers?.length > 1">s</template></strong>
<ol class="ml-4">
<li v-for="identifier in details.identifiers">
{{identifier.value}} ({{identifier.type}})
</li>
</ol>
</div>
<div>
<strong>Label</strong>
<ol>
<strong>Label<template v-if="details?.labels?.length > 1">s</template></strong>
<ol class="ml-4">
<li v-for="label in details.labels">
{{label.name}}
</li>
</ol>
<strong>Société</strong>
<ol>
<li v-for="company in details.companie">
<strong>Société<template v-if="details?.companies?.length > 1">s</template></strong>
<ol class="ml-4">
<li v-for="company in details.companies">
<strong>{{company.entity_type_name}}</strong>
{{company.name}}
</li>
</ol>
@ -146,7 +183,15 @@
</div>
</section>
<footer>
<button class="button is-primary" @click="add">Ajouter</button>
<% if ( user.mastodon && user.mastodon.publish ) { %>
<div class="field">
<label for="share">Partager sur le fédiverse</label>
<span>
<input type="checkbox" id="share" name="share" v-model="share">
</span>
</div>
<% } %>
<button :class="['button is-primary', submitting ? 'is-disabled' : '']" @click="add">Ajouter</button>
<button class="button" @click="toggleModal">Annuler</button>
</footer>
</div>
@ -154,94 +199,6 @@
</main>
<script>
Vue.createApp({
data() {
return {
q: '',
loading: false,
items: [],
details: {},
modalIsVisible: false,
}
},
methods: {
search(event) {
event.preventDefault();
if ( this.loading ) {
return false;
}
this.loading = true;
axios.get(`/api/v1/search?q=${this.q}`)
.then( response => {
const {
results,
} = response.data;
let items = [];
for (let i = 0 ; i < results.length ; i += 1 ) {
const {
id,
title,
thumb,
year,
country,
format,
genre,
style,
} = results[i];
items.push({
id,
title,
thumb,
year,
country,
format,
genre,
style,
});
}
this.items = items;
})
.catch((err) => {
showToastr(err.response?.data?.message || "Aucun résultat trouvé :/");
})
.finally(() => {
this.loading = false;
});
},
toggleModal() {
this.modalIsVisible = !this.modalIsVisible;
},
loadDetails(discogsId) {
axios.get(`/api/v1/search/${discogsId}`)
.then( response => {
const {
data,
} = response;
this.details = data;
this.toggleModal();
})
.catch((err) => {
showToastr(err.response?.data?.message || "Impossible de charger les détails de cet album");
})
.finally(() => {
this.loading = false;
});
},
add() {
axios.post('/api/v1/albums', this.details)
.then(() => {
window.location.href = '/ma-collection';
})
.catch((err) => {
showToastr(err.response?.data?.message || "Impossible d'ajouter ce album pour le moment…");
});
},
}
}).mount('#app');
</script>
const canPublish = <%- (user.mastodon && user.mastodon.publish) || false %>;
const action = "<%- page.action %>";
</script>

View file

@ -1,53 +1,52 @@
<main class="layout-maxed collection" id="app">
<h1>
Collection de <%= page.username %>
</h1>
<%
const pageType = page.username ? 'public' : 'private';
%>
<div class="filters">
<div class="field">
<label for="artist">Artiste</label>
<select id="artist" v-model="artist" @change="changeFilter">
<option value="">Tous</option>
<%
for (let i = 0; i < page.artists.length; i += 1 ) {
__append(`<option value="${page.artists[i]}">${page.artists[i]}</option>`);
}
%>
</select>
<main class="layout-maxed collection" id="collection">
<% if (page.action === 'albums') { %>
<h1>
<% if ( pageType === 'private' ) {
__append('Ma collection <i class="icon-share" @click="toggleModalShare" aria-label="Partager ma collection" title="Votre collection sera visible en lecture aux personnes ayant le lien de partage"></i>');
} else {
__append(`Collection de ${page.username}`);
} %>
</h1>
<% if ( pageType === 'private' ) { %>
<div>
<a :href="shareLink" v-if="isPublicCollection" target="_blank">
<i class="icon-share"></i> Voir ma collection partagée
</a>
</div>
<% } %>
<% } else { %>
<h1>Ma liste de souhaits</h1>
<% } %>
<%- include('../components/filters/index') %>
<div class="grid grid-cols-1 md:grid-cols-2 list hover">
<div class="loader" v-if="loading">
<div class="animation"></div>
<div>
Chargement des données en cours…
</div>
</div>
<div class="field">
<label for="format">Format</label>
<select id="format" v-model="format" @change="changeFilter">
<option value="">Tous</option>
<%
for (let i = 0; i < page.formats.length; i += 1 ) {
__append(`<option value="${page.formats[i]}">${page.formats[i]}</option>`);
}
%>
</select>
</div>
<div class="field">
<label for="sortOrder">Trier par</label>
<select id="sortOrder" v-model="sortOrder" @change="changeSort">
<option value="artists_sort-asc">Artiste (A-Z)</option>
<option value="artists_sort-desc">Artiste (Z-A)</option>
<option value="year-asc">Année (A-Z)</option>
<option value="year-desc">Année (Z-A)</option>
<option value="country-asc">Pays (A-Z)</option>
<option value="country-desc">Pays (Z-A)</option>
<option value="formats.name-asc">Format (A-Z)</option>
<option value="formats.name-desc">Format (Z-A)</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 list">
<div class="item" v-if="!loading" v-for="item in items">
<div class="item" v-if="!loading" v-for="item in items">
<span class="title">
{{ item.artists_sort}} - {{ item.title }}
<% if ( pageType === 'private' ) { %>
<a :href="'/<%= page.action === 'albums' ? 'ma-collection' : 'ma-liste-de-souhaits' %>/' + item._id">{{ renderAlbumTitle(item) }}</a>
<i class="icon-trash" @click="showConfirmDelete(item._id)"></i>
<% } else { %>
{{ item.artists_sort}} - {{ item.title }}
<% } %>
</span>
<div class="grid grid-cols-2 md:grid-cols-4">
<div>
<img :src="item.thumb" :alt="item.title" />
<% if ( pageType === 'private' ) { %>
<a :href="'/<%= page.action === 'albums' ? 'ma-collection' : 'ma-liste-de-souhaits' %>/' + item._id"><img :src="item.thumb" :alt="item.title" /></a>
<% } else { %>
<img :src="item.thumb" :alt="item.title" />
<% } %>
</div>
<div class="md:col-span-3">
<span><strong>Année :</strong> {{ item.year }}</span>
@ -74,111 +73,95 @@
</div>
</div>
</div>
<div class="total">
<strong>Nombre total d'éléments : </strong>{{total}}
<div class="flex justify-between md:align-items-center flex-col md:flex-row">
<div class="total">
<strong>Nombre total d'éléments : </strong>{{total}}
</div>
<nav class="pagination" role="navigation" aria-label="Pagination">
<ul class="flex justify-center md:justify-end pagination-list">
<li v-if="page > 1">
<a class="pagination-link" @click="goTo(1)" aria-label="Aller à la première page">&laquo;</a>
</li>
<template v-for="p in Array.from({length: totalPages}, (v, i) => (i+1))">
<template v-if="p < 2 || p > (totalPages - 1) || (Number(page) - 1) <= p && Number(page) + 1 >= p">
<li>
<a class="pagination-link" :class="{'is-current': p === Number(page)}" @click="goTo(p)" :aria-label="'Aller à la page '+p">{{ p }}</a>
</li>
</template>
<template v-if="(Number(page) - 3 === p && Number(page) - 2 > 1) || (Number(page) + 2 === p && Number(page) + 2 < totalPages - 1)">
<li>
<a class="pagination-link is-disabled">…</a>
</li>
</template>
</template>
<li v-if="page < totalPages">
<a class="pagination-link" @click="goTo(totalPages)" aria-label="Aller à la derière page">&raquo;</a>
</li>
</ul>
</nav>
</div>
<nav class="pagination" role="navigation" aria-label="Pagination">
<ul class="pagination-list">
<template v-for="p in Array.from({length: totalPages}, (v, i) => (i+1))">
<template v-if="p < 2 || p > (totalPages - 1) || (page - 1) <= p && page + 1 >= p">
<li>
<a class="pagination-link" :class="{'is-current': p === page}" @click="goTo(p)" aria-label="Aller à la page {{p}}">{{ p }}</a>
</li>
</template>
<template v-if="(page - 3 === p && page - 2 > 1) || (page + 2 === p && page + 2 < totalPages - 1)">
<li>
<a class="pagination-link is-disabled">…</a>
</li>
</template>
</template>
</ul>
</nav>
<% if ( pageType === 'private' ) { %>
<div class="modal" :class="{'is-visible': showModalDelete}">
<div class="modal-background"></div>
<div class="modal-card">
<header></header>
<section>
Êtes-vous sûr de vouloir supprimer cet album ?
</section>
<footer>
<button class="button is-primary" @click="deleteItem">Supprimer</button>
<button class="button" @click="toggleModal">Annuler</button>
</footer>
</div>
</div>
<div class="modal" :class="{'is-visible': showModalShare}">
<div class="modal-background"></div>
<div class="modal-card">
<header>
Partager ma collection
</header>
<section>
<template v-if="!isPublicCollection">
Votre collection sera visible de toute personne disposant du lien suivant :
<br />
<a :href="shareLink" target="_blank">{{shareLink}}</a>
<br />
Ce lien permet uniquement de visualiser l'ensemble de votre collection mais ne perment <strong class="is-danger">en aucun cas</strong> de la modifier.
<br />
Vous pourrez à tout moment supprimer le lien de partage en cliquant à nouveau sur l'icône <i class="icon-share"></i> sur votre collection.
</template>
<template v-if="isPublicCollection">
Vous êtes sur le point de rendre votre collection privée.
<br />
Toute les personnes ayant le lien partagé ne pourront plus accéder à votre collection.
<br />
Vous pourrez à tout moment rendre à nouveau votre collection publique en cliquant sur l'icône <i class="icon-share"></i>.
</template>
</section>
<footer>
<button v-if="!isPublicCollection" class="button is-primary" @click="shareCollection">Partager</button>
<button v-if="isPublicCollection" class="button is-danger" @click="shareCollection">Supprimer</button>
<button class="button" @click="toggleModalShare">Annuler</button>
</footer>
</div>
</div>
<% } %>
</main>
<script>
const {
protocol,
host
} = window.location;
Vue.createApp({
data() {
return {
loading: false,
items: [],
total: 0,
page: 1,
totalPages: 1,
limit: 16,
artist: '',
format: '',
sortOrder: 'artists_sort-asc',
sort: 'artists_sort',
order: 'asc',
userId: "<%= params.userId %>",
}
},
created() {
this.fetch();
},
methods: {
fetch() {
this.loading = true;
let url = `/api/v1/albums?userId=${this.userId}&page=${this.page}&limit=${this.limit}&sort=${this.sort}&order=${this.order}`;
if ( this.artist ) {
url += `&artists_sort=${this.artist}`;
}
if ( this.format ) {
url += `&format=${this.format}`;
}
axios.get(url)
.then( response => {
this.items = response.data.rows;
this.total = response.data.count;
this.totalPages = parseInt(response.data.count / this.limit) + (response.data.count % this.limit > 0 ? 1 : 0);
})
.catch((err) => {
showToastr(err.response?.data?.message || "Impossible de charger cette collection");
})
.finally(() => {
this.loading = false;
});
},
next(event) {
event.preventDefault();
this.page += 1;
this.fetch();
},
previous(event) {
event.preventDefault();
this.page -= 1;
this.fetch();
},
goTo(page) {
this.page = page;
this.fetch();
},
changeSort() {
const [sort,order] = this.sortOrder.split('-');
this.sort = sort;
this.order = order;
this.page = 1;
this.fetch();
},
changeFilter() {
this.page = 1;
this.fetch();
},
}
}).mount('#app');
const vueType = "<%= pageType %>";
const query = <%- JSON.stringify(query) %>;
const action = "<%- page.action %>";
<% if ( pageType === 'private' ) { %>
const isPublicCollection = <%= user.isPublicCollection ? 'true' : 'false' %>;
const userId = "<%= user._id %>";
<% } else { %>
const userId = "<%= params.userId %>";
const isPublicCollection = false;
<% } %>
</script>

View file

@ -238,7 +238,7 @@
</div>
<h2 id="boites">Les boites</h2>
<div class="box">
<div class="box mini">
<form method="POST">
<h1>
Connexion
@ -274,6 +274,22 @@
Ceci est une erreur
</div>
</div>
<div class="flash info">
<div class="header">
Information
</div>
<div class="body">
Ceci est une information
</div>
</div>
<div class="flash success">
<div class="header">
Succès
</div>
<div class="body">
Ceci est un succès
</div>
</div>
<pre>
&lt;div class="flash"&gt;
&lt;div class="header"&gt;
@ -339,6 +355,7 @@
<i class="icon-left-open">.icon-left-open</i>
<i class="icon-right-open">.icon-right-open</i>
<i class="icon-export">.icon-export</i>
<i class="icon-refresh">.icon-refresh</i>
<i class="icon-share">.icon-share</i>
<i class="icon-spin">.icon-spin</i>
<i class="icon-sun">.icon-sun</i>
@ -470,19 +487,8 @@
&lt;/a&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="navbar-item apparence"&gt;
&lt;div class="theme-switch-wrapper"&gt;
&lt;label class="theme-switch" for="checkbox" aria-label="Passer du thème clair au thème sombre et inversement"&gt;
&lt;input type="checkbox" id="checkbox" /&gt;
&lt;div class="slider round"&gt;&lt;/div&gt;
&lt;/label&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="navbar-item"&gt;
&lt;div class="buttons"&gt;
&lt;button type="button" class="button is-primary" id="switchAriaTheme" aria-label="Renforcer la visibilité de ce site" title="Renforcer la visibilité de ce site"&gt;
&lt;i class="icon-eye"&gt;&lt;/i&gt;
&lt;/button&gt;
&lt;a class="button is-danger" href="/se-deconnecter"&gt;
Déconnexion
&lt;/a&gt;

View file

@ -1,4 +1,4 @@
<div class="box">
<div class="box mini">
<form method="POST">
<h1>
Connexion
@ -12,9 +12,11 @@
<input type="password" name="password" id="password" placeholder="********">
</div>
<% if ( config.registrationOpen === true ) { %>
<div class="text-right mt-10">
<p>Pas encore inscrit ? <a href="/inscription">Inscrivez-vous</a></p>
</div>
<% } %>
<button type="submit" class="button is-primary">Connexion</button>
</form>

View file

@ -14,13 +14,13 @@
<p class="text-justify">
<strong>MusicTopus</strong> est une application Web (que vous pouvez auto-héberger) et un site Web (sur lequel vous pouvez créer un compte), permettant de gérer votre liste des CDs et Vinyles, et de l'utiliser facilement et n'importe où.
<br />
Le code source est publié sous licence libre <a href="https://www.gnu.org/licenses/gpl-3.0-standalone.html" target="_blank" rel="noopener noreferrer">GNU GPL-3.0-or-later <i class="icon-link"></i></a>. Le code source est disponible sur <a href="https://git.darkou.fr/dbroqua/MusicTopus" target="_blank">git.darkou.fr <i class="icon-link"></i></a>.
Le code source est publié sous licence libre <a href="https://www.gnu.org/licenses/gpl-3.0-standalone.html" target="_blank" rel="noopener noreferrer">GNU GPL-3.0-or-later <i class="icon-link"></i></a>. Le code source est disponible sur <a href="https://forge.darkou.fr/contact/MusicTopus" target="_blank">forge.darkou.fr <i class="icon-link"></i></a>.
</p>
<h2>
Pourquoi utiliser MusicTopus ?
</h2>
<p class="text-justify">
<strong>MusicTopus</strong> est indispensable lorsqu'une collection, de CD-audios et vyniles, est devenue trop importante pour qu'on puisse se souvenir de tous les albums qu'elle contient. Consulter MusicTopus peut par exemple éviter un achat en double, et de savoir qu'on a des albums à céder ou échanger.
<strong>MusicTopus</strong> est indispensable lorsqu'une collection, de CD-audios et vinyles, est devenue trop importante pour qu'on puisse se souvenir de tous les albums qu'elle contient. Consulter MusicTopus peut par exemple éviter un achat en double, et de savoir qu'on a des albums à céder ou échanger.
<br />
Il existe déjà plusieurs applications de gestion de librairies musicales mais, (au moment de l'édition de cette présentation) aucune facilement accessible via internet, par exemple lorsqu'on est chez un disquaire.
</p>

View file

@ -0,0 +1,19 @@
<main class="layout-maxed">
<div class="header layout-hero"></div>
<h1>
Inscription
</h1>
<div class="container">
<div class="text">
<p class="text-justify">
Les inscriptions sur ce site sont fermées.
</p>
<p class="text-justify">
Vous avez cependant la possibilité d'héberger vous même une version de <a href="https://www.musictopus.fr" target="_blank">MusicTopus</a> en vous rendant directement sur le <a href="https://forge.darkou.fr/contact/MusicTopus" target="_blank">dépot du projet</a>.
</p>
</div>
<p class="text-center">
<img src="/img/404.svg" alt="Erreur 404" style="max-height: 400px;" />
</p>
</div>
</main>

View file

@ -1,4 +1,4 @@
<div class="box">
<div class="box mini">
<form method="POST">
<h1>
Inscription

View file

@ -0,0 +1,202 @@
<main class="layout-maxed collection" id="mon-compte">
<h1>
Mon compte
</h1>
<form method="POST" @submit.prevent="updateProfil">
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
<div class="box">
<h2>Mes données personnelles</h2>
<div>
<div class="field">
<label for="email">Adresse e-mail</label>
<input
type="email"
readonly
disabled
name="email"
id="email"
v-model="formData.email"
/>
</div>
<div class="field">
<label for="username">Nom d'utilisateur</label>
<input
type="string"
readonly
disabled
name="username"
id="username"
v-model="formData.username"
/>
</div>
<div class="field">
<label for="oldPassword">Mot de passe actuel</label>
<input
type="password"
name="oldPassword"
id="oldPassword"
placeholder="Saisisssez votre mot de passe actuel"
v-model="formData.oldPassword"
/>
<div class="message error" v-if="errors.includes('emptyPassword')">
Pour changer votre mot de passe vous devez saisir votre mot de passe actuel
</div>
</div>
<div class="field">
<label for="password">Nouveau mot de passe</label>
<input
type="password"
name="password"
id="password"
placeholder="Saisisssez votre nouveau mot de passe"
v-model="formData.password"
/>
</div>
<div class="field">
<label for="passwordConfirm">Nouveau mot de passe (confirmation)</label>
<input
type="password"
name="passwordConfirm"
id="passwordConfirm"
placeholder="Confirmez votre nouveau mot de passe"
v-model="formData.passwordConfirm"
/>
<div class="message error" v-if="errors.includes('passwordsDiffer')">
La confirmation ne correspond pas avec votre nouveau mot de passe
</div>
</div>
</div>
</div>
<div class="box">
<h2>Mon activité</h2>
<div>
<div class="field">
<label for="mastodon.publish">Publier sur le fédiverse lorsque j'ajoute un album</label>
<select id="format" v-model="formData.mastodon.publish">
<option value="true">Oui</option>
<option value="false">Non</option>
</select>
<!-- <input
type="checkbox"
name="mastodon.publish"
id="mastodon.publish"
v-model="mastodon.publish"
/> -->
</div>
<div class="field">
<label for="mastodon.url">Url de l'API de votre instance</label>
<input
type="text"
name="mastodon.url"
id="mastodon.url"
v-model="formData.mastodon.url"
placeholder="https://mastodon.social/api/v1/"
/>
</div>
<div class="field">
<label for="mastodon.token">Jeton d'accès (droits nécessaires : write:media et write:statuses)</label>
<input
type="text"
name="mastodon.token"
id="mastodon.token"
v-model="formData.mastodon.token"
/>
</div>
<div class="field">
<label for="mastodon.message">Message</label>
<textarea
name="mastodon.message"
id="mastodon.message"
v-model="formData.mastodon.message"
></textarea>
<label for="mastodon.wantlist">Message pour la liste de souhaits</label>
<textarea
name="mastodon.wantlist"
id="mastodon.wantlist"
v-model="formData.mastodon.wantlist"
></textarea>
<small>
Variables possibles :
<ul>
<li>{artist}, exemple : Iron Maiden</li>
<li>{album}, exemple : Powerslave</li>
<li>{format}, exemple : Cassette</li>
<li>{year}, exemple: 1984</li>
<li>{video}, exemple : https://www.youtube.com/watch?v=Qx0s8OqgBIw</li>
<li>{genres}, exemple : Rock</li>
<li>{#genres}, exemple : #rock</li>
<li>{styles}, exemple : Hard Rock, Heavy Metal</li>
<li>{#styles}, exemple : #hardRock, #heavyMetal</li>
</ul>
</small>
</div>
<button type="button" class="button is-secondary mt-10" :disabled="loading" @click="testMastodon">
<span>Tester</span>
</button>
</div>
</div>
<div class="box">
<h2>Mes préférences</h2>
<div>
<div class="field">
<label for="pagination">Pagination</label>
<select id="pagination" v-model="formData.pagination">
<option value="16">16 albums/page</option>
<option value="24">24 albums/page</option>
<option value="32">32 albums/page</option>
<option value="48">48 albums/page</option>
</select>
</div>
</div>
</div>
<div class="box">
<h2>Supprimer mon compte</h2>
<div>
<p>
Vous souhaitez supprimer votre compte et vos collections ?
<br />
Rien de plus simple !
<br />
Il vous suffit de cliquer sur le bouton ci-dessous et l'on se charge de supprimer dans la seconde absolument toutes les données stockées sur cette instance vous concernant !
</p>
<div class="flash error">
<div class="header">
Attention
</div>
<div class="body">
Toute suppression est définitive
</div>
</div>
<div>
<label for="delete">
<input type="checkbox" id="delete" v-model="formData.delete">
J'ai compris
</label>
</div>
<button type="button" class="button is-danger mt-10" :disabled="deleting || !formData.delete" @click="deleteAccount">
<span v-if="!deleting">Supprimer</span>
<i class="icon-spin animate-spin" v-if="deleting"></i>
</button>
</div>
</div>
<button type="submit" class="button is-primary mt-10" :disabled="loading">
<span v-if="!loading">Mettre à jour</span>
<i class="icon-spin animate-spin" v-if="loading"></i>
</button>
</div>
</form>
</main>
<script>
const email = '<%= user.email %>';
const username = '<%= user.username %>';
const pagination = "<%= user.pagination || 16 %>";
const mastodon = <%- JSON.stringify(user.mastodon || {publish: false, url: '', token: '', message: ''}) %>;
</script>

View file

@ -1,6 +1,15 @@
<main class="layout-maxed ma-collection-details" id="app" v-cloak @keyup="changeImage">
<main class="layout-maxed ma-collection-details" id="ma-collection-details" v-cloak @keyup="changeImage">
<h1>{{item.artists_sort}} - {{item.title}}</h1>
<h1>
<template v-for="artist in item.artists">
<a :href="`/<%= page.action === 'albums' ? 'ma-collection' : 'ma-liste-de-souhaits' %>?page=1&limit=16&sort=year&order=asc&artist=${artist.name}`">{{artist.name}}</a>
<template v-if="artist.join">&nbsp;{{artist.join}}&nbsp;</template>
</template>
- {{item.title}}
<i class="icon-trash" title="Supprimer cette fiche" @click="showConfirmDelete()"></i>
<i class="icon-refresh" title="Mettre à jour les données de cette fiche" @click="updateItem()"></i>
<i class="icon-share" title="Partager cet album sur le fédiverse" @click="showModalShare = true" v-if="canShareItem"></i>
</h1>
<div class="grid sm:grid-cols-3 gap-16">
<div class="text-center">
<img :src="item.thumb %>" :alt="`Miniature pour l'album ${item.title}`" />
@ -12,271 +21,83 @@
</div>
</div>
<hr />
<div class="grid md:grid-cols-3 gap-16">
<div>
<template v-for="album in tracklist">
<strong v-if="album.title">{{album.title}}</strong>
<ol class="ml-4">
<li v-for="track in album.tracks">
{{ track.title }} <template v-if="track.duration">({{track.duration}})</template>
<ul v-if="track.extraartists && track.extraartists.length > 0" class="sm-hidden ml-4">
<li v-for="extra in track.extraartists">
<small>{{extra.role}} : {{extra.name}}</small>
</li>
</ul>
</li>
</ol>
</template>
</div>
<div class="md:col-span-2">
<div class="grid grid-cols-2 gap-10">
<div>
<strong>Genres</strong>
<br />
<template v-for="(genre, index) in item.genres">
{{genre}}<template v-if="index < item.genres.length - 1">, </template>
</template>
</div>
<div>
<strong>Styles</strong>
<br />
<span v-for="(style, index) in item.styles">
{{style}}<template v-if="index < item.styles.length - 1">, </template>
</span>
</div>
</div>
<hr />
<div class="grid grid-cols-3 gap-10">
<div>
<strong>Pays</strong>
<br />
<span>{{item.country}}</span>
</div>
<div>
<strong>Année</strong>
<br />
<span>{{item.year}}</span>
</div>
<div>
<strong>Date de sortie</strong>
<br />
<span>{{item.released}}</span>
</div>
</div>
<hr />
<div class="grid gap-10">
<div>
<strong>Format</strong>
<ul class="ml-4">
<li v-for="(format) in item.formats">
{{format.name}}
<template v-if="format.descriptions && format.descriptions.length > 0">
(<span v-for="(description, index) in format.descriptions">
{{description}}<template v-if="index < format.descriptions.length - 1">, </template>
</span>)
</template>
</li>
</ul>
</div>
</div>
<hr />
<div class="grid grid-cols-2 gap-10">
<div>
<strong id="identifiers">Codes barres</strong>
<ol class="ml-4">
<li v-for="identifier in identifiers">
{{identifier.value}} ({{identifier.type}})
</li>
</ol>
<template v-if="item.identifiers.length > identifiersPreviewLength">
<button type="button" class="button is-link" v-if="identifiersMode === 'preview'" @click="showAllIdentifiers">
Voir la suite
</button>
<button type="button" class="button is-link" v-if="identifiersMode === 'all'" @click="showLessIdentifiers">
Voir moins
</button>
</template>
</div>
<div>
<strong>Label</strong>
<br />
<template v-for="label in item.labels">
{{label.name}} {{label.catno}}
<br />
</template>
<hr />
<strong>Sociétés</strong>
<br />
<template v-for="company in item.companies">
<strong>{{company.entity_type_name}}</strong> : {{company.name}}
<br />
</template>
</div>
</div>
<!-- <hr />
<div class="grid gap-10">
<div>
<strong>Note</strong>
<br />
<span>{{item.notes}}</span>
</div>
</div> -->
<hr />
<div class="grid gap-10">
<div>
<strong>Vidéos</strong>
<dl>
<template v-for="video in item.videos">
<dt>
<a :href="video.uri" target="_blank" rel="noopener noreferrer">{{video.title}}</a>
</dt>
<dd>
{{video.description}}
</dd>
</template>
</dl>
</div>
</div>
</div>
</div>
<%- include('../../../components/album.ejs') %>
<div class="modal" :class="{'is-visible': modalIsVisible}">
<div class="modal-background"></div>
<button type="button" aria-label="Fermer" class="close" @click="toggleModal"></button>
<button type="button" aria-label="Image précédente" class="navigation previous" @click="previous" v-if="index > 0">
<i class="icon-left-open"></i>
</button>
<button type="button" aria-label="Image suivante" class="navigation next" @click="next" v-if="index + 1 < item.images.length">
<i class="icon-right-open"></i>
</button>
<div class="carousel">
<button type="button" aria-label="Image précédente" class="navigation previous" @click="previous">
<i class="icon-left-open"></i>
</button>
<div class="text-center">
<img :src="preview" />
</div>
<button type="button" aria-label="Image suivante" class="navigation next" @click="next">
<i class="icon-right-open"></i>
</button>
</div>
</div>
<div class="modal" :class="{'is-visible': showModalDelete}">
<div class="modal-background"></div>
<div class="modal-card">
<img :src="preview" />
<header></header>
<section>
Êtes-vous sûr de vouloir supprimer cet album ?
</section>
<footer>
<button class="button is-primary" @click="deleteItem">Supprimer</button>
<button class="button" @click="toggleModalDelete">Annuler</button>
</footer>
</div>
</div>
<div class="modal" :class="{'is-visible': showModalShare}">
<div class="modal-background"></div>
<div class="modal-card">
<header>Partager un album sur le fédiverse</header>
<section>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10">
<div class="field">
<label for="shareMessage">Message</label>
<textarea
name="shareMessage"
id="shareMessage"
v-model="shareMessage"
rows="6"
></textarea>
Caractères utilisés : {{ shareMessageLength }}
</div>
<div>
<small>
Variables possibles :
<ul>
<li>{artist}, exemple : Iron Maiden</li>
<li>{album}, exemple : Powerslave</li>
<li>{format}, exemple : Cassette</li>
<li>{year}, exemple: 1984</li>
<li>{video}, exemple : https://www.youtube.com/watch?v=Qx0s8OqgBIw</li>
<li>{genres}, exemple : Rock</li>
<li>{#genres}, exemple : #rock</li>
<li>{styles}, exemple : Hard Rock, Heavy Metal</li>
<li>{#styles}, exemple : #hardRock, #heavyMetal</li>
</ul>
</small>
</div>
</div>
</section>
<footer>
<button :class="['button is-primary', shareSubmiting ? 'is-disabled' : '']" @click="shareAlbum">Partager</button>
<button class="button" @click="showModalShare=!showModalShare">Annuler</button>
</footer>
</div>
</div>
</main>
<script>
Vue.createApp({
data() {
return {
item: <%- JSON.stringify(page.item) %>,
tracklist: [],
identifiers: [],
modalIsVisible: false,
identifiersMode: 'preview',
identifiersPreviewLength: 16,
preview: null,
index: null,
}
},
created() {
this.setTrackList();
this.setIdentifiers();
window.addEventListener("keydown", this.changeImage);
},
destroyed() {
window.removeEventListener('keydown', this.changeImage);
},
methods: {
setIdentifiers() {
this.identifiers = [];
let max = this.identifiersMode == 'preview' && this.item.identifiers.length > this.identifiersPreviewLength ? this.identifiersPreviewLength : this.item.identifiers.length;
for ( let i = 0 ; i < max ; i += 1 ) {
this.identifiers.push(this.item.identifiers[i]);
}
},
setTrackList() {
let subTrack = {
type: null,
title: null,
tracks: [],
};
for (let i = 0 ; i < this.item.tracklist.length ; i += 1 ) {
const {
type_,
title,
position,
duration,
extraartists,
} = this.item.tracklist[i];
if ( type_ === 'heading' ) {
if ( subTrack.type ) {
this.tracklist.push(subTrack);
subTrack = {
type: null,
title: null,
tracks: [],
};
}
subTrack.type = type_;
subTrack.title = title;
} else {
subTrack.tracks.push({
title,
position,
duration,
extraartists
});
}
}
this.tracklist.push(subTrack);
},
setImage() {
this.preview = this.item.images[this.index].uri;
},
showGallery(event) {
const item = event.target.tagName === 'IMG' ? event.target.parentElement : event.target;
const {
index,
} = item.dataset;
this.index = Number(index);
this.modalIsVisible = true;
this.setImage();
},
toggleModal() {
this.modalIsVisible = !this.modalIsVisible;
},
previous() {
this.index = this.index > 0 ? this.index - 1 : this.item.images.length -1;
this.setImage();
},
next() {
this.index = (this.index +1) === this.item.images.length ? 0 : this.index + 1;
this.setImage();
},
changeImage(event) {
const direction = event.code;
if ( this.modalIsVisible && ['ArrowRight', 'ArrowLeft', 'Escape'].indexOf(direction) !== -1 ) {
switch (direction) {
case 'ArrowRight':
return this.next();
case 'ArrowLeft':
return this.previous();
default:
this.modalIsVisible = false;
return true;
}
}
},
showAllIdentifiers() {
this.identifiersMode = 'all';
this.setIdentifiers();
},
showLessIdentifiers() {
this.identifiersMode = 'preview';
this.setIdentifiers();
document.querySelector('#identifiers').scrollIntoView({ behavior: 'smooth' });
},
},
}).mount('#app');
const item = <%- JSON.stringify(page.item) %>;
const action = "<%- page.action %>";
const canShareItem = <%= user.mastodon?.publish || false %>;
</script>

View file

@ -1,5 +1,9 @@
<main class="layout-maxed" id="app">
<h1>Exporter ma collection</h1>
<main class="layout-maxed" id="exporter">
<% if (page.action === 'albums') { %>
<h1>Exporter ma collection</h1>
<% } else { %>
<h1>Exporter ma liste de souhaits</h1>
<% } %>
<p>
Les formats CSV et Excel sont facilement lisiblent par un humain. Dans ces 2 formats vous trouverez seulement les informations principales de vos albums, à savoir :
</p>
@ -47,22 +51,5 @@
</main>
<script>
Vue.createApp({
data() {
return {
format: 'xml',
}
},
created() {
},
destroyed() {
},
methods: {
exportCollection(event) {
event.preventDefault();
window.open(`/api/v1/albums?exportFormat=${this.format}`, '_blank');
}
},
}).mount('#app');
const action = "<%- page.action %>";
</script>

View file

@ -0,0 +1,51 @@
<main class="layout-maxed" id="importer">
<% if (page.action === 'albums') { %>
<h1>Importer une collection</h1>
<% } else { %>
<h1>Importer une liste de souhaits</h1>
<% } %>
<p>
Il est actuellement possible d'importer <%= page.action === 'albums' ? "une collection" : "une liste de souhaits" %> provenant de discogs.
<br />
Vous devez dans un premier temps vous rendre sur la page <a href="https://www.discogs.com/fr/users/export" target="_blank" rel="noopener noreferrer">Exporter</a> de discogs.
<br />
Une fois exporter vous recevrez un mail de Discogs avec un lien de téléchargement. Une fois le fichier .zip téléchargé vous devez en extraire le fichier .csv afin de l'importer dans MusicTopus.
</p>
<p>
D'autres formats d'imports seront ajoutés par la suite, comme l'import entre 2 instances MusicTopus.
</p>
<div class="flash info">
<div class="header">
Information
</div>
<div class="body">
Si un album est déjà présent en base celui-ci sera ignoré.
</div>
</div>
<form @submit="importCollection">
<div class="field">
<label for="file">Fichier .csv</label>
<input type="file" name="file" id="file" @change="handleFileUpload( $event )" accept=".csv">
</div>
<div class="field">
<span>
Albums à impoter : <strong>{{content.length}}</strong>
</span>
</div>
<button type="submit" class="button is-primary my-16" :disabled="disabled">
<i v-if="['parse', 'submit'].includes(state)" class="icon-spin animate-spin"></i>
<span v-if="state === 'default'">Importer</span>
<span v-if="state === 'parse'">Analyse en cours...</span>
<span v-if="state === 'submit'">Importation en cours... ({{imported}}/{{content.length}})</span>
<span v-if="state === 'done'">Importatation terminée</span>
</button>
</form>
</main>
<script>
const action = "<%- page.action %>";
</script>

Some files were not shown because too many files have changed in this diff Show more