PageSourceSearch

https://www.ctsfw.edu/wp-content/plugins/ctsfw-shortcodes/assets/js/ctsfw-modal.js?ver=1.2.3

js ctsfw.edu collected 2026-10-02 05:54:46 UTC 13,407 bytes, 281 lines download raw bytes

1/* CTSFW Shared Events Modal (patched for UX & branding) */
2(() => {
3    const qs = (s, r = document) => r.querySelector(s);
4    const qsa = (s, r = document) => Array.from(r.querySelectorAll(s));
5    const calendarIcon = (name) => {
6        const template = qs(`[data-ctsfw-modal-icon="${name}"]`);
7        const icon = template && template.content ? template.content.firstElementChild : null;
8        return icon ? icon.cloneNode(true) : null;
9    };
10
11    const getShell = () => ({
12        modal: qs('[data-ctsfw-modal]'),
13        overlay: qs('[data-ctsfw-modal-overlay]'),
14        close: qs('[data-ctsfw-modal-close]'),
15        header: qs('[data-ctsfw-modal-header]'),
16        title: qs('[data-ctsfw-modal-title]'),
17        list: qs('[data-ctsfw-modal-events]')
18    });
19
20    // ---- time helpers (unchanged) ----
21    const toISO = (v) => { if (!v) return ''; const d = new Date(v); return isNaN(d) ? '' : d.toISOString(); };
22    const toYMD = (iso) => {
23        const raw = String(iso || '');
24        const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})/);
25        if (match) return match.slice(1).join('');
26        const d = new Date(raw);
27        return isNaN(d) ? '' : [d.getUTCFullYear(), String(d.getUTCMonth() + 1).padStart(2, '0'), String(d.getUTCDate()).padStart(2, '0')].join('');
28    };
29    const fmtZ = (iso) => { const d = new Date(iso); if (isNaN(d)) return ''; const p = n => (n < 10 ? '0' : '') + n; return d.getUTCFullYear() + p(d.getUTCMonth() + 1) + p(d.getUTCDate()) + 'T' + p(d.getUTCHours()) + p(d.getUTCMinutes()) + p(d.getUTCSeconds()) + 'Z'; };
30    const isoTime = (iso) => (String(iso || '').match(/T(\d{2}):(\d{2})(?::(\d{2}))?/) || []).slice(1).map(Number);
31    const nextYMD = (ymd) => { const d = new Date(`${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); return toYMD(d.toISOString()); };
32    const isAllDay = (sISO, eISO) => {
33        const [sh, sm, ss = 0] = isoTime(sISO);
34        if (sh !== 0 || sm !== 0 || ss !== 0) return false;
35        if (!eISO) return true;
36        const [eh, em] = isoTime(eISO);
37        return (eh === 23 && em === 59) || (eh === 0 && em === 0);
38    };
39
40    const escICS = (s = '') => String(s)
41        .replace(/\\/g, '\\\\')
42        .replace(/\r\n|\r|\n/g, '\\n')
43        .replace(/([,;])/g, '\\$1');
44    const safeHref = (value, fallback = '#') => {
45        try {
46            const url = new URL(String(value || ''), window.location.href);
47            return ['https:', 'http:', 'data:'].includes(url.protocol) ? url.href : fallback;
48        } catch (_) { return fallback; }
49    };
50
51    const buildGCalUrl = ({ title = '', url = '', startISO = '', endISO = '', location = '', desc = '' }) => {
52        let u = 'https://calendar.google.com/calendar/render?action=TEMPLATE';
53        u += '&text=' + encodeURIComponent(title || 'Event');
54        const details = [url, desc].filter(Boolean).join('\n\n');
55        if (details) u += '&details=' + encodeURIComponent(details);
56        if (location) u += '&location=' + encodeURIComponent(location);
57
58        if (startISO) {
59            if (isAllDay(startISO, endISO)) {
60                const s = toYMD(startISO);
61                let e = endISO ? toYMD(endISO) : '';
62                if (!e || e === s) e = nextYMD(s);
63                u += '&dates=' + encodeURIComponent(`${s}/${e}`);
64            } else {
65                const s = fmtZ(startISO);
66                const e = fmtZ(endISO || startISO);
67                if (s && e) u += '&dates=' + encodeURIComponent(`${s}/${e}`);
68            }
69        }
70        return u;
71    };
72
73    const buildICSDataUrl = ({ title = '', url = '', startISO = '', endISO = '', location = '', desc = '' }) => {
74        const nowISO = new Date().toISOString();
75        const uid = (Math.random().toString(36).slice(2)) + '@ctsfw';
76        const lines = [
77            'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//CTFW//Event//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH',
78            'BEGIN:VEVENT', 'UID:' + uid, 'DTSTAMP:' + fmtZ(nowISO)
79        ];
80        if (startISO) {
81            if (isAllDay(startISO, endISO)) {
82                const s = toYMD(startISO);
83                let e = endISO ? toYMD(endISO) : '';
84                if (!e || e === s) e = nextYMD(s);
85                lines.push('DTSTART;VALUE=DATE:' + s, 'DTEND;VALUE=DATE:' + e);
86            } else {
87                const s = fmtZ(startISO), e = fmtZ(endISO || startISO);
88                if (s) lines.push('DTSTART:' + s);
89                if (e) lines.push('DTEND:' + e);
90            }
91        }
92        lines.push('SUMMARY:' + escICS(title || 'Event'));
93        if (location) lines.push('LOCATION:' + escICS(location));
94        if (desc) lines.push('DESCRIPTION:' + escICS(desc));
95        if (url) lines.push('URL:' + escICS(url));
96        lines.push('END:VEVENT', 'END:VCALENDAR');
97        return 'data:text/calendar;charset=utf-8,' + encodeURIComponent(lines.join('\r\n'));
98    };
99
100    // ---------- Render + open/close ----------
101    function ensureChrome() {
102        const { modal, overlay, title, header, close } = getShell();
103        if (!modal || !overlay) return;
104
105        // Title: ensure exists + correct text & classes
106        let t = title || (header && header.querySelector('[data-ctsfw-modal-title]'));
107        if (!t) {
108            // create a header/title if not present
109            const hdr = header || document.createElement('div');
110            hdr.setAttribute('data-ctsfw-modal-header', '');
111            if (!header) modal.prepend(hdr);
112
113            t = document.createElement('h2');
114            t.setAttribute('data-ctsfw-modal-title', '');
115            hdr.appendChild(t);
116        }
117        t.textContent = 'Add Event(s) to Calendar';
118        t.className = 'ctsfw-modal__title';
119
120        // Close button: inject if missing
121        let x = close;
122        if (!x) {
123            x = document.createElement('button');
124            x.type = 'button';
125            x.setAttribute('data-ctsfw-modal-close', '');
126            x.setAttribute('aria-label', 'Close');
127            x.className = 'ctsfw-modal-close'; // styled in CSS below
128            x.textContent = '×';
129            modal.prepend(x);
130        }
131    }
132
133    function hydrateItems(listEl, items) {
134        listEl.innerHTML = '';
135        items.forEach((evt) => {
136            const title = evt.title ?? '';
137            const startISO = evt.startISO ?? '';
138            const endISO = evt.endISO ?? '';
139            const location = evt.location ?? '';
140            const url = evt.url ?? '';
141            const desc = evt.desc ?? '';
142
143            const gcalUrl = evt.gcalUrl || buildGCalUrl({ title, url, startISO, endISO, location, desc });
144            const icsUrl = evt.icsUrl || buildICSDataUrl({ title, url, startISO, endISO, location, desc });
145
146            const li = document.createElement('li');
147            const row = document.createElement('div');
148            row.className = 'ctsfw-event-row';
149            const info = document.createElement('div');
150            info.className = 'ctsfw-event-info';
151            const titleEl = document.createElement('div');
152            titleEl.className = 'ctsfw-event-title';
153            titleEl.textContent = title;
154            const meta = document.createElement('div');
155            meta.className = 'ctsfw-event-meta';
156            meta.textContent = `${startISO ? new Date(startISO).toLocaleString() : ''}${location ? ' · ' + location : ''}`;
157            info.append(titleEl, meta);
158            const links = document.createElement('div');
159            links.className = 'ctsfw-cal-links';
160            const google = document.createElement('a');
161            google.className = 'ctsfw-cal-link ctsfw-cal-link--google';
162            google.href = safeHref(gcalUrl);
163            google.target = '_blank';
164            google.rel = 'noopener';
165            google.setAttribute('aria-label', 'Add to Google Calendar');
166            google.title = 'Add to Google Calendar';
167            const googleIcon = calendarIcon('google');
168            if (googleIcon) google.appendChild(googleIcon); else google.textContent = 'Google';
169            const apple = document.createElement('a');
170            apple.className = 'ctsfw-cal-link ctsfw-cal-link--apple';
171            apple.href = safeHref(icsUrl);
172            apple.setAttribute('download', 'event.ics');
173            apple.setAttribute('aria-label', 'Download Apple Calendar (.ics)');
174            apple.title = 'Download Apple Calendar (.ics)';
175            const appleIcon = calendarIcon('apple');
176            if (appleIcon) apple.appendChild(appleIcon); else apple.textContent = 'Apple';
177            links.append(google, apple);
178            row.append(info, links);
179            li.appendChild(row);
180            listEl.appendChild(li);
181        });
182    }
183
184    function doOpen(items) {
185        const { modal, overlay, list } = getShell();
186        if (!modal || !overlay || !list) { console.warn('[CTSFWMODAL] Modal shell missing.'); return; }
187        ensureChrome();
188        hydrateItems(list, items);
189        modal.removeAttribute('hidden');
190        overlay.removeAttribute('hidden');
191        modal.setAttribute('data-open', 'true');
192        modal.removeAttribute('aria-hidden');
193        // focus close for a11y if present
194        const { close } = getShell();
195        if (close) try { close.focus({ preventScroll: true }); } catch (_) { }
196    }
197
198    function doClose() {
199        const { modal, overlay, list } = getShell();
200        if (!modal || !overlay || !list) return;
201        modal.removeAttribute('data-open');
202        modal.setAttribute('aria-hidden', 'true');
203        modal.setAttribute('hidden', '');
204        overlay.setAttribute('hidden', '');
205        list.innerHTML = '';
206    }
207
208    window.CTSFWMODAL = { open: doOpen, close: doClose };
209
210    // Close hooks
211    document.addEventListener('click', (e) => {
212        const { modal, overlay, close } = getShell();
213        if (!modal) return;
214        if (overlay && e.target === overlay) { e.preventDefault(); doClose(); }
215        if (close && e.target.closest('[data-ctsfw-modal-close]') === close) { e.preventDefault(); doClose(); }
216    });
217    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') doClose(); });
218
219    // Delegation for triggers
220    document.addEventListener('click', (e) => {
221        const trigger = e.target.closest('[data-ctsfw-addtocalendar]');
222        if (!trigger) return;
223        e.preventDefault();
224
225        const json = trigger.getAttribute('data-events');
226        if (json) {
227            try {
228                const arr = JSON.parse(json);
229                if (Array.isArray(arr) && arr.length) {
230                    const norm = arr.map(o => ({
231                        title: o.title ?? '',
232                        url: o.url ?? '',
233                        startISO: toISO(o.startISO || o.start || ''),
234                        endISO: toISO(o.endISO || o.end || ''),
235                        location: o.location ?? '',
236                        desc: o.desc ?? ''
237                    }));
238                    return doOpen(norm);
239                }
240            } catch (_) { /* fall through */ }
241        }
242
243        const sel = trigger.getAttribute('data-target');
244        if (!sel) { console.warn('[CTSFWMODAL] Trigger missing data-events or data-target.'); return; }
245
246        const nodes = qsa(sel);
247        if (!nodes.length) { console.warn('[CTSFWMODAL] No nodes matched for selector:', sel); return; }
248
249        const items = nodes.flatMap(n => {
250            const occurrenceData = n.getAttribute('data-occurrences');
251            if (occurrenceData) {
252                try {
253                    const occurrences = JSON.parse(occurrenceData);
254                    if (Array.isArray(occurrences)) {
255                        return occurrences.map(o => ({
256                            title: o.title ?? '',
257                            url: o.url ?? '',
258                            startISO: toISO(o.startISO || o.start || ''),
259                            endISO: toISO(o.endISO || o.end || ''),
260                            location: o.location ?? '',
261                            desc: o.desc ?? ''
262                        }));
263                    }
264                } catch (_) { /* use the parent event fallback below */ }
265            }
266            const title = n.getAttribute('data-title') || n.querySelector('[itemprop="name"]')?.textContent?.trim() || '';
267            const url = n.getAttribute('data-url') || n.getAttribute('data-href') || n.querySelector('a')?.href || '';
268            const start = n.getAttribute('data-start') || n.querySelector('[data-start]')?.getAttribute('data-start') || '';
269            const end = n.getAttribute('data-end') || n.querySelector('[data-end]')?.getAttribute('data-end') || '';
270            const loc = n.getAttribute('data-location') || n.querySelector('[data-location]')?.textContent?.trim() || '';
271            const desc = n.getAttribute('data-desc') || n.querySelector('[data-desc]')?.textContent?.trim() || '';
272            const o = { title, url, startISO: toISO(start), endISO: toISO(end), location: loc, desc };
273            const g = n.getAttribute('data-gcal') || n.querySelector('[data-gcal]')?.getAttribute?.('href');
274            const i = n.getAttribute('data-ics') || n.querySelector('[data-ics]')?.getAttribute?.('href');
275            if (g) o.gcalUrl = g; if (i) o.icsUrl = i;
276            return [o];
277        });
278
279        doOpen(items);
280    });
281})();

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.