1// Bibliothèque · Library â every famoji, animated clip and game world in one sheet. 2// Self-contained: builds its own <dialog id="libraryDlg"> lazily and only reads 3// window.FAMOJI, window.FamilyGameThemes (optional) and /family/gifs/manifest.json. 4// app.js opens it with window.FamilyLibrary.open(lang). 5window.FamilyLibrary = (function () { 6 'use strict' 7 const MANIFEST_URL = '/family/gifs/manifest.json' 8 const T = { 9 title: { fr: 'Bibliothèque', en: 'Library' }, 10 close: { fr: 'Fermer', en: 'Close' }, 11 search: { fr: 'Rechercher un nom, une humeurâ¦', en: 'Search a name or a moodâ¦' }, 12 tabs: { 13 famojis: { fr: 'Famojis', en: 'Famojis' }, 14 gifs: { fr: 'GIFs', en: 'GIFs' }, 15 worlds: { fr: 'Mondes de jeu', en: 'Game worlds' }, 16 }, 17 empty: { fr: 'Rien ne correspond.', en: 'Nothing matches.' }, 18 loading: { fr: 'Chargementâ¦', en: 'Loadingâ¦' }, 19 gifsFail: { fr: 'Les clips ne se chargent pas pour lâinstant.', en: 'The clips cannot be loaded right now.' }, 20 play: { fr: 'Lire', en: 'Play' }, 21 pause: { fr: 'Arrêter', en: 'Stop' }, 22 back: { fr: 'Retour', en: 'Back' }, 23 symbols: { fr: 'Symboles', en: 'Symbols' }, 24 palette: { fr: 'Palette', en: 'Palette' }, 25 themes: { 26 or: { fr: 'Cités dâor', en: 'Cities of Gold' }, hp: { fr: 'Harry Potter', en: 'Harry Potter' }, 27 sarah: { fr: 'Princesse Sarah', en: 'Princess Sarah' }, ranma: { fr: 'Ranma ½', en: 'Ranma ½' }, chat: { fr: 'Alley Cat', en: 'Alley Cat' }, 28 }, 29 } 30 31 let lang = 'fr' 32 let dlg = null 33 let tab = 'famojis' 34 let query = '' 35 let manifest = null // parsed manifest, or null while unknown 36 let manifestState = 'idle' // idle | loading | ready | error 37 let zoom = null // famoji id shown enlarged 38 const playing = new Set() // gif ids currently animating 39 40 const t = (node) => (node && (node[lang] || node.fr || node.en)) || '' 41 const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) 42 const fold = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() 43 const matches = (parts) => !query || fold(parts.filter(Boolean).join(' ')).includes(fold(query)) 44 45 function famojis() { return (window.FAMOJI && Array.isArray(window.FAMOJI.list)) ? window.FAMOJI.list : [] } 46 function worlds() { 47 const g = window.FamilyGameThemes 48 if (!g || !Array.isArray(g.ids) || typeof g.get !== 'function') return [] 49 return g.ids.map((id) => g.get(id)).filter(Boolean) 50 } 51 function gifs() { return manifest && Array.isArray(manifest.gifs) ? manifest.gifs : [] } 52 const gifUrl = (g, key) => g[key] ? ((manifest && manifest.base) || '/family/gifs/') + g[key] : '' 53 const gifSrc = (g) => gifUrl(g, 'file') || g.remote || '' 54 const gifPoster = (g) => gifUrl(g, 'still') 55 56 function counts() { 57 return { famojis: famojis().length, gifs: gifs().length, worlds: worlds().length } 58 } 59 function count() { const c = counts(); return c.famojis + c.gifs + c.worlds } 60 61 // ---------- rendering ---------- 62 function renderFamojis() { 63 const list = famojis().filter((f) => matches([f.id, f.fr, f.en, t(f.hint), f.theme, t(T.themes[f.theme])])) 64 if (!list.length) return `<p class="library-empty">${esc(t(T.empty))}</p>` 65 if (zoom && list.some((f) => f.id === zoom)) { 66 const f = list.find((x) => x.id === zoom) 67 return `<div class="library-zoom" data-zoom="${esc(f.id)}"><button type="button" class="library-back" data-act="unzoom">â¹ ${esc(t(T.back))}</button>${window.FAMOJI.svg(f.id, 140)}<b class="library-zoom-name">${esc(f[lang] || f.fr)}</b><small class="library-zoom-hint">${esc(t(f.hint))}</small><span class="library-chip">${esc(t(T.themes[f.theme]) || f.theme)}</span></div>` 68 } 69 return `<div class="library-grid library-grid-famojis">${list.map((f) => `<button type="button" class="library-tile" data-act="zoom" data-id="${esc(f.id)}" aria-label="${esc(f[lang] || f.fr)}"><span class="library-tile-art">${window.FAMOJI.svg(f.id, 44)}</span><span class="library-cap"><b>${esc(f[lang] || f.fr)}</b><small>${esc(t(f.hint))}</small></span></button>`).join('')}</div>` 70 } 71 function renderGifs() { 72 if (manifestState === 'loading' || manifestState === 'idle') return `<p class="library-empty">${esc(t(T.loading))}</p>` 73 if (manifestState === 'error') return `<p class="library-empty library-error">${esc(t(T.gifsFail))}</p>` 74 const list = gifs().filter((g) => matches([g.id, t(g.title), g.character, g.world, ...(g.mood || []), ...((g.tags && g.tags[lang]) || [])])
74) 75 if (!list.length) return `<p class="library-empty">${esc(t(T.empty))}</p>` 76 return `<div class="library-grid library-grid-gifs">${list.map((g) => { 77 const on = playing.has(g.id) 78 const poster = gifPoster(g) 79 const src = on || !poster ? gifSrc(g) : poster 80 return `<button type="button" class="library-tile library-gif${on ? ' is-playing' : ''}" data-act="gif" data-id="${esc(g.id)}" aria-label="${esc(t(g.title))} · ${esc(on ? t(T.pause) : t(T.play))}"><span class="library-tile-art"><img src="${esc(src)}" alt="" loading="lazy" decoding="async">${poster && !on ? '<span class="library-play" aria-hidden="true">â¶</span>' : ''}</span><span class="library-cap"><b>${esc(t(g.title))}</b><small>${esc(g.character || '')}</small></span></button>` 81 }).join('')}</div>` 82 } 83 function renderWorlds() { 84 const list = worlds().filter((w) => matches([w.id, w.nameFr, w.nameEn, lang === 'en' ? w.introEn : w.introFr, ...(w.symbols || [])])) 85 if (!list.length) return `<p class="library-empty">${esc(t(T.empty))}</p>` 86 return `<div class="library-grid library-grid-worlds">${list.map((w) => { 87 const pal = Array.isArray(w.palette) ? w.palette : [] 88 return `<article class="library-world" style="--library-accent:${esc(pal[0] || 'var(--accent)')};--library-soft:${esc(pal[2] || 'var(--card-2)')}"><header class="library-world-head"><span class="library-world-ico" aria-hidden="true">${esc(w.icon || '')}</span><b>${esc(lang === 'en' ? w.nameEn : w.nameFr)}</b></header><p class="library-world-intro">${esc(lang === 'en' ? w.introEn : w.introFr)}</p><div class="library-world-symbols" aria-label="${esc(t(T.symbols))}">${(w.symbols || []).map((s) => `<span>${esc(s)}</span>`).join('')}</div><div class="library-swatches" aria-label="${esc(t(T.palette))}">${pal.map((c) => `<i style="background:${esc(c)}" title="${esc(c)}"></i>`).join('')}</div></article>` 89 }).join('')}</div>` 90 } 91 function render() { 92 if (!dlg) return 93 const c = counts() 94 const tabs = ['famojis', 'gifs'] 95 if (c.worlds) tabs.push('worlds') 96 if (!tabs.includes(tab)) tab = 'famojis' 97 dlg.querySelector('.library-title').textContent = t(T.title) 98 dlg.querySelector('.library-x').setAttribute('aria-label', t(T.close)) 99 const input = dlg.querySelector('.library-search') 100 input.placeholder = t(T.search) 101 if (input.value !== query) input.value = query 102 dlg.querySelector('.library-tabs').innerHTML = tabs.map((k) => `<button type="button" role="tab" class="library-tab${k === tab ? ' is-on' : ''}" aria-selected="${k === tab}" data-act="tab" data-tab="${k}">${esc(t(T.tabs[k]))}<span class="library-n">${c[k]}</span></button>`).join('') 103 dlg.querySelector('.library-body').innerHTML = tab === 'gifs' ? renderGifs() : tab === 'worlds' ? renderWorlds() : renderFamojis() 104 } 105 106 // ---------- manifest ---------- 107 function loadManifest() { 108 if (manifestState === 'ready' || manifestState === 'loading') return 109 if (typeof fetch !== 'function') { manifestState = 'error'; return } 110 manifestState = 'loading' 111 fetch(MANIFEST_URL, { credentials: 'same-origin' }) 112 .then((r) => { if (!r.ok) throw new Error('manifest ' + r.status); return r.json() }) 113 .then((json) => { manifest = json && typeof json === 'object' ? json : { gifs: [] }; manifestState = 'ready' }) 114 .catch(() => { manifestState = 'error' }) 115 .then(render) 116 } 117 118 // ---------- dialog ---------- 119 function build() { 120 if (dlg) return dlg 121 dlg = document.createElement('dialog') 122 dlg.id = 'libraryDlg' 123 dlg.className = 'sheet library-sheet' 124 dlg.setAttribute('aria-labelledby', 'libraryTitle') 125 dlg.innerHTML = `<div class="library-head"><h2 class="library-title" id="libraryTitle"></h2><button type="button" class="sheet-x library-x" data-act="close">â</button></div><div class="library-bar"><input type="search" class="library-search" autocomplete="off" spellcheck="false"><div class="library-tabs" role="tablist"></div></div><div class="library-body"></div>` 126 dlg.addEventListener('click', (ev) => { 127 const b = ev.target.closest('[data-act]') 128 if (!b || !dlg.contains(b)) return 129 const act = b.dataset.act 130 if (act === 'close') return close() 131 if (act === 'tab') { tab = b.dataset.tab; zoom = null; return render() } 132 if (act === 'zoom') { zoom = b.dataset.id; return render() } 133 if (act === 'unzoom') { zoom = null; return render() } 134 if (act === 'gif') { const id = b.dataset.id; if (playing.has(id)) playing.delete(id); else playing.add(id); return render() } 135 }) 136 dlg.addEventListener('input', (ev) => { 137 if (!ev.target.classList.contains('library-search')) return 138 query = ev.target.value.trim() 139 zoom = null 140 render() 141 }) 142 dlg.addEventListener('close', () => { playing.clear(); zoom = null }) 143 document.body.appendChild(dlg) 144 return dlg 145 } 146 function open(nextLang) { 147 if (nextLang === 'en' || nextLang === 'fr') lang = nextLang 148 else if (typeof document !== 'undefined' && document.documentElement && /^en/i.test(document.documentElement.lang || '')) lang = 'en' 149 build() 150 if (tab === 'gifs' || manifestState === 'idle') loadManifest() 151 render() 152 if (typeof dlg.showModal === 'function') { if (!dlg.open) dlg.showModal() } else dlg.setAttribute('open', '') 153 const input = dlg.querySelector('.library-search') 154 if (input && typeof input.focus === 'function' && window.matchMedia && window.matchMedia('(hover: hover)').matches) input.focus() 155 return dlg 156 } 157 function close() { 158 if (!dlg) return 159 if (typeof dlg.close === 'function' && dlg.open) dlg.close() 160 else dlg.removeAttribute('open') 161 } 162 return { open, close, count, counts, get lang() { return lang } } 163})()
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.