4559 lines
240 KiB
JavaScript
4559 lines
240 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';
|
||
}
|
||
|
||
// ───── PLAIN TEXT ONLY ─────
|
||
// The server refuses fancy-font letters, emoji and other symbols in anything the site sends
|
||
// (SitePlainTextMiddleware). Every text box follows the same rule as you type or paste: keyboard
|
||
// punctuation a phone swaps in (curly quotes, dashes, …) becomes plain, anything else is removed.
|
||
// Password boxes are never touched.
|
||
function toPlainText(s) {
|
||
return s.replace(/[‘’‚′]/g, "'").replace(/[“”„″]/g, '"')
|
||
.replace(/[–—−]/g, '-').replace(/…/g, '...').replace(/[ ]/g, ' ')
|
||
.replace(/[^\x20-\x7E\n\r\t]/g, '');
|
||
}
|
||
let PLAIN_TOAST_AT = 0;
|
||
document.addEventListener('input', e => {
|
||
const el = e.target;
|
||
const box = el instanceof HTMLTextAreaElement ||
|
||
(el instanceof HTMLInputElement && /^(text|search|email|url|tel)$/.test(el.type));
|
||
if (!box) return;
|
||
const v = el.value, p = toPlainText(v);
|
||
if (p === v) return;
|
||
let pos = null;
|
||
try { pos = el.selectionStart - (v.length - p.length); } catch {}
|
||
el.value = p;
|
||
if (pos != null) { try { el.setSelectionRange(pos, pos); } catch {} }
|
||
// Say why once per few seconds; plain punctuation swaps are silent.
|
||
if (toPlainText(v.replace(/[‘’‚′“”„″–—−… ]/g, '')) !== v.replace(/[‘’‚′“”„″–—−… ]/g, '')
|
||
&& Date.now() - PLAIN_TOAST_AT > 4000 && typeof toast === 'function') {
|
||
PLAIN_TOAST_AT = Date.now();
|
||
toast('Only plain text is allowed - no special fonts, symbols or emoji.');
|
||
}
|
||
}, true);
|
||
|
||
// ───── 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(() => ({}));
|
||
banRefused(resp, out);
|
||
return { ok: resp.ok && out.ok !== false, status: resp.status, data: out };
|
||
}
|
||
|
||
// A banned account is signed in but can't change anything: the server answers every write with
|
||
// 403 {banned: true, error}. Say so, whatever the caller does with the failure.
|
||
function banRefused(resp, out) {
|
||
if (resp && resp.status === 403 && out && out.banned) { toast(out.error || 'This account is banned.'); return true; }
|
||
return false;
|
||
}
|
||
|
||
// 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(() => ({}));
|
||
banRefused(resp, out);
|
||
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(/^@/, '')); }
|
||
|
||
// ───── EVENTS (a profile's Events tab and a room's Events tab) ─────
|
||
// Cards come from /api/site/profile/{h}/events and /api/site/rooms/{id}/events: { id, name, description, image,
|
||
// start, end (UTC ISO), status live|upcoming|ended, room{id,name}, host{...}, going, interested, myResponse }.
|
||
// Times are shown in the visitor's own timezone. Signed-in visitors can RSVP (Going / Interested) right on the card.
|
||
function eventWhen(e) {
|
||
// "Jan 12th, 1:00pm - 2:00pm" (a multi-day event: "Jan 12th, 9:00pm - Jan 13th, 1:00am"), in the visitor's timezone
|
||
const s = new Date(e.start), en = new Date(e.end);
|
||
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
const ord = n => { const v = n % 100; return n + ((v >= 11 && v <= 13) ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] || 'th')); };
|
||
const day = d => MON[d.getMonth()] + ' ' + ord(d.getDate());
|
||
const tm = d => { let h = d.getHours(); const ap = h >= 12 ? 'pm' : 'am'; h = h % 12 || 12; return h + ':' + String(d.getMinutes()).padStart(2, '0') + ap; };
|
||
if (e.status === 'live') return 'Live now \u00b7 until ' + tm(en);
|
||
return s.toDateString() === en.toDateString()
|
||
? `${day(s)}, ${tm(s)} - ${tm(en)}`
|
||
: `${day(s)}, ${tm(s)} - ${day(en)}, ${tm(en)}`;
|
||
}
|
||
|
||
function eventCardHtml(e, opts) {
|
||
opts = opts || {};
|
||
const href = '/event/' + encodeURIComponent(e.id);
|
||
const hostName = e.host ? (e.host.username || '') : '';
|
||
const av = (e.host && e.host.profile_image) || avatarUrl(hostName || 'player');
|
||
const img = e.image ? `background-image:url('${escapeHtml(e.image)}')` : 'background-image:linear-gradient(135deg,#ff5a3c,#7b2ff7)';
|
||
const badge = e.status === 'live' ? '<span class="pevent-live">LIVE</span>' : e.status === 'ended' ? '<span class="pevent-tag">ENDED</span>' : '';
|
||
const canRsvp = e.status !== 'ended' && !!(typeof CAMPUS_ME !== 'undefined' && CAMPUS_ME);
|
||
const rsvp = canRsvp
|
||
? `<div class="pevent-rsvp"><button type="button" data-r="going" class="${e.myResponse === 'going' ? 'on' : ''}">Going</button>`
|
||
+ `<button type="button" data-r="interested" class="${e.myResponse === 'interested' ? 'on' : ''}">Interested</button></div>`
|
||
: '';
|
||
// the Rec Room style card: picture + title + @host, a big picture, when, N ATTENDING
|
||
return `<article class="evc event-card${e.status === 'ended' ? ' event-ended' : ''}" data-event="${e.id}" data-href="${href}">
|
||
<a class="evc-head" href="${href}">
|
||
<img src="${escapeHtml(av)}" alt="" onerror="this.style.visibility='hidden'">
|
||
<span class="evc-titles"><b>${escapeHtml(e.name)}</b><small>${hostName ? '@' + escapeHtml(hostName) : ''}</small></span>
|
||
</a>
|
||
<a class="evc-img" href="${href}" style="${img}">${badge}</a>
|
||
<div class="evc-meta">
|
||
<span class="evc-when">${escapeHtml(eventWhen(e))}</span>
|
||
<span class="evc-att"><b>${e.going || 0}</b> ATTENDING</span>
|
||
</div>
|
||
${rsvp}
|
||
</article>`;
|
||
}
|
||
|
||
function renderEvents(el, events, opts, emptyText) {
|
||
if (!events || !events.length) { el.innerHTML = `<p class="profile-empty">${escapeHtml(emptyText || 'No events.')}</p>`; return; }
|
||
el.innerHTML = events.map(e => eventCardHtml(e, opts)).join('');
|
||
el.querySelectorAll('.event-card').forEach(c => c.addEventListener('click', ev => {
|
||
if (ev.target.closest('a, button')) return;
|
||
location.href = c.dataset.href;
|
||
}));
|
||
el.querySelectorAll('.pevent-rsvp button').forEach(btn => btn.addEventListener('click', async () => {
|
||
const card = btn.closest('.event-card');
|
||
const id = card && card.dataset.event;
|
||
const ev = events.find(x => String(x.id) === String(id));
|
||
if (!ev) return;
|
||
// pressing the answer you already gave takes it back
|
||
const want = ev.myResponse === btn.dataset.r ? 'none' : btn.dataset.r;
|
||
btn.disabled = true;
|
||
const r = await apiPost('/api/site/events/' + encodeURIComponent(id) + '/respond', { response: want });
|
||
btn.disabled = false;
|
||
if (r && r.ok && r.data && r.data.event) {
|
||
Object.assign(ev, r.data.event);
|
||
renderEvents(el, events, opts, emptyText); // re-draw the list with the new counts and your answer
|
||
} else toast((r && r.data && r.data.error) || 'Could not save your answer');
|
||
}));
|
||
}
|
||
|
||
async function loadEvents(el, path, opts, emptyText) {
|
||
if (!el) return;
|
||
if (!apiOn()) { el.innerHTML = `<p class="profile-empty">${escapeHtml(emptyText || 'No events.')}</p>`; return; }
|
||
el.innerHTML = '<div class="feed-loading">Loading events\u2026</div>';
|
||
// (the later apiGet in this file THROWS on a non-OK answer, the earlier one returns null: handle both)
|
||
let data = null;
|
||
try { data = await apiGet(path); } catch { data = null; }
|
||
renderEvents(el, data && data.ok ? data.events : [], opts, emptyText);
|
||
}
|
||
|
||
|
||
// 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;
|
||
let CAMPUS_ADMIN = false;
|
||
|
||
// 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;
|
||
CAMPUS_ADMIN = !!out.admin; // dev account on an admin-allowed network: shows the Admin button
|
||
// 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 (+ its 2FA second step, for an account that turned it on)
|
||
const loginForm = document.getElementById('loginForm');
|
||
const login2faForm = document.getElementById('login2faForm');
|
||
let loginPendingTicket = '';
|
||
|
||
function showLogin2faStep(pending) {
|
||
loginPendingTicket = pending;
|
||
loginForm.style.display = 'none';
|
||
document.getElementById('loginFoot').style.display = 'none';
|
||
login2faForm.style.display = 'block';
|
||
document.getElementById('login2faCode').focus();
|
||
}
|
||
|
||
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 (data && data.need2fa) { if (btn) { btn.disabled = false; btn.textContent = 'Log in'; } showLogin2faStep(data.pending); return; }
|
||
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.');
|
||
}
|
||
});
|
||
}
|
||
|
||
if (login2faForm) {
|
||
document.getElementById('login2faBack').addEventListener('click', () => {
|
||
login2faForm.style.display = 'none';
|
||
loginForm.style.display = 'block';
|
||
document.getElementById('loginFoot').style.display = '';
|
||
document.getElementById('loginError').textContent = '';
|
||
document.getElementById('loginError').classList.remove('show');
|
||
});
|
||
login2faForm.addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const err = document.getElementById('login2faError');
|
||
const btn = login2faForm.querySelector('.login-submit');
|
||
err.classList.remove('show'); err.textContent = '';
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Verifying…'; }
|
||
try {
|
||
const { ok, data } = await apiPost('/api/site/login/2fa', { pending: loginPendingTicket, code: document.getElementById('login2faCode').value });
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Verify'; }
|
||
if (data && data.pending) loginPendingTicket = data.pending;
|
||
if (!ok) { err.textContent = data.error || 'Wrong code.'; err.classList.add('show'); document.getElementById('login2faCode').value = ''; return; }
|
||
if (data.user && data.user.username) { clearIdentityCache(); setIdentityCache(data.user); }
|
||
window.location.href = '/';
|
||
} catch {
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Verify'; }
|
||
err.textContent = 'Could not reach the server. Try again in a moment.'; err.classList.add('show');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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 => renderBanBanner(me));
|
||
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 === 'support') {
|
||
return `<a class="nm-item nm-support" data-id="${escapeHtml(n.id)}" href="${escapeHtml(n.link || '/support')}">
|
||
<div class="nm-icon" aria-hidden="true">🎫</div>
|
||
<div class="nm-body"><span class="nm-text">${escapeHtml(n.text)}</span>${time}</div>
|
||
${n.id === 'supq' ? '' : '<button class="nm-dismiss" type="button" aria-label="Dismiss">×</button>'}
|
||
</a>`;
|
||
}
|
||
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();
|
||
e.preventDefault(); // a support item is a link - dismissing it must not follow it
|
||
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 = { id: rm.id, 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 || '';
|
||
if (apiOn() && id != null) {
|
||
let slot = document.getElementById('roomAdmin');
|
||
if (!slot) { slot = document.createElement('div'); slot.id = 'roomAdmin'; slot.className = 'room-admin'; document.getElementById('roomDesc').after(slot); }
|
||
mountAdminButton('room', r.id || id, slot);
|
||
}
|
||
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('');
|
||
}
|
||
}
|
||
// the room's events (hosted by players, booked in this room)
|
||
const roomEventsEl = document.querySelector('.room-panel[data-panel="events"]');
|
||
if (roomEventsEl && apiOn() && r.id != null) {
|
||
roomEventsEl.innerHTML = '<div class="room-events profile-events" id="roomEvents"></div>';
|
||
loadEvents(document.getElementById('roomEvents'), '/api/site/rooms/' + encodeURIComponent(r.id) + '/events', { showHost: true }, 'No events scheduled in this room.');
|
||
}
|
||
// 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();
|
||
|
||
// ───── EVENT PAGE (/event/{id}) - laid out like the photo page ─────
|
||
async function renderEventPage() {
|
||
const root = document.getElementById('eventPage');
|
||
if (!root) return;
|
||
const id = routeSlug('event');
|
||
let data = null;
|
||
try { data = await apiGet('/api/site/events/' + encodeURIComponent(id)); } catch { data = null; }
|
||
if (!data || !data.ok || !data.event) { root.innerHTML = '<p class="image-missing">This event does not exist, or is private.</p>'; return; }
|
||
await SESSION_READY;
|
||
let e = data.event, going = data.going_players || [];
|
||
document.title = e.name + ' - Campus';
|
||
const $ = x => document.getElementById(x);
|
||
const pad = n => String(n).padStart(2, '0');
|
||
const icsStamp = d => d.getUTCFullYear() + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + 'T' + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds()) + 'Z';
|
||
|
||
// the "..." menu: copy link / add to calendar (wired once)
|
||
const menu = $('evMenu'), menuBtn = $('evMenuBtn');
|
||
menuBtn.addEventListener('click', ev => { ev.stopPropagation(); menu.classList.toggle('open'); });
|
||
document.addEventListener('click', () => menu.classList.remove('open'));
|
||
$('evShare').onclick = async () => {
|
||
menu.classList.remove('open');
|
||
try { await navigator.clipboard.writeText(location.origin + '/event/' + e.id); toast('Link copied'); }
|
||
catch { prompt('Copy this link', location.origin + '/event/' + e.id); }
|
||
};
|
||
$('evCal').onclick = () => {
|
||
menu.classList.remove('open');
|
||
const s = new Date(e.start), en = new Date(e.end);
|
||
const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Campus//Events//EN', 'BEGIN:VEVENT',
|
||
'UID:event-' + e.id + '@campus', 'DTSTAMP:' + icsStamp(new Date()), 'DTSTART:' + icsStamp(s), 'DTEND:' + icsStamp(en),
|
||
'SUMMARY:' + String(e.name).replace(/[\r\n]+/g, ' '),
|
||
'DESCRIPTION:' + ((e.description || '') + (e.room ? '\\nIn ^' + e.room.name : '') + '\\n' + location.origin + '/event/' + e.id).replace(/[\r\n]+/g, '\\n'),
|
||
'URL:' + location.origin + '/event/' + e.id, 'END:VEVENT', 'END:VCALENDAR'].join('\r\n');
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
|
||
a.download = 'campus-event-' + e.id + '.ics';
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
};
|
||
|
||
// JOIN: if you are online in the game, the server sends your game to the event
|
||
const join = $('evJoin');
|
||
join.addEventListener('click', async () => {
|
||
if (!CAMPUS_ME) { location.href = '/login'; return; }
|
||
join.disabled = true; const label = join.textContent; join.textContent = 'Joining\u2026';
|
||
const r = await apiPost('/api/site/events/' + encodeURIComponent(e.id) + '/join', {});
|
||
join.textContent = label; join.disabled = false;
|
||
toast(r && r.ok ? ((r.data && r.data.message) || 'Sending you to the event\u2026') : ((r && r.data && r.data.error) || 'Could not join the event'));
|
||
});
|
||
|
||
const draw = () => {
|
||
const s = new Date(e.start);
|
||
// the picture (photo-page style), with the status chip on it
|
||
$('evCover').style.backgroundImage = e.image ? `url('${e.image}')` : 'linear-gradient(135deg,#ff5a3c,#7b2ff7)';
|
||
$('evBadge').innerHTML = e.status === 'live' ? '<span class="pevent-live">LIVE NOW</span>' : e.status === 'ended' ? '<span class="pevent-tag">ENDED</span>' : '';
|
||
// who is hosting
|
||
const host = $('evHost');
|
||
if (e.host) {
|
||
host.hidden = false; host.href = userUrl(e.host.username);
|
||
const av = $('evHostAv'); av.src = e.host.profile_image || avatarUrl(e.host.username); av.onerror = () => { av.style.visibility = 'hidden'; };
|
||
$('evHostName').textContent = e.host.display_name || e.host.username;
|
||
$('evHostHandle').textContent = '@' + e.host.username;
|
||
} else host.hidden = true;
|
||
// title + one meta line: when, how many are going, where
|
||
$('evName').textContent = e.name;
|
||
const meta = $('evMeta');
|
||
meta.innerHTML = `<span>${escapeHtml(eventWhen(e))}</span><span>\u00b7</span><span>${e.going || 0} attending${e.interested ? ', ' + e.interested + ' interested' : ''}</span>`
|
||
+ (e.room ? `<span>\u00b7</span><a class="image-room" href="${roomUrl(e.room.id)}">^${escapeHtml(e.room.name)}</a>` : '');
|
||
|
||
// Join: only while you could actually go in; a signed-out visitor is sent to log in
|
||
join.hidden = e.status === 'ended';
|
||
const canGo = !!(CAMPUS_ME && e.canJoin);
|
||
join.classList.toggle('ev-join-off', !!CAMPUS_ME && !canGo);
|
||
join.title = !CAMPUS_ME ? 'Log in to join' : canGo ? 'Send me to this event (you need to be online in Campus)' : 'Opens when the event starts';
|
||
join.disabled = !!CAMPUS_ME && !canGo;
|
||
|
||
// RSVP pills in the photo page's button style
|
||
const rsvp = $('evRsvp');
|
||
if (e.status === 'ended' || !CAMPUS_ME) rsvp.innerHTML = '';
|
||
else {
|
||
const b = (r, label) => `<button type="button" class="img-report${e.myResponse === r ? ' on' : ''}" data-r="${r}">${label}</button>`;
|
||
rsvp.innerHTML = b('going', 'Going') + b('interested', 'Interested') + b('not_interested', 'Not interested');
|
||
rsvp.querySelectorAll('button').forEach(btn => btn.addEventListener('click', async () => {
|
||
rsvp.querySelectorAll('button').forEach(x => x.disabled = true);
|
||
const r = await apiPost('/api/site/events/' + encodeURIComponent(e.id) + '/respond', { response: e.myResponse === btn.dataset.r ? 'none' : btn.dataset.r });
|
||
if (r && r.ok && r.data && r.data.event) {
|
||
e = r.data.event;
|
||
let again = null; try { again = await apiGet('/api/site/events/' + encodeURIComponent(e.id)); } catch {}
|
||
if (again && again.going_players) going = again.going_players;
|
||
draw();
|
||
} else { rsvp.querySelectorAll('button').forEach(x => x.disabled = false); toast((r && r.data && r.data.error) || 'Could not save your answer'); }
|
||
}));
|
||
}
|
||
|
||
// below: about + who is going (the photo page's "Comments" section look)
|
||
const about = $('evAbout');
|
||
if (e.description && e.description.trim() && e.description.trim().toLowerCase() !== String(e.name).trim().toLowerCase()) { $('evDesc').textContent = e.description; about.hidden = false; } else about.hidden = true;
|
||
$('evGoingTitle').textContent = going.length ? `Attending \u00b7 ${e.going || going.length}` : 'Attending';
|
||
$('evPeople').innerHTML = going.length
|
||
? going.map(p => `<a class="ev-person" href="${userUrl(p.username)}"><img src="${escapeHtml(p.profile_image || avatarUrl(p.username))}" alt="" onerror="this.style.visibility='hidden'"><span>${escapeHtml(p.display_name || p.username)}</span></a>`).join('')
|
||
: '<p class="profile-empty">No one has said they are going yet.</p>';
|
||
};
|
||
draw();
|
||
}
|
||
renderEventPage();
|
||
|
||
// 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');
|
||
// dev accounts: the quick-admin button, after whatever buttons this branch adds
|
||
setTimeout(() => mountAdminButton('player', (u && u.id) || viewed, actions), 0);
|
||
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) loadEvents(eventsEl, '/api/site/profile/' + encodeURIComponent(viewed) + '/events', { showRoom: true }, isOwn ? "You aren't hosting any events." : 'No upcoming events.');
|
||
|
||
// ── 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)
|
||
if (apiOn() && /^\d+$/.test(String(id))) {
|
||
// a real photo: the server points your profile picture at it (no download / re-upload from the browser)
|
||
apiPost('/api/site/me/pfp', { photoId: Number(id) }).then(r => {
|
||
if (r && r.ok) {
|
||
toast('Profile picture updated');
|
||
const url = r.data && r.data.profile_image;
|
||
if (url) refreshAvatars(avKey(me), url);
|
||
} else toast((r && r.data && r.data.error) || 'Could not set the profile picture');
|
||
});
|
||
} else {
|
||
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';
|
||
}
|
||
|
||
// Export Data: the player's own account as one .json file (built by the server from their account; nothing is stored).
|
||
async function openExportData() {
|
||
if (!localStorage.getItem(USER_KEY)) { toast('Log in to export your data'); return; }
|
||
const btn = document.getElementById('exportData');
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const r = await fetch('/api/site/me/export', { credentials: 'same-origin' });
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
const blob = await r.blob();
|
||
const me = localStorage.getItem(USER_KEY) || 'campus';
|
||
saveFile(blob, `campus-data-${me}-${new Date().toISOString().slice(0, 10)}.json`);
|
||
toast('Your data was downloaded');
|
||
} catch { toast('Could not export your data - please try again'); }
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
|
||
// Export Rooms: pick from the rooms you CREATED, the browser fetches each room's files and zips them. The folder layout
|
||
// is the one the admin Archive importer reads, so an export can be imported back.
|
||
function openExportRooms() {
|
||
if (!localStorage.getItem(USER_KEY)) { toast('Log in to export your rooms'); 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 Rooms</h2>
|
||
<p class="report-sub">Rooms you created. Each room is saved with its settings, picture and every saved build.</p>
|
||
<div id="expRoomList" style="max-height:280px;overflow:auto"></div>
|
||
<p class="export-progress" id="expProgress"></p>
|
||
<div class="report-actions">
|
||
<button class="report-cancel export-cancel">Cancel</button>
|
||
<button class="report-submit export-go" disabled>Export</button>
|
||
</div>
|
||
</div>`;
|
||
const card = modal.querySelector('.export-card');
|
||
const list = card.querySelector('#expRoomList');
|
||
const prog = card.querySelector('#expProgress');
|
||
const go = card.querySelector('.export-go');
|
||
const say = t => { prog.hidden = false; prog.textContent = t; };
|
||
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);
|
||
modal.classList.add('open'); scrim.classList.add('open'); document.body.style.overflow = 'hidden';
|
||
say('Loading your rooms...');
|
||
|
||
const boxes = () => Array.from(list.querySelectorAll('input[data-room]'));
|
||
const refresh = () => { go.disabled = busy || !boxes().some(b => b.checked); };
|
||
|
||
apiGet('/api/site/me/rooms').then(d => {
|
||
const rooms = (d && d.ok && d.rooms) || [];
|
||
if (!rooms.length) { say('You have not created any rooms.'); return; }
|
||
prog.hidden = true;
|
||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
list.innerHTML = `<label class="export-opt"><input type="checkbox" id="expAllRooms" checked><span class="export-check"></span><span><b>All rooms (${rooms.length})</b></span></label>` +
|
||
rooms.map(r => `<label class="export-opt"><input type="checkbox" data-room="${esc(r.id)}" data-name="${esc(r.name)}" checked><span class="export-check"></span><span><b>${esc(r.name)}</b><small>${r.isDorm ? 'Your dorm - ' : ''}${r.subrooms} subroom${r.subrooms === 1 ? '' : 's'}</small></span></label>`).join('');
|
||
list.querySelector('#expAllRooms').addEventListener('change', e => { boxes().forEach(b => b.checked = e.target.checked); refresh(); });
|
||
boxes().forEach(b => b.addEventListener('change', refresh));
|
||
refresh();
|
||
});
|
||
|
||
go.addEventListener('click', async () => {
|
||
const picked = boxes().filter(b => b.checked);
|
||
if (!picked.length) return;
|
||
busy = true; go.disabled = true; card.querySelectorAll('input').forEach(i => i.disabled = true);
|
||
try {
|
||
const JSZip = await loadJSZip();
|
||
const zip = new JSZip();
|
||
let failed = 0, done = 0;
|
||
for (const b of picked) {
|
||
done++;
|
||
say(`Room ${done} / ${picked.length}: ${b.dataset.name}...`);
|
||
const man = await apiGet(`/api/site/me/rooms/${b.dataset.room}/manifest`);
|
||
if (!man || !man.ok) { failed++; continue; }
|
||
const dir = zip.folder(man.folder);
|
||
dir.file('RoomDetails.json', man.details);
|
||
for (const f of man.files) {
|
||
try {
|
||
const blob = await fetchBlob(`/api/site/me/rooms/${b.dataset.room}/file?name=${encodeURIComponent(f.name)}`);
|
||
dir.file(f.path, blob);
|
||
} catch { failed++; }
|
||
}
|
||
}
|
||
say('Zipping...');
|
||
const out = await zip.generateAsync({ type: 'blob', compression: 'STORE' });
|
||
const me = localStorage.getItem(USER_KEY) || 'campus';
|
||
saveFile(out, `campus-rooms-${me}-${new Date().toISOString().slice(0, 10)}.zip`);
|
||
say(`Done - ${picked.length} room${picked.length === 1 ? '' : 's'} exported${failed ? `, ${failed} file${failed === 1 ? '' : 's'} 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; card.querySelectorAll('input').forEach(i => i.disabled = false); refresh();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ───── QUICK ADMIN (profile + room pages) ─────
|
||
// The "Admin" button, for dev accounts only: /api/site/me says admin:true when the account is a
|
||
// developer AND this network could reach the admin panel. Every action goes through
|
||
// /api/site/admin/*, which re-checks both and runs the admin panel's own handler, so it bans,
|
||
// kicks and logs exactly like the panel does.
|
||
async function mountAdminButton(kind, key, container) {
|
||
if (!container || !key || !apiOn()) return;
|
||
await SESSION_READY;
|
||
if (!CAMPUS_ADMIN || container.querySelector('.admin-quick-btn')) return;
|
||
const b = document.createElement('button');
|
||
b.className = 'profile-report-btn admin-quick-btn';
|
||
b.textContent = 'Admin';
|
||
b.addEventListener('click', () => openAdminPopup(kind, key));
|
||
container.appendChild(b);
|
||
}
|
||
|
||
function adminDur(sec) {
|
||
if (sec == null) return 'permanently';
|
||
const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.ceil(sec % 3600 / 60);
|
||
return d ? `${d}d ${h}h left` : h ? `${h}h ${m}m left` : `${m}m left`;
|
||
}
|
||
|
||
async function openAdminPopup(kind, key) {
|
||
let scrim = document.getElementById('adminScrim');
|
||
let modal = document.getElementById('adminModal');
|
||
if (!modal) {
|
||
scrim = document.createElement('div'); scrim.id = 'adminScrim'; scrim.className = 'report-scrim';
|
||
modal = document.createElement('div'); modal.id = 'adminModal'; 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 = ''; };
|
||
scrim.onclick = close; modal.onclick = e => { if (e.target === modal) close(); };
|
||
const esc = s => escapeHtml(String(s == null ? '' : s));
|
||
const base = `/api/site/admin/${kind}/${encodeURIComponent(key)}`;
|
||
const ACC = ['Private', 'Public', 'Unlisted', 'Dev only', 'Dev unlisted'];
|
||
|
||
const playerHtml = d => {
|
||
const flags = [d.dev && 'Developer', d.mod && 'Moderator', d.junior && 'Junior'].filter(Boolean);
|
||
const where = d.online ? (d.room ? `Online in ^${esc(d.room.name)}${d.room.isPrivate ? ' (private)' : ''}` : 'Online') : 'Offline';
|
||
const noBan = d.dev || d.is_self;
|
||
return `<h2 class="report-title">Admin · @${esc(d.username)}</h2>
|
||
<p class="report-sub">#${d.id} · ${where} · Level ${d.level} · ${fmt(d.tokens)} tokens${flags.length ? ' · ' + flags.join(', ') : ''}</p>
|
||
${d.ban ? `<p class="adm-status">Banned ${d.ban.permanent ? 'permanently' : '(' + adminDur(d.ban.remaining_seconds) + ')'}${d.ban.reason ? ': ' + esc(d.ban.reason) : ''}</p>` : ''}
|
||
${d.mic_ban ? `<p class="adm-status">Mic banned ${d.mic_ban.permanent ? 'permanently' : '(' + adminDur(d.mic_ban.remaining_seconds) + ')'}</p>` : ''}
|
||
<div class="adm-sec"><h3>Room</h3><div class="adm-row">
|
||
<button class="adm-btn" data-act="kick" ${d.is_self ? 'disabled' : ''}>Kick</button>
|
||
<button class="adm-btn" data-act="join" ${d.is_self || !d.room ? 'disabled' : ''}>Go to them</button>
|
||
<button class="adm-btn" data-act="bring" ${d.is_self ? 'disabled' : ''}>Bring to me</button></div></div>
|
||
<div class="adm-sec"><h3>Ban</h3>
|
||
${d.ban ? `<div class="adm-row"><button class="adm-btn" data-act="unban">Unban</button></div>` : `
|
||
<div class="adm-row"><select class="adm-in" id="admBanDur" aria-label="Ban length">
|
||
<option value="3600">1 hour</option><option value="86400">1 day</option><option value="259200">3 days</option>
|
||
<option value="604800" selected>7 days</option><option value="2592000">30 days</option><option value="permanent">Permanent</option></select>
|
||
<input class="adm-in grow" id="admBanReason" maxlength="200" placeholder="Reason (they see this)" aria-label="Ban reason"></div>
|
||
<div class="adm-row"><label class="adm-chk"><input type="checkbox" id="admBanDevice"> Device</label>
|
||
<label class="adm-chk"><input type="checkbox" id="admBanIp"> IP</label>
|
||
<button class="adm-btn danger" data-act="ban" ${noBan ? 'disabled title="Developers can’t be banned"' : ''}>Ban</button></div>`}</div>
|
||
<div class="adm-sec"><h3>Mic</h3><div class="adm-row">
|
||
${d.mic_ban ? `<button class="adm-btn" data-act="micunban">Lift mic ban</button>` : `
|
||
<select class="adm-in" id="admMicMin" aria-label="Mic ban length"><option value="60">1 hour</option><option value="1440" selected>1 day</option><option value="10080">7 days</option><option value="0">Permanent</option></select>
|
||
<input class="adm-in grow" id="admMicReason" maxlength="200" placeholder="Reason" aria-label="Mic ban reason">
|
||
<button class="adm-btn" data-act="micban" ${d.is_self ? 'disabled' : ''}>Mic ban</button>`}</div></div>
|
||
<div class="adm-sec"><h3>Tokens</h3><div class="adm-row">
|
||
<input class="adm-in grow" id="admTokens" type="number" step="1" placeholder="Amount (negative takes away)" aria-label="Token amount">
|
||
<button class="adm-btn" data-act="tokens">Give</button></div></div>`;
|
||
};
|
||
|
||
const roomHtml = d => `<h2 class="report-title">Admin · ^${esc(d.name)}</h2>
|
||
<p class="report-sub">#${d.id} · by ${esc(d.creator || 'nobody')} · ${d.players_now} playing now${d.featured ? ' · Featured' : ''}${d.dorm ? ' · Dorm' : ''}</p>
|
||
<div class="adm-sec"><h3>Details</h3>
|
||
<div class="adm-row"><input class="adm-in grow" id="admRoomName" maxlength="60" value="${esc(d.name)}" placeholder="Name" aria-label="Room name"></div>
|
||
<div class="adm-row"><textarea class="adm-in grow" id="admRoomDesc" rows="2" maxlength="500" placeholder="Description" aria-label="Description">${esc(d.description)}</textarea></div>
|
||
<div class="adm-row"><select class="adm-in" id="admRoomAcc" aria-label="Who can join">${ACC.map((n, i) => `<option value="${i}"${i === d.accessibility ? ' selected' : ''}>${n}</option>`).join('')}</select>
|
||
<input class="adm-in adm-num" id="admRoomMax" type="number" min="1" max="100" value="${d.max_players}" title="Max players" aria-label="Max players">
|
||
<label class="adm-chk"><input type="checkbox" id="admRoomClone"${d.cloning ? ' checked' : ''}> Cloning</label>
|
||
<button class="adm-btn" data-act="edit">Save</button></div></div>
|
||
<div class="adm-sec"><h3>Room</h3><div class="adm-row">
|
||
<button class="adm-btn" data-act="${d.featured ? 'unfeature' : 'feature'}">${d.featured ? 'Unfeature' : 'Feature'}</button>
|
||
<button class="adm-btn danger" data-act="delete" ${d.dorm ? 'disabled title="A dorm can’t be deleted"' : ''}>Delete room</button></div></div>`;
|
||
|
||
const paintNote = (t, bad) => {
|
||
const n = modal.querySelector('.adm-note');
|
||
if (n) { n.hidden = false; n.textContent = t; n.classList.toggle('bad', !!bad); }
|
||
};
|
||
|
||
const act = async (btn, d) => {
|
||
const a = btn.dataset.act;
|
||
const v = id => { const el = modal.querySelector('#' + id); return el ? el.value : ''; };
|
||
const ck = id => { const el = modal.querySelector('#' + id); return !!(el && el.checked); };
|
||
let body = {};
|
||
if (a === 'ban') {
|
||
if (!v('admBanReason').trim()) { paintNote('Give a reason for the ban.', true); return; }
|
||
body = { duration: v('admBanDur'), reason: v('admBanReason'), device: ck('admBanDevice'), ip: ck('admBanIp') };
|
||
if (!confirm(`Ban @${d.username}${body.duration === 'permanent' ? ' permanently' : ''}?`)) return;
|
||
} else if (a === 'micban') {
|
||
body = { minutes: v('admMicMin'), reason: v('admMicReason') };
|
||
} else if (a === 'tokens') {
|
||
const t = v('admTokens').trim();
|
||
if (!/^-?\d+$/.test(t) || +t === 0) { paintNote('Enter a whole number of tokens.', true); return; }
|
||
body = { amount: t };
|
||
} else if (a === 'edit') {
|
||
body = { name: v('admRoomName'), description: v('admRoomDesc'), accessibility: v('admRoomAcc'), maxPlayers: v('admRoomMax'), cloningAllowed: ck('admRoomClone') ? 'true' : 'false' };
|
||
} else if (a === 'delete') {
|
||
if (!confirm(`Delete ^${d.name}? This can’t be undone.`)) return;
|
||
} else if (a === 'kick') {
|
||
if (!confirm(`Kick @${d.username} from their room?`)) return;
|
||
}
|
||
btn.disabled = true;
|
||
const { ok, data } = await apiPost(`${base}/${a}`, body);
|
||
const msg = (data && (data.message || data.error)) || (ok ? 'Done.' : 'That didn’t work.');
|
||
if (a === 'delete' && ok) { close(); toast(msg); setTimeout(() => { location.href = '/rooms'; }, 900); return; }
|
||
await paint(msg, !ok);
|
||
};
|
||
|
||
const paint = async (note, bad) => {
|
||
// apiGet is redefined further down to THROW on a non-OK answer (a lapsed session is a 403).
|
||
let d = null;
|
||
try { d = await apiGet(base); } catch { d = null; }
|
||
if (!d || !d.ok) {
|
||
modal.innerHTML = `<div class="report-card adm-card"><button class="report-close" aria-label="Close">✕</button>
|
||
<h2 class="report-title">Admin</h2><p class="report-sub">Couldn’t load this ${kind}. Your admin access may have ended - reload the page.</p></div>`;
|
||
modal.querySelector('.report-close').onclick = close;
|
||
return;
|
||
}
|
||
modal.innerHTML = `<div class="report-card adm-card"><button class="report-close" aria-label="Close">✕</button>
|
||
${kind === 'player' ? playerHtml(d) : roomHtml(d)}
|
||
<p class="adm-note${bad ? ' bad' : ''}" ${note ? '' : 'hidden'}>${esc(note || '')}</p>
|
||
<a class="adm-panel-link" href="/admin/home/v4?${kind}=${d.id}" target="_blank" rel="noopener">Open ${kind === 'player' ? '@' + esc(d.username) : '^' + esc(d.name)} in the admin panel</a></div>`;
|
||
const card = modal.querySelector('.adm-card');
|
||
card.querySelector('.report-close').onclick = close;
|
||
card.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => act(b, d)));
|
||
// The panel's login redirect drops ?player= / ?room=, so leave the target for it to pick up.
|
||
card.querySelector('.adm-panel-link').addEventListener('click', () => {
|
||
try { localStorage.setItem('campus-admin-open', JSON.stringify({ kind, id: d.id, at: Date.now() })); } catch {}
|
||
});
|
||
};
|
||
|
||
modal.innerHTML = `<div class="report-card adm-card"><p class="report-sub">Loading…</p></div>`;
|
||
modal.classList.add('open'); scrim.classList.add('open'); document.body.style.overflow = 'hidden';
|
||
await paint();
|
||
}
|
||
|
||
// ───── 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);
|
||
document.getElementById('exportData')?.addEventListener('click', openExportData);
|
||
document.getElementById('exportRooms')?.addEventListener('click', openExportRooms);
|
||
|
||
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 === 'Password' && apiOn()) {
|
||
const cur = document.getElementById('pwCur'), nw = document.getElementById('pwNew'), cf = document.getElementById('pwConf');
|
||
if (!cur.value) { toast('Enter your current password.'); return; }
|
||
if (nw.value.length < 6) { toast('Password must be at least 6 characters.'); return; }
|
||
if (nw.value !== cf.value) { toast("The passwords don't match."); return; }
|
||
btn.disabled = true;
|
||
const { ok, data } = await apiPost('/api/site/password', { current_password: cur.value, new_password: nw.value, confirm_password: cf.value });
|
||
btn.disabled = false;
|
||
if (!ok) { toast((data && data.error) || 'Could not change your password.'); return; }
|
||
cur.value = nw.value = cf.value = '';
|
||
toast('Password changed. Use it next time you log in, on the website and in the game.');
|
||
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', 'halloween'];
|
||
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');
|
||
halloweenFx(t === 'halloween');
|
||
}
|
||
|
||
// ───── SEASONS ─────
|
||
// October is Halloween: the first visit of each October switches the site to the Halloween theme
|
||
// (once - pick another theme and it sticks). After October a Halloween theme goes back to dark.
|
||
const HALLOWEEN_SEEN_KEY = 'campus-halloween-seen';
|
||
function applySeason() {
|
||
const now = new Date();
|
||
const october = now.getMonth() === 9;
|
||
let saved = null;
|
||
try { saved = localStorage.getItem(THEME_KEY); } catch { }
|
||
try {
|
||
if (october && localStorage.getItem(HALLOWEEN_SEEN_KEY) !== String(now.getFullYear())) {
|
||
localStorage.setItem(HALLOWEEN_SEEN_KEY, String(now.getFullYear()));
|
||
localStorage.setItem(THEME_KEY, 'halloween');
|
||
return 'halloween';
|
||
}
|
||
if (!october && saved === 'halloween') { localStorage.setItem(THEME_KEY, 'dark'); return 'dark'; }
|
||
} catch { }
|
||
return saved || 'light';
|
||
}
|
||
|
||
// ───── HALLOWEEN: cobwebs in the corners, spiders on threads and spiders crawling the page ─────
|
||
// Pure decoration in a fixed overlay under the top bar: pointer-events are off, so nothing ever
|
||
// blocks a click. Crawlers wander between random spots, pause, and scuttle away from the mouse.
|
||
// Reduce motion (site setting or the OS) keeps the cobwebs and drops every spider.
|
||
const WEB_SVG = '<svg viewBox="0 0 200 200" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">'
|
||
+ '<path d="M0 0L200 200M0 0L200 92M0 0L92 200M0 0L200 22M0 0L22 200"/>'
|
||
+ [34, 68, 104, 142, 182].map(r => {
|
||
const pts = [[r, 0], [r * .97, r * .11], [r * .9, r * .44], [r * .7, r * .7], [r * .44, r * .9], [r * .11, r * .97], [0, r]];
|
||
let d = 'M' + pts[0].join(' ');
|
||
for (let i = 1; i < pts.length; i++) {
|
||
const a = pts[i - 1], b = pts[i];
|
||
d += ' Q' + ((a[0] + b[0]) / 2 * .9).toFixed(1) + ' ' + ((a[1] + b[1]) / 2 * .9).toFixed(1) + ' ' + b[0].toFixed(1) + ' ' + b[1].toFixed(1);
|
||
}
|
||
return '<path d="' + d + '"/>';
|
||
}).join('')
|
||
+ '</svg>';
|
||
const SPIDER_SVG = '<svg viewBox="0 0 40 40" aria-hidden="true">'
|
||
+ '<g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">'
|
||
+ '<g class="legs-a"><path d="M16 17L9 11L5 5"/><path d="M15 23L7 23L2 27"/><path d="M24 20L32 18L37 13"/><path d="M25 26L31 31L33 37"/></g>'
|
||
+ '<g class="legs-b"><path d="M15 20L8 18L3 14"/><path d="M16 26L10 31L8 37"/><path d="M24 17L31 11L35 5"/><path d="M25 23L33 23L38 27"/></g>'
|
||
+ '</g>'
|
||
+ '<ellipse cx="20" cy="26" rx="6.5" ry="8" fill="currentColor"/>'
|
||
+ '<circle cx="20" cy="15.5" r="4.6" fill="currentColor"/>'
|
||
+ '<circle class="eye" cx="18.2" cy="13.8" r="1.1"/><circle class="eye" cx="21.8" cy="13.8" r="1.1"/>'
|
||
+ '</svg>';
|
||
|
||
let _hwRaf = 0, _hwBugs = [], _hwMouse = { x: -999, y: -999 }, _hwLast = 0, _hwBound = false;
|
||
function halloweenFx(on) {
|
||
let fx = document.querySelector('.hw-fx');
|
||
if (!on) { fx?.remove(); cancelAnimationFrame(_hwRaf); _hwRaf = 0; _hwBugs = []; return; }
|
||
if (fx) return;
|
||
fx = document.createElement('div'); fx.className = 'hw-fx'; fx.setAttribute('aria-hidden', 'true');
|
||
fx.innerHTML = ['tl', 'tr', 'br', 'bl'].map(c => '<div class="hw-web ' + c + '">' + WEB_SVG + '</div>').join('');
|
||
const phone = window.innerWidth < 640;
|
||
const bare = !document.querySelector('.topbar'); // log in / sign up: no top bar - the overlay starts at the top and nothing dangles over the form
|
||
if (bare) fx.classList.add('hw-bare');
|
||
// spiders on threads
|
||
for (let i = 0; i < (bare ? 0 : phone ? 1 : 3); i++) {
|
||
const d = document.createElement('div'); d.className = 'hw-dangle';
|
||
d.style.setProperty('--x', (12 + Math.random() * 76).toFixed(1) + '%');
|
||
d.style.setProperty('--y', (90 + Math.random() * 220).toFixed(0) + 'px');
|
||
d.style.setProperty('--d', (9 + Math.random() * 7).toFixed(1) + 's');
|
||
d.style.setProperty('--delay', (-Math.random() * 12).toFixed(1) + 's');
|
||
d.innerHTML = '<div class="hw-thread"></div><div class="hw-spider">' + SPIDER_SVG + '</div>';
|
||
fx.appendChild(d);
|
||
}
|
||
document.body.appendChild(fx);
|
||
// crawlers
|
||
const W = () => fx.clientWidth, H = () => fx.clientHeight;
|
||
_hwBugs = [];
|
||
for (let i = 0; i < (phone ? 2 : 4); i++) {
|
||
const el = document.createElement('div'); el.className = 'hw-spider walking'; el.innerHTML = SPIDER_SVG;
|
||
const scale = .7 + Math.random() * .6;
|
||
fx.appendChild(el);
|
||
el.style.opacity = '0'; // placed on the first frame the overlay has a real size
|
||
_hwBugs.push({ el, scale, placed: false, x: 0, y: 0, a: Math.random() * Math.PI * 2, tx: 0, ty: 0, speed: 0, pause: Math.random() * 2, flee: 0 });
|
||
}
|
||
if (!_hwBound) {
|
||
_hwBound = true;
|
||
window.addEventListener('pointermove', e => { _hwMouse.x = e.clientX; _hwMouse.y = e.clientY; }, { passive: true });
|
||
document.addEventListener('visibilitychange', () => { _hwLast = 0; });
|
||
}
|
||
_hwLast = 0;
|
||
const step = t => {
|
||
_hwRaf = requestAnimationFrame(step);
|
||
if (document.hidden) return;
|
||
const dt = _hwLast ? Math.min(.05, (t - _hwLast) / 1000) : 0; _hwLast = t;
|
||
const w = W(), h = H(), top = fx.getBoundingClientRect().top;
|
||
for (const b of _hwBugs) {
|
||
// A page that loads in a background tab has no size yet - wait for one before placing.
|
||
if (!Number.isFinite(b.x) || !Number.isFinite(b.y)) b.placed = false;
|
||
if (!b.placed) {
|
||
if (w < 50 || h < 50) continue;
|
||
b.x = 20 + Math.random() * (w - 40); b.y = 20 + Math.random() * (h - 40);
|
||
b.placed = true; b.el.style.opacity = ''; hwRetarget(b, w, h);
|
||
}
|
||
// scuttle away from the cursor
|
||
let mx = _hwMouse.x - b.x, my = (_hwMouse.y - top) - b.y, md = Math.hypot(mx, my);
|
||
if (md < .5) { mx = Math.cos(b.a); my = Math.sin(b.a); md = 1; } // cursor dead centre: no direction to flee, so just pick one
|
||
if (md < 110 && b.flee <= 0) {
|
||
b.flee = .9; b.pause = 0;
|
||
b.tx = Math.max(10, Math.min(w - 10, b.x - mx / md * 220));
|
||
b.ty = Math.max(10, Math.min(h - 10, b.y - my / md * 220));
|
||
b.speed = 230 + Math.random() * 80;
|
||
}
|
||
if (b.flee > 0) b.flee -= dt;
|
||
if (b.pause > 0) {
|
||
b.pause -= dt; b.el.classList.remove('walking');
|
||
} else {
|
||
const dx = b.tx - b.x, dy = b.ty - b.y, dist = Math.hypot(dx, dy);
|
||
if (dist < 4) { b.pause = Math.random() < .55 ? .6 + Math.random() * 2.4 : 0; hwRetarget(b, w, h); }
|
||
else {
|
||
// turn toward the target gradually, with a little wobble
|
||
const want = Math.atan2(dy, dx);
|
||
let da = want - b.a; while (da > Math.PI) da -= 2 * Math.PI; while (da < -Math.PI) da += 2 * Math.PI;
|
||
b.a += da * Math.min(1, dt * 6) + (Math.random() - .5) * dt * 1.5;
|
||
const v = b.speed * dt;
|
||
b.x += Math.cos(b.a) * v; b.y += Math.sin(b.a) * v;
|
||
b.el.classList.add('walking');
|
||
}
|
||
}
|
||
b.x = Math.max(-20, Math.min(w + 20, b.x)); b.y = Math.max(-20, Math.min(h + 20, b.y));
|
||
// the SVG faces up, so +90deg turns it to face its heading
|
||
b.el.style.transform = 'translate(' + (b.x - 17).toFixed(1) + 'px,' + (b.y - 17).toFixed(1) + 'px) rotate(' + (b.a * 180 / Math.PI + 90).toFixed(1) + 'deg) scale(' + b.scale + ')';
|
||
}
|
||
};
|
||
_hwRaf = requestAnimationFrame(step);
|
||
}
|
||
function hwRetarget(b, w, h) {
|
||
// mostly short hops; now and then a long run across the screen
|
||
const far = Math.random() < .3;
|
||
const r = far ? 400 + Math.random() * 500 : 60 + Math.random() * 200, a = Math.random() * Math.PI * 2;
|
||
b.tx = Math.max(15, Math.min(w - 15, b.x + Math.cos(a) * r));
|
||
b.ty = Math.max(15, Math.min(h - 15, b.y + Math.sin(a) * r));
|
||
b.speed = 40 + Math.random() * 70;
|
||
}
|
||
// 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(applySeason());
|
||
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>';
|
||
const PUMPKIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7c-1.2-2-.4-3.6 1-4.2"/><path d="M12 7c-5 0-8 2.6-8 6.5S7 20 12 20s8-2.6 8-6.5S17 7 12 7z"/><path d="M9 7.6C7.6 9.6 7.6 17 9 19.4M15 7.6c1.4 2 1.4 9.4 0 11.8"/></svg>';
|
||
// The drawer button cycles light -> dark -> autumn -> halloween 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>`, halloween: `${PUMPKIN_SVG}<span>Halloween 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 (banRefused(resp, out)) return;
|
||
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;
|
||
}
|
||
|
||
// ───── BAN BANNER ─────
|
||
// A banned account can sign in but can't change anything (the server refuses its writes). Every
|
||
// page shows this strip under the top bar: how long is left (live) and the reason. /api/site/me
|
||
// carries it as user.ban = { permanent, remaining_seconds, reason } - null when not banned.
|
||
function banLeftText(sec) {
|
||
sec = Math.max(0, Math.floor(sec));
|
||
const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
|
||
const parts = [];
|
||
if (d) parts.push(d + (d === 1 ? ' day' : ' days'));
|
||
if (d || h) parts.push(h + (h === 1 ? ' hour' : ' hours'));
|
||
if (!d) parts.push(m + (m === 1 ? ' minute' : ' minutes'));
|
||
if (!d && !h) parts.push(s + (s === 1 ? ' second' : ' seconds'));
|
||
return parts.join(' ');
|
||
}
|
||
function renderBanBanner(me) {
|
||
const ban = me && me.ban;
|
||
const old = document.getElementById('siteBanBanner');
|
||
if (!ban) { if (old) old.remove(); document.body.classList.remove('site-banned'); document.body.style.removeProperty('--ban-h'); return; }
|
||
const bar = old || document.createElement('div');
|
||
bar.id = 'siteBanBanner';
|
||
bar.className = 'site-ban-banner';
|
||
bar.setAttribute('role', 'alert');
|
||
bar.innerHTML = '<span class="sbb-icon" aria-hidden="true">⚠</span>'
|
||
+ '<span class="sbb-text"><strong>This account is currently banned</strong> <span class="sbb-left"></span>'
|
||
+ '<span class="sbb-reason"></span><span class="sbb-note">You can look around, but you can\'t change anything until the ban ends. Think it\'s a mistake? <a href="/support?new=appeal">Appeal it</a>.</span></span>';
|
||
bar.querySelector('.sbb-reason').textContent = ban.reason ? 'Reason: ' + ban.reason : '';
|
||
if (!old) document.body.insertBefore(bar, document.body.firstChild);
|
||
document.body.classList.add('site-banned');
|
||
const left = bar.querySelector('.sbb-left');
|
||
const ends = ban.permanent ? null : Date.now() + (ban.remaining_seconds || 0) * 1000;
|
||
const tick = () => {
|
||
if (!ends) { left.textContent = '- permanently'; return; }
|
||
const sec = (ends - Date.now()) / 1000;
|
||
if (sec <= 0) { left.textContent = '- the ban has ended, refresh the page'; clearInterval(bar._timer); return; }
|
||
left.textContent = '- ' + banLeftText(sec) + ' left';
|
||
};
|
||
clearInterval(bar._timer);
|
||
tick();
|
||
bar._timer = setInterval(tick, 1000);
|
||
// Push the page down by the banner's real height (it wraps on phones).
|
||
const fit = () => document.body.style.setProperty('--ban-h', bar.offsetHeight + 'px');
|
||
fit();
|
||
window.addEventListener('resize', fit);
|
||
}
|
||
|
||
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}. You will see it on the website and in the game. Nobody else will - it never changes their real name.</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);
|
||
})();
|