// public/js/portal.js // Standalone client portal logic – no imports from main app JS to avoid DOM coupling. let portal = null; let portalFormDone = false; // --- Portal helpers: folder + download flag ----------------- function portalFolder() { if (!portal) return 'root'; return portal.folder || portal.targetFolder || portal.path || 'root'; } function portalCanUpload() { if (!portal) return false; // Prefer explicit flags from backend (PortalController) if (typeof portal.canUpload !== 'undefined') { return !!portal.canUpload; } // Fallbacks for older bundles (if you ever add these) if (typeof portal.allowUpload !== 'undefined') { return !!portal.allowUpload; } // Legacy behavior: portals were always upload-capable; // uploadOnly only controlled download visibility. return true; } function portalCanDownload() { if (!portal) return false; // Prefer explicit flag if present (PortalController) if (typeof portal.canDownload !== 'undefined') { return !!portal.canDownload; } // Fallback to allowDownload / allowDownloads (older payloads) if (typeof portal.allowDownload !== 'undefined') { return !!portal.allowDownload; } if (typeof portal.allowDownloads !== 'undefined') { return !!portal.allowDownloads; } // Legacy: uploadOnly = true => no downloads if (typeof portal.uploadOnly !== 'undefined') { return !portal.uploadOnly; } // Default: allow downloads return true; } function getPortalSlug() { return portal && (portal.slug || portal.label || '') || ''; } function normalizeExtList(raw) { if (!raw) return []; return String(raw) .split(/[,\s]+/) .map(x => x.trim().replace(/^\./, '').toLowerCase()) .filter(Boolean); } function getAllowedExts() { if (!portal || !portal.uploadExtWhitelist) return []; return normalizeExtList(portal.uploadExtWhitelist); } function getMaxSizeBytes() { if (!portal || !portal.uploadMaxSizeMb) return 0; const n = parseInt(portal.uploadMaxSizeMb, 10); if (!n || n <= 0) return 0; return n * 1024 * 1024; } // Simple per-browser-per-day counter; not true IP-based. function applyUploadRateLimit(desiredCount) { if (!portal || !portal.uploadMaxPerDay) return desiredCount; const maxPerDay = parseInt(portal.uploadMaxPerDay, 10); if (!maxPerDay || maxPerDay <= 0) return desiredCount; const slug = getPortalSlug() || 'default'; const today = new Date().toISOString().slice(0, 10); const key = 'portalUploadRate:' + slug; let state = { date: today, count: 0 }; try { const raw = localStorage.getItem(key); if (raw) { const parsed = JSON.parse(raw); if (parsed && parsed.date === today && typeof parsed.count === 'number') { state = parsed; } } } catch { // ignore } if (state.count >= maxPerDay) { showToast('Daily upload limit reached for this portal.'); return 0; } const remaining = maxPerDay - state.count; if (desiredCount > remaining) { showToast('You can only upload ' + remaining + ' more file(s) today for this portal.'); return remaining; } return desiredCount; } function bumpUploadRateCounter(delta) { if (!portal || !portal.uploadMaxPerDay || !delta) return; const maxPerDay = parseInt(portal.uploadMaxPerDay, 10); if (!maxPerDay || maxPerDay <= 0) return; const slug = getPortalSlug() || 'default'; const today = new Date().toISOString().slice(0, 10); const key = 'portalUploadRate:' + slug; let state = { date: today, count: 0 }; try { const raw = localStorage.getItem(key); if (raw) { const parsed = JSON.parse(raw); if (parsed && parsed.date === today && typeof parsed.count === 'number') { state = parsed.date === today ? parsed : state; } } } catch { // ignore } if (state.date !== today) { state = { date: today, count: 0 }; } state.count += delta; if (state.count < 0) state.count = 0; try { localStorage.setItem(key, JSON.stringify(state)); } catch { // ignore } } function showThankYouScreen() { if (!portal || !portal.showThankYou) return; const section = qs('portalThankYouSection'); const msgEl = document.getElementById('portalThankYouMessage'); const upload = qs('portalUploadSection'); if (msgEl) { const text = (portal.thankYouText && portal.thankYouText.trim()) || 'Thank you. Your files have been uploaded successfully.'; msgEl.textContent = text; } if (section) { section.style.display = 'block'; } if (upload) { upload.style.opacity = '0.3'; } } // ----------------- DOM helpers / status ----------------- function qs(id) { return document.getElementById(id); } function setStatus(msg, isError = false) { const el = qs('portalStatus'); if (!el) return; el.textContent = msg || ''; el.classList.toggle('text-danger', !!isError); if (!isError) { el.classList.add('text-muted'); } } // ----------------- Form labels (custom captions) ----------------- function applyPortalFormLabels() { if (!portal) return; const labels = portal.formLabels || {}; const required = portal.formRequired || {}; const defs = [ { key: 'name', forId: 'portalFormName', defaultLabel: 'Name' }, { key: 'email', forId: 'portalFormEmail', defaultLabel: 'Email' }, { key: 'reference', forId: 'portalFormReference', defaultLabel: 'Reference / Case / Order #' }, { key: 'notes', forId: 'portalFormNotes', defaultLabel: 'Notes' }, ]; defs.forEach(def => { const labelEl = document.querySelector(`label[for="${def.forId}"]`); if (!labelEl) return; const base = (labels[def.key] || def.defaultLabel || '').trim() || def.defaultLabel; const isRequired = !!required[def.key]; // Add a subtle "*" for required fields; skip if already added const text = isRequired && !base.endsWith('*') ? `${base} *` : base; labelEl.textContent = text; }); } // ----------------- Form submit ----------------- async function submitPortalForm(slug, formData) { const payload = { slug, form: formData }; const headers = { 'X-CSRF-Token': getCsrfToken() || '' }; const res = await sendRequest('/api/pro/portals/submitForm.php', 'POST', payload, headers); if (!res || !res.success) { throw new Error((res && res.error) || 'Error saving form.'); } } // ----------------- Toast ----------------- function showToast(message) { const toast = document.getElementById('customToast'); if (!toast) { console.warn('Toast:', message); return; } toast.textContent = message; toast.style.display = 'block'; // Force reflow void toast.offsetWidth; toast.classList.add('show'); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => { toast.style.display = 'none'; }, 200); }, 2500); } // ----------------- Fetch wrapper ----------------- async function sendRequest(url, method = 'GET', data = null, customHeaders = {}) { const options = { method, credentials: 'include', headers: { ...customHeaders } }; if (data && !(data instanceof FormData)) { options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json'; options.body = JSON.stringify(data); } else if (data instanceof FormData) { options.body = data; } const res = await fetch(url, options); const text = await res.text(); let payload; try { payload = JSON.parse(text); } catch { payload = text; } if (!res.ok) { throw payload; } return payload; } // ----------------- Portal form wiring ----------------- function setupPortalForm(slug) { const formSection = qs('portalFormSection'); const uploadSection = qs('portalUploadSection'); if (!portal || !portal.requireForm || !portalCanUpload()) { if (formSection) formSection.style.display = 'none'; if (uploadSection) uploadSection.style.opacity = '1'; return; } const key = 'portalFormDone:' + slug; if (sessionStorage.getItem(key) === '1') { portalFormDone = true; if (formSection) formSection.style.display = 'none'; if (uploadSection) uploadSection.style.opacity = '1'; return; } portalFormDone = false; if (formSection) formSection.style.display = 'block'; if (uploadSection) uploadSection.style.opacity = '0.5'; const nameEl = qs('portalFormName'); const emailEl = qs('portalFormEmail'); const refEl = qs('portalFormReference'); const notesEl = qs('portalFormNotes'); const submitBtn = qs('portalFormSubmit'); const groupName = qs('portalFormGroupName'); const groupEmail = qs('portalFormGroupEmail'); const groupReference = qs('portalFormGroupReference'); const groupNotes = qs('portalFormGroupNotes'); const labelName = qs('portalFormLabelName'); const labelEmail = qs('portalFormLabelEmail'); const labelReference = qs('portalFormLabelReference'); const labelNotes = qs('portalFormLabelNotes'); const fd = portal.formDefaults || {}; const labels = portal.formLabels || {}; const visRaw = portal.formVisible || portal.formVisibility || {}; const req = portal.formRequired || {}; // default: visible when not specified const visible = { name: visRaw.name !== false, email: visRaw.email !== false, reference: visRaw.reference !== false, notes: visRaw.notes !== false, }; // Apply labels (fallback to defaults) if (labelName) labelName.textContent = labels.name || 'Name'; if (labelEmail) labelEmail.textContent = labels.email || 'Email'; if (labelReference) labelReference.textContent = labels.reference || 'Reference / Case / Order #'; if (labelNotes) labelNotes.textContent = labels.notes || 'Notes'; // Helper to (re)add the required star spans const setStar = (labelEl, isVisible, isRequired) => { if (!labelEl) return; // remove any previous star const old = labelEl.querySelector('.portal-required-star'); if (old) old.remove(); if (isVisible && isRequired) { const s = document.createElement('span'); s.className = 'portal-required-star'; s.textContent = ' *'; labelEl.appendChild(s); } }; // Show/hide groups if (groupName) groupName.style.display = visible.name ? '' : 'none'; if (groupEmail) groupEmail.style.display = visible.email ? '' : 'none'; if (groupReference) groupReference.style.display = visible.reference ? '' : 'none'; if (groupNotes) groupNotes.style.display = visible.notes ? '' : 'none'; // Apply stars AFTER labels and visibility setStar(labelName, visible.name, !!req.name); setStar(labelEmail, visible.email, !!req.email); setStar(labelReference, visible.reference, !!req.reference); setStar(labelNotes, visible.notes, !!req.notes); // If literally no fields are visible, just treat as no form if (!visible.name && !visible.email && !visible.reference && !visible.notes) { portalFormDone = true; sessionStorage.setItem(key, '1'); if (formSection) formSection.style.display = 'none'; if (uploadSection) uploadSection.style.opacity = '1'; return; } // Prefill defaults only for visible fields if (nameEl && visible.name && fd.name && !nameEl.value) { nameEl.value = fd.name; } if (emailEl && visible.email) { if (fd.email && !emailEl.value) { emailEl.value = fd.email; } else if (portal.clientEmail && !emailEl.value) { emailEl.value = portal.clientEmail; } } if (refEl && visible.reference && fd.reference && !refEl.value) { refEl.value = fd.reference; } if (notesEl && visible.notes && fd.notes && !notesEl.value) { notesEl.value = fd.notes; } if (!submitBtn) return; submitBtn.onclick = async () => { const name = nameEl ? nameEl.value.trim() : ''; const email = emailEl ? emailEl.value.trim() : ''; const reference = refEl ? refEl.value.trim() : ''; const notes = notesEl ? notesEl.value.trim() : ''; const missing = []; // Only validate visible fields if (visible.name && req.name && !name) missing.push(labels.name || 'Name'); if (visible.email && req.email && !email) missing.push(labels.email || 'Email'); if (visible.reference && req.reference && !reference) missing.push(labels.reference || 'Reference'); if (visible.notes && req.notes && !notes) missing.push(labels.notes || 'Notes'); if (missing.length) { showToast('Please fill in: ' + missing.join(', ') + '.'); return; } // default behavior when no specific required flags: // at least name or email, but only if those fields are visible if (!req.name && !req.email && !req.reference && !req.notes) { const hasNameField = visible.name; const hasEmailField = visible.email; if ((hasNameField || hasEmailField) && !name && !email) { showToast('Please provide at least a name or email.'); return; } } try { await submitPortalForm(slug, { name, email, reference, notes }); portalFormDone = true; sessionStorage.setItem(key, '1'); if (formSection) formSection.style.display = 'none'; if (uploadSection) uploadSection.style.opacity = '1'; showToast('Thank you. You can now upload files.'); } catch (e) { console.error(e); showToast('Error saving your info. Please try again.'); } }; } // ----------------- CSRF helpers ----------------- function setCsrfToken(token) { if (!token) return; window.csrfToken = token; try { localStorage.setItem('csrf', token); } catch { // ignore } let meta = document.querySelector('meta[name="csrf-token"]'); if (!meta) { meta = document.createElement('meta'); meta.name = 'csrf-token'; document.head.appendChild(meta); } meta.content = token; } function getCsrfToken() { return window.csrfToken || (document.querySelector('meta[name="csrf-token"]')?.content) || ''; } async function loadCsrfToken() { const res = await fetch('/api/auth/token.php', { method: 'GET', credentials: 'include' }); const hdr = res.headers.get('X-CSRF-Token'); if (hdr) setCsrfToken(hdr); let body = {}; try { body = await res.json(); } catch { body = {}; } const token = body.csrf_token || getCsrfToken(); setCsrfToken(token); } // ----------------- Auth ----------------- async function ensureAuthenticated() { try { const data = await sendRequest('/api/auth/checkAuth.php', 'GET'); if (!data || !data.username) { // redirect to main UI/login; after login, user can re-open portal link const target = encodeURIComponent(window.location.href); window.location.href = '/portal-login.html?redirect=' + target; return null; } const lbl = qs('portalUserLabel'); if (lbl) { lbl.textContent = data.username || ''; } return data; } catch (e) { const target = encodeURIComponent(window.location.href); window.location.href = '/portal-login.html?redirect=' + target; return null; } } // ----------------- Portal fetch + render ----------------- async function fetchPortal(slug) { setStatus('Loading portal details…'); try { const data = await sendRequest('/api/pro/portals/get.php?slug=' + encodeURIComponent(slug), 'GET'); if (!data || !data.success || !data.portal) { throw new Error((data && data.error) || 'Portal not found.'); } portal = data.portal; return portal; } catch (e) { console.error(e); setStatus('This portal could not be found or is no longer available.', true); showToast('Portal not found or expired.'); return null; } } function renderPortalInfo() { if (!portal) return; const titleEl = qs('portalTitle'); const descEl = qs('portalDescription'); const subtitleEl = qs('portalSubtitle'); const brandEl = document.getElementById('portalBrandHeading'); const footerEl = document.getElementById('portalFooter'); const drop = qs('portalDropzone'); const card = document.querySelector('.portal-card'); const logoImg = document.querySelector('.portal-logo img'); const formBtn = qs('portalFormSubmit'); const refreshBtn = qs('portalRefreshBtn'); const filesSection = qs('portalFilesSection'); const heading = portal.title && portal.title.trim() ? portal.title.trim() : (portal.label || portal.slug || 'Client portal'); if (titleEl) titleEl.textContent = heading; if (brandEl) brandEl.textContent = heading; if (descEl) { if (portal.introText && portal.introText.trim()) { descEl.textContent = portal.introText.trim(); } else { const folder = portalFolder(); descEl.textContent = 'Files you upload here go directly into: ' + folder; } const bits = []; if (portal.uploadMaxSizeMb) { bits.push('Max file size: ' + portal.uploadMaxSizeMb + ' MB'); } const exts = getAllowedExts(); if (exts.length) { bits.push('Allowed types: ' + exts.join(', ')); } if (portal.uploadMaxPerDay) { bits.push('Daily upload limit: ' + portal.uploadMaxPerDay + ' file(s)'); } if (bits.length) { descEl.textContent += ' (' + bits.join(' • ') + ')'; } } if (logoImg) { if (portal.logoUrl && portal.logoUrl.trim()) { logoImg.src = portal.logoUrl.trim(); } else if (portal.logoFile && portal.logoFile.trim()) { // Fallback if backend only supplies logoFile logoImg.src = '/uploads/profile_pics/' + encodeURIComponent(portal.logoFile.trim()); } } const uploadsEnabled = portalCanUpload(); const downloadsEnabled = portalCanDownload(); if (subtitleEl) { let text = ''; if (uploadsEnabled && downloadsEnabled) { text = 'Upload & download'; } else if (uploadsEnabled && !downloadsEnabled) { text = 'Upload only'; } else if (!uploadsEnabled && downloadsEnabled) { text = 'Download only'; } else { text = 'Access only'; } subtitleEl.textContent = text; } if (footerEl) { footerEl.textContent = portal.footerText && portal.footerText.trim() ? portal.footerText.trim() : ''; } const formSection = qs('portalFormSection'); const uploadSection = qs('portalUploadSection'); // If uploads are disabled, hide upload + form (form is only meaningful for uploads) if (!uploadsEnabled) { if (formSection) { formSection.style.display = 'none'; } if (uploadSection) { uploadSection.style.display = 'none'; } const statusEl = qs('portalStatus'); if (statusEl) { statusEl.textContent = 'Uploads are disabled for this portal.'; statusEl.classList.remove('text-muted'); statusEl.classList.add('text-warning'); } } applyPortalFormLabels(); const color = portal.brandColor && portal.brandColor.trim(); if (color) { // expose brand color as a CSS variable for gallery styling document.documentElement.style.setProperty('--portal-accent', color); if (drop) { drop.style.borderColor = color; } if (card) { card.style.borderTop = '3px solid ' + color; } if (formBtn) { formBtn.style.backgroundColor = color; formBtn.style.borderColor = color; } if (refreshBtn) { refreshBtn.style.borderColor = color; refreshBtn.style.color = color; } } // Show/hide files section based on download capability if (filesSection) { filesSection.style.display = portalCanDownload() ? 'block' : 'none'; } } // ----------------- File helpers for gallery ----------------- function formatFileSizeLabel(f) { // API currently returns f.size as a human-readable string, so prefer that if (f && f.size) return f.size; return ''; } function fileExtLabel(name) { if (!name) return 'FILE'; const parts = name.split('.'); if (parts.length < 2) return 'FILE'; const ext = parts.pop().trim().toUpperCase(); if (!ext) return 'FILE'; return ext.length <= 4 ? ext : ext.slice(0, 4); } function isImageName(name) { if (!name) return false; return /\.(jpe?g|png|gif|bmp|webp|svg)$/i.test(name); } // ----------------- Load files for portal gallery ----------------- async function loadPortalFiles() { if (!portal || !portalCanDownload()) return; const listEl = qs('portalFilesList'); if (!listEl) return; listEl.innerHTML = '
Loading files…
'; try { const folder = portalFolder(); const data = await sendRequest('/api/file/getFileList.php?folder=' + encodeURIComponent(folder), 'GET'); if (!data || data.error) { const msg = (data && data.error) ? data.error : 'Error loading files.'; listEl.innerHTML = '
' + msg + '
'; return; } // Normalize files: handle both array and object-return shapes let files = []; if (Array.isArray(data.files)) { files = data.files; } else if (data.files && typeof data.files === 'object') { files = Object.entries(data.files).map(([name, meta]) => { const f = meta || {}; f.name = name; return f; }); } if (!files.length) { listEl.innerHTML = '
No files in this portal yet.
'; return; } const accent = portal.brandColor && portal.brandColor.trim(); listEl.innerHTML = ''; listEl.classList.add('portal-files-grid'); // gallery layout const MAX = 24; const slice = files.slice(0, MAX); slice.forEach(f => { const card = document.createElement('div'); card.className = 'portal-file-card'; const icon = document.createElement('div'); icon.className = 'portal-file-card-icon'; const main = document.createElement('div'); main.className = 'portal-file-card-main'; const nameEl = document.createElement('div'); nameEl.className = 'portal-file-card-name'; nameEl.textContent = f.name || 'Unnamed file'; const metaEl = document.createElement('div'); metaEl.className = 'portal-file-card-meta text-muted'; metaEl.textContent = formatFileSizeLabel(f); main.appendChild(nameEl); main.appendChild(metaEl); const actions = document.createElement('div'); actions.className = 'portal-file-card-actions'; // Thumbnail vs extension badge const fname = f.name || ''; const folder = portalFolder(); if (isImageName(fname)) { const thumbUrl = '/api/file/download.php?folder=' + encodeURIComponent(folder) + '&file=' + encodeURIComponent(fname) + '&inline=1&t=' + Date.now(); const img = document.createElement('img'); img.src = thumbUrl; img.alt = fname; // 🔧 constrain image so it doesn't fill the whole list img.style.maxWidth = '100%'; img.style.maxHeight = '120px'; img.style.objectFit = 'cover'; img.style.display = 'block'; img.style.borderRadius = '6px'; icon.appendChild(img); } else { icon.textContent = fileExtLabel(fname); } if (accent) { icon.style.borderColor = accent; } if (portalCanDownload()) { const a = document.createElement('a'); a.href = '/api/file/download.php?folder=' + encodeURIComponent(folder) + '&file=' + encodeURIComponent(fname); a.textContent = 'Download'; a.className = 'portal-file-card-download'; a.target = '_blank'; a.rel = 'noopener'; actions.appendChild(a); } card.appendChild(icon); card.appendChild(main); card.appendChild(actions); listEl.appendChild(card); }); if (files.length > MAX) { const more = document.createElement('div'); more.className = 'portal-files-more text-muted'; more.textContent = 'And ' + (files.length - MAX) + ' more…'; listEl.appendChild(more); } } catch (e) { console.error(e); listEl.innerHTML = '
Error loading files.
'; } } // ----------------- Upload ----------------- async function uploadFiles(fileList) { if (!portal || !fileList || !fileList.length) return; if (!portalCanUpload()) { showToast('Uploads are disabled for this portal.'); setStatus('Uploads are disabled for this portal.', true); return; } if (portal.requireForm && !portalFormDone) { showToast('Please fill in your details before uploading.'); return; } let files = Array.from(fileList); if (!files.length) return; // 1) Filter by max size const maxBytes = getMaxSizeBytes(); if (maxBytes > 0) { const tooBigNames = []; files = files.filter(f => { if (f.size && f.size > maxBytes) { tooBigNames.push(f.name || 'unnamed'); return false; } return true; }); if (tooBigNames.length) { showToast( 'Skipped ' + tooBigNames.length + ' file(s) over ' + portal.uploadMaxSizeMb + ' MB.' ); } } // 2) Filter by allowed extensions const allowedExts = getAllowedExts(); if (allowedExts.length) { const skipped = []; files = files.filter(f => { const name = f.name || ''; const parts = name.split('.'); const ext = parts.length > 1 ? parts.pop().trim().toLowerCase() : ''; if (!ext || !allowedExts.includes(ext)) { skipped.push(name || 'unnamed'); return false; } return true; }); if (skipped.length) { showToast( 'Skipped ' + skipped.length + ' file(s) not matching allowed types: ' + allowedExts.join(', ') ); } } if (!files.length) { setStatus('No files to upload after applying portal rules.', true); return; } // 3) Rate-limit per day (simple per-browser guard) const requestedCount = files.length; const allowedCount = applyUploadRateLimit(requestedCount); if (!allowedCount) { setStatus('Upload blocked by daily limit.', true); return; } if (allowedCount < requestedCount) { files = files.slice(0, allowedCount); } const folder = portalFolder(); setStatus('Uploading ' + files.length + ' file(s)…'); let successCount = 0; let failureCount = 0; for (const file of files) { const form = new FormData(); const csrf = getCsrfToken() || ''; // Match main upload.js form.append('file[]', file); form.append('folder', folder); if (csrf) { form.append('upload_token', csrf); // legacy alias, but your controller supports it } let retried = false; while (true) { try { const resp = await fetch('/api/upload/upload.php', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': csrf || '' }, body: form }); const text = await resp.text(); let data; try { data = JSON.parse(text); } catch { data = {}; } if (data && data.csrf_expired && data.csrf_token) { setCsrfToken(data.csrf_token); if (!retried) { retried = true; continue; } } if (!resp.ok || (data && data.error)) { failureCount++; console.error('Upload error:', data || text); } else { successCount++; } break; } catch (e) { console.error('Upload error:', e); failureCount++; break; } } } if (successCount && !failureCount) { setStatus('Uploaded ' + successCount + ' file(s).'); showToast('Upload complete.'); } else if (successCount && failureCount) { setStatus('Uploaded ' + successCount + ' file(s), ' + failureCount + ' failed.', true); showToast('Some files failed to upload.'); } else { setStatus('Upload failed.', true); showToast('Upload failed.'); } // Bump local daily counter by successful uploads if (successCount > 0) { bumpUploadRateCounter(successCount); } if (portalCanDownload()) { loadPortalFiles(); } // Optional thank-you screen if (successCount > 0 && portal.showThankYou) { showThankYouScreen(); } } // ----------------- Upload UI wiring ----------------- function wireUploadUI() { const drop = qs('portalDropzone'); const input = qs('portalFileInput'); const refreshBtn = qs('portalRefreshBtn'); const uploadsEnabled = portalCanUpload(); const downloadsEnabled = portalCanDownload(); // Upload UI if (drop) { if (!uploadsEnabled) { // Visually dim + disable clicks drop.classList.add('portal-dropzone-disabled'); drop.style.cursor = 'not-allowed'; } } if (uploadsEnabled && drop && input) { drop.addEventListener('click', () => input.click()); input.addEventListener('change', (e) => { const files = e.target.files; if (files && files.length) { uploadFiles(files); input.value = ''; } }); ['dragenter', 'dragover'].forEach(ev => { drop.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); drop.classList.add('dragover'); }); }); ['dragleave', 'drop'].forEach(ev => { drop.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); drop.classList.remove('dragover'); }); }); drop.addEventListener('drop', e => { const dt = e.dataTransfer; if (!dt || !dt.files || !dt.files.length) return; uploadFiles(dt.files); }); } // Download / refresh if (refreshBtn) { if (!downloadsEnabled) { refreshBtn.style.display = 'none'; } else { refreshBtn.addEventListener('click', () => { loadPortalFiles(); }); } } } // ----------------- Slug + init ----------------- function getPortalSlugFromUrl() { try { const url = new URL(window.location.href); // 1) Normal case: slug is directly in query (?slug=portal-xxxxx) let slug = url.searchParams.get('slug'); if (slug && slug.trim()) { return slug.trim(); } // 2) Pretty URL: /portal/ // e.g. /portal/portal-h46ozd const pathMatch = url.pathname.match(/\/portal\/([^\/?#]+)/i); if (pathMatch && pathMatch[1]) { return pathMatch[1].trim(); } // 3) Fallback: slug inside redirect param // e.g. ?redirect=/portal.html?slug=portal-h46ozd const redirect = url.searchParams.get('redirect'); if (redirect) { try { const redirectUrl = new URL(redirect, window.location.origin); const innerSlug = redirectUrl.searchParams.get('slug'); if (innerSlug && innerSlug.trim()) { return innerSlug.trim(); } } catch { // ignore parse errors } const m = redirect.match(/[?&]slug=([^&]+)/); if (m && m[1]) { return decodeURIComponent(m[1]).trim(); } } // 4) Final fallback: old regex on our own query string const qs = window.location.search || ''; const m2 = qs.match(/[?&]slug=([^&]+)/); return m2 && m2[1] ? decodeURIComponent(m2[1]).trim() : ''; } catch { const qs = window.location.search || ''; const m = qs.match(/[?&]slug=([^&]+)/); return m && m[1] ? decodeURIComponent(m[1]).trim() : ''; } } async function initPortal() { const slug = getPortalSlugFromUrl(); if (!slug) { setStatus('Missing portal slug.', true); showToast('Portal slug missing in URL.'); return; } try { await loadCsrfToken(); } catch (e) { console.warn('CSRF load failed (may be fine if unauthenticated yet).', e); } const auth = await ensureAuthenticated(); if (!auth) return; const p = await fetchPortal(slug); if (!p) return; renderPortalInfo(); setupPortalForm(slug); wireUploadUI(); if (portalCanDownload()) { loadPortalFiles(); } setStatus('Ready.'); } document.addEventListener('DOMContentLoaded', () => { initPortal().catch(err => { console.error(err); setStatus('Unexpected error initializing portal.', true); showToast('Unexpected error loading portal.'); }); });