Compare commits

...

84 commits
1.2 ... develop

Author SHA1 Message Date
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: #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: #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: #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: #85
Co-authored-by: dbroqua <contact@darkou.fr>
Co-committed-by: dbroqua <contact@darkou.fr>
2023-07-22 18:19:21 +02:00
e0f227af08 1.4.4 (#84)
Fonctionnalités :
- #82 - Utilisateur artists plutôt que artists_sort

Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #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
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: #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
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: #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
9fe49eca27 Version 1.4.1 (#70)
- #69 Partager ma collection

Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #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
1d59ee3b71 Version 1.4 (#67)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #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
da08aa0222 #41 - Ajouter une sécurité sur la page nous contacter (#59)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #59
2022-09-01 11:37:57 +02:00
2da6afa06d #40 - Formulaire de contact via SMTP (#58)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #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
6320764743 #56 (#57)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #57
2022-08-29 23:22:28 +02:00
d473899b20 Update 'README.md' 2022-08-29 14:00:35 +02:00
827dcb9ccc #50 - Sur ma collection les filtres génèrent une nouvelle url (#55)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #55
2022-08-29 08:36:43 +02:00
1377b4c0c1 #51 - Avoir une animation au chargement de la liste (#54)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #54
2022-08-29 08:13:06 +02:00
080471eb37 #47 - Changer la couleur du burger en fonction du thème (#53)
Co-authored-by: dbroqua <contact@darkou.fr>
Reviewed-on: #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
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: #43
2022-04-09 00:42:24 +02:00
74 changed files with 21002 additions and 1545 deletions

View file

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

2
.gitignore vendored
View file

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

View file

@ -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`.
@ -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
@ -227,6 +229,13 @@ S3_BUCKET # Nom du bucket (musictopus par défaut, à changer impérativement si
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

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
@ -37,6 +37,12 @@ services:
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:

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

118
fontello.json Normal file
View file

@ -0,0 +1,118 @@
{
"name": "icon",
"css_prefix_text": "icon-",
"css_use_suffix": false,
"hinting": true,
"units_per_em": 1000,
"ascent": 850,
"glyphs": [
{
"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"
}
]
}

46
gulpfile.js Normal file
View file

@ -0,0 +1,46 @@
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/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,193 @@
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",
],
};
},
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/albums", {
album: this.details,
share: this.share,
})
.then(() => {
window.location.href = "/ma-collection";
})
.catch((err) => {
this.submitting = false;
showToastr(
err.response?.data?.message ||
"Impossible d'ajouter cet album pour le moment…"
);
});
},
orderedItems(items) {
return items.sort();
},
},
}).mount("#ajouter-album");

244
javascripts/collection.js Normal file
View file

@ -0,0 +1,244 @@
Vue.createApp({
data() {
return {
loading: false,
moreFilters: false,
items: [],
total: 0,
// eslint-disable-next-line no-undef
page: query.page || 1,
totalPages: 1,
limit: 16,
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();
},
methods: {
formatParams(param) {
return param.replace("&", "%26").replace("+", "%2B");
},
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;
break;
default:
if (["order", "sort"].indexOf(key) !== -1) {
sortOrder[key] = value;
}
this[key] = value;
}
}
this.sortOrder = `${sortOrder.sort}-${sortOrder.order}`;
let url = `/api/v1/albums?page=${this.page}&limit=${this.limit}&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.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/albums/${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;
},
},
}).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,103 @@
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
mastodon: mastodon || {
publish: false,
url: "",
token: "",
message:
"Je viens d'ajouter {artist} - {album} à ma collection !",
},
},
loading: 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 } =
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;
}
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;
},
},
}).mount("#mon-compte");
}

View file

