ldc-op-insta/resources/ts/bo/components/ParticipationListComponent.vue
2023-04-21 14:49:53 +00:00

721 lines
24 KiB
Vue

<template>
<div class="ig-media-cards-vue row">
<div class="row">
<div class="col-12 col-md-6">
<div class="input-group mb-1">
<label
id="ig-media-cards-search"
class="input-group-text"
><FontAwesomeIcon icon="fa-solid fa-magnifying-glass" /></label>
<input
type="text"
class="form-control"
:placeholder="__('Rechercher dans la liste (utilisateur, lien)')"
:aria-label="__('Rechercher un post instagram dans toute la liste')"
aria-describedby="ig-media-cards-search"
v-model="searchText"
@submit.prevent
:disabled="ajaxLoading"
>
<button
class="btn btn-secondary"
type="button"
:title="__('Efface la recherche')"
data-bs-toggle="tooltip"
@click.prevent="searchText = ''"
:disabled="ajaxLoading"
>
<FontAwesomeIcon icon="fa-solid fa-xmark" />
</button>
</div>
</div>
<div class="col">
<div class="d-flex">
<div class="input-group mb-1 py-2">
<strong>{{
__("Total :total", { total: String(mediaList.length) })
}}</strong>
<span
v-if="ajaxLoading"
class="px-2"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
spin-pulse
/>
</span>
</div>
<div class="input-group mb-1">
<button
class="btn btn-success"
:title="__('Ecrase le classement avec cette nouvelle sélection')"
data-bs-toggle="tooltip"
:disabled="ajaxLoading"
@click.prevent="saveMediaList()"
>
{{ __("Valider le classement") }}
</button>
<button
class="btn btn-primary"
:title="__('Force le rechargement des posts depuis Instagram')"
data-bs-toggle="tooltip"
@click.prevent="fetchMediaList(true)"
:disabled="ajaxLoading"
>
<FontAwesomeIcon :icon="['fas', 'rotate']" />
</button>
</div>
</div>
</div>
</div>
<div
v-if="sMediaList.length"
class="ig-media-cards d-flex flex-wrap"
>
<div
class="ig-media d-flex position-relative user-select-none"
v-for="(igMedia, igMediaIndex) of sMediaList"
:key="igMediaIndex"
>
<label
:for="`${String(name)}${String(igMediaIndex)}`"
:class="`d-flex flex-column justify-content-between flex-grow-1 bg-dark text-bg-dark border-secondary p-1 m-1 border border-2 rounded`"
>
<div
class="d-flex flex-column justify-content-between h-100 user-select-none"
>
<div class="d-flex flex-column text-center">
<img
v-if="mediaFirstPicture(igMedia)"
class="img-fluid ig-picture-mini"
:src="String(mediaFirstPicture(igMedia))"
>
<div class="row">
<div class="col text-center">
<strong
:class="`text-center px-4 fs-${getMediaRank(igMedia)}`"
>{{ `#${getMediaRank(igMedia)}` }}</strong>
</div>
<div
v-if="igMedia.like_count"
class="col text-center"
>
<FontAwesomeIcon :icon="['far', 'heart']" />&nbsp;
<span>{{ igMedia.like_count }}</span>
</div>
<div class="col text-center">
<FontAwesomeIcon :icon="['far', 'message']" />&nbsp;
<span>{{ igMedia.comments_count }}</span>
</div>
</div>
<div class="row px-2">
<div class="col text-center">
<a
:title="
__('Voir le profile de @:username sur Instagram', {
username: igMedia.username,
})
"
data-bs-toggle="tooltip"
:href="`https://www.instagram.com/${igMedia.username}`"
target="_blank"
><strong>@{{ igMedia.username }}</strong></a>
</div>
<div class="col text-center">
<span>{{
new Date(Date.parse(igMedia.timestamp)).toLocaleDateString()
}}</span>
</div>
</div>
<p>"{{ str_limit(igMedia.caption, 95) }}"</p>
<div
class="text-center"
:title="__('Voir le contenu du post en entier')"
data-bs-toggle="tooltip"
>
<div class="btn-group">
<button
class="btn btn-info"
data-bs-toggle="modal"
data-bs-target="#participation-modal"
@click.prevent="initCarousel(igMedia)"
:disabled="ajaxLoading"
>
{{ __("Voir le post entier") }}
</button>
<a
class="btn btn-secondary"
:href="igMedia.permalink"
target="_blank"
>{{ __("Voir sur Instagram") }}</a>
</div>
</div>
</div>
<div class="btn-group">
<button
:title="__('Exclure ce post du classement')"
data-bs-toggle="tooltip"
class="btn btn-sm btn-warning"
:disabled="ajaxLoading"
@click.prevent="excludeMedia(igMedia)"
>
{{ __("Exclure") }}
</button>
<button
:title="
__('Exclure tous les posts de @:username', {
username: igMedia.username,
})
"
data-bs-toggle="tooltip"
class="btn btn-sm btn-danger"
:disabled="ajaxLoading"
@click.prevent="excludeUser(igMedia.username)"
>
{{
__("Exclure @:username", {
username: igMedia.username,
})
}}
</button>
</div>
</div>
</label>
</div>
</div>
<div v-else-if="ajaxLoading">
<span class="text-center fs-4">{{ __("Chargement en cours..") }}</span>
</div>
<div v-else-if="searchText.length">
<span class="text-center fs-4">{{
__("Aucun post Instagram ne correspond à :search", {
search: searchText,
})
}}</span>
</div>
<div v-else>
<span class="text-center fs-4">{{ __("Aucun post Instagram") }}</span>
</div>
<div
id="participation-modal"
class="modal fade"
tabindex="-1"
>
<div class="modal-dialog modal-xl">
<div
v-if="modalMedia"
class="modal-content"
>
<div class="modal-header">
<h5 class="modal-title">
<span :class="`fs-${getMediaRank(modalMedia)}`">#{{ getMediaRank(modalMedia) }}</span>
<a
:title="
__('Voir le profile de @:username sur Instagram', {
username: modalMedia.username,
})
"
data-bs-toggle="tooltip"
:href="`https://www.instagram.com/${modalMedia.username}`"
target="_blank"
>&nbsp;&nbsp;&nbsp;<strong>@{{ modalMedia.username }}</strong></a>
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
:aria-label="__('Fermer la fenêtre')"
/>
</div>
<div class="modal-body">
<div class="row">
<div class="col-12 col-lg-6">
<div
id="carousel-modal-ig-media"
class="carousel slide"
data-bs-ride="carousel"
>
<div class="carousel-indicators">
<button
v-for="(
mediaPictureUrl, mediaPictureIndex
) of mediaPictures(modalMedia)"
:key="mediaPictureIndex"
type="button"
data-bs-target="#carousel-modal-ig-media"
:data-bs-slide-to="mediaPictureIndex"
:class="`${mediaPictureIndex === 0 ? 'active' : ''}`"
:aria-current="mediaPictureIndex === 0 ? true : false"
/>
</div>
<div class="carousel-inner">
<div
:class="`carousel-item ${
mediaPictureIndex === 0 ? 'active' : ''
}`"
v-for="(
mediaPictureUrl, mediaPictureIndex
) of mediaPictures(modalMedia)"
:key="mediaPictureIndex"
>
<img
class="d-block w-100"
:src="mediaPictureUrl"
>
</div>
</div>
<button
class="carousel-control-prev"
type="button"
data-bs-target="#carousel-modal-ig-media"
data-bs-slide="prev"
>
<span
class="carousel-control-prev-icon"
aria-hidden="true"
/>
<span class="visually-hidden">{{ __("Précédent") }}</span>
</button>
<button
class="carousel-control-next"
type="button"
data-bs-target="#carousel-modal-ig-media"
data-bs-slide="next"
>
<span
class="carousel-control-next-icon"
aria-hidden="true"
/>
<span class="visually-hidden">{{ __("Suivant") }}</span>
</button>
</div>
</div>
<div class="col">
<p>{{ modalMedia.caption }}</p>
<strong>
<span v-if="modalMedia.like_count">
<FontAwesomeIcon :icon="['far', 'heart']" />&nbsp;
<span>{{ modalMedia.like_count }}</span> </span>&nbsp;&nbsp;
<span>
<FontAwesomeIcon :icon="['far', 'message']" />&nbsp;
<span>{{ modalMedia.comments_count }}</span> </span>&nbsp;&nbsp;
<span>{{
new Date(
Date.parse(modalMedia.timestamp)
).toLocaleDateString()
}}</span>
</strong>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
{{ __("Fermer") }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import asset from "../../modules/asset";
import trans from "../../modules/trans";
import route from "../../modules/route";
import toast from "../../modules/toast";
import parseLaravelErrorMessages from "../../modules/parseLaravelErrorMessages";
import bsTooltips from "../../modules/bsTooltips";
export default defineComponent({
name: "ParticipationListComponent",
mixins: [asset, trans, route, toast, parseLaravelErrorMessages, bsTooltips],
components: { FontAwesomeIcon },
data(): {
ajaxLoading: boolean;
disabled: boolean;
name: string;
mediaPictureList: Record<number, string[]>;
mediaList: InstaMediaList;
sMediaList: InstaMediaList;
selectedMediaList: InstaMediaList;
modalMedia: null | InstaMedia;
searchText: string;
} {
return {
ajaxLoading: false,
disabled: false,
name: "ig_media_list",
mediaPictureList: {},
mediaList: [],
sMediaList: [],
selectedMediaList: [],
modalMedia: null,
searchText: "",
};
},
mounted() {
if (window.vueDebug) {
console.log("Participation List Component mounted.");
}
const json = String(this.$attrs.json ?? "{}"),
data = JSON.parse(json);
this.mediaList = data.mediaList ?? [];
this.mediaList.forEach((media) => {
this.fetchMediaPicture(media);
});
this.selectedMediaList = data.value ?? [];
this.sMediaList = this.mediaList;
this.initTooltips();
this.fetchMediaList();
},
watch: {
searchText(value: string): void {
this.filterList(value);
},
},
methods: {
scrollTopBody() {
const element = document.getElementById(
"ig-media-cards-search"
) as HTMLLabelElement,
elementPosition = element.getBoundingClientRect().top,
offsetPosition = window.pageYOffset + elementPosition - 80;
scrollTo({
top: offsetPosition,
behavior: "smooth",
});
},
initCarousel(model: InstaMedia): void {
this.modalMedia = model;
const divCarousel = document.getElementById(
"carousel-modal-ig-media"
) as HTMLDivElement;
window.bootstrap.Carousel.getInstance(divCarousel)?.to(0);
},
fetchMediaList(force: boolean = false): void {
const topMediaList = this.route("bo.top-media-list.fetch", {
REFRESH: Number(force),
});
if (!topMediaList) {
throw Error("Missing route 'bo.top-media-list.fetch'");
}
if (!this.ajaxLoading) {
this.ajaxLoading = true;
}
window.axios
.get(topMediaList)
.then((response) => {
if (!response.data.ready) {
setTimeout(() => this.fetchMediaList(), 5000);
return;
}
this.ajaxLoading = false;
this.mediaList = response.data.data as InstaMediaList;
this.sMediaList = Object.assign([], this.mediaList);
setTimeout(
() =>
this.notify(this.__("Les posts Instagram ont été rafraichis.")),
1500
);
})
.catch((e: any) => {
this.ajaxLoading = false;
this.ajaxErrorHandler(e, this.__("Une erreur est survenue."));
})
.then(() => {
this.initTooltips();
this.sMediaList.forEach((media) => this.fetchMediaPicture(media));
});
},
fetchMediaPicture(model: InstaMedia): void {
const igHash = model.permalink.replace(
"https://www.instagram.com/p/",
""
);
const mediaPicture = this.route("bo.top-media-list.picture", {
IGMEDIAHASH: igHash.substring(0, igHash.length - 1),
});
if (!mediaPicture) {
throw Error("Missing route 'bo.top-media-list.picture'");
}
window.axios
.get(mediaPicture)
.then((response) => {
this.mediaPictureList[model.insta_id] = response.data as string[];
})
.catch((e: any) => {
this.ajaxErrorHandler(e, this.__("Une erreur est survenue."));
})
.then(() => this.initTooltips());
},
saveMediaList(): void {
const mediaIndex = this.route("bo.top-media-list.index");
if (!mediaIndex) {
throw Error("Missing route 'bo.top-media-list.index'");
}
const mediaSaveList = this.route("bo.top-media-list.save");
if (!mediaSaveList) {
throw Error("Missing route 'bo.top-media-list.save'");
}
this.ajaxLoading = true;
this.scrollTopBody();
window.axios
.post(mediaSaveList)
.then(() => {
this.ajaxLoading = false;
this.notify(this.__("La liste est enregistrée"));
this.notify(this.__("Redirection.."));
setTimeout(() => (window.location.href = mediaIndex), 2500);
})
.catch((e: any) => {
this.ajaxLoading = false;
this.ajaxErrorHandler(e, this.__("Une erreur est survenue."));
})
.then(() => this.initTooltips());
},
excludeMedia(model: InstaMedia): void {
const mediaExclude = this.route("bo.top-media-list.exclude");
if (!mediaExclude) {
throw Error("Missing route 'bo.top-media-list.exclude'");
}
this.ajaxLoading = true;
this.scrollTopBody();
window.axios
.post(mediaExclude, {
insta_id: model.insta_id,
})
.then(() => {
this.ajaxLoading = false;
this.notify(
this.__(`Le post ${model.permalink} est sur la liste d'exclusion.`)
);
this.notify(this.__("Rafraichissement de la liste.."));
this.fetchMediaList(true);
})
.catch((e: any) => {
this.ajaxLoading = false;
this.ajaxErrorHandler(e, this.__("Une erreur est survenue."));
})
.then(() => this.initTooltips());
},
excludeUser(username: string): void {
const mediaExclude = this.route("bo.top-media-list.exclude");
if (!mediaExclude) {
throw Error("Missing route 'bo.top-media-list.exclude'");
}
this.ajaxLoading = true;
this.scrollTopBody();
window.axios
.post(mediaExclude, {
username: username,
})
.then(() => {
this.ajaxLoading = false;
this.notify(
this.__(`L'utilisateur @${username} est sur la liste d'exclusion.`)
);
this.notify(this.__("Rafraichissement de la liste.."));
this.fetchMediaList(true);
})
.catch((e: any) => {
this.ajaxLoading = false;
this.ajaxErrorHandler(e, this.__("Une erreur est survenue."));
})
.then(() => this.initTooltips());
},
mediaFirstPicture(model: InstaMedia): null | string {
const pictures = this.mediaPictureList[model.insta_id] ?? [];
return pictures[0] ?? null;
},
mediaPictures(model: InstaMedia): string[] {
return this.mediaPictureList[model.insta_id] ?? [];
},
getMediaRank(model: InstaMedia): number {
return this.mediaList.indexOf(model) + 1;
},
ajaxErrorHandler(e: any, defaultMessage: string | null = null): void {
let message =
defaultMessage ?? this.__("Erreur de la récupération du panier");
if (e.response.status === 422) {
message =
this.parseValidationErrors(e.response?.data?.errors ?? {}) || message;
if (window.vueDebug) {
console.warn(e.response.data.errors);
}
} else if (e.response.status === 419) {
// * CSRF TOKEN MISMATCH (On ne pourrais plus faire d'appels AJAX sans recharger la page)
this.errorNotify(
this.__("Votre session a expiré, la page va être rechargée"),
2500
);
setTimeout(() => window.location.reload(), 2000);
} else if (e.response.status === 403) {
// * Access denied
this.errorNotify(
this.__("Vous n'êtes pas autorisé a effectuer cette action"),
2500
);
setTimeout(() => window.location.reload(), 2000);
} else {
if (window.vueDebug) {
console.error(e);
}
}
this.errorNotify(message);
},
isChecked(model: InstaMedia): boolean {
return this.selectedMediaList.find(
(igModel) =>
igModel.insta_id === model.insta_id && model.insta_id !== null
)
? true
: false;
},
str_limit(value: string, size: number): string {
if (!value) return "";
value = value.toString();
if (value.length <= size) {
return value;
}
return value.substr(0, size) + "...";
},
filterList(search: string): void {
this.sMediaList = Object.assign([], this.mediaList);
const results = [];
if (!search.length) {
return;
}
search = search.toLowerCase();
for (const igMedia of this.mediaList) {
const igHash = igMedia.permalink.replace(
"https://www.instagram.com/p/",
""
);
const searchText = `${igMedia.username} ${igHash.substring(
0,
igHash.length - 1
)}`;
// * Doing smart search using contains, fuzzy and distance
let fuzzy =
this.stringSimilarity(searchText.toLowerCase(), search) >= 0.4 ||
this.fuzzysearch(searchText.toLowerCase(), search),
contains = searchText.toLowerCase().indexOf(search) !== -1;
for (const productWord of searchText.toLowerCase().split(" ")) {
for (const keyword of search.split(" ")) {
fuzzy =
fuzzy ||
this.stringSimilarity(productWord, keyword) >= 0.4 ||
this.fuzzysearch(productWord, keyword);
}
}
if (fuzzy || contains) {
results.push(igMedia);
}
}
this.sMediaList = results;
},
fuzzysearch(needle: string, haystack: string): boolean {
let tlen = haystack.length,
qlen = needle.length;
if (qlen > tlen) {
return false;
}
if (qlen === tlen) {
return needle === haystack;
}
outer: for (let i = 0, j = 0; i < qlen; i++) {
let nch = needle.charCodeAt(i);
while (j < tlen) {
if (haystack.charCodeAt(j++) === nch) {
continue outer;
}
}
return false;
}
return true;
},
/**
* https://stackoverflow.com/questions/23305000/javascript-fuzzy-search-that-makes-sense
* Compares the similarity between two strings using an n-gram comparison method.
* The grams default to length 2.
* @param str1 The first string to compare.
* @param str2 The second string to compare.
* @param gramSize The size of the grams. Defaults to length 2.
*/
stringSimilarity(str1: string, str2: string, gramSize: number = 2): number {
const getNGrams = (s: string, len: number) => {
s = " ".repeat(len - 1) + s.toLowerCase() + " ".repeat(len - 1);
const v: string[] = new Array(s.length - len + 1);
for (let i = 0; i < v.length; i++) {
v[i] = s.slice(i, i + len);
}
return v;
};
if (!str1?.length || !str2?.length) {
return 0.0;
}
//Order the strings by length so the order they're passed in doesn't matter
//and so the smaller string's ngrams are always the ones in the set
const s1 = str1.length < str2.length ? str1 : str2,
s2 = str1.length < str2.length ? str2 : str1,
pairs1 = getNGrams(s1, gramSize),
pairs2 = getNGrams(s2, gramSize),
set = new Set(pairs1),
total = pairs2.length;
let hits = 0;
for (const item of pairs2) {
if (set.delete(item)) {
hits++;
}
}
return hits / total;
},
},
});
</script>
<style lang="scss">
@use "sass:math";
.ig-media-cards-vue {
$ig_card_height: 33rem;
$ig_card_width: 20rem;
.ig-media-cards {
justify-content: center;
}
.ig-media {
width: $ig_card_width;
height: $ig_card_height;
}
.ig-picture-mini {
max-height: math.div(17rem, $ig_card_width) * 17rem;
object-fit: contain;
}
.ig-picture-big {
max-height: 28rem;
max-width: 50%;
object-fit: contain;
}
.carousel-control-prev-icon,
.carousel-control-next-icon {
background-color: #000;
border-radius: 0.2rem;
border-top-left-radius: 0.2rem;
border-top-right-radius: 0.2rem;
border-bottom-right-radius: 0.2rem;
border-bottom-left-radius: 0.2rem;
padding: 1.3rem;
}
}
</style>