PageSourceSearch

https://family.pfa87.cc/family/library.js?v=102

js pfa87.cc collected 2026-10-02 00:45:07 UTC 10,348 bytes, 163 lines download raw bytes

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.