@ -0,0 +1,248 @@
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.changeImage);
},
destroyed() {
window.removeEventListener("keydown", this.changeImage);
},
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("{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) {
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;
}
}
return true;
},
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/albums/${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/albums/${this.item._id}`)
.then(() => {
window.location.href = "/ma-collection";
})
.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/albums/${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,16 @@
Vue.createApp({
data() {
return {
format: "xml",
};
},
created() {},
destroyed() {},
methods: {
exportCollection(event) {
event.preventDefault();
window.open(`/api/v1/albums?exportFormat=${this.format}`, "_blank");
},
},
}).mount("#exporter");

View file

@ -0,0 +1,106 @@
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/albums?discogsId=${release_id}`
);
if (res.status === 204) {
await axios.post("/api/v1/albums", {
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");

71
javascripts/theme.js Normal file
View file

@ -0,0 +1,71 @@
/**
* 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);
}
/**
* 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";
}
// INFO: On place un event sur le bouton
const toggleSwitch = document.querySelector(
'.theme-switch input[type="checkbox"]'
);
/**
* 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);
toggleSwitch.checked = preferedColorScheme === "dark";
}
});
}
const currentTheme = localStorage.getItem("theme") || getPreferredColorScheme();
// INFO: Au chargement de la page on détecte le thème à charger
setColorScheme(currentTheme);
toggleSwitch.checked = currentTheme === "dark";
toggleSwitch.addEventListener(
"change",
(e) => {
e.preventDefault();
const scheme = e.target.checked ? "dark" : "light";
saveColorScheme(scheme);
setColorScheme(scheme);
},
false
);

18175
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,16 +4,17 @@
"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": {
@ -23,7 +24,7 @@
"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,10 +39,11 @@
"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",
"aws-sdk": "^2.1110.0",
"axios": "^0.26.0",
"connect-ensure-login": "^0.1.1",
"connect-flash": "^0.1.1",
@ -55,10 +57,17 @@
"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",
"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",
@ -66,14 +75,16 @@
"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__/**",

Binary file not shown.

View file

@ -26,6 +26,8 @@
<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="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 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

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';
}
});

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

@ -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,6 +37,9 @@ $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%)};
@ -56,6 +61,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};
@ -94,4 +103,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 {
main {
&.error {
min-height: calc(100vh - 3.25rem - 100px);
padding-top: 4rem;
}
}

View file

@ -9,7 +9,7 @@
margin: 2rem auto;
.header {
font-weight: 800;
font-weight: 700;
}
&.info {

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

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,11 +1,11 @@
@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?41426785');
src: url('/font/icon.eot?41426785#iefix') format('embedded-opentype'),
url('/font/icon.woff2?41426785') format('woff2'),
url('/font/icon.woff?41426785') format('woff'),
url('/font/icon.ttf?41426785') format('truetype'),
url('/font/icon.svg?41426785#icon') format('svg');
font-weight: normal;
font-style: normal;
}
@ -42,6 +42,7 @@
.icon-left-open:before { content: '\e807'; } /* '' */
.icon-right-open:before { content: '\e808'; } /* '' */
.icon-export:before { content: '\e809'; } /* '' */
.icon-refresh:before { content: '\e80a'; } /* '' */
.icon-spin:before { content: '\e839'; } /* '' */
.icon-link-ext:before { content: '\f08e'; } /* '' */
.icon-sun:before { content: '\f185'; } /* '' */

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,8 +15,10 @@
@import './icons';
@import './list';
@import './box';
@import './loader';
@import './error';
@import './messages.scss';
@import './500';
@import './home';
@import './ajouter-un-album';

View file

@ -42,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;
@ -128,7 +131,6 @@
min-width: 100%;
position: absolute;
top: 100%;
z-index: 20;
}
&:hover {
@ -279,7 +281,6 @@
min-width: 100%;
position: absolute;
top: 100%;
z-index: 20;
.navbar-item {
white-space: nowrap;

View file

@ -3,15 +3,18 @@
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;
border-radius: 6px;
&.error {
background-color: $danger-color;
color: $button-alternate-color;
border-radius: 6px;
}
&.success {
background-color: $success-color;

View file

@ -22,10 +22,13 @@ import importJobsRouter from "./routes/jobs";
import importAlbumRouterApiV1 from "./routes/api/v1/albums";
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";
passportConfig(passport);
mongoose.set("strictQuery", false);
mongoose
.connect(mongoDbUri, { useNewUrlParser: true, useUnifiedTopology: true })
.catch(() => {
@ -34,7 +37,7 @@ mongoose
const sess = {
cookie: {
maxAge: 86400000,
maxAge: 604800000, // INFO: 7 jours
},
secret,
saveUninitialized: false,
@ -74,14 +77,6 @@ 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);
@ -90,7 +85,9 @@ app.use("/collection", collectionRouter);
app.use("/jobs", importJobsRouter);
app.use("/api/v1/albums", importAlbumRouterApiV1);
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) => {

View file

@ -19,4 +19,14 @@ module.exports = {
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;
};

View file

@ -1,4 +1,5 @@
import AWS from "aws-sdk";
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";
@ -10,13 +11,9 @@ import {
s3BaseFolder,
s3Endpoint,
s3Bucket,
s3Signature,
// s3Signature,
} from "../config";
AWS.config.update({
accessKeyId: awsAccessKeyId,
secretAccessKey: awsSecretAccessKey,
});
/**
* Fonction permettant de stocker un fichier local sur S3
* @param {String} filename
@ -27,23 +24,28 @@ AWS.config.update({
*/
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 s3 = new AWS.S3({
endpoint: s3Endpoint,
signatureVersion: s3Signature,
});
await s3
.putObject({
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",
})
.promise();
endpoint: s3Endpoint,
},
});
await multipartUpload.done();
if (deleteFile) {
fs.unlinkSync(file);
@ -62,11 +64,15 @@ export const uploadFromUrl = async (url) => {
const filename = `${uuid()}.jpg`;
const file = `/tmp/${filename}`;
const { data } = await axios.get(url, { responseType: "arraybuffer" });
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);
// return s3Object;
};

View file

@ -1,5 +1,9 @@
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";
@ -7,7 +11,8 @@ import AlbumsModel from "../models/albums";
import JobsModel from "../models/jobs";
import UsersModel from "../models/users";
import ErrorEvent from "../libs/error";
// import { uploadFromUrl } from "../libs/aws";
import { getAlbumDetails } from "../helpers";
/**
* Classe permettant la gestion des albums d'un utilisateur
@ -20,9 +25,21 @@ class Albums extends Pages {
*/
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 = {
...body,
discogsId: body.id,
...albumDetails,
discogsId: albumDetails.id,
User: user._id,
};
data.released = data.released
@ -38,11 +55,88 @@ class Albums extends Pages {
model: "Albums",
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, message } = 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 = `${(
message ||
"Je viens d'ajouter {artist} - {album} à ma collection !"
)
.replaceAll("{artist}", data.artists[0].name)
.replaceAll("{format}", data.formats[0].name)
.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;
}
@ -79,20 +173,22 @@ class Albums extends Pages {
exportFormat = "json",
sort = "artists_sort",
order = "asc",
artists_sort,
artist,
format,
year,
genre,
style,
userId: collectionUserId,
discogsIds,
discogsId,
} = this.req.query;
let userId = this.req.user?._id;
const where = {};
if (artists_sort) {
where.artists_sort = artists_sort;
if (artist) {
where["artists.name"] = artist;
}
if (format) {
where["formats.name"] = format;
@ -134,6 +230,13 @@ class Albums extends Pages {
userId = userIsSharingCollection._id;
}
if (discogsIds) {
where.discogsId = { $in: discogsIds };
}
if (discogsId) {
where.discogsId = Number(discogsId);
}
const count = await AlbumsModel.count({
User: userId,
...where,
@ -182,13 +285,63 @@ class Albums extends Pages {
}
}
/**
* 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 AlbumsModel.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 AlbumsModel.findOne(query);
if (!album) {
throw new ErrorEvent(
404,
"Mise à jour",
"Impossible de trouver cet album"
);
}
const values = await getAlbumDetails(album.discogsId);
await AlbumsModel.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 AlbumsModel.findOneAndDelete({
user: this.req.user._id,
User: this.req.user._id,
_id: this.req.params.itemId,
});
@ -196,7 +349,88 @@ class Albums extends Pages {
return true;
}
throw new ErrorEvent(404, "Impossible de trouver cet album");
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 AlbumsModel.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;
}
/**
@ -204,7 +438,7 @@ class Albums extends Pages {
*/
async loadMyCollection() {
const artists = await Albums.getAllDistincts(
"artists_sort",
"artists.name",
this.req.user._id
);
const formats = await Albums.getAllDistincts(
@ -233,17 +467,7 @@ class Albums extends Pages {
* Méthode permettant d'afficher le détails d'un album
*/
async loadItem() {
const { itemId: _id } = this.req.params;
const { _id: User } = this.req.user;
const album = await AlbumsModel.findOne({
_id,
User,
});
const item = {
...album.toJSON(),
released: formatDate(album.released, "MM/dd/yyyy"),
};
const item = await this.getOne();
this.setPageContent("item", item);
this.setPageTitle(
@ -251,6 +475,31 @@ class Albums extends Pages {
);
}
/**
* 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 AlbumsModel.count({
User,
});
const items = await AlbumsModel.find(
{
User,
},
[],
{
skip: Math.floor(Math.random() * (count + 1)),
limit: 1,
}
);
this.req.params.itemId = items[0]._id;
await this.loadItem();
}
/**
* Méthode permettant de créer la page "collection/:userId"
*/
@ -267,7 +516,7 @@ class Albums extends Pages {
);
}
const artists = await Albums.getAllDistincts("artists_sort", userId);
const artists = await Albums.getAllDistincts("artists.name", userId);
const formats = await Albums.getAllDistincts("formats.name", userId);
const years = await Albums.getAllDistincts("year", userId);
const genres = await Albums.getAllDistincts("genres", userId);

View file

@ -18,7 +18,7 @@ class Jobs {
throw new ErrorEvent(
404,
"Item non trouvé",
`L'album avant l'id ${itemId} n'existe plus dans la collection`
`L'album avec l'id ${itemId} n'existe plus dans la collection`
);
}

View file

@ -12,21 +12,47 @@ class Me extends Pages {
* @return {Object}
*/
async patchMe() {
const { body, user } = this.req;
const { body } = this.req;
const { _id } = this.req.user;
const schema = Joi.object({
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, ""),
},
});
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.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);
}
@ -35,34 +61,7 @@ class Me extends Pages {
});
});
return update;
}
/**
* Méthode permettant de modifier le mot de passe d'un utilisateur
*/
async updatePassword() {
const { body } = this.req;
const { _id } = this.req.user;
const schema = Joi.object({
oldPassword: Joi.string().required(),
password: Joi.string().required(),
passwordConfirm: Joi.ref("password"),
});
const value = await schema.validateAsync(body);
const user = await UsersModel.findById(_id);
if (!user.validPassword(value.oldPassword)) {
throw new Error("Votre ancien mot de passe n'est pas valide");
}
user.salt = value.password;
await user.save();
this.req.flash("success", "Profil correctement mis à jour");
return user;
}
}

