PageSourceSearch

https://creaconnect.world/src/crea-vivant.js

js creaconnect.world collected 2026-09-24 10:10:23 UTC 12,074 bytes, 227 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;')
12      .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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.