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:
+99
-17
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user