1111 lines
32 KiB
JavaScript
1111 lines
32 KiB
JavaScript
// 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 = '<div class="text-muted" style="padding:4px 0;">Loading files…</div>';
|
||
|
||
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 = '<div class="text-danger" style="padding:4px 0;">' + msg + '</div>';
|
||
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 = '<div class="text-muted" style="padding:4px 0;">No files in this portal yet.</div>';
|
||
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 = '<div class="text-danger" style="padding:4px 0;">Error loading files.</div>';
|
||
}
|
||
}
|
||
|
||
// ----------------- 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/<slug>
|
||
// 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.');
|
||
});
|
||
}); |