Init project

This commit is contained in:
2026-01-11 16:19:42 +01:00
commit df59325836
380 changed files with 33805 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
// assets/controllers/ckeditor5_controller.js
import { Controller } from "@hotwired/stimulus";
import EnhancedEditor from "../js/ckeditor-init.js";
export default class extends Controller {
connect() {
this.editor = EnhancedEditor.create(this.element)
.then(editor => (this.editor = editor))
.catch(error => console.error(error));
}
disconnect() {
this.editor.destroy().catch(error => console.error(error));
}
}

View File

@@ -0,0 +1,64 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [
"loaderWrapper",
"loaderProgressBar",
"list",
"imagesWrapper",
];
connect() {
if (!this.hasListTarget || !this.hasImagesWrapperTarget) {
return;
}
const images = Array.from(this.listTarget.querySelectorAll("img"));
// 1⃣ avant chargement → tout cacher
this.loaderWrapperTarget.classList.add("hidden");
this.imagesWrapperTarget.style.opacity = 0;
if (images.length > 0) {
this.trackImages(images);
}
}
trackImages(images) {
let loadedCount = 0;
// 2⃣ début du chargement → afficher le loader
this.loaderWrapperTarget.classList.remove("hidden");
this.loaderProgressBarTarget.style.width = "0%";
images.forEach((img) => {
if (img.complete) {
loadedCount++;
this.updateProgress(loadedCount, images.length);
} else {
img.addEventListener("load", () => {
loadedCount++;
this.updateProgress(loadedCount, images.length);
});
img.addEventListener("error", () => {
loadedCount++;
this.updateProgress(loadedCount, images.length);
});
}
});
}
updateProgress(loadedCount, total) {
const progress = (loadedCount / total) * 100;
this.loaderProgressBarTarget.style.width = `${progress}%`;
if (loadedCount === total) {
// 3⃣ chargement terminé → masquer le loader + afficher les images
setTimeout(() => {
this.loaderWrapperTarget.classList.add("hidden");
this.imagesWrapperTarget.style.transition = "opacity .3s ease";
this.imagesWrapperTarget.style.opacity = 1;
}, 200);
}
}
}

View File

@@ -0,0 +1,81 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';

View File

@@ -0,0 +1,28 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
let usersTable = document.getElementById("users-table");
if (!usersTable) return;
let toggleAllCheckbox = usersTable.querySelector(
"thead input[type='checkbox']",
);
let checkboxes = [
...usersTable.querySelectorAll("tbody input[type='checkbox']"),
];
toggleAllCheckbox.addEventListener("change", (event) => {
checkboxes.forEach((checkbox) => {
checkbox.checked = event.target.checked;
});
});
checkboxes.forEach((checkbox) => {
checkbox.addEventListener("change", () => {
let allChecked = checkboxes.every((checkbox) => checkbox.checked);
let someChecked = checkboxes.some((checkbox) => checkbox.checked);
toggleAllCheckbox.checked = someChecked;
toggleAllCheckbox.indeterminate = someChecked && !allChecked;
});
});
}
}

View File

@@ -0,0 +1,16 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

View File

@@ -0,0 +1,26 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["dialog", "form", "token"];
open(event) {
event.preventDefault();
const button = event.currentTarget;
// Récupère l'URL et le token depuis le bouton
const url = button.dataset.url;
const csrfToken = button.dataset.token;
// Remplit le formulaire de la modale
this.formTarget.action = url;
this.tokenTarget.value = csrfToken;
this.dialogTarget.classList.remove("hidden");
this.dialogTarget.classList.add("flex");
}
close() {
this.dialogTarget.classList.add("hidden");
this.dialogTarget.classList.remove("flex");
}
}

View File

@@ -0,0 +1,66 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input", "preview", "loader"];
connect() {
this.element.addEventListener("dragover", this.dragOver.bind(this));
this.element.addEventListener("dragleave", this.dragLeave.bind(this));
this.element.addEventListener("drop", this.drop.bind(this));
this.inputTarget.addEventListener("change", (event) => {
this.handleFiles(this.inputTarget.files);
});
}
dragOver(event) {
event.preventDefault();
this.element.classList.add("border-amber-600");
}
dragLeave(event) {
event.preventDefault();
this.element.classList.remove("border-amber-600");
}
drop(event) {
event.preventDefault();
this.element.classList.remove("border-amber-600");
this.handleFiles(event.dataTransfer.files);
}
handleFiles(files) {
const formData = new FormData();
this.previewTarget.innerHTML = "";
Array.from(files).forEach((file) => {
formData.append("file-upload[]", file);
const reader = new FileReader();
reader.onload = (e) => {
const img = document.createElement("img");
img.src = e.target.result;
img.className = "h-24 w-24 object-cover rounded border";
this.previewTarget.appendChild(img);
};
reader.readAsDataURL(file);
});
this.uploadFiles(formData);
}
async uploadFiles(formData) {
this.loaderTarget.style.display = "block";
try {
await fetch("/admin/image/upload", {
method: "POST",
body: formData,
});
} catch (err) {
console.error("Upload error:", err);
} finally {
this.loaderTarget.style.display = "none";
}
}
}