View file

@ -29,6 +29,12 @@ const UserSchema = new mongoose.Schema(
type: Boolean,
default: false,
},
mastodon: {
publish: Boolean,
token: String,
url: String,
message: String,
},
},
{
timestamps: true,

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

@ -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

@ -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,20 @@ 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("/exporter")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
@ -32,6 +46,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);

View file

@ -8,9 +8,7 @@ import render from "../libs/format";
// eslint-disable-next-line new-cap
const router = express.Router();
router
.route("/")
.get(ensureLoggedIn("/connexion"), async (req, res, next) => {
router.route("/").get(ensureLoggedIn("/connexion"), async (req, res, next) => {
try {
const page = new Me(req, "mon-compte/index");
@ -20,16 +18,6 @@ router
} catch (err) {
next(err);
}
})
.post(ensureLoggedIn("/connexion"), async (req, res) => {
try {
const page = new Me(req, "mon-compte/index");
await page.updatePassword();
} catch (err) {
req.flash("error", err.toString());
}
res.redirect("/mon-compte");
});
});
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}} : {{extra.name}}</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]}">${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="filters">
<%- include('./artist') %>
<%- include('./format') %>
<%- include('./sort') %>
</div>
<div class="filters" v-if="moreFilters">
<%- include('./year') %>
<%- include('./genre') %>
<%- include('./style') %>
</div>
<span @click="showMoreFilters" class="showMoreFilters">
<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

