Ressources: clickable links, auto video thumbnails (YouTube/Vimeo)

- Card shows clickable domain link with external-link icon
- Modal shows "Ouvrir le lien" under URL field
- Auto-generates thumbnail from YouTube/Vimeo URLs when no image uploaded
- Card rendering switched from innerHTML to DOM methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-06-03 13:50:54 +02:00
parent ff496d1d2d
commit e52878d599
+99 -17
View File
@@ -516,6 +516,13 @@ window.dispatchEvent(new Event('tiptap-loaded'));
.res-card-cat { font-size: 12px; color: var(--accent); font-weight: 500; margin-bottom: 6px; } .res-card-cat { font-size: 12px; color: var(--accent); font-weight: 500; margin-bottom: 6px; }
.res-card-desc { font-size: 13px; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } .res-card-desc { font-size: 13px; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.res-card-tags { margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap; } .res-card-tags { margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap; }
.res-card-link { margin-top: 8px; font-size: 12px; display: flex; align-items: center; gap: 4px; }
.res-card-link a { color: var(--accent); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.res-card-link a:hover { text-decoration: underline; }
.res-card-link svg { width: 12px; height: 12px; flex-shrink: 0; stroke: var(--accent); fill: none; stroke-width: 2; }
.res-modal-link { margin-top: 4px; }
.res-modal-link a { color: var(--accent); text-decoration: none; font-size: 14px; word-break: break-all; }
.res-modal-link a:hover { text-decoration: underline; }
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -829,7 +836,7 @@ window.dispatchEvent(new Event('tiptap-loaded'));
<div class="field"><label>Catégorie</label><select id="fr-cat"><option value=""></option><option>Image</option><option>Lien</option><option>Carte</option><option>Note</option><option>Référence</option></select></div> <div class="field"><label>Catégorie</label><select id="fr-cat"><option value=""></option><option>Image</option><option>Lien</option><option>Carte</option><option>Note</option><option>Référence</option></select></div>
<div class="field"><label>Tags</label><input type="text" id="fr-tags" placeholder="Tags séparés par des virgules..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"></div> <div class="field"><label>Tags</label><input type="text" id="fr-tags" placeholder="Tags séparés par des virgules..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"></div>
</div> </div>
<div class="field"><label>URL</label><input type="text" id="fr-url" placeholder="https://..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"></div> <div class="field"><label>URL</label><input type="text" id="fr-url" placeholder="https://..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"><div id="fr-url-link" class="res-modal-link" style="margin-top:4px;"></div></div>
<div class="field"><label>Image</label><input type="file" id="fr-image-input" accept="image/*" onchange="previewResImage(this)" style="font-family:'Lora',serif;font-size:13px;"><div id="fr-image-preview" style="margin-top:8px;"></div></div> <div class="field"><label>Image</label><input type="file" id="fr-image-input" accept="image/*" onchange="previewResImage(this)" style="font-family:'Lora',serif;font-size:13px;"><div id="fr-image-preview" style="margin-top:8px;"></div></div>
<div class="field"><label>Description</label><textarea id="fr-desc" placeholder="Notes, contexte..." rows="4"></textarea></div> <div class="field"><label>Description</label><textarea id="fr-desc" placeholder="Notes, contexte..." rows="4"></textarea></div>
<div class="modal-actions"> <div class="modal-actions">
@@ -2304,6 +2311,21 @@ let ressources = [];
let editingResId = null; let editingResId = null;
let pendingResImage = null; // dataUrl for new upload let pendingResImage = null; // dataUrl for new upload
function videoThumbnail(url) {
if (!url) return null;
// YouTube: various URL formats
let m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
if (m) return `https://img.youtube.com/vi/${m[1]}/hqdefault.jpg`;
// Vimeo
m = url.match(/vimeo\.com\/(\d+)/);
if (m) return `https://vumbnail.com/${m[1]}.jpg`;
return null;
}
function extractDomain(url) {
try { return new URL(url).hostname.replace('www.', ''); } catch { return url; }
}
async function loadRessources() { async function loadRessources() {
try { try {
const res = await fetch('/api/ressources'); const res = await fetch('/api/ressources');
@@ -2338,35 +2360,93 @@ function renderRessources() {
ressources.forEach(r => { ressources.forEach(r => {
if (filterCat && r.Categorie !== filterCat) return; if (filterCat && r.Categorie !== filterCat) return;
if (filterSearch) { if (filterSearch) {
const hay = [r.Titre, r.Description, r.Tags, r.Categorie].filter(Boolean).join(' ').toLowerCase(); const hay = [r.Titre, r.Description, r.Tags, r.Categorie, r.Lien].filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(filterSearch)) return; if (!hay.includes(filterSearch)) return;
} }
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'res-card'; card.className = 'res-card';
card.onclick = () => openResEdit(r.Id); card.onclick = () => openResEdit(r.Id);
const tags = (r.Tags || '').split(',').filter(t => t.trim()).map(t =>
`<span class="card-badge badge-tag" style="font-size:11px;padding:2px 6px;">${esc(t.trim())}</span>`
).join('');
let imgHtml = ''; // Image: uploaded > video thumbnail > nothing
if (r.Image) { const imgSrc = r.Image || videoThumbnail(r.Lien);
imgHtml = `<img class="res-card-img" src="${esc(r.Image)}" alt="">`; if (imgSrc) {
const img = document.createElement('img');
img.className = 'res-card-img';
img.src = imgSrc;
img.alt = '';
card.appendChild(img);
} }
card.innerHTML = ` const body = document.createElement('div');
${imgHtml} body.className = 'res-card-body';
<div class="res-card-body">
<div class="res-card-title">${esc(r.Titre || '')}</div> const titleEl = document.createElement('div');
${r.Categorie ? `<div class="res-card-cat">${esc(r.Categorie)}</div>` : ''} titleEl.className = 'res-card-title';
${r.Description ? `<div class="res-card-desc">${esc(r.Description)}</div>` : ''} titleEl.textContent = r.Titre || '';
${tags ? `<div class="res-card-tags">${tags}</div>` : ''} body.appendChild(titleEl);
</div>
`; if (r.Categorie) {
const catEl = document.createElement('div');
catEl.className = 'res-card-cat';
catEl.textContent = r.Categorie;
body.appendChild(catEl);
}
if (r.Description) {
const descEl = document.createElement('div');
descEl.className = 'res-card-desc';
descEl.textContent = r.Description;
body.appendChild(descEl);
}
// Lien cliquable
if (r.Lien) {
const linkDiv = document.createElement('div');
linkDiv.className = 'res-card-link';
linkDiv.innerHTML = '<svg viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
const a = document.createElement('a');
a.href = r.Lien;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = extractDomain(r.Lien);
a.onclick = (e) => e.stopPropagation();
linkDiv.appendChild(a);
body.appendChild(linkDiv);
}
const tags = (r.Tags || '').split(',').filter(t => t.trim());
if (tags.length) {
const tagsEl = document.createElement('div');
tagsEl.className = 'res-card-tags';
tags.forEach(t => {
const badge = document.createElement('span');
badge.className = 'card-badge badge-tag';
badge.style.cssText = 'font-size:11px;padding:2px 6px;';
badge.textContent = t.trim();
tagsEl.appendChild(badge);
});
body.appendChild(tagsEl);
}
card.appendChild(body);
grid.appendChild(card); grid.appendChild(card);
}); });
} }
function updateResUrlLink(url) {
const container = document.getElementById('fr-url-link');
container.innerHTML = '';
if (url && url.startsWith('http')) {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Ouvrir le lien';
container.appendChild(a);
}
}
function openResModal() { function openResModal() {
editingResId = null; editingResId = null;
pendingResImage = null; pendingResImage = null;
@@ -2378,6 +2458,7 @@ function openResModal() {
document.getElementById('fr-desc').value = ''; document.getElementById('fr-desc').value = '';
document.getElementById('fr-image-input').value = ''; document.getElementById('fr-image-input').value = '';
document.getElementById('fr-image-preview').innerHTML = ''; document.getElementById('fr-image-preview').innerHTML = '';
updateResUrlLink('');
document.getElementById('btn-delete-res').style.display = 'none'; document.getElementById('btn-delete-res').style.display = 'none';
document.getElementById('res-modal').classList.add('open'); document.getElementById('res-modal').classList.add('open');
} }
@@ -2395,6 +2476,7 @@ function openResEdit(id) {
document.getElementById('fr-desc').value = r.Description || ''; document.getElementById('fr-desc').value = r.Description || '';
document.getElementById('fr-image-input').value = ''; document.getElementById('fr-image-input').value = '';
document.getElementById('fr-image-preview').innerHTML = r.Image ? `<img src="${esc(r.Image)}" style="max-width:200px;max-height:120px;border-radius:8px;">` : ''; document.getElementById('fr-image-preview').innerHTML = r.Image ? `<img src="${esc(r.Image)}" style="max-width:200px;max-height:120px;border-radius:8px;">` : '';
updateResUrlLink(r.Lien || '');
document.getElementById('btn-delete-res').style.display = 'block'; document.getElementById('btn-delete-res').style.display = 'block';
document.getElementById('res-modal').classList.add('open'); document.getElementById('res-modal').classList.add('open');
} }