source: Klonkt/src/assets/js/mod/chrome.js@ 9eb6e1b

main
Last change on this file since 9eb6e1b was 9eb6e1b, checked in by Robin <roboburr@…>, 3 hours ago

Een fediverse-post ziet eruit als een post, ook in de zoekbalk

Plak je een link in de zoekbalk, dan laadt de uitklaplijst nu de post zelf
in plaats van te zoeken naar de tekst van de link. Dezelfde kaart staat op de
zoekpagina en op /authorize_interaction, dus één render op drie plekken.

DE KAART. Wat eromheen stond is teruggebracht tot een kop met gezicht, de
inhoud, en één rij knoppen: Reageer, Like, Boost en het origineel. Weg zijn de
zin "je reageert op", de aparte knop naar het origineel, de twee grote pillen,
"of reageer:" en het antwoordvenster dat altijd openstond. Antwoorden klapt
open als je erom vraagt; melden staat onderaan en niet in de weg.

Foto's staan in een raster met vaste rijhoogtes. Met verhoudingen groeide het
mee met de breedte, en werd op een desktop de eerste van drie foto's zo hoog
dat de knoppen onder de vouw van de uitklaplijst verdwenen.

EEN INHOUDSWAARSCHUWING WORDT GERESPECTEERD. De oude render toonde alles open,
ook wat de schrijver achter een waarschuwing had gezet.

MOBIEL. Elke knop minstens 44 punten. Op een smal scherm vallen de woorden weg
en blijven de tekens staan, gelijk verdeeld; de woorden blijven bestaan voor
wie het scherm voorgelezen krijgt. Nagemeten op 375 pixels: geen horizontale
overloop, kleinste tikdoel 44, de knoppen in beeld zonder te scrollen.

DE PREVIEW IS COMPACT. Waarderen en boosten kan ter plekke; antwoorden en
melden gaan naar de volle pagina, met ?reply=1 of ?report=1 zodat dat deel
daar al openstaat. Een editor in een uitklaplijst is op een telefoon geen plek
om te schrijven. Zonder JS doet dezelfde link hetzelfde.

/search/remote levert het fragment, achter dezelfde grens als de volle
pagina: alleen wie de site beheert laat ophalen. Die grens weegt hier zwaarder,
want deze route wordt bij elke toetsaanslag geraakt; zonder hem is hij een
haalservice die iedereen deze server op adressen naar keuze laat afsturen.
Voor een bezoeker 204, en dan valt de zoekbalk terug op de gewone suggesties.
Elke toetsaanslag krijgt een volgnummer, zodat een traag antwoord op een oude
vraag niet overschrijft wat je inmiddels hebt getypt.

Waarderen en boosten ter plekke verhuisde van authorize-interaction.js naar
chrome.js: de kaart staat nu op elke pagina, via de zoekbalk. De zoekpagina
stuurde je bij een like eerder weg naar /authorize_interaction.

Onderweg: /authorize_interaction haalt nu ook ondertekend op, net als de
zoekbalk. Anders zegt die pagina "niet gevonden" over een post voor volgers die
de preview wel liet zien.

Getoetst: de preview met de post, ondertekend; wat er in de compacte kaart wel
en niet staat; dat een bezoeker via de preview niets laat ophalen; dat een
zoekterm niets ophaalt; de waarschuwing; het venster op verzoek. Tegenproef:
rechtencheck eruit, waarschuwing genegeerd, compact genegeerd en anoniem
ophalen laten elk hun eigen toets omvallen. Volledige suite 1283 groen.

Het gedrag in chrome.js is in de browser nagelopen met een nagebootste
server: herkennen, laden, invoegen, de volgorde bij een traag antwoord, de
terugval bij 204, waarderen en boosten heen en terug, en het antwoordvenster
open en dicht met de focus op de goede plek.

shaer-utpi

Co-Authored-By: Claude Opus 5.5 <noreply@…>

  • Property mode set to 100644