View File

@@ -0,0 +1,23 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["dialog", "form", "token"];
open(event) {
const button = event.currentTarget;
const url = button.dataset.url;
const token = button.dataset.token;
// Remplit le formulaire avec les bonnes données
this.formTarget.action = url;
this.tokenTarget.value = token;
this.dialogTarget.classList.remove("hidden");
this.dialogTarget.classList.add("flex");
}
close() {
this.dialogTarget.classList.add("hidden");
this.dialogTarget.classList.remove("flex");
}
}

View File

@@ -0,0 +1,165 @@
import { Controller } from "@hotwired/stimulus";
import Sortable from "sortablejs";
export default class extends Controller {
static targets = [
"input",
"preview",
"list",
"card",
"checkbox",
"checkIcon",
"overlay",
"count",
];
sortable = null;
connect() {
this.restoreInitialSelection();
this.enableSortable();
this.updateCount();
}
// ---------------------------------------------------------------------
// 1) Restaurer auto la sélection existante (images déjà liées)
// ---------------------------------------------------------------------
restoreInitialSelection() {
this.previewTarget.innerHTML = "";
const selectedIds = [
...document.querySelectorAll('input[name="selectedImages[]"]'),
].map((input) => input.value);
// coche les bonnes cases dans la modale
this.checkboxTargets.forEach((checkbox) => {
if (selectedIds.includes(checkbox.value)) {
checkbox.checked = true;
this.updateCard(checkbox.closest("label"), true);
}
});
// Ajoute dans le preview
selectedIds.forEach((id) => {
const card = this.cardTargets.find((c) => c.dataset.id === id);
if (!card) return;
const img = document.createElement("img");
img.src = card.dataset.url;
img.dataset.id = id;
img.className = "w-20 h-20 rounded object-cover cursor-move";
this.previewTarget.appendChild(img);
});
}
// ---------------------------------------------------------------------
// 2) Sélection dans la modale
// ---------------------------------------------------------------------
cardClick(event) {
const card = event.currentTarget;
const checkbox = card.querySelector("input[type='checkbox']");
checkbox.checked = !checkbox.checked;
this.updateCard(card, checkbox.checked);
this.updateCount();
}
toggleCheckbox(event) {
const checkbox = event.currentTarget;
const card = checkbox.closest("label");
this.updateCard(card, checkbox.checked);
this.updateCount();
}
updateCard(card, checked) {
const icon = card.querySelector(
"[data-news--edit-image-selector-target='checkIcon']",
);
const overlay = card.querySelector(
"[data-news--edit-image-selector-target='overlay']",
);
if (icon) icon.classList.toggle("hidden", !checked);
if (overlay) overlay.classList.toggle("hidden", !checked);
card.classList.toggle("ring-4", checked);
card.classList.toggle("ring-amber-500", checked);
}
updateCount() {
const total = this.checkboxTargets.filter((c) => c.checked).length;
if (this.hasCountTarget) {
this.countTarget.textContent = `${total} sélectionnée(s)`;
}
}
// ---------------------------------------------------------------------
// 3) Validation depuis la modale → update preview + ordre
// ---------------------------------------------------------------------
validate() {
const selected = this.checkboxTargets
.filter((c) => c.checked)
.map((c) => c.value);
// 🔥 RESET COMPLET
this.previewTarget.innerHTML = "";
// 🔁 RECONSTRUCTION PROPRE
selected.forEach((id) => {
const card = this.cardTargets.find((c) => c.dataset.id === id);
if (!card) return;
const img = document.createElement("img");
img.src = card.dataset.url;
img.dataset.id = id;
img.className = "w-20 h-20 rounded object-cover cursor-move";
this.previewTarget.appendChild(img);
});
this.enableSortable();
this.updateOrder();
document
.querySelector("[command='close'][commandfor='edit-news-image-dialog']")
.click();
}
// ---------------------------------------------------------------------
// 4) Drag & drop
// ---------------------------------------------------------------------
enableSortable() {
if (this.sortable) this.sortable.destroy();
this.sortable = Sortable.create(this.previewTarget, {
animation: 150,
ghostClass: "opacity-40",
onSort: () => this.updateOrder(),
});
}
updateOrder() {
const form = this.previewTarget.closest("form");
// supprime uniquement les inputs dynamiques
form
.querySelectorAll(
'input[name="selectedImages[]"]:not([data-permanent="true"])',
)
.forEach((i) => i.remove());
// recréer les inputs cachés
Array.from(this.previewTarget.children).forEach((img) => {
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "selectedImages[]";
hidden.value = img.dataset.id;
form.appendChild(hidden);
});
}
}