@ -16,10 +16,6 @@
<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>
<% if ( config.matomoUrl ) { %>
<!-- Matomo -->
<script>
@ -39,7 +35,8 @@
<% } %>
</head>
<body>
<nav class="navbar" aria-label="Navigation principale">
<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">
@ -90,9 +87,19 @@
<a class="navbar-item" href="/ma-collection">
Ma collection
</a>
<a class="navbar-item" href="/ma-collection/on-air">
On air
</a>
<a class="navbar-item" href="/ma-collection/exporter">
Exporter ma collection
</a>
<a class="navbar-item" href="/ma-collection/importer">
Importer une collection
</a>
<hr />
<a class="navbar-item is-danger" href="/se-deconnecter">
Déconnexion
</a>
</div>
</div>
<% } %>
@ -104,24 +111,18 @@
</label>
</div>
</div>
<% if ( !user ) { %>
<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>
</a>
<% } else { %>
<a class="button is-danger" href="/se-deconnecter">
Déconnexion
</a>
</div>
</div>
<% } %>
</div>
</div>
</div>
</div>
</nav>
</nav>
<div id="toastr">
@ -178,7 +179,7 @@
<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>.
<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://git.darkou.fr/dbroqua/MusicTopus" target="_blank">git.darkou.fr <i class="icon-link"></i></a>.
@ -186,5 +187,8 @@
Fait avec ❤️ à Bordeaux.
</p>
</footer>
<script defer src="/js/libs.js"></script>
<script defer src="/js/main.js"></script>
</body>
</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">
<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>
<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>
</form>
</div>
</div>
<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,5 @@
</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 cet album pour le moment…");
});
},
}
}).mount('#app');
const canPublish = <%- (user.mastodon && user.mastodon.publish) || false %>;
</script>

