3828 lines
196 KiB
JavaScript
3828 lines
196 KiB
JavaScript
// ───── STORAGE SAFETY + SCHEMA VERSION ─────
|
|
// Wrap localStorage.setItem so a full quota (or privacy-mode block) never throws
|
|
// and breaks the page — it degrades gracefully with a warning instead.
|
|
(function () {
|
|
if (!window.localStorage) return;
|
|
const _set = Storage.prototype.setItem;
|
|
Storage.prototype.setItem = function (k, v) {
|
|
try { return _set.call(this, k, v); }
|
|
catch (e) { console.warn('[campus] storage write failed for', k, e && e.name); }
|
|
};
|
|
})();
|
|
|
|
// Bump this when the shape of stored data changes incompatibly; old state is
|
|
// cleared once so users don't get stuck with a broken/mismatched cache.
|
|
const SCHEMA_VERSION = '1';
|
|
try {
|
|
const prev = localStorage.getItem('campus-schema-version');
|
|
if (prev && prev !== SCHEMA_VERSION) {
|
|
// future migrations would run here; for now we just record the new version
|
|
}
|
|
localStorage.setItem('campus-schema-version', SCHEMA_VERSION);
|
|
} catch {}
|
|
|
|
// Global image fallback: if an avatar (DiceBear) or content image (Picsum) fails
|
|
// to load — e.g. the external service is down — show a graceful placeholder
|
|
// instead of a broken-image icon.
|
|
function initialsAvatar(seed) {
|
|
const ch = (String(seed || '?').replace(/[^a-zA-Z0-9]/g, '')[0] || '?').toUpperCase();
|
|
const colors = ['#ff5a3c', '#f0453f', '#ff6b9d', '#e84393', '#c44569', '#d63031'];
|
|
const bg = colors[ch.charCodeAt(0) % colors.length];
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="${bg}"/><text x="50" y="50" dy=".35em" text-anchor="middle" font-family="Inter,sans-serif" font-size="48" font-weight="700" fill="#fff">${ch}</text></svg>`;
|
|
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
|
|
}
|
|
document.addEventListener('error', e => {
|
|
const img = e.target;
|
|
if (!img || img.tagName !== 'IMG' || img.dataset.failed) return;
|
|
img.dataset.failed = '1';
|
|
if (img.hasAttribute('data-av')) {
|
|
img.src = initialsAvatar(img.getAttribute('data-av') || img.getAttribute('alt'));
|
|
} else {
|
|
img.classList.add('img-failed');
|
|
}
|
|
}, true);
|
|
|
|
// ───── NAV TOGGLE (mobile) + cat spin ─────
|
|
const burger = document.getElementById('burger');
|
|
const burgerCat = document.getElementById('burgerCat');
|
|
// force a consistent cat size on every page, overriding any cached CSS
|
|
if (burgerCat) {
|
|
burgerCat.style.setProperty('width', '40px', 'important');
|
|
burgerCat.style.setProperty('height', '40px', 'important');
|
|
burgerCat.style.setProperty('object-fit', 'contain', 'important');
|
|
}
|
|
const scrim = document.getElementById('scrim');
|
|
// home uses the cat drawer, download uses #sidebar, else the top nav
|
|
const navTarget = document.getElementById('catDrawer')
|
|
|| document.getElementById('sidebar')
|
|
|| document.getElementById('topNav');
|
|
|
|
let spin = 0;
|
|
|
|
// inject the "Campus" brand text next to the cat icon
|
|
let topbarBrand = null;
|
|
if (burger) {
|
|
topbarBrand = document.createElement('span');
|
|
topbarBrand.className = 'topbar-brand';
|
|
topbarBrand.textContent = 'Campus';
|
|
burger.insertAdjacentElement('afterend', topbarBrand);
|
|
}
|
|
|
|
function setNav(open) {
|
|
if (navTarget) {
|
|
navTarget.classList.toggle('open', open);
|
|
// when closed, take the off-screen drawer out of the tab order entirely
|
|
navTarget.inert = !open;
|
|
}
|
|
if (scrim) scrim.classList.toggle('open', open);
|
|
if (topbarBrand) topbarBrand.classList.toggle('show', open);
|
|
document.body.style.overflow = open ? 'hidden' : '';
|
|
if (open && burgerCat) {
|
|
spin += 360;
|
|
burgerCat.style.transform = `rotate(${spin}deg)`;
|
|
}
|
|
// move focus into the drawer when opening, back to the burger when closing
|
|
if (open && navTarget) navTarget.querySelector('a, button')?.focus();
|
|
else if (!open && burger && document.activeElement && navTarget?.contains(document.activeElement)) burger.focus();
|
|
}
|
|
|
|
if (burger && navTarget) {
|
|
navTarget.inert = !navTarget.classList.contains('open'); // closed by default
|
|
burger.addEventListener('click', () => setNav(!navTarget.classList.contains('open')));
|
|
if (scrim) scrim.addEventListener('click', () => setNav(false));
|
|
navTarget.querySelectorAll('a').forEach(a => a.addEventListener('click', () => setNav(false)));
|
|
}
|
|
|
|
// skip-to-content link: move real focus to the target so keyboard/screen-reader
|
|
// users land in the main content, not just scroll there
|
|
document.querySelector('.skip-link')?.addEventListener('click', e => {
|
|
// preventDefault matters here: with <base href="/"> on clean-URL pages, the browser would
|
|
// otherwise resolve "#main" to "/#main" and navigate to the home page instead of scrolling.
|
|
e.preventDefault();
|
|
const target = document.querySelector(e.currentTarget.getAttribute('href'));
|
|
if (target) { target.setAttribute('tabindex', '-1'); target.focus(); target.scrollIntoView(); }
|
|
});
|
|
|
|
// Esc closes the drawer and any open dropdown menus
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key !== 'Escape') return;
|
|
if (navTarget && navTarget.classList.contains('open')) setNav(false);
|
|
const notif = document.querySelector('.notif.open');
|
|
if (notif) {
|
|
notif.classList.remove('open');
|
|
const nb = notif.querySelector('#notifBtn');
|
|
if (nb) { nb.setAttribute('aria-expanded', 'false'); nb.focus(); }
|
|
}
|
|
const prof = document.querySelector('.profile.open');
|
|
if (prof) {
|
|
prof.classList.remove('open');
|
|
const av = prof.querySelector('.profile-av');
|
|
if (av) { av.setAttribute('aria-expanded', 'false'); av.focus(); }
|
|
}
|
|
});
|
|
|
|
// arrow-key roving focus within an open dropdown menu
|
|
function enableMenuArrowKeys(menu, itemSelector, returnFocusEl) {
|
|
if (!menu) return;
|
|
menu.addEventListener('keydown', e => {
|
|
const items = [...menu.querySelectorAll(itemSelector)];
|
|
if (!items.length) return;
|
|
const i = items.indexOf(document.activeElement);
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1 + items.length) % items.length].focus(); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
|
|
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
|
|
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
|
|
else if (e.key === 'Escape' && returnFocusEl) { returnFocusEl.focus(); }
|
|
});
|
|
}
|
|
|
|
// ───── DEMO AUTH ─────
|
|
const DEMO_USER = 'catlover';
|
|
const DEMO_PASS = 'meow123';
|
|
const USER_KEY = 'campus-user';
|
|
const BLANK_PX = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
|
|
|
// apply a custom (cropped-photo) image onto an <img>
|
|
function applyCropToImg(img, d) {
|
|
if (!img || !d) return;
|
|
img.removeAttribute('srcset');
|
|
img.src = d.image;
|
|
img.style.objectFit = 'cover';
|
|
img.style.objectPosition = (d.pos && d.pos !== 'center') ? d.pos : 'center';
|
|
}
|
|
|
|
// ───── BACKEND ─────
|
|
// True when the Campus C# server is answering. apiBase is '' (same-origin), which is
|
|
// falsy, so it can't double as the "is the API up?" flag - that's what apiEnabled is for.
|
|
function apiOn() {
|
|
return !!(window.CAMPUS_DATA && window.CAMPUS_DATA.apiEnabled);
|
|
}
|
|
function apiUrl(path) {
|
|
return ((window.CAMPUS_DATA && window.CAMPUS_DATA.apiBase) || '') + path;
|
|
}
|
|
// The session lives in an HttpOnly cookie the server sets, so it is never readable from
|
|
// here - every call just needs credentials to ride along.
|
|
async function apiPost(path, body) {
|
|
const resp = await fetch(apiUrl(path), {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body || {}),
|
|
});
|
|
const out = await resp.json().catch(() => ({}));
|
|
return { ok: resp.ok && out.ok !== false, status: resp.status, data: out };
|
|
}
|
|
|
|
// DELETE an API path. Same envelope as apiPost.
|
|
async function apiDelete(path) {
|
|
const resp = await fetch(apiUrl(path), { method: 'DELETE', credentials: 'same-origin' });
|
|
const out = await resp.json().catch(() => ({}));
|
|
return { ok: resp.ok && out.ok !== false, status: resp.status, data: out };
|
|
}
|
|
|
|
// A photo's numeric server id from the element it was rendered in (data-photo), or null for
|
|
// demo content - which stays local-only.
|
|
function photoIdOf(el) {
|
|
const host = el && el.closest('[data-photo]');
|
|
const id = host && host.dataset.photo;
|
|
return id && /^\d+$/.test(id) ? id : null;
|
|
}
|
|
|
|
// GET an API path as JSON. Returns null on any failure so callers can fall back.
|
|
async function apiGet(path) {
|
|
try {
|
|
const resp = await fetch(apiUrl(path), { credentials: 'same-origin' });
|
|
if (!resp.ok) return null;
|
|
return await resp.json();
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ───── CLEAN URLS (/user/{h}, /room/{id}, /image/{id}) ─────
|
|
// Pages are served at clean paths by WebController; the page JS reads its id/handle from
|
|
// the path here. Query params (?u=, ?id=) still work as a fallback for old links.
|
|
function userUrl(h) { return '/user/' + encodeURIComponent(String(h || '').replace(/^@/, '')); }
|
|
|
|
// Settings header: your own live status (Online · room / Offline) from the same profile API the
|
|
// profile page uses; re-run on every "profile" push about you.
|
|
async function renderSettingsStatus() {
|
|
const el = document.getElementById('suStatus');
|
|
const me = localStorage.getItem(USER_KEY);
|
|
if (!el || !me || !apiOn()) return;
|
|
const r = await apiGet('/api/site/profile/' + encodeURIComponent(me));
|
|
const st = r && r.status;
|
|
if (r && r.user && r.user.id) siteWatch(me, r.user.id);
|
|
const dot = el.querySelector('.su-dot');
|
|
if (dot) dot.style.background = st && st.online ? '#2ecc71' : '#9a9aa6';
|
|
el.lastChild.textContent = ' ' + (st ? (st.online ? ('Online' + (st.where ? ' · ' + st.where : '')) : 'Offline') : '');
|
|
}
|
|
|
|
// ---- realtime: which profile this tab is looking at ------------------------------------------
|
|
// The site socket (ensureNotifSocket) tells the server, and the server pushes "profile" (status,
|
|
// room, name, counts changed - re-fetch) and "spotify" (a new track / pause / seek, already
|
|
// filtered for this viewer) frames for it. SITE_WATCH is the handle; SITE_WATCH_ID the account id
|
|
// once a fetch has told us (the frames carry the id).
|
|
let SITE_WATCH = null, SITE_WATCH_ID = null;
|
|
function siteWatch(handle, id) {
|
|
if (handle) SITE_WATCH = String(handle).replace(/^@/, '');
|
|
if (id) SITE_WATCH_ID = String(id);
|
|
if (!SITE_WATCH) return;
|
|
try { if (NOTIF_WS && NOTIF_WS.readyState === 1) NOTIF_WS.send(JSON.stringify({ type: 'watch', profile: SITE_WATCH })); } catch {}
|
|
}
|
|
|
|
// ---- socials --------------------------------------------------------------------------
|
|
// Icon per platform (simple monochrome glyphs, currentColor). Keys match SiteSocials.
|
|
const SOCIAL_ICONS = {
|
|
discord: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.25.5a14.5 14.5 0 0 1 4.3 1.4 16.6 16.6 0 0 0-14.9 0A14.5 14.5 0 0 1 8.85 3.5L8.6 3a19.8 19.8 0 0 0-4.9 1.4C.6 9 .07 13.5.34 17.9a19.9 19.9 0 0 0 6 3l.5-.7c-.66-.25-1.28-.55-1.86-.9l.46-.36a14.2 14.2 0 0 0 12.12 0l.46.36c-.58.35-1.2.65-1.86.9l.5.7a19.9 19.9 0 0 0 6-3c.33-5.13-.53-9.6-3.86-13.5ZM8.3 15.3c-1.17 0-2.13-1.08-2.13-2.4 0-1.32.94-2.4 2.13-2.4s2.15 1.08 2.13 2.4c0 1.32-.95 2.4-2.13 2.4Zm7.4 0c-1.17 0-2.13-1.08-2.13-2.4 0-1.32.94-2.4 2.13-2.4s2.15 1.08 2.13 2.4c0 1.32-.94 2.4-2.13 2.4Z"/></svg>',
|
|
x: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.5 3h3.1l-6.8 7.8L21.8 21h-6.3l-4.9-6.4L5 21H1.9l7.3-8.3L1.5 3h6.4l4.4 5.9L17.5 3Zm-1.1 16.2h1.7L7.1 4.7H5.3l11.1 14.5Z"/></svg>',
|
|
youtube: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23 7.2a2.9 2.9 0 0 0-2-2C19.2 4.7 12 4.7 12 4.7s-7.2 0-9 .5a2.9 2.9 0 0 0-2 2C.5 9 .5 12 .5 12s0 3 .5 4.8a2.9 2.9 0 0 0 2 2c1.8.5 9 .5 9 .5s7.2 0 9-.5a2.9 2.9 0 0 0 2-2c.5-1.8.5-4.8.5-4.8s0-3-.5-4.8ZM9.7 15.1V8.9l6 3.1-6 3.1Z"/></svg>',
|
|
tiktok: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.6 5.8A4.3 4.3 0 0 1 15.5 3h-3.1v12.4a2.6 2.6 0 1 1-2.6-2.6c.3 0 .5 0 .8.1V9.7a5.7 5.7 0 1 0 4.9 5.7V9.2a7.4 7.4 0 0 0 4.3 1.4V7.5a4.3 4.3 0 0 1-3.2-1.7Z"/></svg>',
|
|
instagram: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>',
|
|
twitch: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.3 2 2.5 6.5V20h5v3h2.8l2.8-3h4.2l5.2-5.2V2H4.3Zm16.4 11.9-3.2 3.2h-5.2l-2.8 2.8v-2.8H5.3V4h15.4v9.9ZM16.9 7.2h-2v5.6h2V7.2Zm-5.2 0h-2v5.6h2V7.2Z"/></svg>',
|
|
steam: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 0 0-10 9.3l5.4 2.2a2.8 2.8 0 0 1 1.6-.5h.2l2.4-3.5v-.1a3.8 3.8 0 1 1 3.8 3.8h-.1l-3.4 2.4v.2a2.8 2.8 0 0 1-5.6.3L2.4 14.5A10 10 0 1 0 12 2Zm-3.7 15.2-1.2-.5a2.1 2.1 0 0 0 3.9-.9 2.1 2.1 0 0 0-2-2.2l1.3.5a1.6 1.6 0 1 1-2 3.1Zm7.1-5.5a2.5 2.5 0 1 1 0-5.1 2.5 2.5 0 0 1 0 5.1Zm0-4.4a1.9 1.9 0 1 0 0 3.8 1.9 1.9 0 0 0 0-3.8Z"/></svg>',
|
|
github: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A11.5 11.5 0 0 0 .5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6A11.5 11.5 0 0 0 23.5 12 11.5 11.5 0 0 0 12 .5Z"/></svg>',
|
|
reddit: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 12.1a2.2 2.2 0 0 0-3.7-1.6 10.8 10.8 0 0 0-5.8-1.8l1-4.6 3.2.7a1.5 1.5 0 1 0 .2-1l-3.6-.8a.5.5 0 0 0-.6.4L11.6 8.7a10.8 10.8 0 0 0-5.9 1.8 2.2 2.2 0 1 0-2.4 3.6 4.3 4.3 0 0 0 0 .7c0 3.4 3.9 6.1 8.7 6.1s8.7-2.7 8.7-6.1a4.3 4.3 0 0 0 0-.7 2.2 2.2 0 0 0 1.3-2ZM7 13.6a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm8.6 4.1a5.7 5.7 0 0 1-3.6 1.1 5.7 5.7 0 0 1-3.6-1.1.4.4 0 0 1 .6-.6 4.9 4.9 0 0 0 3 .9 4.9 4.9 0 0 0 3-.9.4.4 0 0 1 .6.6Zm-.3-2.6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"/></svg>',
|
|
spotify: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1.5a10.5 10.5 0 1 0 0 21 10.5 10.5 0 0 0 0-21Zm4.8 15.2a.65.65 0 0 1-.9.2c-2.5-1.5-5.6-1.9-9.3-1a.65.65 0 1 1-.3-1.3c4-.9 7.5-.5 10.3 1.2.3.2.4.6.2.9Zm1.3-2.9a.8.8 0 0 1-1.1.3c-2.8-1.7-7.2-2.2-10.5-1.2a.8.8 0 1 1-.5-1.6c3.8-1.1 8.6-.6 11.9 1.4.4.2.5.7.2 1.1Zm.1-3a1 1 0 0 1-1.3.3C13.5 9.2 8 9 4.8 10a1 1 0 0 1-.6-1.9c3.7-1.1 9.8-.9 13.7 1.4a1 1 0 0 1 .3 1.3Z"/></svg>'
|
|
};
|
|
function renderSocials(el, rows) {
|
|
if (!el) return;
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
if (!list.length) { el.hidden = true; el.innerHTML = ''; return; }
|
|
el.innerHTML = list.map(r => {
|
|
const ico = SOCIAL_ICONS[r.platform] || '';
|
|
const inner = ico + '<span class="sc-handle">' + escapeHtml(r.handle || '') + '</span>';
|
|
return r.url
|
|
? '<a class="social-chip" href="' + escapeHtml(r.url) + '" target="_blank" rel="noopener" title="' + escapeHtml(r.label || '') + '">' + inner + '</a>'
|
|
: '<span class="social-chip static" title="' + escapeHtml(r.label || '') + ' (click to copy)" data-copy="' + escapeHtml(r.handle || '') + '">' + inner + '</span>';
|
|
}).join('');
|
|
el.hidden = false;
|
|
el.querySelectorAll('.social-chip.static').forEach(ch => ch.addEventListener('click', async () => {
|
|
try { await navigator.clipboard.writeText(ch.dataset.copy || ''); toast('Copied ' + (ch.dataset.copy || '')); } catch {}
|
|
}));
|
|
}
|
|
|
|
// ---- Spotify "listening to" ----------------------------------------------------------------
|
|
// np = {is_playing, track, artists, album, album_art, url, progress_ms, duration_ms, as_of} from
|
|
// the server (null = nothing to show). The bar keeps moving between polls: progress is advanced
|
|
// locally while the track is playing.
|
|
const SP_ICON = '<svg class="sp-ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1.5C6.2 1.5 1.5 6.2 1.5 12S6.2 22.5 12 22.5 22.5 17.8 22.5 12 17.8 1.5 12 1.5zm4.8 15.2a.65.65 0 0 1-.9.2c-2.5-1.5-5.6-1.9-9.3-1a.65.65 0 1 1-.3-1.3c4-.9 7.5-.5 10.3 1.2.3.2.4.6.2.9zm1.3-2.9a.8.8 0 0 1-1.1.3c-2.8-1.7-7.1-2.2-10.5-1.2a.8.8 0 1 1-.5-1.6c3.8-1.2 8.5-.6 11.8 1.4.4.2.5.7.3 1.1zm.1-3a1 1 0 0 1-1.3.3C13.6 9.2 8.3 9 5.1 10a1 1 0 1 1-.6-1.9c3.7-1.1 9.6-.9 13.4 1.4.5.3.6.9.3 1.3z"/></svg>';
|
|
function spFmt(ms) { ms = Math.max(0, ms | 0); const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
|
|
function renderSpotify(el, np) {
|
|
if (!el) return;
|
|
if (!np || !np.track) { el.hidden = true; el.innerHTML = ''; el._np = null; return; }
|
|
const esc = (typeof escapeHtml === 'function') ? escapeHtml : (x => String(x));
|
|
el.hidden = false;
|
|
el.classList.toggle('paused', !np.is_playing);
|
|
if (el.tagName === 'A') { if (np.url) el.href = np.url; else el.removeAttribute('href'); }
|
|
el.innerHTML = (np.album_art ? '<img class="sp-art" src="' + esc(np.album_art) + '" alt="">' : '')
|
|
+ '<div class="sp-meta">'
|
|
+ '<div class="sp-line">' + SP_ICON + '<span class="sp-track">' + (np.is_playing ? 'Listening to ' : 'Paused · ') + '<b>' + esc(np.track) + '</b></span>'
|
|
+ '<span class="sp-by">— ' + esc(np.artists || np.album || '') + '</span></div>'
|
|
+ '<div class="sp-bar"><i></i></div>'
|
|
+ '<div class="sp-time"><span class="sp-cur"></span><span class="sp-dur">' + spFmt(np.duration_ms) + '</span></div>'
|
|
+ '</div>';
|
|
el._np = Object.assign({}, np, { at: Date.now() });
|
|
spTick(el);
|
|
}
|
|
function spTick(el) {
|
|
const np = el && el._np; if (!np) return;
|
|
let p = np.progress_ms + (np.is_playing ? (Date.now() - np.at) : 0);
|
|
if (np.duration_ms > 0) p = Math.min(p, np.duration_ms);
|
|
const bar = el.querySelector('.sp-bar i'), cur = el.querySelector('.sp-cur');
|
|
if (bar) bar.style.width = (np.duration_ms > 0 ? (100 * p / np.duration_ms) : 0).toFixed(2) + '%';
|
|
if (cur) cur.textContent = spFmt(p);
|
|
}
|
|
setInterval(() => { document.querySelectorAll('.spotify-now').forEach(el => { if (!el.hidden) spTick(el); }); }, 1000);
|
|
function roomUrl(id) { return '/room/' + encodeURIComponent(id); }
|
|
function imageUrl(id) { return '/image/' + encodeURIComponent(id); }
|
|
|
|
// The slug for the current clean path of a given kind, or null.
|
|
function routeSlug(kind) {
|
|
const m = location.pathname.match(new RegExp('^/' + kind + '/([^/?#]+)'));
|
|
return m ? decodeURIComponent(m[1]) : null;
|
|
}
|
|
// Which viewed user/room/image this page is about (clean path first, then ?u=/?id=).
|
|
function viewedHandle() { return routeSlug('user') || new URLSearchParams(location.search).get('u'); }
|
|
function viewedRoomId() { return routeSlug('room') || new URLSearchParams(location.search).get('id'); }
|
|
function viewedImageId() { return routeSlug('image') || new URLSearchParams(location.search).get('id'); }
|
|
|
|
// Turn an old .html href into its clean equivalent.
|
|
function cleanHref(href) {
|
|
if (!href || !/\.html/i.test(href)) return href;
|
|
try {
|
|
const u = new URL(href, location.origin);
|
|
if (u.origin !== location.origin) return href;
|
|
const page = u.pathname.replace(/^.*\//, ''); // filename only
|
|
const qs = u.searchParams;
|
|
if (page === 'profile.html') { const h = qs.get('u'); return h ? userUrl(h) : '/profile'; }
|
|
if (page === 'room.html') { const id = qs.get('id'); return id ? roomUrl(id) : '/'; }
|
|
if (page === 'image.html') { const id = qs.get('id'); return id ? imageUrl(id) : '/'; }
|
|
const map = {
|
|
'index.html': '/', 'login.html': '/login', 'settings.html': '/settings', 'stats.html': '/stats',
|
|
'download.html': '/download', 'accessibility.html': '/accessibility',
|
|
'news.html': '/news', 'patch-notes.html': '/patch-notes', 'weekly.html': '/weekly',
|
|
'support.html': '/support',
|
|
};
|
|
if (map[page]) return map[page] + (page.startsWith('news') || page.startsWith('patch') || page.startsWith('weekly') ? u.search : '');
|
|
return href;
|
|
} catch { return href; }
|
|
}
|
|
// Rewrite every .html anchor under root (or the whole document) to a clean URL.
|
|
function rewriteLinks(root) {
|
|
(root || document).querySelectorAll('a[href]').forEach(a => {
|
|
const h = a.getAttribute('href');
|
|
const c = cleanHref(h);
|
|
if (c && c !== h) a.setAttribute('href', c);
|
|
});
|
|
}
|
|
// Static nav is present now (app.js runs after the DOM).
|
|
rewriteLinks();
|
|
// Catch dynamically-rendered links too (feed, profile, search, rails) without editing every
|
|
// generator: rewrite any .html anchor as it's added to the DOM.
|
|
try {
|
|
new MutationObserver(muts => {
|
|
for (const m of muts) {
|
|
m.addedNodes.forEach(n => {
|
|
if (n.nodeType !== 1) return;
|
|
if (n.tagName === 'A') { const h = n.getAttribute('href'); const c = cleanHref(h); if (c && c !== h) n.setAttribute('href', c); }
|
|
if (n.querySelectorAll) rewriteLinks(n);
|
|
});
|
|
}
|
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
} catch {}
|
|
|
|
// The signed-in account, as last reported by the server. localStorage still holds the
|
|
// username so the header can render before /api/site/me answers, but the SERVER is the
|
|
// authority: syncSession() clears the local copy if the session is gone or expired.
|
|
let CAMPUS_ME = null;
|
|
|
|
// All the localStorage keys that describe WHO is signed in. They're stored under fixed
|
|
// (not per-user) keys, so switching accounts must overwrite them or the header/settings/tab
|
|
// keep showing the previous user. setIdentityCache mirrors the server's account; clearIdentity
|
|
// wipes them on logout/401.
|
|
function setIdentityCache(u) {
|
|
if (!u || !u.username) return;
|
|
try {
|
|
localStorage.setItem(USER_KEY, u.username);
|
|
localStorage.setItem('campus-username', u.username);
|
|
localStorage.setItem('campus-mynick', u.display_name || u.username);
|
|
localStorage.setItem('campus-mybio', u.bio || '');
|
|
} catch {}
|
|
}
|
|
function clearIdentityCache() {
|
|
try {
|
|
['campus-user', 'campus-username', 'campus-mynick', 'campus-mybio'].forEach(k => localStorage.removeItem(k));
|
|
} catch {}
|
|
}
|
|
// Repaint the already-rendered header chip + settings header from the current cache, so a
|
|
// server correction that lands AFTER the first paint (renderAuth runs before syncSession
|
|
// resolves) still shows the right name/@handle without needing a manual reload.
|
|
function refreshIdentityUI() {
|
|
const me = localStorage.getItem(USER_KEY);
|
|
if (!me) return;
|
|
const name = myDisplayName(me), handle = '@' + myHandle(me);
|
|
document.querySelectorAll('.pm-name').forEach(el => el.textContent = name);
|
|
document.querySelectorAll('.pm-handle').forEach(el => el.textContent = handle);
|
|
const suName = document.getElementById('suName'); if (suName) suName.textContent = name;
|
|
const suHandle = document.getElementById('suHandle'); if (suHandle) suHandle.textContent = handle;
|
|
const u = document.getElementById('setUsername'); if (u && !u.value) u.placeholder = myHandle(me);
|
|
const n = document.getElementById('setNickname'); if (n && !n.value) n.placeholder = myDisplayName(me);
|
|
}
|
|
|
|
async function syncSession() {
|
|
if (!apiOn()) return null;
|
|
try {
|
|
const resp = await fetch(apiUrl('/api/site/me'), { credentials: 'same-origin' });
|
|
if (!resp.ok) {
|
|
// 401 = signed out or the 12h token expired. Drop the stale local identity so the
|
|
// UI doesn't keep showing a logged-in header for a session the server has dropped.
|
|
if (resp.status === 401) { CAMPUS_ME = null; clearIdentityCache(); }
|
|
return null;
|
|
}
|
|
const out = await resp.json();
|
|
CAMPUS_ME = out.user || null;
|
|
// The SERVER session is the authority. Overwrite the cached identity keys every load so
|
|
// switching accounts - or creating a new one without logging out first - can never leave
|
|
// the previous user's name/@handle/bio showing in the header, settings or tab.
|
|
if (CAMPUS_ME && CAMPUS_ME.username) setIdentityCache(CAMPUS_ME);
|
|
return CAMPUS_ME;
|
|
} catch {
|
|
return null; // server unreachable: leave whatever the page already rendered
|
|
}
|
|
}
|
|
|
|
// handle the login form
|
|
const loginForm = document.getElementById('loginForm');
|
|
if (loginForm) {
|
|
loginForm.addEventListener('submit', async e => {
|
|
e.preventDefault();
|
|
const u = (document.getElementById('loginUser').value || '').trim();
|
|
const p = document.getElementById('loginPass').value || '';
|
|
const err = document.getElementById('loginError');
|
|
const btn = loginForm.querySelector('.login-submit');
|
|
|
|
const fail = msg => {
|
|
err.textContent = msg;
|
|
err.classList.add('show');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Log in'; }
|
|
};
|
|
|
|
if (!u || !p) return fail('Enter your username and password.');
|
|
|
|
// Offline demo mode (apiEnabled: false) keeps the old hardcoded account.
|
|
if (!apiOn()) {
|
|
if (u.toLowerCase() === DEMO_USER && p === DEMO_PASS) {
|
|
localStorage.setItem(USER_KEY, 'catlover');
|
|
window.location.href = '/';
|
|
} else {
|
|
fail('Incorrect username or password - try the demo login below.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Logging in…'; }
|
|
err.classList.remove('show');
|
|
|
|
try {
|
|
const { ok, data } = await apiPost('/api/site/login', { username: u, password: p });
|
|
if (!ok) return fail(data.error || 'Incorrect username or password.');
|
|
if (data.user && data.user.username) { clearIdentityCache(); setIdentityCache(data.user); }
|
|
window.location.href = '/';
|
|
} catch {
|
|
fail('Could not reach the server. Try again in a moment.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// handle the create-account (signup) form
|
|
const signupForm = document.getElementById('signupForm');
|
|
if (signupForm) {
|
|
signupForm.addEventListener('submit', async e => {
|
|
e.preventDefault();
|
|
const val = id => (document.getElementById(id)?.value || '');
|
|
const username = val('suUser').trim();
|
|
const birthday = val('suDob').trim(); // YYYY-MM-DD from <input type=date>
|
|
const pass = val('suPass');
|
|
const confirm = val('suConfirm');
|
|
const email = val('suEmail').trim();
|
|
const website = val('suWebsite'); // honeypot - stays empty for humans
|
|
const err = document.getElementById('signupError');
|
|
const btn = signupForm.querySelector('.login-submit');
|
|
|
|
const fail = msg => {
|
|
err.textContent = msg;
|
|
err.classList.add('show');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Create account'; }
|
|
};
|
|
|
|
// Client-side mirrors of the server rules, for instant feedback. The server re-checks all.
|
|
// NOTE: under-13 accounts get a random @name assigned by the server and are set to junior,
|
|
// so the username rules below are only a courtesy for 13+ (the server re-validates anyway).
|
|
if (username.length < 3 || username.length > 20) return fail('Username must be 3-20 characters.');
|
|
if (!/^[A-Za-z0-9_]+$/.test(username)) return fail('Username can only use letters, numbers and underscores.');
|
|
if (!birthday) return fail('Enter your date of birth.');
|
|
if (pass.length < 6) return fail('Password must be at least 6 characters.');
|
|
if (pass !== confirm) return fail("The passwords don't match.");
|
|
|
|
if (!apiOn()) return fail('Sign-up needs the live server.');
|
|
|
|
if (btn) { btn.disabled = true; btn.textContent = 'Creating…'; }
|
|
err.classList.remove('show');
|
|
|
|
try {
|
|
const { ok, data } = await apiPost('/api/site/register', {
|
|
username, password: pass,
|
|
confirm_password: confirm, email, birthday, website
|
|
});
|
|
if (!ok) return fail(data.error || 'Could not create account.');
|
|
if (data.user && data.user.username) { clearIdentityCache(); setIdentityCache(data.user); }
|
|
window.location.href = '/';
|
|
} catch {
|
|
fail('Could not reach the server. Try again in a moment.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// display name / handle for the logged-in user (username + nickname can be customised)
|
|
function myDisplayName(login) {
|
|
return localStorage.getItem('campus-mynick') || localStorage.getItem('campus-username') || cap(login || '');
|
|
}
|
|
function myHandle(login) {
|
|
return localStorage.getItem('campus-username') || login || '';
|
|
}
|
|
|
|
// reflect logged-in state in the UI (runs on every page)
|
|
function renderAuth() {
|
|
const user = localStorage.getItem(USER_KEY);
|
|
if (!user) return;
|
|
|
|
// swap "Log in" for the profile picture + dropdown menu
|
|
const loginBtn = document.querySelector('.btn-login');
|
|
if (loginBtn) {
|
|
const av = avatarUrl(user);
|
|
const chip = document.createElement('div');
|
|
chip.className = 'profile';
|
|
chip.innerHTML =
|
|
`<img class="profile-av" data-av="${avKey(user)}" src="${av}" alt="${user}" tabindex="0" role="button" aria-haspopup="true" aria-expanded="false" aria-label="Account menu">
|
|
<div class="profile-menu" role="menu">
|
|
<div class="pm-head">
|
|
<img class="pm-av" data-av="${avKey(user)}" src="${av}" alt="">
|
|
<div class="pm-info"><span class="pm-name">${myDisplayName(user)}</span><span class="pm-handle">@${myHandle(user)}</span></div>
|
|
</div>
|
|
<div class="pm-tokens">
|
|
<span class="pm-token-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 7.5v11"/><path d="M10 11c0-2.2 2.2-3.5 4.6-2.9"/><path d="M7 13.6h6.5"/><path d="M7 16.2h6.5"/></svg></span>
|
|
<span class="pm-token-val">1,250</span>
|
|
</div>
|
|
<a href="/profile" class="pm-item"><svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="7" r="3" stroke="currentColor" stroke-width="1.6"/><path d="M4 17c0-3 2.7-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>Profile</a>
|
|
<a href="#" class="pm-item"><svg viewBox="0 0 20 20" fill="none"><path d="M4 6h12l-1 9a2 2 0 0 1-2 1.7H7A2 2 0 0 1 5 15L4 6Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M7 6a3 3 0 0 1 6 0" stroke="currentColor" stroke-width="1.6"/></svg>Shop</a>
|
|
<a href="/stats" class="pm-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V10"/><path d="M10 19V4"/><path d="M16 19v-7"/><path d="M22 19H2"/></svg>Stats</a>
|
|
<a href="/settings" class="pm-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</a>
|
|
<button class="pm-item pm-logout"><svg viewBox="0 0 20 20" fill="none"><path d="M8 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3M13 14l4-4-4-4M17 10H8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>Log out</button>
|
|
</div>`;
|
|
loginBtn.replaceWith(chip);
|
|
|
|
const avBtn = chip.querySelector('.profile-av');
|
|
function toggleProfile(open) {
|
|
document.querySelector('.notif')?.classList.remove('open');
|
|
const isOpen = open ?? !chip.classList.contains('open');
|
|
chip.classList.toggle('open', isOpen);
|
|
avBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
if (isOpen) chip.querySelector('.pm-item')?.focus();
|
|
}
|
|
avBtn.addEventListener('click', e => { e.stopPropagation(); toggleProfile(); });
|
|
avBtn.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleProfile(); }
|
|
else if (e.key === 'ArrowDown') { e.preventDefault(); toggleProfile(true); }
|
|
});
|
|
enableMenuArrowKeys(chip.querySelector('.profile-menu'), '.pm-item', avBtn);
|
|
chip.querySelector('.pm-logout').addEventListener('click', async () => {
|
|
// Tell the server first so it clears the session cookie; the local wipe and the
|
|
// reload happen either way, so a failed request still logs you out of this browser.
|
|
if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} }
|
|
clearIdentityCache();
|
|
window.location.reload();
|
|
});
|
|
document.addEventListener('click', e => {
|
|
if (!chip.contains(e.target)) toggleProfile(false);
|
|
});
|
|
}
|
|
|
|
// hide the top-bar Download button while logged in
|
|
const dlBtn = document.querySelector('.top-right .btn-dl');
|
|
if (dlBtn) dlBtn.style.display = 'none';
|
|
}
|
|
|
|
// Render immediately from the cached username so the header doesn't flash, then confirm
|
|
// with the server. If the session turned out to be dead, syncSession cleared USER_KEY and
|
|
// this second pass leaves the "Log in" button in place.
|
|
renderAuth();
|
|
const SESSION_READY = syncSession();
|
|
SESSION_READY.then(me => {
|
|
const alreadyRendered = !!document.querySelector('.profile');
|
|
if (me && !alreadyRendered) renderAuth();
|
|
// syncSession may have corrected the cached identity (account switch / new account); repaint
|
|
// the header + settings text so it doesn't keep showing the previous user.
|
|
if (me) refreshIdentityUI();
|
|
// Now the server has answered, swap the REAL profile picture in wherever the logged-in
|
|
// user's own avatar shows: the top-right nav chip and the settings header. /api/site/me
|
|
// hands back a raw image name, so turn it into a /img/ URL first.
|
|
applyMyAvatar();
|
|
});
|
|
|
|
// The logged-in user's real profile-picture URL (from CAMPUS_ME), or null.
|
|
function myPfpUrl() {
|
|
const raw = CAMPUS_ME && CAMPUS_ME.profile_image;
|
|
if (!raw) return null;
|
|
return /^https?:|^\//.test(raw) ? raw : (((window.CAMPUS_DATA && window.CAMPUS_DATA.imgBase) || '/img/') + raw);
|
|
}
|
|
// Point every "me" avatar (nav chip + settings) at the real picture, and show the real token
|
|
// balance in the account menu.
|
|
async function applyMyAvatar() {
|
|
const me = localStorage.getItem(USER_KEY);
|
|
if (!me) return;
|
|
|
|
// Avatar: the in-game profile picture, taken from the PUBLIC profile endpoint - it returns
|
|
// profile_image as a ready-to-use /img/ URL (the same one that makes the profile-page avatar
|
|
// work), so this is the source of truth. CAMPUS_ME's raw name is only a last-ditch fallback.
|
|
let url = null;
|
|
if (apiOn()) {
|
|
const r = await apiGet('/api/site/profile/' + encodeURIComponent(me));
|
|
if (r && r.ok && r.user && r.user.profile_image) url = r.user.profile_image;
|
|
}
|
|
if (!url) url = myPfpUrl();
|
|
if (url) {
|
|
// Persist so avatarUrl() hands back the real picture on EVERY page (comments, feed, nav,
|
|
// profile - anywhere the user's avatar is drawn), and update every already-rendered avatar
|
|
// for this user on THIS page live. The 'storage' listener carries the change to other tabs.
|
|
try { localStorage.setItem('campus-pfp-' + avKey(me), JSON.stringify({ image: url })); } catch {}
|
|
refreshAvatars(avKey(me), url);
|
|
document.querySelectorAll('.profile-av, .pm-av, #suAv').forEach(im => {
|
|
im.removeAttribute('srcset'); im.dataset.failed = ''; im.src = url; im.style.objectFit = 'cover';
|
|
});
|
|
}
|
|
|
|
// Tokens (RecCenterTokens) from the authed /me - matches what the game shows. Left as the
|
|
// placeholder if the website session isn't authed (a balance must not come from a public call).
|
|
const tokens = CAMPUS_ME && CAMPUS_ME.tokens;
|
|
if (tokens != null) document.querySelectorAll('.pm-token-val').forEach(el => { el.textContent = Number(tokens).toLocaleString(); });
|
|
}
|
|
|
|
// ───── NOTIFICATIONS (real, persistent, game-synced inbox) ─────
|
|
// Reads /api/site/notifications, which aggregates the two server-side sources of truth so the
|
|
// website shows exactly what the game does: incoming friend requests (accept/decline here) and
|
|
// saved messages (cheers, coach messages…). Game / party / join invites can't be actioned in a
|
|
// browser, so they show "Launch the game to view." Actions hit the same endpoints the game uses,
|
|
// so dismissing / accepting stays in sync. NOTIF_ITEMS === null means signed out.
|
|
let NOTIF_ITEMS = [];
|
|
let NOTIF_WRAP = null, NOTIF_MENU = null, NOTIF_BADGE = null, NOTIF_BTN = null;
|
|
|
|
function notifTimeAgo(iso) {
|
|
if (!iso) return '';
|
|
const t = new Date(iso).getTime();
|
|
if (isNaN(t)) return '';
|
|
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
|
if (s < 60) return 'just now';
|
|
const m = Math.floor(s / 60); if (m < 60) return m + 'm ago';
|
|
const h = Math.floor(m / 60); if (h < 24) return h + 'h ago';
|
|
const d = Math.floor(h / 24); if (d < 7) return d + 'd ago';
|
|
return new Date(iso).toLocaleDateString();
|
|
}
|
|
|
|
function notifAvatarHtml(item) {
|
|
const seed = (item.fromPlayer && item.fromPlayer.username) || 'player';
|
|
const src = (item.fromPlayer && item.fromPlayer.profile_image) || avatarUrl(seed);
|
|
return `<img class="nm-av" data-av="${avKey(seed)}" src="${src}" alt="">`;
|
|
}
|
|
|
|
async function fetchNotifications() {
|
|
// apiGet THROWS on 401 (signed out) / any non-ok, so catch and treat as "no inbox" (null).
|
|
try {
|
|
const data = await apiGet('/api/site/notifications');
|
|
NOTIF_ITEMS = (data && data.ok !== false && Array.isArray(data.items)) ? data.items : null;
|
|
} catch {
|
|
NOTIF_ITEMS = null;
|
|
}
|
|
}
|
|
|
|
function syncNotifBadge() {
|
|
if (!NOTIF_BTN) return;
|
|
const n = Array.isArray(NOTIF_ITEMS) ? NOTIF_ITEMS.length : 0;
|
|
NOTIF_BTN.setAttribute('aria-label', n > 0 ? `Notifications, ${n} unread` : 'Notifications');
|
|
if (!NOTIF_BADGE) return;
|
|
if (n > 0) { NOTIF_BADGE.textContent = n > 99 ? '99+' : n; NOTIF_BADGE.classList.remove('hidden'); }
|
|
else { NOTIF_BADGE.textContent = ''; NOTIF_BADGE.classList.add('hidden'); }
|
|
}
|
|
|
|
function renderNotifMenu() {
|
|
if (!NOTIF_MENU) return;
|
|
let rows;
|
|
if (NOTIF_ITEMS === null) {
|
|
rows = '<p class="nm-empty">Log in to see your notifications</p>';
|
|
} else if (NOTIF_ITEMS.length === 0) {
|
|
rows = '<p class="nm-empty">You\'re all caught up</p>';
|
|
} else {
|
|
rows = NOTIF_ITEMS.map(n => {
|
|
const time = n.time ? `<span class="nm-time">${notifTimeAgo(n.time)}</span>` : '';
|
|
if (n.kind === 'friend_request') {
|
|
return `<div class="nm-item nm-fr" data-id="${escapeHtml(n.id)}">
|
|
${notifAvatarHtml(n)}
|
|
<div class="nm-body">
|
|
<span class="nm-text">${escapeHtml(n.text)}</span>
|
|
<div class="nm-actions">
|
|
<button class="nm-accept" type="button" data-target="${n.targetId}">Accept</button>
|
|
<button class="nm-decline" type="button" data-target="${n.targetId}">Decline</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
if (n.kind === 'launch') {
|
|
return `<div class="nm-item nm-launch" data-id="${escapeHtml(n.id)}">
|
|
<div class="nm-icon" aria-hidden="true">🎮</div>
|
|
<div class="nm-body"><span class="nm-text">${escapeHtml(n.text)}</span>${time}</div>
|
|
<button class="nm-dismiss" type="button" aria-label="Dismiss">×</button>
|
|
</div>`;
|
|
}
|
|
return `<div class="nm-item" data-id="${escapeHtml(n.id)}">
|
|
${notifAvatarHtml(n)}
|
|
<div class="nm-body"><span class="nm-text">${escapeHtml(n.text)}</span>${time}</div>
|
|
<button class="nm-dismiss" type="button" aria-label="Dismiss">×</button>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
const hasDismissable = Array.isArray(NOTIF_ITEMS) && NOTIF_ITEMS.some(n => n.kind !== 'friend_request');
|
|
const clear = hasDismissable ? '<button class="nm-clear" type="button">Mark all read</button>' : '';
|
|
NOTIF_MENU.innerHTML =
|
|
`<div class="nm-head"><span>Notifications</span>${clear}</div>
|
|
<div class="nm-list">${rows}</div>`;
|
|
if (typeof refreshAvatars === 'function') refreshAvatars();
|
|
}
|
|
|
|
async function refreshNotifications() {
|
|
await fetchNotifications();
|
|
syncNotifBadge();
|
|
renderNotifMenu();
|
|
ensureNotifSocket(); // (re)connect the realtime channel once we know we're signed in
|
|
}
|
|
|
|
// ── realtime push ──────────────────────────────────────────────────────────
|
|
// Opens a websocket to /api/site/notifications/ws (cookie-authed, separate from the game's
|
|
// hub so it never disturbs the player's game socket). The server sends {"type":"refresh"}
|
|
// the instant a notification is created for this player; we just re-fetch the inbox. Falls
|
|
// back gracefully to the 30s poll if the socket can't connect.
|
|
let NOTIF_WS = null, NOTIF_WS_TIMER = null;
|
|
function ensureNotifSocket() {
|
|
// Only hold a socket while signed in (NOTIF_ITEMS === null means signed out).
|
|
if (NOTIF_ITEMS === null) { if (NOTIF_WS) { try { NOTIF_WS.close(); } catch {} NOTIF_WS = null; } return; }
|
|
if (NOTIF_WS && (NOTIF_WS.readyState === 0 || NOTIF_WS.readyState === 1)) return; // connecting/open
|
|
try {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const ws = new WebSocket(proto + '//' + location.host + '/api/site/notifications/ws');
|
|
NOTIF_WS = ws;
|
|
ws.onopen = () => { siteWatch(SITE_WATCH); };
|
|
ws.onmessage = (ev) => {
|
|
// ignore keep-alive pings; refresh on a real "refresh" (or anything else)
|
|
let m = {}; try { m = JSON.parse(ev.data) || {}; } catch {}
|
|
const t = m.type || '';
|
|
if (t === 'ping') return;
|
|
// Typed frames about the profile this tab is watching (see siteWatch).
|
|
if (t === 'spotify') {
|
|
if (String(m.id) === String(SITE_WATCH_ID || '') || !SITE_WATCH_ID) {
|
|
renderSpotify(document.getElementById('profileSpotify'), m.now_playing);
|
|
renderSpotify(document.getElementById('spotifyNowPreview'), m.now_playing);
|
|
}
|
|
return;
|
|
}
|
|
if (t === 'profile') {
|
|
if (String(m.id) === String(SITE_WATCH_ID || '')) {
|
|
if (document.getElementById('profilePhotos') && typeof renderProfile === 'function') renderProfile();
|
|
if (typeof renderSettingsStatus === 'function') renderSettingsStatus();
|
|
}
|
|
return;
|
|
}
|
|
{
|
|
refreshNotifications();
|
|
// Anything social changed (a friend came online, a request / favourite / subscription
|
|
// moved): redraw what's on screen. Cheap - one GET each - and only while relevant.
|
|
const fm = document.getElementById('friendsModal');
|
|
if (fm && fm.classList.contains('open') && typeof fm.__reload === 'function') fm.__reload();
|
|
const sm = document.getElementById('subsModal');
|
|
if (sm && sm.classList.contains('open') && typeof sm.__reload === 'function') sm.__reload();
|
|
if (document.getElementById('profilePhotos') && typeof renderProfile === 'function') renderProfile();
|
|
if (typeof hydrateOnline === 'function') hydrateOnline();
|
|
}
|
|
};
|
|
ws.onclose = () => {
|
|
NOTIF_WS = null;
|
|
clearTimeout(NOTIF_WS_TIMER);
|
|
// reconnect only while still signed in
|
|
NOTIF_WS_TIMER = setTimeout(() => { if (NOTIF_ITEMS !== null) ensureNotifSocket(); }, 5000);
|
|
};
|
|
ws.onerror = () => { try { ws.close(); } catch {} };
|
|
} catch { NOTIF_WS = null; }
|
|
}
|
|
|
|
async function onNotifMenuClick(e) {
|
|
const accept = e.target.closest('.nm-accept');
|
|
const decline = e.target.closest('.nm-decline');
|
|
const dismiss = e.target.closest('.nm-dismiss');
|
|
const clear = e.target.closest('.nm-clear');
|
|
|
|
if (accept || decline) {
|
|
e.stopPropagation();
|
|
if (!requireLogin('manage friend requests')) return;
|
|
const btn = accept || decline;
|
|
const target = Number(btn.dataset.target);
|
|
btn.disabled = true;
|
|
const path = accept ? '/api/site/friends/accept' : '/api/site/friends/remove';
|
|
const { ok } = await apiPost(path, { id: target });
|
|
if (!ok) { btn.disabled = false; toast('Something went wrong.'); return; }
|
|
toast(accept ? 'Friend request accepted' : 'Friend request declined');
|
|
await refreshNotifications();
|
|
return;
|
|
}
|
|
if (dismiss) {
|
|
e.stopPropagation();
|
|
const id = dismiss.closest('.nm-item').dataset.id;
|
|
await apiPost('/api/site/notifications/dismiss', { id });
|
|
await refreshNotifications();
|
|
return;
|
|
}
|
|
if (clear) {
|
|
e.stopPropagation();
|
|
// One call: the server deletes every read-able notification and tells the game to drop them.
|
|
const { ok } = await apiPost('/api/site/notifications/dismissall');
|
|
if (!ok) {
|
|
const ids = (NOTIF_ITEMS || []).filter(n => n.kind !== 'friend_request').map(n => n.id);
|
|
for (const id of ids) await apiPost('/api/site/notifications/dismiss', { id });
|
|
}
|
|
await refreshNotifications();
|
|
}
|
|
}
|
|
|
|
function initNotifications() {
|
|
const btn = document.getElementById('notifBtn');
|
|
if (!btn) return;
|
|
NOTIF_BTN = btn;
|
|
NOTIF_BADGE = document.getElementById('notifBadge');
|
|
|
|
// wrap the bell so the menu can anchor to it
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'notif';
|
|
btn.replaceWith(wrap);
|
|
wrap.appendChild(btn);
|
|
const menu = document.createElement('div');
|
|
menu.className = 'notif-menu';
|
|
menu.setAttribute('role', 'menu');
|
|
wrap.appendChild(menu);
|
|
NOTIF_WRAP = wrap; NOTIF_MENU = menu;
|
|
|
|
btn.setAttribute('aria-haspopup', 'true');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
|
|
function toggleNotif(open) {
|
|
document.querySelector('.profile')?.classList.remove('open');
|
|
const isOpen = open ?? !wrap.classList.contains('open');
|
|
wrap.classList.toggle('open', isOpen);
|
|
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
if (isOpen) refreshNotifications(); // refresh on open so it's always current
|
|
}
|
|
btn.addEventListener('click', e => { e.stopPropagation(); toggleNotif(); });
|
|
btn.addEventListener('keydown', e => {
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); toggleNotif(true); }
|
|
});
|
|
menu.addEventListener('click', onNotifMenuClick);
|
|
document.addEventListener('click', e => {
|
|
if (!wrap.contains(e.target) && wrap.classList.contains('open')) toggleNotif(false);
|
|
});
|
|
|
|
renderNotifMenu(); // initial paint (loading/empty)
|
|
refreshNotifications(); // initial fetch
|
|
// Light polling keeps the badge in sync with the game while the tab is open.
|
|
setInterval(() => { if (document.visibilityState === 'visible') refreshNotifications(); }, 30000);
|
|
}
|
|
initNotifications();
|
|
|
|
// ───── FRIENDS ─────
|
|
const FRIENDS_KEY = 'campus-friends';
|
|
function getFriends() { try { return JSON.parse(localStorage.getItem(FRIENDS_KEY) || '{}'); } catch { return {}; } }
|
|
function saveFriends(f) { localStorage.setItem(FRIENDS_KEY, JSON.stringify(f)); }
|
|
function isFriend(h) { const f = getFriends()[h]; return !!f && f.status !== 'pending'; }
|
|
function isPending(h) { const f = getFriends()[h]; return !!f && f.status === 'pending'; }
|
|
function addFriend(h) { const f = getFriends(); if (!f[h]) f[h] = { status: 'pending', nickname: '', pinned: false }; saveFriends(f); }
|
|
function acceptFriend(h) { const f = getFriends(); if (f[h]) { f[h].status = 'accepted'; saveFriends(f); } }
|
|
function removeFriend(h) { const f = getFriends(); delete f[h]; saveFriends(f); }
|
|
function setFriendNick(h, n) { const f = getFriends(); if (f[h]) { f[h].nickname = n; saveFriends(f); } }
|
|
function toggleFriendPin(h) { const f = getFriends(); if (f[h]) { f[h].pinned = !f[h].pinned; saveFriends(f); return f[h].pinned; } return false; }
|
|
function isFavourite(h) { const f = getFriends()[h]; return !!f && !!f.favourite; }
|
|
function toggleFriendFavourite(h) { const f = getFriends(); if (f[h]) { f[h].favourite = !f[h].favourite; saveFriends(f); return f[h].favourite; } return false; }
|
|
|
|
// pinned friends widget on the home right rail
|
|
async function renderPinned() {
|
|
const rail = document.querySelector('.feed-layout .rail');
|
|
if (!rail) return;
|
|
rail.querySelector('.rail-pinned')?.remove();
|
|
if (apiOn()) {
|
|
if (!localStorage.getItem(USER_KEY)) return;
|
|
const d = await apiGet('/api/site/me/pinned');
|
|
const rows = (d && d.ok && d.pinned) || [];
|
|
if (!rows.length) return;
|
|
const items = rows.map(p => `<a class="rail-item" href="${userUrl(p.username)}">
|
|
<span class="rail-on-av"><img class="rail-pin-av" data-av="${avKey(p.username)}" src="${p.profile_image || avatarUrl(p.username)}" alt="">${p.online ? '<i class="online-dot"></i>' : ''}</span>
|
|
<div class="rail-meta"><span class="rail-name">${escapeHtml(p.nickname || p.display_name || p.username)}</span><span class="rail-by">${p.online ? (p.where ? 'In ^' + escapeHtml(p.where) : 'Online') : '@' + escapeHtml(p.username)}</span></div>
|
|
</a>`).join('');
|
|
const html = `<div class="rail-pinned"><h3 class="rail-title">Pinned friends</h3>${items}</div>`;
|
|
rail.insertAdjacentHTML('afterbegin', html);
|
|
return;
|
|
}
|
|
const f = getFriends();
|
|
const pinned = Object.keys(f).filter(h => f[h].pinned);
|
|
if (!pinned.length) return;
|
|
const items = pinned.map(h => {
|
|
const nick = f[h].nickname || cap(h);
|
|
return `<a class="rail-item" href="${userUrl(h)}">
|
|
<img class="rail-pin-av" data-av="${avKey(h)}" src="${avatarUrl(h)}" alt="">
|
|
<div class="rail-meta"><span class="rail-name">${escapeHtml(nick)}</span><span class="rail-by">@${h}</span></div>
|
|
</a>`;
|
|
}).join('');
|
|
rail.insertAdjacentHTML('afterbegin',
|
|
`<div class="rail-pinned"><h3 class="rail-title">Pinned friends</h3>${items}</div>`);
|
|
}
|
|
|
|
// online friends widget on the home right rail
|
|
async function renderOnlineFriends() {
|
|
const rail = document.querySelector('.feed-layout .rail');
|
|
if (!rail) return;
|
|
|
|
// Real "who's in-game right now" from /api/site/online; demo data as a fallback.
|
|
let list = null;
|
|
if (apiOn()) {
|
|
const data = await apiGet('/api/site/online');
|
|
if (data && data.ok && Array.isArray(data.results)) {
|
|
list = data.results.map(o => ({ h: o.username, name: o.display_name || o.username, where: o.where || 'Online now', avatar: o.profile_image }));
|
|
}
|
|
}
|
|
if (!list) {
|
|
const f = getFriends();
|
|
list = ((window.CAMPUS_DATA && window.CAMPUS_DATA.onlineFriends) || []).map(d => ({ h: d.h, name: (f[d.h] && f[d.h].nickname) || cap(d.h), where: d.where, avatar: null }));
|
|
Object.keys(f).filter(h => f[h].status !== 'pending')
|
|
.forEach(h => { if (!list.some(d => d.h === h)) list.unshift({ h, name: (f[h] && f[h].nickname) || cap(h), where: 'Online now', avatar: null }); });
|
|
}
|
|
|
|
rail.querySelector('.rail-online')?.remove();
|
|
const items = list.map(d => {
|
|
const av = d.avatar || avatarUrl(d.h);
|
|
return `<a class="rail-item" href="${userUrl(d.h)}">
|
|
<span class="rail-on-av"><img class="rail-pin-av" data-av="${avKey(d.h)}" src="${av}" alt=""><i class="online-dot"></i></span>
|
|
<div class="rail-meta"><span class="rail-name">${escapeHtml(d.name)}</span><span class="rail-by">${escapeHtml(d.where)}</span></div>
|
|
</a>`;
|
|
}).join('');
|
|
|
|
rail.insertAdjacentHTML('afterbegin',
|
|
`<div class="rail-online"><h3 class="rail-title">Online Friends · ${list.length}</h3>${items}</div>`);
|
|
}
|
|
|
|
// ───── UPDATES PAGE (Patch Notes / Weekly / News) ─────
|
|
function renderUpdates() {
|
|
const list = document.getElementById('updatesList');
|
|
if (!list) return;
|
|
const all = (window.CAMPUS_DATA && window.CAMPUS_DATA.updates) || {};
|
|
// type comes from the page itself (data-updates) or the ?type= param
|
|
let type = list.dataset.updates || new URLSearchParams(location.search).get('type') || 'patch';
|
|
if (!all[type]) type = 'patch';
|
|
const section = all[type] || { title: 'Updates', blurb: '', posts: [] };
|
|
|
|
document.title = `${section.title} - Campus`;
|
|
document.getElementById('updatesTitle').textContent = section.title;
|
|
document.getElementById('updatesBlurb').textContent = section.blurb || '';
|
|
document.querySelectorAll('.utab').forEach(t => t.classList.toggle('active', t.dataset.type === type));
|
|
|
|
const fmtDate = d => {
|
|
try { return new Date(d).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); }
|
|
catch { return d; }
|
|
};
|
|
list.innerHTML = (section.posts || []).map(p => `
|
|
<article class="update-card">
|
|
<div class="update-meta">
|
|
${p.tag ? `<span class="update-tag">${escapeHtml(p.tag)}</span>` : ''}
|
|
<span class="update-date">${escapeHtml(fmtDate(p.date))}</span>
|
|
</div>
|
|
<h2 class="update-title">${escapeHtml(p.title)}</h2>
|
|
<p class="update-body">${escapeHtml(p.body)}</p>
|
|
</article>
|
|
`).join('') || '<p class="search-empty">Nothing here yet.</p>';
|
|
}
|
|
renderUpdates();
|
|
|
|
// ───── ROOM PAGE ─────
|
|
const ROOMS_DATA = (window.CAMPUS_DATA && window.CAMPUS_DATA.rooms) || {};
|
|
|
|
async function renderRoom() {
|
|
const cover = document.getElementById('roomCover');
|
|
if (!cover) return;
|
|
const id = viewedRoomId();
|
|
|
|
// Real room from /api/site/rooms/{id}; demo ROOMS_DATA only as a fallback.
|
|
let r = null;
|
|
if (apiOn() && id != null) {
|
|
const data = await apiGet('/api/site/rooms/' + encodeURIComponent(id));
|
|
if (data && data.ok && data.room) {
|
|
const rm = data.room;
|
|
r = { name: rm.name, creator: rm.creator || 'someone', desc: rm.description || '', image: rm.image, visits: rm.visits || 0, cheers: rm.cheers || 0, favorites: rm.favorites || 0, players: rm.players || 0, photos: data.photos || [] };
|
|
}
|
|
}
|
|
if (!r) r = ROOMS_DATA[id];
|
|
if (!r) { document.getElementById('roomPage').innerHTML = '<p class="image-missing">Room not found.</p>'; return; }
|
|
|
|
document.title = `^${r.name} - Campus`;
|
|
if (r.image) { cover.style.backgroundImage = `url('${r.image}')`; cover.style.backgroundSize = 'cover'; cover.style.backgroundPosition = 'center'; }
|
|
else cover.style.background = `linear-gradient(135deg, ${r.a || '#ff5a3c'}, ${r.b || '#7b2ff7'})`;
|
|
document.getElementById('roomName').textContent = '^' + r.name;
|
|
const by = document.getElementById('roomBy');
|
|
by.textContent = 'by ' + r.creator;
|
|
by.href = userUrl(r.creator);
|
|
document.getElementById('roomDesc').textContent = r.desc || '';
|
|
const EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 12S5.5 5 12 5s10.5 7 10.5 7-4 7-10.5 7S1.5 12 1.5 12z"/><circle cx="12" cy="12" r="3.2"/></svg>';
|
|
const THUMB = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11H3V10zM7 10l4.2-6.6a1.6 1.6 0 0 1 2.9 1.2L13.4 9H19a2 2 0 0 1 2 2.4l-1.5 7.5A2.5 2.5 0 0 1 17 21H7"/></svg>';
|
|
const STAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.4 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9z"/></svg>';
|
|
document.getElementById('roomStats').innerHTML =
|
|
`<div class="room-stat">${EYE}<strong>${fmt(r.visits || 0)}</strong><small>visits</small></div>
|
|
<div class="room-stat">${THUMB}<strong>${fmt(r.cheers || 0)}</strong><small>cheers</small></div>
|
|
<div class="room-stat">${STAR}<strong>${fmt(r.favorites || 0)}</strong><small>favorites</small></div>`;
|
|
|
|
// the room's photos, as the same cards the home feed uses (cheers / comments work here too)
|
|
const feed = document.getElementById('roomPhotos');
|
|
if (feed) {
|
|
const posts = (r.photos || []).map(x => ({
|
|
id: x.id, image: x.image,
|
|
user: (x.author && (x.author.display_name || x.author.username)) || 'Player',
|
|
handle: (x.author && x.author.username) || 'player',
|
|
avatar: x.author && x.author.profile_image,
|
|
caption: x.description || '', room: r.name,
|
|
cheers: x.cheers || 0, cheered: !!x.cheered, comments: x.comments || 0, takenAt: x.createdAt,
|
|
tagged: x.taggedPlayers || [],
|
|
}));
|
|
if (!posts.length) feed.innerHTML = '<div class="feed-loading">No photos taken in this room yet.</div>';
|
|
else {
|
|
const keep = postList; // renderPosts writes into the home feed element; borrow it for this page
|
|
feed.innerHTML = posts.map(p => {
|
|
const [a, b] = avColor(p.user || '?');
|
|
const tags = (p.tagged || []).map(t => `<a class="room-tag" href="${userUrl(t.username)}"><img src="${t.profile_image || avatarUrl(t.username)}" alt="">${escapeHtml(t.display_name || t.username)}</a>`).join('');
|
|
return `<article class="post" data-photo="${p.id}">
|
|
<div class="post-head">
|
|
<a class="post-av" href="${userUrl(p.handle)}" style="--a:${a};--b:${b}"><img data-av="${avKey(p.handle)}" src="${p.avatar || avatarUrl(p.handle)}" alt="" onerror="this.remove()">${(p.user || '?')[0].toUpperCase()}</a>
|
|
<div class="post-meta"><a class="post-user" href="${userUrl(p.handle)}">${escapeHtml(p.user)}</a><span class="post-handle">@${escapeHtml(p.handle)}</span></div>
|
|
<span class="post-time">${p.takenAt ? timeAgo(p.takenAt) : ''}</span>
|
|
</div>
|
|
<a class="post-img" href="${imageUrl(p.id)}" style="background-image:url('${p.image}')"></a>
|
|
<div class="post-foot">
|
|
<span class="post-cap"><span class="room-chip">^${escapeHtml(p.room)}</span>${tags ? `<span class="room-in">In this photo:</span>${tags}` : ''}${p.caption ? ` <span class="room-caption">${escapeHtml(p.caption)}</span>` : ''}</span>
|
|
<div class="post-stats">
|
|
<button class="pstat cheer${p.cheered ? ' liked' : ''}" data-n="${(p.cheers || 0) - (p.cheered ? 1 : 0)}">${CHEER_ICON}<span>${fmt(p.cheers || 0)}</span></button>
|
|
<button class="pstat comment-toggle" data-n="${p.comments || 0}">${CHAT}<span>${p.comments || 0}</span></button>
|
|
</div>
|
|
</div>
|
|
${commentsBlock(p)}
|
|
</article>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
// tabs
|
|
document.querySelectorAll('.room-tab').forEach(t => t.addEventListener('click', () => {
|
|
document.querySelectorAll('.room-tab').forEach(x => x.classList.toggle('active', x === t));
|
|
document.querySelectorAll('.room-panel').forEach(p => p.classList.toggle('active', p.dataset.panel === t.dataset.panel));
|
|
}));
|
|
}
|
|
renderRoom();
|
|
|
|
// featured-rooms slideshow on the home page.
|
|
// Data-driven: called once with the demo rooms at load, then again with the real rooms
|
|
// once /api/site/rooms/hot answers. The previous instance's timer is cleared on re-init,
|
|
// otherwise the two would fight over the same element.
|
|
let FEATURED_TIMER = null;
|
|
function initFeaturedSlideshow(rooms) {
|
|
const el = document.getElementById('featuredSlideshow');
|
|
if (!el) return;
|
|
|
|
if (FEATURED_TIMER) { clearInterval(FEATURED_TIMER); FEATURED_TIMER = null; }
|
|
|
|
// No argument = the original demo path, keyed into ROOMS_DATA by slug.
|
|
const list = (rooms && rooms.length)
|
|
? rooms
|
|
: ['whisker-heights', 'midnight-mews', 'yarn-quest', 'haunted-cattery', 'deep-dive-reef']
|
|
.map(id => Object.assign({ id }, ROOMS_DATA[id] || {}));
|
|
|
|
if (!list.length) return;
|
|
const ids = list.map(r => r.id);
|
|
let i = 0, timer = null;
|
|
|
|
el.innerHTML = `
|
|
<a class="featured" id="featuredSlide" href="#"></a>
|
|
<button class="farrow farrow-prev" aria-label="Previous room"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg></button>
|
|
<button class="farrow farrow-next" aria-label="Next room"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></button>
|
|
<div class="featured-dots" id="featuredDots"></div>`;
|
|
const slide = document.getElementById('featuredSlide');
|
|
const dots = document.getElementById('featuredDots');
|
|
dots.innerHTML = ids.map((_, idx) => `<button class="fdot" data-i="${idx}" aria-label="Slide ${idx + 1}"></button>`).join('');
|
|
|
|
function render() {
|
|
const r = list[i];
|
|
slide.href = roomUrl(ids[i]);
|
|
// A real room has a server image; fall back to its gradient when it has none.
|
|
const bg = r.image
|
|
? `background-image:url('${r.image}');background-size:cover;background-position:center`
|
|
: `background:linear-gradient(135deg, ${r.a || '#ff5a3c'}, ${r.b || '#7b2ff7'})`;
|
|
slide.innerHTML = `
|
|
<div class="featured-img" style="${bg}"></div>
|
|
<div class="featured-body">
|
|
<span class="featured-tag">Featured room</span>
|
|
<h2>^${r.name || ''}</h2>
|
|
<p class="featured-by">by @${r.creator || 'someone'}</p>
|
|
<div class="featured-row">
|
|
<span class="featured-live"><i class="dot"></i>${(Number(r.players) || 0).toLocaleString()} playing now</span>
|
|
<span class="featured-cta">Visit room →</span>
|
|
</div>
|
|
</div>`;
|
|
slide.classList.remove('fade'); void slide.offsetWidth; slide.classList.add('fade');
|
|
dots.querySelectorAll('.fdot').forEach((d, idx) => d.classList.toggle('active', idx === i));
|
|
}
|
|
function start() { timer = setInterval(() => { i = (i + 1) % ids.length; render(); }, 5000); FEATURED_TIMER = timer; }
|
|
function stop() { clearInterval(timer); }
|
|
|
|
render();
|
|
start();
|
|
function go(delta) { i = (i + delta + ids.length) % ids.length; render(); stop(); start(); }
|
|
dots.addEventListener('click', e => {
|
|
const b = e.target.closest('.fdot');
|
|
if (!b) return;
|
|
i = +b.dataset.i; render(); stop(); start();
|
|
});
|
|
el.querySelector('.farrow-prev').addEventListener('click', () => go(-1));
|
|
el.querySelector('.farrow-next').addEventListener('click', () => go(1));
|
|
el.addEventListener('mouseenter', stop);
|
|
el.addEventListener('mouseleave', start);
|
|
}
|
|
|
|
// ───── PROFILE PAGE ─────
|
|
const PROFILE_PHOTOS = [
|
|
{ id: 'a', pos: '8% 45%', size: '230%' },
|
|
{ id: 'b', pos: '40% 50%', size: '250%' },
|
|
{ id: 'c', pos: '70% 48%', size: '240%' },
|
|
{ id: 'd', pos: '95% 50%', size: '260%' },
|
|
{ id: 'e', pos: '20% 60%', size: '220%' },
|
|
{ id: 'f', pos: '55% 40%', size: '270%' },
|
|
];
|
|
|
|
function cap(s) { return s ? s[0].toUpperCase() + s.slice(1) : s; }
|
|
|
|
// per-user photo galleries (random generated images via Lorem Picsum)
|
|
const USER_PHOTOS = {
|
|
catlover: [
|
|
{ id: 'g1', image: 'https://picsum.photos/seed/catlover-aurora/700/700', title: 'Caught a nice view' },
|
|
{ id: 'g2', image: 'https://picsum.photos/seed/catlover-sunset/700/700', title: 'Late night session' },
|
|
],
|
|
};
|
|
|
|
// per-user profile stats
|
|
const PROFILE_DATA = {
|
|
catlover: { friends: 16, subscribers: 2 },
|
|
bubbles: { friends: 48, subscribers: 312 },
|
|
spookytom: { friends: 27, subscribers: 1530 },
|
|
mittens: { friends: 12, subscribers: 89 },
|
|
clawful: { friends: 64, subscribers: 12400 },
|
|
velvetpaw: { friends: 41, subscribers: 4800 },
|
|
tinker9: { friends: 33, subscribers: 760 },
|
|
stitchwitch: { friends: 58, subscribers: 9100 },
|
|
pounce: { friends: 9, subscribers: 34 },
|
|
};
|
|
|
|
async function renderProfile() {
|
|
const photosEl = document.getElementById('profilePhotos');
|
|
if (!photosEl) return;
|
|
|
|
const me = localStorage.getItem(USER_KEY);
|
|
const viewed = (viewedHandle() || me || 'catlover').toLowerCase();
|
|
const isOwn = !!me && me.toLowerCase() === viewed;
|
|
siteWatch(viewed);
|
|
|
|
// Real profile from the Campus server: api.user (profile_image / display_name /
|
|
// username / bio / banner_image), api.friends, api.subscribers, api.photos, api.rooms.
|
|
// Falls back to demo data only when the API is off or the player isn't found.
|
|
let api = null;
|
|
if (apiOn()) {
|
|
// Direct fetch (not apiGet) so we can read a 403 body: if the owner has blocked this viewer
|
|
// the server answers { blocked: true } and we show the notice instead of the profile.
|
|
try {
|
|
const resp = await fetch(apiUrl('/api/site/profile/' + encodeURIComponent(viewed)), { credentials: 'same-origin' });
|
|
const data = await resp.json().catch(() => null);
|
|
if (data && data.blocked) {
|
|
const main = document.getElementById('main') || document.querySelector('.profile-page');
|
|
if (main) main.innerHTML =
|
|
`<div class="profile-blocked" style="max-width:540px;margin:120px auto;text-align:center;padding:0 20px">
|
|
<h1 style="font-size:1.4rem;margin-bottom:10px">This Profile cannot be viewed at this time</h1>
|
|
<p style="color:var(--muted)"><a href="/" style="color:var(--primary)">Back to home</a></p>
|
|
</div>`;
|
|
document.title = 'Profile unavailable - Campus';
|
|
return;
|
|
}
|
|
if (resp.ok && data && data.ok) api = data;
|
|
} catch {}
|
|
}
|
|
const u = api && api.user;
|
|
if (u && u.id) siteWatch(viewed, u.id);
|
|
const data = api
|
|
? { friends: api.friends || 0, subscribers: api.subscribers || 0 }
|
|
: (PROFILE_DATA[viewed] || { friends: 8, subscribers: 24 });
|
|
|
|
const displayName = u ? (u.display_name || u.username) : (isOwn ? myDisplayName(viewed) : cap(viewed));
|
|
const handleText = u ? u.username : (isOwn ? myHandle(viewed) : viewed);
|
|
|
|
document.title = `${displayName} - Campus`;
|
|
const picEl = document.getElementById('profilePic');
|
|
picEl.src = (u && u.profile_image) ? u.profile_image : `https://api.dicebear.com/9.x/thumbs/svg?seed=${viewed}`;
|
|
picEl.dataset.av = avKey(viewed);
|
|
const unameEl = document.getElementById('profileUsername');
|
|
unameEl.textContent = displayName;
|
|
if (!isOwn && isFavourite(viewed)) {
|
|
unameEl.insertAdjacentHTML('beforeend', ' <span class="fav-star" title="Favourite" aria-label="Favourite">★</span>');
|
|
}
|
|
document.getElementById('profileHandle').textContent = '@' + handleText;
|
|
// "You call them X" - the viewer's nickname for this friend, if they set one
|
|
function renderNickChip() {
|
|
document.getElementById('profileNick')?.remove();
|
|
const n = api ? api.nickname : ((getFriends()[viewed] || {}).nickname || '');
|
|
if (!n) return;
|
|
document.getElementById('profileHandle').insertAdjacentHTML('afterend', `<span class="profile-nick" id="profileNick" title="Your nickname for them">aka ${escapeHtml(n)}</span>`);
|
|
}
|
|
renderNickChip();
|
|
const bioEl = document.getElementById('profileBio');
|
|
if (bioEl) {
|
|
const myBio = isOwn ? localStorage.getItem('campus-mybio') : null;
|
|
bioEl.textContent = myBio || (u && u.bio) || (isOwn ? 'No bio yet — add one in Settings.' : '');
|
|
}
|
|
renderSocials(document.getElementById('profileSocials'), u && u.socials);
|
|
|
|
// Real joined date from the account's created_at (was hardcoded "Joined June 2025").
|
|
const joinedText = document.querySelector('#profileJoined .joined-text');
|
|
if (joinedText) {
|
|
let jd = '';
|
|
if (u && u.created_at) { const d = new Date(u.created_at); if (!isNaN(d)) jd = d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); }
|
|
joinedText.textContent = jd ? ('Joined ' + jd) : 'Joined';
|
|
}
|
|
|
|
// Status: shown ONLY when the server disclosed it, which it does only for a signed-in viewer.
|
|
// Signed out -> api.status is null -> the whole line stays hidden (you can't see anyone's status).
|
|
const statusEl = document.getElementById('profileStatus');
|
|
if (statusEl) {
|
|
const st = api && api.status;
|
|
if (st) {
|
|
statusEl.hidden = false;
|
|
const dot = statusEl.querySelector('.status-dot');
|
|
const txt = statusEl.querySelector('.status-text');
|
|
if (dot) dot.style.background = st.online ? '#2ecc71' : '#8a8f98';
|
|
if (txt) txt.textContent = st.online ? ('Online' + (st.where ? ' · ' + st.where : '')) : ('Offline' + (st.lastOnline ? ' · last online ' + lastOnlineText(st.lastOnline) : ''));
|
|
} else {
|
|
statusEl.hidden = true;
|
|
}
|
|
// Spotify "listening to" under the status - only what the server disclosed for THIS viewer
|
|
// (the owner's "show to" choice). Live updates arrive over the site socket; this poll is
|
|
// only the fallback for a tab whose socket is down.
|
|
const spEl = document.getElementById('profileSpotify');
|
|
if (spEl) {
|
|
renderSpotify(spEl, st ? st.spotify : null);
|
|
clearInterval(spEl._poll);
|
|
if (st && u && (u.username || u.id)) {
|
|
const handle = u.username || String(u.id);
|
|
spEl._poll = setInterval(async () => {
|
|
if (document.hidden) return;
|
|
try {
|
|
const r = await fetch('/api/site/spotify/nowplaying/' + encodeURIComponent(handle), { credentials: 'same-origin' });
|
|
if (r.ok) { const j = await r.json(); renderSpotify(spEl, j.now_playing); }
|
|
} catch {}
|
|
}, 60000);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Real banner from the server, if the account has one.
|
|
{
|
|
const b = document.querySelector('.profile-banner');
|
|
const src = (u && u.banner_image) || 'assets/banner.png';
|
|
if (b) { b.style.backgroundImage = `url('${src}')`; b.style.backgroundSize = 'cover'; b.style.backgroundPosition = 'center'; b.style.backgroundRepeat = 'no-repeat'; }
|
|
}
|
|
|
|
// custom profile picture / banner (set from a photo). Only used as a local override when
|
|
// the server has no image of its own, so a real server picture always wins.
|
|
try {
|
|
const pfp = JSON.parse(localStorage.getItem('campus-pfp-' + viewed) || 'null');
|
|
if (pfp && !(u && u.profile_image)) applyCropToImg(picEl, pfp);
|
|
} catch {}
|
|
try {
|
|
const bn = null; // banners are the site-wide banner.png now
|
|
if (bn && !(u && u.banner_image)) {
|
|
const b = document.querySelector('.profile-banner');
|
|
b.style.backgroundImage = `url('${bn.image}')`;
|
|
b.style.backgroundSize = 'cover';
|
|
b.style.backgroundPosition = bn.pos;
|
|
b.style.backgroundRepeat = 'no-repeat';
|
|
if (bn.id) {
|
|
b.style.cursor = 'pointer';
|
|
b.title = 'View photo';
|
|
b.setAttribute('role', 'button');
|
|
b.setAttribute('tabindex', '0');
|
|
b.setAttribute('aria-label', 'View banner photo');
|
|
const goPhoto = () => { window.location.href = imageUrl(bn.id); };
|
|
b.addEventListener('click', goPhoto);
|
|
b.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goPhoto(); } });
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// stats box
|
|
const subKey = 'campus-sub-' + viewed;
|
|
let subscribed = api ? !!api.subscribed : localStorage.getItem(subKey) === '1';
|
|
const statFriends = document.getElementById('statFriends');
|
|
const statSubs = document.getElementById('statSubs');
|
|
if (statFriends) statFriends.textContent = fmt(data.friends);
|
|
// With the API the count already includes the viewer's own subscription.
|
|
const subCount = () => api ? data.subscribers : data.subscribers + (subscribed ? 1 : 0);
|
|
if (statSubs) statSubs.textContent = fmt(subCount());
|
|
|
|
// top action: gear (own) or Add-friend (others)
|
|
const actions = document.getElementById('profileActions');
|
|
if (isOwn) {
|
|
actions.innerHTML = `<a href="/stats" class="profile-gear" title="Stats" aria-label="Stats" style="margin-right:6px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V10"/><path d="M10 19V4"/><path d="M16 19v-7"/><path d="M22 19H2"/></svg></a><a href="/settings" class="profile-gear" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></a>`;
|
|
// clicking your own "Friends" stat opens the friends list
|
|
const friendRow = document.getElementById('statFriendsRow');
|
|
if (friendRow) {
|
|
friendRow.classList.add('statbox-clickable');
|
|
friendRow.setAttribute('role', 'button');
|
|
friendRow.tabIndex = 0;
|
|
// renderProfile re-runs on every live refresh: replace the badge, never stack another.
|
|
friendRow.querySelectorAll('.statbox-badge').forEach(b => b.remove());
|
|
const pendingN = (NOTIF_ITEMS || []).filter(n => n.kind === 'friend_request').length;
|
|
if (pendingN) friendRow.insertAdjacentHTML('beforeend', ` <span class="statbox-badge">${pendingN}</span>`);
|
|
if (!friendRow.dataset.wired) {
|
|
friendRow.dataset.wired = '1';
|
|
friendRow.addEventListener('click', () => openFriendsList(viewed));
|
|
friendRow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFriendsList(viewed); } });
|
|
}
|
|
}
|
|
} else {
|
|
actions.innerHTML = '';
|
|
const rbtn = document.createElement('button');
|
|
rbtn.className = 'profile-report-btn';
|
|
rbtn.textContent = 'Report';
|
|
rbtn.addEventListener('click', () => openReport(`Report ${cap(viewed)}`, () => {
|
|
rbtn.textContent = 'Reported';
|
|
rbtn.disabled = true;
|
|
}, { type: 'player', id: viewed }));
|
|
actions.appendChild(rbtn);
|
|
|
|
// Block / Unblock (server-backed, the same block as in-game). Only YOUR block is yours to
|
|
// lift; if they've also blocked you, that stays when you unblock.
|
|
if (api && u && u.id) {
|
|
const bbtn = document.createElement('button');
|
|
bbtn.className = 'profile-report-btn profile-block-btn';
|
|
let blocked = !!api.blockedByMe;
|
|
const paint = () => { bbtn.textContent = blocked ? 'Unblock' : 'Block'; bbtn.classList.toggle('on', blocked); };
|
|
paint();
|
|
bbtn.addEventListener('click', () => {
|
|
if (!requireLogin('block players')) return;
|
|
const go = async () => {
|
|
bbtn.disabled = true;
|
|
const { ok, data } = await apiPost('/api/site/friends/block', { id: u.id, on: !blocked });
|
|
bbtn.disabled = false;
|
|
if (!ok) { toast((data && data.error) || 'Could not update the block'); return; }
|
|
blocked = !!data.blocked; api.blockedByMe = blocked; paint();
|
|
toast(blocked ? `${escapeHtml(displayName)} blocked` : `${escapeHtml(displayName)} unblocked`);
|
|
if (blocked) setTimeout(() => renderProfile(), 300); // friendship ends with a block; repaint the friend controls
|
|
};
|
|
if (blocked) go();
|
|
else openConfirm({ title: `Block ${escapeHtml(displayName)}?`, message: 'They won\'t be able to friend, message or join you, and any friendship ends. You can unblock them here later.', confirmText: 'Block', onConfirm: go });
|
|
});
|
|
actions.appendChild(bbtn);
|
|
}
|
|
|
|
// friend control replaces the "Friends" row in the stats box
|
|
const friendRow = document.getElementById('statFriendsRow');
|
|
if (friendRow) {
|
|
friendRow.classList.add('statbox-friend');
|
|
const fbtn = document.createElement('button');
|
|
const targetId = u && u.id;
|
|
// REAL relationship state from the server: None | FriendRequestSent | FriendRequestReceived
|
|
// | Friend. Falls back to the old local flags only when there's no API profile.
|
|
let rel = (api && api.relationship) || (isFriend(viewed) ? 'Friend' : (isPending(viewed) ? 'FriendRequestSent' : 'None'));
|
|
const setFbtn = () => {
|
|
if (rel === 'FriendRequestSent') { fbtn.className = 'friend-row-btn pending'; fbtn.textContent = 'Pending friend request'; }
|
|
else if (rel === 'FriendRequestReceived') { fbtn.className = 'friend-row-btn'; fbtn.textContent = 'Accept friend request'; }
|
|
else if (rel === 'Friend') { fbtn.className = 'friend-row-btn is-friend'; fbtn.textContent = 'Friends'; }
|
|
else { fbtn.className = 'friend-row-btn'; fbtn.textContent = 'Add friend'; }
|
|
};
|
|
setFbtn();
|
|
// Hit the real site friend endpoints (they run the same FriendsDB ops the game uses, so a
|
|
// request sent here shows up in-game). action is request|accept|remove.
|
|
async function act(action) {
|
|
if (!targetId) { toast('Could not identify this player.'); return; }
|
|
fbtn.disabled = true;
|
|
const { ok, data } = await apiPost('/api/site/friends/' + action, { id: targetId });
|
|
fbtn.disabled = false;
|
|
if (!ok) { toast((data && data.error) || 'Something went wrong.'); return; }
|
|
rel = data.relationship || (action === 'request' ? 'FriendRequestSent' : action === 'accept' ? 'Friend' : 'None');
|
|
setFbtn();
|
|
toast(action === 'request' ? `Friend request sent to ${escapeHtml(displayName)}`
|
|
: action === 'accept' ? `You and ${escapeHtml(displayName)} are now friends`
|
|
: 'Friendship updated');
|
|
}
|
|
fbtn.addEventListener('click', () => {
|
|
if (!requireLogin('send friend requests')) return;
|
|
if (rel === 'FriendRequestSent') {
|
|
openConfirm({ title: 'Cancel friend request?', message: `Cancel your friend request to ${escapeHtml(displayName)}?`, confirmText: 'Cancel request', onConfirm: () => act('remove') });
|
|
} else if (rel === 'Friend') {
|
|
openConfirm({ title: 'Remove friend?', message: `Remove ${escapeHtml(displayName)} from your friends?`, confirmText: 'Remove friend', onConfirm: () => act('remove') });
|
|
} else if (rel === 'FriendRequestReceived') {
|
|
act('accept');
|
|
} else {
|
|
act('request');
|
|
}
|
|
});
|
|
friendRow.innerHTML = '';
|
|
friendRow.appendChild(fbtn);
|
|
}
|
|
}
|
|
|
|
// friend options (nickname + pin) - others, only when friended
|
|
// Anyone's "Friends" count opens their list (accepted friends only for others).
|
|
if (!isOwn) {
|
|
const fr = document.getElementById('statFriendsRow');
|
|
if (fr) {
|
|
const n = fr.querySelector('strong');
|
|
if (n && !n.dataset.wired) { n.dataset.wired = '1'; n.style.cursor = 'pointer'; n.title = 'See friends'; n.addEventListener('click', e => { e.stopPropagation(); openFriendsList(viewed); }); }
|
|
}
|
|
}
|
|
|
|
function renderFriendPanel() {
|
|
const fp = document.getElementById('friendPanel');
|
|
if (!fp) return;
|
|
const serverFriend = api ? api.relationship === 'Friend' : isFriend(viewed);
|
|
if (isOwn || !serverFriend) { fp.innerHTML = ''; return; }
|
|
const d = Object.assign({ nickname: '', pinned: false }, getFriends()[viewed] || {});
|
|
if (api) { d.favourite = !!api.favourite; d.nickname = api.nickname || ''; d.pinned = !!api.pinned; }
|
|
const starSvg = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M12 3.5l2.6 5.3 5.9.9-4.2 4.1 1 5.8L12 17l-5.3 2.8 1-5.8-4.2-4.1 5.9-.9z"/></svg>';
|
|
fp.innerHTML = `
|
|
<span class="fo-title">Friend options</span>
|
|
<button class="fo-fav${d.favourite ? ' on' : ''}">${starSvg}<span>${d.favourite ? 'Favourited' : 'Favourite'}</span></button>
|
|
<button class="fo-nick-btn">${d.nickname ? 'Edit nickname' : 'Set nickname'}</button>
|
|
<button class="fo-pin${d.pinned ? ' on' : ''}">${d.pinned ? 'Pinned to home' : 'Pin to home'}</button>`;
|
|
fp.querySelector('.fo-fav').addEventListener('click', async e => {
|
|
const btn = e.currentTarget;
|
|
let fav;
|
|
if (api && u && u.id) {
|
|
const want = !btn.classList.contains('on');
|
|
const { ok, data } = await apiPost('/api/site/friends/favourite', { id: u.id, on: want });
|
|
if (!ok) { toast('Could not update favourites'); return; }
|
|
fav = !!data.favourite;
|
|
} else {
|
|
fav = toggleFriendFavourite(viewed);
|
|
}
|
|
btn.classList.toggle('on', fav);
|
|
btn.querySelector('span').textContent = fav ? 'Favourited' : 'Favourite';
|
|
toast(fav ? `Added ${cap(viewed)} to favourites` : `Removed ${cap(viewed)} from favourites`);
|
|
});
|
|
fp.querySelector('.fo-nick-btn').addEventListener('click', () => openNickname(viewed, () => { renderFriendPanel(); renderNickChip(); },
|
|
api && u && u.id ? { id: u.id, current: api.nickname || '', onSaved: n => { api.nickname = n || null; } } : null));
|
|
fp.querySelector('.fo-pin').addEventListener('click', async e => {
|
|
const btn = e.currentTarget;
|
|
let pinned;
|
|
if (api && u && u.id) {
|
|
const want = !btn.classList.contains('on');
|
|
const { ok, data } = await apiPost('/api/site/friends/pin', { id: u.id, on: want });
|
|
if (!ok) { toast((data && data.error) || 'Could not update pins'); return; }
|
|
pinned = !!data.pinned; api.pinned = pinned;
|
|
} else {
|
|
pinned = toggleFriendPin(viewed);
|
|
}
|
|
btn.classList.toggle('on', pinned);
|
|
btn.textContent = pinned ? 'Pinned to home' : 'Pin to home';
|
|
toast(pinned ? `${cap(viewed)} pinned to your home page` : `${cap(viewed)} unpinned`);
|
|
});
|
|
}
|
|
renderFriendPanel();
|
|
|
|
// "Subscribers" row opens the list of people subscribed to this profile (anyone's).
|
|
const subsRow = document.getElementById('statSubsRow');
|
|
if (subsRow && !subsRow.dataset.wired) {
|
|
subsRow.dataset.wired = '1';
|
|
subsRow.classList.add('statbox-clickable');
|
|
subsRow.setAttribute('role', 'button');
|
|
subsRow.tabIndex = 0;
|
|
subsRow.addEventListener('click', () => openSubsList(viewed, 'subscribers'));
|
|
subsRow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSubsList(viewed, 'subscribers'); } });
|
|
}
|
|
|
|
// third stat row: "Subscriptions" (own - opens who you subscribe to) or a Subscribe button (others)
|
|
const subWrap = document.getElementById('profileSubscribe');
|
|
if (subWrap) {
|
|
if (isOwn) {
|
|
// Same markup as before (so it keeps the row's own styling); the ROW is the button.
|
|
subWrap.innerHTML = '<span class="sub-text">Subscriptions</span>';
|
|
if (!subWrap.dataset.wired) {
|
|
subWrap.dataset.wired = '1';
|
|
subWrap.style.cursor = 'pointer';
|
|
subWrap.setAttribute('role', 'button');
|
|
subWrap.tabIndex = 0;
|
|
subWrap.title = 'See who you subscribe to';
|
|
subWrap.addEventListener('click', () => openSubsList(viewed, 'subscriptions'));
|
|
subWrap.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSubsList(viewed, 'subscriptions'); } });
|
|
}
|
|
} else {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'subscribe-btn' + (subscribed ? ' subscribed' : '');
|
|
btn.textContent = subscribed ? 'Subscribed' : 'Subscribe';
|
|
btn.addEventListener('click', async () => {
|
|
if (!requireLogin('subscribe')) return;
|
|
if (!rateLimit('subscribe', 12, 60000)) { toast('You\'re doing that too fast.'); return; }
|
|
if (api && u && u.id) {
|
|
const { ok, data: r } = await apiPost('/api/site/subscribe', { id: u.id, on: !subscribed });
|
|
if (!ok) { toast((r && r.error) || 'Could not update the subscription'); return; }
|
|
subscribed = !!r.subscribed;
|
|
data.subscribers = typeof r.subscribers === 'number' ? r.subscribers : data.subscribers;
|
|
} else {
|
|
subscribed = !subscribed;
|
|
localStorage.setItem(subKey, subscribed ? '1' : '0');
|
|
}
|
|
btn.textContent = subscribed ? 'Subscribed' : 'Subscribe';
|
|
btn.classList.toggle('subscribed', subscribed);
|
|
if (statSubs) statSubs.textContent = fmt(subCount());
|
|
});
|
|
subWrap.innerHTML = '';
|
|
subWrap.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
// photos - real from the API (each links to /image/{id}); demo fallback respects local deletions
|
|
const delKey = 'campus-deleted-' + viewed;
|
|
const deleted = JSON.parse(localStorage.getItem(delKey) || '[]');
|
|
const photosTaggedEl = document.getElementById('profilePhotosTagged');
|
|
if (api && Array.isArray(api.photos)) {
|
|
const lockBadge = '<span class="pphoto-lock" aria-label="Private" title="Only you can see this" style="position:absolute;top:8px;right:8px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);border-radius:50%;color:#fff"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>';
|
|
const tile = p =>
|
|
`<a class="pphoto${p.isPrivate ? ' is-private' : ''}" href="${imageUrl(p.id)}" data-id="${p.id}" style="position:relative;background-image:url('${p.image}');background-size:cover;background-position:center">${p.isPrivate ? lockBadge : ''}</a>`;
|
|
// "Photos taken" = ones this player took; "Tagged in" = ones they're tagged in (theirs or others').
|
|
// Older photos may lack the flags: if neither flag is present on any photo, treat all as taken.
|
|
const hasFlags = api.photos.some(p => 'taken' in p || 'tagged' in p);
|
|
const taken = hasFlags ? api.photos.filter(p => p.taken) : api.photos;
|
|
const tagged = hasFlags ? api.photos.filter(p => p.tagged) : [];
|
|
photosEl.innerHTML = taken.length ? taken.map(tile).join('') : `<p class="profile-empty">No photos taken yet.</p>`;
|
|
if (photosTaggedEl) photosTaggedEl.innerHTML = tagged.length ? tagged.map(tile).join('') : `<p class="profile-empty">Not tagged in any photos yet.</p>`;
|
|
} else {
|
|
if (photosTaggedEl) photosTaggedEl.innerHTML = `<p class="profile-empty">Not tagged in any photos yet.</p>`;
|
|
const source = USER_PHOTOS[viewed] || PROFILE_PHOTOS;
|
|
const visible = source.filter(p => !deleted.includes(p.id));
|
|
photosEl.innerHTML = visible.length
|
|
? visible.map(p => {
|
|
const bg = p.image
|
|
? `background-image:url('${p.image}');background-size:cover;background-position:center`
|
|
: `background-image:url('assets/hero-bg.jpg');background-position:${p.pos};background-size:${p.size}`;
|
|
return `<a class="pphoto" href="${imageUrl(viewed + '__' + p.id)}" data-id="${p.id}" style="${bg}"></a>`;
|
|
}).join('')
|
|
: `<p class="profile-empty">No photos yet.</p>`;
|
|
}
|
|
|
|
// delete handler
|
|
photosEl.addEventListener('click', e => {
|
|
const del = e.target.closest('.pphoto-del');
|
|
if (!del) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const tile = del.closest('.pphoto');
|
|
const id = tile.dataset.id;
|
|
const d = JSON.parse(localStorage.getItem(delKey) || '[]');
|
|
if (!d.includes(id)) { d.push(id); localStorage.setItem(delKey, JSON.stringify(d)); }
|
|
tile.style.opacity = '0';
|
|
tile.style.transform = 'scale(.9)';
|
|
setTimeout(() => { tile.remove(); refreshPhotoCount(); }, 180);
|
|
});
|
|
|
|
function refreshPhotoCount() {
|
|
const n = photosEl.querySelectorAll('.pphoto').length;
|
|
if (n === 0) photosEl.innerHTML = `<p class="profile-empty">No photos yet.</p>`;
|
|
}
|
|
|
|
// ── Rooms tab (rec.net-style room cards) ──
|
|
const roomsEl = document.getElementById('profileRooms');
|
|
if (roomsEl) {
|
|
// visits = an eye, same glyph as the Rooms page and room page stat strip
|
|
const PERSON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 12S5.5 5 12 5s10.5 7 10.5 7-4 7-10.5 7S1.5 12 1.5 12z"/><circle cx="12" cy="12" r="3.2"/></svg>';
|
|
const avSrc = (u && u.profile_image) || avatarUrl(viewed);
|
|
if (api && Array.isArray(api.rooms)) {
|
|
roomsEl.innerHTML = api.rooms.length
|
|
? api.rooms.map(r => {
|
|
const cover = r.image
|
|
? `background-image:url('${r.image}');background-size:cover;background-position:center`
|
|
: `background:linear-gradient(135deg,#ff5a3c,#7b2ff7)`;
|
|
return `<a class="room-card" href="${roomUrl(r.name || r.id)}">
|
|
<div class="room-card-head">
|
|
<img class="room-card-av" src="${avSrc}" alt="">
|
|
<div class="room-card-id"><span class="room-card-name">^${escapeHtml(r.name || 'Room')}</span><span class="room-card-by">@${escapeHtml(r.creator || handleText)}</span></div>
|
|
</div>
|
|
<div class="room-card-cover" style="${cover}"></div>
|
|
<div class="room-card-foot">
|
|
<span class="room-card-stat">${PERSON}${fmt(r.visits || 0)}</span>
|
|
</div>
|
|
</a>`;
|
|
}).join('')
|
|
: `<p class="profile-empty">No rooms yet.</p>`;
|
|
} else {
|
|
const roomIds = ['whisker-heights', 'midnight-mews', 'litterbox-lab', 'yarn-quest', 'deep-dive-reef', 'haunted-cattery'];
|
|
roomsEl.innerHTML = roomIds.map(rid => {
|
|
const r = ROOMS_DATA[rid];
|
|
return `<a class="room-card" href="${roomUrl(rid)}">
|
|
<div class="room-card-head">
|
|
<img class="room-card-av" src="${avSrc}" alt="">
|
|
<div class="room-card-id"><span class="room-card-name">^${r.name}</span><span class="room-card-by">@${viewed}</span></div>
|
|
</div>
|
|
<div class="room-card-cover" style="background:linear-gradient(135deg,${r.a},${r.b})"></div>
|
|
<div class="room-card-foot">
|
|
<span class="room-card-stat">${CHEER_ICON}${fmt(r.players)}</span>
|
|
<span class="room-card-stat">${PERSON}${r.visits}</span>
|
|
</div>
|
|
</a>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// ── Events tab ──
|
|
const eventsEl = document.getElementById('profileEvents');
|
|
if (eventsEl) {
|
|
const events = [
|
|
{ mon: 'JUN', day: '20', name: 'Caturday Hangout', time: '7:00 PM · 42 going' },
|
|
{ mon: 'JUN', day: '24', name: 'Build Battle Night', time: '5:30 PM · 18 going' },
|
|
];
|
|
eventsEl.innerHTML = events.length
|
|
? events.map(e => `
|
|
<div class="pevent">
|
|
<div class="pevent-date"><span class="ped-day">${e.day}</span><span class="ped-mon">${e.mon}</span></div>
|
|
<div class="pevent-info"><span class="pevent-name">${e.name}</span><span class="pevent-time">${e.time}</span></div>
|
|
</div>`).join('')
|
|
: `<p class="profile-empty">No upcoming events.</p>`;
|
|
}
|
|
|
|
// ── tab switching ──
|
|
const ptabs = document.querySelectorAll('.profile-tabs .ptab');
|
|
const ppanels = document.querySelectorAll('.profile-panel');
|
|
ptabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
ptabs.forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const which = tab.dataset.tab;
|
|
ppanels.forEach(p => p.classList.toggle('active', p.dataset.panel === which));
|
|
});
|
|
});
|
|
}
|
|
renderProfile();
|
|
|
|
// ───── IMAGE PAGE ─────
|
|
async function renderImage() {
|
|
const photoEl = document.getElementById('imagePhoto');
|
|
if (!photoEl) return;
|
|
|
|
const id = viewedImageId();
|
|
// Real photo from /api/site/photos/{id} (numeric id); demo getPhoto() as a fallback for
|
|
// the old "user__letter" ids.
|
|
let ph = null;
|
|
if (apiOn() && id != null && /^\d+$/.test(String(id))) {
|
|
const data = await apiGet('/api/site/photos/' + encodeURIComponent(id));
|
|
if (data && data.ok && data.photo) {
|
|
const p = data.photo, a = p.author || {};
|
|
ph = {
|
|
user: a.display_name || a.username || 'Player',
|
|
handle: a.username || 'player',
|
|
avatar: a.profile_image,
|
|
image: p.image, pos: 'center', size: 'cover',
|
|
title: p.description || 'Photo', takenAt: p.createdAt,
|
|
cheers: p.cheers || 0, comments: p.comments || 0, cheered: !!p.cheered,
|
|
commentList: Array.isArray(p.commentList) ? p.commentList.map(serverComment) : [],
|
|
tagged: Array.isArray(p.taggedPlayers) ? p.taggedPlayers : [],
|
|
room: p.room || null,
|
|
isOwner: !!p.isOwner, visibility: p.visibility || 'public',
|
|
};
|
|
}
|
|
}
|
|
if (!ph) ph = getPhoto(id);
|
|
|
|
if (!ph) {
|
|
document.getElementById('imagePage').innerHTML = '<p class="image-missing">Photo not found.</p>';
|
|
return;
|
|
}
|
|
|
|
document.title = `${ph.title || 'Photo'} - Campus`;
|
|
|
|
const author = document.getElementById('imageAuthor');
|
|
author.href = userUrl(ph.handle);
|
|
const imgAvEl = document.getElementById('imageAuthorAv');
|
|
imgAvEl.src = ph.avatar || avatarUrl(ph.handle);
|
|
imgAvEl.dataset.av = avKey(ph.handle);
|
|
document.getElementById('imageAuthorName').textContent = ph.user;
|
|
document.getElementById('imageAuthorHandle').textContent = '@' + ph.handle;
|
|
|
|
photoEl.style.backgroundImage = `url('${ph.image || 'assets/hero-bg.jpg'}')`;
|
|
photoEl.style.backgroundPosition = ph.pos || 'center';
|
|
photoEl.style.backgroundSize = ph.size || 'cover';
|
|
|
|
// ── tagged players: a little people button on the photo that opens a list ──
|
|
const tagged = Array.isArray(ph.tagged) ? ph.tagged.slice() : [];
|
|
if (tagged.length) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'tagbtn'; btn.type = 'button';
|
|
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg><span class="tagbtn-n"></span>';
|
|
const pop = document.createElement('div'); pop.className = 'tagpop';
|
|
photoEl.appendChild(btn); photoEl.appendChild(pop);
|
|
const renderTags = () => {
|
|
const n = btn.querySelector('.tagbtn-n'); if (n) n.textContent = tagged.length;
|
|
if (!tagged.length) { btn.remove(); pop.remove(); return; }
|
|
pop.innerHTML = '<div class="tp-head">In this photo</div>' + tagged.map(t =>
|
|
`<div class="tp-row" data-pid="${t.id}"><span class="tp-av" style="background-image:url('${t.profile_image || ''}')"></span>`
|
|
+ `<a class="tp-name" href="${userUrl(t.username)}">@${escapeHtml(t.username)}</a>`
|
|
+ (ph.isOwner ? `<button class="tp-x" data-remove="${t.id}" title="Remove tag" aria-label="Remove @${escapeHtml(t.username)}">×</button>` : '')
|
|
+ `</div>`).join('');
|
|
};
|
|
renderTags();
|
|
btn.addEventListener('click', e => { e.stopPropagation(); pop.classList.toggle('open'); });
|
|
document.addEventListener('click', () => pop.classList.remove('open'));
|
|
pop.addEventListener('click', async e => {
|
|
e.stopPropagation();
|
|
const rm = e.target.closest('.tp-x'); if (!rm) return;
|
|
const pid = rm.dataset.remove; rm.disabled = true;
|
|
const res = await apiPost('/api/site/photos/' + encodeURIComponent(id) + '/untag', { playerId: Number(pid) });
|
|
if (res && res.ok) {
|
|
const idx = tagged.findIndex(t => String(t.id) === String(pid));
|
|
if (idx >= 0) tagged.splice(idx, 1);
|
|
renderTags();
|
|
if (!tagged.length) pop.classList.remove('open');
|
|
toast('Tag removed');
|
|
} else { rm.disabled = false; toast('Could not remove tag'); }
|
|
});
|
|
}
|
|
|
|
// ── owner: photo visibility (private / friends / public), synced to the server + in-game ──
|
|
if (ph.isOwner) {
|
|
const infoText = document.querySelector('.image-info-text');
|
|
if (infoText) {
|
|
const seg = document.createElement('div'); seg.className = 'vis-seg';
|
|
const opts = [
|
|
['private', 'Private', '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>'],
|
|
['friends', 'Friends', '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/>'],
|
|
['public', 'Public', '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18"/>'],
|
|
];
|
|
seg.innerHTML = opts.map(([v, label, path]) =>
|
|
`<button type="button" data-vis="${v}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${path}</svg>${label}</button>`).join('');
|
|
infoText.appendChild(seg);
|
|
const mark = v => seg.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.vis === v));
|
|
mark(ph.visibility || 'public');
|
|
seg.addEventListener('click', async e => {
|
|
const b = e.target.closest('button'); if (!b) return;
|
|
const v = b.dataset.vis; if (v === ph.visibility) return;
|
|
const prev = ph.visibility; mark(v);
|
|
const res = await apiPost('/api/site/photos/' + encodeURIComponent(id) + '/visibility', { visibility: v });
|
|
if (res && res.ok) { ph.visibility = (res.data && res.data.visibility) || v; mark(ph.visibility); toast('Photo set to ' + (ph.visibility === 'friends' ? 'friends only' : ph.visibility)); }
|
|
else { mark(prev); toast('Could not update visibility'); }
|
|
});
|
|
}
|
|
}
|
|
|
|
const imgExport = document.getElementById('imgExport');
|
|
if (imgExport) imgExport.addEventListener('click', async () => {
|
|
const src = ph.image || '';
|
|
if (!src) { toast('Nothing to download'); return; }
|
|
imgExport.disabled = true;
|
|
try {
|
|
const name = (src.split('/').pop() || 'photo.jpg').split('?')[0];
|
|
await saveFile(await fetchBlob(src), photoFileName(name, ph.title !== 'Photo' ? ph.title : ''));
|
|
} catch { toast('Could not download the photo'); }
|
|
imgExport.disabled = false;
|
|
});
|
|
|
|
const imgReport = document.getElementById('imgReport');
|
|
if (imgReport) imgReport.addEventListener('click', () => openReport('Report photo', () => {
|
|
imgReport.textContent = 'Reported';
|
|
imgReport.disabled = true;
|
|
}, /^\d+$/.test(String(id)) ? { type: 'photo', id } : null));
|
|
|
|
// title (with a custom override) + edit pencil for the owner
|
|
const titleEl = document.getElementById('imageTitle');
|
|
const savedTitle = localStorage.getItem('campus-title-' + id);
|
|
if (savedTitle && !/^\d+$/.test(String(id))) ph.title = savedTitle;
|
|
titleEl.textContent = ph.title || 'Untitled photo';
|
|
document.title = `${ph.title || 'Photo'} - Campus`;
|
|
|
|
const owner = localStorage.getItem(USER_KEY);
|
|
if (owner && owner.toLowerCase() === (ph.handle || '').toLowerCase()) {
|
|
// wrap title in a row with a pencil button
|
|
const row = document.createElement('div');
|
|
row.className = 'title-row';
|
|
titleEl.parentNode.insertBefore(row, titleEl);
|
|
row.appendChild(titleEl);
|
|
|
|
const pencil = document.createElement('button');
|
|
pencil.className = 'title-edit';
|
|
pencil.setAttribute('aria-label', 'Edit title');
|
|
pencil.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
|
|
row.appendChild(pencil);
|
|
|
|
pencil.addEventListener('click', () => {
|
|
const current = titleEl.textContent;
|
|
const editor = document.createElement('div');
|
|
editor.className = 'title-editor';
|
|
editor.innerHTML = `<input class="title-input" type="text" maxlength="80" value="${escapeHtml(current)}"><button class="title-save">Save</button><button class="title-cancel">Cancel</button>`;
|
|
row.style.display = 'none';
|
|
row.parentNode.insertBefore(editor, row);
|
|
const input = editor.querySelector('.title-input');
|
|
input.focus(); input.select();
|
|
const finish = () => { editor.remove(); row.style.display = ''; };
|
|
editor.querySelector('.title-save').addEventListener('click', async () => {
|
|
const v = input.value.trim() || 'Untitled photo';
|
|
if (apiOn() && /^\d+$/.test(String(id))) {
|
|
const r = await apiPost('/api/site/photos/' + encodeURIComponent(id) + '/description', { description: input.value.trim() });
|
|
if (!r || !r.ok) { toast('Could not save the title'); return; }
|
|
const saved = (r.data && r.data.description) || '';
|
|
titleEl.textContent = saved || 'Untitled photo';
|
|
document.title = `${saved || 'Photo'} - Campus`;
|
|
} else {
|
|
titleEl.textContent = v;
|
|
document.title = `${v} - Campus`;
|
|
localStorage.setItem('campus-title-' + id, v);
|
|
}
|
|
toast('Title updated');
|
|
finish();
|
|
});
|
|
editor.querySelector('.title-cancel').addEventListener('click', finish);
|
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') editor.querySelector('.title-save').click(); if (e.key === 'Escape') finish(); });
|
|
});
|
|
}
|
|
|
|
const metaEl = document.getElementById('imageMeta');
|
|
metaEl.innerHTML = (ph.takenAt ? `<span>${timeAgo(ph.takenAt)} ago</span>` : '')
|
|
+ (ph.room && ph.room.name ? `<span class="image-room-sep">·</span><a class="image-room" href="${roomUrl(ph.room.name)}">^${escapeHtml(ph.room.name)}</a>` : '');
|
|
|
|
// comments (reuse the comment system; persist per photo)
|
|
// ⋯ menu - set this photo as profile picture / banner
|
|
const menuBtn = document.getElementById('imageMenuBtn');
|
|
const menu = document.getElementById('imageMenu');
|
|
const me = localStorage.getItem(USER_KEY);
|
|
if (menuBtn && menu) {
|
|
if (!me) {
|
|
menuBtn.style.display = 'none'; // must be logged in to set your own
|
|
} else {
|
|
// owner of the photo gets a delete option
|
|
const isOwner = me.toLowerCase() === (ph.handle || '').toLowerCase();
|
|
if (isOwner) {
|
|
const delItem = document.createElement('button');
|
|
delItem.className = 'image-menu-item danger';
|
|
delItem.dataset.act = 'delete';
|
|
delItem.textContent = 'Delete photo';
|
|
menu.appendChild(delItem);
|
|
}
|
|
menuBtn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('open'); });
|
|
document.addEventListener('click', () => menu.classList.remove('open'));
|
|
menu.addEventListener('click', e => {
|
|
const item = e.target.closest('.image-menu-item');
|
|
if (!item) return;
|
|
menu.classList.remove('open');
|
|
const act = item.dataset.act;
|
|
const cropData = { id: id, image: ph.image || 'assets/hero-bg.jpg', pos: ph.pos, size: ph.size };
|
|
const data = JSON.stringify(cropData);
|
|
if (act === 'pfp') {
|
|
localStorage.setItem('campus-pfp-' + me, data);
|
|
refreshAvatars(avKey(me), cropData.image); // update every avatar instantly (optimistic)
|
|
toast('Profile picture updated');
|
|
syncProfileImageToApi(cropData.image); // also push to the backend
|
|
} else if (act === 'banner') {
|
|
if (apiOn() && /^\d+$/.test(String(id))) {
|
|
apiPost('/api/site/me/banner', { photoId: Number(id) }).then(r => toast(r && r.ok ? 'Profile banner updated' : ((r && r.data && r.data.error) || 'Could not set the banner')));
|
|
} else {
|
|
localStorage.setItem('campus-banner-' + me, data);
|
|
toast('Profile banner updated');
|
|
}
|
|
} else if (act === 'delete') {
|
|
openConfirm({
|
|
title: 'Delete photo?',
|
|
message: 'This photo will be removed from your profile. This can\'t be undone.',
|
|
confirmText: 'Delete',
|
|
onConfirm: async () => {
|
|
if (apiOn() && /^\d+$/.test(String(id))) {
|
|
const r = await apiDelete('/api/site/photos/' + encodeURIComponent(id));
|
|
if (!r || !r.ok) { toast('Could not delete the photo'); return; }
|
|
}
|
|
if (id && String(id).includes('__')) {
|
|
const [u, letter] = id.split('__');
|
|
const key = 'campus-deleted-' + u;
|
|
const d = JSON.parse(localStorage.getItem(key) || '[]');
|
|
if (!d.includes(letter)) { d.push(letter); localStorage.setItem(key, JSON.stringify(d)); }
|
|
}
|
|
toast('Photo deleted');
|
|
setTimeout(() => { window.location.href = userUrl(ph.handle); }, 650);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const host = document.getElementById('imageCommentsHost');
|
|
if (/^\d+$/.test(String(id))) host.dataset.photo = String(id);
|
|
const block = commentsBlock({ thread: ph.commentList || [] });
|
|
host.innerHTML = block.replace(' hidden', ''); // always visible here
|
|
if (host.dataset.photo) { const l = host.querySelector('.comment-list'); if (l) l.dataset.loaded = '1'; }
|
|
|
|
// cheer button just above the comment form / Post button
|
|
const cheerBtn = `<button class="pstat cheer${ph.cheered ? ' liked' : ''}" data-n="${(ph.cheers || 0) - (ph.cheered ? 1 : 0)}">${CHEER_ICON}<span>${fmt(ph.cheers || 0)}</span></button>`;
|
|
const tools = document.getElementById('imageTools');
|
|
if (tools) tools.insertAdjacentHTML('afterbegin', cheerBtn);
|
|
else {
|
|
const form = host.querySelector('.comment-form');
|
|
if (form) form.insertAdjacentHTML('beforebegin', `<div class="image-actions">${cheerBtn}</div>`);
|
|
else host.querySelector('.post-comments')?.insertAdjacentHTML('beforeend', `<div class="image-actions">${cheerBtn}</div>`);
|
|
}
|
|
const ch = document.getElementById('imageComments')?.querySelector('h2');
|
|
if (ch && (ph.commentList || []).length) ch.textContent = `Comments (${ph.commentList.length})`;
|
|
}
|
|
|
|
// ───── EXPORT (Settings > Export, and the image page's Export button) ─────
|
|
// Everything happens in the browser: the page lists the photos, fetches each /img/ file,
|
|
// zips them with JSZip (loaded from cdnjs on first use) and hands the zip to the browser's
|
|
// download. The server never builds or stores an archive.
|
|
async function fetchBlob(url) {
|
|
const r = await fetch(url, { credentials: 'same-origin' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return await r.blob();
|
|
}
|
|
function saveFile(blob, filename) {
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob); a.download = filename;
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
|
}
|
|
// "<id>_<title>.<ext>" - keeps the server's name so the file is unique, adds the title if any.
|
|
function photoFileName(serverName, title) {
|
|
const m = /^(.*?)(\.[a-z0-9]+)?$/i.exec(serverName || 'photo.jpg');
|
|
const base = m[1] || 'photo', ext = m[2] || '.jpg';
|
|
const t = (title || '').replace(/[^\w\- ]+/g, '').trim().slice(0, 40);
|
|
return t ? `${base}_${t.replace(/\s+/g, '_')}${ext}` : base + ext;
|
|
}
|
|
let _jszip = null;
|
|
function loadJSZip() {
|
|
if (window.JSZip) return Promise.resolve(window.JSZip);
|
|
if (_jszip) return _jszip;
|
|
_jszip = new Promise((res, rej) => {
|
|
const sc = document.createElement('script');
|
|
sc.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
|
|
sc.onload = () => res(window.JSZip); sc.onerror = () => { _jszip = null; rej(new Error('jszip')); };
|
|
document.head.appendChild(sc);
|
|
});
|
|
return _jszip;
|
|
}
|
|
function openExportPhotos() {
|
|
if (!localStorage.getItem(USER_KEY)) { toast('Log in to export your photos'); return; }
|
|
let scrim = document.getElementById('exportScrim');
|
|
let modal = document.getElementById('exportModal');
|
|
if (!modal) {
|
|
scrim = document.createElement('div'); scrim.id = 'exportScrim'; scrim.className = 'report-scrim';
|
|
modal = document.createElement('div'); modal.id = 'exportModal'; modal.className = 'report-modal';
|
|
document.body.appendChild(scrim); document.body.appendChild(modal);
|
|
}
|
|
const close = () => { modal.classList.remove('open'); scrim.classList.remove('open'); document.body.style.overflow = ''; };
|
|
modal.innerHTML = `
|
|
<div class="report-card export-card">
|
|
<button class="report-close" aria-label="Close">✕</button>
|
|
<h2 class="report-title">Export Photos</h2>
|
|
<p class="report-sub">Choose what to include. The photos are zipped in your browser.</p>
|
|
<label class="export-opt"><input type="checkbox" id="expTaken" checked><span class="export-check"></span><span><b>Photos taken by you</b><small>Every photo you took, including private ones.</small></span></label>
|
|
<label class="export-opt"><input type="checkbox" id="expTagged" checked><span class="export-check"></span><span><b>Photos including you</b><small>Photos other players took that you're tagged in.</small></span></label>
|
|
<p class="export-progress" id="expProgress" hidden></p>
|
|
<div class="report-actions">
|
|
<button class="report-cancel export-cancel">Cancel</button>
|
|
<button class="report-submit export-go">Continue</button>
|
|
</div>
|
|
</div>`;
|
|
const card = modal.querySelector('.export-card');
|
|
let busy = false;
|
|
const tryClose = () => { if (!busy) close(); };
|
|
scrim.onclick = tryClose; modal.onclick = e => { if (e.target === modal) tryClose(); };
|
|
card.querySelector('.report-close').addEventListener('click', tryClose);
|
|
card.querySelector('.export-cancel').addEventListener('click', tryClose);
|
|
const go = card.querySelector('.export-go');
|
|
const prog = card.querySelector('#expProgress');
|
|
const say = t => { prog.hidden = false; prog.textContent = t; };
|
|
go.addEventListener('click', async () => {
|
|
const taken = card.querySelector('#expTaken').checked, tagged = card.querySelector('#expTagged').checked;
|
|
if (!taken && !tagged) { say('Pick at least one option.'); return; }
|
|
busy = true; go.disabled = true; card.querySelectorAll('input').forEach(i => i.disabled = true);
|
|
try {
|
|
say('Loading your photo list…');
|
|
const d = await apiGet(`/api/site/me/photos?taken=${taken}&tagged=${tagged}`);
|
|
const list = (d && d.ok && d.photos) || [];
|
|
if (!list.length) { say('No photos to export.'); busy = false; go.disabled = false; card.querySelectorAll('input').forEach(i => i.disabled = false); return; }
|
|
const JSZip = await loadJSZip();
|
|
const zip = new JSZip();
|
|
const takenDir = zip.folder('taken-by-me'), taggedDir = zip.folder('photos-of-me');
|
|
let n = 0, failed = 0;
|
|
for (const ph of list) {
|
|
n++; say(`Downloading ${n} / ${list.length}…`);
|
|
try {
|
|
const blob = await fetchBlob(ph.image);
|
|
const name = photoFileName(ph.file || (ph.image || '').split('/').pop(), ph.description);
|
|
(ph.taken ? takenDir : taggedDir).file(name, blob);
|
|
} catch { failed++; }
|
|
}
|
|
say('Zipping…');
|
|
const out = await zip.generateAsync({ type: 'blob', compression: 'STORE' });
|
|
const me = localStorage.getItem(USER_KEY) || 'campus';
|
|
saveFile(out, `campus-photos-${me}-${new Date().toISOString().slice(0, 10)}.zip`);
|
|
say(`Done - ${list.length - failed} photo${list.length - failed === 1 ? '' : 's'} exported${failed ? `, ${failed} could not be downloaded` : ''}.`);
|
|
go.textContent = 'Close'; go.disabled = false; busy = false;
|
|
go.onclick = close;
|
|
} catch (e) {
|
|
say('Export failed - ' + (e && e.message === 'jszip' ? 'could not load the zip library.' : 'please try again.'));
|
|
busy = false; go.disabled = false; card.querySelectorAll('input').forEach(i => i.disabled = false);
|
|
}
|
|
});
|
|
modal.classList.add('open'); scrim.classList.add('open'); document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
// ───── SETTINGS PAGE ─────
|
|
function initSettings() {
|
|
const nav = document.querySelector('.settings-nav');
|
|
if (!nav) return;
|
|
const me = localStorage.getItem(USER_KEY);
|
|
if (!me) {
|
|
// not logged in — show a 401
|
|
const main = document.querySelector('.settings-page');
|
|
if (main) main.innerHTML =
|
|
`<div class="forbidden">
|
|
<h1>401</h1>
|
|
<p>You need to be logged in to view your settings.</p>
|
|
<a href="/login" class="btn-dl">Log in</a>
|
|
</div>`;
|
|
document.title = '401 - Campus';
|
|
return;
|
|
}
|
|
|
|
// header
|
|
const suAv = document.getElementById('suAv');
|
|
if (suAv) { suAv.src = avatarUrl(me); suAv.dataset.av = avKey(me); }
|
|
const suName = document.getElementById('suName');
|
|
if (suName) suName.textContent = myDisplayName(me);
|
|
if (me) { siteWatch(me); renderSettingsStatus(); }
|
|
const suHandle = document.getElementById('suHandle');
|
|
if (suHandle) suHandle.textContent = '@' + myHandle(me);
|
|
|
|
// profile fields — show current values as faded placeholders (clear when typing)
|
|
const username = document.getElementById('setUsername');
|
|
if (username) { username.value = ''; username.placeholder = myHandle(me); }
|
|
const nick = document.getElementById('setNickname');
|
|
if (nick) { nick.value = ''; nick.placeholder = myDisplayName(me); }
|
|
const bio = document.getElementById('setBio');
|
|
if (bio) bio.value = localStorage.getItem('campus-mybio') || '';
|
|
|
|
document.getElementById('exportPhotos')?.addEventListener('click', openExportPhotos);
|
|
|
|
const themeSel = document.getElementById('setTheme');
|
|
if (themeSel) {
|
|
themeSel.value = localStorage.getItem(THEME_KEY) || 'light';
|
|
themeSel.addEventListener('change', () => { localStorage.setItem(THEME_KEY, themeSel.value); applyTheme(themeSel.value); syncThemeToggles(); });
|
|
}
|
|
|
|
// nav switching
|
|
document.querySelectorAll('.snav[data-panel]').forEach(b => {
|
|
b.addEventListener('click', () => {
|
|
document.querySelector('.snav.active')?.classList.remove('active');
|
|
b.classList.add('active');
|
|
const p = b.dataset.panel;
|
|
document.querySelectorAll('.spanel').forEach(s => s.classList.toggle('active', s.dataset.panel === p));
|
|
});
|
|
});
|
|
|
|
// ---- Discord linking tab ----
|
|
// Isolated flow: generate a code here, run /link <code> with the Campus bot. The server only
|
|
// records a Discord<->account link (no admin access). See SiteController /api/site/discord/*.
|
|
(function initDiscordTab() {
|
|
const stText = document.getElementById('discordStatusText');
|
|
if (!stText) return;
|
|
const linkBtn = document.getElementById('discordLinkBtn');
|
|
const unlinkBtn = document.getElementById('discordUnlinkBtn');
|
|
|
|
async function jfetch(path, method) {
|
|
try {
|
|
const r = await fetch(path, { method: method || 'GET', credentials: 'same-origin' });
|
|
return r.ok ? await r.json() : null;
|
|
} catch { return null; }
|
|
}
|
|
const show = (el, on) => { if (el) el.style.display = on ? '' : 'none'; };
|
|
|
|
async function load() {
|
|
stText.textContent = 'Checking…';
|
|
show(linkBtn, false); show(unlinkBtn, false);
|
|
const s = await jfetch('/api/site/discord/status');
|
|
if (!s || !s.ok) { stText.textContent = 'Could not load status.'; return; }
|
|
if (s.linked) {
|
|
stText.textContent = 'Connected' + (s.discordUsername ? (' as ' + s.discordUsername) : '');
|
|
show(unlinkBtn, true);
|
|
} else {
|
|
stText.textContent = 'Not connected';
|
|
show(linkBtn, true);
|
|
}
|
|
}
|
|
|
|
// "Link Discord" -> full-page redirect to Discord's authorize page (OAuth). The callback
|
|
// links the account and returns to /settings?discord=linked. Same app as the admin login, but
|
|
// the link-scoped state means it NEVER logs you into the admin panel.
|
|
document.getElementById('discordLinkBtn')?.addEventListener('click', () => {
|
|
window.location.href = '/api/site/discord/oauth/start';
|
|
});
|
|
document.getElementById('discordUnlinkBtn')?.addEventListener('click', async () => {
|
|
const r = await jfetch('/api/site/discord/unlink', 'POST');
|
|
if (!r || !r.ok) { toast('Could not unlink.'); return; }
|
|
toast('Discord unlinked'); load(); if (typeof initSocialCards === 'function') initSocialCards();
|
|
});
|
|
|
|
// Coming back from the OAuth round-trip: toast the outcome and open the Discord tab.
|
|
const dq = new URLSearchParams(location.search).get('discord');
|
|
if (dq) {
|
|
const msg = { linked: 'Discord linked!', expired: 'Link timed out — try again.',
|
|
inuse: 'That Discord is already linked to another account.',
|
|
error: 'Could not link Discord.' }[dq] || null;
|
|
if (msg) toast(msg);
|
|
document.querySelector('.snav[data-panel="services"]')?.click();
|
|
history.replaceState(null, '', location.pathname);
|
|
}
|
|
|
|
// Load status the first time the Services tab is opened.
|
|
let loaded = false;
|
|
document.querySelector('.snav[data-panel="services"]')?.addEventListener('click', () => {
|
|
if (!loaded) { loaded = true; load(); }
|
|
});
|
|
})();
|
|
|
|
// ---- Spotify (Settings > Services) ----
|
|
// OAuth against the server's Spotify app (SiteController /api/site/spotify/*). The card shows
|
|
// the linked account, a live "listening to" preview and the "show to" choice.
|
|
(function initSpotifyTab() {
|
|
const stText = document.getElementById('spotifyStatusText');
|
|
if (!stText) return;
|
|
const linkBtn = document.getElementById('spotifyLinkBtn');
|
|
const unlinkBtn = document.getElementById('spotifyUnlinkBtn');
|
|
const visRow = document.getElementById('spotifyVisRow');
|
|
const visSel = document.getElementById('spotifyVis');
|
|
const preview = document.getElementById('spotifyNowPreview');
|
|
const show = (el, on) => { if (el) el.style.display = on ? '' : 'none'; };
|
|
let timer = null;
|
|
|
|
async function jfetch(path, method, body) {
|
|
try {
|
|
const r = await fetch(path, { method: method || 'GET', credentials: 'same-origin',
|
|
headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
|
return r.ok ? await r.json() : null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
async function load() {
|
|
stText.textContent = 'Checking…';
|
|
show(linkBtn, false); show(unlinkBtn, false); visRow.hidden = true;
|
|
const s = await jfetch('/api/site/spotify/status');
|
|
if (!s || !s.ok) { stText.textContent = 'Could not load status.'; return; }
|
|
if (!s.configured) { stText.textContent = 'Spotify linking is not set up on this server yet.'; return; }
|
|
if (s.linked) {
|
|
if (s.notAllowed) {
|
|
// The token is real but Spotify refuses this user: the Campus app is in Spotify's
|
|
// Development Mode, which only serves allow-listed users (User Management, max 25).
|
|
stText.textContent = 'Connected, but Spotify is not allowing this account yet — the Campus Spotify app is in development mode, so a Campus dev has to add your Spotify email under User Management. Your song will appear automatically once they have.';
|
|
} else {
|
|
stText.textContent = (s.broken ? 'Link expired — link again' : 'Connected') + (s.displayName ? (' as ' + s.displayName) : '');
|
|
}
|
|
show(unlinkBtn, true); if (s.broken) show(linkBtn, true);
|
|
visRow.hidden = false; visSel.value = String(s.visibility ?? 1);
|
|
renderSpotify(preview, s.now_playing);
|
|
clearInterval(timer);
|
|
timer = setInterval(async () => {
|
|
if (document.hidden) return;
|
|
const r = await jfetch('/api/site/spotify/status');
|
|
if (r && r.ok) renderSpotify(preview, r.now_playing);
|
|
}, 15000);
|
|
} else {
|
|
stText.textContent = 'Not connected';
|
|
show(linkBtn, true); renderSpotify(preview, null);
|
|
}
|
|
}
|
|
|
|
linkBtn?.addEventListener('click', () => { window.location.href = '/api/site/spotify/oauth/start'; });
|
|
unlinkBtn?.addEventListener('click', async () => {
|
|
const r = await jfetch('/api/site/spotify/unlink', 'POST');
|
|
if (!r || !r.ok) { toast('Could not unlink.'); return; }
|
|
toast('Spotify unlinked'); clearInterval(timer); load(); if (typeof initSocialCards === 'function') initSocialCards();
|
|
});
|
|
visSel?.addEventListener('change', async () => {
|
|
const r = await jfetch('/api/site/spotify/visibility', 'POST', { visibility: Number(visSel.value) });
|
|
toast(r && r.ok ? 'Saved' : 'Could not save.');
|
|
});
|
|
|
|
const sq = new URLSearchParams(location.search).get('spotify');
|
|
if (sq) {
|
|
const msg = { linked: 'Spotify linked!', notallowed: 'Linked, but Spotify is not allowing this account yet — ask a Campus dev to add your Spotify email to the app.',
|
|
expired: 'Link timed out — try again.', denied: 'Spotify access was declined.',
|
|
inuse: 'That Spotify account is already linked to another player.',
|
|
unconfigured: 'Spotify linking is not set up on this server yet.', error: 'Could not link Spotify.' }[sq] || null;
|
|
if (msg) toast(msg);
|
|
document.querySelector('.snav[data-panel="services"]')?.click();
|
|
history.replaceState(null, '', location.pathname);
|
|
}
|
|
let loaded = false;
|
|
document.querySelector('.snav[data-panel="services"]')?.addEventListener('click', () => {
|
|
if (!loaded) { loaded = true; load(); initSocialCards(); }
|
|
});
|
|
})();
|
|
|
|
// Services: every linkable social as a card (sign in with the service -> verified handle),
|
|
// each with its own "Show on my profile" switch; Discord and Spotify keep their own cards and
|
|
// only get the switch. Data from /api/site/social/status.
|
|
async function initSocialCards() {
|
|
const host = document.getElementById('socialCards');
|
|
if (!host || !apiOn()) return;
|
|
let r = null;
|
|
try { r = await apiGet('/api/site/social/status'); } catch {}
|
|
if (!r || !r.ok) { host.innerHTML = '<p style="opacity:.7;margin-top:14px">Could not load your services.</p>'; return; }
|
|
const cards = (r.providers || []).filter(p => p.oauth);
|
|
host.innerHTML = cards.map(p => {
|
|
const status = !p.configured ? 'Not set up on this server yet'
|
|
: p.linked ? ('Connected as ' + (p.url ? '<a href="' + escapeHtml(p.url) + '" target="_blank" rel="noopener">' + escapeHtml(p.handle) + '</a>' : escapeHtml(p.handle)))
|
|
: 'Not connected';
|
|
return '<div class="discord-card svc-card svc-' + p.key + '" data-svc="' + p.key + '">'
|
|
+ '<div class="discord-logo" aria-hidden="true" style="color:#fff">' + (SOCIAL_ICONS[p.key] || '').replace('<svg ', '<svg width="26" height="26" ') + '</div>'
|
|
+ '<div class="discord-info"><div class="discord-title">' + escapeHtml(p.label) + '</div><div class="discord-status">' + status + '</div>'
|
|
+ (p.linked ? '<label class="svc-show" data-svc="' + p.key + '"><span>Show on my profile</span><span class="switch"><input type="checkbox" class="svc-show-toggle" data-svc="' + p.key + '"' + (p.show ? ' checked' : '') + '><span class="slider"></span></span></label>' : '')
|
|
+ '</div>'
|
|
+ (p.linked
|
|
? '<button class="discord-unlink-btn svc-unlink" data-svc="' + p.key + '">Unlink</button>'
|
|
: '<button class="discord-link-btn svc-link" data-svc="' + p.key + '"' + (p.configured ? '' : ' disabled') + '>Link ' + escapeHtml(p.label) + '</button>')
|
|
+ '</div>';
|
|
}).join('');
|
|
// Discord / Spotify switches (their cards are static in the HTML)
|
|
for (const p of (r.providers || []).filter(p => !p.oauth)) {
|
|
const row = document.querySelector('.svc-show[data-svc="' + p.key + '"]');
|
|
const box = row && row.querySelector('.svc-show-toggle');
|
|
if (row) row.hidden = !p.linked;
|
|
if (box) box.checked = !!p.show;
|
|
}
|
|
host.querySelectorAll('.svc-link').forEach(b => b.addEventListener('click', () => { window.location.href = '/api/site/social/' + b.dataset.svc + '/start'; }));
|
|
host.querySelectorAll('.svc-unlink').forEach(b => b.addEventListener('click', async () => {
|
|
const { ok } = await apiPost('/api/site/social/' + b.dataset.svc + '/unlink');
|
|
if (!ok) { toast('Could not unlink.'); return; }
|
|
toast('Unlinked'); initSocialCards();
|
|
}));
|
|
document.querySelectorAll('.svc-show-toggle').forEach(t => {
|
|
if (t.dataset.wired) return; t.dataset.wired = '1';
|
|
t.addEventListener('change', async () => {
|
|
const { ok } = await apiPost('/api/site/social/' + t.dataset.svc + '/show', { show: t.checked });
|
|
toast(ok ? (t.checked ? 'Shown on your profile' : 'Hidden from your profile') : 'Could not save.');
|
|
});
|
|
});
|
|
}
|
|
// Back from a service's sign-in: toast the outcome, open Services.
|
|
const sq2 = new URLSearchParams(location.search).get('social');
|
|
if (sq2) {
|
|
const [prov, outcome] = sq2.split(':');
|
|
const label = prov ? prov.charAt(0).toUpperCase() + prov.slice(1) : 'Service';
|
|
const msg = { linked: label + ' linked!', expired: 'Link timed out — try again.', denied: label + ' access was declined.',
|
|
inuse: 'That ' + label + ' account is already linked to another player.',
|
|
unconfigured: label + ' linking is not set up on this server yet.', error: 'Could not link ' + label + '.' }[outcome] || null;
|
|
if (msg) toast(msg);
|
|
document.querySelector('.snav[data-panel="services"]')?.click();
|
|
history.replaceState(null, '', location.pathname);
|
|
}
|
|
|
|
// save buttons — Profile now POSTs to the server (was localStorage-only, so @name / display
|
|
// name / bio never reached the game or another browser). The server persists them and pushes
|
|
// an AccountUpdate so the change shows in-game without a relog.
|
|
// Email panel: load the account's current address, save it to the server.
|
|
const acctEmail = document.getElementById('acctEmail');
|
|
if (acctEmail && apiOn()) {
|
|
(async () => {
|
|
try { const r = await apiGet('/api/site/me'); if (r && r.ok) { acctEmail.value = r.email || ''; acctEmail.dataset.saved = r.email || ''; } } catch {}
|
|
})();
|
|
}
|
|
|
|
document.querySelectorAll('.ssave').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
if (btn.dataset.save === 'Email' && acctEmail && apiOn()) {
|
|
const email = acctEmail.value.trim();
|
|
if (email === (acctEmail.dataset.saved || '')) { toast('Email settings saved'); return; }
|
|
btn.disabled = true;
|
|
const { ok, data } = await apiPost('/api/site/email', { email });
|
|
btn.disabled = false;
|
|
if (!ok) { toast((data && data.error) || 'Could not save your email.'); return; }
|
|
acctEmail.dataset.saved = data.email || '';
|
|
toast(email ? 'Email saved' : 'Email removed');
|
|
return;
|
|
}
|
|
if (btn.dataset.save !== 'Profile') { toast(`${btn.dataset.save} settings saved`); return; }
|
|
|
|
const payload = {};
|
|
if (username && username.value.trim()) payload.username = username.value.trim().replace(/[^a-zA-Z0-9_.]/g, '');
|
|
if (nick && nick.value.trim()) payload.display_name = nick.value.trim();
|
|
if (bio) payload.bio = bio.value.trim(); // bio can be cleared, so always send it
|
|
if (!Object.keys(payload).length) { toast('Nothing to change'); return; }
|
|
|
|
btn.disabled = true;
|
|
const { ok, data } = await apiPost('/api/site/profile', payload);
|
|
btn.disabled = false;
|
|
if (!ok) { toast((data && data.error) || 'Could not save your profile.'); return; }
|
|
|
|
// Reflect the SERVER's truth everywhere.
|
|
if (data.username) localStorage.setItem('campus-username', data.username);
|
|
if (data.display_name) localStorage.setItem('campus-mynick', data.display_name);
|
|
localStorage.setItem('campus-mybio', data.bio || '');
|
|
if (typeof CAMPUS_ME === 'object' && CAMPUS_ME) {
|
|
if (data.username) CAMPUS_ME.username = data.username;
|
|
if (data.display_name) CAMPUS_ME.display_name = data.display_name;
|
|
}
|
|
if (username) { username.value = ''; username.placeholder = data.username || myHandle(me); }
|
|
if (nick) { nick.value = ''; nick.placeholder = data.display_name || myDisplayName(me); }
|
|
if (suName) suName.textContent = data.display_name || myDisplayName(me);
|
|
if (suHandle) suHandle.textContent = '@' + (data.username || myHandle(me));
|
|
toast('Profile settings saved');
|
|
});
|
|
});
|
|
|
|
// logout actions
|
|
const logout = document.getElementById('setLogout');
|
|
if (logout) logout.addEventListener('click', async () => { if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} } clearIdentityCache(); window.location.href = '/'; });
|
|
const logoutAll = document.getElementById('logoutAll');
|
|
if (logoutAll) logoutAll.addEventListener('click', async () => { if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} } clearIdentityCache(); window.location.href = '/'; });
|
|
}
|
|
|
|
// ───── THEME (light/dark + accent) ─────
|
|
const THEME_KEY = 'campus-theme';
|
|
const ACCENT_KEY = 'campus-accent';
|
|
|
|
function hexRgb(h) { h = h.replace('#', ''); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); }
|
|
function darken(h, f) { const [r, g, b] = hexRgb(h); return `rgb(${r * f | 0},${g * f | 0},${b * f | 0})`; }
|
|
function softA(h, a) { const [r, g, b] = hexRgb(h); return `rgba(${r},${g},${b},${a})`; }
|
|
|
|
const THEMES = ['light', 'dark', 'autumn'];
|
|
function applyTheme(t) {
|
|
if (!THEMES.includes(t)) t = 'light';
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
document.querySelectorAll('.cd-mode').forEach(b => b.classList.toggle('active', b.dataset.theme === t));
|
|
autumnLeaves(t === 'autumn');
|
|
}
|
|
// Autumn: a few leaves drift down the home hero. Pure decoration; reduce-motion stops them via CSS.
|
|
function autumnLeaves(on) {
|
|
const hero = document.querySelector('.home-hero');
|
|
if (!hero) return;
|
|
let box = hero.querySelector('.autumn-leaves');
|
|
if (!on) { box?.remove(); return; }
|
|
if (box) return;
|
|
box = document.createElement('div'); box.className = 'autumn-leaves'; box.setAttribute('aria-hidden', 'true');
|
|
const LEAF = '<svg viewBox="0 0 24 24"><path d="M20 3c-7 0-13 3-16 9 3-1 6-1 9 0-4 1-7 3-9 8 6-2 12-4 14-9 1-2.5 2-5 2-8z"/></svg>';
|
|
for (let i = 0; i < 9; i++) {
|
|
const l = document.createElement('span'); l.innerHTML = LEAF;
|
|
l.style.setProperty('--x', (5 + Math.random() * 90).toFixed(1) + '%');
|
|
l.style.setProperty('--d', (9 + Math.random() * 9).toFixed(1) + 's');
|
|
l.style.setProperty('--delay', (-Math.random() * 14).toFixed(1) + 's');
|
|
l.style.setProperty('--s', (.6 + Math.random() * .7).toFixed(2));
|
|
l.style.setProperty('--c', ['#f08a3c', '#e0722e', '#c95a22', '#f2b04a', '#d94f5c'][i % 5]);
|
|
box.appendChild(l);
|
|
}
|
|
hero.appendChild(box);
|
|
}
|
|
function applyAccent(c) {
|
|
const s = document.documentElement.style;
|
|
s.setProperty('--red', c);
|
|
s.setProperty('--red-dark', darken(c, 0.82));
|
|
s.setProperty('--red-soft', softA(c, 0.15));
|
|
document.querySelectorAll('.cd-accent').forEach(b =>
|
|
b.classList.toggle('active', b.dataset.accent.toLowerCase() === c.toLowerCase()));
|
|
}
|
|
|
|
// apply saved choices (runs on every page so theme persists)
|
|
applyTheme(localStorage.getItem(THEME_KEY) || 'light');
|
|
const savedAccent = localStorage.getItem(ACCENT_KEY);
|
|
if (savedAccent) applyAccent(savedAccent);
|
|
|
|
document.querySelectorAll('.cd-mode').forEach(b =>
|
|
b.addEventListener('click', () => { const t = b.dataset.theme; localStorage.setItem(THEME_KEY, t); applyTheme(t); syncThemeToggles(); }));
|
|
document.querySelectorAll('.cd-accent').forEach(b =>
|
|
b.addEventListener('click', () => { const c = b.dataset.accent; localStorage.setItem(ACCENT_KEY, c); applyAccent(c); }));
|
|
|
|
// drawer Light/Dark toggle (under Discord)
|
|
const SUN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
|
const MOON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
|
const LEAF_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 3c-7 0-13 3-16 9 3-1 6-1 9 0-4 1-7 3-9 8 6-2 12-4 14-9 1-2.5 2-5 2-8z"/><path d="M4 20l9-9"/></svg>';
|
|
// The drawer button cycles light -> dark -> autumn and always names the NEXT theme.
|
|
function nextTheme() {
|
|
const cur = document.documentElement.getAttribute('data-theme') || 'light';
|
|
return THEMES[(THEMES.indexOf(cur) + 1) % THEMES.length];
|
|
}
|
|
function syncThemeToggles() {
|
|
const next = nextTheme();
|
|
const label = { light: `${SUN_SVG}<span>Light mode</span>`, dark: `${MOON_SVG}<span>Dark mode</span>`, autumn: `${LEAF_SVG}<span>Autumn mode</span>` }[next];
|
|
document.querySelectorAll('.cd-theme-toggle').forEach(btn => { btn.innerHTML = label; });
|
|
}
|
|
document.querySelectorAll('.cd-theme-toggle').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const next = nextTheme();
|
|
localStorage.setItem(THEME_KEY, next);
|
|
applyTheme(next);
|
|
syncThemeToggles();
|
|
});
|
|
});
|
|
syncThemeToggles();
|
|
|
|
// ───── ACCESSIBILITY PREFERENCES ─────
|
|
const A11Y_KEY = 'campus-a11y';
|
|
// map each toggle key to the <html> data-attribute it controls
|
|
const A11Y_ATTRS = {
|
|
motion: 'data-reduce-motion',
|
|
text: 'data-large-text',
|
|
contrast: 'data-high-contrast',
|
|
links: 'data-underline-links',
|
|
spacing: 'data-readable-spacing',
|
|
dyslexia: 'data-dyslexia-font',
|
|
focus: 'data-focus-rings',
|
|
cursor: 'data-big-cursor',
|
|
taptargets: 'data-tap-targets',
|
|
};
|
|
function getA11y() { try { return JSON.parse(localStorage.getItem(A11Y_KEY) || '{}'); } catch { return {}; } }
|
|
function applyA11y(prefs) {
|
|
const root = document.documentElement;
|
|
Object.keys(A11Y_ATTRS).forEach(k => root.toggleAttribute(A11Y_ATTRS[k], !!prefs[k]));
|
|
document.querySelectorAll('.cd-a11y').forEach(btn => {
|
|
const on = !!prefs[btn.dataset.a11y];
|
|
btn.classList.toggle('active', on);
|
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
});
|
|
}
|
|
applyA11y(getA11y());
|
|
document.querySelectorAll('.cd-a11y').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const prefs = getA11y();
|
|
const k = btn.dataset.a11y;
|
|
prefs[k] = !prefs[k];
|
|
localStorage.setItem(A11Y_KEY, JSON.stringify(prefs));
|
|
applyA11y(prefs);
|
|
syncToggleAll();
|
|
});
|
|
});
|
|
const a11yReset = document.getElementById('a11yReset');
|
|
if (a11yReset) a11yReset.addEventListener('click', () => {
|
|
localStorage.removeItem(A11Y_KEY);
|
|
applyA11y({});
|
|
syncToggleAll();
|
|
});
|
|
|
|
// "Enable all / Disable all" — flips every accessibility setting at once
|
|
const a11yToggleAll = document.getElementById('a11yToggleAll');
|
|
function allA11yOn() { const p = getA11y(); return Object.keys(A11Y_ATTRS).every(k => !!p[k]); }
|
|
function syncToggleAll() {
|
|
if (a11yToggleAll) a11yToggleAll.textContent = allA11yOn() ? 'Disable all' : 'Enable all';
|
|
}
|
|
if (a11yToggleAll) {
|
|
a11yToggleAll.addEventListener('click', () => {
|
|
const turnOn = !allA11yOn();
|
|
const prefs = {};
|
|
Object.keys(A11Y_ATTRS).forEach(k => { prefs[k] = turnOn; });
|
|
localStorage.setItem(A11Y_KEY, JSON.stringify(prefs));
|
|
applyA11y(prefs);
|
|
syncToggleAll();
|
|
});
|
|
syncToggleAll();
|
|
}
|
|
|
|
initSettings();
|
|
|
|
// ───── PHOTO FEED (API-driven) ─────
|
|
//
|
|
// Point this at your "recent photos" endpoint. It should return JSON like:
|
|
// [ { image, user, handle, room, cheers, comments, takenAt }, ... ]
|
|
// where `takenAt` is an ISO date string. Leave blank to use the fallback below.
|
|
const PHOTOS_API = ''; // e.g. 'https://api.campus.net/v1/photos/recent'
|
|
|
|
const postList = document.getElementById('postList');
|
|
|
|
// gradient palette for the author avatars (by first letter, just for colour)
|
|
const AV = [
|
|
['#ff5a3c', '#ffb142'], ['#f0453f', '#ff8a5c'], ['#ff6b9d', '#ffc371'],
|
|
['#e84393', '#ff7f6e'], ['#c44569', '#ff7f50'], ['#d63031', '#ff7675'],
|
|
];
|
|
function avColor(name) { return AV[(name.charCodeAt(0)) % AV.length]; }
|
|
function avKey(seed) { return (seed || 'player').toLowerCase().replace(/[^a-z0-9]/g, ''); }
|
|
function avatarUrl(seed) {
|
|
const key = avKey(seed);
|
|
try {
|
|
const pfp = JSON.parse(localStorage.getItem('campus-pfp-' + key) || 'null');
|
|
if (pfp && pfp.image) return pfp.image; // use the user's chosen profile picture
|
|
} catch {}
|
|
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(key)}`;
|
|
}
|
|
|
|
// Push the chosen profile image to the Campus server, which decodes it, stores it under
|
|
// Data/Images/player_images and points the account row at it. The optimistic UI already
|
|
// updated; this makes it stick (and makes it visible in-game).
|
|
//
|
|
// The old JSON {imageUrl} fallback is gone with server.py: that asked the backend to go
|
|
// fetch a URL on the browser's behalf, which is a server-side request forgery primitive.
|
|
// The server only accepts bytes it was handed.
|
|
async function syncProfileImageToApi(imageUrl) {
|
|
// Deliberately NOT gated on CAMPUS_ME: syncSession() is async, so a quick change right
|
|
// after page load would race it and silently drop the upload. Let the server decide -
|
|
// it answers 401 when there's no session and the catch below surfaces that.
|
|
if (!apiOn()) return; // offline demo mode
|
|
|
|
try {
|
|
const blob = await (await fetch(imageUrl)).blob();
|
|
const form = new FormData();
|
|
form.append('image', blob, 'profile.png');
|
|
|
|
const resp = await fetch(apiUrl('/api/profile-image'), {
|
|
method: 'POST',
|
|
credentials: 'same-origin', // carries the HttpOnly session cookie
|
|
body: form,
|
|
});
|
|
|
|
const out = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || !out.ok) {
|
|
toast('Saved locally, but the server update failed');
|
|
console.warn('[campus] profile image API error:', out.error || resp.status);
|
|
}
|
|
} catch (e) {
|
|
toast('Saved locally, but the server was unreachable');
|
|
console.warn('[campus] profile image API unreachable:', e);
|
|
}
|
|
}
|
|
|
|
// update every avatar image for a given user, live
|
|
function refreshAvatars(key, src) {
|
|
document.querySelectorAll(`img[data-av="${key}"]`).forEach(im => {
|
|
im.src = src;
|
|
im.style.objectFit = 'cover';
|
|
});
|
|
}
|
|
// cross-tab: when the profile picture changes in another tab, update here too
|
|
window.addEventListener('storage', e => {
|
|
if (!e.key || !e.key.startsWith('campus-pfp-')) return;
|
|
const key = e.key.slice('campus-pfp-'.length);
|
|
let src = null;
|
|
try { const d = JSON.parse(e.newValue || 'null'); if (d && d.image) src = d.image; } catch {}
|
|
if (!src) src = `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(key)}`;
|
|
refreshAvatars(key, src);
|
|
});
|
|
|
|
// "2 minutes ago", "3 hours ago", "5 days ago" - the profile's last-online line.
|
|
function lastOnlineText(iso) {
|
|
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
|
if (isNaN(s) || s < 0) return '';
|
|
if (s < 60) return 'just now';
|
|
const unit = (n, w) => `${n} ${w}${n === 1 ? '' : 's'} ago`;
|
|
if (s < 3600) return unit(Math.floor(s / 60), 'minute');
|
|
if (s < 86400) return unit(Math.floor(s / 3600), 'hour');
|
|
if (s < 86400 * 30) return unit(Math.floor(s / 86400), 'day');
|
|
if (s < 86400 * 365) return unit(Math.floor(s / (86400 * 30)), 'month');
|
|
return unit(Math.floor(s / (86400 * 365)), 'year');
|
|
}
|
|
function timeAgo(iso) {
|
|
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
|
if (isNaN(s)) return '';
|
|
if (s < 60) return s + 's';
|
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
|
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
|
return Math.floor(s / 86400) + 'd';
|
|
}
|
|
function fmt(n) { return n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : n; }
|
|
|
|
const HEART = '<svg viewBox="0 0 16 16" fill="none"><path d="M8 14s6-3.7 6-8a3 3 0 0 0-6-1.2A3 3 0 0 0 2 6c0 4.3 6 8 6 8Z" stroke="currentColor" stroke-width="1.5"/></svg>';
|
|
const CHEER_ICON = '<svg class="cheer-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10v11H3V10zM7 10l4.2-6.6a1.6 1.6 0 0 1 2.9 1.2L13.4 9H19a2 2 0 0 1 2 2.4l-1.5 7.5A2.5 2.5 0 0 1 17 21H7"/></svg>';
|
|
const CHAT = '<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10v7H6l-3 2.5V4Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
|
|
// basic profanity filter
|
|
const BANNED = ['fuck','shit','bitch','asshole','bastard','cunt','dick','piss','slut','whore','nigger','nigga','faggot','retard','rape','cock','pussy','wanker','prick','twat'];
|
|
function isInappropriate(text) {
|
|
const t = text.toLowerCase();
|
|
const stripped = t.replace(/[^a-z]/g, ''); // catches sp4ced / punctuated evasions
|
|
return BANNED.some(w => new RegExp(`\\b${w}\\b`, 'i').test(t) || stripped.includes(w));
|
|
}
|
|
function commentRow(c) {
|
|
const me = localStorage.getItem(USER_KEY);
|
|
const mine = !!c.mine || (me && me.toLowerCase() === (c.handle || c.user || '').toLowerCase());
|
|
const action = mine
|
|
? `<button class="comment-del" title="Delete comment" aria-label="Delete comment"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4h11M6 4V2.7h4V4M5 4l.4 8.4a1 1 0 0 0 1 .9h3.2a1 1 0 0 0 1-.9L11 4"/></svg></button>`
|
|
: `<button class="comment-report" title="Report comment" aria-label="Report comment"><svg viewBox="0 0 16 16" fill="none"><path d="M3 14V3M3 3h8l-1.5 3L11 9H3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`;
|
|
const h = String(c.handle || c.user || 'player').replace(/^@/, '');
|
|
return `<div class="comment"${c.id != null ? ` data-cid="${c.id}"` : ''}>
|
|
<a class="comment-av-link" href="${userUrl(h)}" aria-label="@${escapeHtml(h)}"><img class="comment-av" data-av="${avKey(h)}" src="${c.avatar || avatarUrl(h)}" alt=""></a>
|
|
<div class="comment-body"><a class="comment-user" href="${userUrl(h)}">@${escapeHtml(h)}</a> ${escapeHtml(c.text)}</div>
|
|
${action}
|
|
</div>`;
|
|
}
|
|
function serverComment(c) {
|
|
const me = (localStorage.getItem(USER_KEY) || '').toLowerCase();
|
|
return { id: c.id, user: c.display_name || c.username, handle: c.username, avatar: c.profile_image, text: c.text,
|
|
mine: (c.username || '').toLowerCase() === me };
|
|
}
|
|
async function loadCommentsInto(box, photoId) {
|
|
const list = box.querySelector('.comment-list');
|
|
if (!list || !photoId || !apiOn()) return;
|
|
const d = await apiGet('/api/site/photos/' + photoId + '/comments');
|
|
if (!d || !d.ok) return;
|
|
const rows = (d.comments || []).map(serverComment);
|
|
list.innerHTML = rows.map(commentRow).join('') || '<p class="no-comments">No comments yet - be the first!</p>';
|
|
list.dataset.loaded = '1';
|
|
}
|
|
function commentsBlock(p) {
|
|
const me = localStorage.getItem(USER_KEY);
|
|
if (!me) {
|
|
return `<div class="post-comments" hidden><a class="comment-login" href="/login">Log in to view and add comments</a></div>`;
|
|
}
|
|
const list = (p.thread || []).map(commentRow).join('') || '<p class="no-comments">No comments yet - be the first!</p>';
|
|
return `<div class="post-comments" hidden>
|
|
<div class="comment-list">${list}</div>
|
|
<form class="comment-form">
|
|
<img class="comment-av" data-av="${avKey(me)}" src="${avatarUrl(me)}" alt="">
|
|
<input class="comment-input" type="text" placeholder="Add a comment…" maxlength="200" autocomplete="off">
|
|
<button type="submit" class="comment-send">Post</button>
|
|
</form>
|
|
<p class="comment-error"></p>
|
|
</div>`;
|
|
}
|
|
|
|
function renderPosts(posts) {
|
|
if (!postList) return;
|
|
if (!posts.length) { postList.innerHTML = '<div class="feed-loading">No photos yet - check back soon.</div>'; return; }
|
|
postList.innerHTML = posts.map(p => {
|
|
const [a, b] = avColor(p.user || '?');
|
|
const ph = (p.handle || p.user || 'player');
|
|
const av = p.avatar || avatarUrl(ph); // real profile_image when the API provides one
|
|
return `<article class="post"${p.id != null && /^\d+$/.test(String(p.id)) ? ` data-photo="${p.id}"` : ''}>
|
|
<div class="post-head">
|
|
<a class="post-av" href="${userUrl(ph)}" style="--a:${a};--b:${b}"><img data-av="${avKey(ph)}" src="${av}" alt="" onerror="this.remove()">${(p.user || '?')[0].toUpperCase()}</a>
|
|
<div class="post-meta">
|
|
<a class="post-user" href="${userUrl(ph)}">${escapeHtml(p.user || 'Anonymous')}</a>
|
|
<span class="post-handle">@${escapeHtml(p.handle || 'player')}${p.room ? ` in <a href="#">^${escapeHtml(p.room)}</a>` : ''}</span>
|
|
</div>
|
|
<span class="post-time">${p.takenAt ? timeAgo(p.takenAt) : (p.time || '')}</span>
|
|
</div>
|
|
<a class="post-img" href="${imageUrl(p.id || '')}" style="background-image:url('${p.image}')"></a>
|
|
<div class="post-foot">
|
|
<span class="post-cap">${p.caption || ''}</span>
|
|
<div class="post-stats">
|
|
<button class="pstat cheer${p.cheered ? ' liked' : ''}" data-n="${(p.cheers || 0) - (p.cheered ? 1 : 0)}">${CHEER_ICON}<span>${fmt(p.cheers || 0)}</span></button>
|
|
<button class="pstat comment-toggle" data-n="${nComments(p)}">${CHAT}<span>${nComments(p)}</span></button>
|
|
</div>
|
|
</div>
|
|
${commentsBlock(p)}
|
|
</article>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Comment count: the live feed sends a number, the demo data carries a thread array.
|
|
function nComments(p) {
|
|
return (typeof p.comments === 'number') ? p.comments : (p.thread || []).length;
|
|
}
|
|
|
|
// Fallback content used until the API is wired up. Each photo is a different
|
|
// crop/zoom of the game art so the feed doesn't look like one repeated image.
|
|
const FALLBACK_POSTS = [
|
|
{ id: 'clutch-round', user: 'Clawful', handle: 'clawful', room: 'Paws Paintball', title: 'Clutch round on Paws Paintball 🎯', caption: 'gg everyone 🎯', cheers: 1284, comments: 86, takenAt: minsAgo(4), crop: '8% 42% / 220%' },
|
|
{ id: 'midnight-build', user: 'Velvetpaw', handle: 'velvetpaw', room: 'Midnight Mews', title: 'Late night build session 🌙', caption: 'late night session 🌙', cheers: 842, comments: 37, takenAt: minsAgo(22), crop: '38% 50% / 240%' },
|
|
{ id: 'contraption', user: 'Tinker9', handle: 'tinker9', room: 'Litterbox Lab', title: 'New contraption just dropped', caption: 'new contraption just dropped', cheers: 2613, comments: 158, takenAt: minsAgo(63), crop: '66% 55% / 230%' },
|
|
{ id: 'final-boss', user: 'Stitchwitch', handle: 'stitchwitch', room: 'Yarn Quest', title: 'Finally beat the final boss 🧶', caption: 'finally beat the final boss 🧶', cheers: 4097, comments: 289, takenAt: minsAgo(190), crop: '94% 48% / 250%' },
|
|
];
|
|
function minsAgo(m) { return new Date(Date.now() - m * 60000).toISOString(); }
|
|
|
|
// look up a photo by id (feed photos, or profile photos encoded as "user__letter")
|
|
function getPhoto(id) {
|
|
if (!id) return null;
|
|
const feed = FALLBACK_POSTS.find(p => p.id === id);
|
|
if (feed) {
|
|
const [pos, size] = feed.crop.split(' / ');
|
|
return { user: feed.user, handle: feed.handle, image: 'assets/hero-bg.jpg', pos, size, title: feed.title, takenAt: feed.takenAt, cheers: feed.cheers };
|
|
}
|
|
if (id.includes('__')) {
|
|
const [u, letter] = id.split('__');
|
|
const up = (USER_PHOTOS[u] || []).find(x => x.id === letter);
|
|
if (up) return { user: cap(u), handle: u, image: up.image, pos: 'center', size: 'cover', title: up.title, takenAt: null, cheers: 0 };
|
|
const pp = PROFILE_PHOTOS.find(x => x.id === letter);
|
|
if (pp) return { user: cap(u), handle: u, image: 'assets/hero-bg.jpg', pos: pp.pos, size: pp.size, title: 'Untitled photo', takenAt: null, cheers: 0 };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// special renderer for the fallback so we can apply the per-post crop
|
|
function renderFallback(posts) {
|
|
if (!postList) return;
|
|
postList.innerHTML = posts.map(p => {
|
|
const [a, b] = avColor(p.user);
|
|
const [pos, size] = p.crop.split(' / ');
|
|
return `<article class="post">
|
|
<div class="post-head">
|
|
<a class="post-av" href="${userUrl(p.handle)}" style="--a:${a};--b:${b}"><img data-av="${avKey(p.handle)}" src="${avatarUrl(p.handle)}" alt="" onerror="this.remove()">${p.user[0]}</a>
|
|
<div class="post-meta">
|
|
<a class="post-user" href="${userUrl(p.handle)}">${p.user}</a>
|
|
<span class="post-handle">@${p.handle} in <a href="#">^${p.room}</a></span>
|
|
</div>
|
|
<span class="post-time">${timeAgo(p.takenAt)}</span>
|
|
</div>
|
|
<a class="post-img" href="${imageUrl(p.id)}" style="background-image:url('assets/hero-bg.jpg');background-position:${pos};background-size:${size}"></a>
|
|
<div class="post-foot">
|
|
<span class="post-cap">${p.caption}</span>
|
|
<div class="post-stats">
|
|
<button class="pstat cheer" data-n="${p.cheers}">${CHEER_ICON}<span>${fmt(p.cheers)}</span></button>
|
|
<button class="pstat comment-toggle" data-n="${(p.thread || []).length}">${CHAT}<span>${(p.thread || []).length}</span></button>
|
|
</div>
|
|
</div>
|
|
${commentsBlock(p)}
|
|
</article>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function loadFeed() {
|
|
if (!postList) return;
|
|
// Real, newest-first photos from the Campus server (/api/site/photos/recent is Public +
|
|
// ShareCamera + sorted newest-first). Falls back to the demo posts only when the API is off
|
|
// or unreachable.
|
|
if (apiOn()) {
|
|
const data = await apiGet('/api/site/photos/recent?take=24');
|
|
if (data && data.ok && Array.isArray(data.results)) {
|
|
const posts = data.results.map(r => ({
|
|
id: r.id,
|
|
image: r.image,
|
|
user: (r.author && (r.author.display_name || r.author.username)) || 'Player',
|
|
handle: (r.author && r.author.username) || 'player',
|
|
avatar: r.author && r.author.profile_image,
|
|
caption: r.description || '',
|
|
cheers: r.cheers || 0,
|
|
cheered: !!r.cheered,
|
|
comments: r.comments || 0,
|
|
takenAt: r.createdAt,
|
|
}));
|
|
renderPosts(posts);
|
|
return;
|
|
}
|
|
}
|
|
renderFallback(FALLBACK_POSTS);
|
|
}
|
|
loadFeed();
|
|
renderImage(); // image.html - runs here so FALLBACK_POSTS/getPhoto are ready
|
|
// NOTE: renderOnlineFriends() is intentionally NOT called - hydrateOnline() (below) is the real
|
|
// one. It shows only YOUR online friends when signed in (not every online player), and writes the
|
|
// same .rail-online section, so calling both produced a duplicate "Online Friends" list.
|
|
renderPinned(); // home - pinned friends widget
|
|
initFeaturedSlideshow(); // home - featured rooms slideshow (demo first, then real below)
|
|
|
|
// Featured Rooms slideshow: shows the SAME admin-curated featured rooms the game shows
|
|
// (/api/site/rooms/featured). Falls back to hot rooms only when nothing is featured yet, so the
|
|
// showcase is never empty. Change what's featured in the admin panel and it updates both here
|
|
// and in-game.
|
|
(async function loadFeatured() {
|
|
if (!apiOn() || !document.getElementById('featuredSlideshow')) return;
|
|
const map = r => ({
|
|
id: r.id, name: r.name, creator: r.creator || 'someone',
|
|
image: r.image, players: r.players || 0, visits: r.visits || 0,
|
|
});
|
|
|
|
let data = await apiGet('/api/site/rooms/featured?take=8');
|
|
if (data && data.ok && Array.isArray(data.results) && data.results.length) {
|
|
initFeaturedSlideshow(data.results.map(map));
|
|
return;
|
|
}
|
|
// Nothing featured — fall back to the hottest rooms so the showcase still has content.
|
|
data = await apiGet('/api/site/rooms/hot?take=6');
|
|
if (data && data.ok && Array.isArray(data.results) && data.results.length) {
|
|
initFeaturedSlideshow(data.results.map(map));
|
|
}
|
|
})();
|
|
|
|
// ───── SEARCH PANEL ─────
|
|
const searchBtn = document.getElementById('searchBtn');
|
|
const searchPanel = document.getElementById('searchPanel');
|
|
const searchScrim = document.getElementById('searchScrim');
|
|
const searchInput = document.getElementById('searchInput');
|
|
const searchResults = document.getElementById('searchResults');
|
|
|
|
const PEOPLE = [
|
|
{ name: 'Clawful', sub: '@clawful · 12.4k subscribers', a: '#ff5a3c', b: '#ffb142' },
|
|
{ name: 'Velvetpaw', sub: '@velvetpaw · 8.1k subscribers', a: '#6c5ce7', b: '#a29bfe' },
|
|
{ name: 'Tinker9', sub: '@tinker9 · 3.3k subscribers', a: '#00b894', b: '#55efc4' },
|
|
{ name: 'Stitchwitch', sub: '@stitchwitch · 21k subscribers', a: '#e84393', b: '#fd79a8' },
|
|
{ name: 'Bubbles', sub: '@bubbles · 970 subscribers', a: '#0984e3', b: '#74b9ff' },
|
|
{ name: 'SpookyTom', sub: '@spooky.tom · 45k subscribers', a: '#d63031', b: '#ff7675' },
|
|
{ name: 'Mittens', sub: '@mittens · 1.2k subscribers', a: '#fdcb6e', b: '#ff7675' },
|
|
{ name: 'Pounce', sub: '@pounce · 660 subscribers', a: '#00cec9', b: '#81ecec' },
|
|
];
|
|
|
|
const ROOMS = [
|
|
{ name: 'Whisker Heights', sub: 'by @clawful · 67k visits', a: '#ff5a3c', b: '#7b2ff7' },
|
|
{ name: 'Paws Paintball', sub: 'by @clawful · 48k visits', a: '#ff5a3c', b: '#ffb142' },
|
|
{ name: 'Midnight Mews', sub: 'by @velvetpaw · 31k visits', a: '#6c5ce7', b: '#a29bfe' },
|
|
{ name: 'The Litterbox Lab', sub: 'by @tinker9 · 22k visits', a: '#00b894', b: '#55efc4' },
|
|
{ name: 'Yarn Quest', sub: 'by @stitchwitch · 67k visits', a: '#e84393', b: '#fd79a8' },
|
|
{ name: 'Deep Dive Reef', sub: 'by @bubbles · 19k visits', a: '#0984e3', b: '#74b9ff' },
|
|
{ name: 'Haunted Cattery', sub: 'by @spooky.tom · 91k visits', a: '#d63031', b: '#ff7675' },
|
|
];
|
|
|
|
let activeTab = 'people';
|
|
|
|
function searchRow(m) {
|
|
if (activeTab === 'people') {
|
|
const handle = m.handle || m.name.toLowerCase();
|
|
const av = m.avatar || avatarUrl(handle);
|
|
return `<a href="${userUrl(handle)}" class="sresult">
|
|
<div class="sresult-avatar" style="--a:${m.a};--b:${m.b};position:relative;overflow:hidden"><img data-av="${avKey(handle)}" src="${av}" alt="" onerror="this.remove()">${escapeHtml(m.name[0] || '?')}</div>
|
|
<div class="sresult-text"><span class="sresult-name">${escapeHtml(m.name)}</span><span class="sresult-sub">${escapeHtml(m.sub)}</span></div>
|
|
<svg class="sresult-arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg>
|
|
</a>`;
|
|
}
|
|
// Link rooms by NAME (how they're shared/searched), falling back to the id. The server
|
|
// resolves /room/{slug} and /api/site/rooms/{slug} by name first.
|
|
const roomSlug = m.name || m.roomId;
|
|
const href = roomSlug ? `room.html?id=${encodeURIComponent(roomSlug)}` : '#';
|
|
const thumbStyle = m.img
|
|
? `background-image:url('${m.img}');background-size:cover;background-position:center`
|
|
: `--a:${m.a};--b:${m.b}`;
|
|
return `<a href="${href}" class="sresult">
|
|
<div class="sresult-thumb" style="${thumbStyle}"></div>
|
|
<div class="sresult-text"><span class="sresult-name">${escapeHtml(m.name)}</span><span class="sresult-sub">${escapeHtml(m.sub)}</span></div>
|
|
<svg class="sresult-arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg>
|
|
</a>`;
|
|
}
|
|
|
|
// Empty/idle state only. Real results come from liveSearch(); there are no demo placeholders.
|
|
function renderResults() {
|
|
if (!searchResults) return;
|
|
const q = searchInput.value.trim();
|
|
if (searchClear) searchClear.style.display = q ? 'flex' : 'none';
|
|
if (!q) {
|
|
searchResults.innerHTML = `<p class="search-empty">Start typing to search ${activeTab === 'people' ? 'players' : 'rooms'}.</p>`;
|
|
return;
|
|
}
|
|
searchResults.innerHTML = apiOn()
|
|
? '<p class="search-empty">Searching…</p>'
|
|
: '<p class="search-empty">Search is unavailable right now.</p>';
|
|
}
|
|
|
|
// normalise a raw API result object into the shape searchRow() expects
|
|
function normalizeResult(r) {
|
|
const imgBase = (window.CAMPUS_DATA && window.CAMPUS_DATA.imgBase) || '/img/';
|
|
const toUrl = raw => raw ? (/^https?:|^\//.test(raw) ? raw : imgBase + raw) : '';
|
|
if (activeTab === 'people') {
|
|
// The site search returns camelCase: { accountId, username, displayName, profileImage }.
|
|
// (This used to read PascalCase, so every real player came back as "Unknown".)
|
|
const handle = r.username || '';
|
|
const name = r.displayName || handle || 'Unknown';
|
|
return { name, sub: handle ? '@' + handle : '', handle, accountId: r.accountId, avatar: toUrl(r.profileImage), a: '#ff5a3c', b: '#ffb142' };
|
|
}
|
|
// Rooms come back PascalCase: { RoomId, Name, ImageName, Stats:{ VisitCount } }.
|
|
const name = r.Name || 'Untitled room';
|
|
const st = r.Stats || {};
|
|
const sub = st.VisitCount != null ? fmt(st.VisitCount) + ' visits' : '';
|
|
return { name, sub, roomId: r.RoomId, img: toUrl(r.ImageName), a: '#ff5a3c', b: '#7b2ff7' };
|
|
}
|
|
|
|
// live search against the Campus server (SiteController reads RoomDB/PlayerDB directly)
|
|
let searchTimer = null;
|
|
async function liveSearch(q) {
|
|
const path = activeTab === 'people'
|
|
? `/api/search/players?name=${encodeURIComponent(q)}`
|
|
: `/api/search/rooms?query=${encodeURIComponent(q)}`;
|
|
try {
|
|
const resp = await fetch(apiUrl(path), { credentials: 'same-origin' });
|
|
const out = await resp.json();
|
|
if (!out.ok) throw new Error(out.error || 'search failed');
|
|
let list = out.results;
|
|
if (list && !Array.isArray(list)) list = list.Results || list.results || list.Accounts || list.Rooms || [];
|
|
list = (list || []).map(normalizeResult);
|
|
if (!list.length) { searchResults.innerHTML = '<p class="search-empty">No results found</p>'; return; }
|
|
searchResults.innerHTML = list.map(searchRow).join('');
|
|
} catch (e) {
|
|
console.warn('[campus] live search failed:', e);
|
|
searchResults.innerHTML = '<p class="search-empty">Search failed - try again.</p>';
|
|
}
|
|
}
|
|
|
|
// Real search only - no demo fallback.
|
|
function handleSearchInput() {
|
|
const q = searchInput.value.trim();
|
|
if (searchClear) searchClear.style.display = q ? 'flex' : 'none';
|
|
clearTimeout(searchTimer);
|
|
if (!q) { renderResults(); return; } // empty -> hint
|
|
if (!apiOn()) { searchResults.innerHTML = '<p class="search-empty">Search is unavailable right now.</p>'; return; }
|
|
searchResults.innerHTML = '<p class="search-empty">Searching…</p>';
|
|
searchTimer = setTimeout(() => liveSearch(q), 250);
|
|
}
|
|
|
|
function openSearch() {
|
|
searchPanel.classList.add('open');
|
|
searchPanel.inert = false;
|
|
searchScrim.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
renderResults();
|
|
setTimeout(() => searchInput.focus(), 50);
|
|
}
|
|
function closeSearch() {
|
|
const wasOpen = searchPanel.classList.contains('open');
|
|
searchPanel.classList.remove('open');
|
|
searchPanel.inert = true;
|
|
searchScrim.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
if (wasOpen && searchBtn) searchBtn.focus(); // return focus to the trigger
|
|
}
|
|
|
|
let searchClear = null;
|
|
|
|
if (searchPanel) searchPanel.inert = !searchPanel.classList.contains('open'); // closed by default
|
|
if (searchBtn) {
|
|
// inject a clear (✕) button into the search field
|
|
const field = searchInput.parentElement;
|
|
if (field) {
|
|
searchClear = document.createElement('button');
|
|
searchClear.className = 'search-clear';
|
|
searchClear.type = 'button';
|
|
searchClear.setAttribute('aria-label', 'Clear');
|
|
searchClear.innerHTML = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
|
|
searchClear.style.display = 'none';
|
|
field.appendChild(searchClear);
|
|
searchClear.addEventListener('click', () => { searchInput.value = ''; handleSearchInput(); searchInput.focus(); });
|
|
}
|
|
|
|
searchBtn.addEventListener('click', () => {
|
|
searchPanel.classList.contains('open') ? closeSearch() : openSearch();
|
|
});
|
|
searchScrim.addEventListener('click', closeSearch);
|
|
searchInput.addEventListener('input', handleSearchInput);
|
|
|
|
document.querySelectorAll('.stab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelector('.stab.active')?.classList.remove('active');
|
|
tab.classList.add('active');
|
|
activeTab = tab.dataset.tab;
|
|
searchInput.placeholder = activeTab === 'people' ? 'Search people…' : 'Search rooms…';
|
|
handleSearchInput();
|
|
});
|
|
});
|
|
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSearch(); });
|
|
|
|
// click the dimmed background (outside the card) to close
|
|
searchPanel.addEventListener('click', e => {
|
|
if (e.target === searchPanel) closeSearch();
|
|
});
|
|
}
|
|
|
|
// ───── FAQ ACCORDION ─────
|
|
document.querySelectorAll('.dl-faq .faq-q').forEach(q => {
|
|
q.addEventListener('click', () => q.closest('.faq-item').classList.toggle('open'));
|
|
});
|
|
|
|
// ───── RATE LIMITING + TOAST ─────
|
|
// returns true if the action is allowed, false if the limit is hit
|
|
function rateLimit(action, max, windowMs) {
|
|
const key = 'campus-rl-' + action;
|
|
const now = Date.now();
|
|
let arr = [];
|
|
try { arr = JSON.parse(localStorage.getItem(key) || '[]'); } catch { arr = []; }
|
|
arr = arr.filter(t => now - t < windowMs);
|
|
if (arr.length >= max) { localStorage.setItem(key, JSON.stringify(arr)); return false; }
|
|
arr.push(now);
|
|
localStorage.setItem(key, JSON.stringify(arr));
|
|
return true;
|
|
}
|
|
|
|
function toast(msg) {
|
|
let t = document.getElementById('toast');
|
|
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.className = 'toast'; document.body.appendChild(t); }
|
|
t.textContent = msg;
|
|
t.classList.add('show');
|
|
clearTimeout(t._timer);
|
|
t._timer = setTimeout(() => t.classList.remove('show'), 2800);
|
|
}
|
|
|
|
// returns true if logged in; otherwise prompts to log in
|
|
function requireLogin(action) {
|
|
if (localStorage.getItem(USER_KEY)) return true;
|
|
toast(`Log in to ${action}`);
|
|
return false;
|
|
}
|
|
|
|
// ───── REPORT SYSTEM ─────
|
|
const REPORT_REASONS = ['Spam or scam', 'Harassment or bullying', 'Inappropriate content', 'Hate speech', 'Impersonation', 'Other'];
|
|
|
|
function closeReport() {
|
|
document.getElementById('reportModal')?.classList.remove('open');
|
|
document.getElementById('reportScrim')?.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// ctx = {type: 'player'|'photo'|'comment', id} - what is being reported. With the API on,
|
|
// the report is filed to the server (it lands in the admin panel's Reports queue); the
|
|
// onSubmit callback only updates the button that opened it.
|
|
function openReport(title, onSubmit, ctx) {
|
|
if (ctx && apiOn() && !localStorage.getItem(USER_KEY)) { toast('Log in to send a report'); return; }
|
|
let scrim = document.getElementById('reportScrim');
|
|
let modal = document.getElementById('reportModal');
|
|
if (!modal) {
|
|
scrim = document.createElement('div'); scrim.id = 'reportScrim'; scrim.className = 'report-scrim';
|
|
modal = document.createElement('div'); modal.id = 'reportModal'; modal.className = 'report-modal';
|
|
document.body.appendChild(scrim); document.body.appendChild(modal);
|
|
scrim.addEventListener('click', closeReport);
|
|
// click the dimmed area outside the card to close
|
|
modal.addEventListener('click', e => { if (e.target === modal) closeReport(); });
|
|
}
|
|
|
|
modal.innerHTML = `
|
|
<div class="report-card">
|
|
<button class="report-close" aria-label="Close">✕</button>
|
|
<h2 class="report-title">${title}</h2>
|
|
<p class="report-sub">Tell us what's wrong. Reports are anonymous.</p>
|
|
<div class="report-reasons">
|
|
${REPORT_REASONS.map(r => `<button type="button" class="report-reason" data-r="${r}">${r}</button>`).join('')}
|
|
</div>
|
|
<textarea class="report-details" placeholder="Add more details (optional)" maxlength="300"></textarea>
|
|
<div class="report-actions">
|
|
<button class="report-cancel">Cancel</button>
|
|
<button class="report-submit" disabled>Submit report</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
const card = modal.querySelector('.report-card');
|
|
let selected = null;
|
|
card.querySelector('.report-reasons').addEventListener('click', e => {
|
|
const b = e.target.closest('.report-reason');
|
|
if (!b) return;
|
|
card.querySelectorAll('.report-reason').forEach(x => x.classList.remove('sel'));
|
|
b.classList.add('sel');
|
|
selected = b.dataset.r;
|
|
card.querySelector('.report-submit').disabled = false;
|
|
});
|
|
card.querySelector('.report-close').addEventListener('click', closeReport);
|
|
card.querySelector('.report-cancel').addEventListener('click', closeReport);
|
|
card.querySelector('.report-submit').addEventListener('click', async () => {
|
|
if (!rateLimit('report', 5, 10 * 60 * 1000)) {
|
|
toast('You\'ve sent too many reports. Please try again later.');
|
|
closeReport();
|
|
return;
|
|
}
|
|
const submit = card.querySelector('.report-submit');
|
|
if (ctx && apiOn()) {
|
|
submit.disabled = true; submit.textContent = 'Sending…';
|
|
const r = await apiPost('/api/site/report', { type: ctx.type, id: String(ctx.id), photoId: ctx.photoId ? Number(ctx.photoId) : null, reason: selected, details: card.querySelector('.report-details').value.trim() });
|
|
if (!r || !r.ok) {
|
|
submit.disabled = false; submit.textContent = 'Submit report';
|
|
toast((r && r.data && r.data.error) || 'Could not send the report');
|
|
return;
|
|
}
|
|
}
|
|
if (typeof onSubmit === 'function') onSubmit(selected);
|
|
card.innerHTML = `
|
|
<div class="report-done">
|
|
<div class="report-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7"/></svg></div>
|
|
<h2 class="report-title">Report submitted</h2>
|
|
<p class="report-sub">Thanks - our moderation team will review this shortly.</p>
|
|
<button class="report-submit report-ok">Done</button>
|
|
</div>`;
|
|
card.querySelector('.report-ok').addEventListener('click', closeReport);
|
|
});
|
|
|
|
modal.classList.add('open');
|
|
scrim.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
// ───── NICKNAME MODAL ─────
|
|
function closeNick() {
|
|
document.getElementById('nickModal')?.classList.remove('open');
|
|
document.getElementById('nickScrim')?.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
function openNickname(handle, onChange, ctx) {
|
|
let scrim = document.getElementById('nickScrim');
|
|
let modal = document.getElementById('nickModal');
|
|
if (!modal) {
|
|
scrim = document.createElement('div'); scrim.id = 'nickScrim'; scrim.className = 'report-scrim';
|
|
modal = document.createElement('div'); modal.id = 'nickModal'; modal.className = 'report-modal';
|
|
document.body.appendChild(scrim); document.body.appendChild(modal);
|
|
scrim.addEventListener('click', closeNick);
|
|
modal.addEventListener('click', e => { if (e.target === modal) closeNick(); });
|
|
}
|
|
const current = ctx ? (ctx.current || '') : ((getFriends()[handle] || {}).nickname || '');
|
|
modal.innerHTML = `
|
|
<div class="report-card">
|
|
<button class="report-close" aria-label="Close">✕</button>
|
|
<h2 class="report-title">Nickname</h2>
|
|
<p class="report-sub">Set a custom nickname for @${handle}.</p>
|
|
<input class="nick-input" type="text" maxlength="24" placeholder="Enter a nickname" value="${escapeHtml(current)}">
|
|
<div class="report-actions">
|
|
<button class="report-cancel nick-remove">Remove nickname</button>
|
|
<button class="report-submit nick-set">Set nickname</button>
|
|
</div>
|
|
</div>`;
|
|
const card = modal.querySelector('.report-card');
|
|
const input = card.querySelector('.nick-input');
|
|
card.querySelector('.report-close').addEventListener('click', closeNick);
|
|
async function apply(value) {
|
|
if (ctx) {
|
|
const { ok, data } = await apiPost('/api/site/friends/nickname', { id: ctx.id, nickname: value });
|
|
if (!ok) { toast((data && data.error) || 'Could not save the nickname'); return; }
|
|
if (ctx.onSaved) ctx.onSaved(data.nickname || '');
|
|
toast(data.nickname ? `Nickname set to ${data.nickname}` : 'Nickname removed');
|
|
} else {
|
|
setFriendNick(handle, value);
|
|
}
|
|
closeNick();
|
|
if (onChange) onChange();
|
|
}
|
|
card.querySelector('.nick-set').addEventListener('click', () => apply(input.value.trim()));
|
|
card.querySelector('.nick-remove').addEventListener('click', () => apply(''));
|
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') apply(input.value.trim()); });
|
|
modal.classList.add('open');
|
|
scrim.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
setTimeout(() => input.focus(), 50);
|
|
}
|
|
|
|
// ───── FRIENDS LIST (own profile) ─────
|
|
// deterministic fake presence so each friend has a stable online/last-seen value
|
|
function friendPresence(h) {
|
|
let n = 0; for (const c of String(h)) n = (n * 31 + c.charCodeAt(0)) >>> 0;
|
|
const online = n % 3 === 0;
|
|
const buckets = ['just now', '5 min', '12 min', '1 hour', '3 hours', '8 hours', '1 day', '2 days', '4 days', '1 week'];
|
|
return { online, last: buckets[n % buckets.length] };
|
|
}
|
|
function closeFriendsList() {
|
|
document.getElementById('friendsModal')?.classList.remove('open');
|
|
document.getElementById('friendsScrim')?.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
// The friends list is REAL: /api/site/profile/{handle}/friends (All / Favourites, plus
|
|
// Requests when it's your own). Presence is the game's own (online = connected to the hub),
|
|
// favourites are the same flag the in-game star sets, and it re-renders itself when the
|
|
// server pushes a refresh (a friend comes online, a request arrives, a star is toggled in-game).
|
|
let FRIENDS_MODAL_HANDLE = null;
|
|
function openFriendsList(handle) {
|
|
let scrim = document.getElementById('friendsScrim');
|
|
let modal = document.getElementById('friendsModal');
|
|
if (!modal) {
|
|
scrim = document.createElement('div'); scrim.id = 'friendsScrim'; scrim.className = 'report-scrim';
|
|
modal = document.createElement('div'); modal.id = 'friendsModal'; modal.className = 'report-modal';
|
|
document.body.appendChild(scrim); document.body.appendChild(modal);
|
|
scrim.addEventListener('click', closeFriendsList);
|
|
modal.addEventListener('click', e => { if (e.target === modal) closeFriendsList(); });
|
|
}
|
|
const me = localStorage.getItem(USER_KEY);
|
|
FRIENDS_MODAL_HANDLE = (handle || viewedHandle() || me || '').toLowerCase();
|
|
let filter = 'all';
|
|
let cache = null;
|
|
|
|
async function load() {
|
|
let r = null;
|
|
try { if (apiOn()) r = await apiGet('/api/site/profile/' + encodeURIComponent(FRIENDS_MODAL_HANDLE) + '/friends'); } catch {}
|
|
cache = (r && r.ok) ? r : { own: false, friends: [], pending: [], sent: [] };
|
|
render();
|
|
}
|
|
|
|
function row(f, pending) {
|
|
const name = escapeHtml(f.display_name || f.username);
|
|
const av = f.profile_image || avatarUrl(f.username);
|
|
const star = f.favourite ? ' <span class="fav-star">★</span>' : '';
|
|
if (pending) {
|
|
return `<div class="fl-item">
|
|
<img class="fl-av" src="${av}" alt="">
|
|
<div class="fl-meta"><span class="fl-name">${name}</span><span class="fl-sub">Wants to be your friend</span></div>
|
|
<div class="fl-actions"><button class="fl-accept" data-id="${f.id}" data-n="${name}">Accept</button><button class="fl-decline" data-id="${f.id}">Decline</button></div>
|
|
</div>`;
|
|
}
|
|
const status = (f.online
|
|
? `<span class="fl-online"><i></i>Online${f.where ? ' · ' + escapeHtml(f.where) : ''}</span>`
|
|
: 'Offline')
|
|
+ (f.spotify && f.spotify.track ? `<br><span class="fl-spotify">${SP_ICON}${f.spotify.is_playing ? '' : 'Paused · '}${escapeHtml(f.spotify.track)} — ${escapeHtml(f.spotify.artists || '')}</span>` : '');
|
|
const favBtn = cache.own
|
|
? `<button class="fl-fav${f.favourite ? ' on' : ''}" data-id="${f.id}" data-on="${f.favourite ? 1 : 0}" title="${f.favourite ? 'Remove from favourites' : 'Add to favourites'}">${f.favourite ? '★' : '☆'}</button>`
|
|
: '';
|
|
return `<div class="fl-item">
|
|
<a class="fl-link" href="${userUrl(f.username)}" style="display:flex;align-items:center;gap:12px;flex:1;min-width:0;text-decoration:none;color:inherit">
|
|
<img class="fl-av" src="${av}" alt="">
|
|
<div class="fl-meta"><span class="fl-name">${name}${star}</span><span class="fl-sub">${status}</span></div>
|
|
</a>${favBtn}
|
|
</div>`;
|
|
}
|
|
|
|
function render() {
|
|
if (!cache) {
|
|
modal.innerHTML = `<div class="report-card fl-card"><button class="report-close" aria-label="Close">✕</button><h2 class="report-title">Friends</h2><p class="fl-empty">Loading…</p></div>`;
|
|
modal.querySelector('.report-close').addEventListener('click', closeFriendsList);
|
|
return;
|
|
}
|
|
const friends = cache.friends || [];
|
|
const pending = cache.pending || [];
|
|
const favs = friends.filter(f => f.favourite);
|
|
const onlineCount = friends.filter(f => f.online).length;
|
|
const show = filter === 'favourites' ? favs : filter === 'pending' ? pending : friends;
|
|
const rows = show.length ? show.map(f => row(f, filter === 'pending')).join('')
|
|
: `<p class="fl-empty">${filter === 'pending' ? 'No pending requests.' : filter === 'favourites' ? 'No favourites yet.' : 'No friends yet.'}</p>`;
|
|
|
|
modal.innerHTML = `
|
|
<div class="report-card fl-card">
|
|
<button class="report-close" aria-label="Close">✕</button>
|
|
<h2 class="report-title">${cache.own ? 'Friends' : escapeHtml(cap(FRIENDS_MODAL_HANDLE)) + "'s friends"}</h2>
|
|
<p class="report-sub">${friends.length} friend${friends.length === 1 ? '' : 's'} · ${onlineCount} online</p>
|
|
<div class="fl-tabs">
|
|
<button class="fl-tab${filter === 'all' ? ' active' : ''}" data-f="all">All</button>
|
|
<button class="fl-tab${filter === 'favourites' ? ' active' : ''}" data-f="favourites">Favourites${favs.length ? ` (${favs.length})` : ''}</button>
|
|
${cache.own ? `<button class="fl-tab${filter === 'pending' ? ' active' : ''}" data-f="pending">Requests${pending.length ? ` (${pending.length})` : ''}</button>` : ''}
|
|
</div>
|
|
<div class="fl-list">${rows}</div>
|
|
</div>`;
|
|
|
|
modal.querySelector('.report-close').addEventListener('click', closeFriendsList);
|
|
modal.querySelectorAll('.fl-tab').forEach(t => t.addEventListener('click', () => { filter = t.dataset.f; render(); }));
|
|
modal.querySelectorAll('.fl-accept').forEach(b => b.addEventListener('click', async () => {
|
|
const { ok } = await apiPost('/api/site/friends/accept', { id: Number(b.dataset.id) });
|
|
toast(ok ? `You and ${b.dataset.n} are now friends` : 'Could not accept right now');
|
|
load();
|
|
}));
|
|
modal.querySelectorAll('.fl-decline').forEach(b => b.addEventListener('click', async () => {
|
|
await apiPost('/api/site/friends/remove', { id: Number(b.dataset.id) }); load();
|
|
}));
|
|
modal.querySelectorAll('.fl-fav').forEach(b => b.addEventListener('click', async () => {
|
|
const on = b.dataset.on !== '1';
|
|
const { ok } = await apiPost('/api/site/friends/favourite', { id: Number(b.dataset.id), on });
|
|
if (!ok) { toast('Could not update favourites'); return; }
|
|
toast(on ? 'Added to favourites' : 'Removed from favourites');
|
|
load();
|
|
}));
|
|
}
|
|
modal.__reload = load;
|
|
render();
|
|
load();
|
|
modal.classList.add('open');
|
|
scrim.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
// Subscribers / Subscriptions popup. `kind` is 'subscribers' (who subscribes to this
|
|
// profile) or 'subscriptions' (who this profile subscribes to). Same modal chrome as the
|
|
// friends list, real data from /api/site/profile/{handle}/{kind}, and it re-renders itself on a
|
|
// server refresh push (someone subscribes / unsubscribes while it's open).
|
|
let SUBS_MODAL = null;
|
|
function openSubsList(handle, kind) {
|
|
let scrim = document.getElementById('subsScrim');
|
|
let modal = document.getElementById('subsModal');
|
|
if (!modal) {
|
|
scrim = document.createElement('div'); scrim.id = 'subsScrim'; scrim.className = 'report-scrim';
|
|
modal = document.createElement('div'); modal.id = 'subsModal'; modal.className = 'report-modal';
|
|
document.body.appendChild(scrim); document.body.appendChild(modal);
|
|
scrim.addEventListener('click', closeSubsList);
|
|
modal.addEventListener('click', e => { if (e.target === modal) closeSubsList(); });
|
|
}
|
|
const me = localStorage.getItem(USER_KEY);
|
|
const h = (handle || viewedHandle() || me || '').toLowerCase();
|
|
let cache = null;
|
|
|
|
async function load() {
|
|
let r = null;
|
|
try { if (apiOn()) r = await apiGet('/api/site/profile/' + encodeURIComponent(h) + '/' + kind); } catch {}
|
|
cache = (r && r.ok) ? r : { own: false, results: [] };
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
const title = kind === 'subscribers'
|
|
? (cache && cache.own ? 'Your subscribers' : escapeHtml(cap(h)) + "'s subscribers")
|
|
: (cache && cache.own ? 'Your subscriptions' : escapeHtml(cap(h)) + ' subscribes to');
|
|
const list = cache ? (cache.results || []) : null;
|
|
const rows = !list ? '<p class="fl-empty">Loading…</p>'
|
|
: list.length ? list.map(p => {
|
|
const name = escapeHtml(p.display_name || p.username);
|
|
const av = p.profile_image || avatarUrl(p.username);
|
|
const status = p.online ? `<span class="fl-online"><i></i>Online${p.where ? ' · ' + escapeHtml(p.where) : ''}</span>`
|
|
: `${fmt(p.subscribers)} subscriber${p.subscribers === 1 ? '' : 's'}`;
|
|
const canSub = me && p.username && p.username.toLowerCase() !== me.toLowerCase();
|
|
const btn = canSub ? `<button class="fl-sub-btn${p.subscribed ? ' on' : ''}" data-id="${p.id}" data-on="${p.subscribed ? 1 : 0}">${p.subscribed ? 'Subscribed' : 'Subscribe'}</button>` : '';
|
|
return `<div class="fl-item">
|
|
<a class="fl-link" href="${userUrl(p.username)}" style="display:flex;align-items:center;gap:12px;flex:1;min-width:0;text-decoration:none;color:inherit">
|
|
<img class="fl-av" src="${av}" alt="">
|
|
<div class="fl-meta"><span class="fl-name">${name}</span><span class="fl-sub">${status}</span></div>
|
|
</a>${btn}
|
|
</div>`;
|
|
}).join('')
|
|
: `<p class="fl-empty">${kind === 'subscribers' ? 'No subscribers yet.' : 'Not subscribed to anyone yet.'}</p>`;
|
|
|
|
modal.innerHTML = `
|
|
<div class="report-card fl-card">
|
|
<button class="report-close" aria-label="Close">✕</button>
|
|
<h2 class="report-title">${title}</h2>
|
|
<p class="report-sub">${list ? `${list.length} ${kind === 'subscribers' ? 'subscriber' : 'subscription'}${list.length === 1 ? '' : 's'}` : ''}</p>
|
|
<div class="fl-list">${rows}</div>
|
|
</div>`;
|
|
modal.querySelector('.report-close').addEventListener('click', closeSubsList);
|
|
modal.querySelectorAll('.fl-sub-btn').forEach(b => b.addEventListener('click', async () => {
|
|
if (!requireLogin('subscribe')) return;
|
|
const on = b.dataset.on !== '1';
|
|
const { ok, data } = await apiPost('/api/site/subscribe', { id: Number(b.dataset.id), on });
|
|
if (!ok) { toast((data && data.error) || 'Could not update the subscription'); return; }
|
|
toast(on ? 'Subscribed' : 'Unsubscribed');
|
|
load();
|
|
}));
|
|
}
|
|
modal.__reload = load;
|
|
render();
|
|
load();
|
|
modal.classList.add('open');
|
|
scrim.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
function closeSubsList() {
|
|
document.getElementById('subsModal')?.classList.remove('open');
|
|
document.getElementById('subsScrim')?.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
// ───── CONFIRM DIALOG ─────
|
|
function closeConfirm() {
|
|
document.getElementById('confirmModal')?.classList.remove('open');
|
|
document.getElementById('confirmScrim')?.classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
}
|
|
function openConfirm(opts) {
|
|
const { title, message, confirmText, onConfirm } = opts;
|
|
let scrim = document.getElementById('confirmScrim');
|
|
let modal = document.getElementById('confirmModal');
|
|
if (!modal) {
|
|
scrim = document.createElement('div'); scrim.id = 'confirmScrim'; scrim.className = 'report-scrim';
|
|
modal = document.createElement('div'); modal.id = 'confirmModal'; modal.className = 'report-modal';
|
|
document.body.appendChild(scrim); document.body.appendChild(modal);
|
|
scrim.addEventListener('click', closeConfirm);
|
|
modal.addEventListener('click', e => { if (e.target === modal) closeConfirm(); });
|
|
}
|
|
modal.innerHTML = `
|
|
<div class="report-card confirm-card">
|
|
<h2 class="report-title">${title}</h2>
|
|
<p class="report-sub">${message}</p>
|
|
<div class="report-actions">
|
|
<button class="report-cancel confirm-no">Cancel</button>
|
|
<button class="report-submit confirm-yes">${confirmText}</button>
|
|
</div>
|
|
</div>`;
|
|
modal.querySelector('.confirm-no').addEventListener('click', closeConfirm);
|
|
modal.querySelector('.confirm-yes').addEventListener('click', () => {
|
|
closeConfirm();
|
|
if (typeof onConfirm === 'function') onConfirm();
|
|
});
|
|
modal.classList.add('open');
|
|
scrim.classList.add('open');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeReport(); closeNick(); closeConfirm(); } });
|
|
|
|
// ───── INTERACTIONS (cheer + follow + comments) ─────
|
|
document.addEventListener('click', e => {
|
|
// cheer a photo
|
|
const cheer = e.target.closest('.cheer');
|
|
if (cheer) {
|
|
e.preventDefault();
|
|
if (!requireLogin('cheer photos')) return;
|
|
if (!rateLimit('cheer', 15, 10000)) { toast('Whoa! Slow down on the cheers.'); return; }
|
|
const liked = cheer.classList.toggle('liked');
|
|
const base = +cheer.dataset.n;
|
|
const span = cheer.querySelector('span');
|
|
if (span) span.textContent = fmt(base + (liked ? 1 : 0));
|
|
const pid = photoIdOf(cheer);
|
|
if (pid && apiOn()) {
|
|
// The same cheer the game records: what the site shows is what in-game shows.
|
|
apiPost('/api/site/photos/' + pid + '/cheer').then(r => {
|
|
if (r && r.ok && r.data) {
|
|
cheer.classList.toggle('liked', !!r.data.cheered);
|
|
cheer.dataset.n = (r.data.cheers || 0) - (r.data.cheered ? 1 : 0);
|
|
if (span) span.textContent = fmt(r.data.cheers || 0);
|
|
} else if (r && r.status === 401) { cheer.classList.toggle('liked', !liked); if (span) span.textContent = fmt(base); toast('Log in to cheer photos'); }
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
// toggle comments
|
|
const ct = e.target.closest('.comment-toggle');
|
|
if (ct) {
|
|
e.preventDefault();
|
|
const box = ct.closest('.post').querySelector('.post-comments');
|
|
if (box) {
|
|
box.hidden = !box.hidden;
|
|
const list = box.querySelector('.comment-list');
|
|
if (!box.hidden && list && !list.dataset.loaded) loadCommentsInto(box, photoIdOf(ct));
|
|
}
|
|
return;
|
|
}
|
|
// delete your own comment
|
|
const cdel = e.target.closest('.comment-del');
|
|
if (cdel) {
|
|
const comment = cdel.closest('.comment');
|
|
const list = cdel.closest('.comment-list');
|
|
const post = cdel.closest('.post') || cdel.closest('[data-photo]');
|
|
const pidDel = photoIdOf(cdel), cid = comment.dataset.cid;
|
|
if (pidDel && cid && apiOn()) apiDelete('/api/site/photos/' + pidDel + '/comments/' + cid).then(r => { if (!r.ok) toast('Could not delete the comment'); });
|
|
comment.remove();
|
|
if (post) {
|
|
const toggle = post.querySelector('.comment-toggle');
|
|
if (toggle) {
|
|
toggle.dataset.n = Math.max(0, (+toggle.dataset.n || 0) - 1);
|
|
const s = toggle.querySelector('span');
|
|
if (s) s.textContent = fmt(+toggle.dataset.n);
|
|
}
|
|
}
|
|
if (list && !list.querySelector('.comment')) {
|
|
list.innerHTML = '<p class="no-comments">No comments yet - be the first!</p>';
|
|
}
|
|
return;
|
|
}
|
|
// report a comment
|
|
const rep = e.target.closest('.comment-report');
|
|
if (rep) {
|
|
const comment = rep.closest('.comment');
|
|
if (comment.classList.contains('reported')) return;
|
|
openReport('Report comment', () => {
|
|
comment.classList.add('reported');
|
|
rep.outerHTML = '<span class="comment-reported">Reported</span>';
|
|
}, comment.dataset.cid ? { type: 'comment', id: comment.dataset.cid, photoId: photoIdOf(comment) } : null);
|
|
return;
|
|
}
|
|
// follow / unfollow
|
|
const fb = e.target.closest('.follow-btn');
|
|
if (fb) {
|
|
const on = fb.classList.toggle('following');
|
|
fb.textContent = on ? 'Following' : 'Follow';
|
|
}
|
|
});
|
|
|
|
// add a comment
|
|
document.addEventListener('submit', e => {
|
|
const form = e.target.closest('.comment-form');
|
|
if (!form) return;
|
|
e.preventDefault();
|
|
const me = localStorage.getItem(USER_KEY);
|
|
if (!me) return;
|
|
const input = form.querySelector('.comment-input');
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
|
|
const box = form.closest('.post-comments');
|
|
const err = box.querySelector('.comment-error');
|
|
|
|
// block inappropriate content
|
|
if (isInappropriate(text)) {
|
|
if (err) { err.textContent = 'This content is too inappropriate to send'; err.classList.add('show'); }
|
|
return;
|
|
}
|
|
// rate limit: max 5 comments per 30s
|
|
if (!rateLimit('comment', 5, 30000)) {
|
|
if (err) { err.textContent = 'You\'re commenting too fast - please wait a moment.'; err.classList.add('show'); }
|
|
return;
|
|
}
|
|
if (err) { err.classList.remove('show'); err.textContent = ''; }
|
|
|
|
const list = box.querySelector('.comment-list');
|
|
const empty = list.querySelector('.no-comments');
|
|
if (empty) empty.remove();
|
|
|
|
const pidNew = photoIdOf(form);
|
|
if (pidNew && apiOn()) {
|
|
input.value = '';
|
|
apiPost('/api/site/photos/' + pidNew + '/comments', { text }).then(r => {
|
|
if (r && r.ok && r.data && r.data.comment) list.insertAdjacentHTML('beforeend', commentRow(serverComment(r.data.comment)));
|
|
else { if (err) { err.textContent = (r && r.data && r.data.error) || 'Could not post the comment'; err.classList.add('show'); } if (!list.querySelector('.comment')) list.innerHTML = '<p class="no-comments">No comments yet - be the first!</p>'; }
|
|
});
|
|
} else {
|
|
list.insertAdjacentHTML('beforeend', commentRow({ user: me, handle: me, text }));
|
|
input.value = '';
|
|
}
|
|
|
|
// bump the comment count
|
|
const toggle = form.closest('.post').querySelector('.comment-toggle');
|
|
if (toggle) {
|
|
toggle.dataset.n = (+toggle.dataset.n || 0) + 1;
|
|
const span = toggle.querySelector('span');
|
|
if (span) span.textContent = fmt(+toggle.dataset.n);
|
|
}
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// LIVE DATA
|
|
//
|
|
// Everything above renders the demo content in data.js so the page has something
|
|
// to show instantly. This section then replaces it with what is actually on the
|
|
// server: real rooms, real players, real photos, real avatars.
|
|
//
|
|
// Replace-after-render rather than render-only-after-fetch, deliberately: the page
|
|
// is useful immediately and never shows a bank of spinners, and if the API is down
|
|
// the demo content simply stays. Every fetch below is allowed to fail silently for
|
|
// that reason.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// handle -> real profile image URL, filled in from whatever endpoint mentioned them.
|
|
const LIVE_AVATARS = new Map();
|
|
|
|
// avatarUrl is called all over the file and defaults to a generated DiceBear face.
|
|
// Wrap it so a real profile picture wins when we know one, without touching the
|
|
// dozen call sites.
|
|
const _avatarUrlBase = avatarUrl;
|
|
avatarUrl = function (seed) {
|
|
const real = LIVE_AVATARS.get(avKey(seed));
|
|
return real || _avatarUrlBase(seed);
|
|
};
|
|
|
|
// Remember an account's picture and repaint any avatar already on screen for them.
|
|
function noteAvatar(user) {
|
|
if (!user || !user.username) return;
|
|
const key = avKey(user.username);
|
|
if (!user.profile_image) return;
|
|
if (LIVE_AVATARS.get(key) === user.profile_image) return;
|
|
LIVE_AVATARS.set(key, user.profile_image);
|
|
refreshAvatars(key, user.profile_image);
|
|
}
|
|
|
|
async function apiGet(path) {
|
|
const resp = await fetch(apiUrl(path), { credentials: 'same-origin' });
|
|
if (!resp.ok) throw new Error(path + ' -> ' + resp.status);
|
|
const out = await resp.json();
|
|
if (out && out.ok === false) throw new Error(out.error || path);
|
|
return out;
|
|
}
|
|
|
|
// ---- rooms ------------------------------------------------------------------
|
|
|
|
function roomThumb(r) {
|
|
return r.image
|
|
? `<div class="rail-thumb" style="background-image:url('${r.image}');background-size:cover;background-position:center"></div>`
|
|
: `<div class="rail-thumb" style="--a:#ff5a3c;--b:#7b2ff7"></div>`;
|
|
}
|
|
|
|
async function hydrateRooms() {
|
|
if (!apiOn()) return;
|
|
|
|
let rooms;
|
|
try {
|
|
rooms = (await apiGet('/api/site/rooms/hot?take=12')).results || [];
|
|
} catch (e) {
|
|
console.warn('[campus] rooms unavailable, keeping demo rooms:', e.message);
|
|
return;
|
|
}
|
|
if (!rooms.length) return; // empty server: demo content is better than a blank rail
|
|
|
|
// Room names come back with the leading '^' the game uses; strip it for display
|
|
// and re-add it in the label so it matches the client.
|
|
rooms.forEach(r => { r.displayName = (r.name || '').replace(/^\^/, ''); });
|
|
|
|
// Left rail. The markup for this is hardcoded in index.html, so replace the
|
|
// items but keep the heading.
|
|
const rail = document.getElementById('rooms');
|
|
if (rail) {
|
|
// This rail holds the online-friends section (inserted by hydrateOnline) ABOVE the rooms.
|
|
// Preserve it, and always use a fixed "Top Rooms" heading - previously this reused whatever
|
|
// .rail-title was first in the rail, which was the online section's, so the rooms ended up
|
|
// mislabelled "Online Friends".
|
|
const online = rail.querySelector('.rail-online');
|
|
const pinnedBlock = rail.querySelector('.rail-pinned'); // pinned friends (renderPinned) live above the rooms too
|
|
rail.innerHTML = (pinnedBlock ? pinnedBlock.outerHTML : '') + (online ? online.outerHTML : '')
|
|
+ '<h3 class="rail-title">Top Rooms</h3>'
|
|
+ rooms.slice(0, 8).map(r => `
|
|
<a href="${roomUrl(r.id)}" class="rail-item">
|
|
${roomThumb(r)}
|
|
<div class="rail-meta">
|
|
<span class="rail-name">^${escapeHtml(r.displayName)}</span>
|
|
<span class="rail-by">${r.creator ? '@' + escapeHtml(r.creator) : ''}</span>
|
|
</div>
|
|
</a>`).join('');
|
|
}
|
|
|
|
// NOTE: the featured slideshow is NOT set here anymore. It's owned by loadFeatured(), which
|
|
// shows the admin-curated FEATURED rooms (same as in-game), falling back to hot only when
|
|
// nothing is featured. Setting it here too would overwrite that with the top rooms.
|
|
}
|
|
|
|
// ---- presence ---------------------------------------------------------------
|
|
|
|
async function hydrateOnline() {
|
|
if (!apiOn()) return;
|
|
|
|
const rail = document.querySelector('.feed-layout .rail');
|
|
if (!rail) return;
|
|
|
|
// Signed OUT: there are no "your friends" to show, so remove the section entirely (no generic
|
|
// "Online Now" list of strangers).
|
|
if (!CAMPUS_ME) { rail.querySelector('.rail-online')?.remove(); return; }
|
|
|
|
// Signed in: ONLY your own online friends (never every online player).
|
|
let people = [];
|
|
try {
|
|
const f = await apiGet('/api/site/friends');
|
|
people = (f.results || []).filter(p => p.online);
|
|
} catch (e) {
|
|
console.warn('[campus] presence unavailable, keeping demo list:', e.message);
|
|
return;
|
|
}
|
|
|
|
people.forEach(noteAvatar);
|
|
|
|
const existing = rail.querySelector('.rail-online');
|
|
const heading = CAMPUS_ME ? 'Online Friends' : 'Online Now';
|
|
|
|
if (!people.length) {
|
|
// Real answer, and it is "nobody". Say so instead of showing invented friends.
|
|
const emptyMsg = CAMPUS_ME ? 'None of your friends are online right now.' : 'No one is online right now.';
|
|
const html = `<div class="rail-online"><h3 class="rail-title">${heading} · 0</h3>
|
|
<p class="rail-by" style="padding:.5rem .25rem">${emptyMsg}</p></div>`;
|
|
if (existing) existing.outerHTML = html; else rail.insertAdjacentHTML('afterbegin', html);
|
|
return;
|
|
}
|
|
|
|
const items = people.map(p => {
|
|
const handle = p.username || String(p.id);
|
|
const name = p.display_name || p.username || 'Player';
|
|
return `<a class="rail-item" href="${userUrl(handle)}">
|
|
<span class="rail-on-av"><img class="rail-pin-av" data-av="${avKey(handle)}" src="${avatarUrl(handle)}" alt=""><i class="online-dot"></i></span>
|
|
<div class="rail-meta">
|
|
<span class="rail-name">${escapeHtml(name)}</span>
|
|
<span class="rail-by">${escapeHtml(p.where || 'Online now')}</span>
|
|
</div>
|
|
</a>`;
|
|
}).join('');
|
|
|
|
const html = `<div class="rail-online"><h3 class="rail-title">${heading} · ${people.length}</h3>${items}</div>`;
|
|
if (existing) existing.outerHTML = html; else rail.insertAdjacentHTML('afterbegin', html);
|
|
}
|
|
|
|
// ---- photos -----------------------------------------------------------------
|
|
|
|
async function hydrateFeed() {
|
|
if (!apiOn() || !postList) return;
|
|
|
|
let photos;
|
|
try {
|
|
photos = (await apiGet('/api/site/photos/recent?take=24')).results || [];
|
|
} catch (e) {
|
|
console.warn('[campus] photo feed unavailable, keeping fallback:', e.message);
|
|
return;
|
|
}
|
|
|
|
if (!photos.length) {
|
|
postList.innerHTML = '<div class="feed-loading">No photos have been shared yet.</div>';
|
|
return;
|
|
}
|
|
|
|
photos.forEach(p => noteAvatar(p.author));
|
|
|
|
// Map the server shape onto what renderPosts expects.
|
|
renderPosts(photos.map(p => ({
|
|
id: p.id,
|
|
user: (p.author && (p.author.display_name || p.author.username)) || 'Unknown',
|
|
handle: (p.author && p.author.username) || 'player',
|
|
room: null, // room NAME is not in this projection; the id alone is not useful
|
|
image: p.image,
|
|
caption: p.description || '',
|
|
cheers: p.cheers || 0,
|
|
thread: [], // the thread itself is a separate fetch
|
|
comments: p.comments || 0, // count comes from the server (see nComments)
|
|
takenAt: p.createdAt,
|
|
})));
|
|
}
|
|
|
|
// ---- go ---------------------------------------------------------------------
|
|
|
|
// syncSession() has already run above; wait for it so hydrateOnline knows whether
|
|
// anyone is signed in, then fill everything in parallel.
|
|
SESSION_READY.then(() => {
|
|
hydrateRooms();
|
|
hydrateOnline();
|
|
hydrateFeed();
|
|
if (CAMPUS_ME) noteAvatar(CAMPUS_ME);
|
|
});
|
|
|
|
// ---- topbar restyle (look only) --------------------------------------------------------------
|
|
// The brand, an inline search field and the Rooms / News / Support links in the top bar on every
|
|
// page. Nothing new behind them: the field just opens the existing search panel, the links go
|
|
// where the drawer's do.
|
|
(function topbarChrome() {
|
|
const bar = document.querySelector('.topbar');
|
|
if (!bar || bar.querySelector('.top-mid')) return;
|
|
const left = bar.querySelector('.top-left');
|
|
const right = bar.querySelector('.top-right');
|
|
if (topbarBrand) {
|
|
topbarBrand.classList.add('always');
|
|
topbarBrand.addEventListener('click', () => { window.location.href = '/'; });
|
|
topbarBrand.setAttribute('role', 'link');
|
|
topbarBrand.tabIndex = 0;
|
|
}
|
|
if (left && document.getElementById('searchBtn')) {
|
|
const mid = document.createElement('div');
|
|
mid.className = 'top-mid';
|
|
mid.innerHTML = '<label class="top-search"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.7" y2="16.7"/></svg><input type="text" placeholder="Search…" aria-label="Search" autocomplete="off"></label>';
|
|
left.insertAdjacentElement('afterend', mid);
|
|
const fake = mid.querySelector('input');
|
|
const open = () => {
|
|
if (typeof openSearch !== 'function' || !document.getElementById('searchPanel')) return;
|
|
openSearch();
|
|
const real = document.getElementById('searchInput');
|
|
if (real) {
|
|
if (fake.value) { real.value = fake.value; real.dispatchEvent(new Event('input', { bubbles: true })); }
|
|
real.focus();
|
|
}
|
|
fake.value = '';
|
|
fake.blur();
|
|
};
|
|
fake.addEventListener('focus', open);
|
|
fake.addEventListener('click', open);
|
|
}
|
|
if (right) {
|
|
const links = document.createElement('nav');
|
|
links.className = 'top-nav-links';
|
|
links.setAttribute('aria-label', 'Main');
|
|
links.innerHTML =
|
|
'<a href="/rooms"><span>Rooms</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10"/></svg></a>' +
|
|
'<a href="/news"><span>News</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h13v14a1 1 0 0 1-1 1H4z"/><path d="M17 8h2a1 1 0 0 1 1 1v9a2 2 0 0 1-2 2"/><path d="M8 9h5M8 13h5"/></svg></a>' +
|
|
'<a href="/support"><span>Support</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></a>';
|
|
right.insertAdjacentElement('afterbegin', links);
|
|
}
|
|
})();
|
|
|
|
// Home hero: "N players online right now in Campus!" from the same presence API the online list
|
|
// uses. Left as the neutral wording when the API is off or answers nothing.
|
|
(function heroOnline() {
|
|
const el = document.getElementById('heroOnline');
|
|
if (!el || !apiOn()) return;
|
|
const paint = async () => {
|
|
try {
|
|
const d = await apiGet('/api/site/online?take=1');
|
|
if (d && d.ok && typeof d.total === 'number') {
|
|
el.textContent = d.total === 1 ? '1 player is' : `${d.total.toLocaleString()} players are`;
|
|
}
|
|
} catch {}
|
|
};
|
|
paint();
|
|
setInterval(paint, 60000);
|
|
})();
|
|
|
|
|
|
// ---- /rooms: browse every public room (Hot / New / Originals, with a filter) -----------------
|
|
(function roomsPage() {
|
|
const grid = document.getElementById('roomsGrid');
|
|
if (!grid) return;
|
|
const tabs = Array.from(document.querySelectorAll('.rtab'));
|
|
const filter = document.getElementById('roomsFilter');
|
|
const more = document.getElementById('roomsMore');
|
|
const count = document.getElementById('roomsCount');
|
|
const PAGE = 24;
|
|
let tab = 'hot', q = '', skip = 0, total = 0, busy = false, seq = 0;
|
|
|
|
// Deep link: /rooms?tab=rro&q=dodge
|
|
try {
|
|
const p = new URLSearchParams(location.search);
|
|
if (['hot', 'new', 'rro'].includes(p.get('tab'))) tab = p.get('tab');
|
|
if (p.get('q')) { q = p.get('q'); filter.value = q; }
|
|
} catch {}
|
|
|
|
const ICON_VISITS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 12S5.5 5 12 5s10.5 7 10.5 7-4 7-10.5 7S1.5 12 1.5 12z"/><circle cx="12" cy="12" r="3.2"/></svg>';
|
|
const ICON_CHEER = CHEER_ICON;
|
|
|
|
function card(r) {
|
|
const name = String(r.name || '').replace(/^\^/, '');
|
|
const img = r.image
|
|
? `<div class="rc-img" style="background-image:url('${r.image}')"></div>`
|
|
: `<div class="rc-img rc-img-empty">^${escapeHtml(name)}</div>`;
|
|
return `<a class="rc" href="${roomUrl(r.name || r.id)}">
|
|
${img}
|
|
<div class="rc-body">
|
|
<span class="rc-name">^${escapeHtml(name)}</span>
|
|
<span class="rc-by">${r.creator ? '@' + escapeHtml(r.creator) : ''}</span>
|
|
<span class="rc-stats"><span>${ICON_VISITS}${fmt(r.visits || 0)}</span><span>${ICON_CHEER}${fmt(r.cheers || 0)}</span></span>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
function fallback() {
|
|
const list = Object.keys(window.ROOMS_DATA || {}).map(id => Object.assign({ id, name: (ROOMS_DATA[id] || {}).name || id }, ROOMS_DATA[id] || {}));
|
|
grid.innerHTML = list.length ? list.map(card).join('') : '<div class="feed-loading">No rooms to show yet.</div>';
|
|
more.hidden = true;
|
|
count.textContent = '';
|
|
}
|
|
|
|
async function load(reset) {
|
|
if (busy) return;
|
|
if (!apiOn()) { fallback(); return; }
|
|
busy = true;
|
|
const my = ++seq;
|
|
if (reset) { skip = 0; grid.innerHTML = '<div class="feed-loading">Loading rooms…</div>'; }
|
|
more.disabled = true;
|
|
try {
|
|
const d = await apiGet(`/api/site/rooms/browse?tab=${encodeURIComponent(tab)}&q=${encodeURIComponent(q)}&skip=${skip}&take=${PAGE}`);
|
|
if (my !== seq) return; // a newer request superseded this one
|
|
if (!d || !d.ok) throw new Error((d && d.error) || 'failed');
|
|
total = d.total || 0;
|
|
const html = (d.results || []).map(card).join('');
|
|
if (reset) grid.innerHTML = html || '<div class="feed-loading">No rooms match that.</div>';
|
|
else grid.insertAdjacentHTML('beforeend', html);
|
|
skip += (d.results || []).length;
|
|
more.hidden = skip >= total;
|
|
count.textContent = total ? `${total.toLocaleString()} room${total === 1 ? '' : 's'}` : '';
|
|
} catch (e) {
|
|
if (my === seq && reset) grid.innerHTML = '<div class="feed-loading">Could not load rooms.</div>';
|
|
more.hidden = true;
|
|
} finally {
|
|
if (my === seq) { busy = false; more.disabled = false; }
|
|
}
|
|
}
|
|
|
|
function setTab(next) {
|
|
tab = next;
|
|
tabs.forEach(b => {
|
|
const on = b.dataset.tab === tab;
|
|
b.classList.toggle('active', on);
|
|
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
});
|
|
try {
|
|
const u = new URL(location.href);
|
|
u.searchParams.set('tab', tab);
|
|
if (q) u.searchParams.set('q', q); else u.searchParams.delete('q');
|
|
history.replaceState(null, '', u.pathname + (u.search || ''));
|
|
} catch {}
|
|
load(true);
|
|
}
|
|
|
|
tabs.forEach(b => b.addEventListener('click', () => { if (b.dataset.tab !== tab) setTab(b.dataset.tab); }));
|
|
let t = null;
|
|
filter.addEventListener('input', () => {
|
|
clearTimeout(t);
|
|
t = setTimeout(() => { q = filter.value.trim(); setTab(tab); }, 250);
|
|
});
|
|
more.addEventListener('click', () => load(false));
|
|
setTab(tab);
|
|
})();
|