Initial commit

This commit is contained in:
2025-10-10 18:24:12 -04:00
commit c0e05bfd81
27 changed files with 10684 additions and 0 deletions

261
rss/js/main.js Normal file
View File

@@ -0,0 +1,261 @@
// ==========================================================================
// HTTP helpers
// ==========================================================================
async function _post(formObj, target, func) {
try {
const body = await createFormData(formObj, target, func);
const res = await fetch('/rss/php/handler.php', { method: 'POST', body });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Post failed', err);
return { ok: false, error: String(err?.message || err) };
}
}
async function createFormData(data = {}, target, func) {
const fd = new FormData();
try {
Object.entries(data || {}).forEach(([k, v]) => {
if (Array.isArray(v)) v.forEach((item) => fd.append(`${k}[]`, item));
else if (v !== undefined && v !== null) fd.append(k, v);
});
if (target !== undefined) fd.append('target', target);
if (func !== undefined) fd.append('function', func);
} catch (err) {
console.error('Incorrect use of object', err);
}
return fd;
}
// ==========================================================================
// DOM helpers
// ==========================================================================
function createEl(tag, classArr = null, text = null, dataAttrs = null) {
const el = document.createElement(tag);
if (text !== null && text !== undefined) el.append(document.createTextNode(String(text)));
if (classArr && classArr.length) el.classList.add(...classArr);
if (dataAttrs && Object.keys(dataAttrs).length) {
for (const [k, v] of Object.entries(dataAttrs)) el.dataset[k] = v;
}
return el;
}
function getRandomID() {
if (crypto?.randomUUID) return crypto.randomUUID();
return Math.random().toString(36).slice(2);
}
// ==========================================================================
// Alerts (aligned with .alert + .alert-{name})
// ==========================================================================
/*
generateAlert('success', 'Saved!') → <div class="alert alert-success" ...>Saved!</div>
Options: { dismissAfter: ms, withClose: boolean, role: 'status'|'alert' }
*/
async function generateAlert(type = 'info', text = '', opts = {}) {
const { dismissAfter = 0, withClose = false, role = 'status' } = opts;
const alert = createEl('div', ['alert', `alert-${type}`]);
alert.setAttribute('role', role); // status (polite) or alert (assertive)
alert.setAttribute('aria-live', role === 'alert' ? 'assertive' : 'polite');
alert.append(document.createTextNode(text));
if (withClose) {
const btn = createEl('button', ['btn', 'btn-sm', 'btn-ghost-dark'], '×', { dismiss: 'alert' });
btn.setAttribute('aria-label', 'Close alert');
btn.style.marginLeft = '0.5rem';
btn.addEventListener('click', () => alert.remove());
alert.append(btn);
}
if (dismissAfter > 0) setTimeout(() => alert.remove(), dismissAfter);
return alert;
}
async function removeAlert(scope = document) {
scope.querySelectorAll('.alert').forEach((a) => a.remove());
}
// ==========================================================================
// On load
// ==========================================================================
document.addEventListener('DOMContentLoaded', () => {
// Menu toggler (pointerdown → click for better a11y; add ARIA state)
const toggler = document.getElementById('menu-toggler');
const menu = document.getElementById('mainMenu');
if (toggler && menu) {
toggler.setAttribute('aria-expanded', 'false');
toggler.setAttribute('aria-controls', 'mainMenu');
const toggleMenu = () => {
const isOpen = menu.classList.toggle('open');
toggler.setAttribute('aria-expanded', String(isOpen));
};
toggler.addEventListener('click', toggleMenu);
toggler.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleMenu(); }
});
}
// Section reveal
const sections = document.querySelectorAll('section');
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
obs.unobserve(entry.target);
}
});
},
{ threshold: 0.05, rootMargin: '0px 0px -50px 0px' }
);
sections.forEach((section) => {
section.classList.add('section-animation');
observer.observe(section);
});
(() => {
function resolveTarget(el) {
const viaData = el.getAttribute('data-target');
const viaHref = el.getAttribute('href');
if (viaData) return document.querySelector(viaData);
if (viaHref && viaHref.startsWith('#')) return document.querySelector(viaHref);
// Fallback: if only one modal exists and no selector provided
const all = document.querySelectorAll('.modal');
return all.length === 1 ? all[0] : null;
}
function trapFocus(modal, e) {
const focusables = modal.querySelectorAll('a,button,input,textarea,select,[tabindex]:not([tabindex="-1"])');
if (!focusables.length) return;
const first = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
}
let lastActive = null;
function openModal(modal) {
if (!modal) return;
lastActive = document.activeElement;
modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false');
(modal.querySelector('.modal__dialog') || modal).focus({ preventScroll: true });
modal.addEventListener('keydown', onKeyDown);
}
function closeModal(modal) {
if (!modal) return;
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
modal.removeEventListener('keydown', onKeyDown);
if (lastActive) lastActive.focus({ preventScroll: true });
}
function onKeyDown(e) {
const modal = e.currentTarget;
if (e.key === 'Escape') closeModal(modal);
if (e.key === 'Tab') trapFocus(modal, e);
}
// Openers
document.querySelectorAll('.modal-open').forEach(btn => {
btn.addEventListener('click', (e) => {
const modal = resolveTarget(btn);
if (modal) openModal(modal);
e.preventDefault();
});
btn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); }
});
});
// Closers (overlay or close button)
document.addEventListener('click', (e) => {
const overlay = e.target.closest('.modal__overlay');
const closeBtn = e.target.closest('.modal__close');
if (!overlay && !closeBtn) return;
const modal = e.target.closest('.modal');
if (modal) closeModal(modal);
});
})();
(() => {
function getTargetId(tab) {
const href = tab.getAttribute('href');
if (href && href.startsWith('#')) return href.slice(1);
const aria = tab.getAttribute('aria-controls');
if (aria) return aria;
return null;
}
document.querySelectorAll('.tabs').forEach(tabs => {
const tabEls = tabs.querySelectorAll('.tab');
if (!tabEls.length) return;
// If panels are used, they should be siblings elsewhere with .tab-panel
function activateTab(tab) {
// Toggle active tab
tabEls.forEach(t => t.classList.toggle('active', t === tab));
// Panels (optional)
const targetId = getTargetId(tab);
const panels = document.querySelectorAll('.tab-panel');
if (panels.length) {
panels.forEach(p => {
const match = p.id && targetId && p.id === targetId;
p.hidden = !match;
});
}
}
// Initial state: keep existing .active or default to first
const current = tabs.querySelector('.tab.active') || tabEls[0];
if (current) activateTab(current);
tabEls.forEach(tab => {
tab.setAttribute('role', 'tab');
tab.addEventListener('click', (e) => {
activateTab(tab);
if (tab.hasAttribute('href')) e.preventDefault(); // prevent jump for href="#id"
});
tab.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tab.click(); }
// Optional arrow key UX within the same .tabs group
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
const arr = Array.from(tabEls);
const i = arr.indexOf(tab);
const next = e.key === 'ArrowRight' ? (i + 1) % arr.length : (i - 1 + arr.length) % arr.length;
arr[next].focus();
}
});
});
});
})();
});
document.querySelectorAll('.accordion').forEach(acc => {
const single = acc.classList.contains('accordion--single');
acc.querySelectorAll('.accordion__header').forEach(btn => {
btn.addEventListener('click', () => {
const item = btn.closest('.accordion__item');
const isOpen = item.classList.contains('is-open');
if (single) {
acc.querySelectorAll('.accordion__item.is-open').forEach(other => {
if (other !== item) other.classList.remove('is-open');
});
}
item.classList.toggle('is-open', !isOpen);
});
btn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); }
});
});
});