Files
folio/public/assets/js/app.js
T

463 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// varlog — app.js
document.addEventListener('DOMContentLoaded', function () {
// ─── Auto-resize textareas ───────────────────────────────────────────────
document.querySelectorAll('textarea.form-control').forEach(function (ta) {
function resize() {
ta.style.height = 'auto';
ta.style.height = ta.scrollHeight + 'px';
}
ta.addEventListener('input', resize);
resize();
});
// ─── Ctrl+Enter : soumettre le formulaire ────────────────────────────────
var form = document.querySelector('form[method="POST"]');
if (form) {
form.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
form.submit();
}
});
}
// ─── Slug auto-génération ────────────────────────────────────────────────
const titleInput = document.getElementById('title');
const slugField = document.getElementById('slug');
const slugPreview = document.getElementById('slug-preview');
if (titleInput && slugField) {
if (slugField.value !== '') slugField._auto = false;
titleInput.addEventListener('input', function () {
if (slugField._auto !== false) {
const generated = slugify(this.value);
slugField.value = generated;
if (slugPreview) slugPreview.textContent = generated;
}
});
slugField.addEventListener('input', function () {
this._auto = (this.value === '');
if (slugPreview) slugPreview.textContent = this.value;
});
}
function slugify(s) {
const map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'};
return s.toLowerCase().replace(/[àâäéèêëîïôöùûüçæœ]/g, c => map[c] || c)
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
// ─── Rôle : nom technique auto depuis le label ───────────────────────────
var roleLabelInput = document.getElementById('role-label');
var roleNameInput = document.getElementById('role-name');
if (roleLabelInput && roleNameInput) {
roleLabelInput.addEventListener('input', function () {
if (roleNameInput._manual) return;
roleNameInput.value = slugify(this.value);
});
roleNameInput.addEventListener('input', function () {
this._manual = (this.value !== '');
});
roleNameInput.addEventListener('blur', function () {
if (this.value === '') {
this._manual = false;
this.value = slugify(roleLabelInput.value);
}
});
}
// ─── Aperçu couleur catégorie ────────────────────────────────────────────
const KNOWN_CATS = {
'actualité': 10, 'travaux': 35, 'scolaire': 55,
'linux': 120, 'domotique': 160, 'télécom': 190,
'blog': 220, 'informatique': 255, 'réflexion': 285,
'loisirs': 320, 'perso': 345,
};
const FREE_HUES = [87, 140, 205, 237, 302];
function gradient(hue) {
return `linear-gradient(135deg,hsl(${hue},70%,88%) 0%,hsl(${hue},60%,28%) 100%)`;
}
function hashHue(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) h = (((h << 5) + h) + str.charCodeAt(i)) | 0;
return ((Math.abs(h) * 0.6180339887) * 360 | 0) % 360;
}
function nearestKnown(hue) {
let best = null, bestDist = Infinity;
for (const [name, h] of Object.entries(KNOWN_CATS)) {
const d = Math.min(Math.abs(hue - h), 360 - Math.abs(hue - h));
if (d < bestDist) { bestDist = d; best = name; }
}
return { name: best, dist: bestDist };
}
function updateCatPreview(val) {
const key = val.trim().toLowerCase();
const swatch = document.getElementById('cat-swatch');
const hint = document.getElementById('cat-hint');
const freeEl = document.getElementById('cat-free-swatches');
if (!swatch) return;
freeEl.innerHTML = '';
if (!key) {
swatch.style.background = '#e5e7eb';
swatch.title = '';
hint.textContent = '';
return;
}
if (KNOWN_CATS[key] !== undefined) {
const hue = KNOWN_CATS[key];
swatch.style.background = gradient(hue);
swatch.title = `${hue}°`;
hint.textContent = `Catégorie existante · teinte fixe (${hue}°)`;
hint.className = 'text-muted d-block mt-1';
return;
}
const hue = hashHue(key);
const { name, dist } = nearestKnown(hue);
swatch.style.background = gradient(hue);
swatch.title = `${hue}°`;
if (dist < 20) {
hint.innerHTML = `⚠ Teinte proche de <strong>${name}</strong> (${dist}° d'écart) · couleurs disponibles :`;
hint.className = 'text-warning d-block mt-1';
FREE_HUES.forEach(h => {
const el = document.createElement('span');
el.title = `${h}°`;
el.style.cssText = `display:inline-block;width:28px;height:20px;border-radius:4px;cursor:help;background:${gradient(h)}`;
freeEl.appendChild(el);
});
} else {
hint.textContent = `Nouvelle catégorie · teinte libre (${hue}°)`;
hint.className = 'text-muted d-block mt-1';
}
}
const catInput = document.getElementById('category');
if (catInput) {
catInput.addEventListener('input', function () { updateCatPreview(this.value); });
updateCatPreview(catInput.value);
}
// ─── Copier la référence Markdown ────────────────────────────────────────
document.querySelectorAll('[data-copy-md-name]').forEach(function (btn) {
btn.addEventListener('click', function () {
const name = this.dataset.copyMdName;
const isImage = this.dataset.copyMdIsImage === '1';
const ref = isImage ? `![](${name})` : `[${name}](${name})`;
navigator.clipboard.writeText(ref).then(() => {
const orig = this.textContent;
this.textContent = 'Copié !';
setTimeout(() => { this.textContent = orig; }, 1500);
});
});
});
// ─── Boîtes de confirmation (suppression) ───────────────────────────────
document.querySelectorAll('button[data-confirm], a[data-confirm]').forEach(function (el) {
el.addEventListener('click', function (e) {
if (!confirm(this.dataset.confirm)) e.preventDefault();
});
});
document.querySelectorAll('form[data-confirm]').forEach(function (form) {
form.addEventListener('submit', function (e) {
if (!confirm(this.dataset.confirm)) e.preventDefault();
});
});
// ─── Insérer une référence Markdown au curseur ───────────────────────────
const ta = document.getElementById('content');
if (ta) {
ta._savedStart = null;
ta._savedEnd = null;
function saveCursor() {
if (document.activeElement === ta) {
ta._savedStart = ta.selectionStart;
ta._savedEnd = ta.selectionEnd;
}
}
document.addEventListener('mousedown', saveCursor);
ta.addEventListener('keyup', saveCursor);
ta.addEventListener('mouseup', saveCursor);
document.querySelectorAll('[data-insert-ref]').forEach(function (el) {
el.addEventListener('click', function () {
insertRef(this.dataset.insertRef);
});
if (el.tagName === 'IMG') {
el.addEventListener('mouseenter', function () { this.style.borderColor = '#0d6efd'; });
el.addEventListener('mouseleave', function () { this.style.borderColor = 'transparent'; });
}
});
}
function insertRef(url) {
if (!ta) return;
const isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(url);
const label = url.startsWith('http')
? (decodeURIComponent(url.split('/').pop().split('?')[0]) || url)
: url;
const ref = isImage ? `![](${url})` : `[${label}](${url})`;
const len = ta.value.length;
const start = ta._savedStart !== null ? ta._savedStart : len;
const end = ta._savedEnd !== null ? ta._savedEnd : len;
ta.focus();
ta.setRangeText(ref, start, end, 'end');
ta._savedStart = ta._savedEnd = start + ref.length;
ta.dispatchEvent(new Event('input'));
}
// ─── Compteurs SEO ───────────────────────────────────────────────────────
function initCounter(inputId, counterId, max) {
const input = document.getElementById(inputId);
const counter = document.getElementById(counterId);
if (!input || !counter) return;
function update() {
const len = input.value.length;
counter.textContent = `${len} / ${max}`;
counter.className = len > max ? 'text-danger' : 'text-muted';
}
input.addEventListener('input', update);
update();
}
initCounter('seo_title', 'seo_title_counter', 60);
initCounter('seo_description', 'seo_desc_counter', 155);
// ─── Page catégories ─────────────────────────────────────────────────────
function catComputeGradient(val) {
const key = val.trim().toLowerCase();
if (!key) return null;
if (KNOWN_CATS[key] !== undefined) return { hue: KNOWN_CATS[key], known: true };
const hue = hashHue(key);
const { name, dist } = nearestKnown(hue);
return { hue, known: false, conflict: dist < 20 ? name : null };
}
document.querySelectorAll('form[action="/?action=rename_category"] input[name="new"]').forEach(function (input) {
input.addEventListener('input', function () {
const swatch = input.closest('form').querySelector('.rename-swatch');
const result = catComputeGradient(input.value);
if (swatch) swatch.style.background = result ? gradient(result.hue) : '#e5e7eb';
});
});
const newCatInput = document.getElementById('new-cat-input');
if (newCatInput) {
newCatInput.addEventListener('input', function () {
const swatch = document.getElementById('new-cat-swatch');
const hint = document.getElementById('new-cat-hint');
const result = catComputeGradient(this.value);
if (!result) {
swatch.style.background = '#e5e7eb';
hint.textContent = '';
return;
}
swatch.style.background = gradient(result.hue);
if (result.known) {
hint.textContent = `Catégorie existante · teinte fixe (${result.hue}°)`;
hint.className = 'text-muted d-block mb-3';
} else if (result.conflict) {
hint.textContent = `⚠ Teinte proche de « ${result.conflict} » — choisissez un autre nom ou une couleur disponible ci-dessous`;
hint.className = 'text-warning d-block mb-3';
} else {
hint.textContent = `Couleur libre · teinte ${result.hue}°`;
hint.className = 'text-success d-block mb-3';
}
});
}
// ─── Import image : récupérer les métadonnées ────────────────────────────
const fetchMetaBtn = document.getElementById('fetch-meta-btn');
if (fetchMetaBtn) {
fetchMetaBtn.addEventListener('click', async function () {
const urlInput = document.getElementById('import-url');
const resultDiv = document.getElementById('meta-result');
const url = urlInput ? urlInput.value.trim() : '';
if (!url) {
resultDiv.innerHTML = '<small class="text-danger">Saisissez une URL d\'abord.</small>';
return;
}
fetchMetaBtn.disabled = true;
fetchMetaBtn.textContent = 'Chargement…';
resultDiv.innerHTML = '';
try {
const res = await fetch(`/?action=fetch_file_meta&url=${encodeURIComponent(url)}`);
const data = await res.json();
if (!data.ok) {
resultDiv.innerHTML = `<small class="text-danger">${data.error || 'Erreur lors de la récupération.'}</small>`;
return;
}
// Auto-remplissage dynamique des champs (si vides)
const AUTOFILL = {
img_author: { keys: ['author', 'credit'], label: 'Auteur / crédit' },
img_source: { keys: ['canonical', 'source'], label: 'URL source' },
};
const autofillKeys = new Set();
const autofillNotice = [];
for (const [fieldName, cfg] of Object.entries(AUTOFILL)) {
const f = document.querySelector(`input[name="${fieldName}"]`);
if (!f || f.value) continue;
for (const key of cfg.keys) {
if (data[key]) {
f.value = data[key];
autofillKeys.add(key);
autofillNotice.push(`<strong>${cfg.label}</strong> : ${data[key]}`);
break;
}
}
}
// Affichage dynamique de tous les champs retournés
const isPdf = (data.mime === 'application/pdf');
const isHtml = (data.mime || '').startsWith('text/html');
const META_ORDER = ['mime','size','pages','page_size','pdf_version',
'width','site_name','og_type','language',
'title','description','author','subject','keywords',
'credit','source','creator','producer','date','camera','copyright',
'canonical','og_image'];
const META_LABELS = {
mime: 'Type', size: 'Taille', width: 'Dimensions',
pages: 'Pages', page_size: 'Format', pdf_version: 'Version PDF',
site_name: 'Site', og_type: 'Type OG', language: 'Langue',
title: isPdf || isHtml ? 'Titre' : 'Titre EXIF/IPTC',
author: isPdf || isHtml ? 'Auteur' : 'Auteur EXIF/IPTC',
date: isPdf ? 'Créé le' : isHtml ? 'Publié le' : 'Prise de vue',
description: 'Description', subject: 'Sujet', keywords: 'Mots-clés',
credit: 'Crédit', source: 'Source IPTC',
creator: 'Créé avec', producer: 'Produit par',
camera: 'Appareil', copyright: 'Copyright',
canonical: 'URL canonique', og_image: 'Image OG',
};
function fmtVal(key, val) {
if (key === 'size') return (val/1024).toFixed(0) + ' Ko' + (val >= 1048576 ? ` (${(val/1048576).toFixed(1)} Mo)` : '');
if (key === 'width') return `${data.width} × ${data.height} px`;
if (key === 'og_image') return `<img src="${val}" style="max-width:120px;max-height:80px;border-radius:4px" alt="">`;
if (key === 'canonical') return `<a href="${val}" target="_blank" rel="noopener">${val}</a>`;
return String(val);
}
const SKIP = new Set(['ok', 'height']);
const seen = new Set();
const rows = [];
for (const key of META_ORDER) {
const val = data[key];
if (val == null || val === '' || key === 'height') continue;
seen.add(key);
const badge = autofillKeys.has(key)
? ' <span class="badge text-bg-primary ms-1" title="Pré-rempli dans le formulaire">↓ pré-rempli</span>'
: '';
rows.push([META_LABELS[key] ?? key, fmtVal(key, val) + badge]);
}
for (const [key, val] of Object.entries(data)) {
if (seen.has(key) || SKIP.has(key) || val == null || val === '') continue;
rows.push([key, fmtVal(key, val)]);
}
let html = '';
if (rows.length > 0) {
const trs = rows.map(([k, v]) =>
`<tr><th class="text-muted fw-normal pe-3 text-nowrap">${k}</th><td>${v}</td></tr>`
).join('');
html = `<table class="table table-sm table-borderless mb-0 small"><tbody>${trs}</tbody></table>`;
} else {
html = '<small class="text-muted">Aucune métadonnée disponible pour ce fichier.</small>';
}
if (autofillNotice.length > 0) {
html += `<div class="small text-primary mt-1">✓ Pré-rempli — ${autofillNotice.join(' · ')}</div>`;
}
resultDiv.innerHTML = html;
} catch {
resultDiv.innerHTML = '<small class="text-danger">Erreur de connexion.</small>';
} finally {
fetchMetaBtn.disabled = false;
fetchMetaBtn.textContent = 'Métadonnées';
}
});
}
// ─── Import image : toggle mode download ────────────────────────────────
document.querySelectorAll('input[name="mode"]').forEach(function (r) {
r.addEventListener('change', function () {
const dl = this.value === 'download';
const ss = this.value === 'screenshot';
const warn = document.getElementById('copyright-warning');
const fields = document.getElementById('download-fields');
if (warn) warn.style.display = dl ? 'block' : 'none';
if (fields) fields.style.display = (dl || ss) ? 'block' : 'none';
});
});
// ─── Données page (mode édition uniquement) ──────────────────────────────
const pageEl = document.getElementById('vl-page');
if (!pageEl) return;
const uuid = pageEl.dataset.uuid;
const insertUrl = pageEl.dataset.insertUrl;
// Auto-insertion après import d'image
if (insertUrl && ta) {
const isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(insertUrl);
const name = decodeURIComponent(insertUrl.split('/').pop().split('?')[0]) || 'fichier';
const ref = isImage ? `![](${insertUrl})` : `[${name}](${insertUrl})`;
const sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
ta.value += sep + ref;
ta.focus();
ta.selectionStart = ta.selectionEnd = ta.value.length;
ta.dispatchEvent(new Event('input'));
}
// ─── Autosave ────────────────────────────────────────────────────────────
const indicator = document.getElementById('autosave-indicator');
if (!indicator || !uuid) return;
let timer = null;
function scheduleAutosave() {
clearTimeout(timer);
timer = setTimeout(doAutosave, 3000);
}
async function doAutosave() {
const title = document.getElementById('title').value;
const slug = document.getElementById('slug').value;
const content = document.getElementById('content').value;
indicator.textContent = 'Sauvegarde…';
try {
const res = await fetch(`/?action=autosave&uuid=${encodeURIComponent(uuid)}`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({title, slug, content}),
});
const data = await res.json();
indicator.textContent = data.ok ? `Brouillon sauvegardé à ${data.time}` : 'Erreur de sauvegarde';
} catch {
indicator.textContent = 'Erreur de sauvegarde';
}
}
['title', 'slug', 'content'].forEach(id => {
document.getElementById(id)?.addEventListener('input', scheduleAutosave);
});
});