${escapeHtml(p.title)}
${escapeHtml(p.body)}
// ───── 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 = ``;
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
function applyCropToImg(img, d) {
if (!img || !d) return;
img.removeAttribute('srcset');
img.src = d.image;
img.style.objectFit = 'cover';
img.style.objectPosition = (d.pos && d.pos !== 'center') ? d.pos : 'center';
}
// ───── BACKEND ─────
// True when the Campus C# server is answering. apiBase is '' (same-origin), which is
// falsy, so it can't double as the "is the API up?" flag - that's what apiEnabled is for.
function apiOn() {
return !!(window.CAMPUS_DATA && window.CAMPUS_DATA.apiEnabled);
}
function apiUrl(path) {
return ((window.CAMPUS_DATA && window.CAMPUS_DATA.apiBase) || '') + path;
}
// The session lives in an HttpOnly cookie the server sets, so it is never readable from
// here - every call just needs credentials to ride along.
async function apiPost(path, body) {
const resp = await fetch(apiUrl(path), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
const out = await resp.json().catch(() => ({}));
return { ok: resp.ok && out.ok !== false, status: resp.status, data: out };
}
// DELETE an API path. Same envelope as apiPost.
async function apiDelete(path) {
const resp = await fetch(apiUrl(path), { method: 'DELETE', credentials: 'same-origin' });
const out = await resp.json().catch(() => ({}));
return { ok: resp.ok && out.ok !== false, status: resp.status, data: out };
}
// A photo's numeric server id from the element it was rendered in (data-photo), or null for
// demo content - which stays local-only.
function photoIdOf(el) {
const host = el && el.closest('[data-photo]');
const id = host && host.dataset.photo;
return id && /^\d+$/.test(id) ? id : null;
}
// GET an API path as JSON. Returns null on any failure so callers can fall back.
async function apiGet(path) {
try {
const resp = await fetch(apiUrl(path), { credentials: 'same-origin' });
if (!resp.ok) return null;
return await resp.json();
} catch { return null; }
}
// ───── CLEAN URLS (/user/{h}, /room/{id}, /image/{id}) ─────
// Pages are served at clean paths by WebController; the page JS reads its id/handle from
// the path here. Query params (?u=, ?id=) still work as a fallback for old links.
function userUrl(h) { return '/user/' + encodeURIComponent(String(h || '').replace(/^@/, '')); }
// Settings header: your own live status (Online · room / Offline) from the same profile API the
// profile page uses; re-run on every "profile" push about you.
async function renderSettingsStatus() {
const el = document.getElementById('suStatus');
const me = localStorage.getItem(USER_KEY);
if (!el || !me || !apiOn()) return;
const r = await apiGet('/api/site/profile/' + encodeURIComponent(me));
const st = r && r.status;
if (r && r.user && r.user.id) siteWatch(me, r.user.id);
const dot = el.querySelector('.su-dot');
if (dot) dot.style.background = st && st.online ? '#2ecc71' : '#9a9aa6';
el.lastChild.textContent = ' ' + (st ? (st.online ? ('Online' + (st.where ? ' · ' + st.where : '')) : 'Offline') : '');
}
// ---- realtime: which profile this tab is looking at ------------------------------------------
// The site socket (ensureNotifSocket) tells the server, and the server pushes "profile" (status,
// room, name, counts changed - re-fetch) and "spotify" (a new track / pause / seek, already
// filtered for this viewer) frames for it. SITE_WATCH is the handle; SITE_WATCH_ID the account id
// once a fetch has told us (the frames carry the id).
let SITE_WATCH = null, SITE_WATCH_ID = null;
function siteWatch(handle, id) {
if (handle) SITE_WATCH = String(handle).replace(/^@/, '');
if (id) SITE_WATCH_ID = String(id);
if (!SITE_WATCH) return;
try { if (NOTIF_WS && NOTIF_WS.readyState === 1) NOTIF_WS.send(JSON.stringify({ type: 'watch', profile: SITE_WATCH })); } catch {}
}
// ---- socials --------------------------------------------------------------------------
// Icon per platform (simple monochrome glyphs, currentColor). Keys match SiteSocials.
const SOCIAL_ICONS = {
discord: '',
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 + ''
: '';
}).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 ? '
' : '')
+ '
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 `${escapeHtml(p.body)}
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 = { 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 || ''; const EYE = ''; const THUMB = ''; const STAR = ''; document.getElementById('roomStats').innerHTML = `by @${r.creator || 'someone'}
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 `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 `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 = 'Choose what to include. The photos are zipped in your browser.
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 = 'Could not load your services.
'; 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 ? '' + escapeHtml(p.handle) + '' : escapeHtml(p.handle)))
: 'Not connected';
return ''
+ '';
}).join('');
// Discord / Spotify switches (their cards are static in the HTML)
for (const p of (r.providers || []).filter(p => !p.oauth)) {
const row = document.querySelector('.svc-show[data-svc="' + p.key + '"]');
const box = row && row.querySelector('.svc-show-toggle');
if (row) row.hidden = !p.linked;
if (box) box.checked = !!p.show;
}
host.querySelectorAll('.svc-link').forEach(b => b.addEventListener('click', () => { window.location.href = '/api/site/social/' + b.dataset.svc + '/start'; }));
host.querySelectorAll('.svc-unlink').forEach(b => b.addEventListener('click', async () => {
const { ok } = await apiPost('/api/site/social/' + b.dataset.svc + '/unlink');
if (!ok) { toast('Could not unlink.'); return; }
toast('Unlinked'); initSocialCards();
}));
document.querySelectorAll('.svc-show-toggle').forEach(t => {
if (t.dataset.wired) return; t.dataset.wired = '1';
t.addEventListener('change', async () => {
const { ok } = await apiPost('/api/site/social/' + t.dataset.svc + '/show', { show: t.checked });
toast(ok ? (t.checked ? 'Shown on your profile' : 'Hidden from your profile') : 'Could not save.');
});
});
}
// Back from a service's sign-in: toast the outcome, open Services.
const sq2 = new URLSearchParams(location.search).get('social');
if (sq2) {
const [prov, outcome] = sq2.split(':');
const label = prov ? prov.charAt(0).toUpperCase() + prov.slice(1) : 'Service';
const msg = { linked: label + ' linked!', expired: 'Link timed out — try again.', denied: label + ' access was declined.',
inuse: 'That ' + label + ' account is already linked to another player.',
unconfigured: label + ' linking is not set up on this server yet.', error: 'Could not link ' + label + '.' }[outcome] || null;
if (msg) toast(msg);
document.querySelector('.snav[data-panel="services"]')?.click();
history.replaceState(null, '', location.pathname);
}
// save buttons — Profile now POSTs to the server (was localStorage-only, so @name / display
// name / bio never reached the game or another browser). The server persists them and pushes
// an AccountUpdate so the change shows in-game without a relog.
// Email panel: load the account's current address, save it to the server.
const acctEmail = document.getElementById('acctEmail');
if (acctEmail && apiOn()) {
(async () => {
try { const r = await apiGet('/api/site/me'); if (r && r.ok) { acctEmail.value = r.email || ''; acctEmail.dataset.saved = r.email || ''; } } catch {}
})();
}
document.querySelectorAll('.ssave').forEach(btn => {
btn.addEventListener('click', async () => {
if (btn.dataset.save === 'Email' && acctEmail && apiOn()) {
const email = acctEmail.value.trim();
if (email === (acctEmail.dataset.saved || '')) { toast('Email settings saved'); return; }
btn.disabled = true;
const { ok, data } = await apiPost('/api/site/email', { email });
btn.disabled = false;
if (!ok) { toast((data && data.error) || 'Could not save your email.'); return; }
acctEmail.dataset.saved = data.email || '';
toast(email ? 'Email saved' : 'Email removed');
return;
}
if (btn.dataset.save !== 'Profile') { toast(`${btn.dataset.save} settings saved`); return; }
const payload = {};
if (username && username.value.trim()) payload.username = username.value.trim().replace(/[^a-zA-Z0-9_.]/g, '');
if (nick && nick.value.trim()) payload.display_name = nick.value.trim();
if (bio) payload.bio = bio.value.trim(); // bio can be cleared, so always send it
if (!Object.keys(payload).length) { toast('Nothing to change'); return; }
btn.disabled = true;
const { ok, data } = await apiPost('/api/site/profile', payload);
btn.disabled = false;
if (!ok) { toast((data && data.error) || 'Could not save your profile.'); return; }
// Reflect the SERVER's truth everywhere.
if (data.username) localStorage.setItem('campus-username', data.username);
if (data.display_name) localStorage.setItem('campus-mynick', data.display_name);
localStorage.setItem('campus-mybio', data.bio || '');
if (typeof CAMPUS_ME === 'object' && CAMPUS_ME) {
if (data.username) CAMPUS_ME.username = data.username;
if (data.display_name) CAMPUS_ME.display_name = data.display_name;
}
if (username) { username.value = ''; username.placeholder = data.username || myHandle(me); }
if (nick) { nick.value = ''; nick.placeholder = data.display_name || myDisplayName(me); }
if (suName) suName.textContent = data.display_name || myDisplayName(me);
if (suHandle) suHandle.textContent = '@' + (data.username || myHandle(me));
toast('Profile settings saved');
});
});
// logout actions
const logout = document.getElementById('setLogout');
if (logout) logout.addEventListener('click', async () => { if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} } clearIdentityCache(); window.location.href = '/'; });
const logoutAll = document.getElementById('logoutAll');
if (logoutAll) logoutAll.addEventListener('click', async () => { if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} } clearIdentityCache(); window.location.href = '/'; });
}
// ───── THEME (light/dark + accent) ─────
const THEME_KEY = 'campus-theme';
const ACCENT_KEY = 'campus-accent';
function hexRgb(h) { h = h.replace('#', ''); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); }
function darken(h, f) { const [r, g, b] = hexRgb(h); return `rgb(${r * f | 0},${g * f | 0},${b * f | 0})`; }
function softA(h, a) { const [r, g, b] = hexRgb(h); return `rgba(${r},${g},${b},${a})`; }
const THEMES = ['light', 'dark', 'autumn'];
function applyTheme(t) {
if (!THEMES.includes(t)) t = 'light';
document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('.cd-mode').forEach(b => b.classList.toggle('active', b.dataset.theme === t));
autumnLeaves(t === 'autumn');
}
// Autumn: a few leaves drift down the home hero. Pure decoration; reduce-motion stops them via CSS.
function autumnLeaves(on) {
const hero = document.querySelector('.home-hero');
if (!hero) return;
let box = hero.querySelector('.autumn-leaves');
if (!on) { box?.remove(); return; }
if (box) return;
box = document.createElement('div'); box.className = 'autumn-leaves'; box.setAttribute('aria-hidden', 'true');
const LEAF = '';
for (let i = 0; i < 9; i++) {
const l = document.createElement('span'); l.innerHTML = LEAF;
l.style.setProperty('--x', (5 + Math.random() * 90).toFixed(1) + '%');
l.style.setProperty('--d', (9 + Math.random() * 9).toFixed(1) + 's');
l.style.setProperty('--delay', (-Math.random() * 14).toFixed(1) + 's');
l.style.setProperty('--s', (.6 + Math.random() * .7).toFixed(2));
l.style.setProperty('--c', ['#f08a3c', '#e0722e', '#c95a22', '#f2b04a', '#d94f5c'][i % 5]);
box.appendChild(l);
}
hero.appendChild(box);
}
function applyAccent(c) {
const s = document.documentElement.style;
s.setProperty('--red', c);
s.setProperty('--red-dark', darken(c, 0.82));
s.setProperty('--red-soft', softA(c, 0.15));
document.querySelectorAll('.cd-accent').forEach(b =>
b.classList.toggle('active', b.dataset.accent.toLowerCase() === c.toLowerCase()));
}
// apply saved choices (runs on every page so theme persists)
applyTheme(localStorage.getItem(THEME_KEY) || 'light');
const savedAccent = localStorage.getItem(ACCENT_KEY);
if (savedAccent) applyAccent(savedAccent);
document.querySelectorAll('.cd-mode').forEach(b =>
b.addEventListener('click', () => { const t = b.dataset.theme; localStorage.setItem(THEME_KEY, t); applyTheme(t); syncThemeToggles(); }));
document.querySelectorAll('.cd-accent').forEach(b =>
b.addEventListener('click', () => { const c = b.dataset.accent; localStorage.setItem(ACCENT_KEY, c); applyAccent(c); }));
// drawer Light/Dark toggle (under Discord)
const SUN_SVG = '';
const MOON_SVG = '';
const LEAF_SVG = '';
// The drawer button cycles light -> dark -> autumn and always names the NEXT theme.
function nextTheme() {
const cur = document.documentElement.getAttribute('data-theme') || 'light';
return THEMES[(THEMES.indexOf(cur) + 1) % THEMES.length];
}
function syncThemeToggles() {
const next = nextTheme();
const label = { light: `${SUN_SVG}Light mode`, dark: `${MOON_SVG}Dark mode`, autumn: `${LEAF_SVG}Autumn mode` }[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 data-attribute it controls
const A11Y_ATTRS = {
motion: 'data-reduce-motion',
text: 'data-large-text',
contrast: 'data-high-contrast',
links: 'data-underline-links',
spacing: 'data-readable-spacing',
dyslexia: 'data-dyslexia-font',
focus: 'data-focus-rings',
cursor: 'data-big-cursor',
taptargets: 'data-tap-targets',
};
function getA11y() { try { return JSON.parse(localStorage.getItem(A11Y_KEY) || '{}'); } catch { return {}; } }
function applyA11y(prefs) {
const root = document.documentElement;
Object.keys(A11Y_ATTRS).forEach(k => root.toggleAttribute(A11Y_ATTRS[k], !!prefs[k]));
document.querySelectorAll('.cd-a11y').forEach(btn => {
const on = !!prefs[btn.dataset.a11y];
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
applyA11y(getA11y());
document.querySelectorAll('.cd-a11y').forEach(btn => {
btn.addEventListener('click', () => {
const prefs = getA11y();
const k = btn.dataset.a11y;
prefs[k] = !prefs[k];
localStorage.setItem(A11Y_KEY, JSON.stringify(prefs));
applyA11y(prefs);
syncToggleAll();
});
});
const a11yReset = document.getElementById('a11yReset');
if (a11yReset) a11yReset.addEventListener('click', () => {
localStorage.removeItem(A11Y_KEY);
applyA11y({});
syncToggleAll();
});
// "Enable all / Disable all" — flips every accessibility setting at once
const a11yToggleAll = document.getElementById('a11yToggleAll');
function allA11yOn() { const p = getA11y(); return Object.keys(A11Y_ATTRS).every(k => !!p[k]); }
function syncToggleAll() {
if (a11yToggleAll) a11yToggleAll.textContent = allA11yOn() ? 'Disable all' : 'Enable all';
}
if (a11yToggleAll) {
a11yToggleAll.addEventListener('click', () => {
const turnOn = !allA11yOn();
const prefs = {};
Object.keys(A11Y_ATTRS).forEach(k => { prefs[k] = turnOn; });
localStorage.setItem(A11Y_KEY, JSON.stringify(prefs));
applyA11y(prefs);
syncToggleAll();
});
syncToggleAll();
}
initSettings();
// ───── PHOTO FEED (API-driven) ─────
//
// Point this at your "recent photos" endpoint. It should return JSON like:
// [ { image, user, handle, room, cheers, comments, takenAt }, ... ]
// where `takenAt` is an ISO date string. Leave blank to use the fallback below.
const PHOTOS_API = ''; // e.g. 'https://api.campus.net/v1/photos/recent'
const postList = document.getElementById('postList');
// gradient palette for the author avatars (by first letter, just for colour)
const AV = [
['#ff5a3c', '#ffb142'], ['#f0453f', '#ff8a5c'], ['#ff6b9d', '#ffc371'],
['#e84393', '#ff7f6e'], ['#c44569', '#ff7f50'], ['#d63031', '#ff7675'],
];
function avColor(name) { return AV[(name.charCodeAt(0)) % AV.length]; }
function avKey(seed) { return (seed || 'player').toLowerCase().replace(/[^a-z0-9]/g, ''); }
function avatarUrl(seed) {
const key = avKey(seed);
try {
const pfp = JSON.parse(localStorage.getItem('campus-pfp-' + key) || 'null');
if (pfp && pfp.image) return pfp.image; // use the user's chosen profile picture
} catch {}
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(key)}`;
}
// Push the chosen profile image to the Campus server, which decodes it, stores it under
// Data/Images/player_images and points the account row at it. The optimistic UI already
// updated; this makes it stick (and makes it visible in-game).
//
// The old JSON {imageUrl} fallback is gone with server.py: that asked the backend to go
// fetch a URL on the browser's behalf, which is a server-side request forgery primitive.
// The server only accepts bytes it was handed.
async function syncProfileImageToApi(imageUrl) {
// Deliberately NOT gated on CAMPUS_ME: syncSession() is async, so a quick change right
// after page load would race it and silently drop the upload. Let the server decide -
// it answers 401 when there's no session and the catch below surfaces that.
if (!apiOn()) return; // offline demo mode
try {
const blob = await (await fetch(imageUrl)).blob();
const form = new FormData();
form.append('image', blob, 'profile.png');
const resp = await fetch(apiUrl('/api/profile-image'), {
method: 'POST',
credentials: 'same-origin', // carries the HttpOnly session cookie
body: form,
});
const out = await resp.json().catch(() => ({}));
if (!resp.ok || !out.ok) {
toast('Saved locally, but the server update failed');
console.warn('[campus] profile image API error:', out.error || resp.status);
}
} catch (e) {
toast('Saved locally, but the server was unreachable');
console.warn('[campus] profile image API unreachable:', e);
}
}
// update every avatar image for a given user, live
function refreshAvatars(key, src) {
document.querySelectorAll(`img[data-av="${key}"]`).forEach(im => {
im.src = src;
im.style.objectFit = 'cover';
});
}
// cross-tab: when the profile picture changes in another tab, update here too
window.addEventListener('storage', e => {
if (!e.key || !e.key.startsWith('campus-pfp-')) return;
const key = e.key.slice('campus-pfp-'.length);
let src = null;
try { const d = JSON.parse(e.newValue || 'null'); if (d && d.image) src = d.image; } catch {}
if (!src) src = `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(key)}`;
refreshAvatars(key, src);
});
// "2 minutes ago", "3 hours ago", "5 days ago" - the profile's last-online line.
function lastOnlineText(iso) {
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
if (isNaN(s) || s < 0) return '';
if (s < 60) return 'just now';
const unit = (n, w) => `${n} ${w}${n === 1 ? '' : 's'} ago`;
if (s < 3600) return unit(Math.floor(s / 60), 'minute');
if (s < 86400) return unit(Math.floor(s / 3600), 'hour');
if (s < 86400 * 30) return unit(Math.floor(s / 86400), 'day');
if (s < 86400 * 365) return unit(Math.floor(s / (86400 * 30)), 'month');
return unit(Math.floor(s / (86400 * 365)), 'year');
}
function timeAgo(iso) {
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
if (isNaN(s)) return '';
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
}
function fmt(n) { return n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : n; }
const HEART = '';
const CHEER_ICON = '';
const CHAT = '';
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
? ``
: ``;
const h = String(c.handle || c.user || 'player').replace(/^@/, '');
return `
@${escapeHtml(h)} ${escapeHtml(c.text)}
${action}
`;
}
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('') || 'No comments yet - be the first!
';
list.dataset.loaded = '1';
}
function commentsBlock(p) {
const me = localStorage.getItem(USER_KEY);
if (!me) {
return `Log in to view and add comments`;
}
const list = (p.thread || []).map(commentRow).join('') || 'No comments yet - be the first!
';
return `
${list}
`;
}
function renderPosts(posts) {
if (!postList) return;
if (!posts.length) { postList.innerHTML = 'No photos yet - check back soon.'; 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 `
${(p.user || '?')[0].toUpperCase()}
${p.takenAt ? timeAgo(p.takenAt) : (p.time || '')}
${p.caption || ''}
${commentsBlock(p)}
`;
}).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 `
${p.user[0]}
${timeAgo(p.takenAt)}
${p.caption}
${commentsBlock(p)}
`;
}).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 `
${escapeHtml(m.name)}${escapeHtml(m.sub)}
`;
}
// 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 `
${escapeHtml(m.name)}${escapeHtml(m.sub)}
`;
}
// 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 = `Start typing to search ${activeTab === 'people' ? 'players' : 'rooms'}.
`;
return;
}
searchResults.innerHTML = apiOn()
? 'Searching…
'
: 'Search is unavailable right now.
';
}
// 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 = 'No results found
'; return; }
searchResults.innerHTML = list.map(searchRow).join('');
} catch (e) {
console.warn('[campus] live search failed:', e);
searchResults.innerHTML = 'Search failed - try again.
';
}
}
// 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 = 'Search is unavailable right now.
'; return; }
searchResults.innerHTML = 'Searching…
';
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 = '';
searchClear.style.display = 'none';
field.appendChild(searchClear);
searchClear.addEventListener('click', () => { searchInput.value = ''; handleSearchInput(); searchInput.focus(); });
}
searchBtn.addEventListener('click', () => {
searchPanel.classList.contains('open') ? closeSearch() : openSearch();
});
searchScrim.addEventListener('click', closeSearch);
searchInput.addEventListener('input', handleSearchInput);
document.querySelectorAll('.stab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelector('.stab.active')?.classList.remove('active');
tab.classList.add('active');
activeTab = tab.dataset.tab;
searchInput.placeholder = activeTab === 'people' ? 'Search people…' : 'Search rooms…';
handleSearchInput();
});
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSearch(); });
// click the dimmed background (outside the card) to close
searchPanel.addEventListener('click', e => {
if (e.target === searchPanel) closeSearch();
});
}
// ───── FAQ ACCORDION ─────
document.querySelectorAll('.dl-faq .faq-q').forEach(q => {
q.addEventListener('click', () => q.closest('.faq-item').classList.toggle('open'));
});
// ───── RATE LIMITING + TOAST ─────
// returns true if the action is allowed, false if the limit is hit
function rateLimit(action, max, windowMs) {
const key = 'campus-rl-' + action;
const now = Date.now();
let arr = [];
try { arr = JSON.parse(localStorage.getItem(key) || '[]'); } catch { arr = []; }
arr = arr.filter(t => now - t < windowMs);
if (arr.length >= max) { localStorage.setItem(key, JSON.stringify(arr)); return false; }
arr.push(now);
localStorage.setItem(key, JSON.stringify(arr));
return true;
}
function toast(msg) {
let t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove('show'), 2800);
}
// returns true if logged in; otherwise prompts to log in
function requireLogin(action) {
if (localStorage.getItem(USER_KEY)) return true;
toast(`Log in to ${action}`);
return false;
}
// ───── REPORT SYSTEM ─────
const REPORT_REASONS = ['Spam or scam', 'Harassment or bullying', 'Inappropriate content', 'Hate speech', 'Impersonation', 'Other'];
function closeReport() {
document.getElementById('reportModal')?.classList.remove('open');
document.getElementById('reportScrim')?.classList.remove('open');
document.body.style.overflow = '';
}
// ctx = {type: 'player'|'photo'|'comment', id} - what is being reported. With the API on,
// the report is filed to the server (it lands in the admin panel's Reports queue); the
// onSubmit callback only updates the button that opened it.
function openReport(title, onSubmit, ctx) {
if (ctx && apiOn() && !localStorage.getItem(USER_KEY)) { toast('Log in to send a report'); return; }
let scrim = document.getElementById('reportScrim');
let modal = document.getElementById('reportModal');
if (!modal) {
scrim = document.createElement('div'); scrim.id = 'reportScrim'; scrim.className = 'report-scrim';
modal = document.createElement('div'); modal.id = 'reportModal'; modal.className = 'report-modal';
document.body.appendChild(scrim); document.body.appendChild(modal);
scrim.addEventListener('click', closeReport);
// click the dimmed area outside the card to close
modal.addEventListener('click', e => { if (e.target === modal) closeReport(); });
}
modal.innerHTML = `
${title}
Tell us what's wrong. Reports are anonymous.
${REPORT_REASONS.map(r => ``).join('')}
`;
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 = `
Report submitted
Thanks - our moderation team will review this shortly.
`;
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 = `
Nickname
Set a custom nickname for @${handle}.
`;
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 ? ' ★' : '';
if (pending) {
return `
`;
}
const status = (f.online
? `Online${f.where ? ' · ' + escapeHtml(f.where) : ''}`
: 'Offline')
+ (f.spotify && f.spotify.track ? `
${SP_ICON}${f.spotify.is_playing ? '' : 'Paused · '}${escapeHtml(f.spotify.track)} — ${escapeHtml(f.spotify.artists || '')}` : '');
const favBtn = cache.own
? ``
: '';
return ``;
}
function render() {
if (!cache) {
modal.innerHTML = `Friends
Loading…
`;
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('')
: `${filter === 'pending' ? 'No pending requests.' : filter === 'favourites' ? 'No favourites yet.' : 'No friends yet.'}
`;
modal.innerHTML = `
${cache.own ? 'Friends' : escapeHtml(cap(FRIENDS_MODAL_HANDLE)) + "'s friends"}
${friends.length} friend${friends.length === 1 ? '' : 's'} · ${onlineCount} online
${cache.own ? `` : ''}
${rows}
`;
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 ? 'Loading…
'
: 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 ? `Online${p.where ? ' · ' + escapeHtml(p.where) : ''}`
: `${fmt(p.subscribers)} subscriber${p.subscribers === 1 ? '' : 's'}`;
const canSub = me && p.username && p.username.toLowerCase() !== me.toLowerCase();
const btn = canSub ? `` : '';
return ``;
}).join('')
: `${kind === 'subscribers' ? 'No subscribers yet.' : 'Not subscribed to anyone yet.'}
`;
modal.innerHTML = `
${title}
${list ? `${list.length} ${kind === 'subscribers' ? 'subscriber' : 'subscription'}${list.length === 1 ? '' : 's'}` : ''}
${rows}
`;
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 = `
${title}
${message}
`;
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 = 'No comments yet - be the first!
';
}
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 = 'Reported';
}, 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 = 'No comments yet - be the first!
'; }
});
} 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
? ``
: ``;
}
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 : '')
+ 'Top Rooms
'
+ rooms.slice(0, 8).map(r => `
${roomThumb(r)}
`).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 = `${heading} · 0
${emptyMsg}
`;
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 `
`;
}).join('');
const html = `${heading} · ${people.length}
${items}`;
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 = 'No photos have been shared yet.';
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 = '';
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 =
'Rooms' +
'News' +
'Support';
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 = '';
const ICON_CHEER = CHEER_ICON;
function card(r) {
const name = String(r.name || '').replace(/^\^/, '');
const img = r.image
? ``
: `^${escapeHtml(name)}`;
return `
${img}
^${escapeHtml(name)}
${r.creator ? '@' + escapeHtml(r.creator) : ''}
${ICON_VISITS}${fmt(r.visits || 0)}${ICON_CHEER}${fmt(r.cheers || 0)}
`;
}
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('') : 'No rooms to show yet.';
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 = 'Loading rooms…'; }
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 || 'No rooms match that.';
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 = 'Could not load rooms.';
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);
})();