View file

@ -1,97 +1,48 @@
<main class="layout-maxed collection" id="app">
<%
const pageType = page.username ? 'public' : 'private';
%>
<main class="layout-maxed collection" id="collection">
<h1>
Collection de <%= page.username %>
<% 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>
<% } %>
<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>
</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>
<%- include('../components/filters/index') %>
<div class="filters" v-if="moreFilters">
<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>
<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>
<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 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>
<span @click="showMoreFilters" class="showMoreFilters">
<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>
<div class="grid grid-cols-1 md:grid-cols-2 list">
<div class="item" v-if="!loading" v-for="item in items">
<span class="title">
<% if ( pageType === 'private' ) { %>
<a :href="'/ma-collection/' + 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>
<% if ( pageType === 'private' ) { %>
<a :href="'/ma-collection/' + 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>
@ -123,122 +74,84 @@
</div>
<nav class="pagination" role="navigation" aria-label="Pagination">
<ul class="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) || (page - 1) <= p && page + 1 >= p">
<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 === page}" @click="goTo(p)" aria-label="Aller à la page {{p}}">{{ p }}</a>
<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="(page - 3 === p && page - 2 > 1) || (page + 2 === p && page + 2 < totalPages - 1)">
<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>
<% 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,
moreFilters: false,
items: [],
total: 0,
page: 1,
totalPages: 1,
limit: 16,
artist: '',
format: '',
year: '',
genre: '',
style: '',
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}`;
}
if ( this.year ) {
url += `&year=${this.year}`;
}
if ( this.genre ) {
url += `&genre=${this.genre.replace('&', '%26')}`;
}
if ( this.style ) {
url += `&style=${this.style.replace('&', '%26')}`;
}
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();
},
showMoreFilters() {
this.moreFilters = !this.moreFilters;
}
}
}).mount('#app');
const vueType = "<%= pageType %>";
const query = <%- JSON.stringify(query) %>;
<% if ( pageType === 'private' ) { %>
const isPublicCollection = <%= user.isPublicCollection ? 'true' : 'false' %>;
const userId = "<%= user._id %>";
<% } else { %>
const userId = "<%= params.userId %>";
const isPublicCollection = false;
<% } %>
</script>

View file

@ -355,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>
@ -496,9 +497,6 @@
&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,11 +1,13 @@
<main class="layout-maxed collection" id="app">
<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">
<form method="POST" action="/mon-compte" @submit="updateProfil">
<div>
<h2>Mes données personnelles</h2>
<div>
<div class="field">
<label for="email">Adresse e-mail</label>
<input
@ -14,7 +16,7 @@
disabled
name="email"
id="email"
v-model="email"
v-model="formData.email"
/>
</div>
<div class="field">
@ -25,7 +27,7 @@
disabled
name="username"
id="username"
v-model="username"
v-model="formData.username"
/>
</div>
<div class="field">
@ -34,21 +36,21 @@
type="password"
name="oldPassword"
id="oldPassword"
required
placeholder="Saisisssez votre mot de passe actuel"
v-model="oldPassword"
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></div>
<div class="field">
<label for="password">Nouveau mot de passe</label>
<input
type="password"
name="password"
id="password"
required
placeholder="Saisisssez votre nouveau mot de passe"
v-model="password"
v-model="formData.password"
/>
</div>
<div class="field">
@ -57,43 +59,85 @@
type="password"
name="passwordConfirm"
id="passwordConfirm"
required
placeholder="Confirmez votre nouveau mot de passe"
v-model="passwordConfirm"
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>
<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>
<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>
</ul>
</small>
</div>
<button type="button" class="button is-secondary mt-10" :disabled="loading" @click="testMastodon">
<span>Tester</span>
</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>
</form>
</div>
</form>
</main>
<script>
Vue.createApp({
data() {
return {
email: '<%= user.email %>',
username: '<%= user.username %>',
oldPassword: '',
password: '',
passwordConfirm: '',
loading: false,
}
},
methods: {
async updateProfil(event) {
// try {
// if ( this.password !== this.passwordConfirm ) {
// throw "La confirnation du mot de passe ne correspond pas";
// }
// } catch(err) {
// event.preventDefault();
// showToastr(err);
// }
},
}
}).mount('#app');
</script>
const email = '<%= user.email %>';
const username = '<%= user.username %>';
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="`/ma-collection?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,78 @@
</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">
<div class="carousel">
<button type="button" aria-label="Image précédente" class="navigation previous" @click="previous">
<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">
<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>
</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 canShareItem = <%= user.mastodon?.publish || false %>;
</script>