File size: 19.1 KB
RevLine 
[1e172f3]1// De chrome: alles wat om elke pagina heen staat (shaer-bqr).
2//
3// Dit stond als inline <script> in de partials. Dat kan niet blijven: de
4// CSP-nonce rouleert per verzoek, en de chrome wordt bij ELKE htmx-navigatie
5// out-of-band opnieuw ingevoegd. Zo'n script draagt dan een nonce die het
6// document niet kent en wordt geweigerd -- dus viel de chrome-JS bij de eerste
7// klik binnen de site al weg (shaer-0i6).
8//
9// Nu een module, geladen door de bootstrap in shell.ejs. Die zit in het
10// document zelf, heeft dus wel de goede nonce, en een dynamische import vanuit
11// een vertrouwd script is precies waar 'strict-dynamic' voor is.
12//
13// TWEE REGELS voor alles wat hier bij komt:
14//
15// GEDELEGEERD luister op document, nooit op een element dat er nu staat. De
16// chrome wordt vervangen, dus een vastgehouden verwijzing is na
17// een navigatie een verwijzing naar iets dat weg is.
18// IDEMPOTENT de module kan een tweede keer geladen worden. Een slot op
19// window voorkomt dat er een tweede stel luisteraars bij komt --
20// dat is hoe de themaknop ooit twee keer vuurde en dus niets deed.
21//
22// En: GEEN servergegevens in deze code. Interpolatie hoort niet in een statisch
23// bestand; wat de server wil meegeven komt via een data-attribuut op een element.
24
25(function () {
26 'use strict';
27 if (window.__chromeMod) return;
28 window.__chromeMod = true;
29
30 // De zoekknop in de bottom-tab opent de overlay. De overlay zelf wordt bij een
31 // htmx-navigatie vervangen, dus hem hier vasthouden zou na een klik binnen de
32 // site niets meer opleveren: elke keer opnieuw opzoeken.
33 document.addEventListener('click', function (e) {
34 if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
35 var o = document.getElementById('search-overlay');
36 if (o) {
37 o.hidden = false;
38 var i = o.querySelector('input');
39 if (i) i.focus();
40 }
41 });
42})();
[54ee51c]43
44// ── de profielkop ──────────────────────────────────────────────
45// Zat inline in partials/profile-header.ejs, en die partial zit IN de
46// OOB-chrome -- dus hij kwam bij elke navigatie opnieuw binnen.
47(function () {
48 if (window.__pcmsFediFollowWired) return;
49 window.__pcmsFediFollowWired = true;
50 function modal() { return document.querySelector('.pf-follow'); }
51 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
52 function go() {
53 var m = modal(); if (!m) return;
54 var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
55 // Strip a full @user@host handle (and any scheme/path) down to the server host.
56 var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
57 if (!server) { if (inp) inp.focus(); return; }
58 try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
59 var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
60 location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
61 }
62 document.addEventListener('click', function (e) {
63 var b = e.target.closest && e.target.closest('.profile-fedi-link');
64 if (b) { e.preventDefault();
65 var m = modal(); if (!m) return;
66 m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
67 var inp = m.querySelector('.pf-follow-input');
68 try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
69 m.classList.add('is-open');
70 setTimeout(function () { if (inp) inp.focus(); }, 30);
71 return;
72 }
73 if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
74 if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
75 var open = document.querySelector('.pf-follow.is-open');
76 if (open && e.target === open) closeM(); /* click on backdrop */
77 });
78 document.addEventListener('keydown', function (e) {
79 var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
80 if (e.key === 'Escape') { closeM(); }
81 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
82 });
83})();
84
85/* Profile summary: click the avatar to open a modal with photo + details. */
86(function () {
87 if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
88 function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
89 document.addEventListener('click', function (e) {
90 if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
91 var open = document.querySelector('.pf-summary.is-open');
92 if (open && e.target === open) { close(); return; } /* click on backdrop */
93 var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
94 if (!t) return;
95 e.preventDefault();
96 var root = (t.closest && t.closest('#pcms-chrome')) || document;
97 var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
98 if (modal) modal.classList.add('is-open');
99 });
100 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
101})();
[156baa3]102
103// ── de topnav ──────────────────────────────────────────────────
104// Zat inline in partials/topnav.ejs. De zoekteksten komen nu van het
105// overlay-element (data-i18n) in plaats van uit interpolatie.
106(function() {
107 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
108 // → without this guard the script would stack EXTRA listeners on every navigation,
109 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
110 // Everything below uses event delegation on body/document, so it also works for
111 // buttons that appear after this run (OOB).
112 if (window.__pcmsChromeWired) return;
113 window.__pcmsChromeWired = true;
114
115 function toggleTheme() {
116 var cur = document.documentElement.getAttribute('data-theme') || 'dark';
117 var next = cur === 'dark' ? 'light' : 'dark';
118 document.documentElement.setAttribute('data-theme', next);
119 try { localStorage.setItem('pcms-theme', next); } catch (e) {}
120 }
121 function overlay() { return document.getElementById('search-overlay'); }
122 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
123 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
124
125 document.body.addEventListener('click', function(e) {
126 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
127 if (e.target.closest('#search-toggle')) { openSearch(); return; }
128 if (e.target.closest('#search-close')) { closeSearch(); return; }
129 // Close open dropdowns (user menu + language picker) on click outside.
130 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
131 if (!d.contains(e.target)) d.removeAttribute('open');
132 });
133 });
134
135 document.addEventListener('keydown', function(e) {
136 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
137 });
138
139 // ── Live results while typing ───────────────────────────────────
140 // De teksten komen van de overlay zelf, elke keer opnieuw: bij een
141 // htmx-navigatie wordt die vervangen en kan de taal gewisseld zijn.
142 function _st() {
143 var o = document.getElementById('search-overlay');
144 try { return JSON.parse((o && o.getAttribute('data-i18n')) || '{}'); } catch (e) { return {}; }
145 }
146 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
147 function renderSuggest(box, d, q, ov) {
148 var html = '';
149 function grp(title, items, fmt) {
150 if (!items || !items.length) return;
151 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
152 }
153 grp(_st().posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
154 grp(_st().tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
155 grp(_st().events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
156 grp(_st().pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
157 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
158 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_st().none) + '</div>'; return; }
159 var action = (ov && ov.getAttribute('data-action')) || '/search';
160 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>';
161 box.innerHTML = html;
162 }
[9eb6e1b]163 // Een ADRES is geen zoekterm (shaer-utpi). Dezelfde regel als lookupUri in
164 // routes/search.js: een schema is verplicht, anders wordt zoeken naar
165 // "soundfabrics.nl" een netwerkverzoek. De server beslist daarna nog eens,
166 // met de rechten erbij; dit scheelt alleen een vraag die toch nee oplevert.
167 function lookupUri(q) {
168 if (!/^https?:\/\//i.test(q) || /\s/.test(q)) return null;
169 try { var u = new URL(q); return u.hostname.indexOf('.') < 0 ? null : u.href; } catch (e) { return null; }
170 }
171 function allLink(ov, q) {
172 var action = (ov && ov.getAttribute('data-action')) || '/search';
173 return '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>';
174 }
175 function fetchSuggest(box, q, ov, seq) {
176 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
177 fetch(url + '?q=' + encodeURIComponent(q))
178 .then(function(r){ return r.ok ? r.json() : null; })
179 .then(function(d){ if (d && seq === _sSeq) renderSuggest(box, d, q, ov); })
180 .catch(function(){});
181 }
182 // Elke toetsaanslag krijgt een volgnummer. Een ophaling van een andere
183 // server duurt soms seconden; zonder nummer overschrijft een traag antwoord
184 // op een oude vraag wat je inmiddels hebt getypt.
185 var _sTimer, _sSeq = 0;
[156baa3]186 document.addEventListener('input', function(e) {
187 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
188 if (!inp) return;
189 var box = document.getElementById('search-suggest');
190 if (!box) return;
191 var q = inp.value.trim();
[9eb6e1b]192 var seq = ++_sSeq;
[156baa3]193 clearTimeout(_sTimer);
194 if (q.length < 2) { box.innerHTML = ''; return; }
[9eb6e1b]195 var ov = document.getElementById('search-overlay');
196 var address = lookupUri(q);
197 if (!address) { _sTimer = setTimeout(function() { fetchSuggest(box, q, ov, seq); }, 200); return; }
198 // Een geplakt adres: de post zelf, niet de tekst van de link doorzocht.
199 box.innerHTML = '<div class="ss-remote-loading">' + esc(_st().looking) + '</div>';
[156baa3]200 _sTimer = setTimeout(function() {
[9eb6e1b]201 var url = (ov && ov.getAttribute('data-remote')) || '/search/remote';
202 fetch(url + '?q=' + encodeURIComponent(address), { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } })
203 .then(function(r) {
204 if (seq !== _sSeq) return;
205 // 204: geen recht om op te halen (of toch geen adres). Dan is het
206 // gewoon een zoekterm en krijg je de gewone suggesties.
207 if (r.status === 204 || !r.ok) { fetchSuggest(box, q, ov, seq); return; }
208 return r.text().then(function(html) { if (seq === _sSeq) box.innerHTML = html + allLink(ov, q); });
209 })
210 .catch(function() { if (seq === _sSeq) fetchSuggest(box, q, ov, seq); });
211 }, 300);
[156baa3]212 });
[9eb6e1b]213 // Een klik op een suggestie sluit de overlay, want de link gaat ergens heen.
214 // Behalve wat in een nieuw tabblad opent (het origineel, een profiel): dan
215 // blijf je hier, en hoort de kaart er nog te staan als je terugkomt.
[156baa3]216 document.addEventListener('click', function(e) {
[9eb6e1b]217 var a = e.target.closest && e.target.closest('#search-suggest a');
218 if (a && a.getAttribute('target') !== '_blank') { var o = overlay(); if (o) o.hidden = true; }
219 });
220})();
221
222// ── De kaart van een post van een andere server ─────────────────────────
223// Staat hier en niet in authorize-interaction.js, want de kaart staat nu op
224// drie plekken: de live-preview (elke pagina, via de zoekbalk), de zoekpagina
225// en /authorize_interaction. Alles luistert op document, dus een kaart die
226// later binnenkomt (de preview, een htmx-wissel) doet vanzelf mee.
227(function() {
228 if (window.__remoteCardWired) return; window.__remoteCardWired = true;
229
230 // Waarderen en boosten ter plekke: POST via fetch, de knop omzetten, blijven
231 // waar je bent. Zonder JS doet het formulier hetzelfde met een omweg.
232 document.addEventListener('submit', function(e) {
233 var f = e.target.closest && e.target.closest('.fedi-react-form');
234 if (!f) return;
235 e.preventDefault();
236 var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
237 btn.disabled = true;
238 var body = new URLSearchParams();
239 new FormData(f).forEach(function(v, k) { body.append(k, v); });
240 fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
241 .then(function(r) { return r.ok ? r.json() : null; })
242 .then(function(j) {
243 if (!j) return;
244 var on = !!j.on;
245 btn.classList.toggle('is-on', on);
246 btn.setAttribute('aria-pressed', on ? 'true' : 'false');
247 var lbl = btn.querySelector('.rp-act-label');
248 if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
249 })
250 .catch(function() {})
251 .then(function() { btn.disabled = false; });
252 });
253
254 // Antwoorden klapt open waar de kaart staat. Staat er geen venster (de
255 // preview), dan volgt de knop gewoon zijn link naar de volle pagina, met het
256 // venster daar al open.
257 document.addEventListener('click', function(e) {
258 var open = e.target.closest && e.target.closest('[data-rp-reply]');
259 var close = !open && e.target.closest && e.target.closest('[data-rp-reply-close]');
260 if (!open && !close) return;
261 var panel = document.getElementById('rp-reply');
262 if (!panel) return;
263 e.preventDefault();
264 var btn = document.querySelector('[data-rp-reply][aria-controls="rp-reply"]');
265 var show = !!open && panel.hidden;
266 panel.hidden = !show;
267 if (btn) btn.setAttribute('aria-expanded', show ? 'true' : 'false');
268 if (show) {
269 var ed = panel.querySelector('.re-editor, textarea');
270 if (ed) ed.focus();
271 // Op een telefoon staat het venster vaak onder de vouw.
272 if (panel.scrollIntoView) panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
273 } else if (btn) {
274 btn.focus();
275 }
[156baa3]276 });
277})();
278
279// ── het profielblad ────────────────────────────────────────────
280// Zat inline in partials/profile-sheet.ejs, dat de shell opneemt.
281(function() {
282 const sheet = document.getElementById('profile-sheet');
283 if (!sheet) return;
284
285 const backdrop = document.getElementById('profile-sheet-backdrop');
286 const panel = sheet.querySelector('.profile-sheet-panel');
287 const closeBtn = document.getElementById('profile-sheet-close');
288 const dragZone = document.getElementById('profile-sheet-drag-zone');
289 const themeBtn = document.getElementById('profile-sheet-theme');
290 const themeLbl = document.getElementById('profile-sheet-theme-state');
291
292 function openSheet() {
293 sheet.classList.add('is-open');
294 sheet.setAttribute('aria-hidden', 'false');
295 document.body.classList.add('profile-sheet-locked');
296 syncTheme();
297 }
298 function closeSheet() {
299 sheet.classList.remove('is-open');
300 sheet.setAttribute('aria-hidden', 'true');
301 document.body.classList.remove('profile-sheet-locked');
302 panel.style.removeProperty('--pcms-drag-y');
303 }
304
305 // Open: any element with [data-profile-sheet-toggle]
306 document.addEventListener('click', function(e) {
307 const trigger = e.target.closest('[data-profile-sheet-toggle]');
308 if (trigger) {
309 e.preventDefault();
310 openSheet();
311 }
312 });
313
314 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
315 if (backdrop) backdrop.addEventListener('click', closeSheet);
316 if (closeBtn) closeBtn.addEventListener('click', closeSheet);
317 document.addEventListener('keydown', (e) => {
318 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
319 });
320
321 // Menu items that navigate: close synchronously before nav fires
322 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
323 el.addEventListener('click', closeSheet);
324 });
325
326 // Drag-down-to-close on touch.
327 // Uses --pcms-drag-y custom property rather than overwriting the panel's
328 // transform string. This composes correctly with any horizontal centering
329 // (currently none here, but the pattern matches audio-sheet for safety).
330 let startY = 0, lastY = 0, dragging = false;
331 function onDown(e) {
332 if (e.pointerType !== 'touch') return;
333 if (panel.scrollTop > 0) return;
334 startY = lastY = e.clientY;
335 dragging = true;
336 panel.classList.add('is-dragging');
337 }
338 function onMove(e) {
339 if (!dragging) return;
340 lastY = e.clientY;
341 const dy = Math.max(0, lastY - startY);
342 panel.style.setProperty('--pcms-drag-y', dy + 'px');
343 }
344 function onUp() {
345 if (!dragging) return;
346 dragging = false;
347 panel.classList.remove('is-dragging');
348 const dy = lastY - startY;
349 if (dy > 80) closeSheet();
350 else panel.style.removeProperty('--pcms-drag-y');
351 }
352 if (window.PointerEvent && dragZone) {
353 dragZone.addEventListener('pointerdown', onDown);
354 document.addEventListener('pointermove', onMove);
355 document.addEventListener('pointerup', onUp);
356 document.addEventListener('pointercancel', onUp);
357 }
358
359 // Theme toggle inside sheet — syncs label
360 function syncTheme() {
361 if (!themeLbl) return;
362 const t = document.documentElement.getAttribute('data-theme') || 'dark';
363 // De twee labels staan op het element zelf: een module kan geen vertaling
364 // interpoleren, en zo hoort de tekst bij het ding dat hem toont.
365 themeLbl.textContent = themeLbl.getAttribute(t === 'dark' ? 'data-dark' : 'data-light') || themeLbl.textContent;
366 }
367 if (themeBtn) {
368 themeBtn.addEventListener('click', function() {
369 const cur = document.documentElement.getAttribute('data-theme') || 'dark';
370 const next = cur === 'dark' ? 'light' : 'dark';
371 document.documentElement.setAttribute('data-theme', next);
372 try { localStorage.setItem('pcms-theme', next); } catch (e) {}
373 syncTheme();
374 });
375 }
376})();
Note: See TracBrowser for help on using the repository browser.