View File

@@ -0,0 +1,126 @@
import { Controller } from "@hotwired/stimulus";
import Sortable from "sortablejs";
export default class extends Controller {
static targets = [
"container",
"input",
"preview",
"list",
"card",
"checkbox",
"checkIcon",
"count",
];
sortable = null;
// ------------------------------------------------------------
// Sélection normale
// ------------------------------------------------------------
cardClick(event) {
const card = event.currentTarget;
const checkbox = card.querySelector("input[type='checkbox']");
checkbox.checked = !checkbox.checked;
this.updateCard(card, checkbox.checked);
this.updateCount();
}
toggleCheckbox(event) {
const checkbox = event.currentTarget;
const card = checkbox.closest("label");
this.updateCard(card, checkbox.checked);
this.updateCount();
}
updateCard(card, checked) {
const icon = card.querySelector(
"[data-news--image-selector-target='checkIcon']",
);
const overlay = card.querySelector(
"[data-news--image-selector-target='overlay']",
);
icon.classList.toggle("hidden", !checked);
overlay.classList.toggle("hidden", !checked);
card.classList.toggle("ring-4", checked);
card.classList.toggle("ring-amber-500", checked);
}
updateCount() {
const total = this.checkboxTargets.filter((c) => c.checked).length;
if (this.hasCountTarget) {
this.countTarget.textContent = `${total} sélectionnée(s)`;
}
}
// ------------------------------------------------------------
// Validation → création du preview + activation du drag&drop
// ------------------------------------------------------------
validate() {
const selected = this.checkboxTargets
.filter((c) => c.checked)
.map((c) => c.value);
// 🔥 RESET TOTAL DU PREVIEW
this.previewTarget.innerHTML = "";
// 🔁 RECONSTRUCTION À PARTIR DE LA SOURCE DE VÉRITÉ
selected.forEach((id) => {
const card = this.cardTargets.find((c) => c.dataset.id === id);
if (!card) return;
const img = document.createElement("img");
img.src = card.dataset.url;
img.dataset.id = id;
img.className = "w-20 h-20 rounded object-cover cursor-move";
this.previewTarget.appendChild(img);
});
this.enableSortable();
this.updateOrder();
// ferme la modale
document.querySelector("[command='close'][commandfor='dialog']").click();
}
// ------------------------------------------------------------
// SortableJS (drag & drop)
// ------------------------------------------------------------
enableSortable() {
if (this.sortable) {
this.sortable.destroy(); // reset si déjà actif
}
this.sortable = Sortable.create(this.previewTarget, {
animation: 150,
ghostClass: "opacity-40",
onSort: () => {
this.updateOrder();
},
});
}
updateOrder() {
// Supprime les anciennes valeurs
const container = this.previewTarget.closest("form");
container
.querySelectorAll('input[name="selectedImages[]"]')
.forEach((i) => i.remove());
// Crée un input caché par image dans l'ordre
Array.from(this.previewTarget.children).forEach((img) => {
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "selectedImages[]";
hidden.value = img.dataset.id;
container.appendChild(hidden);
});
}
}

View File

@@ -0,0 +1,33 @@
import Sortable from "sortablejs";
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = {
url: String,
};
static targets = ["item"];
connect() {
this.sortable = new Sortable(this.element, {
animation: 150,
handle: '[data-news--sortable-target="handle"]',
onEnd: this.reorder.bind(this),
});
}
reorder() {
const order = this.itemTargets.map((el, index) => ({
id: el.dataset.id,
position: index + 1,
}));
fetch(this.urlValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ order }),
});
}
}

View File

@@ -0,0 +1,36 @@
// assets/controllers/news_carroussel_controller.js
import { Controller } from "@hotwired/stimulus";
import Swiper from "swiper";
import { Navigation, Pagination } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
export default class extends Controller {
connect() {
this.swiper = new Swiper(this.element, {
modules: [Navigation, Pagination],
slidesPerView: 1,
spaceBetween: 24,
loop: true,
pagination: {
el: ".swiper-pagination",
clickable: true,
dynamicBullets: true,
},
navigation: {
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
},
});
}
disconnect() {
if (this.swiper) {
this.swiper.destroy();
}
}
}

View File

@@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
const year = new Date().getFullYear();
this.element.innerHTML = `© ${year} Arts-ticule, Tous droits réservés.`;
}
}