View file

@ -1,4 +1,4 @@
<main class="layout-maxed" id="app">
<main class="layout-maxed" id="exporter">
<h1>Exporter ma collection</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 :
@ -45,24 +45,3 @@
</button>
</form>
</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');
</script>

View file

@ -0,0 +1,43 @@
<main class="layout-maxed" id="importer">
<h1>Importer une collection</h1>
<p>
Il est actuellement possible d'importer une collection 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>

View file

@ -1,340 +0,0 @@
<main class="layout-maxed collection" id="app">
<h1>
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>
</h1>
<a :href="shareLink" v-if="isPublicCollection" target="_blank">
<i class="icon-share"></i> Voir ma collection partagée
</a>
<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>
</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="filters" v-if="moreFilters">
<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>
<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>
<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>
</div>
<span @click="showMoreFilters" class="showMoreFilters">
<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>
<div class="grid grid-cols-1 md:grid-cols-2 list hover">
<div class="item" v-if="!loading" v-for="item in items">
<span class="title">
<a :href="'/ma-collection/' + item._id">{{ item.artists_sort}} - {{ item.title }}</a>
<i class="icon-trash" @click="showConfirmDelete(item._id)"></i>
</span>
<div class="grid grid-cols-2 md:grid-cols-4">
<div>
<a :href="'/ma-collection/' + item._id"><img :src="item.thumb" :alt="item.title" /></a>
</div>
<div class="md:col-span-3">
<span><strong>Année :</strong> {{ item.year }}</span>
<br />
<span><strong>Pays :</strong> {{ item.country }}</span>
<br />
<span>
<strong>Format : </strong>
<span v-for="(format, index) in item.formats">
{{ format.name }}
<template v-if="format.descriptions">
(<template v-for="(description, j) in format.descriptions">
{{description}}<template v-if="j < format.descriptions.length - 1">, </template>
</template>)
</template>
<template v-if="index < item.formats.length - 1">, </template>
</span>
</span>
<br />
<span><strong>Genre :</strong> <template v-for="(genre, index) in item.genres">{{ genre }}<template v-if="index < item.genres.length - 1">, </template></template></span>
<br />
<span><strong>Style :</strong> <template v-for="(style, index) in item.styles">{{ style }}<template v-if="index < item.styles.length - 1">, </template></template></span>
</div>
</div>
</div>
</div>
<div class="total">
<strong>Nombre total d'éléments : </strong>{{total}}
</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>
<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,
moreFilters: false,
items: [],
total: 0,
page: 1,
totalPages: 1,
limit: 16,
artist: '',
format: '',
year: '',
genre: '',
style: '',
sortOrder: 'artists_sort-asc',
sort: 'artists_sort',
order: 'asc',
itemId: null,
showModalDelete: false,
showModalShare: false,
shareLink: `${protocol}//${host}/collection/<%= user._id %>`,
isPublicCollection: <%= user.isPublicCollection ? 'true' : 'false' %>,
}
},
created() {
this.fetch();
},
methods: {
fetch() {
this.loading = true;
let url = `/api/v1/albums?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}`;
}
if ( this.year ) {
url += `&year=${this.year}`;
}
if ( this.genre ) {
url += `&genre=${this.genre.replace('&', '%26')}`;
}
if ( this.style ) {
url += `&style=${this.style.replace('&', '%26')}`;
}
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 votre 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();
},
showMoreFilters() {
this.moreFilters = !this.moreFilters;
},
toggleModal() {
this.showModalDelete = !this.showModalDelete;
},
toggleModalShare() {
this.showModalShare = !this.showModalShare;
},
showConfirmDelete(itemId) {
this.itemId = itemId;
this.toggleModal();
},
deleteItem() {
axios.delete(`/api/v1/albums/${this.itemId}`)
.then( () => {
this.fetch();
})
.catch((err) => {
showToastr(err.response?.data?.message || "Impossible de supprimer cet album");
})
.finally(() => {
this.toggleModal();
});
},
shareCollection() {
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();
});
},
}
}).mount('#app');
</script>

