1// crea-vivant.js â Espace chat vivant 2// Détecte les URLs collées dans la barre de saisie â affiche un aperçu instantané 3// (player YouTube mini, Spotify embed, player audio, mini-preview image, chip générique) 4// Tout se passe AVANT l'envoi, au-dessus de la zone de saisie. 5;(function () { 6 'use strict' 7 8 /* ââ Ãchappement HTML âââââââââââââââââââââââââââââââââââââââââââââââââââ */ 9 function esc(s) { 10 return String(s) 11 .replace(/&/g, '&').replace(/</g, '<') 12 .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') 13 } 14 15 /* ââ Détecteurs de type d'URL ââââââââââââââââââââââââââââââââââââââââââââ */ 16 function isYT(u) { return /youtube\.com\/watch|youtu\.be\//i.test(u) } 17 function isSpotify(u) { return /open\.spotify\.com\/(episode|track|show|playlist|album)/i.test(u) } 18 function isSCloud(u) { return /soundcloud\.com\//i.test(u) } 19 function isAudio(u) { return /\.(mp3|m4a|ogg|wav|aac)(\?|$)/i.test(u) } 20 function isImage(u) { return /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i.test(u) } 21 22 function ytVid(u) { 23 try { 24 const p = new URL(u) 25 return p.hostname.includes('youtu.be') ? p.pathname.slice(1).split('?')[0] : p.searchParams.get('v') || '' 26 } catch { return '' } 27 } 28 29 function extractUrl(text) { 30 const m = (text || '').match(/https?:\/\/[^\s<>()\[\]"'`]+/) 31 return m ? m[0].replace(/[.,;:!?»)]+$/, '') : null 32 } 33 34 /* ââ Zone de preview ââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 35 let _previewUrl = null 36 let _debounce = null 37 38 function clearPreview() { 39 _previewUrl = null 40 const z = document.getElementById('vivant-preview') 41 if (z) z.innerHTML = '' 42 } 43 44 function getZone() { 45 let z = document.getElementById('vivant-preview') 46 if (z) return z 47 const inp = document.getElementById('chat-input') || document.getElementById('chat-input-welcome') 48 if (!inp) return null 49 // On cherche le conteneur le plus proche qui wrap la textarea + send button 50 let bar = inp.parentElement 51 // Remonter jusqu'à trouver un conteneur positionné ou le body 52 while (bar && bar !== document.body) { 53 const s = getComputedStyle(bar) 54 if (s.position !== 'static') break 55 if (bar.children.length > 2) break // wrapper probable 56 bar = bar.parentElement 57 } 58 if (!bar || bar === document.body) { bar = inp.parentElement } 59 if (getComputedStyle(bar).position === 'static') bar.style.position = 'relative' 60 61 z = document.createElement('div') 62 z.id = 'vivant-preview' 63 z.style.cssText = [ 64 'position:absolute', 'bottom:100%', 'left:8px', 'right:8px', 'z-index:60', 65 'padding-bottom:6px', 'pointer-events:auto' 66 ].join(';') 67 bar.appendChild(z) 68 return z 69 } 70 71 /* ââ Renderers de preview ââââââââââââââââââââââââââââââââââââââââââââââââ */ 72 function closeBtn() { 73 return '<button onclick="document.getElementById(\'vivant-preview\').innerHTML=\'\'" ' + 74 'style="background:none;border:none;cursor:pointer;color:var(--t-faint);font-size:16px;' + 75 'padding:0 4px;line-height:1;flex-shrink:0" aria-label="Fermer">â</button>' 76 } 77
78 function renderSpotify(url) { 79 const m = url.match(/open\.spotify\.com\/(episode|track|show|playlist|album)\/([a-zA-Z0-9]+)/) 80 if (!m) return '' 81 return '<div style="border-radius:12px;overflow:hidden;background:var(--bg-secondary);border:1px solid var(--border)">' + 82 '<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid var(--border)">' + 83 '<span style="font-size:11px;color:var(--t-faint)">ðµ Spotify · ' + esc(m[1]) + '</span>' + 84 closeBtn() + '</div>' + 85 '<iframe src="https://open.spotify.com/embed/' + esc(m[1]) + '/' + esc(m[2]) + '?utm_source=generator&theme=0"' + 86 ' width="100%" height="80" frameborder="0"' + 87 ' allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"' + 88 ' style="display:block"></iframe></div>' 89 } 90 91 function renderSoundCloud(url) { 92 return '<div style="border-radius:12px;overflow:hidden;background:var(--bg-secondary);border:1px solid var(--border)">' + 93 '<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid var(--border)">' + 94 '<span style="font-size:11px;color:var(--t-faint)">ð§ SoundCloud</span>' + 95 closeBtn() + '</div>' + 96 '<iframe width="100%" height="80" scrolling="no" frameborder="no" allow="autoplay"' + 97 ' src="https://w.soundcloud.com/player/?url=' + encodeURIComponent(url) + '&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_teaser=false"' + 98 ' style="display:block"></iframe></div>' 99 } 100 101 function renderAudio(url) { 102 return '<div style="border-radius:12px;padding:10px 12px;background:var(--bg-secondary);border:1px solid var(--border)">' + 103 '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' + 104 '<span style="font-size:11px;color:var(--t-faint)">ðµ Audio</span>' + 105 closeBtn() + '</div>' + 106 '<audio controls src="' + esc(url) + '" preload="metadata" style="width:100%;height:36px;outline:none"></audio></div>' 107 } 108 109 function renderImage(url) { 110 return '<div style="border-radius:12px;overflow:hidden;background:var(--bg-secondary);border:1px solid var(--border);display:flex;align-items:stretch">' + 111 '<img src="' + esc(url) + '" alt="" style="max-height:100px;max-width:140px;object-fit:contain;background:#000;display:block"' + 112 ' onerror="document.getElementById(\'vivant-preview\').innerHTML=\'\'" />' + 113 '<div style="padding:8px 10px;display:flex;flex-direction:column;justify-content:space-between;min-width:0">' + 114 '<span style="font-size:11px;color:var(--t-faint)">ð¼ Image</span>' + 115 closeBtn() + '</div></div>' 116 } 117 118 function renderYouTube(url) { 119 const vid = ytVid(url) 120 if (!vid || !/^[a-zA-Z0-9_-]{1,20}$/.test(vid)) return renderGeneric(url) 121 const thumb = 'https://img.youtube.com/vi/' + vid + '/mqdefault.jpg' 122 const origin = window.location.origin 123 const embedSrc = 'https://www.youtube-nocookie.com/embed/' + vid + 124 '?autoplay=1&rel=0&modestbranding=1&playsinline=1&enablejsapi=1&origin=' + origin 125 const thumbId = 'vivant-yt-' + vid 126 127 // Le onclick est construit en JS pur pour éviter les conflits de guillemets dans le HTML 128 const onclick = [ 129 'var d=document.getElementById(\'' + thumbId + '\');', 130 'if(d){', 131 'var f=document.createElement(\'iframe\');', 132 'f.src=\'' + embedSrc + '\';', 133 'f.allow=\'autoplay;encrypted-media;picture-in-picture;fullscreen\';', 134 'f.setAttribute(\'allowfullscreen\',\'\');', 135 'f.style.cssText=\'width:120px;height:68px;border:none;display:block\';', 136 'd.replaceWith(f)}', 137 ].join('') 138 139 return '<div style="border-radius:12px;overflow:hidden;background:var(--bg-secondary);border:1px solid var(--border);display:flex;align-items:stretch">' + 140 '<div id="' + thumbId + '" style="width:120px;flex-shrink:0;background:#000;cursor:pointer;position:relative" onclick="' + onclick + '">' + 141 '<img src="' + thumb + '" alt="" style="width:100%;height:100%;object-fit:cover;display:block" />' + 142 '<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">' + 143 '<span style="width:30px;height:30px;background:rgba(220,38,38,.9);border-radius:50%;display:flex;align-items:center;justify-content:center">' +
144 '<svg width="10" height="10" viewBox="0 0 16 16" fill="white"><path d="M5 3l9 5-9 5z"/></svg>' + 145 '</span></span></div>' + 146 '<div style="padding:8px 10px;flex:1;display:flex;flex-direction:column;justify-content:space-between;min-width:0">' + 147 '<span style="font-size:11px;color:var(--t-faint)">â¶ YouTube</span>' + 148 '<a href="' + esc(url) + '" target="_blank" rel="noopener"' + 149 ' style="font-size:11px;color:var(--t-secondary);text-decoration:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;display:block">' + 150 esc(url.slice(0, 55)) + (url.length > 55 ? 'â¦' : '') + '</a>' + 151 closeBtn() + '</div></div>' 152 } 153 154 function renderGeneric(url) { 155 let host = url 156 try { host = new URL(url).hostname.replace(/^www\./, '') } catch {} 157 return '<div style="border-radius:10px;padding:7px 10px;background:var(--bg-secondary);border:1px solid var(--border);display:flex;align-items:center;gap:8px">' + 158 '<img src="https://www.google.com/s2/favicons?domain=' + encodeURIComponent(host) + '&sz=32" alt=""' + 159 ' style="width:14px;height:14px;border-radius:3px;flex-shrink:0" onerror="this.style.display=\'none\'" />' + 160 '<span style="font-size:11px;color:var(--t-secondary);overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1">' + esc(host) + '</span>' + 161 closeBtn() + '</div>' 162 } 163 164 /* ââ Routeur URL â renderer ââââââââââââââââââââââââââââââââââââââââââââââ */ 165 function renderPreview(url) { 166 const zone = getZone() 167 if (!zone) return 168 let html = '' 169 if (isSpotify(url)) html = renderSpotify(url) 170 else if (isSCloud(url)) html = renderSoundCloud(url) 171 else if (isAudio(url)) html = renderAudio(url) 172 else if (isImage(url)) html = renderImage(url) 173 else if (isYT(url)) html = renderYouTube(url) 174 else html = renderGeneric(url) 175 zone.innerHTML = html || '' 176 } 177 178 /* ââ Détection dans la textarea ââââââââââââââââââââââââââââââââââââââââââ */ 179 function detect(val) { 180 const url = extractUrl(val) 181 if (!url) { clearPreview(); return } 182 if (url === _previewUrl) { 183 // render() peut avoir effacé #vivant-preview sans réinitialiser _previewUrl 184 const z = document.getElementById('vivant-preview') 185 if (z && z.innerHTML.trim()) return 186 } 187 _previewUrl = url 188 renderPreview(url) 189 } 190 191 function onInput(e) { 192 if (_debounce) clearTimeout(_debounce) 193 _debounce = setTimeout(() => detect(e.target.value || ''), 300) 194 } 195 196 function onPaste(e) { 197 setTimeout(() => detect(e.target.value || ''), 30) 198 } 199 200 function onKeydown(e) { 201 // Après envoi (Enter sans Shift) â effacer le preview 202 if (e.key === 'Enter' && !e.shiftKey) setTimeout(clearPreview, 80) 203 } 204 205 /* ââ Attachement ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 206 function attach() { 207 ;['chat-input', 'chat-input-welcome'].forEach(function (id) { 208 const el = document.getElementById(id) 209 if (!el || el._vivantBound) return 210 el._vivantBound = true 211 el.addEventListener('input', onInput) 212 el.addEventListener('paste', onPaste) 213 el.addEventListener('keydown', onKeydown) 214 }) 215 } 216 217 /* ââ Init âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 218 function init() { 219 attach() 220 // subtree:true pour attraper l'input même après un re-render complet de l'app 221 // Pas de disconnect : render() remplace les éléments donc l'input peut apparaître plusieurs fois 222 new MutationObserver(attach).observe(document.body, { childList: true, subtree: true }) 223 } 224 225 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init) 226 else init() 227})()
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.