// ───── 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 = `${ch}`; 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 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 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' ? 'LIVE' : e.status === 'ended' ? 'ENDED' : ''; const canRsvp = e.status !== 'ended' && !!(typeof CAMPUS_ME !== 'undefined' && CAMPUS_ME); const rsvp = canRsvp ? `
` + `
` : ''; // the Rec Room style card: picture + title + @host, a big picture, when, N ATTENDING return `
${escapeHtml(e.name)}${hostName ? '@' + escapeHtml(hostName) : ''} ${badge}
${escapeHtml(eventWhen(e))} ${e.going || 0} ATTENDING
${rsvp}
`; } function renderEvents(el, events, opts, emptyText) { if (!events || !events.length) { el.innerHTML = `

${escapeHtml(emptyText || 'No events.')}

`; 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 = `

${escapeHtml(emptyText || 'No events.')}

`; return; } el.innerHTML = '
Loading events\u2026
'; // (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: '', x: '', youtube: '', tiktok: '', instagram: '', twitch: '', steam: '', github: '', reddit: '', spotify: '' }; 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 + '' + escapeHtml(r.handle || '') + ''; return r.url ? '' + inner + '' : '' + inner + ''; }).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 = ''; 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 ? '' : '') + '
' + '
' + SP_ICON + '' + (np.is_playing ? 'Listening to ' : 'Paused · ') + '' + esc(np.track) + '' + '— ' + esc(np.artists || np.album || '') + '
' + '
' + '
' + spFmt(np.duration_ms) + '
' + '
'; 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 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 = ` `; 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 ``; } 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 = '

Log in to see your notifications

'; } else if (NOTIF_ITEMS.length === 0) { rows = '

You\'re all caught up

'; } else { rows = NOTIF_ITEMS.map(n => { const time = n.time ? `${notifTimeAgo(n.time)}` : ''; if (n.kind === 'friend_request') { return `
${notifAvatarHtml(n)}
${escapeHtml(n.text)}
`; } if (n.kind === 'support') { return `
${escapeHtml(n.text)}${time}
${n.id === 'supq' ? '' : ''}
`; } if (n.kind === 'launch') { return `
${escapeHtml(n.text)}${time}
`; } return `
${notifAvatarHtml(n)}
${escapeHtml(n.text)}${time}
`; }).join(''); } const hasDismissable = Array.isArray(NOTIF_ITEMS) && NOTIF_ITEMS.some(n => n.kind !== 'friend_request'); const clear = hasDismissable ? '' : ''; NOTIF_MENU.innerHTML = `
Notifications${clear}
${rows}
`; 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 => ` ${p.online ? '' : ''}
${escapeHtml(p.nickname || p.display_name || p.username)}${p.online ? (p.where ? 'In ^' + escapeHtml(p.where) : 'Online') : '@' + escapeHtml(p.username)}
`).join(''); const html = `

Pinned friends

${items}
`; 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 `
${escapeHtml(nick)}@${h}
`; }).join(''); rail.insertAdjacentHTML('afterbegin', `

Pinned friends

${items}
`); } // 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 `
${escapeHtml(d.name)}${escapeHtml(d.where)}
`; }).join(''); rail.insertAdjacentHTML('afterbegin', `

Online Friends · ${list.length}

${items}
`); } // ───── 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 => `
${p.tag ? `${escapeHtml(p.tag)}` : ''} ${escapeHtml(fmtDate(p.date))}

${escapeHtml(p.title)}

${escapeHtml(p.body)}

`).join('') || '

Nothing here yet.

'; } 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 = '

Room not found.

'; 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 = ''; const THUMB = ''; const STAR = ''; document.getElementById('roomStats').innerHTML = `
${EYE}${fmt(r.visits || 0)}visits
${THUMB}${fmt(r.cheers || 0)}cheers
${STAR}${fmt(r.favorites || 0)}favorites
`; // 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 = '
No photos taken in this room yet.
'; 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 => `${escapeHtml(t.display_name || t.username)}`).join(''); return `
${(p.user || '?')[0].toUpperCase()} ${p.takenAt ? timeAgo(p.takenAt) : ''}
^${escapeHtml(p.room)}${tags ? `In this photo:${tags}` : ''}${p.caption ? ` ${escapeHtml(p.caption)}` : ''}
${commentsBlock(p)}
`; }).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 = '
'; 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 = '

This event does not exist, or is private.

'; 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' ? 'LIVE NOW' : e.status === 'ended' ? 'ENDED' : ''; // 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 = `${escapeHtml(eventWhen(e))}\u00b7${e.going || 0} attending${e.interested ? ', ' + e.interested + ' interested' : ''}` + (e.room ? `\u00b7^${escapeHtml(e.room.name)}` : ''); // 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) => ``; 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 => `${escapeHtml(p.display_name || p.username)}`).join('') : '

No one has said they are going yet.

'; }; 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 = ` `; const slide = document.getElementById('featuredSlide'); const dots = document.getElementById('featuredDots'); dots.innerHTML = ids.map((_, idx) => ``).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 = ` `; 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 = `

This Profile cannot be viewed at this time

Back to home

`; 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', ' ★'); } 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', `aka ${escapeHtml(n)}`); } 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 = ``; // 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', ` ${pendingN}`); 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 = ''; fp.innerHTML = ` Friend options `; 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 = 'Subscriptions'; 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 = ''; const tile = p => `${p.isPrivate ? lockBadge : ''}`; // "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('') : `

No photos taken yet.

`; if (photosTaggedEl) photosTaggedEl.innerHTML = tagged.length ? tagged.map(tile).join('') : `

Not tagged in any photos yet.

`; } else { if (photosTaggedEl) photosTaggedEl.innerHTML = `

Not tagged in any photos yet.

`; 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 ``; }).join('') : `

No photos yet.

`; } // 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 = `

No photos yet.

`; } // ── 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 = ''; 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 `
^${escapeHtml(r.name || 'Room')}@${escapeHtml(r.creator || handleText)}
${PERSON}${fmt(r.visits || 0)}
`; }).join('') : `

No rooms yet.

`; } 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 `
^${r.name}@${viewed}
${CHEER_ICON}${fmt(r.players)} ${PERSON}${r.visits}
`; }).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 = '

Photo not found.

'; 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 = ''; 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 = '
In this photo
' + tagged.map(t => `
` + `@${escapeHtml(t.username)}` + (ph.isOwner ? `` : '') + `
`).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', ''], ['friends', 'Friends', ''], ['public', 'Public', ''], ]; seg.innerHTML = opts.map(([v, label, path]) => ``).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 = ''; row.appendChild(pencil); pencil.addEventListener('click', () => { const current = titleEl.textContent; const editor = document.createElement('div'); editor.className = 'title-editor'; editor.innerHTML = ``; 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 ? `${timeAgo(ph.takenAt)} ago` : '') + (ph.room && ph.room.name ? `·^${escapeHtml(ph.room.name)}` : ''); // 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 = ``; const tools = document.getElementById('imageTools'); if (tools) tools.insertAdjacentHTML('afterbegin', cheerBtn); else { const form = host.querySelector('.comment-form'); if (form) form.insertAdjacentHTML('beforebegin', `
${cheerBtn}
`); else host.querySelector('.post-comments')?.insertAdjacentHTML('beforeend', `
${cheerBtn}
`); } 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); } // "_.<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); })();