View file

@ -1,22 +1,39 @@
<section class="box">
<section class="box" id="contact">
<h1>Nous contacter</h1>
<form action="https://formspree.io/f/<%= config.formspreeId %>" method="POST">
<form @submit="send" <% if (config.mailMethod === 'formspree' ) { %> id="contact" method="POST" action="https://formspree.io/f/<%= config.formspreeId %>" <% } %>>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16">
<div class="field">
<label for="email">Addresse e-mail*</label>
<input type="email" name="email" id="email" required />
<input type="email" name="email" id="email" v-model="email" required />
</div>
<div class="field">
<label for="name">Prénom, nom</label>
<input type="text" name="name" id="name" />
<input type="text" name="name" id="name" v-model="name" />
</div>
</div>
<div class="field">
<label for="message">Message*</label>
<textarea name="message" id="message" rows="6" required ></textarea>
<textarea name="message" id="message" rows="6" required v-model="message" ></textarea>
</div>
<button type="submit" class="button is-primary">Envoyer</button>
<% if (config.mailMethod !== 'formspree' ) { %>
<img src="/api/v1/contact" alt="Captcha" />
<div class="field">
<label for="captcha">Captcha</label>
<input type="text" name="captcha" id="captcha" v-model="captcha" required />
</div>
<% } %>
<button type="submit" class="button is-primary" :disabled="loading">
<% if (config.mailMethod !== 'formspree' ) { %>
<i class="icon-spin animate-spin" v-if="loading"></i>
<% } %>
Envoyer
</button>
</form>
</section>
<script>
const contactMethod = '<%= config.mailMethod %>';
</script>