the files
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Accessibility - Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="a11y-page">
|
||||
<h1 class="a11y-title">Accessibility</h1>
|
||||
<p class="a11y-intro">Tune how Campus looks and feels. These settings are saved on this device and apply across the whole site.</p>
|
||||
|
||||
<div class="a11y-list">
|
||||
<div class="a11y-row">
|
||||
<div class="a11y-text"><span class="a11y-name">Reduce motion</span><span class="a11y-desc">Minimise animations and transitions.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="motion" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row">
|
||||
<div class="a11y-text"><span class="a11y-name">Larger text</span><span class="a11y-desc">Increase the base font size for easier reading.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="text" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row">
|
||||
<div class="a11y-text"><span class="a11y-name">High contrast</span><span class="a11y-desc">Strengthen borders and text for better visibility.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="contrast" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row">
|
||||
<div class="a11y-text"><span class="a11y-name">Underline links</span><span class="a11y-desc">Always underline links so they're easy to spot.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="links" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row">
|
||||
<div class="a11y-text"><span class="a11y-name">Readable spacing</span><span class="a11y-desc">Add more space between letters and lines of text.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="spacing" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row">
|
||||
<div class="a11y-text"><span class="a11y-name">Dyslexia-friendly font</span><span class="a11y-desc">Switch to a typeface that's easier to read.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="dyslexia" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row a11y-pc-only">
|
||||
<div class="a11y-text"><span class="a11y-name">Always show focus outline</span><span class="a11y-desc">Highlight the focused control when navigating by keyboard.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="focus" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row a11y-pc-only">
|
||||
<div class="a11y-text"><span class="a11y-name">Larger cursor</span><span class="a11y-desc">Make the mouse pointer bigger and easier to track.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="cursor" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
<div class="a11y-row a11y-touch-only">
|
||||
<div class="a11y-text"><span class="a11y-name">Bigger tap targets</span><span class="a11y-desc">Add more space around buttons and links so they're easier to tap.</span></div>
|
||||
<button class="cd-a11y a11y-toggle" data-a11y="taptargets" aria-pressed="false"><span class="cd-a11y-sw"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a11y-actions">
|
||||
<button class="a11y-toggle-all" id="a11yToggleAll" type="button">Enable all</button>
|
||||
<button class="a11y-reset" id="a11yReset" type="button">Reset all to default</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4558 @@
|
||||
// ───── STORAGE SAFETY + SCHEMA VERSION ─────
|
||||
// Wrap localStorage.setItem so a full quota (or privacy-mode block) never throws
|
||||
// and breaks the page — it degrades gracefully with a warning instead.
|
||||
(function () {
|
||||
if (!window.localStorage) return;
|
||||
const _set = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function (k, v) {
|
||||
try { return _set.call(this, k, v); }
|
||||
catch (e) { console.warn('[campus] storage write failed for', k, e && e.name); }
|
||||
};
|
||||
})();
|
||||
|
||||
// Bump this when the shape of stored data changes incompatibly; old state is
|
||||
// cleared once so users don't get stuck with a broken/mismatched cache.
|
||||
const SCHEMA_VERSION = '1';
|
||||
try {
|
||||
const prev = localStorage.getItem('campus-schema-version');
|
||||
if (prev && prev !== SCHEMA_VERSION) {
|
||||
// future migrations would run here; for now we just record the new version
|
||||
}
|
||||
localStorage.setItem('campus-schema-version', SCHEMA_VERSION);
|
||||
} catch {}
|
||||
|
||||
// Global image fallback: if an avatar (DiceBear) or content image (Picsum) fails
|
||||
// to load — e.g. the external service is down — show a graceful placeholder
|
||||
// instead of a broken-image icon.
|
||||
function initialsAvatar(seed) {
|
||||
const ch = (String(seed || '?').replace(/[^a-zA-Z0-9]/g, '')[0] || '?').toUpperCase();
|
||||
const colors = ['#ff5a3c', '#f0453f', '#ff6b9d', '#e84393', '#c44569', '#d63031'];
|
||||
const bg = colors[ch.charCodeAt(0) % colors.length];
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="${bg}"/><text x="50" y="50" dy=".35em" text-anchor="middle" font-family="Inter,sans-serif" font-size="48" font-weight="700" fill="#fff">${ch}</text></svg>`;
|
||||
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
|
||||
}
|
||||
document.addEventListener('error', e => {
|
||||
const img = e.target;
|
||||
if (!img || img.tagName !== 'IMG' || img.dataset.failed) return;
|
||||
img.dataset.failed = '1';
|
||||
if (img.hasAttribute('data-av')) {
|
||||
img.src = initialsAvatar(img.getAttribute('data-av') || img.getAttribute('alt'));
|
||||
} else {
|
||||
img.classList.add('img-failed');
|
||||
}
|
||||
}, true);
|
||||
|
||||
// ───── NAV TOGGLE (mobile) + cat spin ─────
|
||||
const burger = document.getElementById('burger');
|
||||
const burgerCat = document.getElementById('burgerCat');
|
||||
// force a consistent cat size on every page, overriding any cached CSS
|
||||
if (burgerCat) {
|
||||
burgerCat.style.setProperty('width', '40px', 'important');
|
||||
burgerCat.style.setProperty('height', '40px', 'important');
|
||||
burgerCat.style.setProperty('object-fit', 'contain', 'important');
|
||||
}
|
||||
const scrim = document.getElementById('scrim');
|
||||
// home uses the cat drawer, download uses #sidebar, else the top nav
|
||||
const navTarget = document.getElementById('catDrawer')
|
||||
|| document.getElementById('sidebar')
|
||||
|| document.getElementById('topNav');
|
||||
|
||||
let spin = 0;
|
||||
|
||||
// inject the "Campus" brand text next to the cat icon
|
||||
let topbarBrand = null;
|
||||
if (burger) {
|
||||
topbarBrand = document.createElement('span');
|
||||
topbarBrand.className = 'topbar-brand';
|
||||
topbarBrand.textContent = 'Campus';
|
||||
burger.insertAdjacentElement('afterend', topbarBrand);
|
||||
}
|
||||
|
||||
function setNav(open) {
|
||||
if (navTarget) {
|
||||
navTarget.classList.toggle('open', open);
|
||||
// when closed, take the off-screen drawer out of the tab order entirely
|
||||
navTarget.inert = !open;
|
||||
}
|
||||
if (scrim) scrim.classList.toggle('open', open);
|
||||
if (topbarBrand) topbarBrand.classList.toggle('show', open);
|
||||
document.body.style.overflow = open ? 'hidden' : '';
|
||||
if (open && burgerCat) {
|
||||
spin += 360;
|
||||
burgerCat.style.transform = `rotate(${spin}deg)`;
|
||||
}
|
||||
// move focus into the drawer when opening, back to the burger when closing
|
||||
if (open && navTarget) navTarget.querySelector('a, button')?.focus();
|
||||
else if (!open && burger && document.activeElement && navTarget?.contains(document.activeElement)) burger.focus();
|
||||
}
|
||||
|
||||
if (burger && navTarget) {
|
||||
navTarget.inert = !navTarget.classList.contains('open'); // closed by default
|
||||
burger.addEventListener('click', () => setNav(!navTarget.classList.contains('open')));
|
||||
if (scrim) scrim.addEventListener('click', () => setNav(false));
|
||||
navTarget.querySelectorAll('a').forEach(a => a.addEventListener('click', () => setNav(false)));
|
||||
}
|
||||
|
||||
// skip-to-content link: move real focus to the target so keyboard/screen-reader
|
||||
// users land in the main content, not just scroll there
|
||||
document.querySelector('.skip-link')?.addEventListener('click', e => {
|
||||
// preventDefault matters here: with <base href="/"> on clean-URL pages, the browser would
|
||||
// otherwise resolve "#main" to "/#main" and navigate to the home page instead of scrolling.
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(e.currentTarget.getAttribute('href'));
|
||||
if (target) { target.setAttribute('tabindex', '-1'); target.focus(); target.scrollIntoView(); }
|
||||
});
|
||||
|
||||
// Esc closes the drawer and any open dropdown menus
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (navTarget && navTarget.classList.contains('open')) setNav(false);
|
||||
const notif = document.querySelector('.notif.open');
|
||||
if (notif) {
|
||||
notif.classList.remove('open');
|
||||
const nb = notif.querySelector('#notifBtn');
|
||||
if (nb) { nb.setAttribute('aria-expanded', 'false'); nb.focus(); }
|
||||
}
|
||||
const prof = document.querySelector('.profile.open');
|
||||
if (prof) {
|
||||
prof.classList.remove('open');
|
||||
const av = prof.querySelector('.profile-av');
|
||||
if (av) { av.setAttribute('aria-expanded', 'false'); av.focus(); }
|
||||
}
|
||||
});
|
||||
|
||||
// arrow-key roving focus within an open dropdown menu
|
||||
function enableMenuArrowKeys(menu, itemSelector, returnFocusEl) {
|
||||
if (!menu) return;
|
||||
menu.addEventListener('keydown', e => {
|
||||
const items = [...menu.querySelectorAll(itemSelector)];
|
||||
if (!items.length) return;
|
||||
const i = items.indexOf(document.activeElement);
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1 + items.length) % items.length].focus(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
|
||||
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
|
||||
else if (e.key === 'Escape' && returnFocusEl) { returnFocusEl.focus(); }
|
||||
});
|
||||
}
|
||||
|
||||
// ───── DEMO AUTH ─────
|
||||
const DEMO_USER = 'catlover';
|
||||
const DEMO_PASS = 'meow123';
|
||||
const USER_KEY = 'campus-user';
|
||||
const BLANK_PX = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
|
||||
// apply a custom (cropped-photo) image onto an <img>
|
||||
function applyCropToImg(img, d) {
|
||||
if (!img || !d) return;
|
||||
img.removeAttribute('srcset');
|
||||
img.src = d.image;
|
||||
img.style.objectFit = 'cover';
|
||||
img.style.objectPosition = (d.pos && d.pos !== 'center') ? d.pos : 'center';
|
||||
}
|
||||
|
||||
// ───── PLAIN TEXT ONLY ─────
|
||||
// The server refuses fancy-font letters, emoji and other symbols in anything the site sends
|
||||
// (SitePlainTextMiddleware). Every text box follows the same rule as you type or paste: keyboard
|
||||
// punctuation a phone swaps in (curly quotes, dashes, …) becomes plain, anything else is removed.
|
||||
// Password boxes are never touched.
|
||||
function toPlainText(s) {
|
||||
return s.replace(/[‘’‚′]/g, "'").replace(/[“”„″]/g, '"')
|
||||
.replace(/[–—−]/g, '-').replace(/…/g, '...').replace(/[ ]/g, ' ')
|
||||
.replace(/[^\x20-\x7E\n\r\t]/g, '');
|
||||
}
|
||||
let PLAIN_TOAST_AT = 0;
|
||||
document.addEventListener('input', e => {
|
||||
const el = e.target;
|
||||
const box = el instanceof HTMLTextAreaElement ||
|
||||
(el instanceof HTMLInputElement && /^(text|search|email|url|tel)$/.test(el.type));
|
||||
if (!box) return;
|
||||
const v = el.value, p = toPlainText(v);
|
||||
if (p === v) return;
|
||||
let pos = null;
|
||||
try { pos = el.selectionStart - (v.length - p.length); } catch {}
|
||||
el.value = p;
|
||||
if (pos != null) { try { el.setSelectionRange(pos, pos); } catch {} }
|
||||
// Say why once per few seconds; plain punctuation swaps are silent.
|
||||
if (toPlainText(v.replace(/[‘’‚′“”„″–—−… ]/g, '')) !== v.replace(/[‘’‚′“”„″–—−… ]/g, '')
|
||||
&& Date.now() - PLAIN_TOAST_AT > 4000 && typeof toast === 'function') {
|
||||
PLAIN_TOAST_AT = Date.now();
|
||||
toast('Only plain text is allowed - no special fonts, symbols or emoji.');
|
||||
}
|
||||
}, true);
|
||||
|
||||
// ───── BACKEND ─────
|
||||
// True when the Campus C# server is answering. apiBase is '' (same-origin), which is
|
||||
// falsy, so it can't double as the "is the API up?" flag - that's what apiEnabled is for.
|
||||
function apiOn() {
|
||||
return !!(window.CAMPUS_DATA && window.CAMPUS_DATA.apiEnabled);
|
||||
}
|
||||
function apiUrl(path) {
|
||||
return ((window.CAMPUS_DATA && window.CAMPUS_DATA.apiBase) || '') + path;
|
||||
}
|
||||
// The session lives in an HttpOnly cookie the server sets, so it is never readable from
|
||||
// here - every call just needs credentials to ride along.
|
||||
async function apiPost(path, body) {
|
||||
const resp = await fetch(apiUrl(path), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const out = await resp.json().catch(() => ({}));
|
||||
banRefused(resp, out);
|
||||
return { ok: resp.ok && out.ok !== false, status: resp.status, data: out };
|
||||
}
|
||||
|
||||
// A banned account is signed in but can't change anything: the server answers every write with
|
||||
// 403 {banned: true, error}. Say so, whatever the caller does with the failure.
|
||||
function banRefused(resp, out) {
|
||||
if (resp && resp.status === 403 && out && out.banned) { toast(out.error || 'This account is banned.'); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// DELETE an API path. Same envelope as apiPost.
|
||||
async function apiDelete(path) {
|
||||
const resp = await fetch(apiUrl(path), { method: 'DELETE', credentials: 'same-origin' });
|
||||
const out = await resp.json().catch(() => ({}));
|
||||
banRefused(resp, out);
|
||||
return { ok: resp.ok && out.ok !== false, status: resp.status, data: out };
|
||||
}
|
||||
|
||||
// A photo's numeric server id from the element it was rendered in (data-photo), or null for
|
||||
// demo content - which stays local-only.
|
||||
function photoIdOf(el) {
|
||||
const host = el && el.closest('[data-photo]');
|
||||
const id = host && host.dataset.photo;
|
||||
return id && /^\d+$/.test(id) ? id : null;
|
||||
}
|
||||
|
||||
// GET an API path as JSON. Returns null on any failure so callers can fall back.
|
||||
async function apiGet(path) {
|
||||
try {
|
||||
const resp = await fetch(apiUrl(path), { credentials: 'same-origin' });
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ───── CLEAN URLS (/user/{h}, /room/{id}, /image/{id}) ─────
|
||||
// Pages are served at clean paths by WebController; the page JS reads its id/handle from
|
||||
// the path here. Query params (?u=, ?id=) still work as a fallback for old links.
|
||||
function userUrl(h) { return '/user/' + encodeURIComponent(String(h || '').replace(/^@/, '')); }
|
||||
|
||||
// ───── EVENTS (a profile's Events tab and a room's Events tab) ─────
|
||||
// Cards come from /api/site/profile/{h}/events and /api/site/rooms/{id}/events: { id, name, description, image,
|
||||
// start, end (UTC ISO), status live|upcoming|ended, room{id,name}, host{...}, going, interested, myResponse }.
|
||||
// Times are shown in the visitor's own timezone. Signed-in visitors can RSVP (Going / Interested) right on the card.
|
||||
function eventWhen(e) {
|
||||
// "Jan 12th, 1:00pm - 2:00pm" (a multi-day event: "Jan 12th, 9:00pm - Jan 13th, 1:00am"), in the visitor's timezone
|
||||
const s = new Date(e.start), en = new Date(e.end);
|
||||
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const ord = n => { const v = n % 100; return n + ((v >= 11 && v <= 13) ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] || 'th')); };
|
||||
const day = d => MON[d.getMonth()] + ' ' + ord(d.getDate());
|
||||
const tm = d => { let h = d.getHours(); const ap = h >= 12 ? 'pm' : 'am'; h = h % 12 || 12; return h + ':' + String(d.getMinutes()).padStart(2, '0') + ap; };
|
||||
if (e.status === 'live') return 'Live now \u00b7 until ' + tm(en);
|
||||
return s.toDateString() === en.toDateString()
|
||||
? `${day(s)}, ${tm(s)} - ${tm(en)}`
|
||||
: `${day(s)}, ${tm(s)} - ${day(en)}, ${tm(en)}`;
|
||||
}
|
||||
|
||||
function eventCardHtml(e, opts) {
|
||||
opts = opts || {};
|
||||
const href = '/event/' + encodeURIComponent(e.id);
|
||||
const hostName = e.host ? (e.host.username || '') : '';
|
||||
const av = (e.host && e.host.profile_image) || avatarUrl(hostName || 'player');
|
||||
const img = e.image ? `background-image:url('${escapeHtml(e.image)}')` : 'background-image:linear-gradient(135deg,#ff5a3c,#7b2ff7)';
|
||||
const badge = e.status === 'live' ? '<span class="pevent-live">LIVE</span>' : e.status === 'ended' ? '<span class="pevent-tag">ENDED</span>' : '';
|
||||
const canRsvp = e.status !== 'ended' && !!(typeof CAMPUS_ME !== 'undefined' && CAMPUS_ME);
|
||||
const rsvp = canRsvp
|
||||
? `<div class="pevent-rsvp"><button type="button" data-r="going" class="${e.myResponse === 'going' ? 'on' : ''}">Going</button>`
|
||||
+ `<button type="button" data-r="interested" class="${e.myResponse === 'interested' ? 'on' : ''}">Interested</button></div>`
|
||||
: '';
|
||||
// the Rec Room style card: picture + title + @host, a big picture, when, N ATTENDING
|
||||
return `<article class="evc event-card${e.status === 'ended' ? ' event-ended' : ''}" data-event="${e.id}" data-href="${href}">
|
||||
<a class="evc-head" href="${href}">
|
||||
<img src="${escapeHtml(av)}" alt="" onerror="this.style.visibility='hidden'">
|
||||
<span class="evc-titles"><b>${escapeHtml(e.name)}</b><small>${hostName ? '@' + escapeHtml(hostName) : ''}</small></span>
|
||||
</a>
|
||||
<a class="evc-img" href="${href}" style="${img}">${badge}</a>
|
||||
<div class="evc-meta">
|
||||
<span class="evc-when">${escapeHtml(eventWhen(e))}</span>
|
||||
<span class="evc-att"><b>${e.going || 0}</b> ATTENDING</span>
|
||||
</div>
|
||||
${rsvp}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderEvents(el, events, opts, emptyText) {
|
||||
if (!events || !events.length) { el.innerHTML = `<p class="profile-empty">${escapeHtml(emptyText || 'No events.')}</p>`; return; }
|
||||
el.innerHTML = events.map(e => eventCardHtml(e, opts)).join('');
|
||||
el.querySelectorAll('.event-card').forEach(c => c.addEventListener('click', ev => {
|
||||
if (ev.target.closest('a, button')) return;
|
||||
location.href = c.dataset.href;
|
||||
}));
|
||||
el.querySelectorAll('.pevent-rsvp button').forEach(btn => btn.addEventListener('click', async () => {
|
||||
const card = btn.closest('.event-card');
|
||||
const id = card && card.dataset.event;
|
||||
const ev = events.find(x => String(x.id) === String(id));
|
||||
if (!ev) return;
|
||||
// pressing the answer you already gave takes it back
|
||||
const want = ev.myResponse === btn.dataset.r ? 'none' : btn.dataset.r;
|
||||
btn.disabled = true;
|
||||
const r = await apiPost('/api/site/events/' + encodeURIComponent(id) + '/respond', { response: want });
|
||||
btn.disabled = false;
|
||||
if (r && r.ok && r.data && r.data.event) {
|
||||
Object.assign(ev, r.data.event);
|
||||
renderEvents(el, events, opts, emptyText); // re-draw the list with the new counts and your answer
|
||||
} else toast((r && r.data && r.data.error) || 'Could not save your answer');
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadEvents(el, path, opts, emptyText) {
|
||||
if (!el) return;
|
||||
if (!apiOn()) { el.innerHTML = `<p class="profile-empty">${escapeHtml(emptyText || 'No events.')}</p>`; return; }
|
||||
el.innerHTML = '<div class="feed-loading">Loading events\u2026</div>';
|
||||
// (the later apiGet in this file THROWS on a non-OK answer, the earlier one returns null: handle both)
|
||||
let data = null;
|
||||
try { data = await apiGet(path); } catch { data = null; }
|
||||
renderEvents(el, data && data.ok ? data.events : [], opts, emptyText);
|
||||
}
|
||||
|
||||
|
||||
// Settings header: your own live status (Online · room / Offline) from the same profile API the
|
||||
// profile page uses; re-run on every "profile" push about you.
|
||||
async function renderSettingsStatus() {
|
||||
const el = document.getElementById('suStatus');
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
if (!el || !me || !apiOn()) return;
|
||||
const r = await apiGet('/api/site/profile/' + encodeURIComponent(me));
|
||||
const st = r && r.status;
|
||||
if (r && r.user && r.user.id) siteWatch(me, r.user.id);
|
||||
const dot = el.querySelector('.su-dot');
|
||||
if (dot) dot.style.background = st && st.online ? '#2ecc71' : '#9a9aa6';
|
||||
el.lastChild.textContent = ' ' + (st ? (st.online ? ('Online' + (st.where ? ' · ' + st.where : '')) : 'Offline') : '');
|
||||
}
|
||||
|
||||
// ---- realtime: which profile this tab is looking at ------------------------------------------
|
||||
// The site socket (ensureNotifSocket) tells the server, and the server pushes "profile" (status,
|
||||
// room, name, counts changed - re-fetch) and "spotify" (a new track / pause / seek, already
|
||||
// filtered for this viewer) frames for it. SITE_WATCH is the handle; SITE_WATCH_ID the account id
|
||||
// once a fetch has told us (the frames carry the id).
|
||||
let SITE_WATCH = null, SITE_WATCH_ID = null;
|
||||
function siteWatch(handle, id) {
|
||||
if (handle) SITE_WATCH = String(handle).replace(/^@/, '');
|
||||
if (id) SITE_WATCH_ID = String(id);
|
||||
if (!SITE_WATCH) return;
|
||||
try { if (NOTIF_WS && NOTIF_WS.readyState === 1) NOTIF_WS.send(JSON.stringify({ type: 'watch', profile: SITE_WATCH })); } catch {}
|
||||
}
|
||||
|
||||
// ---- socials --------------------------------------------------------------------------
|
||||
// Icon per platform (simple monochrome glyphs, currentColor). Keys match SiteSocials.
|
||||
const SOCIAL_ICONS = {
|
||||
discord: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.25.5a14.5 14.5 0 0 1 4.3 1.4 16.6 16.6 0 0 0-14.9 0A14.5 14.5 0 0 1 8.85 3.5L8.6 3a19.8 19.8 0 0 0-4.9 1.4C.6 9 .07 13.5.34 17.9a19.9 19.9 0 0 0 6 3l.5-.7c-.66-.25-1.28-.55-1.86-.9l.46-.36a14.2 14.2 0 0 0 12.12 0l.46.36c-.58.35-1.2.65-1.86.9l.5.7a19.9 19.9 0 0 0 6-3c.33-5.13-.53-9.6-3.86-13.5ZM8.3 15.3c-1.17 0-2.13-1.08-2.13-2.4 0-1.32.94-2.4 2.13-2.4s2.15 1.08 2.13 2.4c0 1.32-.95 2.4-2.13 2.4Zm7.4 0c-1.17 0-2.13-1.08-2.13-2.4 0-1.32.94-2.4 2.13-2.4s2.15 1.08 2.13 2.4c0 1.32-.94 2.4-2.13 2.4Z"/></svg>',
|
||||
x: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.5 3h3.1l-6.8 7.8L21.8 21h-6.3l-4.9-6.4L5 21H1.9l7.3-8.3L1.5 3h6.4l4.4 5.9L17.5 3Zm-1.1 16.2h1.7L7.1 4.7H5.3l11.1 14.5Z"/></svg>',
|
||||
youtube: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23 7.2a2.9 2.9 0 0 0-2-2C19.2 4.7 12 4.7 12 4.7s-7.2 0-9 .5a2.9 2.9 0 0 0-2 2C.5 9 .5 12 .5 12s0 3 .5 4.8a2.9 2.9 0 0 0 2 2c1.8.5 9 .5 9 .5s7.2 0 9-.5a2.9 2.9 0 0 0 2-2c.5-1.8.5-4.8.5-4.8s0-3-.5-4.8ZM9.7 15.1V8.9l6 3.1-6 3.1Z"/></svg>',
|
||||
tiktok: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.6 5.8A4.3 4.3 0 0 1 15.5 3h-3.1v12.4a2.6 2.6 0 1 1-2.6-2.6c.3 0 .5 0 .8.1V9.7a5.7 5.7 0 1 0 4.9 5.7V9.2a7.4 7.4 0 0 0 4.3 1.4V7.5a4.3 4.3 0 0 1-3.2-1.7Z"/></svg>',
|
||||
instagram: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>',
|
||||
twitch: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.3 2 2.5 6.5V20h5v3h2.8l2.8-3h4.2l5.2-5.2V2H4.3Zm16.4 11.9-3.2 3.2h-5.2l-2.8 2.8v-2.8H5.3V4h15.4v9.9ZM16.9 7.2h-2v5.6h2V7.2Zm-5.2 0h-2v5.6h2V7.2Z"/></svg>',
|
||||
steam: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a10 10 0 0 0-10 9.3l5.4 2.2a2.8 2.8 0 0 1 1.6-.5h.2l2.4-3.5v-.1a3.8 3.8 0 1 1 3.8 3.8h-.1l-3.4 2.4v.2a2.8 2.8 0 0 1-5.6.3L2.4 14.5A10 10 0 1 0 12 2Zm-3.7 15.2-1.2-.5a2.1 2.1 0 0 0 3.9-.9 2.1 2.1 0 0 0-2-2.2l1.3.5a1.6 1.6 0 1 1-2 3.1Zm7.1-5.5a2.5 2.5 0 1 1 0-5.1 2.5 2.5 0 0 1 0 5.1Zm0-4.4a1.9 1.9 0 1 0 0 3.8 1.9 1.9 0 0 0 0-3.8Z"/></svg>',
|
||||
github: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A11.5 11.5 0 0 0 .5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2a11 11 0 0 1 5.8 0c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6A11.5 11.5 0 0 0 23.5 12 11.5 11.5 0 0 0 12 .5Z"/></svg>',
|
||||
reddit: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22 12.1a2.2 2.2 0 0 0-3.7-1.6 10.8 10.8 0 0 0-5.8-1.8l1-4.6 3.2.7a1.5 1.5 0 1 0 .2-1l-3.6-.8a.5.5 0 0 0-.6.4L11.6 8.7a10.8 10.8 0 0 0-5.9 1.8 2.2 2.2 0 1 0-2.4 3.6 4.3 4.3 0 0 0 0 .7c0 3.4 3.9 6.1 8.7 6.1s8.7-2.7 8.7-6.1a4.3 4.3 0 0 0 0-.7 2.2 2.2 0 0 0 1.3-2ZM7 13.6a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Zm8.6 4.1a5.7 5.7 0 0 1-3.6 1.1 5.7 5.7 0 0 1-3.6-1.1.4.4 0 0 1 .6-.6 4.9 4.9 0 0 0 3 .9 4.9 4.9 0 0 0 3-.9.4.4 0 0 1 .6.6Zm-.3-2.6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"/></svg>',
|
||||
spotify: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1.5a10.5 10.5 0 1 0 0 21 10.5 10.5 0 0 0 0-21Zm4.8 15.2a.65.65 0 0 1-.9.2c-2.5-1.5-5.6-1.9-9.3-1a.65.65 0 1 1-.3-1.3c4-.9 7.5-.5 10.3 1.2.3.2.4.6.2.9Zm1.3-2.9a.8.8 0 0 1-1.1.3c-2.8-1.7-7.2-2.2-10.5-1.2a.8.8 0 1 1-.5-1.6c3.8-1.1 8.6-.6 11.9 1.4.4.2.5.7.2 1.1Zm.1-3a1 1 0 0 1-1.3.3C13.5 9.2 8 9 4.8 10a1 1 0 0 1-.6-1.9c3.7-1.1 9.8-.9 13.7 1.4a1 1 0 0 1 .3 1.3Z"/></svg>'
|
||||
};
|
||||
function renderSocials(el, rows) {
|
||||
if (!el) return;
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
if (!list.length) { el.hidden = true; el.innerHTML = ''; return; }
|
||||
el.innerHTML = list.map(r => {
|
||||
const ico = SOCIAL_ICONS[r.platform] || '';
|
||||
const inner = ico + '<span class="sc-handle">' + escapeHtml(r.handle || '') + '</span>';
|
||||
return r.url
|
||||
? '<a class="social-chip" href="' + escapeHtml(r.url) + '" target="_blank" rel="noopener" title="' + escapeHtml(r.label || '') + '">' + inner + '</a>'
|
||||
: '<span class="social-chip static" title="' + escapeHtml(r.label || '') + ' (click to copy)" data-copy="' + escapeHtml(r.handle || '') + '">' + inner + '</span>';
|
||||
}).join('');
|
||||
el.hidden = false;
|
||||
el.querySelectorAll('.social-chip.static').forEach(ch => ch.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(ch.dataset.copy || ''); toast('Copied ' + (ch.dataset.copy || '')); } catch {}
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- Spotify "listening to" ----------------------------------------------------------------
|
||||
// np = {is_playing, track, artists, album, album_art, url, progress_ms, duration_ms, as_of} from
|
||||
// the server (null = nothing to show). The bar keeps moving between polls: progress is advanced
|
||||
// locally while the track is playing.
|
||||
const SP_ICON = '<svg class="sp-ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1.5C6.2 1.5 1.5 6.2 1.5 12S6.2 22.5 12 22.5 22.5 17.8 22.5 12 17.8 1.5 12 1.5zm4.8 15.2a.65.65 0 0 1-.9.2c-2.5-1.5-5.6-1.9-9.3-1a.65.65 0 1 1-.3-1.3c4-.9 7.5-.5 10.3 1.2.3.2.4.6.2.9zm1.3-2.9a.8.8 0 0 1-1.1.3c-2.8-1.7-7.1-2.2-10.5-1.2a.8.8 0 1 1-.5-1.6c3.8-1.2 8.5-.6 11.8 1.4.4.2.5.7.3 1.1zm.1-3a1 1 0 0 1-1.3.3C13.6 9.2 8.3 9 5.1 10a1 1 0 1 1-.6-1.9c3.7-1.1 9.6-.9 13.4 1.4.5.3.6.9.3 1.3z"/></svg>';
|
||||
function spFmt(ms) { ms = Math.max(0, ms | 0); const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
|
||||
function renderSpotify(el, np) {
|
||||
if (!el) return;
|
||||
if (!np || !np.track) { el.hidden = true; el.innerHTML = ''; el._np = null; return; }
|
||||
const esc = (typeof escapeHtml === 'function') ? escapeHtml : (x => String(x));
|
||||
el.hidden = false;
|
||||
el.classList.toggle('paused', !np.is_playing);
|
||||
if (el.tagName === 'A') { if (np.url) el.href = np.url; else el.removeAttribute('href'); }
|
||||
el.innerHTML = (np.album_art ? '<img class="sp-art" src="' + esc(np.album_art) + '" alt="">' : '')
|
||||
+ '<div class="sp-meta">'
|
||||
+ '<div class="sp-line">' + SP_ICON + '<span class="sp-track">' + (np.is_playing ? 'Listening to ' : 'Paused · ') + '<b>' + esc(np.track) + '</b></span>'
|
||||
+ '<span class="sp-by">— ' + esc(np.artists || np.album || '') + '</span></div>'
|
||||
+ '<div class="sp-bar"><i></i></div>'
|
||||
+ '<div class="sp-time"><span class="sp-cur"></span><span class="sp-dur">' + spFmt(np.duration_ms) + '</span></div>'
|
||||
+ '</div>';
|
||||
el._np = Object.assign({}, np, { at: Date.now() });
|
||||
spTick(el);
|
||||
}
|
||||
function spTick(el) {
|
||||
const np = el && el._np; if (!np) return;
|
||||
let p = np.progress_ms + (np.is_playing ? (Date.now() - np.at) : 0);
|
||||
if (np.duration_ms > 0) p = Math.min(p, np.duration_ms);
|
||||
const bar = el.querySelector('.sp-bar i'), cur = el.querySelector('.sp-cur');
|
||||
if (bar) bar.style.width = (np.duration_ms > 0 ? (100 * p / np.duration_ms) : 0).toFixed(2) + '%';
|
||||
if (cur) cur.textContent = spFmt(p);
|
||||
}
|
||||
setInterval(() => { document.querySelectorAll('.spotify-now').forEach(el => { if (!el.hidden) spTick(el); }); }, 1000);
|
||||
function roomUrl(id) { return '/room/' + encodeURIComponent(id); }
|
||||
function imageUrl(id) { return '/image/' + encodeURIComponent(id); }
|
||||
|
||||
// The slug for the current clean path of a given kind, or null.
|
||||
function routeSlug(kind) {
|
||||
const m = location.pathname.match(new RegExp('^/' + kind + '/([^/?#]+)'));
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
// Which viewed user/room/image this page is about (clean path first, then ?u=/?id=).
|
||||
function viewedHandle() { return routeSlug('user') || new URLSearchParams(location.search).get('u'); }
|
||||
function viewedRoomId() { return routeSlug('room') || new URLSearchParams(location.search).get('id'); }
|
||||
function viewedImageId() { return routeSlug('image') || new URLSearchParams(location.search).get('id'); }
|
||||
|
||||
// Turn an old .html href into its clean equivalent.
|
||||
function cleanHref(href) {
|
||||
if (!href || !/\.html/i.test(href)) return href;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (u.origin !== location.origin) return href;
|
||||
const page = u.pathname.replace(/^.*\//, ''); // filename only
|
||||
const qs = u.searchParams;
|
||||
if (page === 'profile.html') { const h = qs.get('u'); return h ? userUrl(h) : '/profile'; }
|
||||
if (page === 'room.html') { const id = qs.get('id'); return id ? roomUrl(id) : '/'; }
|
||||
if (page === 'image.html') { const id = qs.get('id'); return id ? imageUrl(id) : '/'; }
|
||||
const map = {
|
||||
'index.html': '/', 'login.html': '/login', 'settings.html': '/settings', 'stats.html': '/stats',
|
||||
'download.html': '/download', 'accessibility.html': '/accessibility',
|
||||
'news.html': '/news', 'patch-notes.html': '/patch-notes', 'weekly.html': '/weekly',
|
||||
'support.html': '/support',
|
||||
};
|
||||
if (map[page]) return map[page] + (page.startsWith('news') || page.startsWith('patch') || page.startsWith('weekly') ? u.search : '');
|
||||
return href;
|
||||
} catch { return href; }
|
||||
}
|
||||
// Rewrite every .html anchor under root (or the whole document) to a clean URL.
|
||||
function rewriteLinks(root) {
|
||||
(root || document).querySelectorAll('a[href]').forEach(a => {
|
||||
const h = a.getAttribute('href');
|
||||
const c = cleanHref(h);
|
||||
if (c && c !== h) a.setAttribute('href', c);
|
||||
});
|
||||
}
|
||||
// Static nav is present now (app.js runs after the DOM).
|
||||
rewriteLinks();
|
||||
// Catch dynamically-rendered links too (feed, profile, search, rails) without editing every
|
||||
// generator: rewrite any .html anchor as it's added to the DOM.
|
||||
try {
|
||||
new MutationObserver(muts => {
|
||||
for (const m of muts) {
|
||||
m.addedNodes.forEach(n => {
|
||||
if (n.nodeType !== 1) return;
|
||||
if (n.tagName === 'A') { const h = n.getAttribute('href'); const c = cleanHref(h); if (c && c !== h) n.setAttribute('href', c); }
|
||||
if (n.querySelectorAll) rewriteLinks(n);
|
||||
});
|
||||
}
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
} catch {}
|
||||
|
||||
// The signed-in account, as last reported by the server. localStorage still holds the
|
||||
// username so the header can render before /api/site/me answers, but the SERVER is the
|
||||
// authority: syncSession() clears the local copy if the session is gone or expired.
|
||||
let CAMPUS_ME = null;
|
||||
let CAMPUS_ADMIN = false;
|
||||
|
||||
// All the localStorage keys that describe WHO is signed in. They're stored under fixed
|
||||
// (not per-user) keys, so switching accounts must overwrite them or the header/settings/tab
|
||||
// keep showing the previous user. setIdentityCache mirrors the server's account; clearIdentity
|
||||
// wipes them on logout/401.
|
||||
function setIdentityCache(u) {
|
||||
if (!u || !u.username) return;
|
||||
try {
|
||||
localStorage.setItem(USER_KEY, u.username);
|
||||
localStorage.setItem('campus-username', u.username);
|
||||
localStorage.setItem('campus-mynick', u.display_name || u.username);
|
||||
localStorage.setItem('campus-mybio', u.bio || '');
|
||||
} catch {}
|
||||
}
|
||||
function clearIdentityCache() {
|
||||
try {
|
||||
['campus-user', 'campus-username', 'campus-mynick', 'campus-mybio'].forEach(k => localStorage.removeItem(k));
|
||||
} catch {}
|
||||
}
|
||||
// Repaint the already-rendered header chip + settings header from the current cache, so a
|
||||
// server correction that lands AFTER the first paint (renderAuth runs before syncSession
|
||||
// resolves) still shows the right name/@handle without needing a manual reload.
|
||||
function refreshIdentityUI() {
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
if (!me) return;
|
||||
const name = myDisplayName(me), handle = '@' + myHandle(me);
|
||||
document.querySelectorAll('.pm-name').forEach(el => el.textContent = name);
|
||||
document.querySelectorAll('.pm-handle').forEach(el => el.textContent = handle);
|
||||
const suName = document.getElementById('suName'); if (suName) suName.textContent = name;
|
||||
const suHandle = document.getElementById('suHandle'); if (suHandle) suHandle.textContent = handle;
|
||||
const u = document.getElementById('setUsername'); if (u && !u.value) u.placeholder = myHandle(me);
|
||||
const n = document.getElementById('setNickname'); if (n && !n.value) n.placeholder = myDisplayName(me);
|
||||
}
|
||||
|
||||
async function syncSession() {
|
||||
if (!apiOn()) return null;
|
||||
try {
|
||||
const resp = await fetch(apiUrl('/api/site/me'), { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
// 401 = signed out or the 12h token expired. Drop the stale local identity so the
|
||||
// UI doesn't keep showing a logged-in header for a session the server has dropped.
|
||||
if (resp.status === 401) { CAMPUS_ME = null; clearIdentityCache(); }
|
||||
return null;
|
||||
}
|
||||
const out = await resp.json();
|
||||
CAMPUS_ME = out.user || null;
|
||||
CAMPUS_ADMIN = !!out.admin; // dev account on an admin-allowed network: shows the Admin button
|
||||
// The SERVER session is the authority. Overwrite the cached identity keys every load so
|
||||
// switching accounts - or creating a new one without logging out first - can never leave
|
||||
// the previous user's name/@handle/bio showing in the header, settings or tab.
|
||||
if (CAMPUS_ME && CAMPUS_ME.username) setIdentityCache(CAMPUS_ME);
|
||||
return CAMPUS_ME;
|
||||
} catch {
|
||||
return null; // server unreachable: leave whatever the page already rendered
|
||||
}
|
||||
}
|
||||
|
||||
// handle the login form (+ its 2FA second step, for an account that turned it on)
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const login2faForm = document.getElementById('login2faForm');
|
||||
let loginPendingTicket = '';
|
||||
|
||||
function showLogin2faStep(pending) {
|
||||
loginPendingTicket = pending;
|
||||
loginForm.style.display = 'none';
|
||||
document.getElementById('loginFoot').style.display = 'none';
|
||||
login2faForm.style.display = 'block';
|
||||
document.getElementById('login2faCode').focus();
|
||||
}
|
||||
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const u = (document.getElementById('loginUser').value || '').trim();
|
||||
const p = document.getElementById('loginPass').value || '';
|
||||
const err = document.getElementById('loginError');
|
||||
const btn = loginForm.querySelector('.login-submit');
|
||||
|
||||
const fail = msg => {
|
||||
err.textContent = msg;
|
||||
err.classList.add('show');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Log in'; }
|
||||
};
|
||||
|
||||
if (!u || !p) return fail('Enter your username and password.');
|
||||
|
||||
// Offline demo mode (apiEnabled: false) keeps the old hardcoded account.
|
||||
if (!apiOn()) {
|
||||
if (u.toLowerCase() === DEMO_USER && p === DEMO_PASS) {
|
||||
localStorage.setItem(USER_KEY, 'catlover');
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
fail('Incorrect username or password - try the demo login below.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Logging in…'; }
|
||||
err.classList.remove('show');
|
||||
|
||||
try {
|
||||
const { ok, data } = await apiPost('/api/site/login', { username: u, password: p });
|
||||
if (data && data.need2fa) { if (btn) { btn.disabled = false; btn.textContent = 'Log in'; } showLogin2faStep(data.pending); return; }
|
||||
if (!ok) return fail(data.error || 'Incorrect username or password.');
|
||||
if (data.user && data.user.username) { clearIdentityCache(); setIdentityCache(data.user); }
|
||||
window.location.href = '/';
|
||||
} catch {
|
||||
fail('Could not reach the server. Try again in a moment.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (login2faForm) {
|
||||
document.getElementById('login2faBack').addEventListener('click', () => {
|
||||
login2faForm.style.display = 'none';
|
||||
loginForm.style.display = 'block';
|
||||
document.getElementById('loginFoot').style.display = '';
|
||||
document.getElementById('loginError').textContent = '';
|
||||
document.getElementById('loginError').classList.remove('show');
|
||||
});
|
||||
login2faForm.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const err = document.getElementById('login2faError');
|
||||
const btn = login2faForm.querySelector('.login-submit');
|
||||
err.classList.remove('show'); err.textContent = '';
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Verifying…'; }
|
||||
try {
|
||||
const { ok, data } = await apiPost('/api/site/login/2fa', { pending: loginPendingTicket, code: document.getElementById('login2faCode').value });
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Verify'; }
|
||||
if (data && data.pending) loginPendingTicket = data.pending;
|
||||
if (!ok) { err.textContent = data.error || 'Wrong code.'; err.classList.add('show'); document.getElementById('login2faCode').value = ''; return; }
|
||||
if (data.user && data.user.username) { clearIdentityCache(); setIdentityCache(data.user); }
|
||||
window.location.href = '/';
|
||||
} catch {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Verify'; }
|
||||
err.textContent = 'Could not reach the server. Try again in a moment.'; err.classList.add('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// handle the create-account (signup) form
|
||||
const signupForm = document.getElementById('signupForm');
|
||||
if (signupForm) {
|
||||
signupForm.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const val = id => (document.getElementById(id)?.value || '');
|
||||
const username = val('suUser').trim();
|
||||
const birthday = val('suDob').trim(); // YYYY-MM-DD from <input type=date>
|
||||
const pass = val('suPass');
|
||||
const confirm = val('suConfirm');
|
||||
const email = val('suEmail').trim();
|
||||
const website = val('suWebsite'); // honeypot - stays empty for humans
|
||||
const err = document.getElementById('signupError');
|
||||
const btn = signupForm.querySelector('.login-submit');
|
||||
|
||||
const fail = msg => {
|
||||
err.textContent = msg;
|
||||
err.classList.add('show');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Create account'; }
|
||||
};
|
||||
|
||||
// Client-side mirrors of the server rules, for instant feedback. The server re-checks all.
|
||||
// NOTE: under-13 accounts get a random @name assigned by the server and are set to junior,
|
||||
// so the username rules below are only a courtesy for 13+ (the server re-validates anyway).
|
||||
if (username.length < 3 || username.length > 20) return fail('Username must be 3-20 characters.');
|
||||
if (!/^[A-Za-z0-9_]+$/.test(username)) return fail('Username can only use letters, numbers and underscores.');
|
||||
if (!birthday) return fail('Enter your date of birth.');
|
||||
if (pass.length < 6) return fail('Password must be at least 6 characters.');
|
||||
if (pass !== confirm) return fail("The passwords don't match.");
|
||||
|
||||
if (!apiOn()) return fail('Sign-up needs the live server.');
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Creating…'; }
|
||||
err.classList.remove('show');
|
||||
|
||||
try {
|
||||
const { ok, data } = await apiPost('/api/site/register', {
|
||||
username, password: pass,
|
||||
confirm_password: confirm, email, birthday, website
|
||||
});
|
||||
if (!ok) return fail(data.error || 'Could not create account.');
|
||||
if (data.user && data.user.username) { clearIdentityCache(); setIdentityCache(data.user); }
|
||||
window.location.href = '/';
|
||||
} catch {
|
||||
fail('Could not reach the server. Try again in a moment.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// display name / handle for the logged-in user (username + nickname can be customised)
|
||||
function myDisplayName(login) {
|
||||
return localStorage.getItem('campus-mynick') || localStorage.getItem('campus-username') || cap(login || '');
|
||||
}
|
||||
function myHandle(login) {
|
||||
return localStorage.getItem('campus-username') || login || '';
|
||||
}
|
||||
|
||||
// reflect logged-in state in the UI (runs on every page)
|
||||
function renderAuth() {
|
||||
const user = localStorage.getItem(USER_KEY);
|
||||
if (!user) return;
|
||||
|
||||
// swap "Log in" for the profile picture + dropdown menu
|
||||
const loginBtn = document.querySelector('.btn-login');
|
||||
if (loginBtn) {
|
||||
const av = avatarUrl(user);
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'profile';
|
||||
chip.innerHTML =
|
||||
`<img class="profile-av" data-av="${avKey(user)}" src="${av}" alt="${user}" tabindex="0" role="button" aria-haspopup="true" aria-expanded="false" aria-label="Account menu">
|
||||
<div class="profile-menu" role="menu">
|
||||
<div class="pm-head">
|
||||
<img class="pm-av" data-av="${avKey(user)}" src="${av}" alt="">
|
||||
<div class="pm-info"><span class="pm-name">${myDisplayName(user)}</span><span class="pm-handle">@${myHandle(user)}</span></div>
|
||||
</div>
|
||||
<div class="pm-tokens">
|
||||
<span class="pm-token-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 7.5v11"/><path d="M10 11c0-2.2 2.2-3.5 4.6-2.9"/><path d="M7 13.6h6.5"/><path d="M7 16.2h6.5"/></svg></span>
|
||||
<span class="pm-token-val">1,250</span>
|
||||
</div>
|
||||
<a href="/profile" class="pm-item"><svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="7" r="3" stroke="currentColor" stroke-width="1.6"/><path d="M4 17c0-3 2.7-5 6-5s6 2 6 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>Profile</a>
|
||||
<a href="#" class="pm-item"><svg viewBox="0 0 20 20" fill="none"><path d="M4 6h12l-1 9a2 2 0 0 1-2 1.7H7A2 2 0 0 1 5 15L4 6Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M7 6a3 3 0 0 1 6 0" stroke="currentColor" stroke-width="1.6"/></svg>Shop</a>
|
||||
<a href="/stats" class="pm-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V10"/><path d="M10 19V4"/><path d="M16 19v-7"/><path d="M22 19H2"/></svg>Stats</a>
|
||||
<a href="/settings" class="pm-item"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</a>
|
||||
<button class="pm-item pm-logout"><svg viewBox="0 0 20 20" fill="none"><path d="M8 17H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3M13 14l4-4-4-4M17 10H8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>Log out</button>
|
||||
</div>`;
|
||||
loginBtn.replaceWith(chip);
|
||||
|
||||
const avBtn = chip.querySelector('.profile-av');
|
||||
function toggleProfile(open) {
|
||||
document.querySelector('.notif')?.classList.remove('open');
|
||||
const isOpen = open ?? !chip.classList.contains('open');
|
||||
chip.classList.toggle('open', isOpen);
|
||||
avBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (isOpen) chip.querySelector('.pm-item')?.focus();
|
||||
}
|
||||
avBtn.addEventListener('click', e => { e.stopPropagation(); toggleProfile(); });
|
||||
avBtn.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleProfile(); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); toggleProfile(true); }
|
||||
});
|
||||
enableMenuArrowKeys(chip.querySelector('.profile-menu'), '.pm-item', avBtn);
|
||||
chip.querySelector('.pm-logout').addEventListener('click', async () => {
|
||||
// Tell the server first so it clears the session cookie; the local wipe and the
|
||||
// reload happen either way, so a failed request still logs you out of this browser.
|
||||
if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} }
|
||||
clearIdentityCache();
|
||||
window.location.reload();
|
||||
});
|
||||
document.addEventListener('click', e => {
|
||||
if (!chip.contains(e.target)) toggleProfile(false);
|
||||
});
|
||||
}
|
||||
|
||||
// hide the top-bar Download button while logged in
|
||||
const dlBtn = document.querySelector('.top-right .btn-dl');
|
||||
if (dlBtn) dlBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Render immediately from the cached username so the header doesn't flash, then confirm
|
||||
// with the server. If the session turned out to be dead, syncSession cleared USER_KEY and
|
||||
// this second pass leaves the "Log in" button in place.
|
||||
renderAuth();
|
||||
const SESSION_READY = syncSession();
|
||||
SESSION_READY.then(me => renderBanBanner(me));
|
||||
SESSION_READY.then(me => {
|
||||
const alreadyRendered = !!document.querySelector('.profile');
|
||||
if (me && !alreadyRendered) renderAuth();
|
||||
// syncSession may have corrected the cached identity (account switch / new account); repaint
|
||||
// the header + settings text so it doesn't keep showing the previous user.
|
||||
if (me) refreshIdentityUI();
|
||||
// Now the server has answered, swap the REAL profile picture in wherever the logged-in
|
||||
// user's own avatar shows: the top-right nav chip and the settings header. /api/site/me
|
||||
// hands back a raw image name, so turn it into a /img/ URL first.
|
||||
applyMyAvatar();
|
||||
});
|
||||
|
||||
// The logged-in user's real profile-picture URL (from CAMPUS_ME), or null.
|
||||
function myPfpUrl() {
|
||||
const raw = CAMPUS_ME && CAMPUS_ME.profile_image;
|
||||
if (!raw) return null;
|
||||
return /^https?:|^\//.test(raw) ? raw : (((window.CAMPUS_DATA && window.CAMPUS_DATA.imgBase) || '/img/') + raw);
|
||||
}
|
||||
// Point every "me" avatar (nav chip + settings) at the real picture, and show the real token
|
||||
// balance in the account menu.
|
||||
async function applyMyAvatar() {
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
if (!me) return;
|
||||
|
||||
// Avatar: the in-game profile picture, taken from the PUBLIC profile endpoint - it returns
|
||||
// profile_image as a ready-to-use /img/ URL (the same one that makes the profile-page avatar
|
||||
// work), so this is the source of truth. CAMPUS_ME's raw name is only a last-ditch fallback.
|
||||
let url = null;
|
||||
if (apiOn()) {
|
||||
const r = await apiGet('/api/site/profile/' + encodeURIComponent(me));
|
||||
if (r && r.ok && r.user && r.user.profile_image) url = r.user.profile_image;
|
||||
}
|
||||
if (!url) url = myPfpUrl();
|
||||
if (url) {
|
||||
// Persist so avatarUrl() hands back the real picture on EVERY page (comments, feed, nav,
|
||||
// profile - anywhere the user's avatar is drawn), and update every already-rendered avatar
|
||||
// for this user on THIS page live. The 'storage' listener carries the change to other tabs.
|
||||
try { localStorage.setItem('campus-pfp-' + avKey(me), JSON.stringify({ image: url })); } catch {}
|
||||
refreshAvatars(avKey(me), url);
|
||||
document.querySelectorAll('.profile-av, .pm-av, #suAv').forEach(im => {
|
||||
im.removeAttribute('srcset'); im.dataset.failed = ''; im.src = url; im.style.objectFit = 'cover';
|
||||
});
|
||||
}
|
||||
|
||||
// Tokens (RecCenterTokens) from the authed /me - matches what the game shows. Left as the
|
||||
// placeholder if the website session isn't authed (a balance must not come from a public call).
|
||||
const tokens = CAMPUS_ME && CAMPUS_ME.tokens;
|
||||
if (tokens != null) document.querySelectorAll('.pm-token-val').forEach(el => { el.textContent = Number(tokens).toLocaleString(); });
|
||||
}
|
||||
|
||||
// ───── NOTIFICATIONS (real, persistent, game-synced inbox) ─────
|
||||
// Reads /api/site/notifications, which aggregates the two server-side sources of truth so the
|
||||
// website shows exactly what the game does: incoming friend requests (accept/decline here) and
|
||||
// saved messages (cheers, coach messages…). Game / party / join invites can't be actioned in a
|
||||
// browser, so they show "Launch the game to view." Actions hit the same endpoints the game uses,
|
||||
// so dismissing / accepting stays in sync. NOTIF_ITEMS === null means signed out.
|
||||
let NOTIF_ITEMS = [];
|
||||
let NOTIF_WRAP = null, NOTIF_MENU = null, NOTIF_BADGE = null, NOTIF_BTN = null;
|
||||
|
||||
function notifTimeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
const t = new Date(iso).getTime();
|
||||
if (isNaN(t)) return '';
|
||||
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.floor(s / 60); if (m < 60) return m + 'm ago';
|
||||
const h = Math.floor(m / 60); if (h < 24) return h + 'h ago';
|
||||
const d = Math.floor(h / 24); if (d < 7) return d + 'd ago';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function notifAvatarHtml(item) {
|
||||
const seed = (item.fromPlayer && item.fromPlayer.username) || 'player';
|
||||
const src = (item.fromPlayer && item.fromPlayer.profile_image) || avatarUrl(seed);
|
||||
return `<img class="nm-av" data-av="${avKey(seed)}" src="${src}" alt="">`;
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
// apiGet THROWS on 401 (signed out) / any non-ok, so catch and treat as "no inbox" (null).
|
||||
try {
|
||||
const data = await apiGet('/api/site/notifications');
|
||||
NOTIF_ITEMS = (data && data.ok !== false && Array.isArray(data.items)) ? data.items : null;
|
||||
} catch {
|
||||
NOTIF_ITEMS = null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncNotifBadge() {
|
||||
if (!NOTIF_BTN) return;
|
||||
const n = Array.isArray(NOTIF_ITEMS) ? NOTIF_ITEMS.length : 0;
|
||||
NOTIF_BTN.setAttribute('aria-label', n > 0 ? `Notifications, ${n} unread` : 'Notifications');
|
||||
if (!NOTIF_BADGE) return;
|
||||
if (n > 0) { NOTIF_BADGE.textContent = n > 99 ? '99+' : n; NOTIF_BADGE.classList.remove('hidden'); }
|
||||
else { NOTIF_BADGE.textContent = ''; NOTIF_BADGE.classList.add('hidden'); }
|
||||
}
|
||||
|
||||
function renderNotifMenu() {
|
||||
if (!NOTIF_MENU) return;
|
||||
let rows;
|
||||
if (NOTIF_ITEMS === null) {
|
||||
rows = '<p class="nm-empty">Log in to see your notifications</p>';
|
||||
} else if (NOTIF_ITEMS.length === 0) {
|
||||
rows = '<p class="nm-empty">You\'re all caught up</p>';
|
||||
} else {
|
||||
rows = NOTIF_ITEMS.map(n => {
|
||||
const time = n.time ? `<span class="nm-time">${notifTimeAgo(n.time)}</span>` : '';
|
||||
if (n.kind === 'friend_request') {
|
||||
return `<div class="nm-item nm-fr" data-id="${escapeHtml(n.id)}">
|
||||
${notifAvatarHtml(n)}
|
||||
<div class="nm-body">
|
||||
<span class="nm-text">${escapeHtml(n.text)}</span>
|
||||
<div class="nm-actions">
|
||||
<button class="nm-accept" type="button" data-target="${n.targetId}">Accept</button>
|
||||
<button class="nm-decline" type="button" data-target="${n.targetId}">Decline</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
if (n.kind === 'support') {
|
||||
return `<a class="nm-item nm-support" data-id="${escapeHtml(n.id)}" href="${escapeHtml(n.link || '/support')}">
|
||||
<div class="nm-icon" aria-hidden="true">🎫</div>
|
||||
<div class="nm-body"><span class="nm-text">${escapeHtml(n.text)}</span>${time}</div>
|
||||
${n.id === 'supq' ? '' : '<button class="nm-dismiss" type="button" aria-label="Dismiss">×</button>'}
|
||||
</a>`;
|
||||
}
|
||||
if (n.kind === 'launch') {
|
||||
return `<div class="nm-item nm-launch" data-id="${escapeHtml(n.id)}">
|
||||
<div class="nm-icon" aria-hidden="true">🎮</div>
|
||||
<div class="nm-body"><span class="nm-text">${escapeHtml(n.text)}</span>${time}</div>
|
||||
<button class="nm-dismiss" type="button" aria-label="Dismiss">×</button>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="nm-item" data-id="${escapeHtml(n.id)}">
|
||||
${notifAvatarHtml(n)}
|
||||
<div class="nm-body"><span class="nm-text">${escapeHtml(n.text)}</span>${time}</div>
|
||||
<button class="nm-dismiss" type="button" aria-label="Dismiss">×</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
const hasDismissable = Array.isArray(NOTIF_ITEMS) && NOTIF_ITEMS.some(n => n.kind !== 'friend_request');
|
||||
const clear = hasDismissable ? '<button class="nm-clear" type="button">Mark all read</button>' : '';
|
||||
NOTIF_MENU.innerHTML =
|
||||
`<div class="nm-head"><span>Notifications</span>${clear}</div>
|
||||
<div class="nm-list">${rows}</div>`;
|
||||
if (typeof refreshAvatars === 'function') refreshAvatars();
|
||||
}
|
||||
|
||||
async function refreshNotifications() {
|
||||
await fetchNotifications();
|
||||
syncNotifBadge();
|
||||
renderNotifMenu();
|
||||
ensureNotifSocket(); // (re)connect the realtime channel once we know we're signed in
|
||||
}
|
||||
|
||||
// ── realtime push ──────────────────────────────────────────────────────────
|
||||
// Opens a websocket to /api/site/notifications/ws (cookie-authed, separate from the game's
|
||||
// hub so it never disturbs the player's game socket). The server sends {"type":"refresh"}
|
||||
// the instant a notification is created for this player; we just re-fetch the inbox. Falls
|
||||
// back gracefully to the 30s poll if the socket can't connect.
|
||||
let NOTIF_WS = null, NOTIF_WS_TIMER = null;
|
||||
function ensureNotifSocket() {
|
||||
// Only hold a socket while signed in (NOTIF_ITEMS === null means signed out).
|
||||
if (NOTIF_ITEMS === null) { if (NOTIF_WS) { try { NOTIF_WS.close(); } catch {} NOTIF_WS = null; } return; }
|
||||
if (NOTIF_WS && (NOTIF_WS.readyState === 0 || NOTIF_WS.readyState === 1)) return; // connecting/open
|
||||
try {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(proto + '//' + location.host + '/api/site/notifications/ws');
|
||||
NOTIF_WS = ws;
|
||||
ws.onopen = () => { siteWatch(SITE_WATCH); };
|
||||
ws.onmessage = (ev) => {
|
||||
// ignore keep-alive pings; refresh on a real "refresh" (or anything else)
|
||||
let m = {}; try { m = JSON.parse(ev.data) || {}; } catch {}
|
||||
const t = m.type || '';
|
||||
if (t === 'ping') return;
|
||||
// Typed frames about the profile this tab is watching (see siteWatch).
|
||||
if (t === 'spotify') {
|
||||
if (String(m.id) === String(SITE_WATCH_ID || '') || !SITE_WATCH_ID) {
|
||||
renderSpotify(document.getElementById('profileSpotify'), m.now_playing);
|
||||
renderSpotify(document.getElementById('spotifyNowPreview'), m.now_playing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t === 'profile') {
|
||||
if (String(m.id) === String(SITE_WATCH_ID || '')) {
|
||||
if (document.getElementById('profilePhotos') && typeof renderProfile === 'function') renderProfile();
|
||||
if (typeof renderSettingsStatus === 'function') renderSettingsStatus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
{
|
||||
refreshNotifications();
|
||||
// Anything social changed (a friend came online, a request / favourite / subscription
|
||||
// moved): redraw what's on screen. Cheap - one GET each - and only while relevant.
|
||||
const fm = document.getElementById('friendsModal');
|
||||
if (fm && fm.classList.contains('open') && typeof fm.__reload === 'function') fm.__reload();
|
||||
const sm = document.getElementById('subsModal');
|
||||
if (sm && sm.classList.contains('open') && typeof sm.__reload === 'function') sm.__reload();
|
||||
if (document.getElementById('profilePhotos') && typeof renderProfile === 'function') renderProfile();
|
||||
if (typeof hydrateOnline === 'function') hydrateOnline();
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
NOTIF_WS = null;
|
||||
clearTimeout(NOTIF_WS_TIMER);
|
||||
// reconnect only while still signed in
|
||||
NOTIF_WS_TIMER = setTimeout(() => { if (NOTIF_ITEMS !== null) ensureNotifSocket(); }, 5000);
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch {} };
|
||||
} catch { NOTIF_WS = null; }
|
||||
}
|
||||
|
||||
async function onNotifMenuClick(e) {
|
||||
const accept = e.target.closest('.nm-accept');
|
||||
const decline = e.target.closest('.nm-decline');
|
||||
const dismiss = e.target.closest('.nm-dismiss');
|
||||
const clear = e.target.closest('.nm-clear');
|
||||
|
||||
if (accept || decline) {
|
||||
e.stopPropagation();
|
||||
if (!requireLogin('manage friend requests')) return;
|
||||
const btn = accept || decline;
|
||||
const target = Number(btn.dataset.target);
|
||||
btn.disabled = true;
|
||||
const path = accept ? '/api/site/friends/accept' : '/api/site/friends/remove';
|
||||
const { ok } = await apiPost(path, { id: target });
|
||||
if (!ok) { btn.disabled = false; toast('Something went wrong.'); return; }
|
||||
toast(accept ? 'Friend request accepted' : 'Friend request declined');
|
||||
await refreshNotifications();
|
||||
return;
|
||||
}
|
||||
if (dismiss) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault(); // a support item is a link - dismissing it must not follow it
|
||||
const id = dismiss.closest('.nm-item').dataset.id;
|
||||
await apiPost('/api/site/notifications/dismiss', { id });
|
||||
await refreshNotifications();
|
||||
return;
|
||||
}
|
||||
if (clear) {
|
||||
e.stopPropagation();
|
||||
// One call: the server deletes every read-able notification and tells the game to drop them.
|
||||
const { ok } = await apiPost('/api/site/notifications/dismissall');
|
||||
if (!ok) {
|
||||
const ids = (NOTIF_ITEMS || []).filter(n => n.kind !== 'friend_request').map(n => n.id);
|
||||
for (const id of ids) await apiPost('/api/site/notifications/dismiss', { id });
|
||||
}
|
||||
await refreshNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function initNotifications() {
|
||||
const btn = document.getElementById('notifBtn');
|
||||
if (!btn) return;
|
||||
NOTIF_BTN = btn;
|
||||
NOTIF_BADGE = document.getElementById('notifBadge');
|
||||
|
||||
// wrap the bell so the menu can anchor to it
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'notif';
|
||||
btn.replaceWith(wrap);
|
||||
wrap.appendChild(btn);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'notif-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
wrap.appendChild(menu);
|
||||
NOTIF_WRAP = wrap; NOTIF_MENU = menu;
|
||||
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
function toggleNotif(open) {
|
||||
document.querySelector('.profile')?.classList.remove('open');
|
||||
const isOpen = open ?? !wrap.classList.contains('open');
|
||||
wrap.classList.toggle('open', isOpen);
|
||||
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
if (isOpen) refreshNotifications(); // refresh on open so it's always current
|
||||
}
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); toggleNotif(); });
|
||||
btn.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); toggleNotif(true); }
|
||||
});
|
||||
menu.addEventListener('click', onNotifMenuClick);
|
||||
document.addEventListener('click', e => {
|
||||
if (!wrap.contains(e.target) && wrap.classList.contains('open')) toggleNotif(false);
|
||||
});
|
||||
|
||||
renderNotifMenu(); // initial paint (loading/empty)
|
||||
refreshNotifications(); // initial fetch
|
||||
// Light polling keeps the badge in sync with the game while the tab is open.
|
||||
setInterval(() => { if (document.visibilityState === 'visible') refreshNotifications(); }, 30000);
|
||||
}
|
||||
initNotifications();
|
||||
|
||||
// ───── FRIENDS ─────
|
||||
const FRIENDS_KEY = 'campus-friends';
|
||||
function getFriends() { try { return JSON.parse(localStorage.getItem(FRIENDS_KEY) || '{}'); } catch { return {}; } }
|
||||
function saveFriends(f) { localStorage.setItem(FRIENDS_KEY, JSON.stringify(f)); }
|
||||
function isFriend(h) { const f = getFriends()[h]; return !!f && f.status !== 'pending'; }
|
||||
function isPending(h) { const f = getFriends()[h]; return !!f && f.status === 'pending'; }
|
||||
function addFriend(h) { const f = getFriends(); if (!f[h]) f[h] = { status: 'pending', nickname: '', pinned: false }; saveFriends(f); }
|
||||
function acceptFriend(h) { const f = getFriends(); if (f[h]) { f[h].status = 'accepted'; saveFriends(f); } }
|
||||
function removeFriend(h) { const f = getFriends(); delete f[h]; saveFriends(f); }
|
||||
function setFriendNick(h, n) { const f = getFriends(); if (f[h]) { f[h].nickname = n; saveFriends(f); } }
|
||||
function toggleFriendPin(h) { const f = getFriends(); if (f[h]) { f[h].pinned = !f[h].pinned; saveFriends(f); return f[h].pinned; } return false; }
|
||||
function isFavourite(h) { const f = getFriends()[h]; return !!f && !!f.favourite; }
|
||||
function toggleFriendFavourite(h) { const f = getFriends(); if (f[h]) { f[h].favourite = !f[h].favourite; saveFriends(f); return f[h].favourite; } return false; }
|
||||
|
||||
// pinned friends widget on the home right rail
|
||||
async function renderPinned() {
|
||||
const rail = document.querySelector('.feed-layout .rail');
|
||||
if (!rail) return;
|
||||
rail.querySelector('.rail-pinned')?.remove();
|
||||
if (apiOn()) {
|
||||
if (!localStorage.getItem(USER_KEY)) return;
|
||||
const d = await apiGet('/api/site/me/pinned');
|
||||
const rows = (d && d.ok && d.pinned) || [];
|
||||
if (!rows.length) return;
|
||||
const items = rows.map(p => `<a class="rail-item" href="${userUrl(p.username)}">
|
||||
<span class="rail-on-av"><img class="rail-pin-av" data-av="${avKey(p.username)}" src="${p.profile_image || avatarUrl(p.username)}" alt="">${p.online ? '<i class="online-dot"></i>' : ''}</span>
|
||||
<div class="rail-meta"><span class="rail-name">${escapeHtml(p.nickname || p.display_name || p.username)}</span><span class="rail-by">${p.online ? (p.where ? 'In ^' + escapeHtml(p.where) : 'Online') : '@' + escapeHtml(p.username)}</span></div>
|
||||
</a>`).join('');
|
||||
const html = `<div class="rail-pinned"><h3 class="rail-title">Pinned friends</h3>${items}</div>`;
|
||||
rail.insertAdjacentHTML('afterbegin', html);
|
||||
return;
|
||||
}
|
||||
const f = getFriends();
|
||||
const pinned = Object.keys(f).filter(h => f[h].pinned);
|
||||
if (!pinned.length) return;
|
||||
const items = pinned.map(h => {
|
||||
const nick = f[h].nickname || cap(h);
|
||||
return `<a class="rail-item" href="${userUrl(h)}">
|
||||
<img class="rail-pin-av" data-av="${avKey(h)}" src="${avatarUrl(h)}" alt="">
|
||||
<div class="rail-meta"><span class="rail-name">${escapeHtml(nick)}</span><span class="rail-by">@${h}</span></div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
rail.insertAdjacentHTML('afterbegin',
|
||||
`<div class="rail-pinned"><h3 class="rail-title">Pinned friends</h3>${items}</div>`);
|
||||
}
|
||||
|
||||
// online friends widget on the home right rail
|
||||
async function renderOnlineFriends() {
|
||||
const rail = document.querySelector('.feed-layout .rail');
|
||||
if (!rail) return;
|
||||
|
||||
// Real "who's in-game right now" from /api/site/online; demo data as a fallback.
|
||||
let list = null;
|
||||
if (apiOn()) {
|
||||
const data = await apiGet('/api/site/online');
|
||||
if (data && data.ok && Array.isArray(data.results)) {
|
||||
list = data.results.map(o => ({ h: o.username, name: o.display_name || o.username, where: o.where || 'Online now', avatar: o.profile_image }));
|
||||
}
|
||||
}
|
||||
if (!list) {
|
||||
const f = getFriends();
|
||||
list = ((window.CAMPUS_DATA && window.CAMPUS_DATA.onlineFriends) || []).map(d => ({ h: d.h, name: (f[d.h] && f[d.h].nickname) || cap(d.h), where: d.where, avatar: null }));
|
||||
Object.keys(f).filter(h => f[h].status !== 'pending')
|
||||
.forEach(h => { if (!list.some(d => d.h === h)) list.unshift({ h, name: (f[h] && f[h].nickname) || cap(h), where: 'Online now', avatar: null }); });
|
||||
}
|
||||
|
||||
rail.querySelector('.rail-online')?.remove();
|
||||
const items = list.map(d => {
|
||||
const av = d.avatar || avatarUrl(d.h);
|
||||
return `<a class="rail-item" href="${userUrl(d.h)}">
|
||||
<span class="rail-on-av"><img class="rail-pin-av" data-av="${avKey(d.h)}" src="${av}" alt=""><i class="online-dot"></i></span>
|
||||
<div class="rail-meta"><span class="rail-name">${escapeHtml(d.name)}</span><span class="rail-by">${escapeHtml(d.where)}</span></div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
|
||||
rail.insertAdjacentHTML('afterbegin',
|
||||
`<div class="rail-online"><h3 class="rail-title">Online Friends · ${list.length}</h3>${items}</div>`);
|
||||
}
|
||||
|
||||
// ───── UPDATES PAGE (Patch Notes / Weekly / News) ─────
|
||||
function renderUpdates() {
|
||||
const list = document.getElementById('updatesList');
|
||||
if (!list) return;
|
||||
const all = (window.CAMPUS_DATA && window.CAMPUS_DATA.updates) || {};
|
||||
// type comes from the page itself (data-updates) or the ?type= param
|
||||
let type = list.dataset.updates || new URLSearchParams(location.search).get('type') || 'patch';
|
||||
if (!all[type]) type = 'patch';
|
||||
const section = all[type] || { title: 'Updates', blurb: '', posts: [] };
|
||||
|
||||
document.title = `${section.title} - Campus`;
|
||||
document.getElementById('updatesTitle').textContent = section.title;
|
||||
document.getElementById('updatesBlurb').textContent = section.blurb || '';
|
||||
document.querySelectorAll('.utab').forEach(t => t.classList.toggle('active', t.dataset.type === type));
|
||||
|
||||
const fmtDate = d => {
|
||||
try { return new Date(d).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }); }
|
||||
catch { return d; }
|
||||
};
|
||||
list.innerHTML = (section.posts || []).map(p => `
|
||||
<article class="update-card">
|
||||
<div class="update-meta">
|
||||
${p.tag ? `<span class="update-tag">${escapeHtml(p.tag)}</span>` : ''}
|
||||
<span class="update-date">${escapeHtml(fmtDate(p.date))}</span>
|
||||
</div>
|
||||
<h2 class="update-title">${escapeHtml(p.title)}</h2>
|
||||
<p class="update-body">${escapeHtml(p.body)}</p>
|
||||
</article>
|
||||
`).join('') || '<p class="search-empty">Nothing here yet.</p>';
|
||||
}
|
||||
renderUpdates();
|
||||
|
||||
// ───── ROOM PAGE ─────
|
||||
const ROOMS_DATA = (window.CAMPUS_DATA && window.CAMPUS_DATA.rooms) || {};
|
||||
|
||||
async function renderRoom() {
|
||||
const cover = document.getElementById('roomCover');
|
||||
if (!cover) return;
|
||||
const id = viewedRoomId();
|
||||
|
||||
// Real room from /api/site/rooms/{id}; demo ROOMS_DATA only as a fallback.
|
||||
let r = null;
|
||||
if (apiOn() && id != null) {
|
||||
const data = await apiGet('/api/site/rooms/' + encodeURIComponent(id));
|
||||
if (data && data.ok && data.room) {
|
||||
const rm = data.room;
|
||||
r = { id: rm.id, name: rm.name, creator: rm.creator || 'someone', desc: rm.description || '', image: rm.image, visits: rm.visits || 0, cheers: rm.cheers || 0, favorites: rm.favorites || 0, players: rm.players || 0, photos: data.photos || [] };
|
||||
}
|
||||
}
|
||||
if (!r) r = ROOMS_DATA[id];
|
||||
if (!r) { document.getElementById('roomPage').innerHTML = '<p class="image-missing">Room not found.</p>'; return; }
|
||||
|
||||
document.title = `^${r.name} - Campus`;
|
||||
if (r.image) { cover.style.backgroundImage = `url('${r.image}')`; cover.style.backgroundSize = 'cover'; cover.style.backgroundPosition = 'center'; }
|
||||
else cover.style.background = `linear-gradient(135deg, ${r.a || '#ff5a3c'}, ${r.b || '#7b2ff7'})`;
|
||||
document.getElementById('roomName').textContent = '^' + r.name;
|
||||
const by = document.getElementById('roomBy');
|
||||
by.textContent = 'by ' + r.creator;
|
||||
by.href = userUrl(r.creator);
|
||||
document.getElementById('roomDesc').textContent = r.desc || '';
|
||||
if (apiOn() && id != null) {
|
||||
let slot = document.getElementById('roomAdmin');
|
||||
if (!slot) { slot = document.createElement('div'); slot.id = 'roomAdmin'; slot.className = 'room-admin'; document.getElementById('roomDesc').after(slot); }
|
||||
mountAdminButton('room', r.id || id, slot);
|
||||
}
|
||||
const EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 12S5.5 5 12 5s10.5 7 10.5 7-4 7-10.5 7S1.5 12 1.5 12z"/><circle cx="12" cy="12" r="3.2"/></svg>';
|
||||
const THUMB = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11H3V10zM7 10l4.2-6.6a1.6 1.6 0 0 1 2.9 1.2L13.4 9H19a2 2 0 0 1 2 2.4l-1.5 7.5A2.5 2.5 0 0 1 17 21H7"/></svg>';
|
||||
const STAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.4 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9z"/></svg>';
|
||||
document.getElementById('roomStats').innerHTML =
|
||||
`<div class="room-stat">${EYE}<strong>${fmt(r.visits || 0)}</strong><small>visits</small></div>
|
||||
<div class="room-stat">${THUMB}<strong>${fmt(r.cheers || 0)}</strong><small>cheers</small></div>
|
||||
<div class="room-stat">${STAR}<strong>${fmt(r.favorites || 0)}</strong><small>favorites</small></div>`;
|
||||
|
||||
// the room's photos, as the same cards the home feed uses (cheers / comments work here too)
|
||||
const feed = document.getElementById('roomPhotos');
|
||||
if (feed) {
|
||||
const posts = (r.photos || []).map(x => ({
|
||||
id: x.id, image: x.image,
|
||||
user: (x.author && (x.author.display_name || x.author.username)) || 'Player',
|
||||
handle: (x.author && x.author.username) || 'player',
|
||||
avatar: x.author && x.author.profile_image,
|
||||
caption: x.description || '', room: r.name,
|
||||
cheers: x.cheers || 0, cheered: !!x.cheered, comments: x.comments || 0, takenAt: x.createdAt,
|
||||
tagged: x.taggedPlayers || [],
|
||||
}));
|
||||
if (!posts.length) feed.innerHTML = '<div class="feed-loading">No photos taken in this room yet.</div>';
|
||||
else {
|
||||
const keep = postList; // renderPosts writes into the home feed element; borrow it for this page
|
||||
feed.innerHTML = posts.map(p => {
|
||||
const [a, b] = avColor(p.user || '?');
|
||||
const tags = (p.tagged || []).map(t => `<a class="room-tag" href="${userUrl(t.username)}"><img src="${t.profile_image || avatarUrl(t.username)}" alt="">${escapeHtml(t.display_name || t.username)}</a>`).join('');
|
||||
return `<article class="post" data-photo="${p.id}">
|
||||
<div class="post-head">
|
||||
<a class="post-av" href="${userUrl(p.handle)}" style="--a:${a};--b:${b}"><img data-av="${avKey(p.handle)}" src="${p.avatar || avatarUrl(p.handle)}" alt="" onerror="this.remove()">${(p.user || '?')[0].toUpperCase()}</a>
|
||||
<div class="post-meta"><a class="post-user" href="${userUrl(p.handle)}">${escapeHtml(p.user)}</a><span class="post-handle">@${escapeHtml(p.handle)}</span></div>
|
||||
<span class="post-time">${p.takenAt ? timeAgo(p.takenAt) : ''}</span>
|
||||
</div>
|
||||
<a class="post-img" href="${imageUrl(p.id)}" style="background-image:url('${p.image}')"></a>
|
||||
<div class="post-foot">
|
||||
<span class="post-cap"><span class="room-chip">^${escapeHtml(p.room)}</span>${tags ? `<span class="room-in">In this photo:</span>${tags}` : ''}${p.caption ? ` <span class="room-caption">${escapeHtml(p.caption)}</span>` : ''}</span>
|
||||
<div class="post-stats">
|
||||
<button class="pstat cheer${p.cheered ? ' liked' : ''}" data-n="${(p.cheers || 0) - (p.cheered ? 1 : 0)}">${CHEER_ICON}<span>${fmt(p.cheers || 0)}</span></button>
|
||||
<button class="pstat comment-toggle" data-n="${p.comments || 0}">${CHAT}<span>${p.comments || 0}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
${commentsBlock(p)}
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
// the room's events (hosted by players, booked in this room)
|
||||
const roomEventsEl = document.querySelector('.room-panel[data-panel="events"]');
|
||||
if (roomEventsEl && apiOn() && r.id != null) {
|
||||
roomEventsEl.innerHTML = '<div class="room-events profile-events" id="roomEvents"></div>';
|
||||
loadEvents(document.getElementById('roomEvents'), '/api/site/rooms/' + encodeURIComponent(r.id) + '/events', { showHost: true }, 'No events scheduled in this room.');
|
||||
}
|
||||
// tabs
|
||||
document.querySelectorAll('.room-tab').forEach(t => t.addEventListener('click', () => {
|
||||
document.querySelectorAll('.room-tab').forEach(x => x.classList.toggle('active', x === t));
|
||||
document.querySelectorAll('.room-panel').forEach(p => p.classList.toggle('active', p.dataset.panel === t.dataset.panel));
|
||||
}));
|
||||
}
|
||||
renderRoom();
|
||||
|
||||
// ───── EVENT PAGE (/event/{id}) - laid out like the photo page ─────
|
||||
async function renderEventPage() {
|
||||
const root = document.getElementById('eventPage');
|
||||
if (!root) return;
|
||||
const id = routeSlug('event');
|
||||
let data = null;
|
||||
try { data = await apiGet('/api/site/events/' + encodeURIComponent(id)); } catch { data = null; }
|
||||
if (!data || !data.ok || !data.event) { root.innerHTML = '<p class="image-missing">This event does not exist, or is private.</p>'; return; }
|
||||
await SESSION_READY;
|
||||
let e = data.event, going = data.going_players || [];
|
||||
document.title = e.name + ' - Campus';
|
||||
const $ = x => document.getElementById(x);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const icsStamp = d => d.getUTCFullYear() + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate()) + 'T' + pad(d.getUTCHours()) + pad(d.getUTCMinutes()) + pad(d.getUTCSeconds()) + 'Z';
|
||||
|
||||
// the "..." menu: copy link / add to calendar (wired once)
|
||||
const menu = $('evMenu'), menuBtn = $('evMenuBtn');
|
||||
menuBtn.addEventListener('click', ev => { ev.stopPropagation(); menu.classList.toggle('open'); });
|
||||
document.addEventListener('click', () => menu.classList.remove('open'));
|
||||
$('evShare').onclick = async () => {
|
||||
menu.classList.remove('open');
|
||||
try { await navigator.clipboard.writeText(location.origin + '/event/' + e.id); toast('Link copied'); }
|
||||
catch { prompt('Copy this link', location.origin + '/event/' + e.id); }
|
||||
};
|
||||
$('evCal').onclick = () => {
|
||||
menu.classList.remove('open');
|
||||
const s = new Date(e.start), en = new Date(e.end);
|
||||
const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Campus//Events//EN', 'BEGIN:VEVENT',
|
||||
'UID:event-' + e.id + '@campus', 'DTSTAMP:' + icsStamp(new Date()), 'DTSTART:' + icsStamp(s), 'DTEND:' + icsStamp(en),
|
||||
'SUMMARY:' + String(e.name).replace(/[\r\n]+/g, ' '),
|
||||
'DESCRIPTION:' + ((e.description || '') + (e.room ? '\\nIn ^' + e.room.name : '') + '\\n' + location.origin + '/event/' + e.id).replace(/[\r\n]+/g, '\\n'),
|
||||
'URL:' + location.origin + '/event/' + e.id, 'END:VEVENT', 'END:VCALENDAR'].join('\r\n');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
|
||||
a.download = 'campus-event-' + e.id + '.ics';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
};
|
||||
|
||||
// JOIN: if you are online in the game, the server sends your game to the event
|
||||
const join = $('evJoin');
|
||||
join.addEventListener('click', async () => {
|
||||
if (!CAMPUS_ME) { location.href = '/login'; return; }
|
||||
join.disabled = true; const label = join.textContent; join.textContent = 'Joining\u2026';
|
||||
const r = await apiPost('/api/site/events/' + encodeURIComponent(e.id) + '/join', {});
|
||||
join.textContent = label; join.disabled = false;
|
||||
toast(r && r.ok ? ((r.data && r.data.message) || 'Sending you to the event\u2026') : ((r && r.data && r.data.error) || 'Could not join the event'));
|
||||
});
|
||||
|
||||
const draw = () => {
|
||||
const s = new Date(e.start);
|
||||
// the picture (photo-page style), with the status chip on it
|
||||
$('evCover').style.backgroundImage = e.image ? `url('${e.image}')` : 'linear-gradient(135deg,#ff5a3c,#7b2ff7)';
|
||||
$('evBadge').innerHTML = e.status === 'live' ? '<span class="pevent-live">LIVE NOW</span>' : e.status === 'ended' ? '<span class="pevent-tag">ENDED</span>' : '';
|
||||
// who is hosting
|
||||
const host = $('evHost');
|
||||
if (e.host) {
|
||||
host.hidden = false; host.href = userUrl(e.host.username);
|
||||
const av = $('evHostAv'); av.src = e.host.profile_image || avatarUrl(e.host.username); av.onerror = () => { av.style.visibility = 'hidden'; };
|
||||
$('evHostName').textContent = e.host.display_name || e.host.username;
|
||||
$('evHostHandle').textContent = '@' + e.host.username;
|
||||
} else host.hidden = true;
|
||||
// title + one meta line: when, how many are going, where
|
||||
$('evName').textContent = e.name;
|
||||
const meta = $('evMeta');
|
||||
meta.innerHTML = `<span>${escapeHtml(eventWhen(e))}</span><span>\u00b7</span><span>${e.going || 0} attending${e.interested ? ', ' + e.interested + ' interested' : ''}</span>`
|
||||
+ (e.room ? `<span>\u00b7</span><a class="image-room" href="${roomUrl(e.room.id)}">^${escapeHtml(e.room.name)}</a>` : '');
|
||||
|
||||
// Join: only while you could actually go in; a signed-out visitor is sent to log in
|
||||
join.hidden = e.status === 'ended';
|
||||
const canGo = !!(CAMPUS_ME && e.canJoin);
|
||||
join.classList.toggle('ev-join-off', !!CAMPUS_ME && !canGo);
|
||||
join.title = !CAMPUS_ME ? 'Log in to join' : canGo ? 'Send me to this event (you need to be online in Campus)' : 'Opens when the event starts';
|
||||
join.disabled = !!CAMPUS_ME && !canGo;
|
||||
|
||||
// RSVP pills in the photo page's button style
|
||||
const rsvp = $('evRsvp');
|
||||
if (e.status === 'ended' || !CAMPUS_ME) rsvp.innerHTML = '';
|
||||
else {
|
||||
const b = (r, label) => `<button type="button" class="img-report${e.myResponse === r ? ' on' : ''}" data-r="${r}">${label}</button>`;
|
||||
rsvp.innerHTML = b('going', 'Going') + b('interested', 'Interested') + b('not_interested', 'Not interested');
|
||||
rsvp.querySelectorAll('button').forEach(btn => btn.addEventListener('click', async () => {
|
||||
rsvp.querySelectorAll('button').forEach(x => x.disabled = true);
|
||||
const r = await apiPost('/api/site/events/' + encodeURIComponent(e.id) + '/respond', { response: e.myResponse === btn.dataset.r ? 'none' : btn.dataset.r });
|
||||
if (r && r.ok && r.data && r.data.event) {
|
||||
e = r.data.event;
|
||||
let again = null; try { again = await apiGet('/api/site/events/' + encodeURIComponent(e.id)); } catch {}
|
||||
if (again && again.going_players) going = again.going_players;
|
||||
draw();
|
||||
} else { rsvp.querySelectorAll('button').forEach(x => x.disabled = false); toast((r && r.data && r.data.error) || 'Could not save your answer'); }
|
||||
}));
|
||||
}
|
||||
|
||||
// below: about + who is going (the photo page's "Comments" section look)
|
||||
const about = $('evAbout');
|
||||
if (e.description && e.description.trim() && e.description.trim().toLowerCase() !== String(e.name).trim().toLowerCase()) { $('evDesc').textContent = e.description; about.hidden = false; } else about.hidden = true;
|
||||
$('evGoingTitle').textContent = going.length ? `Attending \u00b7 ${e.going || going.length}` : 'Attending';
|
||||
$('evPeople').innerHTML = going.length
|
||||
? going.map(p => `<a class="ev-person" href="${userUrl(p.username)}"><img src="${escapeHtml(p.profile_image || avatarUrl(p.username))}" alt="" onerror="this.style.visibility='hidden'"><span>${escapeHtml(p.display_name || p.username)}</span></a>`).join('')
|
||||
: '<p class="profile-empty">No one has said they are going yet.</p>';
|
||||
};
|
||||
draw();
|
||||
}
|
||||
renderEventPage();
|
||||
|
||||
// featured-rooms slideshow on the home page.
|
||||
// Data-driven: called once with the demo rooms at load, then again with the real rooms
|
||||
// once /api/site/rooms/hot answers. The previous instance's timer is cleared on re-init,
|
||||
// otherwise the two would fight over the same element.
|
||||
let FEATURED_TIMER = null;
|
||||
function initFeaturedSlideshow(rooms) {
|
||||
const el = document.getElementById('featuredSlideshow');
|
||||
if (!el) return;
|
||||
|
||||
if (FEATURED_TIMER) { clearInterval(FEATURED_TIMER); FEATURED_TIMER = null; }
|
||||
|
||||
// No argument = the original demo path, keyed into ROOMS_DATA by slug.
|
||||
const list = (rooms && rooms.length)
|
||||
? rooms
|
||||
: ['whisker-heights', 'midnight-mews', 'yarn-quest', 'haunted-cattery', 'deep-dive-reef']
|
||||
.map(id => Object.assign({ id }, ROOMS_DATA[id] || {}));
|
||||
|
||||
if (!list.length) return;
|
||||
const ids = list.map(r => r.id);
|
||||
let i = 0, timer = null;
|
||||
|
||||
el.innerHTML = `
|
||||
<a class="featured" id="featuredSlide" href="#"></a>
|
||||
<button class="farrow farrow-prev" aria-label="Previous room"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg></button>
|
||||
<button class="farrow farrow-next" aria-label="Next room"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></button>
|
||||
<div class="featured-dots" id="featuredDots"></div>`;
|
||||
const slide = document.getElementById('featuredSlide');
|
||||
const dots = document.getElementById('featuredDots');
|
||||
dots.innerHTML = ids.map((_, idx) => `<button class="fdot" data-i="${idx}" aria-label="Slide ${idx + 1}"></button>`).join('');
|
||||
|
||||
function render() {
|
||||
const r = list[i];
|
||||
slide.href = roomUrl(ids[i]);
|
||||
// A real room has a server image; fall back to its gradient when it has none.
|
||||
const bg = r.image
|
||||
? `background-image:url('${r.image}');background-size:cover;background-position:center`
|
||||
: `background:linear-gradient(135deg, ${r.a || '#ff5a3c'}, ${r.b || '#7b2ff7'})`;
|
||||
slide.innerHTML = `
|
||||
<div class="featured-img" style="${bg}"></div>
|
||||
<div class="featured-body">
|
||||
<span class="featured-tag">Featured room</span>
|
||||
<h2>^${r.name || ''}</h2>
|
||||
<p class="featured-by">by @${r.creator || 'someone'}</p>
|
||||
<div class="featured-row">
|
||||
<span class="featured-live"><i class="dot"></i>${(Number(r.players) || 0).toLocaleString()} playing now</span>
|
||||
<span class="featured-cta">Visit room →</span>
|
||||
</div>
|
||||
</div>`;
|
||||
slide.classList.remove('fade'); void slide.offsetWidth; slide.classList.add('fade');
|
||||
dots.querySelectorAll('.fdot').forEach((d, idx) => d.classList.toggle('active', idx === i));
|
||||
}
|
||||
function start() { timer = setInterval(() => { i = (i + 1) % ids.length; render(); }, 5000); FEATURED_TIMER = timer; }
|
||||
function stop() { clearInterval(timer); }
|
||||
|
||||
render();
|
||||
start();
|
||||
function go(delta) { i = (i + delta + ids.length) % ids.length; render(); stop(); start(); }
|
||||
dots.addEventListener('click', e => {
|
||||
const b = e.target.closest('.fdot');
|
||||
if (!b) return;
|
||||
i = +b.dataset.i; render(); stop(); start();
|
||||
});
|
||||
el.querySelector('.farrow-prev').addEventListener('click', () => go(-1));
|
||||
el.querySelector('.farrow-next').addEventListener('click', () => go(1));
|
||||
el.addEventListener('mouseenter', stop);
|
||||
el.addEventListener('mouseleave', start);
|
||||
}
|
||||
|
||||
// ───── PROFILE PAGE ─────
|
||||
const PROFILE_PHOTOS = [
|
||||
{ id: 'a', pos: '8% 45%', size: '230%' },
|
||||
{ id: 'b', pos: '40% 50%', size: '250%' },
|
||||
{ id: 'c', pos: '70% 48%', size: '240%' },
|
||||
{ id: 'd', pos: '95% 50%', size: '260%' },
|
||||
{ id: 'e', pos: '20% 60%', size: '220%' },
|
||||
{ id: 'f', pos: '55% 40%', size: '270%' },
|
||||
];
|
||||
|
||||
function cap(s) { return s ? s[0].toUpperCase() + s.slice(1) : s; }
|
||||
|
||||
// per-user photo galleries (random generated images via Lorem Picsum)
|
||||
const USER_PHOTOS = {
|
||||
catlover: [
|
||||
{ id: 'g1', image: 'https://picsum.photos/seed/catlover-aurora/700/700', title: 'Caught a nice view' },
|
||||
{ id: 'g2', image: 'https://picsum.photos/seed/catlover-sunset/700/700', title: 'Late night session' },
|
||||
],
|
||||
};
|
||||
|
||||
// per-user profile stats
|
||||
const PROFILE_DATA = {
|
||||
catlover: { friends: 16, subscribers: 2 },
|
||||
bubbles: { friends: 48, subscribers: 312 },
|
||||
spookytom: { friends: 27, subscribers: 1530 },
|
||||
mittens: { friends: 12, subscribers: 89 },
|
||||
clawful: { friends: 64, subscribers: 12400 },
|
||||
velvetpaw: { friends: 41, subscribers: 4800 },
|
||||
tinker9: { friends: 33, subscribers: 760 },
|
||||
stitchwitch: { friends: 58, subscribers: 9100 },
|
||||
pounce: { friends: 9, subscribers: 34 },
|
||||
};
|
||||
|
||||
async function renderProfile() {
|
||||
const photosEl = document.getElementById('profilePhotos');
|
||||
if (!photosEl) return;
|
||||
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
const viewed = (viewedHandle() || me || 'catlover').toLowerCase();
|
||||
const isOwn = !!me && me.toLowerCase() === viewed;
|
||||
siteWatch(viewed);
|
||||
|
||||
// Real profile from the Campus server: api.user (profile_image / display_name /
|
||||
// username / bio / banner_image), api.friends, api.subscribers, api.photos, api.rooms.
|
||||
// Falls back to demo data only when the API is off or the player isn't found.
|
||||
let api = null;
|
||||
if (apiOn()) {
|
||||
// Direct fetch (not apiGet) so we can read a 403 body: if the owner has blocked this viewer
|
||||
// the server answers { blocked: true } and we show the notice instead of the profile.
|
||||
try {
|
||||
const resp = await fetch(apiUrl('/api/site/profile/' + encodeURIComponent(viewed)), { credentials: 'same-origin' });
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (data && data.blocked) {
|
||||
const main = document.getElementById('main') || document.querySelector('.profile-page');
|
||||
if (main) main.innerHTML =
|
||||
`<div class="profile-blocked" style="max-width:540px;margin:120px auto;text-align:center;padding:0 20px">
|
||||
<h1 style="font-size:1.4rem;margin-bottom:10px">This Profile cannot be viewed at this time</h1>
|
||||
<p style="color:var(--muted)"><a href="/" style="color:var(--primary)">Back to home</a></p>
|
||||
</div>`;
|
||||
document.title = 'Profile unavailable - Campus';
|
||||
return;
|
||||
}
|
||||
if (resp.ok && data && data.ok) api = data;
|
||||
} catch {}
|
||||
}
|
||||
const u = api && api.user;
|
||||
if (u && u.id) siteWatch(viewed, u.id);
|
||||
const data = api
|
||||
? { friends: api.friends || 0, subscribers: api.subscribers || 0 }
|
||||
: (PROFILE_DATA[viewed] || { friends: 8, subscribers: 24 });
|
||||
|
||||
const displayName = u ? (u.display_name || u.username) : (isOwn ? myDisplayName(viewed) : cap(viewed));
|
||||
const handleText = u ? u.username : (isOwn ? myHandle(viewed) : viewed);
|
||||
|
||||
document.title = `${displayName} - Campus`;
|
||||
const picEl = document.getElementById('profilePic');
|
||||
picEl.src = (u && u.profile_image) ? u.profile_image : `https://api.dicebear.com/9.x/thumbs/svg?seed=${viewed}`;
|
||||
picEl.dataset.av = avKey(viewed);
|
||||
const unameEl = document.getElementById('profileUsername');
|
||||
unameEl.textContent = displayName;
|
||||
if (!isOwn && isFavourite(viewed)) {
|
||||
unameEl.insertAdjacentHTML('beforeend', ' <span class="fav-star" title="Favourite" aria-label="Favourite">★</span>');
|
||||
}
|
||||
document.getElementById('profileHandle').textContent = '@' + handleText;
|
||||
// "You call them X" - the viewer's nickname for this friend, if they set one
|
||||
function renderNickChip() {
|
||||
document.getElementById('profileNick')?.remove();
|
||||
const n = api ? api.nickname : ((getFriends()[viewed] || {}).nickname || '');
|
||||
if (!n) return;
|
||||
document.getElementById('profileHandle').insertAdjacentHTML('afterend', `<span class="profile-nick" id="profileNick" title="Your nickname for them">aka ${escapeHtml(n)}</span>`);
|
||||
}
|
||||
renderNickChip();
|
||||
const bioEl = document.getElementById('profileBio');
|
||||
if (bioEl) {
|
||||
const myBio = isOwn ? localStorage.getItem('campus-mybio') : null;
|
||||
bioEl.textContent = myBio || (u && u.bio) || (isOwn ? 'No bio yet — add one in Settings.' : '');
|
||||
}
|
||||
renderSocials(document.getElementById('profileSocials'), u && u.socials);
|
||||
|
||||
// Real joined date from the account's created_at (was hardcoded "Joined June 2025").
|
||||
const joinedText = document.querySelector('#profileJoined .joined-text');
|
||||
if (joinedText) {
|
||||
let jd = '';
|
||||
if (u && u.created_at) { const d = new Date(u.created_at); if (!isNaN(d)) jd = d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); }
|
||||
joinedText.textContent = jd ? ('Joined ' + jd) : 'Joined';
|
||||
}
|
||||
|
||||
// Status: shown ONLY when the server disclosed it, which it does only for a signed-in viewer.
|
||||
// Signed out -> api.status is null -> the whole line stays hidden (you can't see anyone's status).
|
||||
const statusEl = document.getElementById('profileStatus');
|
||||
if (statusEl) {
|
||||
const st = api && api.status;
|
||||
if (st) {
|
||||
statusEl.hidden = false;
|
||||
const dot = statusEl.querySelector('.status-dot');
|
||||
const txt = statusEl.querySelector('.status-text');
|
||||
if (dot) dot.style.background = st.online ? '#2ecc71' : '#8a8f98';
|
||||
if (txt) txt.textContent = st.online ? ('Online' + (st.where ? ' · ' + st.where : '')) : ('Offline' + (st.lastOnline ? ' · last online ' + lastOnlineText(st.lastOnline) : ''));
|
||||
} else {
|
||||
statusEl.hidden = true;
|
||||
}
|
||||
// Spotify "listening to" under the status - only what the server disclosed for THIS viewer
|
||||
// (the owner's "show to" choice). Live updates arrive over the site socket; this poll is
|
||||
// only the fallback for a tab whose socket is down.
|
||||
const spEl = document.getElementById('profileSpotify');
|
||||
if (spEl) {
|
||||
renderSpotify(spEl, st ? st.spotify : null);
|
||||
clearInterval(spEl._poll);
|
||||
if (st && u && (u.username || u.id)) {
|
||||
const handle = u.username || String(u.id);
|
||||
spEl._poll = setInterval(async () => {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const r = await fetch('/api/site/spotify/nowplaying/' + encodeURIComponent(handle), { credentials: 'same-origin' });
|
||||
if (r.ok) { const j = await r.json(); renderSpotify(spEl, j.now_playing); }
|
||||
} catch {}
|
||||
}, 60000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Real banner from the server, if the account has one.
|
||||
{
|
||||
const b = document.querySelector('.profile-banner');
|
||||
const src = (u && u.banner_image) || 'assets/banner.png';
|
||||
if (b) { b.style.backgroundImage = `url('${src}')`; b.style.backgroundSize = 'cover'; b.style.backgroundPosition = 'center'; b.style.backgroundRepeat = 'no-repeat'; }
|
||||
}
|
||||
|
||||
// custom profile picture / banner (set from a photo). Only used as a local override when
|
||||
// the server has no image of its own, so a real server picture always wins.
|
||||
try {
|
||||
const pfp = JSON.parse(localStorage.getItem('campus-pfp-' + viewed) || 'null');
|
||||
if (pfp && !(u && u.profile_image)) applyCropToImg(picEl, pfp);
|
||||
} catch {}
|
||||
try {
|
||||
const bn = null; // banners are the site-wide banner.png now
|
||||
if (bn && !(u && u.banner_image)) {
|
||||
const b = document.querySelector('.profile-banner');
|
||||
b.style.backgroundImage = `url('${bn.image}')`;
|
||||
b.style.backgroundSize = 'cover';
|
||||
b.style.backgroundPosition = bn.pos;
|
||||
b.style.backgroundRepeat = 'no-repeat';
|
||||
if (bn.id) {
|
||||
b.style.cursor = 'pointer';
|
||||
b.title = 'View photo';
|
||||
b.setAttribute('role', 'button');
|
||||
b.setAttribute('tabindex', '0');
|
||||
b.setAttribute('aria-label', 'View banner photo');
|
||||
const goPhoto = () => { window.location.href = imageUrl(bn.id); };
|
||||
b.addEventListener('click', goPhoto);
|
||||
b.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goPhoto(); } });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// stats box
|
||||
const subKey = 'campus-sub-' + viewed;
|
||||
let subscribed = api ? !!api.subscribed : localStorage.getItem(subKey) === '1';
|
||||
const statFriends = document.getElementById('statFriends');
|
||||
const statSubs = document.getElementById('statSubs');
|
||||
if (statFriends) statFriends.textContent = fmt(data.friends);
|
||||
// With the API the count already includes the viewer's own subscription.
|
||||
const subCount = () => api ? data.subscribers : data.subscribers + (subscribed ? 1 : 0);
|
||||
if (statSubs) statSubs.textContent = fmt(subCount());
|
||||
|
||||
// top action: gear (own) or Add-friend (others)
|
||||
const actions = document.getElementById('profileActions');
|
||||
// dev accounts: the quick-admin button, after whatever buttons this branch adds
|
||||
setTimeout(() => mountAdminButton('player', (u && u.id) || viewed, actions), 0);
|
||||
if (isOwn) {
|
||||
actions.innerHTML = `<a href="/stats" class="profile-gear" title="Stats" aria-label="Stats" style="margin-right:6px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V10"/><path d="M10 19V4"/><path d="M16 19v-7"/><path d="M22 19H2"/></svg></a><a href="/settings" class="profile-gear" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></a>`;
|
||||
// clicking your own "Friends" stat opens the friends list
|
||||
const friendRow = document.getElementById('statFriendsRow');
|
||||
if (friendRow) {
|
||||
friendRow.classList.add('statbox-clickable');
|
||||
friendRow.setAttribute('role', 'button');
|
||||
friendRow.tabIndex = 0;
|
||||
// renderProfile re-runs on every live refresh: replace the badge, never stack another.
|
||||
friendRow.querySelectorAll('.statbox-badge').forEach(b => b.remove());
|
||||
const pendingN = (NOTIF_ITEMS || []).filter(n => n.kind === 'friend_request').length;
|
||||
if (pendingN) friendRow.insertAdjacentHTML('beforeend', ` <span class="statbox-badge">${pendingN}</span>`);
|
||||
if (!friendRow.dataset.wired) {
|
||||
friendRow.dataset.wired = '1';
|
||||
friendRow.addEventListener('click', () => openFriendsList(viewed));
|
||||
friendRow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFriendsList(viewed); } });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
actions.innerHTML = '';
|
||||
const rbtn = document.createElement('button');
|
||||
rbtn.className = 'profile-report-btn';
|
||||
rbtn.textContent = 'Report';
|
||||
rbtn.addEventListener('click', () => openReport(`Report ${cap(viewed)}`, () => {
|
||||
rbtn.textContent = 'Reported';
|
||||
rbtn.disabled = true;
|
||||
}, { type: 'player', id: viewed }));
|
||||
actions.appendChild(rbtn);
|
||||
|
||||
// Block / Unblock (server-backed, the same block as in-game). Only YOUR block is yours to
|
||||
// lift; if they've also blocked you, that stays when you unblock.
|
||||
if (api && u && u.id) {
|
||||
const bbtn = document.createElement('button');
|
||||
bbtn.className = 'profile-report-btn profile-block-btn';
|
||||
let blocked = !!api.blockedByMe;
|
||||
const paint = () => { bbtn.textContent = blocked ? 'Unblock' : 'Block'; bbtn.classList.toggle('on', blocked); };
|
||||
paint();
|
||||
bbtn.addEventListener('click', () => {
|
||||
if (!requireLogin('block players')) return;
|
||||
const go = async () => {
|
||||
bbtn.disabled = true;
|
||||
const { ok, data } = await apiPost('/api/site/friends/block', { id: u.id, on: !blocked });
|
||||
bbtn.disabled = false;
|
||||
if (!ok) { toast((data && data.error) || 'Could not update the block'); return; }
|
||||
blocked = !!data.blocked; api.blockedByMe = blocked; paint();
|
||||
toast(blocked ? `${escapeHtml(displayName)} blocked` : `${escapeHtml(displayName)} unblocked`);
|
||||
if (blocked) setTimeout(() => renderProfile(), 300); // friendship ends with a block; repaint the friend controls
|
||||
};
|
||||
if (blocked) go();
|
||||
else openConfirm({ title: `Block ${escapeHtml(displayName)}?`, message: 'They won\'t be able to friend, message or join you, and any friendship ends. You can unblock them here later.', confirmText: 'Block', onConfirm: go });
|
||||
});
|
||||
actions.appendChild(bbtn);
|
||||
}
|
||||
|
||||
// friend control replaces the "Friends" row in the stats box
|
||||
const friendRow = document.getElementById('statFriendsRow');
|
||||
if (friendRow) {
|
||||
friendRow.classList.add('statbox-friend');
|
||||
const fbtn = document.createElement('button');
|
||||
const targetId = u && u.id;
|
||||
// REAL relationship state from the server: None | FriendRequestSent | FriendRequestReceived
|
||||
// | Friend. Falls back to the old local flags only when there's no API profile.
|
||||
let rel = (api && api.relationship) || (isFriend(viewed) ? 'Friend' : (isPending(viewed) ? 'FriendRequestSent' : 'None'));
|
||||
const setFbtn = () => {
|
||||
if (rel === 'FriendRequestSent') { fbtn.className = 'friend-row-btn pending'; fbtn.textContent = 'Pending friend request'; }
|
||||
else if (rel === 'FriendRequestReceived') { fbtn.className = 'friend-row-btn'; fbtn.textContent = 'Accept friend request'; }
|
||||
else if (rel === 'Friend') { fbtn.className = 'friend-row-btn is-friend'; fbtn.textContent = 'Friends'; }
|
||||
else { fbtn.className = 'friend-row-btn'; fbtn.textContent = 'Add friend'; }
|
||||
};
|
||||
setFbtn();
|
||||
// Hit the real site friend endpoints (they run the same FriendsDB ops the game uses, so a
|
||||
// request sent here shows up in-game). action is request|accept|remove.
|
||||
async function act(action) {
|
||||
if (!targetId) { toast('Could not identify this player.'); return; }
|
||||
fbtn.disabled = true;
|
||||
const { ok, data } = await apiPost('/api/site/friends/' + action, { id: targetId });
|
||||
fbtn.disabled = false;
|
||||
if (!ok) { toast((data && data.error) || 'Something went wrong.'); return; }
|
||||
rel = data.relationship || (action === 'request' ? 'FriendRequestSent' : action === 'accept' ? 'Friend' : 'None');
|
||||
setFbtn();
|
||||
toast(action === 'request' ? `Friend request sent to ${escapeHtml(displayName)}`
|
||||
: action === 'accept' ? `You and ${escapeHtml(displayName)} are now friends`
|
||||
: 'Friendship updated');
|
||||
}
|
||||
fbtn.addEventListener('click', () => {
|
||||
if (!requireLogin('send friend requests')) return;
|
||||
if (rel === 'FriendRequestSent') {
|
||||
openConfirm({ title: 'Cancel friend request?', message: `Cancel your friend request to ${escapeHtml(displayName)}?`, confirmText: 'Cancel request', onConfirm: () => act('remove') });
|
||||
} else if (rel === 'Friend') {
|
||||
openConfirm({ title: 'Remove friend?', message: `Remove ${escapeHtml(displayName)} from your friends?`, confirmText: 'Remove friend', onConfirm: () => act('remove') });
|
||||
} else if (rel === 'FriendRequestReceived') {
|
||||
act('accept');
|
||||
} else {
|
||||
act('request');
|
||||
}
|
||||
});
|
||||
friendRow.innerHTML = '';
|
||||
friendRow.appendChild(fbtn);
|
||||
}
|
||||
}
|
||||
|
||||
// friend options (nickname + pin) - others, only when friended
|
||||
// Anyone's "Friends" count opens their list (accepted friends only for others).
|
||||
if (!isOwn) {
|
||||
const fr = document.getElementById('statFriendsRow');
|
||||
if (fr) {
|
||||
const n = fr.querySelector('strong');
|
||||
if (n && !n.dataset.wired) { n.dataset.wired = '1'; n.style.cursor = 'pointer'; n.title = 'See friends'; n.addEventListener('click', e => { e.stopPropagation(); openFriendsList(viewed); }); }
|
||||
}
|
||||
}
|
||||
|
||||
function renderFriendPanel() {
|
||||
const fp = document.getElementById('friendPanel');
|
||||
if (!fp) return;
|
||||
const serverFriend = api ? api.relationship === 'Friend' : isFriend(viewed);
|
||||
if (isOwn || !serverFriend) { fp.innerHTML = ''; return; }
|
||||
const d = Object.assign({ nickname: '', pinned: false }, getFriends()[viewed] || {});
|
||||
if (api) { d.favourite = !!api.favourite; d.nickname = api.nickname || ''; d.pinned = !!api.pinned; }
|
||||
const starSvg = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M12 3.5l2.6 5.3 5.9.9-4.2 4.1 1 5.8L12 17l-5.3 2.8 1-5.8-4.2-4.1 5.9-.9z"/></svg>';
|
||||
fp.innerHTML = `
|
||||
<span class="fo-title">Friend options</span>
|
||||
<button class="fo-fav${d.favourite ? ' on' : ''}">${starSvg}<span>${d.favourite ? 'Favourited' : 'Favourite'}</span></button>
|
||||
<button class="fo-nick-btn">${d.nickname ? 'Edit nickname' : 'Set nickname'}</button>
|
||||
<button class="fo-pin${d.pinned ? ' on' : ''}">${d.pinned ? 'Pinned to home' : 'Pin to home'}</button>`;
|
||||
fp.querySelector('.fo-fav').addEventListener('click', async e => {
|
||||
const btn = e.currentTarget;
|
||||
let fav;
|
||||
if (api && u && u.id) {
|
||||
const want = !btn.classList.contains('on');
|
||||
const { ok, data } = await apiPost('/api/site/friends/favourite', { id: u.id, on: want });
|
||||
if (!ok) { toast('Could not update favourites'); return; }
|
||||
fav = !!data.favourite;
|
||||
} else {
|
||||
fav = toggleFriendFavourite(viewed);
|
||||
}
|
||||
btn.classList.toggle('on', fav);
|
||||
btn.querySelector('span').textContent = fav ? 'Favourited' : 'Favourite';
|
||||
toast(fav ? `Added ${cap(viewed)} to favourites` : `Removed ${cap(viewed)} from favourites`);
|
||||
});
|
||||
fp.querySelector('.fo-nick-btn').addEventListener('click', () => openNickname(viewed, () => { renderFriendPanel(); renderNickChip(); },
|
||||
api && u && u.id ? { id: u.id, current: api.nickname || '', onSaved: n => { api.nickname = n || null; } } : null));
|
||||
fp.querySelector('.fo-pin').addEventListener('click', async e => {
|
||||
const btn = e.currentTarget;
|
||||
let pinned;
|
||||
if (api && u && u.id) {
|
||||
const want = !btn.classList.contains('on');
|
||||
const { ok, data } = await apiPost('/api/site/friends/pin', { id: u.id, on: want });
|
||||
if (!ok) { toast((data && data.error) || 'Could not update pins'); return; }
|
||||
pinned = !!data.pinned; api.pinned = pinned;
|
||||
} else {
|
||||
pinned = toggleFriendPin(viewed);
|
||||
}
|
||||
btn.classList.toggle('on', pinned);
|
||||
btn.textContent = pinned ? 'Pinned to home' : 'Pin to home';
|
||||
toast(pinned ? `${cap(viewed)} pinned to your home page` : `${cap(viewed)} unpinned`);
|
||||
});
|
||||
}
|
||||
renderFriendPanel();
|
||||
|
||||
// "Subscribers" row opens the list of people subscribed to this profile (anyone's).
|
||||
const subsRow = document.getElementById('statSubsRow');
|
||||
if (subsRow && !subsRow.dataset.wired) {
|
||||
subsRow.dataset.wired = '1';
|
||||
subsRow.classList.add('statbox-clickable');
|
||||
subsRow.setAttribute('role', 'button');
|
||||
subsRow.tabIndex = 0;
|
||||
subsRow.addEventListener('click', () => openSubsList(viewed, 'subscribers'));
|
||||
subsRow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSubsList(viewed, 'subscribers'); } });
|
||||
}
|
||||
|
||||
// third stat row: "Subscriptions" (own - opens who you subscribe to) or a Subscribe button (others)
|
||||
const subWrap = document.getElementById('profileSubscribe');
|
||||
if (subWrap) {
|
||||
if (isOwn) {
|
||||
// Same markup as before (so it keeps the row's own styling); the ROW is the button.
|
||||
subWrap.innerHTML = '<span class="sub-text">Subscriptions</span>';
|
||||
if (!subWrap.dataset.wired) {
|
||||
subWrap.dataset.wired = '1';
|
||||
subWrap.style.cursor = 'pointer';
|
||||
subWrap.setAttribute('role', 'button');
|
||||
subWrap.tabIndex = 0;
|
||||
subWrap.title = 'See who you subscribe to';
|
||||
subWrap.addEventListener('click', () => openSubsList(viewed, 'subscriptions'));
|
||||
subWrap.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSubsList(viewed, 'subscriptions'); } });
|
||||
}
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'subscribe-btn' + (subscribed ? ' subscribed' : '');
|
||||
btn.textContent = subscribed ? 'Subscribed' : 'Subscribe';
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!requireLogin('subscribe')) return;
|
||||
if (!rateLimit('subscribe', 12, 60000)) { toast('You\'re doing that too fast.'); return; }
|
||||
if (api && u && u.id) {
|
||||
const { ok, data: r } = await apiPost('/api/site/subscribe', { id: u.id, on: !subscribed });
|
||||
if (!ok) { toast((r && r.error) || 'Could not update the subscription'); return; }
|
||||
subscribed = !!r.subscribed;
|
||||
data.subscribers = typeof r.subscribers === 'number' ? r.subscribers : data.subscribers;
|
||||
} else {
|
||||
subscribed = !subscribed;
|
||||
localStorage.setItem(subKey, subscribed ? '1' : '0');
|
||||
}
|
||||
btn.textContent = subscribed ? 'Subscribed' : 'Subscribe';
|
||||
btn.classList.toggle('subscribed', subscribed);
|
||||
if (statSubs) statSubs.textContent = fmt(subCount());
|
||||
});
|
||||
subWrap.innerHTML = '';
|
||||
subWrap.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// photos - real from the API (each links to /image/{id}); demo fallback respects local deletions
|
||||
const delKey = 'campus-deleted-' + viewed;
|
||||
const deleted = JSON.parse(localStorage.getItem(delKey) || '[]');
|
||||
const photosTaggedEl = document.getElementById('profilePhotosTagged');
|
||||
if (api && Array.isArray(api.photos)) {
|
||||
const lockBadge = '<span class="pphoto-lock" aria-label="Private" title="Only you can see this" style="position:absolute;top:8px;right:8px;width:26px;height:26px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);border-radius:50%;color:#fff"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>';
|
||||
const tile = p =>
|
||||
`<a class="pphoto${p.isPrivate ? ' is-private' : ''}" href="${imageUrl(p.id)}" data-id="${p.id}" style="position:relative;background-image:url('${p.image}');background-size:cover;background-position:center">${p.isPrivate ? lockBadge : ''}</a>`;
|
||||
// "Photos taken" = ones this player took; "Tagged in" = ones they're tagged in (theirs or others').
|
||||
// Older photos may lack the flags: if neither flag is present on any photo, treat all as taken.
|
||||
const hasFlags = api.photos.some(p => 'taken' in p || 'tagged' in p);
|
||||
const taken = hasFlags ? api.photos.filter(p => p.taken) : api.photos;
|
||||
const tagged = hasFlags ? api.photos.filter(p => p.tagged) : [];
|
||||
photosEl.innerHTML = taken.length ? taken.map(tile).join('') : `<p class="profile-empty">No photos taken yet.</p>`;
|
||||
if (photosTaggedEl) photosTaggedEl.innerHTML = tagged.length ? tagged.map(tile).join('') : `<p class="profile-empty">Not tagged in any photos yet.</p>`;
|
||||
} else {
|
||||
if (photosTaggedEl) photosTaggedEl.innerHTML = `<p class="profile-empty">Not tagged in any photos yet.</p>`;
|
||||
const source = USER_PHOTOS[viewed] || PROFILE_PHOTOS;
|
||||
const visible = source.filter(p => !deleted.includes(p.id));
|
||||
photosEl.innerHTML = visible.length
|
||||
? visible.map(p => {
|
||||
const bg = p.image
|
||||
? `background-image:url('${p.image}');background-size:cover;background-position:center`
|
||||
: `background-image:url('assets/hero-bg.jpg');background-position:${p.pos};background-size:${p.size}`;
|
||||
return `<a class="pphoto" href="${imageUrl(viewed + '__' + p.id)}" data-id="${p.id}" style="${bg}"></a>`;
|
||||
}).join('')
|
||||
: `<p class="profile-empty">No photos yet.</p>`;
|
||||
}
|
||||
|
||||
// delete handler
|
||||
photosEl.addEventListener('click', e => {
|
||||
const del = e.target.closest('.pphoto-del');
|
||||
if (!del) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tile = del.closest('.pphoto');
|
||||
const id = tile.dataset.id;
|
||||
const d = JSON.parse(localStorage.getItem(delKey) || '[]');
|
||||
if (!d.includes(id)) { d.push(id); localStorage.setItem(delKey, JSON.stringify(d)); }
|
||||
tile.style.opacity = '0';
|
||||
tile.style.transform = 'scale(.9)';
|
||||
setTimeout(() => { tile.remove(); refreshPhotoCount(); }, 180);
|
||||
});
|
||||
|
||||
function refreshPhotoCount() {
|
||||
const n = photosEl.querySelectorAll('.pphoto').length;
|
||||
if (n === 0) photosEl.innerHTML = `<p class="profile-empty">No photos yet.</p>`;
|
||||
}
|
||||
|
||||
// ── Rooms tab (rec.net-style room cards) ──
|
||||
const roomsEl = document.getElementById('profileRooms');
|
||||
if (roomsEl) {
|
||||
// visits = an eye, same glyph as the Rooms page and room page stat strip
|
||||
const PERSON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 12S5.5 5 12 5s10.5 7 10.5 7-4 7-10.5 7S1.5 12 1.5 12z"/><circle cx="12" cy="12" r="3.2"/></svg>';
|
||||
const avSrc = (u && u.profile_image) || avatarUrl(viewed);
|
||||
if (api && Array.isArray(api.rooms)) {
|
||||
roomsEl.innerHTML = api.rooms.length
|
||||
? api.rooms.map(r => {
|
||||
const cover = r.image
|
||||
? `background-image:url('${r.image}');background-size:cover;background-position:center`
|
||||
: `background:linear-gradient(135deg,#ff5a3c,#7b2ff7)`;
|
||||
return `<a class="room-card" href="${roomUrl(r.name || r.id)}">
|
||||
<div class="room-card-head">
|
||||
<img class="room-card-av" src="${avSrc}" alt="">
|
||||
<div class="room-card-id"><span class="room-card-name">^${escapeHtml(r.name || 'Room')}</span><span class="room-card-by">@${escapeHtml(r.creator || handleText)}</span></div>
|
||||
</div>
|
||||
<div class="room-card-cover" style="${cover}"></div>
|
||||
<div class="room-card-foot">
|
||||
<span class="room-card-stat">${PERSON}${fmt(r.visits || 0)}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('')
|
||||
: `<p class="profile-empty">No rooms yet.</p>`;
|
||||
} else {
|
||||
const roomIds = ['whisker-heights', 'midnight-mews', 'litterbox-lab', 'yarn-quest', 'deep-dive-reef', 'haunted-cattery'];
|
||||
roomsEl.innerHTML = roomIds.map(rid => {
|
||||
const r = ROOMS_DATA[rid];
|
||||
return `<a class="room-card" href="${roomUrl(rid)}">
|
||||
<div class="room-card-head">
|
||||
<img class="room-card-av" src="${avSrc}" alt="">
|
||||
<div class="room-card-id"><span class="room-card-name">^${r.name}</span><span class="room-card-by">@${viewed}</span></div>
|
||||
</div>
|
||||
<div class="room-card-cover" style="background:linear-gradient(135deg,${r.a},${r.b})"></div>
|
||||
<div class="room-card-foot">
|
||||
<span class="room-card-stat">${CHEER_ICON}${fmt(r.players)}</span>
|
||||
<span class="room-card-stat">${PERSON}${r.visits}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events tab ──
|
||||
const eventsEl = document.getElementById('profileEvents');
|
||||
if (eventsEl) loadEvents(eventsEl, '/api/site/profile/' + encodeURIComponent(viewed) + '/events', { showRoom: true }, isOwn ? "You aren't hosting any events." : 'No upcoming events.');
|
||||
|
||||
// ── tab switching ──
|
||||
const ptabs = document.querySelectorAll('.profile-tabs .ptab');
|
||||
const ppanels = document.querySelectorAll('.profile-panel');
|
||||
ptabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
ptabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
const which = tab.dataset.tab;
|
||||
ppanels.forEach(p => p.classList.toggle('active', p.dataset.panel === which));
|
||||
});
|
||||
});
|
||||
}
|
||||
renderProfile();
|
||||
|
||||
// ───── IMAGE PAGE ─────
|
||||
async function renderImage() {
|
||||
const photoEl = document.getElementById('imagePhoto');
|
||||
if (!photoEl) return;
|
||||
|
||||
const id = viewedImageId();
|
||||
// Real photo from /api/site/photos/{id} (numeric id); demo getPhoto() as a fallback for
|
||||
// the old "user__letter" ids.
|
||||
let ph = null;
|
||||
if (apiOn() && id != null && /^\d+$/.test(String(id))) {
|
||||
const data = await apiGet('/api/site/photos/' + encodeURIComponent(id));
|
||||
if (data && data.ok && data.photo) {
|
||||
const p = data.photo, a = p.author || {};
|
||||
ph = {
|
||||
user: a.display_name || a.username || 'Player',
|
||||
handle: a.username || 'player',
|
||||
avatar: a.profile_image,
|
||||
image: p.image, pos: 'center', size: 'cover',
|
||||
title: p.description || 'Photo', takenAt: p.createdAt,
|
||||
cheers: p.cheers || 0, comments: p.comments || 0, cheered: !!p.cheered,
|
||||
commentList: Array.isArray(p.commentList) ? p.commentList.map(serverComment) : [],
|
||||
tagged: Array.isArray(p.taggedPlayers) ? p.taggedPlayers : [],
|
||||
room: p.room || null,
|
||||
isOwner: !!p.isOwner, visibility: p.visibility || 'public',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!ph) ph = getPhoto(id);
|
||||
|
||||
if (!ph) {
|
||||
document.getElementById('imagePage').innerHTML = '<p class="image-missing">Photo not found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${ph.title || 'Photo'} - Campus`;
|
||||
|
||||
const author = document.getElementById('imageAuthor');
|
||||
author.href = userUrl(ph.handle);
|
||||
const imgAvEl = document.getElementById('imageAuthorAv');
|
||||
imgAvEl.src = ph.avatar || avatarUrl(ph.handle);
|
||||
imgAvEl.dataset.av = avKey(ph.handle);
|
||||
document.getElementById('imageAuthorName').textContent = ph.user;
|
||||
document.getElementById('imageAuthorHandle').textContent = '@' + ph.handle;
|
||||
|
||||
photoEl.style.backgroundImage = `url('${ph.image || 'assets/hero-bg.jpg'}')`;
|
||||
photoEl.style.backgroundPosition = ph.pos || 'center';
|
||||
photoEl.style.backgroundSize = ph.size || 'cover';
|
||||
|
||||
// ── tagged players: a little people button on the photo that opens a list ──
|
||||
const tagged = Array.isArray(ph.tagged) ? ph.tagged.slice() : [];
|
||||
if (tagged.length) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'tagbtn'; btn.type = 'button';
|
||||
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg><span class="tagbtn-n"></span>';
|
||||
const pop = document.createElement('div'); pop.className = 'tagpop';
|
||||
photoEl.appendChild(btn); photoEl.appendChild(pop);
|
||||
const renderTags = () => {
|
||||
const n = btn.querySelector('.tagbtn-n'); if (n) n.textContent = tagged.length;
|
||||
if (!tagged.length) { btn.remove(); pop.remove(); return; }
|
||||
pop.innerHTML = '<div class="tp-head">In this photo</div>' + tagged.map(t =>
|
||||
`<div class="tp-row" data-pid="${t.id}"><span class="tp-av" style="background-image:url('${t.profile_image || ''}')"></span>`
|
||||
+ `<a class="tp-name" href="${userUrl(t.username)}">@${escapeHtml(t.username)}</a>`
|
||||
+ (ph.isOwner ? `<button class="tp-x" data-remove="${t.id}" title="Remove tag" aria-label="Remove @${escapeHtml(t.username)}">×</button>` : '')
|
||||
+ `</div>`).join('');
|
||||
};
|
||||
renderTags();
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); pop.classList.toggle('open'); });
|
||||
document.addEventListener('click', () => pop.classList.remove('open'));
|
||||
pop.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const rm = e.target.closest('.tp-x'); if (!rm) return;
|
||||
const pid = rm.dataset.remove; rm.disabled = true;
|
||||
const res = await apiPost('/api/site/photos/' + encodeURIComponent(id) + '/untag', { playerId: Number(pid) });
|
||||
if (res && res.ok) {
|
||||
const idx = tagged.findIndex(t => String(t.id) === String(pid));
|
||||
if (idx >= 0) tagged.splice(idx, 1);
|
||||
renderTags();
|
||||
if (!tagged.length) pop.classList.remove('open');
|
||||
toast('Tag removed');
|
||||
} else { rm.disabled = false; toast('Could not remove tag'); }
|
||||
});
|
||||
}
|
||||
|
||||
// ── owner: photo visibility (private / friends / public), synced to the server + in-game ──
|
||||
if (ph.isOwner) {
|
||||
const infoText = document.querySelector('.image-info-text');
|
||||
if (infoText) {
|
||||
const seg = document.createElement('div'); seg.className = 'vis-seg';
|
||||
const opts = [
|
||||
['private', 'Private', '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>'],
|
||||
['friends', 'Friends', '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/>'],
|
||||
['public', 'Public', '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18"/>'],
|
||||
];
|
||||
seg.innerHTML = opts.map(([v, label, path]) =>
|
||||
`<button type="button" data-vis="${v}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${path}</svg>${label}</button>`).join('');
|
||||
infoText.appendChild(seg);
|
||||
const mark = v => seg.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.vis === v));
|
||||
mark(ph.visibility || 'public');
|
||||
seg.addEventListener('click', async e => {
|
||||
const b = e.target.closest('button'); if (!b) return;
|
||||
const v = b.dataset.vis; if (v === ph.visibility) return;
|
||||
const prev = ph.visibility; mark(v);
|
||||
const res = await apiPost('/api/site/photos/' + encodeURIComponent(id) + '/visibility', { visibility: v });
|
||||
if (res && res.ok) { ph.visibility = (res.data && res.data.visibility) || v; mark(ph.visibility); toast('Photo set to ' + (ph.visibility === 'friends' ? 'friends only' : ph.visibility)); }
|
||||
else { mark(prev); toast('Could not update visibility'); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const imgExport = document.getElementById('imgExport');
|
||||
if (imgExport) imgExport.addEventListener('click', async () => {
|
||||
const src = ph.image || '';
|
||||
if (!src) { toast('Nothing to download'); return; }
|
||||
imgExport.disabled = true;
|
||||
try {
|
||||
const name = (src.split('/').pop() || 'photo.jpg').split('?')[0];
|
||||
await saveFile(await fetchBlob(src), photoFileName(name, ph.title !== 'Photo' ? ph.title : ''));
|
||||
} catch { toast('Could not download the photo'); }
|
||||
imgExport.disabled = false;
|
||||
});
|
||||
|
||||
const imgReport = document.getElementById('imgReport');
|
||||
if (imgReport) imgReport.addEventListener('click', () => openReport('Report photo', () => {
|
||||
imgReport.textContent = 'Reported';
|
||||
imgReport.disabled = true;
|
||||
}, /^\d+$/.test(String(id)) ? { type: 'photo', id } : null));
|
||||
|
||||
// title (with a custom override) + edit pencil for the owner
|
||||
const titleEl = document.getElementById('imageTitle');
|
||||
const savedTitle = localStorage.getItem('campus-title-' + id);
|
||||
if (savedTitle && !/^\d+$/.test(String(id))) ph.title = savedTitle;
|
||||
titleEl.textContent = ph.title || 'Untitled photo';
|
||||
document.title = `${ph.title || 'Photo'} - Campus`;
|
||||
|
||||
const owner = localStorage.getItem(USER_KEY);
|
||||
if (owner && owner.toLowerCase() === (ph.handle || '').toLowerCase()) {
|
||||
// wrap title in a row with a pencil button
|
||||
const row = document.createElement('div');
|
||||
row.className = 'title-row';
|
||||
titleEl.parentNode.insertBefore(row, titleEl);
|
||||
row.appendChild(titleEl);
|
||||
|
||||
const pencil = document.createElement('button');
|
||||
pencil.className = 'title-edit';
|
||||
pencil.setAttribute('aria-label', 'Edit title');
|
||||
pencil.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>';
|
||||
row.appendChild(pencil);
|
||||
|
||||
pencil.addEventListener('click', () => {
|
||||
const current = titleEl.textContent;
|
||||
const editor = document.createElement('div');
|
||||
editor.className = 'title-editor';
|
||||
editor.innerHTML = `<input class="title-input" type="text" maxlength="80" value="${escapeHtml(current)}"><button class="title-save">Save</button><button class="title-cancel">Cancel</button>`;
|
||||
row.style.display = 'none';
|
||||
row.parentNode.insertBefore(editor, row);
|
||||
const input = editor.querySelector('.title-input');
|
||||
input.focus(); input.select();
|
||||
const finish = () => { editor.remove(); row.style.display = ''; };
|
||||
editor.querySelector('.title-save').addEventListener('click', async () => {
|
||||
const v = input.value.trim() || 'Untitled photo';
|
||||
if (apiOn() && /^\d+$/.test(String(id))) {
|
||||
const r = await apiPost('/api/site/photos/' + encodeURIComponent(id) + '/description', { description: input.value.trim() });
|
||||
if (!r || !r.ok) { toast('Could not save the title'); return; }
|
||||
const saved = (r.data && r.data.description) || '';
|
||||
titleEl.textContent = saved || 'Untitled photo';
|
||||
document.title = `${saved || 'Photo'} - Campus`;
|
||||
} else {
|
||||
titleEl.textContent = v;
|
||||
document.title = `${v} - Campus`;
|
||||
localStorage.setItem('campus-title-' + id, v);
|
||||
}
|
||||
toast('Title updated');
|
||||
finish();
|
||||
});
|
||||
editor.querySelector('.title-cancel').addEventListener('click', finish);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') editor.querySelector('.title-save').click(); if (e.key === 'Escape') finish(); });
|
||||
});
|
||||
}
|
||||
|
||||
const metaEl = document.getElementById('imageMeta');
|
||||
metaEl.innerHTML = (ph.takenAt ? `<span>${timeAgo(ph.takenAt)} ago</span>` : '')
|
||||
+ (ph.room && ph.room.name ? `<span class="image-room-sep">·</span><a class="image-room" href="${roomUrl(ph.room.name)}">^${escapeHtml(ph.room.name)}</a>` : '');
|
||||
|
||||
// comments (reuse the comment system; persist per photo)
|
||||
// ⋯ menu - set this photo as profile picture / banner
|
||||
const menuBtn = document.getElementById('imageMenuBtn');
|
||||
const menu = document.getElementById('imageMenu');
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
if (menuBtn && menu) {
|
||||
if (!me) {
|
||||
menuBtn.style.display = 'none'; // must be logged in to set your own
|
||||
} else {
|
||||
// owner of the photo gets a delete option
|
||||
const isOwner = me.toLowerCase() === (ph.handle || '').toLowerCase();
|
||||
if (isOwner) {
|
||||
const delItem = document.createElement('button');
|
||||
delItem.className = 'image-menu-item danger';
|
||||
delItem.dataset.act = 'delete';
|
||||
delItem.textContent = 'Delete photo';
|
||||
menu.appendChild(delItem);
|
||||
}
|
||||
menuBtn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('open'); });
|
||||
document.addEventListener('click', () => menu.classList.remove('open'));
|
||||
menu.addEventListener('click', e => {
|
||||
const item = e.target.closest('.image-menu-item');
|
||||
if (!item) return;
|
||||
menu.classList.remove('open');
|
||||
const act = item.dataset.act;
|
||||
const cropData = { id: id, image: ph.image || 'assets/hero-bg.jpg', pos: ph.pos, size: ph.size };
|
||||
const data = JSON.stringify(cropData);
|
||||
if (act === 'pfp') {
|
||||
localStorage.setItem('campus-pfp-' + me, data);
|
||||
refreshAvatars(avKey(me), cropData.image); // update every avatar instantly (optimistic)
|
||||
if (apiOn() && /^\d+$/.test(String(id))) {
|
||||
// a real photo: the server points your profile picture at it (no download / re-upload from the browser)
|
||||
apiPost('/api/site/me/pfp', { photoId: Number(id) }).then(r => {
|
||||
if (r && r.ok) {
|
||||
toast('Profile picture updated');
|
||||
const url = r.data && r.data.profile_image;
|
||||
if (url) refreshAvatars(avKey(me), url);
|
||||
} else toast((r && r.data && r.data.error) || 'Could not set the profile picture');
|
||||
});
|
||||
} else {
|
||||
toast('Profile picture updated');
|
||||
syncProfileImageToApi(cropData.image); // also push to the backend
|
||||
}
|
||||
} else if (act === 'banner') {
|
||||
if (apiOn() && /^\d+$/.test(String(id))) {
|
||||
apiPost('/api/site/me/banner', { photoId: Number(id) }).then(r => toast(r && r.ok ? 'Profile banner updated' : ((r && r.data && r.data.error) || 'Could not set the banner')));
|
||||
} else {
|
||||
localStorage.setItem('campus-banner-' + me, data);
|
||||
toast('Profile banner updated');
|
||||
}
|
||||
} else if (act === 'delete') {
|
||||
openConfirm({
|
||||
title: 'Delete photo?',
|
||||
message: 'This photo will be removed from your profile. This can\'t be undone.',
|
||||
confirmText: 'Delete',
|
||||
onConfirm: async () => {
|
||||
if (apiOn() && /^\d+$/.test(String(id))) {
|
||||
const r = await apiDelete('/api/site/photos/' + encodeURIComponent(id));
|
||||
if (!r || !r.ok) { toast('Could not delete the photo'); return; }
|
||||
}
|
||||
if (id && String(id).includes('__')) {
|
||||
const [u, letter] = id.split('__');
|
||||
const key = 'campus-deleted-' + u;
|
||||
const d = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
if (!d.includes(letter)) { d.push(letter); localStorage.setItem(key, JSON.stringify(d)); }
|
||||
}
|
||||
toast('Photo deleted');
|
||||
setTimeout(() => { window.location.href = userUrl(ph.handle); }, 650);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const host = document.getElementById('imageCommentsHost');
|
||||
if (/^\d+$/.test(String(id))) host.dataset.photo = String(id);
|
||||
const block = commentsBlock({ thread: ph.commentList || [] });
|
||||
host.innerHTML = block.replace(' hidden', ''); // always visible here
|
||||
if (host.dataset.photo) { const l = host.querySelector('.comment-list'); if (l) l.dataset.loaded = '1'; }
|
||||
|
||||
// cheer button just above the comment form / Post button
|
||||
const cheerBtn = `<button class="pstat cheer${ph.cheered ? ' liked' : ''}" data-n="${(ph.cheers || 0) - (ph.cheered ? 1 : 0)}">${CHEER_ICON}<span>${fmt(ph.cheers || 0)}</span></button>`;
|
||||
const tools = document.getElementById('imageTools');
|
||||
if (tools) tools.insertAdjacentHTML('afterbegin', cheerBtn);
|
||||
else {
|
||||
const form = host.querySelector('.comment-form');
|
||||
if (form) form.insertAdjacentHTML('beforebegin', `<div class="image-actions">${cheerBtn}</div>`);
|
||||
else host.querySelector('.post-comments')?.insertAdjacentHTML('beforeend', `<div class="image-actions">${cheerBtn}</div>`);
|
||||
}
|
||||
const ch = document.getElementById('imageComments')?.querySelector('h2');
|
||||
if (ch && (ph.commentList || []).length) ch.textContent = `Comments (${ph.commentList.length})`;
|
||||
}
|
||||
|
||||
// ───── EXPORT (Settings > Export, and the image page's Export button) ─────
|
||||
// Everything happens in the browser: the page lists the photos, fetches each /img/ file,
|
||||
// zips them with JSZip (loaded from cdnjs on first use) and hands the zip to the browser's
|
||||
// download. The server never builds or stores an archive.
|
||||
async function fetchBlob(url) {
|
||||
const r = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return await r.blob();
|
||||
}
|
||||
function saveFile(blob, filename) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = filename;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||||
}
|
||||
// "<id>_<title>.<ext>" - keeps the server's name so the file is unique, adds the title if any.
|
||||
function photoFileName(serverName, title) {
|
||||
const m = /^(.*?)(\.[a-z0-9]+)?$/i.exec(serverName || 'photo.jpg');
|
||||
const base = m[1] || 'photo', ext = m[2] || '.jpg';
|
||||
const t = (title || '').replace(/[^\w\- ]+/g, '').trim().slice(0, 40);
|
||||
return t ? `${base}_${t.replace(/\s+/g, '_')}${ext}` : base + ext;
|
||||
}
|
||||
let _jszip = null;
|
||||
function loadJSZip() {
|
||||
if (window.JSZip) return Promise.resolve(window.JSZip);
|
||||
if (_jszip) return _jszip;
|
||||
_jszip = new Promise((res, rej) => {
|
||||
const sc = document.createElement('script');
|
||||
sc.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
|
||||
sc.onload = () => res(window.JSZip); sc.onerror = () => { _jszip = null; rej(new Error('jszip')); };
|
||||
document.head.appendChild(sc);
|
||||
});
|
||||
return _jszip;
|
||||
}
|
||||
function openExportPhotos() {
|
||||
if (!localStorage.getItem(USER_KEY)) { toast('Log in to export your photos'); return; }
|
||||
let scrim = document.getElementById('exportScrim');
|
||||
let modal = document.getElementById('exportModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'exportScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'exportModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
}
|
||||
const close = () => { modal.classList.remove('open'); scrim.classList.remove('open'); document.body.style.overflow = ''; };
|
||||
modal.innerHTML = `
|
||||
<div class="report-card export-card">
|
||||
<button class="report-close" aria-label="Close">✕</button>
|
||||
<h2 class="report-title">Export Photos</h2>
|
||||
<p class="report-sub">Choose what to include. The photos are zipped in your browser.</p>
|
||||
<label class="export-opt"><input type="checkbox" id="expTaken" checked><span class="export-check"></span><span><b>Photos taken by you</b><small>Every photo you took, including private ones.</small></span></label>
|
||||
<label class="export-opt"><input type="checkbox" id="expTagged" checked><span class="export-check"></span><span><b>Photos including you</b><small>Photos other players took that you're tagged in.</small></span></label>
|
||||
<p class="export-progress" id="expProgress" hidden></p>
|
||||
<div class="report-actions">
|
||||
<button class="report-cancel export-cancel">Cancel</button>
|
||||
<button class="report-submit export-go">Continue</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const card = modal.querySelector('.export-card');
|
||||
let busy = false;
|
||||
const tryClose = () => { if (!busy) close(); };
|
||||
scrim.onclick = tryClose; modal.onclick = e => { if (e.target === modal) tryClose(); };
|
||||
card.querySelector('.report-close').addEventListener('click', tryClose);
|
||||
card.querySelector('.export-cancel').addEventListener('click', tryClose);
|
||||
const go = card.querySelector('.export-go');
|
||||
const prog = card.querySelector('#expProgress');
|
||||
const say = t => { prog.hidden = false; prog.textContent = t; };
|
||||
go.addEventListener('click', async () => {
|
||||
const taken = card.querySelector('#expTaken').checked, tagged = card.querySelector('#expTagged').checked;
|
||||
if (!taken && !tagged) { say('Pick at least one option.'); return; }
|
||||
busy = true; go.disabled = true; card.querySelectorAll('input').forEach(i => i.disabled = true);
|
||||
try {
|
||||
say('Loading your photo list…');
|
||||
const d = await apiGet(`/api/site/me/photos?taken=${taken}&tagged=${tagged}`);
|
||||
const list = (d && d.ok && d.photos) || [];
|
||||
if (!list.length) { say('No photos to export.'); busy = false; go.disabled = false; card.querySelectorAll('input').forEach(i => i.disabled = false); return; }
|
||||
const JSZip = await loadJSZip();
|
||||
const zip = new JSZip();
|
||||
const takenDir = zip.folder('taken-by-me'), taggedDir = zip.folder('photos-of-me');
|
||||
let n = 0, failed = 0;
|
||||
for (const ph of list) {
|
||||
n++; say(`Downloading ${n} / ${list.length}…`);
|
||||
try {
|
||||
const blob = await fetchBlob(ph.image);
|
||||
const name = photoFileName(ph.file || (ph.image || '').split('/').pop(), ph.description);
|
||||
(ph.taken ? takenDir : taggedDir).file(name, blob);
|
||||
} catch { failed++; }
|
||||
}
|
||||
say('Zipping…');
|
||||
const out = await zip.generateAsync({ type: 'blob', compression: 'STORE' });
|
||||
const me = localStorage.getItem(USER_KEY) || 'campus';
|
||||
saveFile(out, `campus-photos-${me}-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
say(`Done - ${list.length - failed} photo${list.length - failed === 1 ? '' : 's'} exported${failed ? `, ${failed} could not be downloaded` : ''}.`);
|
||||
go.textContent = 'Close'; go.disabled = false; busy = false;
|
||||
go.onclick = close;
|
||||
} catch (e) {
|
||||
say('Export failed - ' + (e && e.message === 'jszip' ? 'could not load the zip library.' : 'please try again.'));
|
||||
busy = false; go.disabled = false; card.querySelectorAll('input').forEach(i => i.disabled = false);
|
||||
}
|
||||
});
|
||||
modal.classList.add('open'); scrim.classList.add('open'); document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Export Data: the player's own account as one .json file (built by the server from their account; nothing is stored).
|
||||
async function openExportData() {
|
||||
if (!localStorage.getItem(USER_KEY)) { toast('Log in to export your data'); return; }
|
||||
const btn = document.getElementById('exportData');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/site/me/export', { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const blob = await r.blob();
|
||||
const me = localStorage.getItem(USER_KEY) || 'campus';
|
||||
saveFile(blob, `campus-data-${me}-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
toast('Your data was downloaded');
|
||||
} catch { toast('Could not export your data - please try again'); }
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
|
||||
// Export Rooms: pick from the rooms you CREATED, the browser fetches each room's files and zips them. The folder layout
|
||||
// is the one the admin Archive importer reads, so an export can be imported back.
|
||||
function openExportRooms() {
|
||||
if (!localStorage.getItem(USER_KEY)) { toast('Log in to export your rooms'); return; }
|
||||
let scrim = document.getElementById('exportScrim');
|
||||
let modal = document.getElementById('exportModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'exportScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'exportModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
}
|
||||
const close = () => { modal.classList.remove('open'); scrim.classList.remove('open'); document.body.style.overflow = ''; };
|
||||
modal.innerHTML = `
|
||||
<div class="report-card export-card">
|
||||
<button class="report-close" aria-label="Close">✕</button>
|
||||
<h2 class="report-title">Export Rooms</h2>
|
||||
<p class="report-sub">Rooms you created. Each room is saved with its settings, picture and every saved build.</p>
|
||||
<div id="expRoomList" style="max-height:280px;overflow:auto"></div>
|
||||
<p class="export-progress" id="expProgress"></p>
|
||||
<div class="report-actions">
|
||||
<button class="report-cancel export-cancel">Cancel</button>
|
||||
<button class="report-submit export-go" disabled>Export</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const card = modal.querySelector('.export-card');
|
||||
const list = card.querySelector('#expRoomList');
|
||||
const prog = card.querySelector('#expProgress');
|
||||
const go = card.querySelector('.export-go');
|
||||
const say = t => { prog.hidden = false; prog.textContent = t; };
|
||||
let busy = false;
|
||||
const tryClose = () => { if (!busy) close(); };
|
||||
scrim.onclick = tryClose; modal.onclick = e => { if (e.target === modal) tryClose(); };
|
||||
card.querySelector('.report-close').addEventListener('click', tryClose);
|
||||
card.querySelector('.export-cancel').addEventListener('click', tryClose);
|
||||
modal.classList.add('open'); scrim.classList.add('open'); document.body.style.overflow = 'hidden';
|
||||
say('Loading your rooms...');
|
||||
|
||||
const boxes = () => Array.from(list.querySelectorAll('input[data-room]'));
|
||||
const refresh = () => { go.disabled = busy || !boxes().some(b => b.checked); };
|
||||
|
||||
apiGet('/api/site/me/rooms').then(d => {
|
||||
const rooms = (d && d.ok && d.rooms) || [];
|
||||
if (!rooms.length) { say('You have not created any rooms.'); return; }
|
||||
prog.hidden = true;
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
list.innerHTML = `<label class="export-opt"><input type="checkbox" id="expAllRooms" checked><span class="export-check"></span><span><b>All rooms (${rooms.length})</b></span></label>` +
|
||||
rooms.map(r => `<label class="export-opt"><input type="checkbox" data-room="${esc(r.id)}" data-name="${esc(r.name)}" checked><span class="export-check"></span><span><b>${esc(r.name)}</b><small>${r.isDorm ? 'Your dorm - ' : ''}${r.subrooms} subroom${r.subrooms === 1 ? '' : 's'}</small></span></label>`).join('');
|
||||
list.querySelector('#expAllRooms').addEventListener('change', e => { boxes().forEach(b => b.checked = e.target.checked); refresh(); });
|
||||
boxes().forEach(b => b.addEventListener('change', refresh));
|
||||
refresh();
|
||||
});
|
||||
|
||||
go.addEventListener('click', async () => {
|
||||
const picked = boxes().filter(b => b.checked);
|
||||
if (!picked.length) return;
|
||||
busy = true; go.disabled = true; card.querySelectorAll('input').forEach(i => i.disabled = true);
|
||||
try {
|
||||
const JSZip = await loadJSZip();
|
||||
const zip = new JSZip();
|
||||
let failed = 0, done = 0;
|
||||
for (const b of picked) {
|
||||
done++;
|
||||
say(`Room ${done} / ${picked.length}: ${b.dataset.name}...`);
|
||||
const man = await apiGet(`/api/site/me/rooms/${b.dataset.room}/manifest`);
|
||||
if (!man || !man.ok) { failed++; continue; }
|
||||
const dir = zip.folder(man.folder);
|
||||
dir.file('RoomDetails.json', man.details);
|
||||
for (const f of man.files) {
|
||||
try {
|
||||
const blob = await fetchBlob(`/api/site/me/rooms/${b.dataset.room}/file?name=${encodeURIComponent(f.name)}`);
|
||||
dir.file(f.path, blob);
|
||||
} catch { failed++; }
|
||||
}
|
||||
}
|
||||
say('Zipping...');
|
||||
const out = await zip.generateAsync({ type: 'blob', compression: 'STORE' });
|
||||
const me = localStorage.getItem(USER_KEY) || 'campus';
|
||||
saveFile(out, `campus-rooms-${me}-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||
say(`Done - ${picked.length} room${picked.length === 1 ? '' : 's'} exported${failed ? `, ${failed} file${failed === 1 ? '' : 's'} could not be downloaded` : ''}.`);
|
||||
go.textContent = 'Close'; go.disabled = false; busy = false;
|
||||
go.onclick = close;
|
||||
} catch (e) {
|
||||
say('Export failed - ' + (e && e.message === 'jszip' ? 'could not load the zip library.' : 'please try again.'));
|
||||
busy = false; card.querySelectorAll('input').forEach(i => i.disabled = false); refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ───── QUICK ADMIN (profile + room pages) ─────
|
||||
// The "Admin" button, for dev accounts only: /api/site/me says admin:true when the account is a
|
||||
// developer AND this network could reach the admin panel. Every action goes through
|
||||
// /api/site/admin/*, which re-checks both and runs the admin panel's own handler, so it bans,
|
||||
// kicks and logs exactly like the panel does.
|
||||
async function mountAdminButton(kind, key, container) {
|
||||
if (!container || !key || !apiOn()) return;
|
||||
await SESSION_READY;
|
||||
if (!CAMPUS_ADMIN || container.querySelector('.admin-quick-btn')) return;
|
||||
const b = document.createElement('button');
|
||||
b.className = 'profile-report-btn admin-quick-btn';
|
||||
b.textContent = 'Admin';
|
||||
b.addEventListener('click', () => openAdminPopup(kind, key));
|
||||
container.appendChild(b);
|
||||
}
|
||||
|
||||
function adminDur(sec) {
|
||||
if (sec == null) return 'permanently';
|
||||
const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.ceil(sec % 3600 / 60);
|
||||
return d ? `${d}d ${h}h left` : h ? `${h}h ${m}m left` : `${m}m left`;
|
||||
}
|
||||
|
||||
async function openAdminPopup(kind, key) {
|
||||
let scrim = document.getElementById('adminScrim');
|
||||
let modal = document.getElementById('adminModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'adminScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'adminModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
}
|
||||
const close = () => { modal.classList.remove('open'); scrim.classList.remove('open'); document.body.style.overflow = ''; };
|
||||
scrim.onclick = close; modal.onclick = e => { if (e.target === modal) close(); };
|
||||
const esc = s => escapeHtml(String(s == null ? '' : s));
|
||||
const base = `/api/site/admin/${kind}/${encodeURIComponent(key)}`;
|
||||
const ACC = ['Private', 'Public', 'Unlisted', 'Dev only', 'Dev unlisted'];
|
||||
|
||||
const playerHtml = d => {
|
||||
const flags = [d.dev && 'Developer', d.mod && 'Moderator', d.junior && 'Junior'].filter(Boolean);
|
||||
const where = d.online ? (d.room ? `Online in ^${esc(d.room.name)}${d.room.isPrivate ? ' (private)' : ''}` : 'Online') : 'Offline';
|
||||
const noBan = d.dev || d.is_self;
|
||||
return `<h2 class="report-title">Admin · @${esc(d.username)}</h2>
|
||||
<p class="report-sub">#${d.id} · ${where} · Level ${d.level} · ${fmt(d.tokens)} tokens${flags.length ? ' · ' + flags.join(', ') : ''}</p>
|
||||
${d.ban ? `<p class="adm-status">Banned ${d.ban.permanent ? 'permanently' : '(' + adminDur(d.ban.remaining_seconds) + ')'}${d.ban.reason ? ': ' + esc(d.ban.reason) : ''}</p>` : ''}
|
||||
${d.mic_ban ? `<p class="adm-status">Mic banned ${d.mic_ban.permanent ? 'permanently' : '(' + adminDur(d.mic_ban.remaining_seconds) + ')'}</p>` : ''}
|
||||
<div class="adm-sec"><h3>Room</h3><div class="adm-row">
|
||||
<button class="adm-btn" data-act="kick" ${d.is_self ? 'disabled' : ''}>Kick</button>
|
||||
<button class="adm-btn" data-act="join" ${d.is_self || !d.room ? 'disabled' : ''}>Go to them</button>
|
||||
<button class="adm-btn" data-act="bring" ${d.is_self ? 'disabled' : ''}>Bring to me</button></div></div>
|
||||
<div class="adm-sec"><h3>Ban</h3>
|
||||
${d.ban ? `<div class="adm-row"><button class="adm-btn" data-act="unban">Unban</button></div>` : `
|
||||
<div class="adm-row"><select class="adm-in" id="admBanDur" aria-label="Ban length">
|
||||
<option value="3600">1 hour</option><option value="86400">1 day</option><option value="259200">3 days</option>
|
||||
<option value="604800" selected>7 days</option><option value="2592000">30 days</option><option value="permanent">Permanent</option></select>
|
||||
<input class="adm-in grow" id="admBanReason" maxlength="200" placeholder="Reason (they see this)" aria-label="Ban reason"></div>
|
||||
<div class="adm-row"><label class="adm-chk"><input type="checkbox" id="admBanDevice"> Device</label>
|
||||
<label class="adm-chk"><input type="checkbox" id="admBanIp"> IP</label>
|
||||
<button class="adm-btn danger" data-act="ban" ${noBan ? 'disabled title="Developers can’t be banned"' : ''}>Ban</button></div>`}</div>
|
||||
<div class="adm-sec"><h3>Mic</h3><div class="adm-row">
|
||||
${d.mic_ban ? `<button class="adm-btn" data-act="micunban">Lift mic ban</button>` : `
|
||||
<select class="adm-in" id="admMicMin" aria-label="Mic ban length"><option value="60">1 hour</option><option value="1440" selected>1 day</option><option value="10080">7 days</option><option value="0">Permanent</option></select>
|
||||
<input class="adm-in grow" id="admMicReason" maxlength="200" placeholder="Reason" aria-label="Mic ban reason">
|
||||
<button class="adm-btn" data-act="micban" ${d.is_self ? 'disabled' : ''}>Mic ban</button>`}</div></div>
|
||||
<div class="adm-sec"><h3>Tokens</h3><div class="adm-row">
|
||||
<input class="adm-in grow" id="admTokens" type="number" step="1" placeholder="Amount (negative takes away)" aria-label="Token amount">
|
||||
<button class="adm-btn" data-act="tokens">Give</button></div></div>`;
|
||||
};
|
||||
|
||||
const roomHtml = d => `<h2 class="report-title">Admin · ^${esc(d.name)}</h2>
|
||||
<p class="report-sub">#${d.id} · by ${esc(d.creator || 'nobody')} · ${d.players_now} playing now${d.featured ? ' · Featured' : ''}${d.dorm ? ' · Dorm' : ''}</p>
|
||||
<div class="adm-sec"><h3>Details</h3>
|
||||
<div class="adm-row"><input class="adm-in grow" id="admRoomName" maxlength="60" value="${esc(d.name)}" placeholder="Name" aria-label="Room name"></div>
|
||||
<div class="adm-row"><textarea class="adm-in grow" id="admRoomDesc" rows="2" maxlength="500" placeholder="Description" aria-label="Description">${esc(d.description)}</textarea></div>
|
||||
<div class="adm-row"><select class="adm-in" id="admRoomAcc" aria-label="Who can join">${ACC.map((n, i) => `<option value="${i}"${i === d.accessibility ? ' selected' : ''}>${n}</option>`).join('')}</select>
|
||||
<input class="adm-in adm-num" id="admRoomMax" type="number" min="1" max="100" value="${d.max_players}" title="Max players" aria-label="Max players">
|
||||
<label class="adm-chk"><input type="checkbox" id="admRoomClone"${d.cloning ? ' checked' : ''}> Cloning</label>
|
||||
<button class="adm-btn" data-act="edit">Save</button></div></div>
|
||||
<div class="adm-sec"><h3>Room</h3><div class="adm-row">
|
||||
<button class="adm-btn" data-act="${d.featured ? 'unfeature' : 'feature'}">${d.featured ? 'Unfeature' : 'Feature'}</button>
|
||||
<button class="adm-btn danger" data-act="delete" ${d.dorm ? 'disabled title="A dorm can’t be deleted"' : ''}>Delete room</button></div></div>`;
|
||||
|
||||
const paintNote = (t, bad) => {
|
||||
const n = modal.querySelector('.adm-note');
|
||||
if (n) { n.hidden = false; n.textContent = t; n.classList.toggle('bad', !!bad); }
|
||||
};
|
||||
|
||||
const act = async (btn, d) => {
|
||||
const a = btn.dataset.act;
|
||||
const v = id => { const el = modal.querySelector('#' + id); return el ? el.value : ''; };
|
||||
const ck = id => { const el = modal.querySelector('#' + id); return !!(el && el.checked); };
|
||||
let body = {};
|
||||
if (a === 'ban') {
|
||||
if (!v('admBanReason').trim()) { paintNote('Give a reason for the ban.', true); return; }
|
||||
body = { duration: v('admBanDur'), reason: v('admBanReason'), device: ck('admBanDevice'), ip: ck('admBanIp') };
|
||||
if (!confirm(`Ban @${d.username}${body.duration === 'permanent' ? ' permanently' : ''}?`)) return;
|
||||
} else if (a === 'micban') {
|
||||
body = { minutes: v('admMicMin'), reason: v('admMicReason') };
|
||||
} else if (a === 'tokens') {
|
||||
const t = v('admTokens').trim();
|
||||
if (!/^-?\d+$/.test(t) || +t === 0) { paintNote('Enter a whole number of tokens.', true); return; }
|
||||
body = { amount: t };
|
||||
} else if (a === 'edit') {
|
||||
body = { name: v('admRoomName'), description: v('admRoomDesc'), accessibility: v('admRoomAcc'), maxPlayers: v('admRoomMax'), cloningAllowed: ck('admRoomClone') ? 'true' : 'false' };
|
||||
} else if (a === 'delete') {
|
||||
if (!confirm(`Delete ^${d.name}? This can’t be undone.`)) return;
|
||||
} else if (a === 'kick') {
|
||||
if (!confirm(`Kick @${d.username} from their room?`)) return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
const { ok, data } = await apiPost(`${base}/${a}`, body);
|
||||
const msg = (data && (data.message || data.error)) || (ok ? 'Done.' : 'That didn’t work.');
|
||||
if (a === 'delete' && ok) { close(); toast(msg); setTimeout(() => { location.href = '/rooms'; }, 900); return; }
|
||||
await paint(msg, !ok);
|
||||
};
|
||||
|
||||
const paint = async (note, bad) => {
|
||||
// apiGet is redefined further down to THROW on a non-OK answer (a lapsed session is a 403).
|
||||
let d = null;
|
||||
try { d = await apiGet(base); } catch { d = null; }
|
||||
if (!d || !d.ok) {
|
||||
modal.innerHTML = `<div class="report-card adm-card"><button class="report-close" aria-label="Close">✕</button>
|
||||
<h2 class="report-title">Admin</h2><p class="report-sub">Couldn’t load this ${kind}. Your admin access may have ended - reload the page.</p></div>`;
|
||||
modal.querySelector('.report-close').onclick = close;
|
||||
return;
|
||||
}
|
||||
modal.innerHTML = `<div class="report-card adm-card"><button class="report-close" aria-label="Close">✕</button>
|
||||
${kind === 'player' ? playerHtml(d) : roomHtml(d)}
|
||||
<p class="adm-note${bad ? ' bad' : ''}" ${note ? '' : 'hidden'}>${esc(note || '')}</p>
|
||||
<a class="adm-panel-link" href="/admin/home/v4?${kind}=${d.id}" target="_blank" rel="noopener">Open ${kind === 'player' ? '@' + esc(d.username) : '^' + esc(d.name)} in the admin panel</a></div>`;
|
||||
const card = modal.querySelector('.adm-card');
|
||||
card.querySelector('.report-close').onclick = close;
|
||||
card.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => act(b, d)));
|
||||
// The panel's login redirect drops ?player= / ?room=, so leave the target for it to pick up.
|
||||
card.querySelector('.adm-panel-link').addEventListener('click', () => {
|
||||
try { localStorage.setItem('campus-admin-open', JSON.stringify({ kind, id: d.id, at: Date.now() })); } catch {}
|
||||
});
|
||||
};
|
||||
|
||||
modal.innerHTML = `<div class="report-card adm-card"><p class="report-sub">Loading…</p></div>`;
|
||||
modal.classList.add('open'); scrim.classList.add('open'); document.body.style.overflow = 'hidden';
|
||||
await paint();
|
||||
}
|
||||
|
||||
// ───── SETTINGS PAGE ─────
|
||||
function initSettings() {
|
||||
const nav = document.querySelector('.settings-nav');
|
||||
if (!nav) return;
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
if (!me) {
|
||||
// not logged in — show a 401
|
||||
const main = document.querySelector('.settings-page');
|
||||
if (main) main.innerHTML =
|
||||
`<div class="forbidden">
|
||||
<h1>401</h1>
|
||||
<p>You need to be logged in to view your settings.</p>
|
||||
<a href="/login" class="btn-dl">Log in</a>
|
||||
</div>`;
|
||||
document.title = '401 - Campus';
|
||||
return;
|
||||
}
|
||||
|
||||
// header
|
||||
const suAv = document.getElementById('suAv');
|
||||
if (suAv) { suAv.src = avatarUrl(me); suAv.dataset.av = avKey(me); }
|
||||
const suName = document.getElementById('suName');
|
||||
if (suName) suName.textContent = myDisplayName(me);
|
||||
if (me) { siteWatch(me); renderSettingsStatus(); }
|
||||
const suHandle = document.getElementById('suHandle');
|
||||
if (suHandle) suHandle.textContent = '@' + myHandle(me);
|
||||
|
||||
// profile fields — show current values as faded placeholders (clear when typing)
|
||||
const username = document.getElementById('setUsername');
|
||||
if (username) { username.value = ''; username.placeholder = myHandle(me); }
|
||||
const nick = document.getElementById('setNickname');
|
||||
if (nick) { nick.value = ''; nick.placeholder = myDisplayName(me); }
|
||||
const bio = document.getElementById('setBio');
|
||||
if (bio) bio.value = localStorage.getItem('campus-mybio') || '';
|
||||
|
||||
document.getElementById('exportPhotos')?.addEventListener('click', openExportPhotos);
|
||||
document.getElementById('exportData')?.addEventListener('click', openExportData);
|
||||
document.getElementById('exportRooms')?.addEventListener('click', openExportRooms);
|
||||
|
||||
const themeSel = document.getElementById('setTheme');
|
||||
if (themeSel) {
|
||||
themeSel.value = localStorage.getItem(THEME_KEY) || 'light';
|
||||
themeSel.addEventListener('change', () => { localStorage.setItem(THEME_KEY, themeSel.value); applyTheme(themeSel.value); syncThemeToggles(); });
|
||||
}
|
||||
|
||||
// nav switching
|
||||
document.querySelectorAll('.snav[data-panel]').forEach(b => {
|
||||
b.addEventListener('click', () => {
|
||||
document.querySelector('.snav.active')?.classList.remove('active');
|
||||
b.classList.add('active');
|
||||
const p = b.dataset.panel;
|
||||
document.querySelectorAll('.spanel').forEach(s => s.classList.toggle('active', s.dataset.panel === p));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Discord linking tab ----
|
||||
// Isolated flow: generate a code here, run /link <code> with the Campus bot. The server only
|
||||
// records a Discord<->account link (no admin access). See SiteController /api/site/discord/*.
|
||||
(function initDiscordTab() {
|
||||
const stText = document.getElementById('discordStatusText');
|
||||
if (!stText) return;
|
||||
const linkBtn = document.getElementById('discordLinkBtn');
|
||||
const unlinkBtn = document.getElementById('discordUnlinkBtn');
|
||||
|
||||
async function jfetch(path, method) {
|
||||
try {
|
||||
const r = await fetch(path, { method: method || 'GET', credentials: 'same-origin' });
|
||||
return r.ok ? await r.json() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
const show = (el, on) => { if (el) el.style.display = on ? '' : 'none'; };
|
||||
|
||||
async function load() {
|
||||
stText.textContent = 'Checking…';
|
||||
show(linkBtn, false); show(unlinkBtn, false);
|
||||
const s = await jfetch('/api/site/discord/status');
|
||||
if (!s || !s.ok) { stText.textContent = 'Could not load status.'; return; }
|
||||
if (s.linked) {
|
||||
stText.textContent = 'Connected' + (s.discordUsername ? (' as ' + s.discordUsername) : '');
|
||||
show(unlinkBtn, true);
|
||||
} else {
|
||||
stText.textContent = 'Not connected';
|
||||
show(linkBtn, true);
|
||||
}
|
||||
}
|
||||
|
||||
// "Link Discord" -> full-page redirect to Discord's authorize page (OAuth). The callback
|
||||
// links the account and returns to /settings?discord=linked. Same app as the admin login, but
|
||||
// the link-scoped state means it NEVER logs you into the admin panel.
|
||||
document.getElementById('discordLinkBtn')?.addEventListener('click', () => {
|
||||
window.location.href = '/api/site/discord/oauth/start';
|
||||
});
|
||||
document.getElementById('discordUnlinkBtn')?.addEventListener('click', async () => {
|
||||
const r = await jfetch('/api/site/discord/unlink', 'POST');
|
||||
if (!r || !r.ok) { toast('Could not unlink.'); return; }
|
||||
toast('Discord unlinked'); load(); if (typeof initSocialCards === 'function') initSocialCards();
|
||||
});
|
||||
|
||||
// Coming back from the OAuth round-trip: toast the outcome and open the Discord tab.
|
||||
const dq = new URLSearchParams(location.search).get('discord');
|
||||
if (dq) {
|
||||
const msg = { linked: 'Discord linked!', expired: 'Link timed out — try again.',
|
||||
inuse: 'That Discord is already linked to another account.',
|
||||
error: 'Could not link Discord.' }[dq] || null;
|
||||
if (msg) toast(msg);
|
||||
document.querySelector('.snav[data-panel="services"]')?.click();
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
// Load status the first time the Services tab is opened.
|
||||
let loaded = false;
|
||||
document.querySelector('.snav[data-panel="services"]')?.addEventListener('click', () => {
|
||||
if (!loaded) { loaded = true; load(); }
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- Spotify (Settings > Services) ----
|
||||
// OAuth against the server's Spotify app (SiteController /api/site/spotify/*). The card shows
|
||||
// the linked account, a live "listening to" preview and the "show to" choice.
|
||||
(function initSpotifyTab() {
|
||||
const stText = document.getElementById('spotifyStatusText');
|
||||
if (!stText) return;
|
||||
const linkBtn = document.getElementById('spotifyLinkBtn');
|
||||
const unlinkBtn = document.getElementById('spotifyUnlinkBtn');
|
||||
const visRow = document.getElementById('spotifyVisRow');
|
||||
const visSel = document.getElementById('spotifyVis');
|
||||
const preview = document.getElementById('spotifyNowPreview');
|
||||
const show = (el, on) => { if (el) el.style.display = on ? '' : 'none'; };
|
||||
let timer = null;
|
||||
|
||||
async function jfetch(path, method, body) {
|
||||
try {
|
||||
const r = await fetch(path, { method: method || 'GET', credentials: 'same-origin',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||
return r.ok ? await r.json() : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
stText.textContent = 'Checking…';
|
||||
show(linkBtn, false); show(unlinkBtn, false); visRow.hidden = true;
|
||||
const s = await jfetch('/api/site/spotify/status');
|
||||
if (!s || !s.ok) { stText.textContent = 'Could not load status.'; return; }
|
||||
if (!s.configured) { stText.textContent = 'Spotify linking is not set up on this server yet.'; return; }
|
||||
if (s.linked) {
|
||||
if (s.notAllowed) {
|
||||
// The token is real but Spotify refuses this user: the Campus app is in Spotify's
|
||||
// Development Mode, which only serves allow-listed users (User Management, max 25).
|
||||
stText.textContent = 'Connected, but Spotify is not allowing this account yet — the Campus Spotify app is in development mode, so a Campus dev has to add your Spotify email under User Management. Your song will appear automatically once they have.';
|
||||
} else {
|
||||
stText.textContent = (s.broken ? 'Link expired — link again' : 'Connected') + (s.displayName ? (' as ' + s.displayName) : '');
|
||||
}
|
||||
show(unlinkBtn, true); if (s.broken) show(linkBtn, true);
|
||||
visRow.hidden = false; visSel.value = String(s.visibility ?? 1);
|
||||
renderSpotify(preview, s.now_playing);
|
||||
clearInterval(timer);
|
||||
timer = setInterval(async () => {
|
||||
if (document.hidden) return;
|
||||
const r = await jfetch('/api/site/spotify/status');
|
||||
if (r && r.ok) renderSpotify(preview, r.now_playing);
|
||||
}, 15000);
|
||||
} else {
|
||||
stText.textContent = 'Not connected';
|
||||
show(linkBtn, true); renderSpotify(preview, null);
|
||||
}
|
||||
}
|
||||
|
||||
linkBtn?.addEventListener('click', () => { window.location.href = '/api/site/spotify/oauth/start'; });
|
||||
unlinkBtn?.addEventListener('click', async () => {
|
||||
const r = await jfetch('/api/site/spotify/unlink', 'POST');
|
||||
if (!r || !r.ok) { toast('Could not unlink.'); return; }
|
||||
toast('Spotify unlinked'); clearInterval(timer); load(); if (typeof initSocialCards === 'function') initSocialCards();
|
||||
});
|
||||
visSel?.addEventListener('change', async () => {
|
||||
const r = await jfetch('/api/site/spotify/visibility', 'POST', { visibility: Number(visSel.value) });
|
||||
toast(r && r.ok ? 'Saved' : 'Could not save.');
|
||||
});
|
||||
|
||||
const sq = new URLSearchParams(location.search).get('spotify');
|
||||
if (sq) {
|
||||
const msg = { linked: 'Spotify linked!', notallowed: 'Linked, but Spotify is not allowing this account yet — ask a Campus dev to add your Spotify email to the app.',
|
||||
expired: 'Link timed out — try again.', denied: 'Spotify access was declined.',
|
||||
inuse: 'That Spotify account is already linked to another player.',
|
||||
unconfigured: 'Spotify linking is not set up on this server yet.', error: 'Could not link Spotify.' }[sq] || null;
|
||||
if (msg) toast(msg);
|
||||
document.querySelector('.snav[data-panel="services"]')?.click();
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
let loaded = false;
|
||||
document.querySelector('.snav[data-panel="services"]')?.addEventListener('click', () => {
|
||||
if (!loaded) { loaded = true; load(); initSocialCards(); }
|
||||
});
|
||||
})();
|
||||
|
||||
// Services: every linkable social as a card (sign in with the service -> verified handle),
|
||||
// each with its own "Show on my profile" switch; Discord and Spotify keep their own cards and
|
||||
// only get the switch. Data from /api/site/social/status.
|
||||
async function initSocialCards() {
|
||||
const host = document.getElementById('socialCards');
|
||||
if (!host || !apiOn()) return;
|
||||
let r = null;
|
||||
try { r = await apiGet('/api/site/social/status'); } catch {}
|
||||
if (!r || !r.ok) { host.innerHTML = '<p style="opacity:.7;margin-top:14px">Could not load your services.</p>'; return; }
|
||||
const cards = (r.providers || []).filter(p => p.oauth);
|
||||
host.innerHTML = cards.map(p => {
|
||||
const status = !p.configured ? 'Not set up on this server yet'
|
||||
: p.linked ? ('Connected as ' + (p.url ? '<a href="' + escapeHtml(p.url) + '" target="_blank" rel="noopener">' + escapeHtml(p.handle) + '</a>' : escapeHtml(p.handle)))
|
||||
: 'Not connected';
|
||||
return '<div class="discord-card svc-card svc-' + p.key + '" data-svc="' + p.key + '">'
|
||||
+ '<div class="discord-logo" aria-hidden="true" style="color:#fff">' + (SOCIAL_ICONS[p.key] || '').replace('<svg ', '<svg width="26" height="26" ') + '</div>'
|
||||
+ '<div class="discord-info"><div class="discord-title">' + escapeHtml(p.label) + '</div><div class="discord-status">' + status + '</div>'
|
||||
+ (p.linked ? '<label class="svc-show" data-svc="' + p.key + '"><span>Show on my profile</span><span class="switch"><input type="checkbox" class="svc-show-toggle" data-svc="' + p.key + '"' + (p.show ? ' checked' : '') + '><span class="slider"></span></span></label>' : '')
|
||||
+ '</div>'
|
||||
+ (p.linked
|
||||
? '<button class="discord-unlink-btn svc-unlink" data-svc="' + p.key + '">Unlink</button>'
|
||||
: '<button class="discord-link-btn svc-link" data-svc="' + p.key + '"' + (p.configured ? '' : ' disabled') + '>Link ' + escapeHtml(p.label) + '</button>')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
// Discord / Spotify switches (their cards are static in the HTML)
|
||||
for (const p of (r.providers || []).filter(p => !p.oauth)) {
|
||||
const row = document.querySelector('.svc-show[data-svc="' + p.key + '"]');
|
||||
const box = row && row.querySelector('.svc-show-toggle');
|
||||
if (row) row.hidden = !p.linked;
|
||||
if (box) box.checked = !!p.show;
|
||||
}
|
||||
host.querySelectorAll('.svc-link').forEach(b => b.addEventListener('click', () => { window.location.href = '/api/site/social/' + b.dataset.svc + '/start'; }));
|
||||
host.querySelectorAll('.svc-unlink').forEach(b => b.addEventListener('click', async () => {
|
||||
const { ok } = await apiPost('/api/site/social/' + b.dataset.svc + '/unlink');
|
||||
if (!ok) { toast('Could not unlink.'); return; }
|
||||
toast('Unlinked'); initSocialCards();
|
||||
}));
|
||||
document.querySelectorAll('.svc-show-toggle').forEach(t => {
|
||||
if (t.dataset.wired) return; t.dataset.wired = '1';
|
||||
t.addEventListener('change', async () => {
|
||||
const { ok } = await apiPost('/api/site/social/' + t.dataset.svc + '/show', { show: t.checked });
|
||||
toast(ok ? (t.checked ? 'Shown on your profile' : 'Hidden from your profile') : 'Could not save.');
|
||||
});
|
||||
});
|
||||
}
|
||||
// Back from a service's sign-in: toast the outcome, open Services.
|
||||
const sq2 = new URLSearchParams(location.search).get('social');
|
||||
if (sq2) {
|
||||
const [prov, outcome] = sq2.split(':');
|
||||
const label = prov ? prov.charAt(0).toUpperCase() + prov.slice(1) : 'Service';
|
||||
const msg = { linked: label + ' linked!', expired: 'Link timed out — try again.', denied: label + ' access was declined.',
|
||||
inuse: 'That ' + label + ' account is already linked to another player.',
|
||||
unconfigured: label + ' linking is not set up on this server yet.', error: 'Could not link ' + label + '.' }[outcome] || null;
|
||||
if (msg) toast(msg);
|
||||
document.querySelector('.snav[data-panel="services"]')?.click();
|
||||
history.replaceState(null, '', location.pathname);
|
||||
}
|
||||
|
||||
// save buttons — Profile now POSTs to the server (was localStorage-only, so @name / display
|
||||
// name / bio never reached the game or another browser). The server persists them and pushes
|
||||
// an AccountUpdate so the change shows in-game without a relog.
|
||||
// Email panel: load the account's current address, save it to the server.
|
||||
const acctEmail = document.getElementById('acctEmail');
|
||||
if (acctEmail && apiOn()) {
|
||||
(async () => {
|
||||
try { const r = await apiGet('/api/site/me'); if (r && r.ok) { acctEmail.value = r.email || ''; acctEmail.dataset.saved = r.email || ''; } } catch {}
|
||||
})();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.ssave').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (btn.dataset.save === 'Email' && acctEmail && apiOn()) {
|
||||
const email = acctEmail.value.trim();
|
||||
if (email === (acctEmail.dataset.saved || '')) { toast('Email settings saved'); return; }
|
||||
btn.disabled = true;
|
||||
const { ok, data } = await apiPost('/api/site/email', { email });
|
||||
btn.disabled = false;
|
||||
if (!ok) { toast((data && data.error) || 'Could not save your email.'); return; }
|
||||
acctEmail.dataset.saved = data.email || '';
|
||||
toast(email ? 'Email saved' : 'Email removed');
|
||||
return;
|
||||
}
|
||||
if (btn.dataset.save === 'Password' && apiOn()) {
|
||||
const cur = document.getElementById('pwCur'), nw = document.getElementById('pwNew'), cf = document.getElementById('pwConf');
|
||||
if (!cur.value) { toast('Enter your current password.'); return; }
|
||||
if (nw.value.length < 6) { toast('Password must be at least 6 characters.'); return; }
|
||||
if (nw.value !== cf.value) { toast("The passwords don't match."); return; }
|
||||
btn.disabled = true;
|
||||
const { ok, data } = await apiPost('/api/site/password', { current_password: cur.value, new_password: nw.value, confirm_password: cf.value });
|
||||
btn.disabled = false;
|
||||
if (!ok) { toast((data && data.error) || 'Could not change your password.'); return; }
|
||||
cur.value = nw.value = cf.value = '';
|
||||
toast('Password changed. Use it next time you log in, on the website and in the game.');
|
||||
return;
|
||||
}
|
||||
if (btn.dataset.save !== 'Profile') { toast(`${btn.dataset.save} settings saved`); return; }
|
||||
|
||||
const payload = {};
|
||||
if (username && username.value.trim()) payload.username = username.value.trim().replace(/[^a-zA-Z0-9_.]/g, '');
|
||||
if (nick && nick.value.trim()) payload.display_name = nick.value.trim();
|
||||
if (bio) payload.bio = bio.value.trim(); // bio can be cleared, so always send it
|
||||
if (!Object.keys(payload).length) { toast('Nothing to change'); return; }
|
||||
|
||||
btn.disabled = true;
|
||||
const { ok, data } = await apiPost('/api/site/profile', payload);
|
||||
btn.disabled = false;
|
||||
if (!ok) { toast((data && data.error) || 'Could not save your profile.'); return; }
|
||||
|
||||
// Reflect the SERVER's truth everywhere.
|
||||
if (data.username) localStorage.setItem('campus-username', data.username);
|
||||
if (data.display_name) localStorage.setItem('campus-mynick', data.display_name);
|
||||
localStorage.setItem('campus-mybio', data.bio || '');
|
||||
if (typeof CAMPUS_ME === 'object' && CAMPUS_ME) {
|
||||
if (data.username) CAMPUS_ME.username = data.username;
|
||||
if (data.display_name) CAMPUS_ME.display_name = data.display_name;
|
||||
}
|
||||
if (username) { username.value = ''; username.placeholder = data.username || myHandle(me); }
|
||||
if (nick) { nick.value = ''; nick.placeholder = data.display_name || myDisplayName(me); }
|
||||
if (suName) suName.textContent = data.display_name || myDisplayName(me);
|
||||
if (suHandle) suHandle.textContent = '@' + (data.username || myHandle(me));
|
||||
toast('Profile settings saved');
|
||||
});
|
||||
});
|
||||
|
||||
// logout actions
|
||||
const logout = document.getElementById('setLogout');
|
||||
if (logout) logout.addEventListener('click', async () => { if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} } clearIdentityCache(); window.location.href = '/'; });
|
||||
const logoutAll = document.getElementById('logoutAll');
|
||||
if (logoutAll) logoutAll.addEventListener('click', async () => { if (apiOn()) { try { await apiPost('/api/site/logout'); } catch {} } clearIdentityCache(); window.location.href = '/'; });
|
||||
}
|
||||
|
||||
// ───── THEME (light/dark + accent) ─────
|
||||
const THEME_KEY = 'campus-theme';
|
||||
const ACCENT_KEY = 'campus-accent';
|
||||
|
||||
function hexRgb(h) { h = h.replace('#', ''); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); }
|
||||
function darken(h, f) { const [r, g, b] = hexRgb(h); return `rgb(${r * f | 0},${g * f | 0},${b * f | 0})`; }
|
||||
function softA(h, a) { const [r, g, b] = hexRgb(h); return `rgba(${r},${g},${b},${a})`; }
|
||||
|
||||
const THEMES = ['light', 'dark', 'autumn', 'halloween'];
|
||||
function applyTheme(t) {
|
||||
if (!THEMES.includes(t)) t = 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('.cd-mode').forEach(b => b.classList.toggle('active', b.dataset.theme === t));
|
||||
autumnLeaves(t === 'autumn');
|
||||
halloweenFx(t === 'halloween');
|
||||
}
|
||||
|
||||
// ───── SEASONS ─────
|
||||
// October is Halloween: the first visit of each October switches the site to the Halloween theme
|
||||
// (once - pick another theme and it sticks). After October a Halloween theme goes back to dark.
|
||||
const HALLOWEEN_SEEN_KEY = 'campus-halloween-seen';
|
||||
function applySeason() {
|
||||
const now = new Date();
|
||||
const october = now.getMonth() === 9;
|
||||
let saved = null;
|
||||
try { saved = localStorage.getItem(THEME_KEY); } catch { }
|
||||
try {
|
||||
if (october && localStorage.getItem(HALLOWEEN_SEEN_KEY) !== String(now.getFullYear())) {
|
||||
localStorage.setItem(HALLOWEEN_SEEN_KEY, String(now.getFullYear()));
|
||||
localStorage.setItem(THEME_KEY, 'halloween');
|
||||
return 'halloween';
|
||||
}
|
||||
if (!october && saved === 'halloween') { localStorage.setItem(THEME_KEY, 'dark'); return 'dark'; }
|
||||
} catch { }
|
||||
return saved || 'light';
|
||||
}
|
||||
|
||||
// ───── HALLOWEEN: cobwebs in the corners, spiders on threads and spiders crawling the page ─────
|
||||
// Pure decoration in a fixed overlay under the top bar: pointer-events are off, so nothing ever
|
||||
// blocks a click. Crawlers wander between random spots, pause, and scuttle away from the mouse.
|
||||
// Reduce motion (site setting or the OS) keeps the cobwebs and drops every spider.
|
||||
const WEB_SVG = '<svg viewBox="0 0 200 200" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">'
|
||||
+ '<path d="M0 0L200 200M0 0L200 92M0 0L92 200M0 0L200 22M0 0L22 200"/>'
|
||||
+ [34, 68, 104, 142, 182].map(r => {
|
||||
const pts = [[r, 0], [r * .97, r * .11], [r * .9, r * .44], [r * .7, r * .7], [r * .44, r * .9], [r * .11, r * .97], [0, r]];
|
||||
let d = 'M' + pts[0].join(' ');
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const a = pts[i - 1], b = pts[i];
|
||||
d += ' Q' + ((a[0] + b[0]) / 2 * .9).toFixed(1) + ' ' + ((a[1] + b[1]) / 2 * .9).toFixed(1) + ' ' + b[0].toFixed(1) + ' ' + b[1].toFixed(1);
|
||||
}
|
||||
return '<path d="' + d + '"/>';
|
||||
}).join('')
|
||||
+ '</svg>';
|
||||
const SPIDER_SVG = '<svg viewBox="0 0 40 40" aria-hidden="true">'
|
||||
+ '<g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">'
|
||||
+ '<g class="legs-a"><path d="M16 17L9 11L5 5"/><path d="M15 23L7 23L2 27"/><path d="M24 20L32 18L37 13"/><path d="M25 26L31 31L33 37"/></g>'
|
||||
+ '<g class="legs-b"><path d="M15 20L8 18L3 14"/><path d="M16 26L10 31L8 37"/><path d="M24 17L31 11L35 5"/><path d="M25 23L33 23L38 27"/></g>'
|
||||
+ '</g>'
|
||||
+ '<ellipse cx="20" cy="26" rx="6.5" ry="8" fill="currentColor"/>'
|
||||
+ '<circle cx="20" cy="15.5" r="4.6" fill="currentColor"/>'
|
||||
+ '<circle class="eye" cx="18.2" cy="13.8" r="1.1"/><circle class="eye" cx="21.8" cy="13.8" r="1.1"/>'
|
||||
+ '</svg>';
|
||||
|
||||
let _hwRaf = 0, _hwBugs = [], _hwMouse = { x: -999, y: -999 }, _hwLast = 0, _hwBound = false;
|
||||
function halloweenFx(on) {
|
||||
let fx = document.querySelector('.hw-fx');
|
||||
if (!on) { fx?.remove(); cancelAnimationFrame(_hwRaf); _hwRaf = 0; _hwBugs = []; return; }
|
||||
if (fx) return;
|
||||
fx = document.createElement('div'); fx.className = 'hw-fx'; fx.setAttribute('aria-hidden', 'true');
|
||||
fx.innerHTML = ['tl', 'tr', 'br', 'bl'].map(c => '<div class="hw-web ' + c + '">' + WEB_SVG + '</div>').join('');
|
||||
const phone = window.innerWidth < 640;
|
||||
const bare = !document.querySelector('.topbar'); // log in / sign up: no top bar - the overlay starts at the top and nothing dangles over the form
|
||||
if (bare) fx.classList.add('hw-bare');
|
||||
// spiders on threads
|
||||
for (let i = 0; i < (bare ? 0 : phone ? 1 : 3); i++) {
|
||||
const d = document.createElement('div'); d.className = 'hw-dangle';
|
||||
d.style.setProperty('--x', (12 + Math.random() * 76).toFixed(1) + '%');
|
||||
d.style.setProperty('--y', (90 + Math.random() * 220).toFixed(0) + 'px');
|
||||
d.style.setProperty('--d', (9 + Math.random() * 7).toFixed(1) + 's');
|
||||
d.style.setProperty('--delay', (-Math.random() * 12).toFixed(1) + 's');
|
||||
d.innerHTML = '<div class="hw-thread"></div><div class="hw-spider">' + SPIDER_SVG + '</div>';
|
||||
fx.appendChild(d);
|
||||
}
|
||||
document.body.appendChild(fx);
|
||||
// crawlers
|
||||
const W = () => fx.clientWidth, H = () => fx.clientHeight;
|
||||
_hwBugs = [];
|
||||
for (let i = 0; i < (phone ? 2 : 4); i++) {
|
||||
const el = document.createElement('div'); el.className = 'hw-spider walking'; el.innerHTML = SPIDER_SVG;
|
||||
const scale = .7 + Math.random() * .6;
|
||||
fx.appendChild(el);
|
||||
el.style.opacity = '0'; // placed on the first frame the overlay has a real size
|
||||
_hwBugs.push({ el, scale, placed: false, x: 0, y: 0, a: Math.random() * Math.PI * 2, tx: 0, ty: 0, speed: 0, pause: Math.random() * 2, flee: 0 });
|
||||
}
|
||||
if (!_hwBound) {
|
||||
_hwBound = true;
|
||||
window.addEventListener('pointermove', e => { _hwMouse.x = e.clientX; _hwMouse.y = e.clientY; }, { passive: true });
|
||||
document.addEventListener('visibilitychange', () => { _hwLast = 0; });
|
||||
}
|
||||
_hwLast = 0;
|
||||
const step = t => {
|
||||
_hwRaf = requestAnimationFrame(step);
|
||||
if (document.hidden) return;
|
||||
const dt = _hwLast ? Math.min(.05, (t - _hwLast) / 1000) : 0; _hwLast = t;
|
||||
const w = W(), h = H(), top = fx.getBoundingClientRect().top;
|
||||
for (const b of _hwBugs) {
|
||||
// A page that loads in a background tab has no size yet - wait for one before placing.
|
||||
if (!Number.isFinite(b.x) || !Number.isFinite(b.y)) b.placed = false;
|
||||
if (!b.placed) {
|
||||
if (w < 50 || h < 50) continue;
|
||||
b.x = 20 + Math.random() * (w - 40); b.y = 20 + Math.random() * (h - 40);
|
||||
b.placed = true; b.el.style.opacity = ''; hwRetarget(b, w, h);
|
||||
}
|
||||
// scuttle away from the cursor
|
||||
let mx = _hwMouse.x - b.x, my = (_hwMouse.y - top) - b.y, md = Math.hypot(mx, my);
|
||||
if (md < .5) { mx = Math.cos(b.a); my = Math.sin(b.a); md = 1; } // cursor dead centre: no direction to flee, so just pick one
|
||||
if (md < 110 && b.flee <= 0) {
|
||||
b.flee = .9; b.pause = 0;
|
||||
b.tx = Math.max(10, Math.min(w - 10, b.x - mx / md * 220));
|
||||
b.ty = Math.max(10, Math.min(h - 10, b.y - my / md * 220));
|
||||
b.speed = 230 + Math.random() * 80;
|
||||
}
|
||||
if (b.flee > 0) b.flee -= dt;
|
||||
if (b.pause > 0) {
|
||||
b.pause -= dt; b.el.classList.remove('walking');
|
||||
} else {
|
||||
const dx = b.tx - b.x, dy = b.ty - b.y, dist = Math.hypot(dx, dy);
|
||||
if (dist < 4) { b.pause = Math.random() < .55 ? .6 + Math.random() * 2.4 : 0; hwRetarget(b, w, h); }
|
||||
else {
|
||||
// turn toward the target gradually, with a little wobble
|
||||
const want = Math.atan2(dy, dx);
|
||||
let da = want - b.a; while (da > Math.PI) da -= 2 * Math.PI; while (da < -Math.PI) da += 2 * Math.PI;
|
||||
b.a += da * Math.min(1, dt * 6) + (Math.random() - .5) * dt * 1.5;
|
||||
const v = b.speed * dt;
|
||||
b.x += Math.cos(b.a) * v; b.y += Math.sin(b.a) * v;
|
||||
b.el.classList.add('walking');
|
||||
}
|
||||
}
|
||||
b.x = Math.max(-20, Math.min(w + 20, b.x)); b.y = Math.max(-20, Math.min(h + 20, b.y));
|
||||
// the SVG faces up, so +90deg turns it to face its heading
|
||||
b.el.style.transform = 'translate(' + (b.x - 17).toFixed(1) + 'px,' + (b.y - 17).toFixed(1) + 'px) rotate(' + (b.a * 180 / Math.PI + 90).toFixed(1) + 'deg) scale(' + b.scale + ')';
|
||||
}
|
||||
};
|
||||
_hwRaf = requestAnimationFrame(step);
|
||||
}
|
||||
function hwRetarget(b, w, h) {
|
||||
// mostly short hops; now and then a long run across the screen
|
||||
const far = Math.random() < .3;
|
||||
const r = far ? 400 + Math.random() * 500 : 60 + Math.random() * 200, a = Math.random() * Math.PI * 2;
|
||||
b.tx = Math.max(15, Math.min(w - 15, b.x + Math.cos(a) * r));
|
||||
b.ty = Math.max(15, Math.min(h - 15, b.y + Math.sin(a) * r));
|
||||
b.speed = 40 + Math.random() * 70;
|
||||
}
|
||||
// Autumn: a few leaves drift down the home hero. Pure decoration; reduce-motion stops them via CSS.
|
||||
function autumnLeaves(on) {
|
||||
const hero = document.querySelector('.home-hero');
|
||||
if (!hero) return;
|
||||
let box = hero.querySelector('.autumn-leaves');
|
||||
if (!on) { box?.remove(); return; }
|
||||
if (box) return;
|
||||
box = document.createElement('div'); box.className = 'autumn-leaves'; box.setAttribute('aria-hidden', 'true');
|
||||
const LEAF = '<svg viewBox="0 0 24 24"><path d="M20 3c-7 0-13 3-16 9 3-1 6-1 9 0-4 1-7 3-9 8 6-2 12-4 14-9 1-2.5 2-5 2-8z"/></svg>';
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const l = document.createElement('span'); l.innerHTML = LEAF;
|
||||
l.style.setProperty('--x', (5 + Math.random() * 90).toFixed(1) + '%');
|
||||
l.style.setProperty('--d', (9 + Math.random() * 9).toFixed(1) + 's');
|
||||
l.style.setProperty('--delay', (-Math.random() * 14).toFixed(1) + 's');
|
||||
l.style.setProperty('--s', (.6 + Math.random() * .7).toFixed(2));
|
||||
l.style.setProperty('--c', ['#f08a3c', '#e0722e', '#c95a22', '#f2b04a', '#d94f5c'][i % 5]);
|
||||
box.appendChild(l);
|
||||
}
|
||||
hero.appendChild(box);
|
||||
}
|
||||
function applyAccent(c) {
|
||||
const s = document.documentElement.style;
|
||||
s.setProperty('--red', c);
|
||||
s.setProperty('--red-dark', darken(c, 0.82));
|
||||
s.setProperty('--red-soft', softA(c, 0.15));
|
||||
document.querySelectorAll('.cd-accent').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.accent.toLowerCase() === c.toLowerCase()));
|
||||
}
|
||||
|
||||
// apply saved choices (runs on every page so theme persists)
|
||||
applyTheme(applySeason());
|
||||
const savedAccent = localStorage.getItem(ACCENT_KEY);
|
||||
if (savedAccent) applyAccent(savedAccent);
|
||||
|
||||
document.querySelectorAll('.cd-mode').forEach(b =>
|
||||
b.addEventListener('click', () => { const t = b.dataset.theme; localStorage.setItem(THEME_KEY, t); applyTheme(t); syncThemeToggles(); }));
|
||||
document.querySelectorAll('.cd-accent').forEach(b =>
|
||||
b.addEventListener('click', () => { const c = b.dataset.accent; localStorage.setItem(ACCENT_KEY, c); applyAccent(c); }));
|
||||
|
||||
// drawer Light/Dark toggle (under Discord)
|
||||
const SUN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
||||
const MOON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
|
||||
const LEAF_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 3c-7 0-13 3-16 9 3-1 6-1 9 0-4 1-7 3-9 8 6-2 12-4 14-9 1-2.5 2-5 2-8z"/><path d="M4 20l9-9"/></svg>';
|
||||
const PUMPKIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7c-1.2-2-.4-3.6 1-4.2"/><path d="M12 7c-5 0-8 2.6-8 6.5S7 20 12 20s8-2.6 8-6.5S17 7 12 7z"/><path d="M9 7.6C7.6 9.6 7.6 17 9 19.4M15 7.6c1.4 2 1.4 9.4 0 11.8"/></svg>';
|
||||
// The drawer button cycles light -> dark -> autumn -> halloween and always names the NEXT theme.
|
||||
function nextTheme() {
|
||||
const cur = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
return THEMES[(THEMES.indexOf(cur) + 1) % THEMES.length];
|
||||
}
|
||||
function syncThemeToggles() {
|
||||
const next = nextTheme();
|
||||
const label = { light: `${SUN_SVG}<span>Light mode</span>`, dark: `${MOON_SVG}<span>Dark mode</span>`, autumn: `${LEAF_SVG}<span>Autumn mode</span>`, halloween: `${PUMPKIN_SVG}<span>Halloween mode</span>` }[next];
|
||||
document.querySelectorAll('.cd-theme-toggle').forEach(btn => { btn.innerHTML = label; });
|
||||
}
|
||||
document.querySelectorAll('.cd-theme-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const next = nextTheme();
|
||||
localStorage.setItem(THEME_KEY, next);
|
||||
applyTheme(next);
|
||||
syncThemeToggles();
|
||||
});
|
||||
});
|
||||
syncThemeToggles();
|
||||
|
||||
// ───── ACCESSIBILITY PREFERENCES ─────
|
||||
const A11Y_KEY = 'campus-a11y';
|
||||
// map each toggle key to the <html> data-attribute it controls
|
||||
const A11Y_ATTRS = {
|
||||
motion: 'data-reduce-motion',
|
||||
text: 'data-large-text',
|
||||
contrast: 'data-high-contrast',
|
||||
links: 'data-underline-links',
|
||||
spacing: 'data-readable-spacing',
|
||||
dyslexia: 'data-dyslexia-font',
|
||||
focus: 'data-focus-rings',
|
||||
cursor: 'data-big-cursor',
|
||||
taptargets: 'data-tap-targets',
|
||||
};
|
||||
function getA11y() { try { return JSON.parse(localStorage.getItem(A11Y_KEY) || '{}'); } catch { return {}; } }
|
||||
function applyA11y(prefs) {
|
||||
const root = document.documentElement;
|
||||
Object.keys(A11Y_ATTRS).forEach(k => root.toggleAttribute(A11Y_ATTRS[k], !!prefs[k]));
|
||||
document.querySelectorAll('.cd-a11y').forEach(btn => {
|
||||
const on = !!prefs[btn.dataset.a11y];
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
applyA11y(getA11y());
|
||||
document.querySelectorAll('.cd-a11y').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const prefs = getA11y();
|
||||
const k = btn.dataset.a11y;
|
||||
prefs[k] = !prefs[k];
|
||||
localStorage.setItem(A11Y_KEY, JSON.stringify(prefs));
|
||||
applyA11y(prefs);
|
||||
syncToggleAll();
|
||||
});
|
||||
});
|
||||
const a11yReset = document.getElementById('a11yReset');
|
||||
if (a11yReset) a11yReset.addEventListener('click', () => {
|
||||
localStorage.removeItem(A11Y_KEY);
|
||||
applyA11y({});
|
||||
syncToggleAll();
|
||||
});
|
||||
|
||||
// "Enable all / Disable all" — flips every accessibility setting at once
|
||||
const a11yToggleAll = document.getElementById('a11yToggleAll');
|
||||
function allA11yOn() { const p = getA11y(); return Object.keys(A11Y_ATTRS).every(k => !!p[k]); }
|
||||
function syncToggleAll() {
|
||||
if (a11yToggleAll) a11yToggleAll.textContent = allA11yOn() ? 'Disable all' : 'Enable all';
|
||||
}
|
||||
if (a11yToggleAll) {
|
||||
a11yToggleAll.addEventListener('click', () => {
|
||||
const turnOn = !allA11yOn();
|
||||
const prefs = {};
|
||||
Object.keys(A11Y_ATTRS).forEach(k => { prefs[k] = turnOn; });
|
||||
localStorage.setItem(A11Y_KEY, JSON.stringify(prefs));
|
||||
applyA11y(prefs);
|
||||
syncToggleAll();
|
||||
});
|
||||
syncToggleAll();
|
||||
}
|
||||
|
||||
initSettings();
|
||||
|
||||
// ───── PHOTO FEED (API-driven) ─────
|
||||
//
|
||||
// Point this at your "recent photos" endpoint. It should return JSON like:
|
||||
// [ { image, user, handle, room, cheers, comments, takenAt }, ... ]
|
||||
// where `takenAt` is an ISO date string. Leave blank to use the fallback below.
|
||||
const PHOTOS_API = ''; // e.g. 'https://api.campus.net/v1/photos/recent'
|
||||
|
||||
const postList = document.getElementById('postList');
|
||||
|
||||
// gradient palette for the author avatars (by first letter, just for colour)
|
||||
const AV = [
|
||||
['#ff5a3c', '#ffb142'], ['#f0453f', '#ff8a5c'], ['#ff6b9d', '#ffc371'],
|
||||
['#e84393', '#ff7f6e'], ['#c44569', '#ff7f50'], ['#d63031', '#ff7675'],
|
||||
];
|
||||
function avColor(name) { return AV[(name.charCodeAt(0)) % AV.length]; }
|
||||
function avKey(seed) { return (seed || 'player').toLowerCase().replace(/[^a-z0-9]/g, ''); }
|
||||
function avatarUrl(seed) {
|
||||
const key = avKey(seed);
|
||||
try {
|
||||
const pfp = JSON.parse(localStorage.getItem('campus-pfp-' + key) || 'null');
|
||||
if (pfp && pfp.image) return pfp.image; // use the user's chosen profile picture
|
||||
} catch {}
|
||||
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
// Push the chosen profile image to the Campus server, which decodes it, stores it under
|
||||
// Data/Images/player_images and points the account row at it. The optimistic UI already
|
||||
// updated; this makes it stick (and makes it visible in-game).
|
||||
//
|
||||
// The old JSON {imageUrl} fallback is gone with server.py: that asked the backend to go
|
||||
// fetch a URL on the browser's behalf, which is a server-side request forgery primitive.
|
||||
// The server only accepts bytes it was handed.
|
||||
async function syncProfileImageToApi(imageUrl) {
|
||||
// Deliberately NOT gated on CAMPUS_ME: syncSession() is async, so a quick change right
|
||||
// after page load would race it and silently drop the upload. Let the server decide -
|
||||
// it answers 401 when there's no session and the catch below surfaces that.
|
||||
if (!apiOn()) return; // offline demo mode
|
||||
|
||||
try {
|
||||
const blob = await (await fetch(imageUrl)).blob();
|
||||
const form = new FormData();
|
||||
form.append('image', blob, 'profile.png');
|
||||
|
||||
const resp = await fetch(apiUrl('/api/profile-image'), {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin', // carries the HttpOnly session cookie
|
||||
body: form,
|
||||
});
|
||||
|
||||
const out = await resp.json().catch(() => ({}));
|
||||
if (banRefused(resp, out)) return;
|
||||
if (!resp.ok || !out.ok) {
|
||||
toast('Saved locally, but the server update failed');
|
||||
console.warn('[campus] profile image API error:', out.error || resp.status);
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Saved locally, but the server was unreachable');
|
||||
console.warn('[campus] profile image API unreachable:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// update every avatar image for a given user, live
|
||||
function refreshAvatars(key, src) {
|
||||
document.querySelectorAll(`img[data-av="${key}"]`).forEach(im => {
|
||||
im.src = src;
|
||||
im.style.objectFit = 'cover';
|
||||
});
|
||||
}
|
||||
// cross-tab: when the profile picture changes in another tab, update here too
|
||||
window.addEventListener('storage', e => {
|
||||
if (!e.key || !e.key.startsWith('campus-pfp-')) return;
|
||||
const key = e.key.slice('campus-pfp-'.length);
|
||||
let src = null;
|
||||
try { const d = JSON.parse(e.newValue || 'null'); if (d && d.image) src = d.image; } catch {}
|
||||
if (!src) src = `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(key)}`;
|
||||
refreshAvatars(key, src);
|
||||
});
|
||||
|
||||
// "2 minutes ago", "3 hours ago", "5 days ago" - the profile's last-online line.
|
||||
function lastOnlineText(iso) {
|
||||
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
||||
if (isNaN(s) || s < 0) return '';
|
||||
if (s < 60) return 'just now';
|
||||
const unit = (n, w) => `${n} ${w}${n === 1 ? '' : 's'} ago`;
|
||||
if (s < 3600) return unit(Math.floor(s / 60), 'minute');
|
||||
if (s < 86400) return unit(Math.floor(s / 3600), 'hour');
|
||||
if (s < 86400 * 30) return unit(Math.floor(s / 86400), 'day');
|
||||
if (s < 86400 * 365) return unit(Math.floor(s / (86400 * 30)), 'month');
|
||||
return unit(Math.floor(s / (86400 * 365)), 'year');
|
||||
}
|
||||
function timeAgo(iso) {
|
||||
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
||||
if (isNaN(s)) return '';
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||
return Math.floor(s / 86400) + 'd';
|
||||
}
|
||||
function fmt(n) { return n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'k' : n; }
|
||||
|
||||
const HEART = '<svg viewBox="0 0 16 16" fill="none"><path d="M8 14s6-3.7 6-8a3 3 0 0 0-6-1.2A3 3 0 0 0 2 6c0 4.3 6 8 6 8Z" stroke="currentColor" stroke-width="1.5"/></svg>';
|
||||
const CHEER_ICON = '<svg class="cheer-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10v11H3V10zM7 10l4.2-6.6a1.6 1.6 0 0 1 2.9 1.2L13.4 9H19a2 2 0 0 1 2 2.4l-1.5 7.5A2.5 2.5 0 0 1 17 21H7"/></svg>';
|
||||
const CHAT = '<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10v7H6l-3 2.5V4Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
// basic profanity filter
|
||||
const BANNED = ['fuck','shit','bitch','asshole','bastard','cunt','dick','piss','slut','whore','nigger','nigga','faggot','retard','rape','cock','pussy','wanker','prick','twat'];
|
||||
function isInappropriate(text) {
|
||||
const t = text.toLowerCase();
|
||||
const stripped = t.replace(/[^a-z]/g, ''); // catches sp4ced / punctuated evasions
|
||||
return BANNED.some(w => new RegExp(`\\b${w}\\b`, 'i').test(t) || stripped.includes(w));
|
||||
}
|
||||
function commentRow(c) {
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
const mine = !!c.mine || (me && me.toLowerCase() === (c.handle || c.user || '').toLowerCase());
|
||||
const action = mine
|
||||
? `<button class="comment-del" title="Delete comment" aria-label="Delete comment"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4h11M6 4V2.7h4V4M5 4l.4 8.4a1 1 0 0 0 1 .9h3.2a1 1 0 0 0 1-.9L11 4"/></svg></button>`
|
||||
: `<button class="comment-report" title="Report comment" aria-label="Report comment"><svg viewBox="0 0 16 16" fill="none"><path d="M3 14V3M3 3h8l-1.5 3L11 9H3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`;
|
||||
const h = String(c.handle || c.user || 'player').replace(/^@/, '');
|
||||
return `<div class="comment"${c.id != null ? ` data-cid="${c.id}"` : ''}>
|
||||
<a class="comment-av-link" href="${userUrl(h)}" aria-label="@${escapeHtml(h)}"><img class="comment-av" data-av="${avKey(h)}" src="${c.avatar || avatarUrl(h)}" alt=""></a>
|
||||
<div class="comment-body"><a class="comment-user" href="${userUrl(h)}">@${escapeHtml(h)}</a> ${escapeHtml(c.text)}</div>
|
||||
${action}
|
||||
</div>`;
|
||||
}
|
||||
function serverComment(c) {
|
||||
const me = (localStorage.getItem(USER_KEY) || '').toLowerCase();
|
||||
return { id: c.id, user: c.display_name || c.username, handle: c.username, avatar: c.profile_image, text: c.text,
|
||||
mine: (c.username || '').toLowerCase() === me };
|
||||
}
|
||||
async function loadCommentsInto(box, photoId) {
|
||||
const list = box.querySelector('.comment-list');
|
||||
if (!list || !photoId || !apiOn()) return;
|
||||
const d = await apiGet('/api/site/photos/' + photoId + '/comments');
|
||||
if (!d || !d.ok) return;
|
||||
const rows = (d.comments || []).map(serverComment);
|
||||
list.innerHTML = rows.map(commentRow).join('') || '<p class="no-comments">No comments yet - be the first!</p>';
|
||||
list.dataset.loaded = '1';
|
||||
}
|
||||
function commentsBlock(p) {
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
if (!me) {
|
||||
return `<div class="post-comments" hidden><a class="comment-login" href="/login">Log in to view and add comments</a></div>`;
|
||||
}
|
||||
const list = (p.thread || []).map(commentRow).join('') || '<p class="no-comments">No comments yet - be the first!</p>';
|
||||
return `<div class="post-comments" hidden>
|
||||
<div class="comment-list">${list}</div>
|
||||
<form class="comment-form">
|
||||
<img class="comment-av" data-av="${avKey(me)}" src="${avatarUrl(me)}" alt="">
|
||||
<input class="comment-input" type="text" placeholder="Add a comment…" maxlength="200" autocomplete="off">
|
||||
<button type="submit" class="comment-send">Post</button>
|
||||
</form>
|
||||
<p class="comment-error"></p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
if (!postList) return;
|
||||
if (!posts.length) { postList.innerHTML = '<div class="feed-loading">No photos yet - check back soon.</div>'; return; }
|
||||
postList.innerHTML = posts.map(p => {
|
||||
const [a, b] = avColor(p.user || '?');
|
||||
const ph = (p.handle || p.user || 'player');
|
||||
const av = p.avatar || avatarUrl(ph); // real profile_image when the API provides one
|
||||
return `<article class="post"${p.id != null && /^\d+$/.test(String(p.id)) ? ` data-photo="${p.id}"` : ''}>
|
||||
<div class="post-head">
|
||||
<a class="post-av" href="${userUrl(ph)}" style="--a:${a};--b:${b}"><img data-av="${avKey(ph)}" src="${av}" alt="" onerror="this.remove()">${(p.user || '?')[0].toUpperCase()}</a>
|
||||
<div class="post-meta">
|
||||
<a class="post-user" href="${userUrl(ph)}">${escapeHtml(p.user || 'Anonymous')}</a>
|
||||
<span class="post-handle">@${escapeHtml(p.handle || 'player')}${p.room ? ` in <a href="#">^${escapeHtml(p.room)}</a>` : ''}</span>
|
||||
</div>
|
||||
<span class="post-time">${p.takenAt ? timeAgo(p.takenAt) : (p.time || '')}</span>
|
||||
</div>
|
||||
<a class="post-img" href="${imageUrl(p.id || '')}" style="background-image:url('${p.image}')"></a>
|
||||
<div class="post-foot">
|
||||
<span class="post-cap">${p.caption || ''}</span>
|
||||
<div class="post-stats">
|
||||
<button class="pstat cheer${p.cheered ? ' liked' : ''}" data-n="${(p.cheers || 0) - (p.cheered ? 1 : 0)}">${CHEER_ICON}<span>${fmt(p.cheers || 0)}</span></button>
|
||||
<button class="pstat comment-toggle" data-n="${nComments(p)}">${CHAT}<span>${nComments(p)}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
${commentsBlock(p)}
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Comment count: the live feed sends a number, the demo data carries a thread array.
|
||||
function nComments(p) {
|
||||
return (typeof p.comments === 'number') ? p.comments : (p.thread || []).length;
|
||||
}
|
||||
|
||||
// Fallback content used until the API is wired up. Each photo is a different
|
||||
// crop/zoom of the game art so the feed doesn't look like one repeated image.
|
||||
const FALLBACK_POSTS = [
|
||||
{ id: 'clutch-round', user: 'Clawful', handle: 'clawful', room: 'Paws Paintball', title: 'Clutch round on Paws Paintball 🎯', caption: 'gg everyone 🎯', cheers: 1284, comments: 86, takenAt: minsAgo(4), crop: '8% 42% / 220%' },
|
||||
{ id: 'midnight-build', user: 'Velvetpaw', handle: 'velvetpaw', room: 'Midnight Mews', title: 'Late night build session 🌙', caption: 'late night session 🌙', cheers: 842, comments: 37, takenAt: minsAgo(22), crop: '38% 50% / 240%' },
|
||||
{ id: 'contraption', user: 'Tinker9', handle: 'tinker9', room: 'Litterbox Lab', title: 'New contraption just dropped', caption: 'new contraption just dropped', cheers: 2613, comments: 158, takenAt: minsAgo(63), crop: '66% 55% / 230%' },
|
||||
{ id: 'final-boss', user: 'Stitchwitch', handle: 'stitchwitch', room: 'Yarn Quest', title: 'Finally beat the final boss 🧶', caption: 'finally beat the final boss 🧶', cheers: 4097, comments: 289, takenAt: minsAgo(190), crop: '94% 48% / 250%' },
|
||||
];
|
||||
function minsAgo(m) { return new Date(Date.now() - m * 60000).toISOString(); }
|
||||
|
||||
// look up a photo by id (feed photos, or profile photos encoded as "user__letter")
|
||||
function getPhoto(id) {
|
||||
if (!id) return null;
|
||||
const feed = FALLBACK_POSTS.find(p => p.id === id);
|
||||
if (feed) {
|
||||
const [pos, size] = feed.crop.split(' / ');
|
||||
return { user: feed.user, handle: feed.handle, image: 'assets/hero-bg.jpg', pos, size, title: feed.title, takenAt: feed.takenAt, cheers: feed.cheers };
|
||||
}
|
||||
if (id.includes('__')) {
|
||||
const [u, letter] = id.split('__');
|
||||
const up = (USER_PHOTOS[u] || []).find(x => x.id === letter);
|
||||
if (up) return { user: cap(u), handle: u, image: up.image, pos: 'center', size: 'cover', title: up.title, takenAt: null, cheers: 0 };
|
||||
const pp = PROFILE_PHOTOS.find(x => x.id === letter);
|
||||
if (pp) return { user: cap(u), handle: u, image: 'assets/hero-bg.jpg', pos: pp.pos, size: pp.size, title: 'Untitled photo', takenAt: null, cheers: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// special renderer for the fallback so we can apply the per-post crop
|
||||
function renderFallback(posts) {
|
||||
if (!postList) return;
|
||||
postList.innerHTML = posts.map(p => {
|
||||
const [a, b] = avColor(p.user);
|
||||
const [pos, size] = p.crop.split(' / ');
|
||||
return `<article class="post">
|
||||
<div class="post-head">
|
||||
<a class="post-av" href="${userUrl(p.handle)}" style="--a:${a};--b:${b}"><img data-av="${avKey(p.handle)}" src="${avatarUrl(p.handle)}" alt="" onerror="this.remove()">${p.user[0]}</a>
|
||||
<div class="post-meta">
|
||||
<a class="post-user" href="${userUrl(p.handle)}">${p.user}</a>
|
||||
<span class="post-handle">@${p.handle} in <a href="#">^${p.room}</a></span>
|
||||
</div>
|
||||
<span class="post-time">${timeAgo(p.takenAt)}</span>
|
||||
</div>
|
||||
<a class="post-img" href="${imageUrl(p.id)}" style="background-image:url('assets/hero-bg.jpg');background-position:${pos};background-size:${size}"></a>
|
||||
<div class="post-foot">
|
||||
<span class="post-cap">${p.caption}</span>
|
||||
<div class="post-stats">
|
||||
<button class="pstat cheer" data-n="${p.cheers}">${CHEER_ICON}<span>${fmt(p.cheers)}</span></button>
|
||||
<button class="pstat comment-toggle" data-n="${(p.thread || []).length}">${CHAT}<span>${(p.thread || []).length}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
${commentsBlock(p)}
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadFeed() {
|
||||
if (!postList) return;
|
||||
// Real, newest-first photos from the Campus server (/api/site/photos/recent is Public +
|
||||
// ShareCamera + sorted newest-first). Falls back to the demo posts only when the API is off
|
||||
// or unreachable.
|
||||
if (apiOn()) {
|
||||
const data = await apiGet('/api/site/photos/recent?take=24');
|
||||
if (data && data.ok && Array.isArray(data.results)) {
|
||||
const posts = data.results.map(r => ({
|
||||
id: r.id,
|
||||
image: r.image,
|
||||
user: (r.author && (r.author.display_name || r.author.username)) || 'Player',
|
||||
handle: (r.author && r.author.username) || 'player',
|
||||
avatar: r.author && r.author.profile_image,
|
||||
caption: r.description || '',
|
||||
cheers: r.cheers || 0,
|
||||
cheered: !!r.cheered,
|
||||
comments: r.comments || 0,
|
||||
takenAt: r.createdAt,
|
||||
}));
|
||||
renderPosts(posts);
|
||||
return;
|
||||
}
|
||||
}
|
||||
renderFallback(FALLBACK_POSTS);
|
||||
}
|
||||
loadFeed();
|
||||
renderImage(); // image.html - runs here so FALLBACK_POSTS/getPhoto are ready
|
||||
// NOTE: renderOnlineFriends() is intentionally NOT called - hydrateOnline() (below) is the real
|
||||
// one. It shows only YOUR online friends when signed in (not every online player), and writes the
|
||||
// same .rail-online section, so calling both produced a duplicate "Online Friends" list.
|
||||
renderPinned(); // home - pinned friends widget
|
||||
initFeaturedSlideshow(); // home - featured rooms slideshow (demo first, then real below)
|
||||
|
||||
// Featured Rooms slideshow: shows the SAME admin-curated featured rooms the game shows
|
||||
// (/api/site/rooms/featured). Falls back to hot rooms only when nothing is featured yet, so the
|
||||
// showcase is never empty. Change what's featured in the admin panel and it updates both here
|
||||
// and in-game.
|
||||
(async function loadFeatured() {
|
||||
if (!apiOn() || !document.getElementById('featuredSlideshow')) return;
|
||||
const map = r => ({
|
||||
id: r.id, name: r.name, creator: r.creator || 'someone',
|
||||
image: r.image, players: r.players || 0, visits: r.visits || 0,
|
||||
});
|
||||
|
||||
let data = await apiGet('/api/site/rooms/featured?take=8');
|
||||
if (data && data.ok && Array.isArray(data.results) && data.results.length) {
|
||||
initFeaturedSlideshow(data.results.map(map));
|
||||
return;
|
||||
}
|
||||
// Nothing featured — fall back to the hottest rooms so the showcase still has content.
|
||||
data = await apiGet('/api/site/rooms/hot?take=6');
|
||||
if (data && data.ok && Array.isArray(data.results) && data.results.length) {
|
||||
initFeaturedSlideshow(data.results.map(map));
|
||||
}
|
||||
})();
|
||||
|
||||
// ───── SEARCH PANEL ─────
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
const searchPanel = document.getElementById('searchPanel');
|
||||
const searchScrim = document.getElementById('searchScrim');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
|
||||
const PEOPLE = [
|
||||
{ name: 'Clawful', sub: '@clawful · 12.4k subscribers', a: '#ff5a3c', b: '#ffb142' },
|
||||
{ name: 'Velvetpaw', sub: '@velvetpaw · 8.1k subscribers', a: '#6c5ce7', b: '#a29bfe' },
|
||||
{ name: 'Tinker9', sub: '@tinker9 · 3.3k subscribers', a: '#00b894', b: '#55efc4' },
|
||||
{ name: 'Stitchwitch', sub: '@stitchwitch · 21k subscribers', a: '#e84393', b: '#fd79a8' },
|
||||
{ name: 'Bubbles', sub: '@bubbles · 970 subscribers', a: '#0984e3', b: '#74b9ff' },
|
||||
{ name: 'SpookyTom', sub: '@spooky.tom · 45k subscribers', a: '#d63031', b: '#ff7675' },
|
||||
{ name: 'Mittens', sub: '@mittens · 1.2k subscribers', a: '#fdcb6e', b: '#ff7675' },
|
||||
{ name: 'Pounce', sub: '@pounce · 660 subscribers', a: '#00cec9', b: '#81ecec' },
|
||||
];
|
||||
|
||||
const ROOMS = [
|
||||
{ name: 'Whisker Heights', sub: 'by @clawful · 67k visits', a: '#ff5a3c', b: '#7b2ff7' },
|
||||
{ name: 'Paws Paintball', sub: 'by @clawful · 48k visits', a: '#ff5a3c', b: '#ffb142' },
|
||||
{ name: 'Midnight Mews', sub: 'by @velvetpaw · 31k visits', a: '#6c5ce7', b: '#a29bfe' },
|
||||
{ name: 'The Litterbox Lab', sub: 'by @tinker9 · 22k visits', a: '#00b894', b: '#55efc4' },
|
||||
{ name: 'Yarn Quest', sub: 'by @stitchwitch · 67k visits', a: '#e84393', b: '#fd79a8' },
|
||||
{ name: 'Deep Dive Reef', sub: 'by @bubbles · 19k visits', a: '#0984e3', b: '#74b9ff' },
|
||||
{ name: 'Haunted Cattery', sub: 'by @spooky.tom · 91k visits', a: '#d63031', b: '#ff7675' },
|
||||
];
|
||||
|
||||
let activeTab = 'people';
|
||||
|
||||
function searchRow(m) {
|
||||
if (activeTab === 'people') {
|
||||
const handle = m.handle || m.name.toLowerCase();
|
||||
const av = m.avatar || avatarUrl(handle);
|
||||
return `<a href="${userUrl(handle)}" class="sresult">
|
||||
<div class="sresult-avatar" style="--a:${m.a};--b:${m.b};position:relative;overflow:hidden"><img data-av="${avKey(handle)}" src="${av}" alt="" onerror="this.remove()">${escapeHtml(m.name[0] || '?')}</div>
|
||||
<div class="sresult-text"><span class="sresult-name">${escapeHtml(m.name)}</span><span class="sresult-sub">${escapeHtml(m.sub)}</span></div>
|
||||
<svg class="sresult-arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg>
|
||||
</a>`;
|
||||
}
|
||||
// Link rooms by NAME (how they're shared/searched), falling back to the id. The server
|
||||
// resolves /room/{slug} and /api/site/rooms/{slug} by name first.
|
||||
const roomSlug = m.name || m.roomId;
|
||||
const href = roomSlug ? `room.html?id=${encodeURIComponent(roomSlug)}` : '#';
|
||||
const thumbStyle = m.img
|
||||
? `background-image:url('${m.img}');background-size:cover;background-position:center`
|
||||
: `--a:${m.a};--b:${m.b}`;
|
||||
return `<a href="${href}" class="sresult">
|
||||
<div class="sresult-thumb" style="${thumbStyle}"></div>
|
||||
<div class="sresult-text"><span class="sresult-name">${escapeHtml(m.name)}</span><span class="sresult-sub">${escapeHtml(m.sub)}</span></div>
|
||||
<svg class="sresult-arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m6 4 4 4-4 4"/></svg>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
// Empty/idle state only. Real results come from liveSearch(); there are no demo placeholders.
|
||||
function renderResults() {
|
||||
if (!searchResults) return;
|
||||
const q = searchInput.value.trim();
|
||||
if (searchClear) searchClear.style.display = q ? 'flex' : 'none';
|
||||
if (!q) {
|
||||
searchResults.innerHTML = `<p class="search-empty">Start typing to search ${activeTab === 'people' ? 'players' : 'rooms'}.</p>`;
|
||||
return;
|
||||
}
|
||||
searchResults.innerHTML = apiOn()
|
||||
? '<p class="search-empty">Searching…</p>'
|
||||
: '<p class="search-empty">Search is unavailable right now.</p>';
|
||||
}
|
||||
|
||||
// normalise a raw API result object into the shape searchRow() expects
|
||||
function normalizeResult(r) {
|
||||
const imgBase = (window.CAMPUS_DATA && window.CAMPUS_DATA.imgBase) || '/img/';
|
||||
const toUrl = raw => raw ? (/^https?:|^\//.test(raw) ? raw : imgBase + raw) : '';
|
||||
if (activeTab === 'people') {
|
||||
// The site search returns camelCase: { accountId, username, displayName, profileImage }.
|
||||
// (This used to read PascalCase, so every real player came back as "Unknown".)
|
||||
const handle = r.username || '';
|
||||
const name = r.displayName || handle || 'Unknown';
|
||||
return { name, sub: handle ? '@' + handle : '', handle, accountId: r.accountId, avatar: toUrl(r.profileImage), a: '#ff5a3c', b: '#ffb142' };
|
||||
}
|
||||
// Rooms come back PascalCase: { RoomId, Name, ImageName, Stats:{ VisitCount } }.
|
||||
const name = r.Name || 'Untitled room';
|
||||
const st = r.Stats || {};
|
||||
const sub = st.VisitCount != null ? fmt(st.VisitCount) + ' visits' : '';
|
||||
return { name, sub, roomId: r.RoomId, img: toUrl(r.ImageName), a: '#ff5a3c', b: '#7b2ff7' };
|
||||
}
|
||||
|
||||
// live search against the Campus server (SiteController reads RoomDB/PlayerDB directly)
|
||||
let searchTimer = null;
|
||||
async function liveSearch(q) {
|
||||
const path = activeTab === 'people'
|
||||
? `/api/search/players?name=${encodeURIComponent(q)}`
|
||||
: `/api/search/rooms?query=${encodeURIComponent(q)}`;
|
||||
try {
|
||||
const resp = await fetch(apiUrl(path), { credentials: 'same-origin' });
|
||||
const out = await resp.json();
|
||||
if (!out.ok) throw new Error(out.error || 'search failed');
|
||||
let list = out.results;
|
||||
if (list && !Array.isArray(list)) list = list.Results || list.results || list.Accounts || list.Rooms || [];
|
||||
list = (list || []).map(normalizeResult);
|
||||
if (!list.length) { searchResults.innerHTML = '<p class="search-empty">No results found</p>'; return; }
|
||||
searchResults.innerHTML = list.map(searchRow).join('');
|
||||
} catch (e) {
|
||||
console.warn('[campus] live search failed:', e);
|
||||
searchResults.innerHTML = '<p class="search-empty">Search failed - try again.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Real search only - no demo fallback.
|
||||
function handleSearchInput() {
|
||||
const q = searchInput.value.trim();
|
||||
if (searchClear) searchClear.style.display = q ? 'flex' : 'none';
|
||||
clearTimeout(searchTimer);
|
||||
if (!q) { renderResults(); return; } // empty -> hint
|
||||
if (!apiOn()) { searchResults.innerHTML = '<p class="search-empty">Search is unavailable right now.</p>'; return; }
|
||||
searchResults.innerHTML = '<p class="search-empty">Searching…</p>';
|
||||
searchTimer = setTimeout(() => liveSearch(q), 250);
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
searchPanel.classList.add('open');
|
||||
searchPanel.inert = false;
|
||||
searchScrim.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
renderResults();
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
function closeSearch() {
|
||||
const wasOpen = searchPanel.classList.contains('open');
|
||||
searchPanel.classList.remove('open');
|
||||
searchPanel.inert = true;
|
||||
searchScrim.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
if (wasOpen && searchBtn) searchBtn.focus(); // return focus to the trigger
|
||||
}
|
||||
|
||||
let searchClear = null;
|
||||
|
||||
if (searchPanel) searchPanel.inert = !searchPanel.classList.contains('open'); // closed by default
|
||||
if (searchBtn) {
|
||||
// inject a clear (✕) button into the search field
|
||||
const field = searchInput.parentElement;
|
||||
if (field) {
|
||||
searchClear = document.createElement('button');
|
||||
searchClear.className = 'search-clear';
|
||||
searchClear.type = 'button';
|
||||
searchClear.setAttribute('aria-label', 'Clear');
|
||||
searchClear.innerHTML = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
|
||||
searchClear.style.display = 'none';
|
||||
field.appendChild(searchClear);
|
||||
searchClear.addEventListener('click', () => { searchInput.value = ''; handleSearchInput(); searchInput.focus(); });
|
||||
}
|
||||
|
||||
searchBtn.addEventListener('click', () => {
|
||||
searchPanel.classList.contains('open') ? closeSearch() : openSearch();
|
||||
});
|
||||
searchScrim.addEventListener('click', closeSearch);
|
||||
searchInput.addEventListener('input', handleSearchInput);
|
||||
|
||||
document.querySelectorAll('.stab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelector('.stab.active')?.classList.remove('active');
|
||||
tab.classList.add('active');
|
||||
activeTab = tab.dataset.tab;
|
||||
searchInput.placeholder = activeTab === 'people' ? 'Search people…' : 'Search rooms…';
|
||||
handleSearchInput();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSearch(); });
|
||||
|
||||
// click the dimmed background (outside the card) to close
|
||||
searchPanel.addEventListener('click', e => {
|
||||
if (e.target === searchPanel) closeSearch();
|
||||
});
|
||||
}
|
||||
|
||||
// ───── FAQ ACCORDION ─────
|
||||
document.querySelectorAll('.dl-faq .faq-q').forEach(q => {
|
||||
q.addEventListener('click', () => q.closest('.faq-item').classList.toggle('open'));
|
||||
});
|
||||
|
||||
// ───── RATE LIMITING + TOAST ─────
|
||||
// returns true if the action is allowed, false if the limit is hit
|
||||
function rateLimit(action, max, windowMs) {
|
||||
const key = 'campus-rl-' + action;
|
||||
const now = Date.now();
|
||||
let arr = [];
|
||||
try { arr = JSON.parse(localStorage.getItem(key) || '[]'); } catch { arr = []; }
|
||||
arr = arr.filter(t => now - t < windowMs);
|
||||
if (arr.length >= max) { localStorage.setItem(key, JSON.stringify(arr)); return false; }
|
||||
arr.push(now);
|
||||
localStorage.setItem(key, JSON.stringify(arr));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───── BAN BANNER ─────
|
||||
// A banned account can sign in but can't change anything (the server refuses its writes). Every
|
||||
// page shows this strip under the top bar: how long is left (live) and the reason. /api/site/me
|
||||
// carries it as user.ban = { permanent, remaining_seconds, reason } - null when not banned.
|
||||
function banLeftText(sec) {
|
||||
sec = Math.max(0, Math.floor(sec));
|
||||
const d = Math.floor(sec / 86400), h = Math.floor(sec % 86400 / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
|
||||
const parts = [];
|
||||
if (d) parts.push(d + (d === 1 ? ' day' : ' days'));
|
||||
if (d || h) parts.push(h + (h === 1 ? ' hour' : ' hours'));
|
||||
if (!d) parts.push(m + (m === 1 ? ' minute' : ' minutes'));
|
||||
if (!d && !h) parts.push(s + (s === 1 ? ' second' : ' seconds'));
|
||||
return parts.join(' ');
|
||||
}
|
||||
function renderBanBanner(me) {
|
||||
const ban = me && me.ban;
|
||||
const old = document.getElementById('siteBanBanner');
|
||||
if (!ban) { if (old) old.remove(); document.body.classList.remove('site-banned'); document.body.style.removeProperty('--ban-h'); return; }
|
||||
const bar = old || document.createElement('div');
|
||||
bar.id = 'siteBanBanner';
|
||||
bar.className = 'site-ban-banner';
|
||||
bar.setAttribute('role', 'alert');
|
||||
bar.innerHTML = '<span class="sbb-icon" aria-hidden="true">⚠</span>'
|
||||
+ '<span class="sbb-text"><strong>This account is currently banned</strong> <span class="sbb-left"></span>'
|
||||
+ '<span class="sbb-reason"></span><span class="sbb-note">You can look around, but you can\'t change anything until the ban ends. Think it\'s a mistake? <a href="/support?new=appeal">Appeal it</a>.</span></span>';
|
||||
bar.querySelector('.sbb-reason').textContent = ban.reason ? 'Reason: ' + ban.reason : '';
|
||||
if (!old) document.body.insertBefore(bar, document.body.firstChild);
|
||||
document.body.classList.add('site-banned');
|
||||
const left = bar.querySelector('.sbb-left');
|
||||
const ends = ban.permanent ? null : Date.now() + (ban.remaining_seconds || 0) * 1000;
|
||||
const tick = () => {
|
||||
if (!ends) { left.textContent = '- permanently'; return; }
|
||||
const sec = (ends - Date.now()) / 1000;
|
||||
if (sec <= 0) { left.textContent = '- the ban has ended, refresh the page'; clearInterval(bar._timer); return; }
|
||||
left.textContent = '- ' + banLeftText(sec) + ' left';
|
||||
};
|
||||
clearInterval(bar._timer);
|
||||
tick();
|
||||
bar._timer = setInterval(tick, 1000);
|
||||
// Push the page down by the banner's real height (it wraps on phones).
|
||||
const fit = () => document.body.style.setProperty('--ban-h', bar.offsetHeight + 'px');
|
||||
fit();
|
||||
window.addEventListener('resize', fit);
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
let t = document.getElementById('toast');
|
||||
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.className = 'toast'; document.body.appendChild(t); }
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(t._timer);
|
||||
t._timer = setTimeout(() => t.classList.remove('show'), 2800);
|
||||
}
|
||||
|
||||
// returns true if logged in; otherwise prompts to log in
|
||||
function requireLogin(action) {
|
||||
if (localStorage.getItem(USER_KEY)) return true;
|
||||
toast(`Log in to ${action}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ───── REPORT SYSTEM ─────
|
||||
const REPORT_REASONS = ['Spam or scam', 'Harassment or bullying', 'Inappropriate content', 'Hate speech', 'Impersonation', 'Other'];
|
||||
|
||||
function closeReport() {
|
||||
document.getElementById('reportModal')?.classList.remove('open');
|
||||
document.getElementById('reportScrim')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// ctx = {type: 'player'|'photo'|'comment', id} - what is being reported. With the API on,
|
||||
// the report is filed to the server (it lands in the admin panel's Reports queue); the
|
||||
// onSubmit callback only updates the button that opened it.
|
||||
function openReport(title, onSubmit, ctx) {
|
||||
if (ctx && apiOn() && !localStorage.getItem(USER_KEY)) { toast('Log in to send a report'); return; }
|
||||
let scrim = document.getElementById('reportScrim');
|
||||
let modal = document.getElementById('reportModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'reportScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'reportModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
scrim.addEventListener('click', closeReport);
|
||||
// click the dimmed area outside the card to close
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeReport(); });
|
||||
}
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="report-card">
|
||||
<button class="report-close" aria-label="Close">✕</button>
|
||||
<h2 class="report-title">${title}</h2>
|
||||
<p class="report-sub">Tell us what's wrong. Reports are anonymous.</p>
|
||||
<div class="report-reasons">
|
||||
${REPORT_REASONS.map(r => `<button type="button" class="report-reason" data-r="${r}">${r}</button>`).join('')}
|
||||
</div>
|
||||
<textarea class="report-details" placeholder="Add more details (optional)" maxlength="300"></textarea>
|
||||
<div class="report-actions">
|
||||
<button class="report-cancel">Cancel</button>
|
||||
<button class="report-submit" disabled>Submit report</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const card = modal.querySelector('.report-card');
|
||||
let selected = null;
|
||||
card.querySelector('.report-reasons').addEventListener('click', e => {
|
||||
const b = e.target.closest('.report-reason');
|
||||
if (!b) return;
|
||||
card.querySelectorAll('.report-reason').forEach(x => x.classList.remove('sel'));
|
||||
b.classList.add('sel');
|
||||
selected = b.dataset.r;
|
||||
card.querySelector('.report-submit').disabled = false;
|
||||
});
|
||||
card.querySelector('.report-close').addEventListener('click', closeReport);
|
||||
card.querySelector('.report-cancel').addEventListener('click', closeReport);
|
||||
card.querySelector('.report-submit').addEventListener('click', async () => {
|
||||
if (!rateLimit('report', 5, 10 * 60 * 1000)) {
|
||||
toast('You\'ve sent too many reports. Please try again later.');
|
||||
closeReport();
|
||||
return;
|
||||
}
|
||||
const submit = card.querySelector('.report-submit');
|
||||
if (ctx && apiOn()) {
|
||||
submit.disabled = true; submit.textContent = 'Sending…';
|
||||
const r = await apiPost('/api/site/report', { type: ctx.type, id: String(ctx.id), photoId: ctx.photoId ? Number(ctx.photoId) : null, reason: selected, details: card.querySelector('.report-details').value.trim() });
|
||||
if (!r || !r.ok) {
|
||||
submit.disabled = false; submit.textContent = 'Submit report';
|
||||
toast((r && r.data && r.data.error) || 'Could not send the report');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof onSubmit === 'function') onSubmit(selected);
|
||||
card.innerHTML = `
|
||||
<div class="report-done">
|
||||
<div class="report-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7"/></svg></div>
|
||||
<h2 class="report-title">Report submitted</h2>
|
||||
<p class="report-sub">Thanks - our moderation team will review this shortly.</p>
|
||||
<button class="report-submit report-ok">Done</button>
|
||||
</div>`;
|
||||
card.querySelector('.report-ok').addEventListener('click', closeReport);
|
||||
});
|
||||
|
||||
modal.classList.add('open');
|
||||
scrim.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// ───── NICKNAME MODAL ─────
|
||||
function closeNick() {
|
||||
document.getElementById('nickModal')?.classList.remove('open');
|
||||
document.getElementById('nickScrim')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
function openNickname(handle, onChange, ctx) {
|
||||
let scrim = document.getElementById('nickScrim');
|
||||
let modal = document.getElementById('nickModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'nickScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'nickModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
scrim.addEventListener('click', closeNick);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeNick(); });
|
||||
}
|
||||
const current = ctx ? (ctx.current || '') : ((getFriends()[handle] || {}).nickname || '');
|
||||
modal.innerHTML = `
|
||||
<div class="report-card">
|
||||
<button class="report-close" aria-label="Close">✕</button>
|
||||
<h2 class="report-title">Nickname</h2>
|
||||
<p class="report-sub">Set a custom nickname for @${handle}. You will see it on the website and in the game. Nobody else will - it never changes their real name.</p>
|
||||
<input class="nick-input" type="text" maxlength="24" placeholder="Enter a nickname" value="${escapeHtml(current)}">
|
||||
<div class="report-actions">
|
||||
<button class="report-cancel nick-remove">Remove nickname</button>
|
||||
<button class="report-submit nick-set">Set nickname</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const card = modal.querySelector('.report-card');
|
||||
const input = card.querySelector('.nick-input');
|
||||
card.querySelector('.report-close').addEventListener('click', closeNick);
|
||||
async function apply(value) {
|
||||
if (ctx) {
|
||||
const { ok, data } = await apiPost('/api/site/friends/nickname', { id: ctx.id, nickname: value });
|
||||
if (!ok) { toast((data && data.error) || 'Could not save the nickname'); return; }
|
||||
if (ctx.onSaved) ctx.onSaved(data.nickname || '');
|
||||
toast(data.nickname ? `Nickname set to ${data.nickname}` : 'Nickname removed');
|
||||
} else {
|
||||
setFriendNick(handle, value);
|
||||
}
|
||||
closeNick();
|
||||
if (onChange) onChange();
|
||||
}
|
||||
card.querySelector('.nick-set').addEventListener('click', () => apply(input.value.trim()));
|
||||
card.querySelector('.nick-remove').addEventListener('click', () => apply(''));
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') apply(input.value.trim()); });
|
||||
modal.classList.add('open');
|
||||
scrim.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
// ───── FRIENDS LIST (own profile) ─────
|
||||
// deterministic fake presence so each friend has a stable online/last-seen value
|
||||
function friendPresence(h) {
|
||||
let n = 0; for (const c of String(h)) n = (n * 31 + c.charCodeAt(0)) >>> 0;
|
||||
const online = n % 3 === 0;
|
||||
const buckets = ['just now', '5 min', '12 min', '1 hour', '3 hours', '8 hours', '1 day', '2 days', '4 days', '1 week'];
|
||||
return { online, last: buckets[n % buckets.length] };
|
||||
}
|
||||
function closeFriendsList() {
|
||||
document.getElementById('friendsModal')?.classList.remove('open');
|
||||
document.getElementById('friendsScrim')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
// The friends list is REAL: /api/site/profile/{handle}/friends (All / Favourites, plus
|
||||
// Requests when it's your own). Presence is the game's own (online = connected to the hub),
|
||||
// favourites are the same flag the in-game star sets, and it re-renders itself when the
|
||||
// server pushes a refresh (a friend comes online, a request arrives, a star is toggled in-game).
|
||||
let FRIENDS_MODAL_HANDLE = null;
|
||||
function openFriendsList(handle) {
|
||||
let scrim = document.getElementById('friendsScrim');
|
||||
let modal = document.getElementById('friendsModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'friendsScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'friendsModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
scrim.addEventListener('click', closeFriendsList);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeFriendsList(); });
|
||||
}
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
FRIENDS_MODAL_HANDLE = (handle || viewedHandle() || me || '').toLowerCase();
|
||||
let filter = 'all';
|
||||
let cache = null;
|
||||
|
||||
async function load() {
|
||||
let r = null;
|
||||
try { if (apiOn()) r = await apiGet('/api/site/profile/' + encodeURIComponent(FRIENDS_MODAL_HANDLE) + '/friends'); } catch {}
|
||||
cache = (r && r.ok) ? r : { own: false, friends: [], pending: [], sent: [] };
|
||||
render();
|
||||
}
|
||||
|
||||
function row(f, pending) {
|
||||
const name = escapeHtml(f.display_name || f.username);
|
||||
const av = f.profile_image || avatarUrl(f.username);
|
||||
const star = f.favourite ? ' <span class="fav-star">★</span>' : '';
|
||||
if (pending) {
|
||||
return `<div class="fl-item">
|
||||
<img class="fl-av" src="${av}" alt="">
|
||||
<div class="fl-meta"><span class="fl-name">${name}</span><span class="fl-sub">Wants to be your friend</span></div>
|
||||
<div class="fl-actions"><button class="fl-accept" data-id="${f.id}" data-n="${name}">Accept</button><button class="fl-decline" data-id="${f.id}">Decline</button></div>
|
||||
</div>`;
|
||||
}
|
||||
const status = (f.online
|
||||
? `<span class="fl-online"><i></i>Online${f.where ? ' · ' + escapeHtml(f.where) : ''}</span>`
|
||||
: 'Offline')
|
||||
+ (f.spotify && f.spotify.track ? `<br><span class="fl-spotify">${SP_ICON}${f.spotify.is_playing ? '' : 'Paused · '}${escapeHtml(f.spotify.track)} — ${escapeHtml(f.spotify.artists || '')}</span>` : '');
|
||||
const favBtn = cache.own
|
||||
? `<button class="fl-fav${f.favourite ? ' on' : ''}" data-id="${f.id}" data-on="${f.favourite ? 1 : 0}" title="${f.favourite ? 'Remove from favourites' : 'Add to favourites'}">${f.favourite ? '★' : '☆'}</button>`
|
||||
: '';
|
||||
return `<div class="fl-item">
|
||||
<a class="fl-link" href="${userUrl(f.username)}" style="display:flex;align-items:center;gap:12px;flex:1;min-width:0;text-decoration:none;color:inherit">
|
||||
<img class="fl-av" src="${av}" alt="">
|
||||
<div class="fl-meta"><span class="fl-name">${name}${star}</span><span class="fl-sub">${status}</span></div>
|
||||
</a>${favBtn}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!cache) {
|
||||
modal.innerHTML = `<div class="report-card fl-card"><button class="report-close" aria-label="Close">✕</button><h2 class="report-title">Friends</h2><p class="fl-empty">Loading…</p></div>`;
|
||||
modal.querySelector('.report-close').addEventListener('click', closeFriendsList);
|
||||
return;
|
||||
}
|
||||
const friends = cache.friends || [];
|
||||
const pending = cache.pending || [];
|
||||
const favs = friends.filter(f => f.favourite);
|
||||
const onlineCount = friends.filter(f => f.online).length;
|
||||
const show = filter === 'favourites' ? favs : filter === 'pending' ? pending : friends;
|
||||
const rows = show.length ? show.map(f => row(f, filter === 'pending')).join('')
|
||||
: `<p class="fl-empty">${filter === 'pending' ? 'No pending requests.' : filter === 'favourites' ? 'No favourites yet.' : 'No friends yet.'}</p>`;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="report-card fl-card">
|
||||
<button class="report-close" aria-label="Close">✕</button>
|
||||
<h2 class="report-title">${cache.own ? 'Friends' : escapeHtml(cap(FRIENDS_MODAL_HANDLE)) + "'s friends"}</h2>
|
||||
<p class="report-sub">${friends.length} friend${friends.length === 1 ? '' : 's'} · ${onlineCount} online</p>
|
||||
<div class="fl-tabs">
|
||||
<button class="fl-tab${filter === 'all' ? ' active' : ''}" data-f="all">All</button>
|
||||
<button class="fl-tab${filter === 'favourites' ? ' active' : ''}" data-f="favourites">Favourites${favs.length ? ` (${favs.length})` : ''}</button>
|
||||
${cache.own ? `<button class="fl-tab${filter === 'pending' ? ' active' : ''}" data-f="pending">Requests${pending.length ? ` (${pending.length})` : ''}</button>` : ''}
|
||||
</div>
|
||||
<div class="fl-list">${rows}</div>
|
||||
</div>`;
|
||||
|
||||
modal.querySelector('.report-close').addEventListener('click', closeFriendsList);
|
||||
modal.querySelectorAll('.fl-tab').forEach(t => t.addEventListener('click', () => { filter = t.dataset.f; render(); }));
|
||||
modal.querySelectorAll('.fl-accept').forEach(b => b.addEventListener('click', async () => {
|
||||
const { ok } = await apiPost('/api/site/friends/accept', { id: Number(b.dataset.id) });
|
||||
toast(ok ? `You and ${b.dataset.n} are now friends` : 'Could not accept right now');
|
||||
load();
|
||||
}));
|
||||
modal.querySelectorAll('.fl-decline').forEach(b => b.addEventListener('click', async () => {
|
||||
await apiPost('/api/site/friends/remove', { id: Number(b.dataset.id) }); load();
|
||||
}));
|
||||
modal.querySelectorAll('.fl-fav').forEach(b => b.addEventListener('click', async () => {
|
||||
const on = b.dataset.on !== '1';
|
||||
const { ok } = await apiPost('/api/site/friends/favourite', { id: Number(b.dataset.id), on });
|
||||
if (!ok) { toast('Could not update favourites'); return; }
|
||||
toast(on ? 'Added to favourites' : 'Removed from favourites');
|
||||
load();
|
||||
}));
|
||||
}
|
||||
modal.__reload = load;
|
||||
render();
|
||||
load();
|
||||
modal.classList.add('open');
|
||||
scrim.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Subscribers / Subscriptions popup. `kind` is 'subscribers' (who subscribes to this
|
||||
// profile) or 'subscriptions' (who this profile subscribes to). Same modal chrome as the
|
||||
// friends list, real data from /api/site/profile/{handle}/{kind}, and it re-renders itself on a
|
||||
// server refresh push (someone subscribes / unsubscribes while it's open).
|
||||
let SUBS_MODAL = null;
|
||||
function openSubsList(handle, kind) {
|
||||
let scrim = document.getElementById('subsScrim');
|
||||
let modal = document.getElementById('subsModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'subsScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'subsModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
scrim.addEventListener('click', closeSubsList);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeSubsList(); });
|
||||
}
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
const h = (handle || viewedHandle() || me || '').toLowerCase();
|
||||
let cache = null;
|
||||
|
||||
async function load() {
|
||||
let r = null;
|
||||
try { if (apiOn()) r = await apiGet('/api/site/profile/' + encodeURIComponent(h) + '/' + kind); } catch {}
|
||||
cache = (r && r.ok) ? r : { own: false, results: [] };
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const title = kind === 'subscribers'
|
||||
? (cache && cache.own ? 'Your subscribers' : escapeHtml(cap(h)) + "'s subscribers")
|
||||
: (cache && cache.own ? 'Your subscriptions' : escapeHtml(cap(h)) + ' subscribes to');
|
||||
const list = cache ? (cache.results || []) : null;
|
||||
const rows = !list ? '<p class="fl-empty">Loading…</p>'
|
||||
: list.length ? list.map(p => {
|
||||
const name = escapeHtml(p.display_name || p.username);
|
||||
const av = p.profile_image || avatarUrl(p.username);
|
||||
const status = p.online ? `<span class="fl-online"><i></i>Online${p.where ? ' · ' + escapeHtml(p.where) : ''}</span>`
|
||||
: `${fmt(p.subscribers)} subscriber${p.subscribers === 1 ? '' : 's'}`;
|
||||
const canSub = me && p.username && p.username.toLowerCase() !== me.toLowerCase();
|
||||
const btn = canSub ? `<button class="fl-sub-btn${p.subscribed ? ' on' : ''}" data-id="${p.id}" data-on="${p.subscribed ? 1 : 0}">${p.subscribed ? 'Subscribed' : 'Subscribe'}</button>` : '';
|
||||
return `<div class="fl-item">
|
||||
<a class="fl-link" href="${userUrl(p.username)}" style="display:flex;align-items:center;gap:12px;flex:1;min-width:0;text-decoration:none;color:inherit">
|
||||
<img class="fl-av" src="${av}" alt="">
|
||||
<div class="fl-meta"><span class="fl-name">${name}</span><span class="fl-sub">${status}</span></div>
|
||||
</a>${btn}
|
||||
</div>`;
|
||||
}).join('')
|
||||
: `<p class="fl-empty">${kind === 'subscribers' ? 'No subscribers yet.' : 'Not subscribed to anyone yet.'}</p>`;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="report-card fl-card">
|
||||
<button class="report-close" aria-label="Close">✕</button>
|
||||
<h2 class="report-title">${title}</h2>
|
||||
<p class="report-sub">${list ? `${list.length} ${kind === 'subscribers' ? 'subscriber' : 'subscription'}${list.length === 1 ? '' : 's'}` : ''}</p>
|
||||
<div class="fl-list">${rows}</div>
|
||||
</div>`;
|
||||
modal.querySelector('.report-close').addEventListener('click', closeSubsList);
|
||||
modal.querySelectorAll('.fl-sub-btn').forEach(b => b.addEventListener('click', async () => {
|
||||
if (!requireLogin('subscribe')) return;
|
||||
const on = b.dataset.on !== '1';
|
||||
const { ok, data } = await apiPost('/api/site/subscribe', { id: Number(b.dataset.id), on });
|
||||
if (!ok) { toast((data && data.error) || 'Could not update the subscription'); return; }
|
||||
toast(on ? 'Subscribed' : 'Unsubscribed');
|
||||
load();
|
||||
}));
|
||||
}
|
||||
modal.__reload = load;
|
||||
render();
|
||||
load();
|
||||
modal.classList.add('open');
|
||||
scrim.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closeSubsList() {
|
||||
document.getElementById('subsModal')?.classList.remove('open');
|
||||
document.getElementById('subsScrim')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// ───── CONFIRM DIALOG ─────
|
||||
function closeConfirm() {
|
||||
document.getElementById('confirmModal')?.classList.remove('open');
|
||||
document.getElementById('confirmScrim')?.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
function openConfirm(opts) {
|
||||
const { title, message, confirmText, onConfirm } = opts;
|
||||
let scrim = document.getElementById('confirmScrim');
|
||||
let modal = document.getElementById('confirmModal');
|
||||
if (!modal) {
|
||||
scrim = document.createElement('div'); scrim.id = 'confirmScrim'; scrim.className = 'report-scrim';
|
||||
modal = document.createElement('div'); modal.id = 'confirmModal'; modal.className = 'report-modal';
|
||||
document.body.appendChild(scrim); document.body.appendChild(modal);
|
||||
scrim.addEventListener('click', closeConfirm);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeConfirm(); });
|
||||
}
|
||||
modal.innerHTML = `
|
||||
<div class="report-card confirm-card">
|
||||
<h2 class="report-title">${title}</h2>
|
||||
<p class="report-sub">${message}</p>
|
||||
<div class="report-actions">
|
||||
<button class="report-cancel confirm-no">Cancel</button>
|
||||
<button class="report-submit confirm-yes">${confirmText}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
modal.querySelector('.confirm-no').addEventListener('click', closeConfirm);
|
||||
modal.querySelector('.confirm-yes').addEventListener('click', () => {
|
||||
closeConfirm();
|
||||
if (typeof onConfirm === 'function') onConfirm();
|
||||
});
|
||||
modal.classList.add('open');
|
||||
scrim.classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeReport(); closeNick(); closeConfirm(); } });
|
||||
|
||||
// ───── INTERACTIONS (cheer + follow + comments) ─────
|
||||
document.addEventListener('click', e => {
|
||||
// cheer a photo
|
||||
const cheer = e.target.closest('.cheer');
|
||||
if (cheer) {
|
||||
e.preventDefault();
|
||||
if (!requireLogin('cheer photos')) return;
|
||||
if (!rateLimit('cheer', 15, 10000)) { toast('Whoa! Slow down on the cheers.'); return; }
|
||||
const liked = cheer.classList.toggle('liked');
|
||||
const base = +cheer.dataset.n;
|
||||
const span = cheer.querySelector('span');
|
||||
if (span) span.textContent = fmt(base + (liked ? 1 : 0));
|
||||
const pid = photoIdOf(cheer);
|
||||
if (pid && apiOn()) {
|
||||
// The same cheer the game records: what the site shows is what in-game shows.
|
||||
apiPost('/api/site/photos/' + pid + '/cheer').then(r => {
|
||||
if (r && r.ok && r.data) {
|
||||
cheer.classList.toggle('liked', !!r.data.cheered);
|
||||
cheer.dataset.n = (r.data.cheers || 0) - (r.data.cheered ? 1 : 0);
|
||||
if (span) span.textContent = fmt(r.data.cheers || 0);
|
||||
} else if (r && r.status === 401) { cheer.classList.toggle('liked', !liked); if (span) span.textContent = fmt(base); toast('Log in to cheer photos'); }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// toggle comments
|
||||
const ct = e.target.closest('.comment-toggle');
|
||||
if (ct) {
|
||||
e.preventDefault();
|
||||
const box = ct.closest('.post').querySelector('.post-comments');
|
||||
if (box) {
|
||||
box.hidden = !box.hidden;
|
||||
const list = box.querySelector('.comment-list');
|
||||
if (!box.hidden && list && !list.dataset.loaded) loadCommentsInto(box, photoIdOf(ct));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// delete your own comment
|
||||
const cdel = e.target.closest('.comment-del');
|
||||
if (cdel) {
|
||||
const comment = cdel.closest('.comment');
|
||||
const list = cdel.closest('.comment-list');
|
||||
const post = cdel.closest('.post') || cdel.closest('[data-photo]');
|
||||
const pidDel = photoIdOf(cdel), cid = comment.dataset.cid;
|
||||
if (pidDel && cid && apiOn()) apiDelete('/api/site/photos/' + pidDel + '/comments/' + cid).then(r => { if (!r.ok) toast('Could not delete the comment'); });
|
||||
comment.remove();
|
||||
if (post) {
|
||||
const toggle = post.querySelector('.comment-toggle');
|
||||
if (toggle) {
|
||||
toggle.dataset.n = Math.max(0, (+toggle.dataset.n || 0) - 1);
|
||||
const s = toggle.querySelector('span');
|
||||
if (s) s.textContent = fmt(+toggle.dataset.n);
|
||||
}
|
||||
}
|
||||
if (list && !list.querySelector('.comment')) {
|
||||
list.innerHTML = '<p class="no-comments">No comments yet - be the first!</p>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
// report a comment
|
||||
const rep = e.target.closest('.comment-report');
|
||||
if (rep) {
|
||||
const comment = rep.closest('.comment');
|
||||
if (comment.classList.contains('reported')) return;
|
||||
openReport('Report comment', () => {
|
||||
comment.classList.add('reported');
|
||||
rep.outerHTML = '<span class="comment-reported">Reported</span>';
|
||||
}, comment.dataset.cid ? { type: 'comment', id: comment.dataset.cid, photoId: photoIdOf(comment) } : null);
|
||||
return;
|
||||
}
|
||||
// follow / unfollow
|
||||
const fb = e.target.closest('.follow-btn');
|
||||
if (fb) {
|
||||
const on = fb.classList.toggle('following');
|
||||
fb.textContent = on ? 'Following' : 'Follow';
|
||||
}
|
||||
});
|
||||
|
||||
// add a comment
|
||||
document.addEventListener('submit', e => {
|
||||
const form = e.target.closest('.comment-form');
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
const me = localStorage.getItem(USER_KEY);
|
||||
if (!me) return;
|
||||
const input = form.querySelector('.comment-input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const box = form.closest('.post-comments');
|
||||
const err = box.querySelector('.comment-error');
|
||||
|
||||
// block inappropriate content
|
||||
if (isInappropriate(text)) {
|
||||
if (err) { err.textContent = 'This content is too inappropriate to send'; err.classList.add('show'); }
|
||||
return;
|
||||
}
|
||||
// rate limit: max 5 comments per 30s
|
||||
if (!rateLimit('comment', 5, 30000)) {
|
||||
if (err) { err.textContent = 'You\'re commenting too fast - please wait a moment.'; err.classList.add('show'); }
|
||||
return;
|
||||
}
|
||||
if (err) { err.classList.remove('show'); err.textContent = ''; }
|
||||
|
||||
const list = box.querySelector('.comment-list');
|
||||
const empty = list.querySelector('.no-comments');
|
||||
if (empty) empty.remove();
|
||||
|
||||
const pidNew = photoIdOf(form);
|
||||
if (pidNew && apiOn()) {
|
||||
input.value = '';
|
||||
apiPost('/api/site/photos/' + pidNew + '/comments', { text }).then(r => {
|
||||
if (r && r.ok && r.data && r.data.comment) list.insertAdjacentHTML('beforeend', commentRow(serverComment(r.data.comment)));
|
||||
else { if (err) { err.textContent = (r && r.data && r.data.error) || 'Could not post the comment'; err.classList.add('show'); } if (!list.querySelector('.comment')) list.innerHTML = '<p class="no-comments">No comments yet - be the first!</p>'; }
|
||||
});
|
||||
} else {
|
||||
list.insertAdjacentHTML('beforeend', commentRow({ user: me, handle: me, text }));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// bump the comment count
|
||||
const toggle = form.closest('.post').querySelector('.comment-toggle');
|
||||
if (toggle) {
|
||||
toggle.dataset.n = (+toggle.dataset.n || 0) + 1;
|
||||
const span = toggle.querySelector('span');
|
||||
if (span) span.textContent = fmt(+toggle.dataset.n);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LIVE DATA
|
||||
//
|
||||
// Everything above renders the demo content in data.js so the page has something
|
||||
// to show instantly. This section then replaces it with what is actually on the
|
||||
// server: real rooms, real players, real photos, real avatars.
|
||||
//
|
||||
// Replace-after-render rather than render-only-after-fetch, deliberately: the page
|
||||
// is useful immediately and never shows a bank of spinners, and if the API is down
|
||||
// the demo content simply stays. Every fetch below is allowed to fail silently for
|
||||
// that reason.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// handle -> real profile image URL, filled in from whatever endpoint mentioned them.
|
||||
const LIVE_AVATARS = new Map();
|
||||
|
||||
// avatarUrl is called all over the file and defaults to a generated DiceBear face.
|
||||
// Wrap it so a real profile picture wins when we know one, without touching the
|
||||
// dozen call sites.
|
||||
const _avatarUrlBase = avatarUrl;
|
||||
avatarUrl = function (seed) {
|
||||
const real = LIVE_AVATARS.get(avKey(seed));
|
||||
return real || _avatarUrlBase(seed);
|
||||
};
|
||||
|
||||
// Remember an account's picture and repaint any avatar already on screen for them.
|
||||
function noteAvatar(user) {
|
||||
if (!user || !user.username) return;
|
||||
const key = avKey(user.username);
|
||||
if (!user.profile_image) return;
|
||||
if (LIVE_AVATARS.get(key) === user.profile_image) return;
|
||||
LIVE_AVATARS.set(key, user.profile_image);
|
||||
refreshAvatars(key, user.profile_image);
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const resp = await fetch(apiUrl(path), { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error(path + ' -> ' + resp.status);
|
||||
const out = await resp.json();
|
||||
if (out && out.ok === false) throw new Error(out.error || path);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- rooms ------------------------------------------------------------------
|
||||
|
||||
function roomThumb(r) {
|
||||
return r.image
|
||||
? `<div class="rail-thumb" style="background-image:url('${r.image}');background-size:cover;background-position:center"></div>`
|
||||
: `<div class="rail-thumb" style="--a:#ff5a3c;--b:#7b2ff7"></div>`;
|
||||
}
|
||||
|
||||
async function hydrateRooms() {
|
||||
if (!apiOn()) return;
|
||||
|
||||
let rooms;
|
||||
try {
|
||||
rooms = (await apiGet('/api/site/rooms/hot?take=12')).results || [];
|
||||
} catch (e) {
|
||||
console.warn('[campus] rooms unavailable, keeping demo rooms:', e.message);
|
||||
return;
|
||||
}
|
||||
if (!rooms.length) return; // empty server: demo content is better than a blank rail
|
||||
|
||||
// Room names come back with the leading '^' the game uses; strip it for display
|
||||
// and re-add it in the label so it matches the client.
|
||||
rooms.forEach(r => { r.displayName = (r.name || '').replace(/^\^/, ''); });
|
||||
|
||||
// Left rail. The markup for this is hardcoded in index.html, so replace the
|
||||
// items but keep the heading.
|
||||
const rail = document.getElementById('rooms');
|
||||
if (rail) {
|
||||
// This rail holds the online-friends section (inserted by hydrateOnline) ABOVE the rooms.
|
||||
// Preserve it, and always use a fixed "Top Rooms" heading - previously this reused whatever
|
||||
// .rail-title was first in the rail, which was the online section's, so the rooms ended up
|
||||
// mislabelled "Online Friends".
|
||||
const online = rail.querySelector('.rail-online');
|
||||
const pinnedBlock = rail.querySelector('.rail-pinned'); // pinned friends (renderPinned) live above the rooms too
|
||||
rail.innerHTML = (pinnedBlock ? pinnedBlock.outerHTML : '') + (online ? online.outerHTML : '')
|
||||
+ '<h3 class="rail-title">Top Rooms</h3>'
|
||||
+ rooms.slice(0, 8).map(r => `
|
||||
<a href="${roomUrl(r.id)}" class="rail-item">
|
||||
${roomThumb(r)}
|
||||
<div class="rail-meta">
|
||||
<span class="rail-name">^${escapeHtml(r.displayName)}</span>
|
||||
<span class="rail-by">${r.creator ? '@' + escapeHtml(r.creator) : ''}</span>
|
||||
</div>
|
||||
</a>`).join('');
|
||||
}
|
||||
|
||||
// NOTE: the featured slideshow is NOT set here anymore. It's owned by loadFeatured(), which
|
||||
// shows the admin-curated FEATURED rooms (same as in-game), falling back to hot only when
|
||||
// nothing is featured. Setting it here too would overwrite that with the top rooms.
|
||||
}
|
||||
|
||||
// ---- presence ---------------------------------------------------------------
|
||||
|
||||
async function hydrateOnline() {
|
||||
if (!apiOn()) return;
|
||||
|
||||
const rail = document.querySelector('.feed-layout .rail');
|
||||
if (!rail) return;
|
||||
|
||||
// Signed OUT: there are no "your friends" to show, so remove the section entirely (no generic
|
||||
// "Online Now" list of strangers).
|
||||
if (!CAMPUS_ME) { rail.querySelector('.rail-online')?.remove(); return; }
|
||||
|
||||
// Signed in: ONLY your own online friends (never every online player).
|
||||
let people = [];
|
||||
try {
|
||||
const f = await apiGet('/api/site/friends');
|
||||
people = (f.results || []).filter(p => p.online);
|
||||
} catch (e) {
|
||||
console.warn('[campus] presence unavailable, keeping demo list:', e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
people.forEach(noteAvatar);
|
||||
|
||||
const existing = rail.querySelector('.rail-online');
|
||||
const heading = CAMPUS_ME ? 'Online Friends' : 'Online Now';
|
||||
|
||||
if (!people.length) {
|
||||
// Real answer, and it is "nobody". Say so instead of showing invented friends.
|
||||
const emptyMsg = CAMPUS_ME ? 'None of your friends are online right now.' : 'No one is online right now.';
|
||||
const html = `<div class="rail-online"><h3 class="rail-title">${heading} · 0</h3>
|
||||
<p class="rail-by" style="padding:.5rem .25rem">${emptyMsg}</p></div>`;
|
||||
if (existing) existing.outerHTML = html; else rail.insertAdjacentHTML('afterbegin', html);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = people.map(p => {
|
||||
const handle = p.username || String(p.id);
|
||||
const name = p.display_name || p.username || 'Player';
|
||||
return `<a class="rail-item" href="${userUrl(handle)}">
|
||||
<span class="rail-on-av"><img class="rail-pin-av" data-av="${avKey(handle)}" src="${avatarUrl(handle)}" alt=""><i class="online-dot"></i></span>
|
||||
<div class="rail-meta">
|
||||
<span class="rail-name">${escapeHtml(name)}</span>
|
||||
<span class="rail-by">${escapeHtml(p.where || 'Online now')}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
|
||||
const html = `<div class="rail-online"><h3 class="rail-title">${heading} · ${people.length}</h3>${items}</div>`;
|
||||
if (existing) existing.outerHTML = html; else rail.insertAdjacentHTML('afterbegin', html);
|
||||
}
|
||||
|
||||
// ---- photos -----------------------------------------------------------------
|
||||
|
||||
async function hydrateFeed() {
|
||||
if (!apiOn() || !postList) return;
|
||||
|
||||
let photos;
|
||||
try {
|
||||
photos = (await apiGet('/api/site/photos/recent?take=24')).results || [];
|
||||
} catch (e) {
|
||||
console.warn('[campus] photo feed unavailable, keeping fallback:', e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!photos.length) {
|
||||
postList.innerHTML = '<div class="feed-loading">No photos have been shared yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
photos.forEach(p => noteAvatar(p.author));
|
||||
|
||||
// Map the server shape onto what renderPosts expects.
|
||||
renderPosts(photos.map(p => ({
|
||||
id: p.id,
|
||||
user: (p.author && (p.author.display_name || p.author.username)) || 'Unknown',
|
||||
handle: (p.author && p.author.username) || 'player',
|
||||
room: null, // room NAME is not in this projection; the id alone is not useful
|
||||
image: p.image,
|
||||
caption: p.description || '',
|
||||
cheers: p.cheers || 0,
|
||||
thread: [], // the thread itself is a separate fetch
|
||||
comments: p.comments || 0, // count comes from the server (see nComments)
|
||||
takenAt: p.createdAt,
|
||||
})));
|
||||
}
|
||||
|
||||
// ---- go ---------------------------------------------------------------------
|
||||
|
||||
// syncSession() has already run above; wait for it so hydrateOnline knows whether
|
||||
// anyone is signed in, then fill everything in parallel.
|
||||
SESSION_READY.then(() => {
|
||||
hydrateRooms();
|
||||
hydrateOnline();
|
||||
hydrateFeed();
|
||||
if (CAMPUS_ME) noteAvatar(CAMPUS_ME);
|
||||
});
|
||||
|
||||
// ---- topbar restyle (look only) --------------------------------------------------------------
|
||||
// The brand, an inline search field and the Rooms / News / Support links in the top bar on every
|
||||
// page. Nothing new behind them: the field just opens the existing search panel, the links go
|
||||
// where the drawer's do.
|
||||
(function topbarChrome() {
|
||||
const bar = document.querySelector('.topbar');
|
||||
if (!bar || bar.querySelector('.top-mid')) return;
|
||||
const left = bar.querySelector('.top-left');
|
||||
const right = bar.querySelector('.top-right');
|
||||
if (topbarBrand) {
|
||||
topbarBrand.classList.add('always');
|
||||
topbarBrand.addEventListener('click', () => { window.location.href = '/'; });
|
||||
topbarBrand.setAttribute('role', 'link');
|
||||
topbarBrand.tabIndex = 0;
|
||||
}
|
||||
if (left && document.getElementById('searchBtn')) {
|
||||
const mid = document.createElement('div');
|
||||
mid.className = 'top-mid';
|
||||
mid.innerHTML = '<label class="top-search"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.7" y2="16.7"/></svg><input type="text" placeholder="Search…" aria-label="Search" autocomplete="off"></label>';
|
||||
left.insertAdjacentElement('afterend', mid);
|
||||
const fake = mid.querySelector('input');
|
||||
const open = () => {
|
||||
if (typeof openSearch !== 'function' || !document.getElementById('searchPanel')) return;
|
||||
openSearch();
|
||||
const real = document.getElementById('searchInput');
|
||||
if (real) {
|
||||
if (fake.value) { real.value = fake.value; real.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
real.focus();
|
||||
}
|
||||
fake.value = '';
|
||||
fake.blur();
|
||||
};
|
||||
fake.addEventListener('focus', open);
|
||||
fake.addEventListener('click', open);
|
||||
}
|
||||
if (right) {
|
||||
const links = document.createElement('nav');
|
||||
links.className = 'top-nav-links';
|
||||
links.setAttribute('aria-label', 'Main');
|
||||
links.innerHTML =
|
||||
'<a href="/rooms"><span>Rooms</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10"/></svg></a>' +
|
||||
'<a href="/news"><span>News</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h13v14a1 1 0 0 1-1 1H4z"/><path d="M17 8h2a1 1 0 0 1 1 1v9a2 2 0 0 1-2 2"/><path d="M8 9h5M8 13h5"/></svg></a>' +
|
||||
'<a href="/support"><span>Support</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></a>';
|
||||
right.insertAdjacentElement('afterbegin', links);
|
||||
}
|
||||
})();
|
||||
|
||||
// Home hero: "N players online right now in Campus!" from the same presence API the online list
|
||||
// uses. Left as the neutral wording when the API is off or answers nothing.
|
||||
(function heroOnline() {
|
||||
const el = document.getElementById('heroOnline');
|
||||
if (!el || !apiOn()) return;
|
||||
const paint = async () => {
|
||||
try {
|
||||
const d = await apiGet('/api/site/online?take=1');
|
||||
if (d && d.ok && typeof d.total === 'number') {
|
||||
el.textContent = d.total === 1 ? '1 player is' : `${d.total.toLocaleString()} players are`;
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
paint();
|
||||
setInterval(paint, 60000);
|
||||
})();
|
||||
|
||||
|
||||
// ---- /rooms: browse every public room (Hot / New / Originals, with a filter) -----------------
|
||||
(function roomsPage() {
|
||||
const grid = document.getElementById('roomsGrid');
|
||||
if (!grid) return;
|
||||
const tabs = Array.from(document.querySelectorAll('.rtab'));
|
||||
const filter = document.getElementById('roomsFilter');
|
||||
const more = document.getElementById('roomsMore');
|
||||
const count = document.getElementById('roomsCount');
|
||||
const PAGE = 24;
|
||||
let tab = 'hot', q = '', skip = 0, total = 0, busy = false, seq = 0;
|
||||
|
||||
// Deep link: /rooms?tab=rro&q=dodge
|
||||
try {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (['hot', 'new', 'rro'].includes(p.get('tab'))) tab = p.get('tab');
|
||||
if (p.get('q')) { q = p.get('q'); filter.value = q; }
|
||||
} catch {}
|
||||
|
||||
const ICON_VISITS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 12S5.5 5 12 5s10.5 7 10.5 7-4 7-10.5 7S1.5 12 1.5 12z"/><circle cx="12" cy="12" r="3.2"/></svg>';
|
||||
const ICON_CHEER = CHEER_ICON;
|
||||
|
||||
function card(r) {
|
||||
const name = String(r.name || '').replace(/^\^/, '');
|
||||
const img = r.image
|
||||
? `<div class="rc-img" style="background-image:url('${r.image}')"></div>`
|
||||
: `<div class="rc-img rc-img-empty">^${escapeHtml(name)}</div>`;
|
||||
return `<a class="rc" href="${roomUrl(r.name || r.id)}">
|
||||
${img}
|
||||
<div class="rc-body">
|
||||
<span class="rc-name">^${escapeHtml(name)}</span>
|
||||
<span class="rc-by">${r.creator ? '@' + escapeHtml(r.creator) : ''}</span>
|
||||
<span class="rc-stats"><span>${ICON_VISITS}${fmt(r.visits || 0)}</span><span>${ICON_CHEER}${fmt(r.cheers || 0)}</span></span>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
const list = Object.keys(window.ROOMS_DATA || {}).map(id => Object.assign({ id, name: (ROOMS_DATA[id] || {}).name || id }, ROOMS_DATA[id] || {}));
|
||||
grid.innerHTML = list.length ? list.map(card).join('') : '<div class="feed-loading">No rooms to show yet.</div>';
|
||||
more.hidden = true;
|
||||
count.textContent = '';
|
||||
}
|
||||
|
||||
async function load(reset) {
|
||||
if (busy) return;
|
||||
if (!apiOn()) { fallback(); return; }
|
||||
busy = true;
|
||||
const my = ++seq;
|
||||
if (reset) { skip = 0; grid.innerHTML = '<div class="feed-loading">Loading rooms…</div>'; }
|
||||
more.disabled = true;
|
||||
try {
|
||||
const d = await apiGet(`/api/site/rooms/browse?tab=${encodeURIComponent(tab)}&q=${encodeURIComponent(q)}&skip=${skip}&take=${PAGE}`);
|
||||
if (my !== seq) return; // a newer request superseded this one
|
||||
if (!d || !d.ok) throw new Error((d && d.error) || 'failed');
|
||||
total = d.total || 0;
|
||||
const html = (d.results || []).map(card).join('');
|
||||
if (reset) grid.innerHTML = html || '<div class="feed-loading">No rooms match that.</div>';
|
||||
else grid.insertAdjacentHTML('beforeend', html);
|
||||
skip += (d.results || []).length;
|
||||
more.hidden = skip >= total;
|
||||
count.textContent = total ? `${total.toLocaleString()} room${total === 1 ? '' : 's'}` : '';
|
||||
} catch (e) {
|
||||
if (my === seq && reset) grid.innerHTML = '<div class="feed-loading">Could not load rooms.</div>';
|
||||
more.hidden = true;
|
||||
} finally {
|
||||
if (my === seq) { busy = false; more.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(next) {
|
||||
tab = next;
|
||||
tabs.forEach(b => {
|
||||
const on = b.dataset.tab === tab;
|
||||
b.classList.toggle('active', on);
|
||||
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.set('tab', tab);
|
||||
if (q) u.searchParams.set('q', q); else u.searchParams.delete('q');
|
||||
history.replaceState(null, '', u.pathname + (u.search || ''));
|
||||
} catch {}
|
||||
load(true);
|
||||
}
|
||||
|
||||
tabs.forEach(b => b.addEventListener('click', () => { if (b.dataset.tab !== tab) setTab(b.dataset.tab); }));
|
||||
let t = null;
|
||||
filter.addEventListener('input', () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => { q = filter.value.trim(); setTab(tab); }, 250);
|
||||
});
|
||||
more.addEventListener('click', () => load(false));
|
||||
setTab(tab);
|
||||
})();
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 284 KiB |
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Weekly Challenges - Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="wc-page">
|
||||
<section class="wc-hero" aria-labelledby="wcTitle">
|
||||
<div class="wc-hero-text">
|
||||
<h1 id="wcTitle">Weekly Challenges</h1>
|
||||
<p>Finish games in Rec Room to tick them off. Complete <b id="wcNeed">3</b> of the 5 and this week's reward is yours.</p>
|
||||
</div>
|
||||
<div class="wc-hero-side">
|
||||
<div class="wc-meter" id="wcMeter" role="img" aria-label="Your progress">
|
||||
<svg viewBox="0 0 120 120" aria-hidden="true"><circle class="wc-meter-track" cx="60" cy="60" r="52"/><circle class="wc-meter-fill" id="wcMeterFill" cx="60" cy="60" r="52"/></svg>
|
||||
<div class="wc-meter-num"><b id="wcMeterNum">-</b><span id="wcMeterOf">of 3</span></div>
|
||||
</div>
|
||||
<div class="wc-timer">
|
||||
<span class="wc-timer-label">Resets in</span>
|
||||
<span class="wc-timer-val" id="wcTimer">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="wc-layout">
|
||||
<section class="wc-list" id="wcList" aria-label="This week's challenges">
|
||||
<div class="wc-card wc-loading">Loading this week's challenges...</div>
|
||||
</section>
|
||||
|
||||
<aside class="wc-reward" id="wcReward" aria-label="This week's reward">
|
||||
<div class="wc-reward-label">This week's reward</div>
|
||||
<div class="wc-reward-art">
|
||||
<div class="wc-reward-img"><img id="wcRewardImg" alt="" hidden></div>
|
||||
<span class="wc-rarity" id="wcRarity" hidden></span>
|
||||
</div>
|
||||
<div class="wc-reward-name" id="wcRewardName"></div>
|
||||
<div class="wc-reward-kind" id="wcRewardKind"></div>
|
||||
<div class="wc-reward-status" id="wcStatus"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<p class="wc-note">Games count when you finish them; quests count when you beat them. The reward arrives as a gift in game. New challenges every Wednesday at 21:00 UTC.</p>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
<script src="challenges.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,99 @@
|
||||
// ───── WEEKLY CHALLENGES PAGE (/challenges) ─────
|
||||
// Data from GET /api/site/weekly (WeeklyChallengeController). Signed in, every challenge carries
|
||||
// the player's own progress; signed out, the page shows the challenges and asks them to log in.
|
||||
(function () {
|
||||
const list = document.getElementById('wcList');
|
||||
if (!list) return;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
// Store rarity -> the name the game shows on its boxes.
|
||||
function rarityName(r) {
|
||||
if (r >= 50) return ['Legendary', 'legendary'];
|
||||
if (r >= 30) return ['Epic', 'epic'];
|
||||
if (r >= 20) return ['Rare', 'rare'];
|
||||
if (r >= 10) return ['Uncommon', 'uncommon'];
|
||||
return ['Common', 'common'];
|
||||
}
|
||||
|
||||
function countdown(iso) {
|
||||
const ms = new Date(iso) - Date.now();
|
||||
if (!(ms > 0)) return 'any moment';
|
||||
const d = Math.floor(ms / 86400000), h = Math.floor(ms % 86400000 / 3600000), m = Math.floor(ms % 3600000 / 60000);
|
||||
return d > 0 ? d + 'd ' + h + 'h' : h > 0 ? h + 'h ' + m + 'm' : m + 'm';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let d = null;
|
||||
try {
|
||||
const r = await fetch('/api/site/weekly', { credentials: 'same-origin' });
|
||||
if (r.ok) d = await r.json();
|
||||
} catch { }
|
||||
if (!d || !d.ok) {
|
||||
list.innerHTML = '<div class="wc-card wc-loading">Couldn\'t load this week\'s challenges. Try again in a moment.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('wcNeed').textContent = d.required;
|
||||
|
||||
// ---- challenges ----
|
||||
list.innerHTML = d.challenges.map(c => {
|
||||
const n = Math.min(c.progress, c.target);
|
||||
const done = d.signedIn && n >= c.target;
|
||||
const pct = d.signedIn ? Math.round(100 * n / c.target) : 0;
|
||||
const room = c.roomId ? '<a class="wc-room" href="/room/' + encodeURIComponent(c.roomId) + '">^' + esc(c.roomName || 'Room') + '</a>' : '<span class="wc-room wc-any">Any room</span>';
|
||||
return '<article class="wc-card' + (done ? ' wc-done' : '') + '">'
|
||||
+ '<div class="wc-row"><div class="wc-text">' + esc(c.text) + '</div>'
|
||||
+ (d.signedIn ? '<span class="wc-count">' + (done ? 'Done' : n + ' / ' + c.target) + '</span>' : '') + '</div>'
|
||||
+ '<div class="wc-meta">' + room + '</div>'
|
||||
+ (d.signedIn ? '<div class="wc-bar" role="progressbar" aria-valuemin="0" aria-valuemax="' + c.target + '" aria-valuenow="' + n + '" aria-label="' + esc(c.text) + '"><span style="width:' + pct + '%"></span></div>' : '')
|
||||
+ '</article>';
|
||||
}).join('');
|
||||
|
||||
// ---- the progress ring in the header ----
|
||||
const need = d.required, have = d.signedIn ? Math.min(d.completed, need) : 0;
|
||||
const fill = document.getElementById('wcMeterFill');
|
||||
const C = 2 * Math.PI * 52;
|
||||
fill.style.strokeDasharray = C;
|
||||
fill.style.strokeDashoffset = C * (1 - have / need);
|
||||
document.getElementById('wcMeterNum').textContent = d.signedIn ? have : '?';
|
||||
document.getElementById('wcMeterOf').textContent = 'of ' + need;
|
||||
document.getElementById('wcMeter').setAttribute('aria-label', d.signedIn ? have + ' of ' + need + ' challenges done' : 'Log in to see your progress');
|
||||
document.getElementById('wcMeter').classList.toggle('wc-meter-full', d.signedIn && (d.rewardSent || have >= need));
|
||||
|
||||
// ---- reward ----
|
||||
const img = document.getElementById('wcRewardImg');
|
||||
const r = d.reward;
|
||||
if (r) {
|
||||
if (r.image) { img.onerror = () => { img.hidden = true; }; img.src = r.image + '?width=360'; img.alt = r.name; img.hidden = false; }
|
||||
document.getElementById('wcRewardName').textContent = r.name;
|
||||
document.getElementById('wcRewardKind').textContent = r.kind === 'skin' ? 'Equipment skin' : r.kind === 'clothing' ? 'Clothing' : '';
|
||||
const [rn, rc] = rarityName(r.rarity || 0);
|
||||
const badge = document.getElementById('wcRarity');
|
||||
badge.textContent = rn; badge.hidden = false;
|
||||
document.getElementById('wcReward').classList.add('wc-r-' + rc);
|
||||
} else {
|
||||
document.getElementById('wcRewardName').textContent = 'A surprise';
|
||||
}
|
||||
|
||||
const st = document.getElementById('wcStatus');
|
||||
if (!d.signedIn) {
|
||||
st.innerHTML = '<a href="/login" class="wc-login">Log in</a> to track your progress';
|
||||
} else if (d.rewardSent) {
|
||||
st.className = 'wc-reward-status wc-earned';
|
||||
st.textContent = 'Earned! ' + (d.rewardSentName && r && d.rewardSentName !== r.name ? 'You already had it, so you got a ' + d.rewardSentName + ' instead. ' : '') + 'Open your gifts in game.';
|
||||
} else {
|
||||
const left = Math.max(0, d.required - d.completed);
|
||||
st.textContent = left > 0 ? left + ' more challenge' + (left === 1 ? '' : 's') + ' to unlock' : 'Unlocked - check your gifts in game';
|
||||
}
|
||||
|
||||
const timer = document.getElementById('wcTimer');
|
||||
const tick = () => { timer.textContent = countdown(d.endsAt); };
|
||||
tick();
|
||||
setInterval(tick, 30000);
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,69 @@
|
||||
// ───── CAMPUS — central demo data ─────
|
||||
// All mock/seed data lives here so it's easy to find and edit in one place.
|
||||
// app.js reads from window.CAMPUS_DATA (with inline fallbacks if this file is missing).
|
||||
window.CAMPUS_DATA = {
|
||||
// The site is now served BY the Campus C# server (Kestrel), not by the old Flask
|
||||
// server.py, so every backend call is same-origin and apiBase stays empty.
|
||||
apiBase: '',
|
||||
|
||||
// apiBase '' is falsy, and the old code used `if (!apiBase)` as its "is the API
|
||||
// available?" test - which meant a same-origin deployment silently ran in demo mode
|
||||
// with search and login disabled. This flag is the real switch; set it to false to
|
||||
// go back to the offline demo data.
|
||||
apiEnabled: true,
|
||||
|
||||
// Image filenames (ProfileImage / ImageName) are served directly by the C# server's
|
||||
// ImagesController at /img/. The old /api/img/ Flask proxy (which fetched from an
|
||||
// external, Cloudflare-protected host using a stored token) is gone.
|
||||
imgBase: '/img/',
|
||||
|
||||
// featured / popular rooms
|
||||
rooms: {
|
||||
'whisker-heights': { name: 'Whisker Heights', creator: 'clawful', a: '#ff5a3c', b: '#ffb142', players: 2143, visits: '67k', desc: 'Scale a floating city of rooftops and neon. Co-op parkour for up to 16 cats.' },
|
||||
'midnight-mews': { name: 'Midnight Mews', creator: 'velvetpaw', a: '#f0453f', b: '#ff8a5c', players: 312, visits: '31k', desc: 'A cozy late-night hangout under the stars — chill, chat, and build with friends.' },
|
||||
'litterbox-lab': { name: 'Litterbox Lab', creator: 'tinker9', a: '#ff6b9d', b: '#ffc371', players: 540, visits: '22k', desc: 'Tinker with wild contraptions and machines in this maker playground.' },
|
||||
'yarn-quest': { name: 'Yarn Quest', creator: 'stitchwitch', a: '#e84393', b: '#ff7f6e', players: 880, visits: '67k', desc: 'An epic co-op adventure through a hand-stitched world of yarn and wool.' },
|
||||
'deep-dive-reef': { name: 'Deep Dive Reef', creator: 'bubbles', a: '#c44569', b: '#ff7f50', players: 198, visits: '19k', desc: 'Explore a glowing underwater reef and discover its hidden depths.' },
|
||||
'haunted-cattery': { name: 'Haunted Cattery', creator: 'spookytom', a: '#d63031', b: '#ff7675', players: 1500, visits: '91k', desc: 'A spooky mansion full of jump-scares, puzzles, and secrets to uncover.' },
|
||||
},
|
||||
|
||||
// notification dropdown items — empty by default (no notifications when logged in)
|
||||
notifications: [],
|
||||
|
||||
// updates page content — Patch Notes / Weekly / News (edit freely)
|
||||
updates: {
|
||||
patch: {
|
||||
title: 'Patch Notes',
|
||||
blurb: 'Every fix, tweak and balance change, newest first.',
|
||||
posts: [
|
||||
{ date: '2026-06-16', tag: 'v1.4.2', title: 'Stability & login fixes', body: 'Fixed a crash when joining full rooms, improved login reliability on mobile, and reduced load times in the Rec Center.' },
|
||||
{ date: '2026-06-09', tag: 'v1.4.1', title: 'Photo upload improvements', body: 'Photos now upload faster and keep their full resolution. Fixed profile pictures occasionally not updating.' },
|
||||
{ date: '2026-06-02', tag: 'v1.4.0', title: 'New creator tools', body: 'Added the maker pen radial menu, more room tags, and a fresh set of props for builders.' },
|
||||
],
|
||||
},
|
||||
weekly: {
|
||||
title: 'Weekly',
|
||||
blurb: 'Your weekly roundup of featured rooms, events and creators.',
|
||||
posts: [
|
||||
{ date: '2026-06-15', tag: 'Week 24', title: 'Featured: Whisker Heights', body: 'This week we are spotlighting Whisker Heights by @clawful — a co-op parkour city with neon rooftops. Jump in this weekend for double cheers!' },
|
||||
{ date: '2026-06-08', tag: 'Week 23', title: 'Creator of the week: @stitchwitch', body: 'Yarn Quest hit 67k visits. We sat down with @stitchwitch to talk hand-stitched worlds and what is next.' },
|
||||
],
|
||||
},
|
||||
news: {
|
||||
title: 'News',
|
||||
blurb: 'Announcements and what is happening across Campus.',
|
||||
posts: [
|
||||
{ date: '2026-06-14', tag: 'Event', title: 'Summer Paw Festival incoming', body: 'Our biggest seasonal event returns at the end of the month — limited-time rooms, prizes and a community build challenge.' },
|
||||
{ date: '2026-06-10', tag: 'Update', title: 'Mobile cross-play is here', body: 'You can now hang out across PC, Quest and mobile in the same rooms. Bring your friends on any device.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// "online now" friends shown on the home rail
|
||||
onlineFriends: [
|
||||
{ h: 'velvetpaw', where: 'Midnight Mews' },
|
||||
{ h: 'clawful', where: 'Whisker Heights' },
|
||||
{ h: 'stitchwitch', where: 'Yarn Quest' },
|
||||
{ h: 'bubbles', where: 'In the menu' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Download - Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body class="dl-page">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- CAT DRAWER -->
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<div id="main" class="dl-wrap">
|
||||
|
||||
<!-- HERO BANNER -->
|
||||
<section class="dl-banner">
|
||||
<img src="assets/hero-bg.jpg" alt="" class="dl-banner-img" onerror="this.remove()">
|
||||
<div class="dl-banner-body">
|
||||
<span class="dl-pill">Free to play</span>
|
||||
<h1>Download Campus</h1>
|
||||
<p>Play on PC or Meta Quest - with or without a VR headset. Cross-play with millions of players.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PLATFORM TILES -->
|
||||
<section class="dl-grid">
|
||||
<a href="/campus.exe" download="Campus.exe" class="dl-tile">
|
||||
<img src="assets/steamicon.png" alt="" class="dl-icon">
|
||||
<div class="dl-tile-text"><span class="dl-small">Download for</span><span class="dl-big">PC · Steam</span></div>
|
||||
<span class="dl-free">Free</span>
|
||||
<span class="dl-arrow">→</span>
|
||||
</a>
|
||||
<a href="https://www.meta.com/s/6B36IdCIj" target="_blank" rel="noopener" class="dl-tile">
|
||||
<img src="assets/metaicon.png" alt="" class="dl-icon">
|
||||
<div class="dl-tile-text"><span class="dl-small">Available on</span><span class="dl-big">Meta Quest</span></div>
|
||||
<span class="dl-free">Free</span>
|
||||
<span class="dl-arrow">→</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section class="dl-faq">
|
||||
<h2>Frequently asked</h2>
|
||||
<div class="faq-item">
|
||||
<button class="faq-q">Do I need to be in the Discord server?<span class="faq-x">+</span></button>
|
||||
<div class="faq-a-wrap"><div class="faq-a"><p>No - the full game is free to download and play on PC and Quest by clicking the platforms download buttons.</p></div></div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<button class="faq-q">Do I need a VR headset?<span class="faq-x">+</span></button>
|
||||
<div class="faq-a-wrap"><div class="faq-a"><p>No. You can play on PC with a keyboard and mouse, or in VR on Meta Quest - both join the same rooms.</p></div></div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<button class="faq-q">Can I play with friends on other devices?<span class="faq-x">+</span></button>
|
||||
<div class="faq-a-wrap"><div class="faq-a"><p>Absolutely. Campus is fully cross-platform, so PC and VR players hang out together seamlessly.</p></div></div>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<button class="faq-q">How big is the download?<span class="faq-x">+</span></button>
|
||||
<div class="faq-a-wrap"><div class="faq-a"><p>On PC you download the Campus launcher (about 4 MB). It installs the game for you - a 3.8 GB download that takes about 6.7 GB on disk - and keeps it up to date. On Quest it's around 1.5 GB.</p></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a href="/" class="dl-back">← Back to home</a>
|
||||
</div>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Event - Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#eventPage">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main class="image-page event-page" id="eventPage">
|
||||
<div class="image-photo" id="evCover"><span class="ev-photo-badge" id="evBadge"></span></div>
|
||||
|
||||
<div class="image-below">
|
||||
<div class="image-head">
|
||||
<a class="image-author" id="evHost" href="#">
|
||||
<img class="image-author-av" id="evHostAv" alt="">
|
||||
<div class="image-author-id">
|
||||
<span class="image-author-name" id="evHostName">Host</span>
|
||||
<span class="image-author-handle" id="evHostHandle"></span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="image-tools" id="evTools">
|
||||
<button class="img-report ev-join" id="evJoin" type="button">Join</button>
|
||||
<span class="ev-rsvp-tools" id="evRsvp"></span>
|
||||
<div class="image-menu-wrap">
|
||||
<button class="image-menu-btn" id="evMenuBtn" aria-label="More options" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div class="image-menu" id="evMenu">
|
||||
<button class="image-menu-item" id="evShare" type="button">Copy link</button>
|
||||
<button class="image-menu-item" id="evCal" type="button">Add to calendar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info">
|
||||
<div class="image-info-text">
|
||||
<h1 class="image-title" id="evName">Event</h1>
|
||||
<p class="image-meta" id="evMeta"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="image-comments" id="evAbout"><h2>About this event</h2><p class="ev-desc" id="evDesc"></p></section>
|
||||
<section class="image-comments"><h2 id="evGoingTitle">Attending</h2><div class="ev-people" id="evPeople"></div></section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Photo - Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
<style>
|
||||
.image-photo{position:relative;}
|
||||
/* tagged-players button + popover */
|
||||
.tagbtn{position:absolute;left:12px;bottom:12px;display:inline-flex;align-items:center;gap:6px;cursor:pointer;border:none;
|
||||
background:rgba(0,0,0,.58);color:#fff;font:inherit;font-weight:700;font-size:.82rem;padding:7px 11px;border-radius:999px;}
|
||||
.tagbtn svg{width:16px;height:16px;}
|
||||
.tagpop{position:absolute;left:12px;bottom:52px;min-width:190px;max-width:280px;background:var(--card);border:1px solid var(--line);
|
||||
border-radius:14px;box-shadow:0 16px 44px rgba(0,0,0,.35);padding:8px;display:none;z-index:6;}
|
||||
.tagpop.open{display:block;}
|
||||
.tagpop .tp-head{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:5px 8px 7px;}
|
||||
.tagpop .tp-row{display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:10px;}
|
||||
.tagpop .tp-row:hover{background:var(--panel);}
|
||||
.tagpop .tp-av{width:28px;height:28px;border-radius:50%;flex:none;background:var(--panel) center/cover no-repeat;}
|
||||
.tagpop a.tp-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-decoration:none;color:var(--text);font-weight:600;font-size:.9rem;}
|
||||
.tagpop a.tp-name:hover{color:var(--primary);}
|
||||
.tagpop .tp-x{border:none;background:transparent;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1;padding:2px 6px;border-radius:7px;}
|
||||
.tagpop .tp-x:hover{color:#e11d48;background:rgba(225,29,72,.13);}
|
||||
/* owner visibility control */
|
||||
.vis-seg{display:inline-flex;gap:2px;margin-top:12px;padding:3px;border-radius:999px;background:var(--panel);border:1px solid var(--line);}
|
||||
.vis-seg button{cursor:pointer;border:none;background:transparent;color:var(--muted);font:inherit;font-weight:700;font-size:.8rem;padding:6px 12px;border-radius:999px;display:inline-flex;align-items:center;gap:5px;transition:.12s;}
|
||||
.vis-seg button.active{background:var(--card);color:var(--text);box-shadow:0 1px 4px rgba(0,0,0,.14);}
|
||||
.vis-seg svg{width:13px;height:13px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#imagePage">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main class="image-page" id="imagePage">
|
||||
<div class="image-photo" id="imagePhoto"></div>
|
||||
|
||||
<div class="image-below">
|
||||
<div class="image-head">
|
||||
<a class="image-author" id="imageAuthor" href="#">
|
||||
<img class="image-author-av" id="imageAuthorAv" alt="">
|
||||
<div class="image-author-id">
|
||||
<span class="image-author-name" id="imageAuthorName">User</span>
|
||||
<span class="image-author-handle" id="imageAuthorHandle">@user</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="image-tools" id="imageTools">
|
||||
<button class="img-report img-export" id="imgExport" title="Download this photo"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 19h16"/></svg>Export</button>
|
||||
<button class="img-report" id="imgReport">Report</button>
|
||||
<div class="image-menu-wrap">
|
||||
<button class="image-menu-btn" id="imageMenuBtn" aria-label="More options">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||
</button>
|
||||
<div class="image-menu" id="imageMenu">
|
||||
<button class="image-menu-item" data-act="pfp">Set as Profile Picture</button>
|
||||
<button class="image-menu-item" data-act="banner">Set as Profile Banner</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-info">
|
||||
<div class="image-info-text">
|
||||
<h1 class="image-title" id="imageTitle">Title</h1>
|
||||
<p class="image-meta" id="imageMeta"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="image-comments" id="imageComments">
|
||||
<h2>Comments</h2>
|
||||
<div id="imageCommentsHost"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body class="home">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<!-- ───── TOP BAR ───── -->
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- CAT DRAWER -->
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="home-main">
|
||||
|
||||
<!-- ───── HERO ───── -->
|
||||
<section class="home-hero" aria-label="Welcome">
|
||||
<svg class="hero-wave hero-wave-back" viewBox="0 0 1440 320" preserveAspectRatio="none" aria-hidden="true"><path d="M0,96 C240,180 420,40 720,120 C1020,200 1200,60 1440,140 L1440,320 L0,320 Z"/></svg>
|
||||
<svg class="hero-wave hero-wave-front" viewBox="0 0 1440 320" preserveAspectRatio="none" aria-hidden="true"><path d="M0,200 C260,120 480,260 760,180 C1040,100 1240,220 1440,170 L1440,320 L0,320 Z"/></svg>
|
||||
<div class="hero-inner">
|
||||
<h1 class="hero-title"><span id="heroOnline">Players are</span> online right now in Campus!</h1>
|
||||
<a href="/download" class="hero-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───── FEATURES ───── -->
|
||||
<section class="home-features" aria-label="About Campus">
|
||||
<div class="hf">
|
||||
<div class="hf-img" style="background-position:2% 50%;background-size:400%"></div>
|
||||
<h3 class="hf-title">Cross-platform</h3>
|
||||
<p class="hf-text">Campus is cross-platform, free, and can be played with or without a VR headset. Hang out with friends from all around the world across all devices!</p>
|
||||
</div>
|
||||
<div class="hf">
|
||||
<div class="hf-img" style="background-position:5% 90%;background-size:300%"></div>
|
||||
<h3 class="hf-title">Create</h3>
|
||||
<p class="hf-text">Discover thousands of player-created rooms or build your own rooms with friends. With new rooms being added daily, you'll never run out of things to do!</p>
|
||||
</div>
|
||||
<div class="hf">
|
||||
<div class="hf-img" style="background-position:100% 50%;background-size:400%"></div>
|
||||
<h3 class="hf-title">Compete</h3>
|
||||
<p class="hf-text">Grab a paintball gun and get ready to dive for cover! Try Capture the Flag or Team Battle on multiple super-fun maps. Plenty of customization options to keep the action fresh!</p>
|
||||
</div>
|
||||
<div class="hf">
|
||||
<div class="hf-img" style="background-position:100% 90%;background-size:300%"></div>
|
||||
<h3 class="hf-title">Cooperate</h3>
|
||||
<p class="hf-text">Team up with your friends and arm yourselves with laser guns. It'll take teamwork and high-fives to defeat Jumbotron and his army of robots.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ───── FEED LAYOUT ───── -->
|
||||
<div class="feed-layout">
|
||||
|
||||
<!-- LEFT RAIL: ROOMS -->
|
||||
<div class="rail-col">
|
||||
<a href="/rooms" class="hot-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2c1 4 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 1-3s1 2 2 2c0-3 1-5 2-9z"/></svg>
|
||||
View hot rooms
|
||||
</a>
|
||||
<div class="rail-box">
|
||||
<div class="rail-box-head">
|
||||
<strong>Top Rooms</strong>
|
||||
<span>Rooms provided by <a href="#feed">Campus Creative Program</a></span>
|
||||
</div>
|
||||
<aside class="rail" id="rooms">
|
||||
<h3 class="rail-title">Top Rooms</h3>
|
||||
<a href="/room/whisker-heights" class="rail-item">
|
||||
<div class="rail-thumb" style="--a:#ff5a3c;--b:#ffb142"></div>
|
||||
<div class="rail-meta"><span class="rail-name">^Whisker Heights</span><span class="rail-by">@clawful</span></div>
|
||||
</a>
|
||||
<a href="/room/midnight-mews" class="rail-item">
|
||||
<div class="rail-thumb" style="--a:#f0453f;--b:#ff8a5c"></div>
|
||||
<div class="rail-meta"><span class="rail-name">^Midnight Mews</span><span class="rail-by">@velvetpaw</span></div>
|
||||
</a>
|
||||
<a href="/room/litterbox-lab" class="rail-item">
|
||||
<div class="rail-thumb" style="--a:#ff6b9d;--b:#ffc371"></div>
|
||||
<div class="rail-meta"><span class="rail-name">^Litterbox Lab</span><span class="rail-by">@tinker9</span></div>
|
||||
</a>
|
||||
<a href="/room/yarn-quest" class="rail-item">
|
||||
<div class="rail-thumb" style="--a:#e84393;--b:#ff7f6e"></div>
|
||||
<div class="rail-meta"><span class="rail-name">^Yarn Quest</span><span class="rail-by">@stitchwitch</span></div>
|
||||
</a>
|
||||
<a href="/room/deep-dive-reef" class="rail-item">
|
||||
<div class="rail-thumb" style="--a:#c44569;--b:#ff7f50"></div>
|
||||
<div class="rail-meta"><span class="rail-name">^Deep Dive Reef</span><span class="rail-by">@bubbles</span></div>
|
||||
</a>
|
||||
<a href="/room/haunted-cattery" class="rail-item">
|
||||
<div class="rail-thumb" style="--a:#d63031;--b:#ff7675"></div>
|
||||
<div class="rail-meta"><span class="rail-name">^Haunted Cattery</span><span class="rail-by">@spooky.tom</span></div>
|
||||
</a>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER FEED -->
|
||||
<section class="feed" id="feed">
|
||||
|
||||
<h2 class="feed-title">Take a look at what's happening right now in Campus</h2>
|
||||
|
||||
<!-- featured rooms slideshow -->
|
||||
<div class="featured-slideshow" id="featuredSlideshow"></div>
|
||||
|
||||
<!-- quick links: patch notes / weekly / news -->
|
||||
<div class="home-links">
|
||||
<a href="/patch-notes" class="home-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6M9 17h6"/></svg>
|
||||
<span>Patch Notes</span>
|
||||
</a>
|
||||
<a href="/support" class="home-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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>
|
||||
<span>Support</span>
|
||||
</a>
|
||||
<a href="/news" class="home-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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 13h5M8 17h3"/></svg>
|
||||
<span>News</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Posts are rendered here by app.js (from the photos API, with a fallback) -->
|
||||
<div id="postList">
|
||||
<div class="feed-loading">Loading recent photos…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<main id="main" class="login-wrap">
|
||||
<a href="/" class="login-card-logo">
|
||||
<span class="cat-shift"><img src="assets/cat-icon.png" alt="Campus" class="login-cat"
|
||||
onerror="this.outerHTML='<span class=logo-text style=font-size:2rem>campus</span>'"></span>
|
||||
<span class="brand-wrap"><span class="login-brand">Campus</span></span>
|
||||
</a>
|
||||
|
||||
<div class="login-card">
|
||||
<h1>Welcome back</h1>
|
||||
<p class="login-sub">Log in to your Campus account</p>
|
||||
|
||||
<form id="loginForm">
|
||||
<label class="field">
|
||||
<span>Username or email</span>
|
||||
<input id="loginUser" type="text" placeholder="Username" autocomplete="username">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input id="loginPass" type="password" placeholder="••••••••" autocomplete="current-password">
|
||||
</label>
|
||||
|
||||
<p class="login-error" id="loginError"></p>
|
||||
|
||||
<div class="login-row">
|
||||
<label class="remember"><input type="checkbox"> Remember me</label>
|
||||
<a href="#" class="forgot">Forgot password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-dl login-submit">Log in</button>
|
||||
</form>
|
||||
|
||||
<!-- Shown instead of the form above once the password checks out on a 2FA account. -->
|
||||
<form id="login2faForm" style="display:none;">
|
||||
<label class="field">
|
||||
<span>6-digit code (or a recovery code)</span>
|
||||
<input id="login2faCode" type="text" inputmode="numeric" placeholder="123456" autocomplete="one-time-code">
|
||||
</label>
|
||||
<p class="login-error" id="login2faError"></p>
|
||||
<button type="submit" class="btn-dl login-submit">Verify</button>
|
||||
<button type="button" id="login2faBack" class="btn-dl login-submit" style="background:transparent; border:1px solid rgba(255,255,255,.2); margin-top:8px;">← Back</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot" id="loginFoot">New here? <a href="/signup">Create account</a></p>
|
||||
</div>
|
||||
|
||||
<a href="/" class="dl-back">← Back to home</a>
|
||||
</main>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,174 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Messages - Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
<style>
|
||||
.msg-page{display:flex; margin-top:var(--topbar-h); height:calc(100vh - var(--topbar-h)); min-height:420px;}
|
||||
.msg-list{width:320px; flex:0 0 auto; border-right:1px solid rgba(127,127,127,.2); overflow-y:auto; display:flex; flex-direction:column;}
|
||||
.msg-list-head{display:flex; align-items:center; justify-content:space-between; padding:16px; gap:10px;}
|
||||
.msg-list-head h1{font-size:19px; margin:0;}
|
||||
.msg-new-btn{background:var(--card-hover-bg,#e85d2f); color:#fff; border:none; border-radius:8px; padding:8px 12px; font-weight:600; font-size:13px; cursor:pointer; font-family:inherit;}
|
||||
.msg-thread{display:flex; align-items:center; gap:10px; padding:10px 16px; cursor:pointer; border-left:3px solid transparent; text-align:left; background:none; border-top:none; border-right:none; border-bottom:none; width:100%; color:inherit; font:inherit;}
|
||||
.msg-thread:hover{background:rgba(127,127,127,.08);}
|
||||
.msg-thread.active{background:rgba(127,127,127,.14); border-left-color:var(--card-hover-bg,#e85d2f);}
|
||||
.msg-thread-avs{position:relative; width:42px; height:42px; flex:0 0 auto;}
|
||||
.msg-thread-avs img{width:42px; height:42px; border-radius:50%; object-fit:cover; display:block;}
|
||||
.msg-thread-avs img.msg-av-2{position:absolute; right:-10px; bottom:-10px; width:24px; height:24px; border:2px solid var(--bg,#111);}
|
||||
.msg-thread-meta{min-width:0; flex:1 1 auto;}
|
||||
.msg-thread-name{font-weight:700; font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
|
||||
.msg-thread-prev{font-size:13px; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
|
||||
.msg-thread.unread .msg-thread-name{color:var(--card-hover-bg,#e85d2f);}
|
||||
.msg-thread.unread .msg-thread-prev{opacity:1; font-weight:600;}
|
||||
.msg-empty{padding:24px 16px; opacity:.65; font-size:14px;}
|
||||
.msg-conv{flex:1 1 auto; display:flex; flex-direction:column; min-width:0;}
|
||||
.msg-conv-head{padding:16px; border-bottom:1px solid rgba(127,127,127,.2); font-weight:700; display:flex; align-items:center; gap:10px;}
|
||||
.msg-conv-body{flex:1 1 auto; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:10px;}
|
||||
.msg-bubble-row{display:flex; align-items:flex-end; gap:8px; max-width:74%;}
|
||||
.msg-bubble-row.mine{align-self:flex-end; flex-direction:row-reverse;}
|
||||
.msg-bubble-av{width:28px; height:28px; border-radius:50%; object-fit:cover; flex:0 0 auto; margin-bottom:2px;}
|
||||
.msg-bubble-col{display:flex; flex-direction:column; min-width:0;}
|
||||
.msg-bubble-row.mine .msg-bubble-col{align-items:flex-end;}
|
||||
.msg-bubble-sender{font-size:12px; opacity:.6; margin:0 4px 2px;}
|
||||
.msg-bubble{background:rgba(127,127,127,.14); padding:9px 13px; border-radius:14px; font-size:14px; line-height:1.4; white-space:pre-wrap; word-break:break-word;}
|
||||
.msg-bubble-row.mine .msg-bubble{background:var(--card-hover-bg,#e85d2f); color:#fff;}
|
||||
.msg-bubble-row.system{align-self:center; max-width:90%;}
|
||||
.msg-bubble-row.system .msg-bubble{background:none; opacity:.6; font-size:12px; padding:2px;}
|
||||
.msg-bubble-time{font-size:11px; opacity:.5; margin:2px 4px 0;}
|
||||
.msg-conv-foot{display:flex; gap:10px; padding:14px 16px; border-top:1px solid rgba(127,127,127,.2);}
|
||||
.msg-conv-foot textarea{flex:1 1 auto; resize:none; border-radius:10px; border:1px solid rgba(127,127,127,.3); background:transparent; color:inherit; font:inherit; font-size:14px; padding:10px 12px; max-height:120px;}
|
||||
.msg-conv-foot button{background:var(--card-hover-bg,#e85d2f); color:#fff; border:none; border-radius:10px; padding:0 18px; font-weight:600; cursor:pointer; font-family:inherit;}
|
||||
.msg-conv-placeholder{flex:1 1 auto; display:flex; align-items:center; justify-content:center; opacity:.55; font-size:14px; text-align:center; padding:24px;}
|
||||
.msg-new-panel{position:fixed; inset:0; background:rgba(0,0,0,.5); display:none; align-items:center; justify-content:center; z-index:50;}
|
||||
.msg-new-panel.open{display:flex;}
|
||||
.msg-new-card{background:var(--bg,#1b1b1f); border-radius:14px; padding:20px; width:400px; max-width:92vw; max-height:85vh; overflow-y:auto;}
|
||||
.msg-new-card h2{margin:0 0 14px; font-size:18px;}
|
||||
.msg-new-card label{display:block; font-size:13px; opacity:.75; margin:12px 0 5px;}
|
||||
.msg-new-card input[type=text]{width:100%; box-sizing:border-box; padding:9px 11px; border-radius:8px; border:1px solid rgba(127,127,127,.35); background:transparent; color:inherit; font:inherit; font-size:14px;}
|
||||
.msg-new-card textarea{width:100%; box-sizing:border-box; padding:9px 11px; border-radius:8px; border:1px solid rgba(127,127,127,.35); background:transparent; color:inherit; font:inherit; font-size:14px; min-height:60px; resize:vertical;}
|
||||
.msg-chips{display:flex; flex-wrap:wrap; gap:6px; margin-top:8px;}
|
||||
.msg-chip{display:flex; align-items:center; gap:6px; background:rgba(127,127,127,.18); border-radius:999px; padding:4px 6px 4px 10px; font-size:13px;}
|
||||
.msg-chip button{background:none; border:none; color:inherit; cursor:pointer; opacity:.6; font-size:15px; line-height:1; padding:0 2px;}
|
||||
.msg-suggest{margin-top:6px; max-height:160px; overflow-y:auto; border-radius:8px; border:1px solid rgba(127,127,127,.2); display:none;}
|
||||
.msg-suggest.open{display:block;}
|
||||
.msg-suggest-row{display:flex; align-items:center; gap:8px; padding:7px 10px; cursor:pointer; font-size:13px;}
|
||||
.msg-suggest-row:hover{background:rgba(127,127,127,.12);}
|
||||
.msg-suggest-row img{width:26px; height:26px; border-radius:50%; object-fit:cover;}
|
||||
.msg-new-actions{display:flex; justify-content:flex-end; gap:10px; margin-top:18px;}
|
||||
.msg-new-actions button{font-family:inherit; font-size:14px; padding:9px 16px; border-radius:8px; border:none; cursor:pointer;}
|
||||
.msg-new-cancel{background:rgba(127,127,127,.2); color:inherit;}
|
||||
.msg-new-send{background:var(--card-hover-bg,#e85d2f); color:#fff; font-weight:600;}
|
||||
.msg-new-send:disabled{opacity:.5; cursor:not-allowed;}
|
||||
.msg-err{color:#e74c3c; font-size:13px; margin-top:10px; min-height:16px;}
|
||||
@media (max-width: 760px){
|
||||
.msg-page{height:calc(100vh - var(--topbar-h));}
|
||||
.msg-list{width:100%; border-right:none;}
|
||||
.msg-list.has-open{display:none;}
|
||||
.msg-conv{display:none;}
|
||||
.msg-conv.open{display:flex;}
|
||||
.msg-conv-back{display:inline-block !important;}
|
||||
}
|
||||
.msg-conv-back{display:none; background:none; border:none; color:inherit; font-size:20px; cursor:pointer; padding:0 4px 0 0;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="msg-page">
|
||||
<section class="msg-list" id="msgList" aria-label="Conversations">
|
||||
<div class="msg-list-head">
|
||||
<h1>Messages</h1>
|
||||
<button class="msg-new-btn" id="msgNewBtn">New message</button>
|
||||
</div>
|
||||
<div id="msgThreads"><div class="msg-empty">Loading...</div></div>
|
||||
</section>
|
||||
|
||||
<section class="msg-conv" id="msgConv">
|
||||
<div class="msg-conv-placeholder" id="msgPlaceholder">Pick a conversation, or start a new one.</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="msg-new-panel" id="msgNewPanel">
|
||||
<div class="msg-new-card">
|
||||
<h2>New message</h2>
|
||||
<label for="msgNewTo">To</label>
|
||||
<input type="text" id="msgNewTo" placeholder="Search a username..." autocomplete="off">
|
||||
<div class="msg-suggest" id="msgSuggest"></div>
|
||||
<div class="msg-chips" id="msgChips"></div>
|
||||
<label for="msgNewName" id="msgNewNameLabel" hidden>Group name (optional)</label>
|
||||
<input type="text" id="msgNewName" placeholder="Group name" hidden>
|
||||
<label for="msgNewText">Message</label>
|
||||
<textarea id="msgNewText" placeholder="Say something..."></textarea>
|
||||
<div class="msg-err" id="msgNewErr"></div>
|
||||
<div class="msg-new-actions">
|
||||
<button class="msg-new-cancel" id="msgNewCancel">Cancel</button>
|
||||
<button class="msg-new-send" id="msgNewSend">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
<script src="messages.js?v=20261008c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,249 @@
|
||||
// ───── MESSAGES PAGE (/messages) ─────
|
||||
// DMs and group chats, backed by SiteChatController -> the same ChatDB the game client uses. A message sent here shows up
|
||||
// in-game (ChatDB.Push notifies the game's hub) and a message sent in-game shows up here on the next poll.
|
||||
(function () {
|
||||
const threadsEl = document.getElementById('msgThreads');
|
||||
if (!threadsEl) return;
|
||||
|
||||
const convEl = document.getElementById('msgConv');
|
||||
const placeholderEl = document.getElementById('msgPlaceholder');
|
||||
const listEl = document.getElementById('msgList');
|
||||
|
||||
let openId = null;
|
||||
let lastMessageId = 0;
|
||||
let pollTimer = null;
|
||||
let listTimer = null;
|
||||
let picked = []; // [{id, username, display_name, profile_image}]
|
||||
let meId = null; // fetched fresh, not read from app.js's module-scoped CAMPUS_ME
|
||||
let meUser = null; // same shape as a thread member (id/username/display_name/profile_image), for the avatar on my own bubbles
|
||||
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||
// profile_image comes back as either a ready /img/ URL or a bare filename (same shape /api/site/profile/{u} uses - see app.js's myPfpUrl).
|
||||
function avatar(u) {
|
||||
const raw = u && u.profile_image;
|
||||
if (raw) return /^https?:|^\//.test(raw) ? raw : (((window.CAMPUS_DATA && window.CAMPUS_DATA.imgBase) || '/img/') + raw);
|
||||
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent((u && u.username) || 'player')}`;
|
||||
}
|
||||
function name(u) { return (u && (u.display_name || u.username)) || 'Player'; }
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '';
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(ms / 60000);
|
||||
if (m < 1) return 'now';
|
||||
if (m < 60) return m + 'm';
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return h + 'h';
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return d + 'd';
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const r = await fetch('/api/site/chat/' + path, Object.assign({ credentials: 'same-origin' }, opts));
|
||||
if (r.status === 401) { location.href = '/login'; throw new Error('signed out'); }
|
||||
const d = await r.json().catch(() => ({ ok: false }));
|
||||
return d;
|
||||
}
|
||||
|
||||
function threadTitle(t) {
|
||||
if (t.isGroup) return t.name || t.members.map(name).join(', ');
|
||||
return t.members.length ? name(t.members[0]) : 'Player';
|
||||
}
|
||||
|
||||
// ---------------- thread list ----------------
|
||||
|
||||
async function loadThreads(keepScroll) {
|
||||
const d = await api('thread');
|
||||
if (!d.ok) { threadsEl.innerHTML = '<div class="msg-empty">Could not load your messages.</div>'; return; }
|
||||
if (!d.threads.length) { threadsEl.innerHTML = '<div class="msg-empty">No conversations yet. Click "New message" to start one.</div>'; return; }
|
||||
threadsEl.innerHTML = d.threads.map(t => {
|
||||
const m = t.members[0];
|
||||
const second = t.isGroup && t.members[1] ? `<img class="msg-av-2" src="${avatar(t.members[1])}" alt="">` : '';
|
||||
return `<button class="msg-thread${t.unread ? ' unread' : ''}${t.id === openId ? ' active' : ''}" data-id="${t.id}">
|
||||
<span class="msg-thread-avs"><img src="${avatar(m)}" alt="">${second}</span>
|
||||
<span class="msg-thread-meta">
|
||||
<span class="msg-thread-name">${esc(threadTitle(t))}</span>
|
||||
<span class="msg-thread-prev">${t.latestText ? esc(t.latestText) : 'No messages yet'}${t.latestAt ? ' · ' + timeAgo(t.latestAt) : ''}</span>
|
||||
</span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
threadsEl.querySelectorAll('.msg-thread').forEach(b => b.addEventListener('click', () => openThread(Number(b.dataset.id))));
|
||||
}
|
||||
|
||||
// ---------------- conversation ----------------
|
||||
|
||||
// System notices carry raw "Player <@U123>" tags (the format the game client itself resolves, e.g. "Player <@U6> left") -
|
||||
// replace them with a name using whoever is in this thread, so the website doesn't leak the internal tag to a reader.
|
||||
function resolveTags(text, thread) {
|
||||
const who = idStr => { const id = Number(idStr); if (id === meId) return 'You'; const u = thread.members.find(m => m.id === id); return u ? name(u) : 'A player'; };
|
||||
return text.replace(/Player <@U(\d+)>/g, (whole, idStr) => who(idStr)) // "Player <@U6> left" -> "You left" / "Alice left"
|
||||
.replace(/<@U(\d+)>/g, (whole, idStr) => who(idStr)); // any tag not preceded by "Player " on its own
|
||||
}
|
||||
|
||||
function renderMessages(thread, messages) {
|
||||
const body = document.getElementById('msgBody');
|
||||
if (!body) return;
|
||||
const atBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 60;
|
||||
body.innerHTML = messages.map(m => {
|
||||
if (m.system) return `<div class="msg-bubble-row system"><div class="msg-bubble">${esc(resolveTags(m.text, thread))}</div></div>`;
|
||||
const mine = meId != null && m.senderId === meId;
|
||||
const who = mine ? meUser : m.sender;
|
||||
return `<div class="msg-bubble-row${mine ? ' mine' : ''}">
|
||||
<img class="msg-bubble-av" src="${avatar(who)}" alt="">
|
||||
<div class="msg-bubble-col">
|
||||
${thread.isGroup && !mine ? `<span class="msg-bubble-sender">${esc(name(m.sender))}</span>` : ''}
|
||||
<div class="msg-bubble">${esc(m.text)}</div>
|
||||
<span class="msg-bubble-time">${timeAgo(m.at)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (atBottom || messages.length <= 20) body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
|
||||
async function openThread(id) {
|
||||
openId = id;
|
||||
listEl.classList.add('has-open');
|
||||
convEl.classList.add('open');
|
||||
const d = await api('thread/' + id);
|
||||
if (!d.ok) { convEl.innerHTML = `<div class="msg-conv-placeholder">Could not open that conversation.</div>`; return; }
|
||||
lastMessageId = d.messages.length ? d.messages[d.messages.length - 1].id : 0;
|
||||
convEl.innerHTML = `
|
||||
<div class="msg-conv-head"><button class="msg-conv-back" id="msgBack" aria-label="Back">←</button>${esc(threadTitle(d.thread))}</div>
|
||||
<div class="msg-conv-body" id="msgBody"></div>
|
||||
<div class="msg-conv-foot">
|
||||
<textarea id="msgInput" placeholder="Message..." rows="1"></textarea>
|
||||
<button id="msgSend">Send</button>
|
||||
</div>`;
|
||||
renderMessages(d.thread, d.messages);
|
||||
document.getElementById('msgBack').addEventListener('click', closeConv);
|
||||
const input = document.getElementById('msgInput');
|
||||
const send = async () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = ''; input.style.height = 'auto';
|
||||
const r = await api('thread/' + id + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) });
|
||||
// Don't bump lastMessageId here - pollOnce() below reads it to ask "what's newer than this?", so setting it to the
|
||||
// message we just sent would make that message look already-seen and it would never get rendered.
|
||||
if (r.ok) { pollOnce(d.thread); loadThreads(); }
|
||||
};
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
|
||||
input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = Math.min(120, input.scrollHeight) + 'px'; });
|
||||
document.getElementById('msgSend').addEventListener('click', send);
|
||||
|
||||
loadThreads();
|
||||
restartPoll(d.thread);
|
||||
}
|
||||
|
||||
function closeConv() {
|
||||
openId = null;
|
||||
listEl.classList.remove('has-open');
|
||||
convEl.classList.remove('open');
|
||||
convEl.innerHTML = `<div class="msg-conv-placeholder" id="msgPlaceholder">Pick a conversation, or start a new one.</div>`;
|
||||
clearTimeout(pollTimer);
|
||||
}
|
||||
|
||||
async function pollOnce(thread) {
|
||||
if (!openId) return;
|
||||
const d = await api('thread/' + openId + '/since/' + lastMessageId);
|
||||
if (d.ok && d.messages.length) {
|
||||
lastMessageId = d.messages[d.messages.length - 1].id;
|
||||
const r = await api('thread/' + openId);
|
||||
if (r.ok) renderMessages(r.thread, r.messages);
|
||||
loadThreads();
|
||||
}
|
||||
}
|
||||
|
||||
function restartPoll(thread) {
|
||||
clearTimeout(pollTimer);
|
||||
const tick = () => { pollOnce(thread).finally(() => { if (openId) pollTimer = setTimeout(tick, 4000); }); };
|
||||
pollTimer = setTimeout(tick, 4000);
|
||||
}
|
||||
|
||||
// ---------------- new message ----------------
|
||||
|
||||
const panel = document.getElementById('msgNewPanel');
|
||||
const toInput = document.getElementById('msgNewTo');
|
||||
const suggestEl = document.getElementById('msgSuggest');
|
||||
const chipsEl = document.getElementById('msgChips');
|
||||
const nameInput = document.getElementById('msgNewName');
|
||||
const nameLabel = document.getElementById('msgNewNameLabel');
|
||||
const errEl = document.getElementById('msgNewErr');
|
||||
|
||||
function renderChips() {
|
||||
chipsEl.innerHTML = picked.map(u => `<span class="msg-chip"><img src="${avatar(u)}" alt="" style="width:18px;height:18px;border-radius:50%;">${esc(name(u))}<button type="button" data-id="${u.id}">×</button></span>`).join('');
|
||||
chipsEl.querySelectorAll('button').forEach(b => b.addEventListener('click', () => { picked = picked.filter(u => u.id != b.dataset.id); renderChips(); toggleGroupName(); }));
|
||||
}
|
||||
function toggleGroupName() {
|
||||
const show = picked.length > 1;
|
||||
nameInput.hidden = !show; nameLabel.hidden = !show;
|
||||
}
|
||||
function pick(u) {
|
||||
if (picked.some(p => p.id === u.id)) return;
|
||||
picked.push(u); renderChips(); toggleGroupName();
|
||||
toInput.value = ''; suggestEl.classList.remove('open'); suggestEl.innerHTML = '';
|
||||
}
|
||||
|
||||
let suggestTimer = null;
|
||||
toInput.addEventListener('input', () => {
|
||||
clearTimeout(suggestTimer);
|
||||
const q = toInput.value.trim();
|
||||
if (!q) { suggestEl.classList.remove('open'); return; }
|
||||
suggestTimer = setTimeout(async () => {
|
||||
const d = await api('lookup?username=' + encodeURIComponent(q));
|
||||
const rows = [];
|
||||
if (d.ok && d.user) rows.push(d.user);
|
||||
suggestEl.innerHTML = rows.length ? rows.map(u => `<div class="msg-suggest-row" data-id="${u.id}"><img src="${avatar(u)}" alt="">${esc(name(u))} <span style="opacity:.6;">@${esc(u.username)}</span></div>`).join('')
|
||||
: `<div class="msg-suggest-row" style="opacity:.6; cursor:default;">No player found</div>`;
|
||||
suggestEl.classList.add('open');
|
||||
suggestEl.querySelectorAll('.msg-suggest-row[data-id]').forEach(row => row.addEventListener('click', () => {
|
||||
const u = rows.find(r => String(r.id) === row.dataset.id);
|
||||
if (u) pick(u);
|
||||
}));
|
||||
}, 250);
|
||||
});
|
||||
|
||||
async function loadFriendsIntoSuggest() {
|
||||
const d = await api('friends');
|
||||
if (!d.ok || !d.friends.length) return;
|
||||
suggestEl.innerHTML = d.friends.map(u => `<div class="msg-suggest-row" data-id="${u.id}"><img src="${avatar(u)}" alt="">${esc(name(u))} <span style="opacity:.6;">@${esc(u.username)}</span></div>`).join('');
|
||||
suggestEl.classList.add('open');
|
||||
suggestEl.querySelectorAll('.msg-suggest-row[data-id]').forEach(row => row.addEventListener('click', () => {
|
||||
const u = d.friends.find(r => String(r.id) === row.dataset.id);
|
||||
if (u) pick(u);
|
||||
}));
|
||||
}
|
||||
|
||||
document.getElementById('msgNewBtn').addEventListener('click', () => {
|
||||
picked = []; renderChips(); toggleGroupName();
|
||||
toInput.value = ''; nameInput.value = ''; document.getElementById('msgNewText').value = ''; errEl.textContent = '';
|
||||
panel.classList.add('open');
|
||||
loadFriendsIntoSuggest();
|
||||
});
|
||||
document.getElementById('msgNewCancel').addEventListener('click', () => panel.classList.remove('open'));
|
||||
panel.addEventListener('click', e => { if (e.target === panel) panel.classList.remove('open'); });
|
||||
|
||||
document.getElementById('msgNewSend').addEventListener('click', async () => {
|
||||
if (!picked.length) { errEl.textContent = 'Add at least one person.'; return; }
|
||||
const btn = document.getElementById('msgNewSend');
|
||||
btn.disabled = true; errEl.textContent = '';
|
||||
const body = { Ids: picked.map(u => u.id), Name: nameInput.value.trim() || null, Message: document.getElementById('msgNewText').value.trim() || null };
|
||||
const d = await api('thread', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
btn.disabled = false;
|
||||
if (!d.ok) { errEl.textContent = d.error || 'Could not start that conversation.'; return; }
|
||||
panel.classList.remove('open');
|
||||
await loadThreads();
|
||||
openThread(d.thread.id);
|
||||
});
|
||||
|
||||
// ---------------- boot ----------------
|
||||
|
||||
fetch('/api/site/me', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : null).then(d => {
|
||||
if (!d || !d.ok) return;
|
||||
meId = d.user.id; meUser = d.user;
|
||||
const open = document.getElementById('msgBody'); if (open && openId) openThread(openId); // repaint "mine" bubbles once we know who "me" is
|
||||
}).catch(() => {});
|
||||
loadThreads();
|
||||
listTimer = setInterval(() => { if (!document.hidden) loadThreads(); }, 15000);
|
||||
window.addEventListener('beforeunload', () => { clearInterval(listTimer); clearTimeout(pollTimer); });
|
||||
})();
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>News Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="updates-page">
|
||||
<header class="updates-head">
|
||||
<h1 id="updatesTitle">Updates</h1>
|
||||
<p id="updatesBlurb"></p>
|
||||
</header>
|
||||
<div id="updatesList" class="updates-list" data-updates="news"></div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Patch Notes Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="updates-page">
|
||||
<header class="updates-head">
|
||||
<h1 id="updatesTitle">Updates</h1>
|
||||
<p id="updatesBlurb"></p>
|
||||
</header>
|
||||
<div id="updatesList" class="updates-list" data-updates="patch"></div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profile Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<!-- PROFILE -->
|
||||
<main id="main" class="profile-page">
|
||||
<div class="profile-banner"></div>
|
||||
|
||||
<div class="profile-layout">
|
||||
<!-- LEFT INFO SIDEBAR -->
|
||||
<aside class="profile-side">
|
||||
<img class="profile-pic" id="profilePic" alt="">
|
||||
<h1 class="profile-username" id="profileUsername">Username</h1>
|
||||
<span class="profile-handle" id="profileHandle">@handle</span>
|
||||
<div class="profile-actions" id="profileActions"></div>
|
||||
|
||||
<div class="profile-status" id="profileStatus" hidden><span class="status-dot"></span><span class="status-text"></span></div>
|
||||
<a class="spotify-now profile-spotify" id="profileSpotify" hidden target="_blank" rel="noopener"></a>
|
||||
<div class="profile-socials" id="profileSocials" hidden></div>
|
||||
<p class="profile-bio" id="profileBio">this is my bio</p>
|
||||
<div class="profile-joined" id="profileJoined">
|
||||
<svg viewBox="0 0 20 20" fill="none" width="15" height="15"><rect x="3" y="4" width="14" height="13" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M3 8h14M7 2.5v3M13 2.5v3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
<span class="joined-text"></span>
|
||||
</div>
|
||||
|
||||
<div class="profile-statbox">
|
||||
<div class="statbox-row" id="statFriendsRow"><strong id="statFriends">16</strong> Friends</div>
|
||||
<div class="statbox-row" id="statSubsRow"><strong id="statSubs">2</strong> Subscribers</div>
|
||||
<div class="statbox-sub" id="profileSubscribe"></div>
|
||||
</div>
|
||||
|
||||
<div class="friend-panel" id="friendPanel"></div>
|
||||
<div id="profileReportWrap"></div>
|
||||
</aside>
|
||||
|
||||
<!-- RIGHT MAIN -->
|
||||
<section class="profile-main">
|
||||
<div class="profile-tabs">
|
||||
<button class="ptab active" data-tab="photos" title="Photos they took" aria-label="Photos they took"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="15" rx="2.5" stroke="currentColor" stroke-width="1.7"/><circle cx="9" cy="11" r="2" stroke="currentColor" stroke-width="1.6"/><path d="m4 18 5-4 4 3 3-3 4 4" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></button>
|
||||
<button class="ptab" data-tab="tagged" title="Tagged in" aria-label="Photos they're tagged in"><svg viewBox="0 0 24 24" fill="none"><path d="M20.6 13.4 12 22l-8.6-8.6a2 2 0 0 1 0-2.8l7.2-7.2a2 2 0 0 1 1.4-.6H19a2 2 0 0 1 2 2v6.2a2 2 0 0 1-.4 1.4Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><circle cx="16.5" cy="7.5" r="1.4" fill="currentColor"/></svg></button>
|
||||
<button class="ptab" data-tab="rooms" title="Rooms" aria-label="Rooms"><svg viewBox="0 0 24 24" fill="none"><path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-8Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></button>
|
||||
<button class="ptab" data-tab="events" title="Events" aria-label="Events"><svg viewBox="0 0 24 24" fill="none"><rect x="4" y="5" width="16" height="15" rx="2.5" stroke="currentColor" stroke-width="1.7"/><path d="M4 10h16M8 3v4M16 3v4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="profile-panel active" data-panel="photos">
|
||||
<div class="profile-photos" id="profilePhotos"></div>
|
||||
</div>
|
||||
<div class="profile-panel" data-panel="tagged">
|
||||
<div class="profile-photos" id="profilePhotosTagged"></div>
|
||||
</div>
|
||||
<div class="profile-panel" data-panel="rooms">
|
||||
<div class="profile-rooms" id="profileRooms"></div>
|
||||
</div>
|
||||
<div class="profile-panel" data-panel="events">
|
||||
<div class="profile-events" id="profileEvents"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Room Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#roomPage">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main class="room-page" id="roomPage">
|
||||
<section class="room-hero">
|
||||
<div class="room-cover" id="roomCover"></div>
|
||||
<div class="room-side">
|
||||
<h1 class="room-name" id="roomName">^Room</h1>
|
||||
<a class="room-by" id="roomBy" href="#">by creator</a>
|
||||
<div class="room-stats" id="roomStats"></div>
|
||||
<p class="room-desc" id="roomDesc"></p>
|
||||
<a href="/download" class="btn-dl room-play">Play</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="room-tabs">
|
||||
<button class="room-tab active" data-panel="photos"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></svg>Photos</button>
|
||||
<button class="room-tab" data-panel="events"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>Events</button>
|
||||
</div>
|
||||
<section class="room-panel active" data-panel="photos"><div class="room-feed" id="roomPhotos"><div class="feed-loading">Loading photos…</div></div></section>
|
||||
<section class="room-panel" data-panel="events"><div class="feed-loading">No events scheduled in this room.</div></section>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rooms - Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="rooms-page">
|
||||
<div class="rooms-head">
|
||||
<div class="rooms-tabs" role="tablist" aria-label="Room lists">
|
||||
<button class="rtab active" data-tab="hot" role="tab" aria-selected="true">Hot</button>
|
||||
<button class="rtab" data-tab="new" role="tab" aria-selected="false">New</button>
|
||||
<button class="rtab" data-tab="rro" role="tab" aria-selected="false">Originals</button>
|
||||
</div>
|
||||
<label class="rooms-filter">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" 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" id="roomsFilter" placeholder="Filter rooms…" autocomplete="off">
|
||||
</label>
|
||||
<span class="rooms-count" id="roomsCount"></span>
|
||||
</div>
|
||||
<div class="rooms-grid" id="roomsGrid">
|
||||
<div class="feed-loading">Loading rooms…</div>
|
||||
</div>
|
||||
<div class="rooms-more">
|
||||
<button class="btn-more" id="roomsMore" hidden>Show more</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
// ───── SETTINGS > SECURITY: two-factor authentication ─────
|
||||
// Backed by SiteController's /api/site/2fa/* (TwoFactorDB - the same store the admin panel's own 2FA uses, one row per
|
||||
// account). QR rendering is 100% client-side (qrcode-generator, loaded just before this file) - the secret never leaves
|
||||
// the browser except in the /setup response itself, so there's no third party in the loop.
|
||||
(function () {
|
||||
const stateText = document.getElementById('tfaStatusText');
|
||||
if (!stateText) return; // not on this page
|
||||
|
||||
const onBtn = document.getElementById('tfaOnBtn'), offBtn = document.getElementById('tfaOffBtn');
|
||||
const setupBox = document.getElementById('tfaSetup'), recoveryBox = document.getElementById('tfaRecovery');
|
||||
const qrBox = document.getElementById('tfaQr'), secretEl = document.getElementById('tfaSecret');
|
||||
const codeInput = document.getElementById('tfaCode'), setupErr = document.getElementById('tfaSetupError');
|
||||
|
||||
function show(which) {
|
||||
onBtn.style.display = which === 'off' ? 'inline-block' : 'none';
|
||||
offBtn.style.display = which === 'on' ? 'inline-block' : 'none';
|
||||
setupBox.style.display = which === 'setup' ? 'block' : 'none';
|
||||
recoveryBox.style.display = which === 'recovery' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/site/2fa/status', { credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (!d.ok) { stateText.textContent = 'Log in to manage this.'; return; }
|
||||
stateText.textContent = d.enabled ? 'On - a code from your app is needed to log in.' : 'Off - your password alone signs you in.';
|
||||
show(d.enabled ? 'on' : 'off');
|
||||
} catch { stateText.textContent = 'Could not load.'; }
|
||||
}
|
||||
|
||||
onBtn.addEventListener('click', async () => {
|
||||
const r = await fetch('/api/site/2fa/setup', { method: 'POST', credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (!d.ok) { alert(d.error || 'Could not start setup.'); return; }
|
||||
secretEl.textContent = d.secret;
|
||||
qrBox.innerHTML = '';
|
||||
try {
|
||||
const qr = qrcode(0, 'M'); // type 0 = auto-size for the data length
|
||||
qr.addData(d.otpauth);
|
||||
qr.make();
|
||||
qrBox.innerHTML = qr.createSvgTag({ scalable: true });
|
||||
} catch { qrBox.innerHTML = '<div style="color:#333; font-size:12px; padding:8px;">QR unavailable - use the key.</div>'; }
|
||||
codeInput.value = ''; setupErr.textContent = '';
|
||||
show('setup');
|
||||
});
|
||||
|
||||
document.getElementById('tfaCancelBtn').addEventListener('click', () => show('off'));
|
||||
|
||||
document.getElementById('tfaConfirmBtn').addEventListener('click', async () => {
|
||||
const code = codeInput.value.trim();
|
||||
if (!code) return;
|
||||
const r = await fetch('/api/site/2fa/confirm', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) { setupErr.textContent = d.error || 'Wrong code.'; return; }
|
||||
document.getElementById('tfaRecoveryList').textContent = d.recoveryCodes.join('\n');
|
||||
show('recovery');
|
||||
});
|
||||
|
||||
document.getElementById('tfaRecoveryDoneBtn').addEventListener('click', () => { show('on'); load(); });
|
||||
|
||||
offBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Turn off two-factor authentication? Your password alone will sign you in again.')) return;
|
||||
await fetch('/api/site/2fa/disable', { method: 'POST', credentials: 'same-origin' });
|
||||
load();
|
||||
});
|
||||
|
||||
// Only the Security tab needs this, and it's cheap - load once the page is ready rather than waiting for the tab click.
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
// ---- SETTINGS > DO NOT DISTURB ----
|
||||
// Backed by SiteDndController (/api/site/dnd). While it is on, invites from people you have not favourited are refused and
|
||||
// "friend is online" notices stop. It ends by itself. Plain ASCII only.
|
||||
(function () {
|
||||
const box = document.getElementById('dndStatus');
|
||||
if (!box) return; // not on this page
|
||||
const msg = document.getElementById('dndMsg');
|
||||
const buttons = Array.from(document.querySelectorAll('[data-dnd]'));
|
||||
let timer = null;
|
||||
|
||||
function describe(s) {
|
||||
if (!s.active) return 'Off. You get invites and friend notices as normal.';
|
||||
const m = s.minutesLeft;
|
||||
const left = m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min';
|
||||
return 'On - ' + left + ' left. Only your favourites can invite you.';
|
||||
}
|
||||
|
||||
function show(s) {
|
||||
box.textContent = describe(s);
|
||||
box.style.fontWeight = s.active ? '600' : '';
|
||||
clearTimeout(timer);
|
||||
if (s.active) timer = setTimeout(load, 30000); // keep the countdown honest
|
||||
}
|
||||
|
||||
async function call(method, body) {
|
||||
const r = await fetch('/api/site/dnd', {
|
||||
method, credentials: 'same-origin',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const s = await call('GET');
|
||||
if (!s.ok) { box.textContent = 'Log in to use Do Not Disturb.'; buttons.forEach(b => b.disabled = true); return; }
|
||||
show(s);
|
||||
} catch (e) { box.textContent = 'Could not load Do Not Disturb.'; }
|
||||
}
|
||||
|
||||
buttons.forEach(b => b.addEventListener('click', async () => {
|
||||
msg.textContent = '';
|
||||
try {
|
||||
const s = await call('POST', { minutes: parseInt(b.dataset.dnd, 10) });
|
||||
if (!s.ok) { msg.textContent = s.error || 'Could not change it.'; return; }
|
||||
show(s);
|
||||
} catch (e) { msg.textContent = 'Could not change it.'; }
|
||||
}));
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,92 @@
|
||||
// ---- SETTINGS > QUICK CHAT: your own phrases for the in-game quick chat menu ----
|
||||
// Backed by SiteQuickChatController (/api/site/quickchat). Four sets, five phrases each, matching the four tabs of the in-game panel.
|
||||
// An empty box keeps the original phrase. Plain ASCII only, like every website write.
|
||||
(function () {
|
||||
const setSel = document.getElementById('qcSet');
|
||||
if (!setSel) return; // not on this page
|
||||
|
||||
const slotsBox = document.getElementById('qcSlots');
|
||||
const msg = document.getElementById('qcMsg');
|
||||
const saveBtn = document.getElementById('qcSave');
|
||||
const resetBtn = document.getElementById('qcReset');
|
||||
let data = null; // the last answer from the server
|
||||
|
||||
function say(text, bad) {
|
||||
msg.textContent = text || '';
|
||||
msg.style.color = bad ? '#c0392b' : '';
|
||||
}
|
||||
|
||||
function current() { return data && data.sets.find(s => s.key === setSel.value); }
|
||||
|
||||
function render() {
|
||||
const s = current();
|
||||
slotsBox.innerHTML = '';
|
||||
if (!s) return;
|
||||
for (let i = 0; i < s.defaults.length; i++) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'sfield';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = 'Phrase ' + (i + 1);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.maxLength = data.maxLength;
|
||||
input.id = 'qcSlot' + i;
|
||||
input.placeholder = s.defaults[i];
|
||||
input.value = s.mine[i] || '';
|
||||
wrap.appendChild(label);
|
||||
wrap.appendChild(input);
|
||||
slotsBox.appendChild(wrap);
|
||||
}
|
||||
say('');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/site/quickchat', { credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (!d.ok) { say('Log in to change your quick chat.', true); saveBtn.disabled = true; resetBtn.disabled = true; return; }
|
||||
data = d;
|
||||
setSel.innerHTML = '';
|
||||
d.sets.forEach(s => {
|
||||
const o = document.createElement('option');
|
||||
o.value = s.key;
|
||||
o.textContent = s.label;
|
||||
setSel.appendChild(o);
|
||||
});
|
||||
render();
|
||||
} catch (e) { say('Could not load your quick chat.', true); }
|
||||
}
|
||||
|
||||
async function post(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const s = current();
|
||||
if (!s) return;
|
||||
const phrases = [];
|
||||
for (let i = 0; i < s.defaults.length; i++) phrases.push((document.getElementById('qcSlot' + i) || {}).value || '');
|
||||
const d = await post('/api/site/quickchat', { key: s.key, phrases });
|
||||
if (!d.ok) { say(d.error || 'Could not save.', true); return; }
|
||||
s.mine = d.mine;
|
||||
render();
|
||||
say('Saved. It shows the next time you start the game.');
|
||||
});
|
||||
|
||||
resetBtn.addEventListener('click', async () => {
|
||||
const s = current();
|
||||
if (!s) return;
|
||||
const d = await post('/api/site/quickchat/reset', { key: s.key });
|
||||
if (!d.ok) { say(d.error || 'Could not reset.', true); return; }
|
||||
s.mine = d.mine;
|
||||
render();
|
||||
say('Back to the original phrases. It shows the next time you start the game.');
|
||||
});
|
||||
|
||||
setSel.addEventListener('change', render);
|
||||
load();
|
||||
})();
|
||||
@@ -0,0 +1,322 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Settings Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
<style>
|
||||
.discord-card{display:flex;align-items:center;gap:14px;max-width:520px;padding:16px;
|
||||
border-radius:14px;background:rgba(88,101,242,.10);border:1px solid rgba(88,101,242,.35);}
|
||||
.discord-logo{width:44px;height:44px;border-radius:12px;background:#5865F2;flex:0 0 auto;
|
||||
display:flex;align-items:center;justify-content:center;}
|
||||
.discord-info{flex:1 1 auto;min-width:0;}
|
||||
.discord-title{font-weight:700;font-size:15px;}
|
||||
.discord-status{opacity:.75;font-size:14px;margin-top:2px;}
|
||||
.discord-link-btn{background:#5865F2;color:#fff;border:0;padding:10px 18px;border-radius:10px;
|
||||
font-weight:600;font-family:inherit;font-size:14px;cursor:pointer;flex:0 0 auto;}
|
||||
.discord-link-btn:hover{background:#4752c4;}
|
||||
.discord-unlink-btn{background:transparent;color:#c0392b;border:1px solid rgba(192,57,43,.5);
|
||||
padding:10px 16px;border-radius:10px;font-weight:600;font-family:inherit;font-size:14px;cursor:pointer;flex:0 0 auto;}
|
||||
.discord-unlink-btn:hover{background:rgba(192,57,43,.10);}
|
||||
.spotify-card{background:rgba(30,215,96,.10);border-color:rgba(30,215,96,.35);align-items:center;}
|
||||
.spotify-card .discord-logo,.spotify-card .discord-link-btn,.spotify-card .discord-unlink-btn{margin-top:2px;}
|
||||
.spotify-logo{background:#1DB954;}
|
||||
.spotify-link-btn{background:#1DB954;}
|
||||
.spotify-link-btn:hover{background:#169c46;}
|
||||
.spotify-card .spotify-now{margin-top:10px;}
|
||||
.spotify-vis[hidden]{display:none;}
|
||||
.svc-show{display:flex;align-items:center;gap:10px;margin-top:10px;font-size:13px;opacity:.9;}
|
||||
.svc-show .switch{width:38px;height:22px;} .svc-show .slider::before{width:16px;height:16px;} .svc-show .switch input:checked + .slider::before{transform:translateX(16px);}
|
||||
.svc-show[hidden]{display:none;}
|
||||
.discord-card .switch input:checked + .slider{background:#5865F2;}
|
||||
.spotify-card .switch input:checked + .slider{background:#1DB954;}
|
||||
.svc-card{margin-top:14px;}
|
||||
.svc-card .discord-status a{color:inherit;}
|
||||
.svc-youtube{background:rgba(255,0,0,.07);border-color:rgba(255,0,0,.3);} .svc-youtube .discord-logo,.svc-youtube .discord-link-btn,.svc-youtube .switch input:checked + .slider{background:#e60000;}
|
||||
.svc-tiktok{background:rgba(105,201,208,.08);border-color:rgba(105,201,208,.35);} .svc-tiktok .discord-logo,.svc-tiktok .discord-link-btn{background:#111;} .svc-tiktok .switch input:checked + .slider{background:#69C9D0;}
|
||||
.svc-x{background:rgba(127,127,127,.08);border-color:rgba(127,127,127,.3);} .svc-x .discord-logo,.svc-x .discord-link-btn{background:#000;} .svc-x .switch input:checked + .slider{background:#555;}
|
||||
.svc-twitch{background:rgba(145,70,255,.10);border-color:rgba(145,70,255,.35);} .svc-twitch .discord-logo,.svc-twitch .discord-link-btn,.svc-twitch .switch input:checked + .slider{background:#9146FF;}
|
||||
.svc-steam{background:rgba(27,40,56,.10);border-color:rgba(102,192,244,.35);} .svc-steam .discord-logo,.svc-steam .discord-link-btn{background:#171a21;} .svc-steam .switch input:checked + .slider{background:#66C0F4;}
|
||||
.svc-github{background:rgba(36,41,46,.08);border-color:rgba(127,127,127,.3);} .svc-github .discord-logo,.svc-github .discord-link-btn{background:#24292e;} .svc-github .switch input:checked + .slider{background:#6e7681;}
|
||||
.svc-reddit{background:rgba(255,69,0,.08);border-color:rgba(255,69,0,.35);} .svc-reddit .discord-logo,.svc-reddit .discord-link-btn,.svc-reddit .switch input:checked + .slider{background:#FF4500;}
|
||||
.svc-instagram{background:rgba(225,48,108,.08);border-color:rgba(225,48,108,.35);} .svc-instagram .discord-logo,.svc-instagram .discord-link-btn,.svc-instagram .switch input:checked + .slider{background:#E1306C;}
|
||||
.svc-card .discord-link-btn[disabled]{opacity:.5;cursor:not-allowed;}
|
||||
.youtube-card{background:rgba(255,0,0,.07);border-color:rgba(255,0,0,.3);}
|
||||
.youtube-logo{background:#FF0000;} .youtube-link-btn{background:#e60000;} .youtube-link-btn:hover{background:#b80000;}
|
||||
.tiktok-card{background:rgba(0,0,0,.06);border-color:rgba(0,0,0,.25);}
|
||||
.tiktok-logo{background:#111;} .tiktok-link-btn{background:#111;} .tiktok-link-btn:hover{background:#333;}
|
||||
.svc-handle{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:14px;}
|
||||
.svc-handle input{flex:1 1 auto;min-width:0;max-width:260px;padding:8px 10px;border-radius:8px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;font:inherit;font-size:14px;}
|
||||
.spotify-vis{display:flex;align-items:center;gap:10px;margin-top:10px;font-size:13px;opacity:.9;flex-wrap:wrap;}
|
||||
.spotify-vis select{font:inherit;font-size:13px;padding:6px 10px;border-radius:8px;border:1px solid rgba(128,128,140,.3);background:var(--card,#fff);color:inherit;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="settings-page">
|
||||
<div class="settings-userhead">
|
||||
<img class="su-av" id="suAv" alt="">
|
||||
<div class="su-id"><span class="su-name" id="suName">User</span><span class="su-handle" id="suHandle">@user</span></div>
|
||||
<div class="su-status" id="suStatus"><span class="su-dot"></span> </div>
|
||||
</div>
|
||||
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav">
|
||||
<button class="snav active" data-panel="profile">Profile</button>
|
||||
<button class="snav" data-panel="email">Email</button>
|
||||
<button class="snav" data-panel="password">Password</button>
|
||||
<button class="snav" data-panel="quickchat">Quick chat</button>
|
||||
<button class="snav" data-panel="dnd">Do not disturb</button>
|
||||
<button class="snav" data-panel="security">Security</button>
|
||||
<button class="snav" data-panel="account">Account</button>
|
||||
<button class="snav" data-panel="juniors">Juniors</button>
|
||||
<button class="snav" data-panel="services">Services</button>
|
||||
<button class="snav" data-panel="export">Export</button>
|
||||
<button class="snav snav-logout" id="setLogout">Logout</button>
|
||||
</nav>
|
||||
|
||||
<div class="settings-content">
|
||||
<!-- PROFILE -->
|
||||
<section class="spanel active" data-panel="profile">
|
||||
<h2>Profile Settings</h2>
|
||||
<div class="sfield"><label>Username</label><input type="text" id="setUsername" maxlength="24" placeholder="username"></div>
|
||||
<div class="sfield"><label>Display name</label><input type="text" id="setNickname" maxlength="24" placeholder="Your display name"></div>
|
||||
<div class="sfield"><label class="sreq">Bio</label><textarea id="setBio" rows="3" maxlength="200" placeholder="Tell people about yourself"></textarea></div>
|
||||
<div class="srow-save"><button class="btn-dl ssave" data-save="Profile">Save</button></div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- EMAIL -->
|
||||
<section class="spanel" data-panel="email">
|
||||
<h2>Email Settings</h2>
|
||||
<div class="sfield"><label for="acctEmail">Email address</label><input id="acctEmail" type="email" autocomplete="email" placeholder="you@example.com" maxlength="254"><p class="shint" id="acctEmailHint" style="color:var(--muted);font-size:.85rem;margin:6px 0 0">Used for account recovery and the emails below. Leave blank to remove it.</p></div>
|
||||
<div class="etog"><span>New Features</span><label class="switch"><input type="checkbox" checked><span class="slider"></span></label></div>
|
||||
<div class="etog"><span>Game Invites</span><label class="switch"><input type="checkbox" checked><span class="slider"></span></label></div>
|
||||
<div class="etog"><span>Friend Requests</span><div class="etog-right"><label class="switch"><input type="checkbox" checked><span class="slider"></span></label><select class="efreq"><option>Daily</option><option>Weekly</option><option>Never</option></select></div></div>
|
||||
<div class="etog"><span>Your Room Summary</span><div class="etog-right"><label class="switch"><input type="checkbox" checked><span class="slider"></span></label><select class="efreq"><option selected>Weekly</option><option>Daily</option><option>Never</option></select></div></div>
|
||||
<div class="etog"><span>New Subscription Rooms</span><div class="etog-right"><label class="switch"><input type="checkbox" checked><span class="slider"></span></label><select class="efreq"><option selected>Weekly</option><option>Daily</option><option>Never</option></select></div></div>
|
||||
<div class="etog"><span>Photos Of You</span><div class="etog-right"><label class="switch"><input type="checkbox" checked><span class="slider"></span></label><select class="efreq"><option>Daily</option><option>Weekly</option><option>Never</option></select></div></div>
|
||||
<div class="etog"><span>Event Invites</span><div class="etog-right"><label class="switch"><input type="checkbox" checked><span class="slider"></span></label><select class="efreq"><option>Daily</option><option>Weekly</option><option>Never</option></select></div></div>
|
||||
<div class="etog"><span>Event Reminders</span><label class="switch"><input type="checkbox" checked><span class="slider"></span></label></div>
|
||||
<div class="srow-save"><button class="btn-dl ssave" data-save="Email">Save</button></div>
|
||||
</section>
|
||||
|
||||
<!-- PASSWORD -->
|
||||
<section class="spanel" data-panel="password">
|
||||
<h2>Password Settings</h2>
|
||||
<div class="sfield"><label>Current Password</label><input id="pwCur" type="password" autocomplete="current-password"></div>
|
||||
<div class="sfield"><label>New Password</label><input id="pwNew" type="password" autocomplete="new-password"></div>
|
||||
<div class="sfield"><label>Confirm New Password</label><input id="pwConf" type="password" autocomplete="new-password"></div>
|
||||
<div class="srow-save"><button class="btn-dl ssave" data-save="Password">Save</button></div>
|
||||
</section>
|
||||
|
||||
<!-- QUICK CHAT: your own phrases for the in-game quick chat menu (settings-quickchat.js) -->
|
||||
<section class="spanel" data-panel="quickchat">
|
||||
<h2>Quick chat</h2>
|
||||
<p class="shint">Pick a set and change any of its phrases. Only you see your phrases in your game, and they show the next time you start the game. Leave a box empty to keep the original phrase. No commas, letters and numbers only, short, and nothing that is not allowed in chat.</p>
|
||||
<div class="sfield"><label for="qcSet">Set</label><select id="qcSet" class="sselect"></select></div>
|
||||
<div id="qcSlots"></div>
|
||||
<p class="shint" id="qcMsg"></p>
|
||||
<div class="srow-save"><button class="btn-dl ssave" id="qcSave" type="button">Save</button> <button class="btn-dl" id="qcReset" type="button">Back to the originals</button></div>
|
||||
</section>
|
||||
|
||||
<!-- DO NOT DISTURB: invites only from favourites for a while (settings-dnd.js) -->
|
||||
<section class="spanel" data-panel="dnd">
|
||||
<h2>Do not disturb</h2>
|
||||
<p class="shint">Turn this on for a while and only your favourites can invite you into a room, and you stop getting "friend is online" notices. It switches itself off when the time is up. Favourite a friend from your friends list to let them through.</p>
|
||||
<p class="shint" id="dndStatus">Checking…</p>
|
||||
<div class="srow-save" style="flex-wrap:wrap;gap:8px">
|
||||
<button class="btn-dl" type="button" data-dnd="60">1 hour</button>
|
||||
<button class="btn-dl" type="button" data-dnd="120">2 hours</button>
|
||||
<button class="btn-dl" type="button" data-dnd="240">4 hours</button>
|
||||
<button class="btn-dl" type="button" data-dnd="480">8 hours</button>
|
||||
<button class="btn-dl" type="button" data-dnd="1440">24 hours</button>
|
||||
<button class="btn-dl ssave" type="button" data-dnd="0">Turn off</button>
|
||||
</div>
|
||||
<p class="shint" id="dndMsg"></p>
|
||||
</section>
|
||||
|
||||
<!-- SECURITY: two-factor authentication -->
|
||||
<section class="spanel" data-panel="security">
|
||||
<h2>Security</h2>
|
||||
<p style="margin:-6px 0 14px;opacity:.75;font-size:14px">Two-factor authentication: a code from an app on your phone, on top of your password, so a leaked password alone can't sign in to your account.</p>
|
||||
|
||||
<div id="tfaState" class="discord-card" style="align-items:center;">
|
||||
<div class="discord-info"><div class="discord-title">Two-factor authentication</div><div class="discord-status" id="tfaStatusText">Checking…</div></div>
|
||||
<button id="tfaOnBtn" class="discord-link-btn" style="display:none;">Turn on</button>
|
||||
<button id="tfaOffBtn" class="discord-unlink-btn" style="display:none;">Turn off</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: scan or type the key into an authenticator app -->
|
||||
<div id="tfaSetup" style="display:none; margin-top:16px;">
|
||||
<p style="opacity:.85;">Scan this with an authenticator app (Google Authenticator, Authy, 1Password, ...) - or type the key in by hand if you can't scan.</p>
|
||||
<div style="display:flex; gap:20px; flex-wrap:wrap; align-items:flex-start; margin:14px 0;">
|
||||
<div id="tfaQr" style="width:180px; height:180px; background:#fff; border-radius:10px; padding:10px; box-sizing:border-box;"></div>
|
||||
<div style="flex:1; min-width:200px;">
|
||||
<div style="opacity:.7; font-size:13px; margin-bottom:4px;">Manual key</div>
|
||||
<code id="tfaSecret" style="display:block; word-break:break-all; background:rgba(127,127,127,.15); padding:10px 12px; border-radius:8px; user-select:all;"></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sfield"><label>Enter the 6-digit code it shows</label><input id="tfaCode" inputmode="numeric" placeholder="123456"></div>
|
||||
<div class="srow-save">
|
||||
<button class="btn-dl ssave" id="tfaConfirmBtn">Confirm</button>
|
||||
<button class="btn-dl" id="tfaCancelBtn" style="background:transparent; border:1px solid rgba(255,255,255,.2);">Cancel</button>
|
||||
</div>
|
||||
<p id="tfaSetupError" style="color:#ff6b6b; min-height:1em; margin-top:8px;"></p>
|
||||
</div>
|
||||
|
||||
<!-- After confirming: the one-time recovery codes -->
|
||||
<div id="tfaRecovery" style="display:none; margin-top:16px;">
|
||||
<p style="color:#f3c969;">Two-factor authentication is on. Save these recovery codes somewhere safe - each works once, instead of a code from your app, if you lose your phone. They won't be shown again.</p>
|
||||
<pre id="tfaRecoveryList" style="background:rgba(127,127,127,.15); padding:14px; border-radius:8px; line-height:1.8;"></pre>
|
||||
<div class="srow-save"><button class="btn-dl ssave" id="tfaRecoveryDoneBtn">Done</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ACCOUNT -->
|
||||
<section class="spanel" data-panel="account">
|
||||
<h2>Account Settings</h2>
|
||||
<p class="account-note">This will sign you out everywhere you're currently logged in.</p>
|
||||
<button class="btn-danger" id="logoutAll">Logout from all devices</button>
|
||||
</section>
|
||||
|
||||
<!-- JUNIORS -->
|
||||
<section class="spanel" data-panel="juniors">
|
||||
<h2>Juniors Settings</h2>
|
||||
<p class="juniors-empty">You don't have any juniors <span class="juniors-face">.</span></p>
|
||||
</section>
|
||||
|
||||
<!-- EXPORT -->
|
||||
<section class="spanel" data-panel="export">
|
||||
<h2>Export</h2>
|
||||
<p style="margin:-6px 0 14px;opacity:.75;font-size:14px">Download a copy of what's on your Campus account. Everything is put together in your browser - nothing is stored on the server.</p>
|
||||
<div class="export-row">
|
||||
<div class="export-info"><div class="export-title">Export Data</div><div class="export-sub">Your profile, avatar, items, friends, settings and progress as a .json file. Passwords, login tokens and private messages are never included.</div></div>
|
||||
<button class="btn-dl export-btn" id="exportData">Export Data</button>
|
||||
</div>
|
||||
<div class="export-row">
|
||||
<div class="export-info"><div class="export-title">Export Rooms</div><div class="export-sub">Rooms you created, with their saved builds, picture and settings, as a .zip. Only rooms you own are listed.</div></div>
|
||||
<button class="btn-dl export-btn" id="exportRooms">Export Rooms</button>
|
||||
</div>
|
||||
<div class="export-row">
|
||||
<div class="export-info"><div class="export-title">Export Photos</div><div class="export-sub">Every photo you took, and every photo you're in, as a .zip.</div></div>
|
||||
<button class="btn-dl export-btn" id="exportPhotos">Export Photos</button>
|
||||
</div>
|
||||
<p class="export-note" id="exportNote" hidden></p>
|
||||
</section>
|
||||
|
||||
<!-- SERVICES: Discord + Spotify -->
|
||||
<section class="spanel" data-panel="services">
|
||||
<h2>Services</h2>
|
||||
<p style="margin:-6px 0 14px;opacity:.75;font-size:14px">Connect other accounts to your Campus profile.</p>
|
||||
<div class="discord-card">
|
||||
<div class="discord-logo" aria-hidden="true">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="#fff"><path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.25.5a14.5 14.5 0 0 1 4.3 1.4 16.6 16.6 0 0 0-14.9 0A14.5 14.5 0 0 1 8.85 3.5L8.6 3a19.8 19.8 0 0 0-4.9 1.4C.6 9 .07 13.5.34 17.9a19.9 19.9 0 0 0 6 3l.5-.7c-.66-.25-1.28-.55-1.86-.9l.46-.36a14.2 14.2 0 0 0 12.12 0l.46.36c-.58.35-1.2.65-1.86.9l.5.7a19.9 19.9 0 0 0 6-3c.33-5.13-.53-9.6-3.86-13.5ZM8.3 15.3c-1.17 0-2.13-1.08-2.13-2.4 0-1.32.94-2.4 2.13-2.4s2.15 1.08 2.13 2.4c0 1.32-.95 2.4-2.13 2.4Zm7.4 0c-1.17 0-2.13-1.08-2.13-2.4 0-1.32.94-2.4 2.13-2.4s2.15 1.08 2.13 2.4c0 1.32-.94 2.4-2.13 2.4Z"/></svg>
|
||||
</div>
|
||||
<div class="discord-info">
|
||||
<div class="discord-title">Discord</div>
|
||||
<div class="discord-status" id="discordStatusText">Checking…</div>
|
||||
<label class="svc-show" data-svc="discord" hidden><span>Show on my profile</span><span class="switch"><input type="checkbox" class="svc-show-toggle" data-svc="discord"><span class="slider"></span></span></label>
|
||||
</div>
|
||||
<button id="discordLinkBtn" class="discord-link-btn" style="display:none;">Link Discord</button>
|
||||
<button id="discordUnlinkBtn" class="discord-unlink-btn" style="display:none;">Unlink</button>
|
||||
</div>
|
||||
|
||||
<div class="discord-card spotify-card" style="margin-top:14px">
|
||||
<div class="discord-logo spotify-logo" aria-hidden="true"><svg width="26" height="26" viewBox="0 0 24 24" fill="#fff"><path d="M12 1.5C6.2 1.5 1.5 6.2 1.5 12S6.2 22.5 12 22.5 22.5 17.8 22.5 12 17.8 1.5 12 1.5zm4.8 15.2a.65.65 0 0 1-.9.2c-2.5-1.5-5.6-1.9-9.3-1a.65.65 0 1 1-.3-1.3c4-.9 7.5-.5 10.3 1.2.3.2.4.6.2.9zm1.3-2.9a.8.8 0 0 1-1.1.3c-2.8-1.7-7.1-2.2-10.5-1.2a.8.8 0 1 1-.5-1.6c3.8-1.2 8.5-.6 11.8 1.4.4.2.5.7.3 1.1zm.1-3a1 1 0 0 1-1.3.3C13.6 9.2 8.3 9 5.1 10a1 1 0 1 1-.6-1.9c3.7-1.1 9.6-.9 13.4 1.4.5.3.6.9.3 1.3z"/></svg></div>
|
||||
<div class="discord-info">
|
||||
<div class="discord-title">Spotify</div>
|
||||
<div class="discord-status" id="spotifyStatusText">Checking…</div>
|
||||
<div class="spotify-now" id="spotifyNowPreview" hidden></div>
|
||||
<div class="spotify-vis" id="spotifyVisRow" hidden>
|
||||
<label for="spotifyVis">Show what I'm listening to</label>
|
||||
<select id="spotifyVis">
|
||||
<option value="0">Everyone</option>
|
||||
<option value="1">Friends</option>
|
||||
<option value="2">Favourites only</option>
|
||||
<option value="3">No one</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="svc-show" data-svc="spotify" hidden><span>Show on my profile</span><span class="switch"><input type="checkbox" class="svc-show-toggle" data-svc="spotify"><span class="slider"></span></span></label>
|
||||
</div>
|
||||
<button id="spotifyLinkBtn" class="discord-link-btn spotify-link-btn" style="display:none;">Link Spotify</button>
|
||||
<button id="spotifyUnlinkBtn" class="discord-unlink-btn" style="display:none;">Unlink</button>
|
||||
</div>
|
||||
|
||||
<!-- Every other service: sign in with it, we keep the verified handle. Rendered by initSocialCards(). -->
|
||||
<div id="socialCards"></div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
|
||||
<script src="settings-2fa.js?v=20261008a"></script>
|
||||
<script src="settings-quickchat.js?v=20261013b"></script>
|
||||
<script src="settings-dnd.js?v=20261013a"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create Account · Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<main id="main" class="login-wrap">
|
||||
<a href="/" class="login-card-logo">
|
||||
<span class="cat-shift"><img src="assets/cat-icon.png" alt="Campus" class="login-cat"
|
||||
onerror="this.outerHTML='<span class=logo-text style=font-size:2rem>campus</span>'"></span>
|
||||
<span class="brand-wrap"><span class="login-brand">Campus</span></span>
|
||||
</a>
|
||||
|
||||
<div class="login-card">
|
||||
<h1>Create your account</h1>
|
||||
<p class="login-sub">One Campus account, one device. Sign in with it in-game.</p>
|
||||
|
||||
<form id="signupForm" autocomplete="on">
|
||||
<label class="field">
|
||||
<span>Username (your @ and display name)</span>
|
||||
<input id="suUser" type="text" placeholder="yourname" autocomplete="username"
|
||||
maxlength="20" spellcheck="false" autocapitalize="none">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Date of birth</span>
|
||||
<input id="suDob" type="date" autocomplete="bday" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input id="suPass" type="password" placeholder="••••••••" autocomplete="new-password">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Re-enter password</span>
|
||||
<input id="suConfirm" type="password" placeholder="••••••••" autocomplete="new-password">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Email <span class="field-optional">(optional)</span></span>
|
||||
<input id="suEmail" type="email" placeholder="you@example.com" autocomplete="email">
|
||||
</label>
|
||||
|
||||
<!-- honeypot: hidden from humans, trips naive bots. Do not remove. -->
|
||||
<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">
|
||||
<label>Website<input id="suWebsite" type="text" tabindex="-1" autocomplete="off"></label>
|
||||
</div>
|
||||
|
||||
<p class="login-error" id="signupError"></p>
|
||||
|
||||
<button type="submit" class="btn-dl login-submit">Create account</button>
|
||||
</form>
|
||||
|
||||
<p class="login-foot">Already have an account? <a href="/login">Log in</a></p>
|
||||
</div>
|
||||
|
||||
<a href="/" class="dl-back">← Back to home</a>
|
||||
</main>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,292 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stats Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
<style>
|
||||
.stats-page{max-width:1100px;margin:0 auto;padding:calc(var(--topbar-h) + 28px) 24px 80px;}
|
||||
/* hero */
|
||||
.st-hero{position:relative;overflow:hidden;border-radius:22px;padding:26px 28px;color:#fff;background:var(--gradient-main);box-shadow:0 12px 34px rgba(0,0,0,.18);}
|
||||
.st-hero::after{content:'';position:absolute;inset:0;background:radial-gradient(circle at 18% 20%,rgba(255,255,255,.28),transparent 48%),radial-gradient(circle at 90% 110%,rgba(255,255,255,.16),transparent 40%);pointer-events:none;}
|
||||
.st-hero > *{position:relative;z-index:1;}
|
||||
.st-who{display:flex;align-items:center;gap:16px;}
|
||||
.st-who img{width:72px;height:72px;border-radius:50%;object-fit:cover;border:3px solid rgba(255,255,255,.7);box-shadow:0 6px 18px rgba(0,0,0,.25);background:rgba(255,255,255,.2);}
|
||||
.st-who h1{font-size:26px;font-weight:800;line-height:1.1;margin:0;}
|
||||
.st-who .sub{opacity:.9;font-size:14px;margin-top:4px;font-weight:600;}
|
||||
.st-lvl{display:inline-flex;align-items:center;gap:6px;background:rgba(255,255,255,.22);padding:3px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-left:8px;vertical-align:middle;}
|
||||
.st-tokens{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:22px;}
|
||||
.st-tok{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.28);backdrop-filter:blur(6px);border-radius:16px;padding:14px 16px;}
|
||||
.st-tok .v{font-size:30px;font-weight:800;letter-spacing:-.02em;line-height:1;display:flex;align-items:center;gap:8px;}
|
||||
.st-tok .v svg{width:22px;height:22px;flex:0 0 auto;}
|
||||
.st-tok .l{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;opacity:.85;margin-top:8px;}
|
||||
.st-tok .h{font-size:12px;opacity:.8;margin-top:4px;}
|
||||
/* sections */
|
||||
.st-sec{margin-top:30px;}
|
||||
.st-sec h2{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:0 0 12px;display:flex;align-items:center;gap:10px;}
|
||||
.st-sec h2::after{content:'';flex:1;height:1px;background:var(--line);}
|
||||
.st-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;}
|
||||
.st-box{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 16px 14px;box-shadow:0 2px 10px rgba(0,0,0,.05);transition:transform .15s,box-shadow .15s;}
|
||||
.st-box:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(0,0,0,.10);}
|
||||
.st-box .ic{width:34px;height:34px;border-radius:10px;display:flex;align-items:center;justify-content:center;background:var(--primary-soft);color:var(--primary);margin-bottom:12px;}
|
||||
.st-box .ic svg{width:18px;height:18px;}
|
||||
.st-box .v{font-size:26px;font-weight:800;letter-spacing:-.02em;line-height:1.05;}
|
||||
.st-box .l{color:var(--muted);font-size:13px;font-weight:600;margin-top:6px;}
|
||||
.st-note{color:var(--muted);font-size:13px;margin:-4px 0 12px;}
|
||||
/* sales */
|
||||
.sale-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;}
|
||||
.sale{display:grid;grid-template-columns:72px 1fr;gap:14px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:14px;box-shadow:0 2px 10px rgba(0,0,0,.05);}
|
||||
.sale img,.sale .ph{width:72px;height:72px;border-radius:14px;object-fit:cover;background:var(--gradient-cool);display:flex;align-items:center;justify-content:center;font-size:28px;box-shadow:0 4px 12px rgba(0,0,0,.14);}
|
||||
.sale .name{font-weight:800;font-size:15px;line-height:1.2;}
|
||||
.sale .meta{color:var(--muted);font-size:12.5px;margin-top:4px;}
|
||||
.kind{display:inline-block;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:2px 8px;border-radius:999px;background:var(--primary-soft);color:var(--primary);margin-right:6px;vertical-align:middle;}
|
||||
.sale .nums{display:flex;gap:14px;margin-top:10px;}
|
||||
.sale .num{background:var(--panel);border-radius:10px;padding:7px 10px;flex:1;}
|
||||
.sale .num b{display:block;font-size:16px;font-weight:800;}
|
||||
.sale .num span{font-size:11px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.st-empty{background:var(--card);border:1px dashed var(--line);border-radius:16px;padding:26px;text-align:center;color:var(--muted);font-weight:600;}
|
||||
/* recent */
|
||||
.recent-wrap{background:var(--card);border:1px solid var(--line);border-radius:16px;overflow:hidden;box-shadow:0 2px 10px rgba(0,0,0,.05);}
|
||||
.recent{width:100%;border-collapse:collapse;font-size:13.5px;}
|
||||
.recent th{text-align:left;padding:12px 14px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);background:var(--panel);}
|
||||
.recent td{padding:12px 14px;border-top:1px solid var(--line);}
|
||||
.recent tr:hover td{background:var(--panel);}
|
||||
.pill{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700;}
|
||||
.pill.paid{background:rgba(34,197,94,.16);color:#16a34a;}
|
||||
.pill.pending{background:rgba(245,158,11,.16);color:#d97706;}
|
||||
/* the Rec Room token mark */
|
||||
.rr-token{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:#6d4fd9;color:#fff;box-shadow:0 3px 8px rgba(0,0,0,.25);flex:0 0 auto;vertical-align:middle;}
|
||||
.rr-token svg{width:24px;height:24px;}
|
||||
.st-tok .v .rr-token{background:rgba(255,255,255,.22);border:1px solid rgba(255,255,255,.35);}
|
||||
.st-box .ic.tok{background:#6d4fd9;color:#fff;}
|
||||
/* earnings graph */
|
||||
.chart-card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px 10px;box-shadow:0 2px 10px rgba(0,0,0,.05);}
|
||||
.chart-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:6px;}
|
||||
.chart-head .t{font-weight:800;font-size:15px;}
|
||||
.chart-head .sum{color:var(--muted);font-size:13px;font-weight:600;}
|
||||
.chart-head .sum b{color:var(--text);}
|
||||
.range{appearance:none;-webkit-appearance:none;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:10px;padding:7px 34px 7px 12px;font:inherit;font-weight:700;font-size:13px;cursor:pointer;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a8a90' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");background-repeat:no-repeat;background-position:right 12px center;}
|
||||
.range:focus{outline:2px solid var(--primary);outline-offset:1px;}
|
||||
.chart{width:100%;height:220px;display:block;}
|
||||
.chart .bar{fill:var(--primary);opacity:.85;transition:opacity .12s;}
|
||||
.chart .bar:hover{opacity:1;}
|
||||
.chart .bar.zero{fill:var(--line);opacity:1;}
|
||||
.chart .grid{stroke:var(--line);stroke-width:1;}
|
||||
.chart .lbl{fill:var(--muted);font-size:11px;font-weight:600;}
|
||||
.chart .ylbl{fill:var(--muted);font-size:10.5px;font-weight:600;}
|
||||
.chart .line{fill:none;stroke:#f97316;stroke-width:2.2;stroke-linejoin:round;stroke-linecap:round;}
|
||||
.chart .dot{fill:#f97316;stroke:var(--card);stroke-width:2;}
|
||||
.legend{display:flex;gap:16px;font-size:12px;color:var(--muted);font-weight:600;margin-top:4px;}
|
||||
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:6px;vertical-align:middle;}
|
||||
.chart-empty{color:var(--muted);font-size:13px;text-align:center;padding:40px 0;}
|
||||
@media (max-width:640px){.st-tokens{grid-template-columns:1fr;}.st-who h1{font-size:22px;}.stats-page{padding-left:14px;padding-right:14px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="stats-page">
|
||||
<section class="st-hero">
|
||||
<div class="st-who">
|
||||
<img id="stAv" alt="">
|
||||
<div>
|
||||
<h1>Stats <span class="st-lvl" id="stLvl"></span></h1>
|
||||
<div class="sub" id="stSub">Your rooms, your sales, your tokens.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-tokens">
|
||||
<div class="st-tok"><div class="v"><span class="rr-token"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 7.5v11"/><path d="M10 11c0-2.2 2.2-3.5 4.6-2.9"/><path d="M7 13.6h6.5"/><path d="M7 16.2h6.5"/></svg></span><span id="tkNow">0</span></div><div class="l">Tokens</div><div class="h">what you have right now</div></div>
|
||||
<div class="st-tok"><div class="v"><span class="rr-token"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 7.5v11"/><path d="M10 11c0-2.2 2.2-3.5 4.6-2.9"/><path d="M7 13.6h6.5"/><path d="M7 16.2h6.5"/></svg></span><span id="tkPend">0</span></div><div class="l">Pending</div><div class="h" id="tkPendH">from sales still in the 14-day hold</div></div>
|
||||
<div class="st-tok"><div class="v"><span class="rr-token"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 7.5v11"/><path d="M10 11c0-2.2 2.2-3.5 4.6-2.9"/><path d="M7 13.6h6.5"/><path d="M7 16.2h6.5"/></svg></span><span id="tkEarn">0</span></div><div class="l">Earned</div><div class="h">paid out to you so far</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="st-sec">
|
||||
<h2>Earnings</h2>
|
||||
<div class="chart-card">
|
||||
<div class="chart-head">
|
||||
<div><div class="t">Tokens earned over time</div><div class="sum" id="chartSum"></div></div>
|
||||
<select class="range" id="chartRange" aria-label="Time range">
|
||||
<option value="week">This week</option>
|
||||
<option value="month">This month</option>
|
||||
<option value="year">This year</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="chartWrap"><div class="chart-empty">Loading…</div></div>
|
||||
<div class="legend"><span><i style="background:var(--primary)"></i>Your cut</span><span><i style="background:#f97316"></i>What buyers spent</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="st-sec"><h2>Rooms</h2><div class="st-grid" id="gridRooms"></div></section>
|
||||
<section class="st-sec"><h2>Creations</h2><div class="st-grid" id="gridMade"></div></section>
|
||||
<section class="st-sec">
|
||||
<h2>Sales</h2>
|
||||
<p class="st-note" id="stNote"></p>
|
||||
<div class="sale-list" id="saleList"><div class="st-empty">Loading…</div></div>
|
||||
</section>
|
||||
<section class="st-sec"><h2>Recent purchases</h2><div id="recentWrap"><div class="st-empty">Loading…</div></div></section>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
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 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>", "cheers": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.3a2 2 0 0 0 2-1.7l1.4-9a2 2 0 0 0-2-2.3H14z\"/><path d=\"M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3\"/></svg>", "rooms": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/><path d=\"M9 22V12h6v10\"/></svg>", "inv": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"/></svg>", "cloth": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20.4 6.5 16 4l-1.5 2h-5L8 4 3.6 6.5 5 11l2.5-.8V20h9v-9.8l2.5.8z\"/></svg>", "key": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 2l-2 2m-7.6 7.6a5.5 5.5 0 1 1-7.8 7.8 5.5 5.5 0 0 1 7.8-7.8zm0 0L19 4m-3 3 3 3\"/></svg>", "coin": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10 7.5v11\"/><path d=\"M10 11c0-2.2 2.2-3.5 4.6-2.9\"/><path d=\"M7 13.6h6.5\"/><path d=\"M7 16.2h6.5\"/></svg>", "flask": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 3h6M10 3v6L4.5 19a1.5 1.5 0 0 0 1.3 2.2h12.4a1.5 1.5 0 0 0 1.3-2.2L14 9V3\"/><path d=\"M7 15h10\"/></svg>", "cart": "<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"9\" cy=\"21\" r=\"1\"/><circle cx=\"20\" cy=\"21\" r=\"1\"/><path d=\"M1 1h4l2.7 13.4a2 2 0 0 0 2 1.6h9.7a2 2 0 0 0 2-1.6L23 6H6\"/></svg>"};
|
||||
const fmt = n => (n == null ? '0' : Number(n).toLocaleString());
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const day = iso => iso ? new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : '';
|
||||
const KIND = { invention: 'Invention', ugc: 'Clothing', roomkey: 'Room key', roomcurrency: 'Room currency', roomconsumable: 'Consumable' };
|
||||
const KICON = { invention: ICON.inv, ugc: ICON.cloth, roomkey: ICON.key, roomcurrency: ICON.coin, roomconsumable: ICON.flask };
|
||||
const box = (ic, v, l) => '<div class="st-box"><div class="ic">' + ic + '</div><div class="v">' + v + '</div><div class="l">' + l + '</div></div>';
|
||||
fetch('/api/site/stats', { credentials: 'same-origin' }).then(r => r.json()).then(d => {
|
||||
if (!d || !d.ok) {
|
||||
document.querySelector('.stats-page').innerHTML = '<div class="forbidden"><h1>401</h1><p>You need to be logged in to view your stats.</p><a href="/login" class="btn-dl">Log in</a></div>';
|
||||
return;
|
||||
}
|
||||
const av = document.getElementById('stAv');
|
||||
try {
|
||||
const me = (typeof USER_KEY !== 'undefined') ? localStorage.getItem(USER_KEY) : null;
|
||||
if (av && me && typeof avatarUrl === 'function') av.src = avatarUrl(me); else if (av) av.style.display = 'none';
|
||||
} catch (e) { if (av) av.style.display = 'none'; }
|
||||
document.getElementById('stSub').textContent = '@' + d.username;
|
||||
document.getElementById('stLvl').textContent = 'Level ' + d.level;
|
||||
document.getElementById('tkNow').textContent = fmt(d.tokens);
|
||||
document.getElementById('tkPend').textContent = fmt(d.tokensPending);
|
||||
document.getElementById('tkEarn').textContent = fmt(d.tokensEarned);
|
||||
if (d.nextPayout) document.getElementById('tkPendH').textContent = 'next payout ' + day(d.nextPayout);
|
||||
document.getElementById('gridRooms').innerHTML =
|
||||
box(ICON.visits, fmt(d.roomVisits), 'Room visits') +
|
||||
box(ICON.cheers, fmt(d.roomCheers), 'Room cheers') +
|
||||
box(ICON.rooms, fmt(d.rooms), 'Rooms');
|
||||
document.getElementById('gridMade').innerHTML =
|
||||
box(ICON.inv, fmt(d.inventionsPublished) + '<span style="color:var(--muted);font-size:16px;font-weight:700"> / ' + fmt(d.inventions) + '</span>', 'Inventions published / made') +
|
||||
box(ICON.cart, fmt(d.inventionsBought), 'Inventions bought by others') +
|
||||
box(ICON.cloth, fmt(d.customItems), 'Custom clothing') +
|
||||
box(ICON.key, fmt(d.keys), 'Room keys') +
|
||||
box(ICON.coin, fmt(d.currencies), 'Room currencies') +
|
||||
box(ICON.flask, fmt(d.consumables), 'Room consumables') +
|
||||
box(ICON.cart, fmt(d.salesCount), 'Sales') +
|
||||
box(ICON.coin, fmt(d.revenue), 'Tokens buyers spent').replace('class="ic"', 'class="ic tok"');
|
||||
// ---- earnings graph ----
|
||||
const series = (d.series || []).map(x => ({ t: new Date(x.d + 'T00:00:00Z'), share: x.share, spent: x.spent, n: x.n }));
|
||||
const rangeSel = document.getElementById('chartRange');
|
||||
function buckets(range) {
|
||||
const now = new Date(); const out = [];
|
||||
if (range === 'year') {
|
||||
for (let i = 11; i >= 0; i--) { const m = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)); out.push({ start: m, end: new Date(Date.UTC(m.getUTCFullYear(), m.getUTCMonth() + 1, 1)), label: m.toLocaleDateString(undefined, { month: 'short' }) }); }
|
||||
} else {
|
||||
const days = range === 'week' ? 7 : 30;
|
||||
for (let i = days - 1; i >= 0; i--) { const dd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - i)); out.push({ start: dd, end: new Date(dd.getTime() + 864e5), label: days === 7 ? dd.toLocaleDateString(undefined, { weekday: 'short' }) : String(dd.getUTCDate()) }); }
|
||||
}
|
||||
for (const b of out) { b.share = 0; b.spent = 0; b.n = 0; for (const p of series) if (p.t >= b.start && p.t < b.end) { b.share += p.share; b.spent += p.spent; b.n += p.n; } }
|
||||
return out;
|
||||
}
|
||||
function draw(range) {
|
||||
const b = buckets(range);
|
||||
const total = b.reduce((a, x) => a + x.share, 0), spent = b.reduce((a, x) => a + x.spent, 0), n = b.reduce((a, x) => a + x.n, 0);
|
||||
document.getElementById('chartSum').innerHTML = '<b>' + fmt(total) + '</b> earned \u00b7 <b>' + fmt(spent) + '</b> spent by buyers \u00b7 ' + fmt(n) + ' sale' + (n === 1 ? '' : 's');
|
||||
const W = 800, H = 220, padL = 46, padR = 12, padT = 12, padB = 28;
|
||||
const max = Math.max(1, ...b.map(x => Math.max(x.share, x.spent)));
|
||||
const nice = Math.pow(10, Math.floor(Math.log10(max))); const top = Math.ceil(max / nice) * nice;
|
||||
const iw = W - padL - padR, ih = H - padT - padB, bw = iw / b.length;
|
||||
const y = v => padT + ih - (v / top) * ih;
|
||||
let svg = '<svg class="chart" viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none">';
|
||||
for (let g = 0; g <= 4; g++) { const v = top * g / 4, yy = y(v); svg += '<line class="grid" x1="' + padL + '" x2="' + (W - padR) + '" y1="' + yy + '" y2="' + yy + '"/><text class="ylbl" x="' + (padL - 6) + '" y="' + (yy + 4) + '" text-anchor="end">' + fmt(v) + '</text>'; }
|
||||
b.forEach((x, i) => {
|
||||
const cx = padL + bw * i + bw / 2, w = Math.max(4, bw * 0.55), hh = Math.max(0, y(0) - y(x.share));
|
||||
svg += '<rect class="bar' + (x.share ? '' : ' zero') + '" x="' + (cx - w / 2) + '" y="' + (x.share ? y(x.share) : y(0) - 2) + '" width="' + w + '" height="' + (x.share ? hh : 2) + '" rx="3"><title>' + x.label + ': ' + fmt(x.share) + ' earned, ' + fmt(x.spent) + ' spent, ' + x.n + ' sale' + (x.n === 1 ? '' : 's') + '</title></rect>';
|
||||
const every = b.length > 12 ? 5 : 1;
|
||||
if (i % every === 0 || i === b.length - 1) svg += '<text class="lbl" x="' + cx + '" y="' + (H - 8) + '" text-anchor="middle">' + x.label + '</text>';
|
||||
});
|
||||
svg += '<polyline class="line" points="' + b.map((x, i) => (padL + bw * i + bw / 2) + ',' + y(x.spent)).join(' ') + '"/>';
|
||||
b.forEach((x, i) => { if (x.spent) svg += '<circle class="dot" cx="' + (padL + bw * i + bw / 2) + '" cy="' + y(x.spent) + '" r="3.5"><title>' + x.label + ': ' + fmt(x.spent) + ' spent</title></circle>'; });
|
||||
svg += '</svg>';
|
||||
document.getElementById('chartWrap').innerHTML = (n === 0 && series.length === 0) ? '<div class="chart-empty">No sales yet - the graph fills in as things sell.</div>' : svg;
|
||||
}
|
||||
rangeSel.addEventListener('change', () => draw(rangeSel.value));
|
||||
draw(rangeSel.value);
|
||||
document.getElementById('stNote').textContent = 'You keep ' + d.percent + '% of what a buyer spends. Each sale is held for ' + d.pendDays + ' days and paid out on the first Sunday after that.';
|
||||
const list = document.getElementById('saleList');
|
||||
const items = d.items || [];
|
||||
list.innerHTML = items.length ? items.map(it =>
|
||||
'<div class="sale">'
|
||||
+ (it.image ? '<img src="' + esc(it.image) + '" alt="">' : '<div class="ph" style="color:#fff">' + (KICON[it.kind] || ICON.cart).replace('<svg ', '<svg width="30" height="30" ') + '</div>')
|
||||
+ '<div><div class="name">' + esc(it.name || it.itemId) + '</div>'
|
||||
+ '<div class="meta"><span class="kind">' + esc(KIND[it.kind] || it.kind) + '</span>' + fmt(it.sold) + ' sold \u00b7 ' + fmt(it.revenue) + ' spent by buyers \u00b7 last ' + day(it.lastSold) + '</div>'
|
||||
+ '<div class="nums"><div class="num"><b>' + fmt(it.pending) + '</b><span>pending' + (it.nextPayout ? ' \u00b7 ' + day(it.nextPayout) : '') + '</span></div><div class="num"><b>' + fmt(it.earned) + '</b><span>earned</span></div></div>'
|
||||
+ '</div></div>').join('')
|
||||
: '<div class="st-empty">Nothing sold yet. Publish an invention, clothing, a room key, currency or consumable with a price and it shows up here.</div>';
|
||||
const rec = d.recent || [];
|
||||
document.getElementById('recentWrap').innerHTML = rec.length
|
||||
? '<div class="recent-wrap"><table class="recent"><tr><th>When</th><th>Item</th><th>Buyer</th><th>Spent</th><th>Your cut</th><th>Pays out</th><th></th></tr>'
|
||||
+ rec.map(r => '<tr><td>' + day(r.at) + '</td><td><span class="kind">' + esc(KIND[r.Kind] || r.Kind) + '</span>' + esc(r.item) + '</td><td>@' + esc(r.buyer) + '</td><td>' + fmt(r.Spent) + '</td><td><b>' + fmt(r.Share) + '</b></td><td>' + day(r.payoutAt) + '</td><td><span class="pill ' + (r.paid ? 'paid">paid' : 'pending">pending') + '</span></td></tr>').join('')
|
||||
+ '</table></div>'
|
||||
: '<div class="st-empty">No purchases yet.</div>';
|
||||
}).catch(() => { document.getElementById('saleList').innerHTML = '<div class="st-empty">Could not load stats.</div>'; });
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3065 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--primary: #8B5CF6;
|
||||
--primary-dark: #6D28D9;
|
||||
--primary-soft: #EDE9FE;
|
||||
--gradient-main: linear-gradient(135deg, #621A87 0%, #A41F73 20%, #CF2860 40%, #E73648 60%, #F7522D 80%, #FA651C 100%);
|
||||
--gradient-warm: linear-gradient(135deg, #F97316, #FBBF24);
|
||||
--gradient-cool: linear-gradient(135deg, #8B5CF6, #A78BFA);
|
||||
--bg: #ffffff;
|
||||
--panel: #f6f6f8;
|
||||
--line: #ececef;
|
||||
--text: #1c1c1e;
|
||||
--muted: #8a8a90;
|
||||
--page: #f5f2ef;
|
||||
--card: #ffffff;
|
||||
--topbar-bg: rgba(255,255,255,0.82);
|
||||
--topbar-h: 64px;
|
||||
--side-w: 230px;
|
||||
}
|
||||
|
||||
/* DARK THEME */
|
||||
[data-theme="dark"] {
|
||||
--bg: #1a1a2e;
|
||||
--panel: #2d2b4a;
|
||||
--line: rgba(255,255,255,0.09);
|
||||
--text: #f1f1f4;
|
||||
--muted: #a09fb0;
|
||||
--page: #12101f;
|
||||
--card: #22203a;
|
||||
--topbar-bg: rgba(18,16,31,0.82);
|
||||
--primary-soft: rgba(139,92,246,0.16);
|
||||
}
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: -0.01em;
|
||||
transition: background .25s, color .25s;
|
||||
}
|
||||
::selection { background: var(--primary); color: #fff; }
|
||||
|
||||
/* all avatar images crop cleanly (so custom profile pictures fit) */
|
||||
.comment-av, .image-author-av, .rail-pin-av, .profile-av, .pm-av, .sresult-avatar img,
|
||||
.post-av img, .follow-av img { object-fit: cover; }
|
||||
|
||||
/* accessible focus rings — cover every interactive element, native or custom */
|
||||
a:focus-visible, button:focus-visible, input:focus-visible,
|
||||
textarea:focus-visible, select:focus-visible, summary:focus-visible,
|
||||
[tabindex]:focus-visible, [role="button"]:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* skip-to-content link — hidden until focused by keyboard */
|
||||
.skip-link {
|
||||
position: fixed; top: 8px; left: 8px; z-index: 1000;
|
||||
background: var(--gradient-main); color: #fff;
|
||||
padding: 10px 16px; border-radius: 8px;
|
||||
font-weight: 700; text-decoration: none;
|
||||
transform: translateY(-150%);
|
||||
transition: transform .15s;
|
||||
}
|
||||
.skip-link:focus { transform: translateY(0); outline: 2px solid #fff; outline-offset: 2px; }
|
||||
img { user-select: none; }
|
||||
h1, h2, h3, .logo-text, .btn-dl { font-family: 'Roboto', sans-serif; }
|
||||
h1, h2, h3 { letter-spacing: -0.022em; }
|
||||
|
||||
/* ───── TOP BAR ───── */
|
||||
.topbar {
|
||||
position: fixed; inset: 0 0 auto 0;
|
||||
height: var(--topbar-h);
|
||||
z-index: 60;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: saturate(180%) blur(16px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(16px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.top-left { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
/* "Campus" text that slides out when the cat drawer opens */
|
||||
.topbar-brand {
|
||||
font-size: 1.4rem; font-weight: 700; color: var(--text);
|
||||
letter-spacing: -0.02em; white-space: nowrap;
|
||||
opacity: 0; transform: translateX(-18px);
|
||||
pointer-events: none;
|
||||
transition: opacity .3s ease, transform .4s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.topbar-brand.show { opacity: 1; transform: translateX(0); }
|
||||
|
||||
/* animated color-cycle for the cat-icon logo — sweeps the same
|
||||
deep violet → magenta → hot pink → coral red → orange palette
|
||||
used in --gradient-main, via a looping hue-rotate filter */
|
||||
@keyframes catGradientCycle {
|
||||
0% { filter: invert(54%) sepia(85%) saturate(1200%) hue-rotate(298deg) brightness(92%) contrast(101%); } /* deep violet */
|
||||
20% { filter: invert(54%) sepia(80%) saturate(1300%) hue-rotate(312deg) brightness(96%) contrast(101%); } /* purple-magenta */
|
||||
40% { filter: invert(54%) sepia(75%) saturate(1400%) hue-rotate(325deg) brightness(100%) contrast(101%); } /* hot pink */
|
||||
60% { filter: invert(54%) sepia(70%) saturate(1400%) hue-rotate(342deg) brightness(104%) contrast(101%); } /* coral red */
|
||||
80% { filter: invert(54%) sepia(65%) saturate(1400%) hue-rotate(358deg) brightness(107%) contrast(101%); } /* red-orange */
|
||||
100% { filter: invert(54%) sepia(60%) saturate(1400%) hue-rotate(15deg) brightness(109%) contrast(101%); } /* vivid orange */
|
||||
}
|
||||
|
||||
.side-toggle {
|
||||
background: none; border: none; cursor: pointer; padding: 0;
|
||||
width: 52px; height: 52px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.side-toggle:focus, .side-toggle:focus-visible { outline: none; }
|
||||
.burger-cat {
|
||||
width: 40px !important; height: 40px !important;
|
||||
object-fit: contain; display: block;
|
||||
animation: catGradientCycle 7s ease-in-out infinite alternate;
|
||||
transition: transform .5s ease, filter .2s ease;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.side-toggle:hover .burger-cat { animation: none; filter: brightness(0) saturate(100%) invert(8%) sepia(8%) saturate(200%) brightness(95%); }
|
||||
[data-theme="dark"] .side-toggle:hover .burger-cat { filter: brightness(0) invert(1); }
|
||||
|
||||
.logo { display: flex; align-items: center; }
|
||||
.logo img { height: 34px; width: auto; display: block; }
|
||||
.logo-cat {
|
||||
height: 40px !important;
|
||||
animation: catGradientCycle 7s ease-in-out infinite alternate;
|
||||
transition: transform .3s ease;
|
||||
}
|
||||
.logo:hover .logo-cat { transform: rotate(-8deg) scale(1.05); }
|
||||
.logo-text {
|
||||
font-size: 1.5rem; font-weight: 700;
|
||||
background: var(--gradient-main); background-size: 250% 250%;
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
letter-spacing: .5px;
|
||||
animation: logoTextGradientPan 7s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes logoTextGradientPan {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
.top-right { display: flex; align-items: center; gap: 12px; }
|
||||
.icon-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text);
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--panel); color: var(--primary); }
|
||||
|
||||
.notif-btn { position: relative; }
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
bottom: 4px; right: 4px;
|
||||
min-width: 16px; height: 16px;
|
||||
padding: 0 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--gradient-main); color: #fff;
|
||||
font-size: .62rem; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 2px solid var(--topbar-bg);
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
.notif-badge:empty, .notif-badge.hidden { display: none; }
|
||||
|
||||
/* graceful placeholder when a content image fails to load */
|
||||
.img-failed {
|
||||
background: repeating-linear-gradient(45deg, var(--panel), var(--panel) 10px, var(--line) 10px, var(--line) 20px);
|
||||
min-height: 80px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* notifications dropdown */
|
||||
.notif { position: relative; display: flex; align-items: center; }
|
||||
.notif.open .notif-btn { background: var(--panel); color: var(--primary); }
|
||||
.notif-menu {
|
||||
position: absolute; top: calc(100% + 12px); right: 0;
|
||||
width: 300px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; box-shadow: 0 12px 36px rgba(0,0,0,.16);
|
||||
padding: 6px;
|
||||
opacity: 0; visibility: hidden; transform: translateY(-8px) scale(.98);
|
||||
transform-origin: top right;
|
||||
transition: opacity .16s ease, transform .16s ease, visibility .16s;
|
||||
z-index: 130;
|
||||
}
|
||||
.notif.open .notif-menu { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
|
||||
.nm-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 10px 10px; margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 700; font-size: .95rem;
|
||||
}
|
||||
.nm-clear {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--primary); font-weight: 600; font-size: .78rem;
|
||||
}
|
||||
.nm-clear:hover { text-decoration: underline; }
|
||||
.nm-list { display: flex; flex-direction: column; }
|
||||
.nm-item {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 10px; border-radius: 10px; text-decoration: none;
|
||||
color: var(--text); transition: background .15s;
|
||||
}
|
||||
.nm-item { position: relative; }
|
||||
.nm-item:hover { background: var(--panel); }
|
||||
.nm-dismiss {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--muted); font-size: 1.1rem; line-height: 1;
|
||||
padding: 2px 4px; border-radius: 6px; flex-shrink: 0;
|
||||
opacity: 0; transition: opacity .12s, color .12s, background .12s;
|
||||
}
|
||||
.nm-item:hover .nm-dismiss { opacity: 1; }
|
||||
.nm-dismiss:hover { color: var(--primary); background: var(--card); }
|
||||
.nm-av { width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0; object-fit: cover; }
|
||||
.nm-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.nm-text { font-size: .86rem; line-height: 1.3; }
|
||||
.nm-text b { font-weight: 700; }
|
||||
.nm-time { font-size: .74rem; color: var(--muted); font-weight: 500; }
|
||||
.nm-empty { text-align: center; color: var(--muted); font-size: .85rem; padding: 22px 10px; }
|
||||
.nm-foot {
|
||||
display: block; text-align: center;
|
||||
padding: 10px; margin-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--primary); font-weight: 600; font-size: .85rem; text-decoration: none;
|
||||
}
|
||||
.nm-foot:hover { text-decoration: underline; }
|
||||
.nm-list { max-height: 60vh; overflow-y: auto; }
|
||||
/* friend-request rows are taller (they carry Accept/Decline), so top-align the avatar */
|
||||
.nm-item.nm-fr { align-items: flex-start; }
|
||||
.nm-item.nm-fr .nm-av { margin-top: 2px; }
|
||||
.nm-actions { display: flex; gap: 6px; margin-top: 7px; }
|
||||
.nm-accept, .nm-decline {
|
||||
padding: 5px 13px; border-radius: 8px; font-size: .78rem; font-weight: 600;
|
||||
cursor: pointer; border: 1px solid var(--line); line-height: 1;
|
||||
}
|
||||
.nm-accept { background: var(--primary); color: #fff; border-color: transparent; }
|
||||
.nm-accept:hover { filter: brightness(1.06); }
|
||||
.nm-decline { background: transparent; color: var(--text); }
|
||||
.nm-decline:hover { background: var(--panel); }
|
||||
.nm-accept:disabled, .nm-decline:disabled { opacity: .55; cursor: default; }
|
||||
/* "Launch the game to view" rows */
|
||||
.nm-icon {
|
||||
width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; background: var(--panel);
|
||||
}
|
||||
.nm-launch .nm-text { color: var(--muted); }
|
||||
|
||||
.btn-login {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 600; font-size: .9rem;
|
||||
padding: 9px 18px; border-radius: 8px;
|
||||
border: 1.5px solid var(--line);
|
||||
background: transparent;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.btn-login:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.account-note { color: var(--muted); font-size: .9rem; margin: 0 0 14px; }
|
||||
.btn-danger {
|
||||
display: inline-block;
|
||||
font-weight: 600; font-size: .9rem;
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--primary);
|
||||
padding: 10px 20px; border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.btn-danger:hover { background: var(--gradient-main); color: #fff; }
|
||||
|
||||
.btn-dl {
|
||||
display: inline-block;
|
||||
background: var(--gradient-main);
|
||||
color: #fff; text-decoration: none;
|
||||
font-weight: 600; font-size: .9rem; letter-spacing: 0;
|
||||
padding: 10px 20px; border-radius: 8px;
|
||||
transition: transform .1s, box-shadow .2s;
|
||||
box-shadow: 0 2px 8px rgba(139,92,246,0.3);
|
||||
}
|
||||
.btn-dl:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
.btn-dl:active { transform: translateY(1px); }
|
||||
|
||||
/* ───── SIDEBAR ───── */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: var(--topbar-h); left: 0;
|
||||
width: var(--side-w);
|
||||
height: calc(100dvh - var(--topbar-h));
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 14px 12px;
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 50;
|
||||
}
|
||||
.sidebar nav { display: flex; flex-direction: column; gap: 3px; }
|
||||
.side-link {
|
||||
display: flex; align-items: center; gap: 13px;
|
||||
padding: 11px 14px; border-radius: 11px;
|
||||
color: #55555b; text-decoration: none;
|
||||
font-size: .98rem; font-weight: 700;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.side-link svg { width: 22px; height: 22px; }
|
||||
.side-link:hover { background: var(--panel); color: var(--text); }
|
||||
.side-link.active { background: var(--primary-soft); color: var(--primary); }
|
||||
|
||||
.side-discord {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
background: var(--gradient-main);
|
||||
color: #fff; text-decoration: none;
|
||||
font-weight: 600; font-size: .9rem;
|
||||
padding: 11px; border-radius: 8px;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.side-discord:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
|
||||
.side-scrim {
|
||||
display: none;
|
||||
position: fixed; inset: var(--topbar-h) 0 0 0;
|
||||
background: rgba(0,0,0,.4); z-index: 49;
|
||||
}
|
||||
.side-scrim.open { display: block; }
|
||||
|
||||
/* ───── CONTENT ───── */
|
||||
.content {
|
||||
margin-left: var(--side-w);
|
||||
padding: calc(var(--topbar-h) + 24px) 28px 40px;
|
||||
max-width: 1240px;
|
||||
}
|
||||
|
||||
/* HERO BANNER */
|
||||
.hero-banner {
|
||||
position: relative;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16/7;
|
||||
background: var(--gradient-main);
|
||||
margin-bottom: 40px;
|
||||
box-shadow: 0 6px 22px rgba(0,0,0,.10);
|
||||
}
|
||||
.hero-img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center center; }
|
||||
.hero-overlay {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 22px;
|
||||
background: linear-gradient(to top, rgba(0,0,0,.5), transparent);
|
||||
}
|
||||
.hero-pill {
|
||||
background: rgba(255,255,255,.9); color: var(--text);
|
||||
font-weight: 800; font-size: .82rem;
|
||||
padding: 7px 14px; border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* STAT STRIP */
|
||||
.stat-strip {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px; margin-bottom: 40px;
|
||||
}
|
||||
.stat-item {
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 18px 14px; text-align: center;
|
||||
}
|
||||
.stat-item strong { display: block; font-family: 'Roboto', sans-serif; font-size: 1.6rem; color: var(--text); }
|
||||
.stat-item span { font-size: .82rem; color: var(--muted); font-weight: 700; }
|
||||
@media (max-width: 620px) {
|
||||
.stat-strip { grid-template-columns: 1fr 1fr; }
|
||||
.stat-item strong { font-size: 1.3rem; }
|
||||
}
|
||||
|
||||
/* ROWS */
|
||||
.row { margin-bottom: 38px; scroll-margin-top: 80px; }
|
||||
.row-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; }
|
||||
.row-head h2 { font-size: 1.5rem; font-weight: 700; }
|
||||
.see-all { color: var(--primary); text-decoration: none; font-weight: 800; font-size: .9rem; }
|
||||
.see-all:hover { text-decoration: underline; }
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.card { text-decoration: none; color: inherit; transition: transform .15s; }
|
||||
.card:hover { transform: translateY(-4px); }
|
||||
.card-thumb {
|
||||
aspect-ratio: 16/10; border-radius: 16px;
|
||||
background: var(--gradient-main);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-thumb::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: radial-gradient(circle at 30% 25%, rgba(255,255,255,.22), transparent 55%);
|
||||
}
|
||||
.card:hover .card-thumb { box-shadow: 0 10px 26px rgba(0,0,0,.16); }
|
||||
|
||||
.card-badge {
|
||||
position: absolute; top: 10px; left: 10px; z-index: 1;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: rgba(0,0,0,.55); color: #fff;
|
||||
font-size: .72rem; font-weight: 800;
|
||||
padding: 5px 10px; border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.card-badge .dot { width: 6px; height: 6px; border-radius: 50%; background: #3ad07a; box-shadow: 0 0 0 3px rgba(58,208,122,.3); }
|
||||
.card-badge.date { background: rgba(255,255,255,.92); color: var(--text); }
|
||||
|
||||
.card-info { display: flex; align-items: center; gap: 10px; padding: 11px 4px 0; }
|
||||
.card-av {
|
||||
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 900; font-size: .85rem;
|
||||
background: var(--gradient-main);
|
||||
}
|
||||
.card-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.card-name { font-weight: 800; font-size: 1rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-sub { font-size: .8rem; color: var(--muted); font-weight: 700; }
|
||||
|
||||
/* PHOTO STRIP */
|
||||
.photo-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.photo {
|
||||
aspect-ratio: 1; border-radius: 16px;
|
||||
background: var(--gradient-main);
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,.08);
|
||||
transition: transform .15s;
|
||||
}
|
||||
.photo:hover { transform: scale(1.03); }
|
||||
|
||||
/* BIG CTA */
|
||||
.big-cta {
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
margin: 10px 0 40px;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 90% at 50% 0%, var(--primary-soft), transparent 70%),
|
||||
var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.big-cta h2 { font-size: clamp(1.8rem, 5vw, 2.8rem); font-weight: 700; margin-bottom: 12px; }
|
||||
.big-cta p { color: var(--muted); font-weight: 700; max-width: 420px; margin: 0 auto 26px; line-height: 1.5; }
|
||||
|
||||
/* FOOTER */
|
||||
.foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
flex-wrap: wrap; gap: 16px;
|
||||
padding-top: 28px; margin-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted); font-size: .85rem; font-weight: 700;
|
||||
}
|
||||
.foot-brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-weight: 800; font-size: 1rem; }
|
||||
.foot-cat { height: 26px; width: auto; filter: invert(54%) sepia(72%) saturate(1400%) hue-rotate(325deg) brightness(103%) contrast(101%); }
|
||||
.foot nav { display: flex; flex-wrap: wrap; gap: 18px; }
|
||||
.foot nav a { color: var(--muted); text-decoration: none; }
|
||||
.foot nav a:hover { color: var(--primary); }
|
||||
.foot-copy { width: 100%; text-align: left; color: #b5b5bb; }
|
||||
|
||||
/* ───── CAT DRAWER ───── */
|
||||
.cat-drawer {
|
||||
position: fixed;
|
||||
top: var(--topbar-h); left: 0;
|
||||
width: 270px;
|
||||
height: calc(100dvh - var(--topbar-h));
|
||||
background: var(--card);
|
||||
border-right: 1px solid var(--line);
|
||||
z-index: 80;
|
||||
transform: translateX(-100%);
|
||||
transition: transform .28s cubic-bezier(.4,0,.2,1);
|
||||
display: flex; flex-direction: column;
|
||||
padding: 10px 0 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.cat-drawer.open { transform: translateX(0); box-shadow: 8px 0 40px rgba(0,0,0,.18); }
|
||||
|
||||
.cd-nav { display: flex; flex-direction: column; }
|
||||
.cd-nav a {
|
||||
padding: 15px 24px;
|
||||
color: var(--text); text-decoration: none;
|
||||
font-size: 1.05rem; font-weight: 800;
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: color .15s, background .15s, padding-left .15s;
|
||||
}
|
||||
.cd-nav a:hover { color: var(--primary); background: var(--primary-soft); padding-left: 30px; }
|
||||
|
||||
.cd-theme-toggle {
|
||||
display: flex; align-items: center; gap: 13px;
|
||||
width: 100%; padding: 15px 24px;
|
||||
background: none; border: none; border-top: 1px solid var(--line); cursor: pointer;
|
||||
font-family: inherit; font-size: 1.05rem; font-weight: 700; color: var(--text);
|
||||
text-align: left;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.cd-theme-toggle:hover { color: var(--primary); background: var(--primary-soft); }
|
||||
.cd-theme-toggle svg { width: 20px; height: 20px; flex-shrink: 0; }
|
||||
|
||||
/* accessibility page */
|
||||
.a11y-page {
|
||||
max-width: 620px; margin: 0 auto;
|
||||
padding: calc(var(--topbar-h) + 40px) 24px 60px;
|
||||
}
|
||||
.a11y-title { font-size: 1.9rem; font-weight: 800; margin: 0 0 6px; }
|
||||
.a11y-intro { color: var(--muted); font-size: .95rem; margin: 0 0 28px; line-height: 1.5; }
|
||||
.a11y-actions { display: flex; gap: 10px; align-items: center; margin-top: 22px; }
|
||||
.a11y-toggle-all {
|
||||
background: var(--gradient-main); color: #fff; border: none;
|
||||
font-family: inherit; font-weight: 600; font-size: .85rem;
|
||||
padding: 9px 16px; border-radius: 8px; cursor: pointer;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.a11y-toggle-all:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
.a11y-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.a11y-row {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 18px 20px;
|
||||
}
|
||||
.a11y-text { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
|
||||
.a11y-name { font-weight: 700; font-size: 1.02rem; }
|
||||
.a11y-desc { color: var(--muted); font-size: .86rem; line-height: 1.4; }
|
||||
.cd-a11y {
|
||||
background: none; border: none; cursor: pointer; padding: 0;
|
||||
display: inline-flex; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
.cd-a11y-sw {
|
||||
width: 38px; height: 22px; border-radius: 999px; flex-shrink: 0;
|
||||
background: var(--line); position: relative;
|
||||
transition: background .15s;
|
||||
}
|
||||
.cd-a11y-sw::after {
|
||||
content: ''; position: absolute; top: 2px; left: 2px;
|
||||
width: 18px; height: 18px; border-radius: 50%; background: #fff;
|
||||
transition: transform .15s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.25);
|
||||
}
|
||||
.cd-a11y.active .cd-a11y-sw { background: var(--primary); }
|
||||
.cd-a11y.active .cd-a11y-sw::after { transform: translateX(16px); }
|
||||
|
||||
/* accessibility effects */
|
||||
[data-reduce-motion] *, [data-reduce-motion] *::before, [data-reduce-motion] *::after {
|
||||
animation-duration: .001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
[data-large-text] { font-size: 18.5px; }
|
||||
[data-high-contrast] {
|
||||
--muted: var(--text);
|
||||
--line: var(--text);
|
||||
}
|
||||
[data-high-contrast] .btn-login,
|
||||
[data-high-contrast] .cd-a11y-sw { border: 1.5px solid var(--text); }
|
||||
|
||||
[data-underline-links] a:not(.btn-dl):not(.btn-login):not(.cd-nav-link) { text-decoration: underline; }
|
||||
[data-underline-links] .cd-nav a { text-decoration: none; }
|
||||
[data-readable-spacing] { letter-spacing: .03em; word-spacing: .12em; line-height: 1.8; }
|
||||
[data-dyslexia-font], [data-dyslexia-font] button, [data-dyslexia-font] input, [data-dyslexia-font] textarea, [data-dyslexia-font] select {
|
||||
font-family: "Comic Sans MS", "Trebuchet MS", Verdana, sans-serif !important;
|
||||
}
|
||||
[data-focus-rings] a:focus, [data-focus-rings] button:focus,
|
||||
[data-focus-rings] input:focus, [data-focus-rings] textarea:focus, [data-focus-rings] select:focus {
|
||||
outline: 3px solid var(--primary) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
[data-big-cursor], [data-big-cursor] * {
|
||||
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M6 4 L6 38 L15 29 L21 42 L27 39 L21 27 L34 27 Z" fill="white" stroke="black" stroke-width="2.5" stroke-linejoin="round"/></svg>') 4 4, auto;
|
||||
}
|
||||
/* Bigger tap targets: roomier padding on the things people actually tap,
|
||||
without force-resizing every button (which broke the topbar buttons). */
|
||||
[data-tap-targets] .cd-nav a { padding-top: 18px; padding-bottom: 18px; }
|
||||
[data-tap-targets] .pm-item,
|
||||
[data-tap-targets] .nm-item { padding: 14px; }
|
||||
[data-tap-targets] .rail-item,
|
||||
[data-tap-targets] .home-link { padding: 16px; }
|
||||
[data-tap-targets] .utab,
|
||||
[data-tap-targets] .stab { padding: 13px 18px; }
|
||||
[data-tap-targets] .icon-btn { width: 46px; height: 46px; }
|
||||
[data-tap-targets] .ptab { padding: 14px; }
|
||||
|
||||
/* show accessibility settings relevant to the device:
|
||||
PC-only (keyboard focus, mouse cursor) hidden on touch screens,
|
||||
touch-only (tap targets) hidden on devices with a real mouse */
|
||||
@media (hover: none) and (pointer: coarse) { .a11y-pc-only { display: none; } }
|
||||
@media (hover: hover) and (pointer: fine) { .a11y-touch-only { display: none; } }
|
||||
|
||||
.a11y-reset {
|
||||
background: none; border: 1.5px solid var(--line);
|
||||
color: var(--text); font-family: inherit; font-weight: 600; font-size: .9rem;
|
||||
padding: 10px 18px; border-radius: 9px; cursor: pointer;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.a11y-reset:hover { border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.cd-theme { padding: 20px 24px 0; }
|
||||
.cd-label {
|
||||
display: block;
|
||||
font-size: .68rem; font-weight: 800; letter-spacing: 1.2px; text-transform: uppercase;
|
||||
color: var(--muted); margin: 16px 0 10px;
|
||||
}
|
||||
.cd-modes { display: flex; gap: 8px; }
|
||||
.cd-mode {
|
||||
flex: 1; padding: 10px 0;
|
||||
background: var(--panel); border: 1.5px solid var(--line);
|
||||
border-radius: 11px; cursor: pointer;
|
||||
font-family: inherit; font-weight: 800; font-size: .85rem;
|
||||
color: var(--text);
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.cd-mode:hover { border-color: var(--primary); }
|
||||
.cd-mode.active { border-color: var(--primary); background: var(--primary-soft); color: var(--primary); }
|
||||
|
||||
.cd-accents { display: flex; gap: 10px; }
|
||||
.cd-accent {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: var(--c); border: 2px solid transparent;
|
||||
cursor: pointer; padding: 0;
|
||||
outline-offset: 2px;
|
||||
transition: transform .12s, border-color .15s;
|
||||
}
|
||||
.cd-accent:hover { transform: scale(1.12); }
|
||||
.cd-accent.active { border-color: var(--text); }
|
||||
|
||||
/* ───── TOP NAV (radie-style) ───── */
|
||||
.top-nav { display: flex; gap: 4px; margin-left: 16px; }
|
||||
.top-nav a {
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
color: var(--muted); text-decoration: none;
|
||||
font-weight: 600; font-size: .9rem;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.top-nav a:hover { background: var(--panel); color: var(--text); }
|
||||
.top-nav a.active { color: var(--primary); font-weight: 700; }
|
||||
|
||||
/* ───── FEED LAYOUT ───── */
|
||||
.feed-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 250px minmax(0, 640px);
|
||||
gap: 30px;
|
||||
justify-content: center;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: calc(var(--topbar-h) + 26px) 24px 70px;
|
||||
}
|
||||
|
||||
.rail { align-self: start; position: sticky; top: calc(var(--topbar-h) + 26px); }
|
||||
.rail-title {
|
||||
font-size: .72rem; font-weight: 800; letter-spacing: 1.2px; text-transform: uppercase;
|
||||
margin-bottom: 12px; color: var(--muted);
|
||||
}
|
||||
.rail-item {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 8px; border-radius: 14px;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: background .15s, transform .12s;
|
||||
}
|
||||
.rail-item:hover { background: var(--card); box-shadow: 0 4px 14px rgba(0,0,0,.06); transform: translateX(2px); }
|
||||
.rail-thumb {
|
||||
width: 58px; height: 44px; border-radius: 10px; flex-shrink: 0;
|
||||
background: var(--gradient-main);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.15), 0 2px 6px rgba(0,0,0,.1);
|
||||
}
|
||||
.rail-meta { display: flex; flex-direction: column; min-width: 0; }
|
||||
.rail-name { font-weight: 600; font-size: .9rem; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rail-item:hover .rail-name { color: var(--primary); }
|
||||
.rail-by { font-size: .78rem; color: var(--muted); font-weight: 700; }
|
||||
|
||||
/* HOME QUICK LINKS (patch notes / weekly / news) */
|
||||
.home-links { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; }
|
||||
.home-link {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
padding: 16px 10px; border-radius: 14px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
color: var(--text); text-decoration: none; font-weight: 700; font-size: .92rem;
|
||||
transition: border-color .15s, transform .12s, box-shadow .15s, color .15s;
|
||||
}
|
||||
.home-link svg { width: 24px; height: 24px; color: var(--primary); }
|
||||
.home-link:hover { border-color: var(--primary); color: var(--primary); transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.08); }
|
||||
@media (max-width: 480px) { .home-link { font-size: .82rem; padding: 13px 6px; } }
|
||||
|
||||
/* UPDATES PAGE */
|
||||
.updates-page { max-width: 680px; margin: 0 auto; padding: calc(var(--topbar-h) + 32px) 24px 60px; }
|
||||
.updates-tabs { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.utab {
|
||||
padding: 9px 16px; border-radius: 999px; text-decoration: none;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
color: var(--text); font-weight: 700; font-size: .9rem;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
}
|
||||
.utab:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.utab.active { background: var(--gradient-main); border-color: var(--primary); color: #fff; }
|
||||
.updates-head { margin-bottom: 22px; }
|
||||
.updates-head h1 { font-size: 1.9rem; font-weight: 800; margin: 0 0 4px; }
|
||||
.updates-head p { color: var(--muted); margin: 0; }
|
||||
.updates-list { display: flex; flex-direction: column; gap: 14px; }
|
||||
.update-card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 18px 20px;
|
||||
}
|
||||
.update-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.update-tag {
|
||||
background: var(--primary-soft); color: var(--primary);
|
||||
font-weight: 700; font-size: .74rem; padding: 3px 9px; border-radius: 999px;
|
||||
}
|
||||
.update-date { color: var(--muted); font-size: .8rem; font-weight: 600; }
|
||||
.update-title { font-size: 1.12rem; font-weight: 700; margin: 0 0 6px; }
|
||||
.update-body { color: var(--text); font-size: .92rem; line-height: 1.55; margin: 0; opacity: .9; }
|
||||
|
||||
/* FEATURED SLIDESHOW */
|
||||
.featured-slideshow { position: relative; margin-bottom: 30px; }
|
||||
.featured-dots {
|
||||
position: absolute; bottom: 16px; right: 20px; z-index: 2;
|
||||
display: flex; gap: 7px;
|
||||
}
|
||||
.fdot {
|
||||
width: 8px; height: 8px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.5); border: none; cursor: pointer; padding: 0;
|
||||
transition: background .2s, width .2s;
|
||||
}
|
||||
.fdot:hover { background: rgba(255,255,255,.8); }
|
||||
.fdot.active { background: #fff; width: 22px; }
|
||||
|
||||
.farrow {
|
||||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
|
||||
width: 42px; height: 42px; border-radius: 50%;
|
||||
background: rgba(0,0,0,.4); color: #fff; border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px); opacity: 0;
|
||||
transition: opacity .2s, background .15s, transform .15s;
|
||||
}
|
||||
.farrow svg { width: 24px; height: 24px; }
|
||||
.farrow:hover { background: rgba(0,0,0,.65); }
|
||||
.farrow-prev { left: 14px; }
|
||||
.farrow-next { right: 14px; }
|
||||
.featured-slideshow:hover .farrow { opacity: 1; }
|
||||
.farrow-prev:active { transform: translateY(-50%) scale(.9); }
|
||||
.farrow-next:active { transform: translateY(-50%) scale(.9); }
|
||||
@media (max-width: 700px) { .farrow { opacity: 1; } }
|
||||
@keyframes featuredFade { from { opacity: .25; } to { opacity: 1; } }
|
||||
.featured.fade { animation: featuredFade .4s ease; }
|
||||
|
||||
/* FEATURED */
|
||||
.featured {
|
||||
display: block; position: relative;
|
||||
border-radius: 14px; overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
text-decoration: none; color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06), 0 12px 30px rgba(0,0,0,.08);
|
||||
transition: transform .2s, box-shadow .2s;
|
||||
}
|
||||
.featured:hover { transform: translateY(-2px); box-shadow: 0 2px 6px rgba(0,0,0,.08), 0 18px 40px rgba(0,0,0,.12); }
|
||||
.featured-img {
|
||||
aspect-ratio: 16/9;
|
||||
background: var(--gradient-main);
|
||||
background-size: cover; background-position: center;
|
||||
}
|
||||
.featured-body {
|
||||
position: absolute; inset: auto 0 0 0;
|
||||
padding: 26px 24px 22px;
|
||||
background: linear-gradient(to top, rgba(20,5,5,.92) 0%, rgba(20,5,5,.55) 55%, transparent 100%);
|
||||
}
|
||||
.featured-tag {
|
||||
display: inline-block;
|
||||
font-size: .68rem; font-weight: 800; letter-spacing: 1.5px; text-transform: uppercase;
|
||||
background: var(--gradient-main); color: #fff;
|
||||
padding: 5px 11px; border-radius: 999px; margin-bottom: 12px;
|
||||
}
|
||||
.featured-body h2 { font-size: clamp(1.4rem, 3.6vw, 1.9rem); font-weight: 700; margin-bottom: 2px; }
|
||||
.featured-by { font-size: .9rem; font-weight: 500; opacity: .85; margin-bottom: 14px; }
|
||||
.featured-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.featured-live {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
font-size: .85rem; font-weight: 600;
|
||||
background: rgba(255,255,255,.16); backdrop-filter: blur(4px);
|
||||
padding: 6px 12px; border-radius: 999px;
|
||||
}
|
||||
.featured-live .dot { width: 7px; height: 7px; border-radius: 50%; background: #3ad07a; box-shadow: 0 0 0 3px rgba(58,208,122,.35); }
|
||||
.featured-cta { font-weight: 600; font-size: .9rem; color: #fff; }
|
||||
.featured:hover .featured-cta { color: #ffd9d4; }
|
||||
|
||||
/* FEED */
|
||||
.feed-title { font-size: 1.35rem; font-weight: 700; margin: 4px 0 18px; }
|
||||
.feed-title small { color: var(--muted); font-weight: 700; font-size: .85rem; }
|
||||
.feed-loading {
|
||||
padding: 40px 0; text-align: center;
|
||||
color: var(--muted); font-weight: 700;
|
||||
}
|
||||
|
||||
.post {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; overflow: hidden;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 8px 24px rgba(0,0,0,.05);
|
||||
transition: transform .18s, box-shadow .18s;
|
||||
}
|
||||
.post:hover { transform: translateY(-2px); box-shadow: 0 2px 4px rgba(0,0,0,.05), 0 16px 36px rgba(0,0,0,.09); }
|
||||
.post-head { display: flex; align-items: center; gap: 11px; padding: 14px 16px; }
|
||||
.post-av {
|
||||
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; position: relative; overflow: hidden;
|
||||
background: var(--gradient-main);
|
||||
}
|
||||
.post-av img, .follow-av img, .sresult-avatar img {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
|
||||
}
|
||||
.post-meta { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.post-user { font-weight: 800; font-size: .95rem; }
|
||||
.post-handle { font-size: .82rem; color: var(--muted); font-weight: 700; }
|
||||
.post-handle a { color: var(--primary); text-decoration: none; font-weight: 800; }
|
||||
.post-handle a:hover { text-decoration: underline; }
|
||||
.post-time { font-size: .8rem; color: var(--muted); font-weight: 700; }
|
||||
.post-img {
|
||||
aspect-ratio: 16/10;
|
||||
background-color: #2a1718;
|
||||
background-size: cover; background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
.post-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 16px; }
|
||||
.post-cap { font-size: .9rem; color: #55555b; font-weight: 700; }
|
||||
.post-stats { display: flex; gap: 16px; flex-shrink: 0; }
|
||||
.pstat { display: flex; align-items: center; gap: 5px; font-size: .85rem; font-weight: 700; color: var(--muted); cursor: pointer; background: none; border: none; font-family: inherit; padding: 0; }
|
||||
.pstat svg { width: 16px; height: 16px; transition: transform .15s; }
|
||||
.pstat:hover { color: var(--primary); }
|
||||
.cheer { align-items: center; }
|
||||
.cheer .cheer-icon {
|
||||
width: 17px; height: 17px; flex-shrink: 0;
|
||||
fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
|
||||
transition: fill .15s, transform .15s;
|
||||
}
|
||||
.cheer span { min-width: 1.6ch; text-align: left; }
|
||||
.cheer:hover .cheer-icon { transform: translateY(-1px); }
|
||||
.cheer.liked { color: var(--primary); background: var(--primary-soft); }
|
||||
.cheer.liked .cheer-icon { fill: currentColor; }
|
||||
|
||||
.follow-btn.following { background: var(--gradient-main); color: #fff; border-color: var(--primary); }
|
||||
|
||||
/* ── COMMENTS ── */
|
||||
.post-comments { border-top: 1px solid var(--line); padding: 14px 16px 16px; }
|
||||
.comment-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.comment { display: flex; gap: 9px; align-items: center; }
|
||||
.comment-av { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; align-self: flex-start; }
|
||||
.comment-body {
|
||||
background: var(--panel); border-radius: 12px; padding: 8px 12px;
|
||||
font-size: .88rem; color: var(--text); line-height: 1.45;
|
||||
}
|
||||
.comment.reported .comment-body { opacity: .5; }
|
||||
|
||||
.comment-report {
|
||||
flex-shrink: 0; margin-left: auto;
|
||||
background: none; border: none; cursor: pointer; padding: 5px;
|
||||
border-radius: 6px; color: var(--muted);
|
||||
opacity: 0; transition: opacity .15s, color .15s, background .15s;
|
||||
}
|
||||
.comment:hover .comment-report { opacity: 1; }
|
||||
.comment-report:hover { color: var(--primary); background: var(--primary-soft); }
|
||||
.comment-report svg { width: 15px; height: 15px; display: block; }
|
||||
.comment-reported {
|
||||
flex-shrink: 0; margin-left: auto;
|
||||
font-size: .75rem; font-weight: 600; color: var(--primary); white-space: nowrap;
|
||||
}
|
||||
|
||||
.comment-del {
|
||||
flex-shrink: 0; margin-left: auto;
|
||||
background: none; border: none; cursor: pointer; padding: 5px;
|
||||
border-radius: 6px; color: var(--muted);
|
||||
opacity: 0; transition: opacity .15s, color .15s, background .15s;
|
||||
}
|
||||
.comment:hover .comment-del { opacity: 1; }
|
||||
.comment-del:hover { color: var(--primary); background: var(--primary-soft); }
|
||||
.comment-del svg { width: 15px; height: 15px; display: block; }
|
||||
.comment-user { font-weight: 700; margin-right: 5px; }
|
||||
.no-comments { color: var(--muted); font-size: .85rem; font-weight: 500; padding: 2px 0 6px; }
|
||||
|
||||
.comment-form { display: flex; align-items: center; gap: 9px; }
|
||||
.comment-input {
|
||||
flex: 1; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 9px 15px;
|
||||
font-family: inherit; font-size: .88rem; color: var(--text); outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.comment-input:focus { border-color: var(--primary); }
|
||||
.comment-send {
|
||||
background: var(--gradient-main); color: #fff; border: none; border-radius: 999px;
|
||||
padding: 9px 18px; font-family: inherit; font-weight: 600; font-size: .85rem; cursor: pointer;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.comment-send:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
.comment-login {
|
||||
display: block; text-align: center; padding: 6px;
|
||||
color: var(--primary); font-weight: 600; font-size: .9rem; text-decoration: none;
|
||||
}
|
||||
.comment-login:hover { text-decoration: underline; }
|
||||
|
||||
.comment-error {
|
||||
display: none;
|
||||
color: var(--primary); font-weight: 600; font-size: .82rem;
|
||||
margin: 9px 2px 0;
|
||||
}
|
||||
.comment-error.show { display: block; }
|
||||
|
||||
/* RIGHT RAIL — follow */
|
||||
.follow { display: flex; align-items: center; gap: 10px; padding: 9px 0; }
|
||||
.follow-link {
|
||||
display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0;
|
||||
text-decoration: none; color: inherit; border-radius: 10px;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.follow-link:hover { opacity: .75; }
|
||||
|
||||
/* post author links */
|
||||
.post-av { text-decoration: none; }
|
||||
a.post-user { text-decoration: none; color: var(--text); }
|
||||
a.post-user:hover { text-decoration: underline; }
|
||||
.follow-av {
|
||||
width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; position: relative; overflow: hidden;
|
||||
background: var(--gradient-main);
|
||||
}
|
||||
.follow-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.follow-btn {
|
||||
border: 1.5px solid var(--primary); color: var(--primary);
|
||||
background: transparent; font-family: inherit;
|
||||
font-weight: 800; font-size: .8rem;
|
||||
padding: 6px 15px; border-radius: 999px; cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.follow-btn:hover { background: var(--gradient-main); color: #fff; }
|
||||
.rail-discord {
|
||||
display: block; margin-top: 20px; text-align: center;
|
||||
background: var(--gradient-main); color: #fff; text-decoration: none;
|
||||
font-weight: 600; font-size: .88rem;
|
||||
padding: 11px; border-radius: 8px;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.rail-discord:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
|
||||
/* feed responsive */
|
||||
@media (max-width: 1080px) {
|
||||
.feed-layout { grid-template-columns: 230px minmax(0, 1fr); }
|
||||
.rail-right { display: none; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.feed-layout { grid-template-columns: 1fr; padding-left: 16px; padding-right: 16px; }
|
||||
/* keep the rail (Online Friends / Pinned / Popular Rooms) visible, stacked below the feed */
|
||||
.rail { position: static; order: 2; margin-top: 14px; }
|
||||
.feed { order: 1; }
|
||||
.top-nav {
|
||||
position: fixed; top: var(--topbar-h); left: 0; right: 0;
|
||||
flex-direction: column; gap: 2px; margin: 0;
|
||||
background: #fff; border-bottom: 1px solid var(--line);
|
||||
padding: 8px; z-index: 55;
|
||||
transform: translateY(-130%);
|
||||
transition: transform .25s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.top-nav.open { transform: translateY(0); }
|
||||
.top-nav a { padding: 13px 16px; }
|
||||
}
|
||||
|
||||
/* ───── PROFILE PAGE ───── */
|
||||
.profile-page { width: 100%; margin: 0; padding: 0 0 60px; }
|
||||
.profile-banner {
|
||||
height: 300px; margin-top: var(--topbar-h);
|
||||
background:
|
||||
radial-gradient(ellipse 50% 120% at 25% 10%, rgba(255,255,255,.16), transparent 55%),
|
||||
var(--gradient-main);
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid; grid-template-columns: 290px 1fr; gap: 32px;
|
||||
max-width: 1240px; margin: 0 auto; padding: 0 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* left sidebar */
|
||||
.profile-side { position: relative; }
|
||||
.profile-pic {
|
||||
width: 150px; height: 150px; border-radius: 50%;
|
||||
border: 5px solid var(--page); background: var(--card);
|
||||
display: block; margin-top: -88px;
|
||||
/* Without this the browser defaults to object-fit: fill, which stretches a non-square
|
||||
avatar into the circle and looks "goofy". cover fills the circle undistorted, centred. */
|
||||
object-fit: cover; object-position: center;
|
||||
}
|
||||
.profile-username { font-size: 1.55rem; font-weight: 700; line-height: 1.15; margin-top: 14px; }
|
||||
.profile-handle { color: var(--muted); font-weight: 500; font-size: .92rem; }
|
||||
.profile-actions { margin-top: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.profile-report-btn {
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--text);
|
||||
font-family: inherit; font-weight: 600; font-size: .9rem;
|
||||
padding: 9px 18px; border-radius: 10px; cursor: pointer;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.profile-report-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
||||
.profile-report-btn:disabled { opacity: .6; cursor: default; color: var(--muted); }
|
||||
.profile-gear {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
border: 1px solid var(--line); background: var(--card); color: var(--text);
|
||||
text-decoration: none; transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.profile-gear svg { width: 20px; height: 20px; }
|
||||
.profile-gear:hover { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
||||
.profile-follow { font-size: .9rem; padding: 9px 26px; }
|
||||
|
||||
/* friend button */
|
||||
.friend-btn {
|
||||
font-family: inherit; font-weight: 600; font-size: .9rem;
|
||||
padding: 9px 20px; border-radius: 10px; cursor: pointer;
|
||||
background: var(--gradient-main); color: #fff; border: 1px solid var(--primary);
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.friend-btn:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
.friend-btn.is-friend { background: var(--panel); color: var(--text); border-color: var(--line); }
|
||||
.friend-btn.is-friend:hover { background: var(--primary-soft); color: var(--primary); border-color: var(--primary); }
|
||||
.friend-btn.pending { background: var(--panel); color: var(--muted); border-color: var(--line); }
|
||||
.friend-btn.pending:hover { background: var(--primary-soft); color: var(--primary); border-color: var(--primary); }
|
||||
|
||||
/* friend options panel */
|
||||
.friend-panel:empty { display: none; }
|
||||
.friend-panel {
|
||||
margin-top: 16px; padding: 14px;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.fo-title { font-size: .72rem; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); }
|
||||
.fo-nick-btn {
|
||||
text-align: left;
|
||||
font-family: inherit; font-weight: 600; font-size: .85rem; cursor: pointer;
|
||||
padding: 9px 12px; border-radius: 9px;
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.fo-nick-btn:hover { border-color: var(--primary); }
|
||||
|
||||
.nick-input {
|
||||
width: 100%; height: 46px; padding: 0 15px; margin-bottom: 16px;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
font-family: inherit; font-size: .98rem; font-weight: 500; color: var(--text); outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.nick-input:focus { border-color: var(--primary); }
|
||||
.fo-pin {
|
||||
font-family: inherit; font-weight: 600; font-size: .85rem; cursor: pointer;
|
||||
padding: 9px 12px; border-radius: 9px;
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--text);
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.fo-pin:hover { border-color: var(--primary); }
|
||||
.fo-pin.on { background: var(--primary-soft); color: var(--primary); border-color: var(--primary); }
|
||||
|
||||
.fo-fav {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-family: inherit; font-weight: 600; font-size: .85rem; cursor: pointer;
|
||||
padding: 9px 12px; border-radius: 9px; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--text);
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.fo-fav svg { color: var(--muted); flex-shrink: 0; }
|
||||
.fo-fav:hover { border-color: #f5a623; }
|
||||
.fo-fav.on { background: rgba(245,166,35,.14); color: #e6952a; border-color: #f5a623; }
|
||||
.fo-fav.on svg { color: #f5a623; }
|
||||
.fav-star { color: #f5a623; font-size: .9em; }
|
||||
|
||||
/* clickable Friends stat */
|
||||
.statbox-clickable { cursor: pointer; border-radius: 8px; transition: background .15s; display: flex; align-items: center; gap: 8px; }
|
||||
.statbox-clickable:hover { background: var(--panel); }
|
||||
.statbox-badge {
|
||||
background: var(--gradient-main); color: #fff; font-size: .72rem; font-weight: 700;
|
||||
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* friends list modal */
|
||||
.fl-card { max-width: 440px; }
|
||||
.fl-tabs { display: flex; gap: 6px; margin: 14px 0; }
|
||||
.fl-tab {
|
||||
flex: 1; padding: 8px 6px; border-radius: 9px;
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--text);
|
||||
font-family: inherit; font-weight: 700; font-size: .82rem; cursor: pointer;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.fl-tab:hover { border-color: var(--primary); }
|
||||
.fl-tab.active { background: var(--gradient-main); border-color: var(--primary); color: #fff; }
|
||||
.fl-list { display: flex; flex-direction: column; gap: 4px; max-height: 50vh; overflow-y: auto; }
|
||||
.fl-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px; border-radius: 11px; text-decoration: none; color: var(--text);
|
||||
transition: background .15s;
|
||||
}
|
||||
.fl-item:hover { background: var(--panel); }
|
||||
.fl-av { width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0; object-fit: cover; }
|
||||
.fl-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
||||
.fl-name { font-weight: 700; font-size: .92rem; }
|
||||
.fl-sub { font-size: .8rem; color: var(--muted); font-weight: 500; }
|
||||
.fl-online { display: inline-flex; align-items: center; gap: 6px; color: #2ecc71; font-weight: 600; }
|
||||
.fl-online i { width: 8px; height: 8px; border-radius: 50%; background: #2ecc71; display: inline-block; }
|
||||
.fl-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.fl-accept, .fl-decline {
|
||||
font-family: inherit; font-weight: 700; font-size: .78rem; cursor: pointer;
|
||||
padding: 7px 11px; border-radius: 8px; border: 1px solid var(--line);
|
||||
}
|
||||
.fl-accept { background: var(--gradient-main); color: #fff; border-color: var(--primary); }
|
||||
.fl-decline { background: var(--panel); color: var(--text); }
|
||||
.fl-empty { text-align: center; color: var(--muted); font-size: .88rem; padding: 26px 10px; }
|
||||
|
||||
/* pinned friends rail */
|
||||
.rail-pinned { margin-bottom: 26px; }
|
||||
.rail-pin-av { width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
/* online friends widget */
|
||||
.rail-online { margin-bottom: 26px; }
|
||||
.rail-on-av { position: relative; flex-shrink: 0; display: inline-flex; }
|
||||
.rail-on-av .online-dot {
|
||||
position: absolute; bottom: 1px; right: 1px;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: #2ecc71; border: 2.5px solid var(--page);
|
||||
}
|
||||
|
||||
.profile-status { display: flex; align-items: center; gap: 8px; margin-top: 18px; color: var(--muted); font-weight: 600; font-size: .9rem; }
|
||||
.status-dot { width: 9px; height: 9px; border-radius: 50%; background: #9a9aa6; flex-shrink: 0; }
|
||||
.profile-bio { margin-top: 12px; font-size: .95rem; line-height: 1.5; }
|
||||
.profile-joined { display: flex; align-items: center; gap: 7px; margin-top: 12px; color: var(--muted); font-weight: 600; font-size: .85rem; }
|
||||
|
||||
/* friend control as a stat-box row */
|
||||
.statbox-friend { padding: 0 !important; }
|
||||
.friend-row-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 13px 15px; background: none; border: none; cursor: pointer;
|
||||
font-family: inherit; font-weight: 700; font-size: .9rem; color: var(--primary);
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.friend-row-btn:hover { background: var(--primary-soft); }
|
||||
.friend-row-btn.pending { color: var(--muted); }
|
||||
.friend-row-btn.is-friend { color: var(--text); }
|
||||
.friend-row-btn.pending:hover, .friend-row-btn.is-friend:hover { background: var(--panel); }
|
||||
|
||||
/* third stat-box row */
|
||||
.statbox-sub { display: block; }
|
||||
.sub-text { display: block; padding: 13px 15px; color: var(--muted); font-weight: 600; font-size: .9rem; }
|
||||
.subscribe-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 13px 15px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--primary); font-family: inherit; font-weight: 700; font-size: .9rem;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.subscribe-btn:hover { background: var(--primary-soft); }
|
||||
.subscribe-btn.subscribed { color: var(--muted); }
|
||||
.subscribe-btn.subscribed:hover { background: var(--panel); color: var(--text); }
|
||||
|
||||
.profile-statbox { margin-top: 20px; border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: var(--card); }
|
||||
.statbox-row { padding: 13px 15px; border-bottom: 1px solid var(--line); font-size: .9rem; font-weight: 600; color: var(--muted); }
|
||||
.statbox-row:last-child { border-bottom: none; }
|
||||
.statbox-row strong { color: var(--text); margin-right: 6px; font-weight: 700; }
|
||||
.statbox-row:hover { background: var(--panel); cursor: pointer; }
|
||||
|
||||
/* right main */
|
||||
.profile-main { padding-top: 14px; }
|
||||
.profile-tabs { display: flex; border-bottom: 1px solid var(--line); margin-bottom: 20px; }
|
||||
.ptab {
|
||||
flex: 1; background: none; border: none; cursor: pointer;
|
||||
padding: 14px 0; color: var(--muted);
|
||||
display: flex; justify-content: center;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.ptab svg { width: 22px; height: 22px; }
|
||||
.ptab:hover { color: var(--text); }
|
||||
.ptab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
|
||||
.profile-panel { display: none; }
|
||||
.profile-panel.active { display: block; }
|
||||
|
||||
.profile-photos {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* rooms tab (rec.net-style cards) */
|
||||
.profile-rooms { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 18px; }
|
||||
.room-card {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; overflow: hidden;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
.room-card:hover { transform: translateY(-3px); box-shadow: 0 10px 26px rgba(0,0,0,.12); }
|
||||
.room-card-head { display: flex; align-items: center; gap: 10px; padding: 11px 12px; }
|
||||
.room-card-av { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
.room-card-id { display: flex; flex-direction: column; min-width: 0; }
|
||||
.room-card-name { font-weight: 700; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.room-card-by { font-size: .78rem; color: var(--muted); font-weight: 600; }
|
||||
.room-card-cover { aspect-ratio: 16/10; position: relative; background: var(--gradient-main); }
|
||||
.room-card-cover::after { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at 30% 25%, rgba(255,255,255,.18), transparent 55%); }
|
||||
.room-card-foot { display: flex; gap: 18px; padding: 11px 14px; }
|
||||
.room-card-stat { display: flex; align-items: center; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--muted); }
|
||||
.room-card-stat img { width: 16px; height: 16px; object-fit: contain; }
|
||||
.room-card-stat svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
/* events tab */
|
||||
.profile-events { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
|
||||
.profile-events .profile-empty, .profile-events .feed-loading { grid-column: 1 / -1; }
|
||||
/* event cards (profile + room Events tab): picture + title + @host, a big picture, when, N ATTENDING - the Rec Room look */
|
||||
.profile-events { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.evc { display: flex; flex-direction: column; gap: 14px; padding: 16px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; cursor: pointer; transition: transform .15s, box-shadow .15s; }
|
||||
.evc:hover { transform: translateY(-3px); box-shadow: 0 10px 26px rgba(0,0,0,.18); }
|
||||
.evc-head { display: flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; min-width: 0; }
|
||||
.evc-head img { width: 42px; height: 42px; border-radius: 50%; object-fit: cover; flex-shrink: 0; background: var(--panel); }
|
||||
.evc-titles { display: flex; flex-direction: column; min-width: 0; }
|
||||
.evc-titles b { font-size: .98rem; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
.evc-titles small { font-size: .85rem; color: var(--muted); font-weight: 600; margin-top: 2px; }
|
||||
.evc-img { position: relative; display: block; aspect-ratio: 1 / 1; border-radius: 8px; background-color: var(--panel); background-size: cover; background-position: center; }
|
||||
.evc-img .pevent-live, .evc-img .pevent-tag { position: absolute; top: 10px; left: 10px; }
|
||||
.evc-meta { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }
|
||||
.evc-when { font-size: .9rem; color: var(--muted); font-weight: 500; }
|
||||
.evc-att { font-size: .8rem; font-weight: 500; letter-spacing: .3px; }
|
||||
.evc-att b { font-weight: 800; }
|
||||
.event-ended { opacity: .62; }
|
||||
.pevent-live { background: #e5383b; color: #fff; font-size: .64rem; font-weight: 800; letter-spacing: 1px; padding: 4px 9px; border-radius: 999px; }
|
||||
.pevent-tag { background: rgba(15,10,12,.72); color: #ddd; font-size: .64rem; font-weight: 700; letter-spacing: 1px; padding: 4px 9px; border-radius: 999px; }
|
||||
.pevent-rsvp { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pevent-rsvp button {
|
||||
border: 1px solid var(--line); background: var(--panel); color: inherit; font: inherit; font-weight: 700; font-size: .78rem;
|
||||
padding: 7px 14px; border-radius: 999px; cursor: pointer; transition: background .15s, border-color .15s;
|
||||
}
|
||||
.pevent-rsvp button:hover { border-color: var(--primary); }
|
||||
.pevent-rsvp button.on { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.pevent-rsvp button:disabled { opacity: .6; cursor: default; }
|
||||
.room-events { padding-top: 6px; }
|
||||
|
||||
/* the event page: the photo page's layout (.image-page / .image-photo / .image-head / .image-info / .image-comments) */
|
||||
.ev-photo-badge { position: absolute; top: 14px; left: 14px; z-index: 1; }
|
||||
.ev-rsvp-tools { display: contents; }
|
||||
.image-tools .img-report.on { background: var(--primary-soft); color: var(--primary); }
|
||||
.image-tools .img-report[data-r="not_interested"].on { background: var(--panel); color: var(--text); }
|
||||
.image-tools .ev-join { background: var(--primary); color: #fff; font-weight: 700; padding: 7px 18px; }
|
||||
.image-tools .ev-join:hover { background: var(--primary); color: #fff; filter: brightness(1.1); }
|
||||
.image-tools .ev-join.ev-join-off, .image-tools .ev-join:disabled { background: var(--panel); color: var(--muted); filter: none; cursor: default; }
|
||||
.image-meta a { color: var(--primary); text-decoration: none; font-weight: 500; }
|
||||
.ev-desc { color: var(--muted); font-weight: 500; line-height: 1.7; white-space: pre-line; overflow-wrap: anywhere; max-width: 720px; }
|
||||
.ev-people { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.ev-person { display: flex; align-items: center; gap: 9px; padding: 7px 14px 7px 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: inherit; text-decoration: none; font-weight: 700; font-size: .88rem; }
|
||||
.ev-person:hover { border-color: var(--primary); }
|
||||
.ev-person img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; }
|
||||
.pphoto {
|
||||
position: relative; aspect-ratio: 1; border-radius: 14px;
|
||||
background-color: #2a1718; background-size: cover; background-position: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,.08);
|
||||
transition: opacity .18s, transform .18s;
|
||||
}
|
||||
.pphoto-del {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: rgba(0,0,0,.55); color: #fff; border: none; cursor: pointer;
|
||||
font-size: 1rem; line-height: 1; display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px); transition: background .15s, transform .12s;
|
||||
}
|
||||
.pphoto-del:hover { background: var(--primary); transform: scale(1.1); }
|
||||
.profile-empty { color: var(--muted); font-weight: 500; padding: 20px 2px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.profile-banner { height: 200px; }
|
||||
.profile-layout { grid-template-columns: 1fr; gap: 20px; padding: 0 20px; }
|
||||
.profile-pic { width: 110px; height: 110px; margin-top: -70px; }
|
||||
.profile-username { font-size: 1.4rem; }
|
||||
}
|
||||
|
||||
/* ───── TOAST ───── */
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 28px;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: rgba(22,22,26,.97); color: #fff;
|
||||
padding: 13px 22px; border-radius: 11px;
|
||||
font-size: .9rem; font-weight: 600; letter-spacing: -0.01em;
|
||||
box-shadow: 0 10px 36px rgba(0,0,0,.35);
|
||||
z-index: 300; max-width: 90vw; text-align: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .25s ease, transform .25s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
/* ───── REPORT MODAL ───── */
|
||||
.report-scrim {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(0,0,0,.5); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
|
||||
display: none;
|
||||
}
|
||||
.report-scrim.open { display: block; }
|
||||
.report-modal {
|
||||
position: fixed; inset: 0; z-index: 210;
|
||||
display: none; align-items: center; justify-content: center; padding: 18px;
|
||||
}
|
||||
.report-modal.open { display: flex; }
|
||||
.report-card {
|
||||
position: relative; width: 100%; max-width: 440px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; padding: 24px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,.32);
|
||||
animation: reportPop .24s cubic-bezier(.34,1.56,.64,1);
|
||||
}
|
||||
@keyframes reportPop { from { transform: scale(.94); opacity: .4; } to { transform: scale(1); opacity: 1; } }
|
||||
.report-close {
|
||||
position: absolute; top: 14px; right: 14px;
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: none; border: none; cursor: pointer; color: var(--muted);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1rem;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.report-close:hover { background: var(--panel); color: var(--text); }
|
||||
.report-title { font-size: 1.3rem; font-weight: 700; }
|
||||
.report-sub { color: var(--muted); font-weight: 500; font-size: .9rem; margin: 6px 0 16px; }
|
||||
.report-reasons { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
|
||||
.report-reason {
|
||||
text-align: left; padding: 12px 14px; border-radius: 10px;
|
||||
border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
||||
font-family: inherit; font-weight: 600; font-size: .9rem; cursor: pointer;
|
||||
transition: border-color .15s, background .15s, color .15s;
|
||||
}
|
||||
.report-reason:hover { border-color: var(--primary); }
|
||||
.report-reason.sel { border-color: var(--primary); background: var(--primary-soft); color: var(--primary); }
|
||||
.report-details {
|
||||
width: 100%; min-height: 72px; resize: vertical;
|
||||
padding: 11px 13px; border-radius: 10px;
|
||||
border: 1px solid var(--line); background: var(--panel); color: var(--text);
|
||||
font-family: inherit; font-size: .9rem; outline: none; margin-bottom: 16px;
|
||||
}
|
||||
.report-details:focus { border-color: var(--primary); }
|
||||
.report-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.report-cancel {
|
||||
background: none; border: 1px solid var(--line); color: var(--text);
|
||||
font-family: inherit; font-weight: 600; font-size: .9rem;
|
||||
padding: 10px 18px; border-radius: 9px; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.report-cancel:hover { background: var(--panel); }
|
||||
.report-submit {
|
||||
background: var(--gradient-main); color: #fff; border: none;
|
||||
font-family: inherit; font-weight: 600; font-size: .9rem;
|
||||
padding: 10px 18px; border-radius: 9px; cursor: pointer; transition: box-shadow .15s, opacity .15s;
|
||||
}
|
||||
.report-submit:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.report-submit:not(:disabled):hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
.report-done { text-align: center; padding: 6px 0; }
|
||||
.report-check {
|
||||
width: 54px; height: 54px; border-radius: 50%;
|
||||
background: var(--primary-soft); color: var(--primary);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.6rem;
|
||||
margin: 0 auto 14px;
|
||||
}
|
||||
.report-ok { margin-top: 16px; }
|
||||
|
||||
/* report trigger buttons */
|
||||
.image-header { display: flex; align-items: center; gap: 11px; padding: 14px 16px; }
|
||||
.image-header .image-author { flex: 1; padding: 0; }
|
||||
.img-report {
|
||||
flex-shrink: 0; background: none; border: 1px solid var(--line); color: var(--muted);
|
||||
font-family: inherit; font-weight: 600; font-size: .82rem;
|
||||
padding: 7px 13px; border-radius: 8px; cursor: pointer;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.img-report:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.img-report:disabled { opacity: .6; cursor: default; }
|
||||
.profile-report {
|
||||
background: none; border: none; color: var(--muted);
|
||||
font-family: inherit; font-weight: 600; font-size: .85rem; cursor: pointer;
|
||||
padding: 14px 2px 0; margin-top: 6px;
|
||||
transition: color .15s;
|
||||
}
|
||||
.profile-report:hover { color: var(--primary); }
|
||||
.profile-report:disabled { color: var(--muted); cursor: default; }
|
||||
|
||||
/* ───── ROOM PAGE ───── */
|
||||
.room-page { width: 100%; margin: 0; padding: 0 0 60px; }
|
||||
.room-cover {
|
||||
position: relative; overflow: hidden;
|
||||
height: 340px; display: flex; align-items: flex-end;
|
||||
margin-top: var(--topbar-h);
|
||||
}
|
||||
.room-cover::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: linear-gradient(to top, rgba(0,0,0,.55), transparent 60%);
|
||||
}
|
||||
.room-cover-body { position: relative; z-index: 1; padding: 30px 40px; color: #fff; }
|
||||
.room-name { font-size: clamp(1.8rem, 5vw, 2.6rem); font-weight: 700; line-height: 1.1; }
|
||||
.room-by { color: rgba(255,255,255,.92); font-weight: 600; font-size: .98rem; text-decoration: none; }
|
||||
.room-by:hover { text-decoration: underline; }
|
||||
|
||||
.room-detail { padding: 28px 40px; }
|
||||
.room-stats { display: flex; gap: 28px; margin-bottom: 18px; }
|
||||
.room-stat { display: flex; flex-direction: column; gap: 2px; }
|
||||
.room-stat strong { font-size: 1.3rem; font-weight: 700; }
|
||||
.room-stat small { font-size: .8rem; color: var(--muted); font-weight: 600; }
|
||||
.room-live { display: flex; align-items: center; gap: 7px; font-size: 1.3rem; font-weight: 700; }
|
||||
.room-live .dot { width: 9px; height: 9px; border-radius: 50%; background: #3ad07a; box-shadow: 0 0 0 3px rgba(58,208,122,.3); }
|
||||
.room-desc { color: var(--muted); font-weight: 500; line-height: 1.6; margin-bottom: 24px; max-width: 560px; }
|
||||
.room-play { font-size: 1rem; padding: 13px 32px; }
|
||||
|
||||
/* ───── IMAGE PAGE ───── */
|
||||
.image-page {
|
||||
min-height: 100dvh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: calc(var(--topbar-h) + 28px) 20px 40px;
|
||||
}
|
||||
.image-card {
|
||||
max-width: 940px; width: 100%;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 16px; overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 30px rgba(0,0,0,.06);
|
||||
}
|
||||
.image-author {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 14px 16px; text-decoration: none; color: var(--text);
|
||||
}
|
||||
.image-author-av { width: 42px; height: 42px; border-radius: 50%; }
|
||||
.image-author-id { display: flex; flex-direction: column; }
|
||||
.image-author-name { font-weight: 700; font-size: .95rem; }
|
||||
.image-author:hover .image-author-name { text-decoration: underline; }
|
||||
.image-author-handle { font-size: .82rem; color: var(--muted); font-weight: 500; }
|
||||
.image-photo {
|
||||
width: 100%; aspect-ratio: 16/10;
|
||||
background-color: #2a1718; background-size: cover; background-position: center;
|
||||
}
|
||||
.image-info { padding: 20px 22px 6px; }
|
||||
.image-info-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.image-info-text { min-width: 0; }
|
||||
.image-menu-wrap { position: relative; flex-shrink: 0; }
|
||||
.image-menu-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
width: 36px; height: 36px; border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted); transition: background .15s, color .15s;
|
||||
}
|
||||
.image-menu-btn:hover { background: var(--panel); color: var(--text); }
|
||||
.image-menu-btn svg { width: 22px; height: 22px; }
|
||||
.image-menu {
|
||||
position: absolute; top: calc(100% + 6px); right: 0;
|
||||
width: 210px; z-index: 50;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,.18);
|
||||
padding: 6px;
|
||||
opacity: 0; visibility: hidden; transform: translateY(-6px) scale(.98);
|
||||
transform-origin: top right;
|
||||
transition: opacity .15s, transform .15s, visibility .15s;
|
||||
}
|
||||
.image-menu.open { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
|
||||
.image-menu-item {
|
||||
display: block; width: 100%; text-align: left;
|
||||
padding: 10px 12px; border-radius: 8px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
font-family: inherit; font-weight: 600; font-size: .88rem; color: var(--text);
|
||||
transition: background .12s;
|
||||
}
|
||||
.image-menu-item:hover { background: var(--panel); }
|
||||
.image-menu-item.danger { color: var(--primary); }
|
||||
.image-menu-item.danger:hover { background: var(--primary-soft); }
|
||||
|
||||
.image-title { font-size: 1.5rem; font-weight: 700; line-height: 1.25; }
|
||||
.title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.title-edit {
|
||||
background: none; border: none; cursor: pointer; color: var(--muted);
|
||||
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.title-edit:hover { background: var(--panel); color: var(--primary); }
|
||||
.title-edit svg { width: 17px; height: 17px; }
|
||||
.title-editor { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.title-input {
|
||||
flex: 1; min-width: 200px; height: 44px; padding: 0 14px;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
font-family: inherit; font-size: 1.2rem; font-weight: 700; color: var(--text); outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.title-input:focus { border-color: var(--primary); }
|
||||
.title-save {
|
||||
background: var(--gradient-main); color: #fff; border: none; border-radius: 9px;
|
||||
padding: 9px 18px; font-family: inherit; font-weight: 600; cursor: pointer; transition: box-shadow .15s;
|
||||
}
|
||||
.title-save:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
.title-cancel {
|
||||
background: none; border: 1px solid var(--line); color: var(--text); border-radius: 9px;
|
||||
padding: 9px 18px; font-family: inherit; font-weight: 600; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.title-cancel:hover { background: var(--panel); }
|
||||
.image-meta { color: var(--muted); font-weight: 500; font-size: .85rem; margin-top: 6px; }
|
||||
.image-actions { display: flex; justify-content: flex-end; padding: 6px 16px 10px; }
|
||||
.image-actions .cheer { font-size: .95rem; gap: 7px; }
|
||||
.image-actions .cheer-icon { width: 20px; height: 20px; }
|
||||
|
||||
.image-comments { padding: 8px 0 4px; }
|
||||
.image-comments h2 { font-size: 1.05rem; font-weight: 700; padding: 8px 18px 0; }
|
||||
.image-comments .post-comments { border-top: none; }
|
||||
.image-missing { text-align: center; color: var(--muted); font-weight: 600; padding: 80px 20px; }
|
||||
|
||||
/* clickable photos */
|
||||
.post-img { display: block; cursor: pointer; }
|
||||
a.pphoto { cursor: pointer; display: block; }
|
||||
|
||||
/* ───── SETTINGS PAGE (rec.net style) ───── */
|
||||
.settings-page { max-width: 1040px; margin: 0 auto; padding: calc(var(--topbar-h) + 24px) 24px 70px; }
|
||||
|
||||
.settings-userhead { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
|
||||
.su-av { width: 52px; height: 52px; border-radius: 50%; object-fit: cover; }
|
||||
.su-id { display: flex; flex-direction: column; }
|
||||
.su-name { font-size: 1.15rem; font-weight: 700; }
|
||||
.su-handle { font-size: .85rem; color: var(--muted); font-weight: 500; }
|
||||
.su-status { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--muted); font-weight: 600; font-size: .9rem; }
|
||||
.su-dot { width: 9px; height: 9px; border-radius: 50%; background: #9a9aa6; }
|
||||
|
||||
.settings-layout { display: grid; grid-template-columns: 200px 1fr; gap: 28px; align-items: start; }
|
||||
.settings-nav { display: flex; flex-direction: column; gap: 4px; }
|
||||
.snav {
|
||||
text-align: left; background: none; border: none; cursor: pointer;
|
||||
padding: 12px 16px; border-radius: 10px;
|
||||
font-family: inherit; font-size: 1rem; font-weight: 600; color: var(--text);
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.snav:hover { background: var(--panel); }
|
||||
.snav.active { background: var(--gradient-main); color: #fff; }
|
||||
.snav-logout { color: var(--muted); margin-top: 6px; }
|
||||
.snav-logout:hover { background: var(--primary-soft); color: var(--primary); }
|
||||
|
||||
.settings-content { min-width: 0; max-width: 560px; }
|
||||
.spanel { display: none; }
|
||||
.spanel.active { display: block; }
|
||||
.spanel h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 20px; }
|
||||
|
||||
.sfield { margin-bottom: 18px; }
|
||||
.sfield label {
|
||||
display: block; font-size: .78rem; font-weight: 600;
|
||||
letter-spacing: .2px; color: var(--muted); margin-bottom: 7px;
|
||||
}
|
||||
.sfield label.sreq::after { content: ' *'; color: var(--primary); }
|
||||
.sfield input, .sfield textarea, .sselect {
|
||||
width: 100%; padding: 11px 14px;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
||||
font-family: inherit; font-size: .95rem; font-weight: 500; color: var(--text); outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.sfield input:focus, .sfield textarea:focus, .sselect:focus {
|
||||
border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft);
|
||||
}
|
||||
.sfield input[readonly] { color: var(--muted); cursor: default; }
|
||||
.sfield input[readonly]:focus { box-shadow: none; border-color: var(--line); }
|
||||
.sfield input[readonly] { color: var(--muted); cursor: default; }
|
||||
.sfield textarea { resize: vertical; line-height: 1.5; }
|
||||
.sselect { cursor: pointer; }
|
||||
.srow-save { display: flex; justify-content: flex-end; margin-top: 26px; }
|
||||
.ssave {
|
||||
background: var(--gradient-main); color: #fff; border: none;
|
||||
font-family: inherit; font-weight: 600; font-size: .92rem; letter-spacing: 0;
|
||||
padding: 11px 28px; border-radius: 9px; cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(139,92,246,0.3);
|
||||
transition: box-shadow .15s, transform .1s;
|
||||
}
|
||||
.ssave:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
.ssave:active { transform: translateY(1px); }
|
||||
|
||||
/* email toggles */
|
||||
.etog { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||||
.etog > span { font-weight: 600; }
|
||||
.etog-right { display: flex; align-items: center; gap: 14px; }
|
||||
.efreq { padding: 8px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 9px; font-family: inherit; font-weight: 600; font-size: .85rem; color: var(--text); cursor: pointer; }
|
||||
|
||||
.switch { position: relative; display: inline-block; width: 44px; height: 26px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; inset: 0; background: var(--line); border-radius: 999px; cursor: pointer; transition: background .2s; }
|
||||
.slider::before { content: ''; position: absolute; width: 20px; height: 20px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform .2s; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||
.switch input:checked + .slider { background: var(--primary); }
|
||||
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||
|
||||
.forbidden {
|
||||
text-align: center; padding: 13vh 20px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
}
|
||||
.forbidden h1 { font-size: clamp(4.5rem, 16vw, 8rem); font-weight: 800; color: var(--primary); line-height: 1; }
|
||||
.forbidden p { color: var(--muted); font-weight: 600; font-size: 1.05rem; max-width: 360px; }
|
||||
.forbidden .btn-dl { margin-top: 8px; }
|
||||
|
||||
.juniors-empty { color: var(--muted); font-weight: 600; line-height: 1.6; }
|
||||
.juniors-face { color: var(--text); }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.settings-layout { grid-template-columns: 1fr; gap: 18px; }
|
||||
.settings-nav { flex-direction: row; flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
.settings-page-old { max-width: 640px; margin: 0 auto; padding: calc(var(--topbar-h) + 36px) 24px 60px; }
|
||||
.settings-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 18px; }
|
||||
|
||||
.settings-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 22px; }
|
||||
.settings-tab {
|
||||
background: none; border: none; cursor: pointer;
|
||||
padding: 12px 16px; font-family: inherit; font-weight: 600; font-size: .95rem;
|
||||
color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.settings-tab:hover { color: var(--text); }
|
||||
.settings-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.settings-panel { display: none; }
|
||||
.settings-panel.active { display: block; }
|
||||
.settings-card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 22px; margin-bottom: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
.settings-card h2 { font-size: 1.05rem; font-weight: 700; margin-bottom: 14px; }
|
||||
.set-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
padding: 11px 0; border-top: 1px solid var(--line);
|
||||
}
|
||||
.set-row:first-of-type { border-top: none; }
|
||||
.set-label { color: var(--muted); font-weight: 600; font-size: .92rem; }
|
||||
.set-value { font-weight: 600; font-size: .92rem; }
|
||||
.set-link { color: var(--primary); font-weight: 600; text-decoration: none; font-size: .92rem; }
|
||||
.set-link:hover { text-decoration: underline; }
|
||||
.set-logout {
|
||||
background: var(--primary-soft); color: var(--primary); border: none;
|
||||
font-family: inherit; font-weight: 700; font-size: .92rem;
|
||||
padding: 11px 20px; border-radius: 9px; cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.set-logout:hover { background: var(--primary); color: #fff; }
|
||||
.settings-page .cd-modes { display: flex; gap: 8px; }
|
||||
.settings-page .cd-accents { display: flex; gap: 10px; }
|
||||
|
||||
/* ───── SEARCH PANEL ───── */
|
||||
.search-panel {
|
||||
position: fixed; inset: 0;
|
||||
z-index: 120;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 18px;
|
||||
opacity: 0; visibility: hidden;
|
||||
transition: opacity .2s ease, visibility .2s;
|
||||
}
|
||||
.search-panel.open { opacity: 1; visibility: visible; }
|
||||
.search-card {
|
||||
width: 100%; max-width: 560px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,.3), 0 6px 16px rgba(0,0,0,.12);
|
||||
padding: 18px;
|
||||
transform: translateY(8px) scale(.93);
|
||||
transition: transform .26s cubic-bezier(.34,1.56,.64,1);
|
||||
}
|
||||
.search-panel.open .search-card { transform: translateY(0) scale(1); }
|
||||
.search-tabs {
|
||||
display: flex; gap: 4px; margin-bottom: 14px;
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 4px;
|
||||
}
|
||||
.stab {
|
||||
flex: 1; padding: 9px 0; border: none; border-radius: 9px;
|
||||
background: transparent; color: var(--muted);
|
||||
font-family: 'Roboto', sans-serif; font-size: .9rem; font-weight: 700;
|
||||
cursor: pointer; transition: background .15s, color .15s, box-shadow .15s;
|
||||
}
|
||||
.stab:hover { color: var(--text); }
|
||||
.stab.active { background: var(--card); color: var(--primary); box-shadow: 0 1px 3px rgba(0,0,0,.12); }
|
||||
.search-field {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 16px; height: 54px;
|
||||
background: var(--panel); border: 1.5px solid var(--line);
|
||||
border-radius: 14px; color: var(--muted); margin-bottom: 14px;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.search-field svg { width: 22px; height: 22px; flex-shrink: 0; }
|
||||
.search-field:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
|
||||
.search-field input {
|
||||
flex: 1; background: none; border: none; outline: none;
|
||||
color: var(--text); font-family: inherit; font-size: 1.05rem; font-weight: 600;
|
||||
}
|
||||
.search-field input::placeholder { color: var(--muted); }
|
||||
.search-results { display: flex; flex-direction: column; gap: 4px; max-height: 52vh; overflow-y: auto; }
|
||||
.search-empty { text-align: center; color: var(--muted); font-weight: 500; padding: 34px 0 28px; font-size: .92rem; }
|
||||
|
||||
.search-clear {
|
||||
flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--card); border: none; cursor: pointer; color: var(--muted);
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.search-clear:hover { background: var(--line); color: var(--text); }
|
||||
.search-clear svg { width: 14px; height: 14px; }
|
||||
|
||||
.search-label {
|
||||
font-size: .7rem; font-weight: 800; letter-spacing: 1px; text-transform: uppercase;
|
||||
color: var(--muted); padding: 4px 6px 8px;
|
||||
}
|
||||
|
||||
.sresult-arrow { width: 16px; height: 16px; color: var(--muted); margin-left: auto; opacity: 0; transition: opacity .15s, transform .15s; flex-shrink: 0; }
|
||||
.sresult:hover .sresult-arrow { opacity: 1; transform: translateX(2px); }
|
||||
.sresult {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px; border-radius: 12px; text-decoration: none; color: var(--text);
|
||||
transition: background .12s;
|
||||
}
|
||||
.sresult:hover { background: var(--panel); }
|
||||
.sresult-avatar {
|
||||
width: 42px; height: 42px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 900; color: #fff; flex-shrink: 0;
|
||||
background: var(--gradient-main);
|
||||
}
|
||||
.sresult-thumb {
|
||||
width: 56px; height: 40px; border-radius: 9px; flex-shrink: 0;
|
||||
background: var(--gradient-main);
|
||||
}
|
||||
.sresult-text { display: flex; flex-direction: column; }
|
||||
.sresult-name { font-weight: 800; font-size: .98rem; }
|
||||
.sresult-sub { font-size: .8rem; color: var(--muted); font-weight: 700; }
|
||||
.search-scrim {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.5); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
|
||||
z-index: 110; display: none;
|
||||
}
|
||||
.search-scrim.open { display: block; }
|
||||
|
||||
/* ───── RESPONSIVE ───── */
|
||||
@media (max-width: 880px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform .25s cubic-bezier(.4,0,.2,1);
|
||||
z-index: 70;
|
||||
}
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.content { margin-left: 0; padding-left: 18px; padding-right: 18px; }
|
||||
.btn-login { display: none; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.logo img { height: 28px; }
|
||||
.top-right .btn-dl { padding: 9px 16px; font-size: .85rem; }
|
||||
}
|
||||
|
||||
/* ───── GENERAL MOBILE POLISH ───── */
|
||||
@media (max-width: 560px) {
|
||||
html, body { overflow-x: hidden; }
|
||||
/* tighter topbar so the icons + Download button fit small phones */
|
||||
.topbar { padding: 0 12px; }
|
||||
.top-left { gap: 8px; }
|
||||
.top-right { gap: 6px; }
|
||||
.icon-btn { width: 38px; height: 38px; }
|
||||
.side-toggle { width: 46px; height: 46px; }
|
||||
.btn-dl { padding: 8px 13px; font-size: .8rem; letter-spacing: 0; }
|
||||
|
||||
/* search panel: keep the card off the screen edges */
|
||||
.search-panel { align-items: flex-start; padding: 70px 14px 14px; }
|
||||
.search-card { padding: 14px; }
|
||||
|
||||
/* feed + cards breathe a little less */
|
||||
.feed-layout { padding-left: 12px; padding-right: 12px; }
|
||||
.home-links { gap: 8px; margin-bottom: 20px; }
|
||||
|
||||
/* updates page */
|
||||
.updates-page { padding-left: 16px; padding-right: 16px; }
|
||||
.updates-head h1 { font-size: 1.55rem; }
|
||||
.update-card { padding: 15px 16px; }
|
||||
|
||||
/* fit the whole topbar on a phone: nothing should run off-screen */
|
||||
.topbar { padding: 0 10px; }
|
||||
.top-right { gap: 5px; min-width: 0; }
|
||||
.btn-login { display: inline-block; white-space: nowrap; padding: 7px 9px; font-size: .78rem; border: none; }
|
||||
.top-right .btn-dl { padding: 7px 11px; font-size: .74rem; white-space: nowrap; }
|
||||
|
||||
/* slideshow arrows overlap the title on narrow screens — hide them and
|
||||
let the dots handle navigation */
|
||||
.featured-slideshow .farrow { display: none; }
|
||||
.featured-dots { gap: 9px; }
|
||||
.fdot { width: 9px; height: 9px; }
|
||||
|
||||
/* pin dropdowns under the topbar and within the screen so they never
|
||||
run off the right edge on a phone */
|
||||
.notif-menu, .profile-menu {
|
||||
position: fixed;
|
||||
top: calc(var(--topbar-h) + 6px);
|
||||
right: 10px; left: auto;
|
||||
width: calc(100vw - 20px); max-width: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───── DOWNLOAD PAGE ───── */
|
||||
.dl-page { background: var(--page); }
|
||||
.dl-wrap { max-width: 720px; margin: 0 auto; padding: calc(var(--topbar-h) + 48px) 24px 60px; }
|
||||
.dl-hero { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 10px; margin-bottom: 36px; }
|
||||
.dl-cat {
|
||||
width: 76px; height: auto; margin-bottom: 6px;
|
||||
filter: invert(54%) sepia(72%) saturate(1400%) hue-rotate(325deg) brightness(103%) contrast(101%);
|
||||
}
|
||||
|
||||
/* hero banner */
|
||||
.dl-banner {
|
||||
position: relative; border-radius: 16px; overflow: hidden;
|
||||
margin-bottom: 28px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06), 0 12px 30px rgba(0,0,0,.08);
|
||||
}
|
||||
.dl-banner-img { display: block; width: 100%; aspect-ratio: 16/7; object-fit: cover; }
|
||||
.dl-banner::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, rgba(15,5,5,.9), rgba(15,5,5,.25) 60%, transparent); }
|
||||
.dl-banner-body { position: absolute; inset: auto 0 0 0; padding: 26px 28px; color: #fff; z-index: 1; }
|
||||
.dl-pill { display: inline-block; background: var(--gradient-main); color: #fff; font-size: .72rem; font-weight: 700; letter-spacing: .4px; padding: 5px 11px; border-radius: 999px; margin-bottom: 12px; }
|
||||
.dl-banner-body h1 { font-size: clamp(1.7rem, 5vw, 2.4rem); font-weight: 700; margin-bottom: 6px; }
|
||||
.dl-banner-body p { font-size: .98rem; font-weight: 500; opacity: .92; max-width: 440px; }
|
||||
|
||||
/* free badge on tiles */
|
||||
.dl-free {
|
||||
margin-left: auto;
|
||||
font-size: .72rem; font-weight: 700; color: var(--primary);
|
||||
background: var(--primary-soft); padding: 4px 10px; border-radius: 999px;
|
||||
}
|
||||
.dl-tile .dl-arrow { margin-left: 12px; }
|
||||
|
||||
/* feature row */
|
||||
.dl-features {
|
||||
display: grid; grid-template-columns: 1fr; gap: 14px;
|
||||
margin: 34px 0;
|
||||
}
|
||||
@media (min-width: 600px) { .dl-features { grid-template-columns: repeat(3, 1fr); } }
|
||||
.dl-feat {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 22px 20px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
.dl-feat svg { width: 26px; height: 26px; color: var(--primary); margin-bottom: 12px; }
|
||||
.dl-feat h3 { font-size: 1rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.dl-feat p { font-size: .88rem; color: var(--muted); font-weight: 500; line-height: 1.5; }
|
||||
|
||||
/* FAQ */
|
||||
.dl-faq { margin-top: 38px; }
|
||||
.dl-faq h2 { font-size: 1.5rem; font-weight: 700; text-align: center; margin-bottom: 20px; }
|
||||
.faq-item {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; margin-bottom: 10px; overflow: hidden;
|
||||
transition: border-color .15s, box-shadow .2s;
|
||||
}
|
||||
.faq-item:hover { border-color: var(--primary); }
|
||||
.faq-item.open { box-shadow: 0 6px 20px rgba(0,0,0,.06); }
|
||||
.faq-q {
|
||||
width: 100%; text-align: left; background: none; border: none;
|
||||
font-family: inherit; cursor: pointer; color: var(--text);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 18px; font-weight: 600; font-size: .98rem;
|
||||
transition: color .15s;
|
||||
}
|
||||
.faq-q:hover { color: var(--primary); }
|
||||
.faq-x {
|
||||
color: var(--muted); font-size: 1.4rem; font-weight: 400; line-height: 1;
|
||||
transition: transform .25s cubic-bezier(.34,1.56,.64,1), color .2s;
|
||||
}
|
||||
.faq-item.open .faq-x { transform: rotate(135deg); color: var(--primary); }
|
||||
.faq-a-wrap {
|
||||
display: grid; grid-template-rows: 0fr;
|
||||
transition: grid-template-rows .3s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.faq-item.open .faq-a-wrap { grid-template-rows: 1fr; }
|
||||
.faq-a { overflow: hidden; }
|
||||
.faq-a p {
|
||||
padding: 0 18px 18px; color: var(--muted); font-weight: 500; font-size: .92rem; line-height: 1.6;
|
||||
}
|
||||
.dl-title { font-size: clamp(1.8rem, 6vw, 2.8rem); font-weight: 700; }
|
||||
.dl-sub { font-size: 1.05rem; color: var(--muted); font-weight: 700; max-width: 440px; }
|
||||
.dl-grid { display: grid; grid-template-columns: 1fr; gap: 14px; max-width: 640px; margin: 0 auto 30px; }
|
||||
@media (min-width: 600px) { .dl-grid { grid-template-columns: 1fr 1fr; } }
|
||||
.dl-tile {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 18px 20px; background: var(--card);
|
||||
border: 1px solid var(--line); border-radius: 12px;
|
||||
text-decoration: none; color: var(--text);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
transition: transform .12s, border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.dl-tile:hover { transform: translateY(-2px); border-color: var(--primary); box-shadow: 0 8px 22px rgba(0,0,0,.08); }
|
||||
.dl-tile svg { color: var(--text); flex-shrink: 0; }
|
||||
.dl-icon { width: 40px; height: 40px; object-fit: contain; flex-shrink: 0; }
|
||||
.dl-arrow { margin-left: auto; color: var(--muted); font-size: 1.2rem; transition: transform .15s, color .15s; }
|
||||
.dl-tile:hover .dl-arrow { color: var(--primary); transform: translateX(3px); }
|
||||
.dl-tile-text { display: flex; flex-direction: column; }
|
||||
.dl-small { font-size: .8rem; color: var(--muted); font-weight: 700; }
|
||||
.dl-big { font-size: 1.25rem; font-weight: 800; }
|
||||
.dl-info-block { max-width: 640px; margin: 0 auto; }
|
||||
.dl-info-block h2 { text-align: center; font-size: 1.5rem; font-weight: 700; margin-bottom: 20px; }
|
||||
.req-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.req { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 20px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
|
||||
.req h3 { color: var(--primary); font-weight: 800; margin-bottom: 12px; }
|
||||
.req ul { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
||||
.req li { color: var(--muted); font-weight: 700; font-size: .92rem; padding-left: 16px; position: relative; }
|
||||
.req li::before { content: '›'; position: absolute; left: 0; color: var(--primary); font-weight: 900; }
|
||||
.dl-back { display: block; text-align: center; margin: 30px auto; color: var(--muted); font-weight: 800; text-decoration: none; }
|
||||
.dl-back:hover { color: var(--primary); }
|
||||
|
||||
/* ───── LOGIN PAGE ───── */
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 32px 18px;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%, var(--primary-soft), transparent 70%),
|
||||
var(--bg);
|
||||
}
|
||||
.login-wrap { display: flex; flex-direction: column; align-items: center; gap: 24px; width: 100%; max-width: 400px; }
|
||||
.login-card-logo { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; }
|
||||
.cat-shift {
|
||||
display: inline-block; position: relative; z-index: 2;
|
||||
/* start centered over the text spot, then slide left as the text appears */
|
||||
transform: translateX(82px);
|
||||
animation: catShift .5s cubic-bezier(.22,1,.36,1) .72s both;
|
||||
}
|
||||
.login-cat {
|
||||
width: 104px; height: auto; display: block;
|
||||
transition: transform .3s ease;
|
||||
animation: catWiggle .85s ease, catGradientCycle 7s ease-in-out infinite alternate;
|
||||
}
|
||||
.login-card-logo:hover .login-cat { transform: rotate(-6deg) scale(1.05); }
|
||||
|
||||
@keyframes catShift {
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.brand-wrap { display: inline-block; line-height: 1.2; padding: 2px 0; }
|
||||
.login-brand {
|
||||
display: inline-block; white-space: nowrap;
|
||||
font-size: 2rem; font-weight: 700; color: var(--text); letter-spacing: -0.03em;
|
||||
transform: translateX(-32px); opacity: 0;
|
||||
animation: brandSlide .5s cubic-bezier(.22,1,.36,1) .72s forwards;
|
||||
}
|
||||
|
||||
@keyframes catWiggle {
|
||||
0% { transform: rotate(0) scale(.7); opacity: 0; }
|
||||
35% { opacity: 1; }
|
||||
45% { transform: rotate(-15deg) scale(1.1); }
|
||||
70% { transform: rotate(10deg) scale(1.02); }
|
||||
100% { transform: rotate(0) scale(1); }
|
||||
}
|
||||
@keyframes brandSlide {
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 12px 32px rgba(0,0,0,.08);
|
||||
padding: 34px 30px;
|
||||
}
|
||||
.login-card h1 { font-size: 1.7rem; font-weight: 700; text-align: center; letter-spacing: -0.03em; }
|
||||
.login-sub { text-align: center; color: var(--muted); font-weight: 500; margin: 8px 0 26px; font-size: .95rem; }
|
||||
|
||||
.field { display: block; margin-bottom: 18px; }
|
||||
.field span { display: block; font-size: .82rem; font-weight: 600; color: var(--text); margin-bottom: 8px; }
|
||||
.field input {
|
||||
width: 100%; height: 46px;
|
||||
padding: 0 15px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
font-family: inherit; font-size: .98rem; font-weight: 500; color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s, background .15s;
|
||||
}
|
||||
.field input:focus { border-color: var(--primary); background: var(--card); box-shadow: 0 0 0 3px var(--primary-soft); }
|
||||
.field input::placeholder { color: var(--muted); opacity: .6; }
|
||||
|
||||
.login-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; font-size: .88rem; font-weight: 500; }
|
||||
.remember { display: flex; align-items: center; gap: 8px; color: var(--text); cursor: pointer; }
|
||||
.remember input { accent-color: var(--primary); width: 16px; height: 16px; }
|
||||
.forgot { color: var(--primary); text-decoration: none; font-weight: 600; }
|
||||
.forgot:hover { text-decoration: underline; }
|
||||
|
||||
.login-submit {
|
||||
width: 100%; text-align: center;
|
||||
padding: 14px 0; font-size: 1rem; font-weight: 700;
|
||||
border: none; outline: none;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
cursor: pointer; line-height: 1;
|
||||
}
|
||||
.login-submit:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||
|
||||
.login-divider { display: flex; align-items: center; gap: 12px; margin: 24px 0; color: var(--muted); font-weight: 500; font-size: .82rem; }
|
||||
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--line); }
|
||||
|
||||
.login-alt {
|
||||
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||
width: 100%; padding: 12px 0;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
text-decoration: none; color: var(--text);
|
||||
font-weight: 600; font-size: .95rem;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.login-alt:hover { border-color: var(--text); background: var(--panel); }
|
||||
.login-alt-icon { width: 20px; height: 20px; object-fit: contain; }
|
||||
|
||||
.login-error { color: var(--primary); font-weight: 600; font-size: .85rem; margin: -4px 0 14px; display: none; }
|
||||
.login-error.show { display: block; }
|
||||
.login-hint {
|
||||
text-align: center; margin-top: 18px;
|
||||
font-size: .82rem; color: var(--muted); font-weight: 500;
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 9px; padding: 9px;
|
||||
}
|
||||
.login-hint b { color: var(--text); font-weight: 700; }
|
||||
|
||||
/* logged-in profile chip */
|
||||
.profile { display: flex; align-items: center; position: relative; }
|
||||
.profile-av {
|
||||
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
|
||||
cursor: pointer; border: 2px solid transparent;
|
||||
transition: border-color .15s, transform .12s;
|
||||
}
|
||||
.profile-av:hover, .profile.open .profile-av { border-color: var(--primary); }
|
||||
|
||||
.profile-menu {
|
||||
position: absolute; top: calc(100% + 12px); right: 0;
|
||||
width: 210px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; box-shadow: 0 12px 36px rgba(0,0,0,.16);
|
||||
padding: 6px;
|
||||
opacity: 0; visibility: hidden; transform: translateY(-8px) scale(.98);
|
||||
transform-origin: top right;
|
||||
transition: opacity .16s ease, transform .16s ease, visibility .16s;
|
||||
z-index: 130;
|
||||
}
|
||||
.profile.open .profile-menu { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
|
||||
|
||||
.pm-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 10px 12px; margin-bottom: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.pm-av { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
|
||||
.pm-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.pm-name { font-weight: 700; font-size: .92rem; }
|
||||
.pm-handle { font-size: .8rem; color: var(--muted); font-weight: 500; }
|
||||
|
||||
.pm-tokens {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 8px 10px; margin: 0 2px 8px;
|
||||
}
|
||||
.pm-token-ic {
|
||||
width: 36px; height: 36px; border-radius: 9px; flex-shrink: 0;
|
||||
background: var(--primary-soft);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.pm-token-ic svg { width: 24px; height: 24px; color: var(--primary); }
|
||||
.pm-token-val { font-weight: 700; font-size: .95rem; color: var(--text); flex: 1; text-align: center; }
|
||||
|
||||
.pm-item {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
width: 100%; text-align: left;
|
||||
padding: 10px 12px; border-radius: 9px;
|
||||
text-decoration: none; color: var(--text);
|
||||
font-weight: 600; font-size: .9rem;
|
||||
background: none; border: none; font-family: inherit; cursor: pointer;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.pm-item svg { width: 19px; height: 19px; color: var(--muted); flex-shrink: 0; }
|
||||
.pm-item:hover { background: var(--panel); }
|
||||
.pm-item:hover svg { color: var(--text); }
|
||||
.pm-logout { color: var(--primary); margin-top: 2px; }
|
||||
.pm-logout svg { color: var(--primary); }
|
||||
.pm-logout:hover { background: var(--primary-soft); }
|
||||
.pm-logout:hover svg { color: var(--primary); }
|
||||
|
||||
/* compose bar (logged in) */
|
||||
.compose {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 12px 14px; margin-bottom: 24px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
.compose-av { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; }
|
||||
.compose-input {
|
||||
flex: 1; color: var(--muted); font-weight: 500; font-size: .92rem;
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 999px; padding: 10px 16px; cursor: text;
|
||||
}
|
||||
.compose-btn {
|
||||
background: var(--gradient-main); color: #fff; border: none; border-radius: 8px;
|
||||
padding: 9px 18px; font-family: inherit; font-weight: 600; font-size: .9rem; cursor: pointer;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.compose-btn:hover { box-shadow: 0 4px 16px rgba(139,92,246,0.4); }
|
||||
|
||||
.login-foot { text-align: center; margin-top: 14px; color: var(--muted); font-weight: 500; font-size: .9rem; }
|
||||
.login-foot a { color: var(--primary); text-decoration: none; font-weight: 600; }
|
||||
.login-foot a:hover { text-decoration: underline; }
|
||||
/* favourite star in the friends list (own list only) */
|
||||
.fl-item{position:relative}
|
||||
.fl-fav{margin-left:auto;background:none;border:0;cursor:pointer;font-size:1.15rem;line-height:1;color:var(--muted,#888);padding:4px 6px;border-radius:6px}
|
||||
.fl-fav:hover{color:var(--primary,#f5a623)}
|
||||
.fl-fav.on{color:var(--primary,#f5a623)}
|
||||
|
||||
/* subscribe button in the subscribers/subscriptions popup */
|
||||
.fl-sub-btn{margin-left:auto;padding:6px 12px;border-radius:999px;border:1px solid var(--line,#333);background:transparent;color:inherit;font:inherit;font-size:.8rem;font-weight:600;cursor:pointer;white-space:nowrap}
|
||||
.fl-sub-btn:hover{border-color:var(--primary,#f5a623)}
|
||||
.fl-sub-btn.on{background:var(--primary,#f5a623);border-color:var(--primary,#f5a623);color:#111}
|
||||
|
||||
|
||||
/* ---- Spotify "listening to" (profile status, friends list, Settings preview) ---- */
|
||||
.spotify-now { display: flex; align-items: center; gap: 10px; text-decoration: none; color: inherit; max-width: 420px; }
|
||||
.spotify-now[hidden] { display: none; }
|
||||
.spotify-now .sp-art { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; flex: 0 0 auto; background: rgba(30,215,96,.15); }
|
||||
.spotify-now .sp-meta { min-width: 0; flex: 1 1 auto; }
|
||||
.spotify-now .sp-line { display: flex; align-items: center; gap: 6px; font-size: .85rem; font-weight: 600; white-space: nowrap; overflow: hidden; }
|
||||
.spotify-now .sp-line .sp-ic { width: 14px; height: 14px; flex: 0 0 auto; color: #1DB954; }
|
||||
.spotify-now .sp-line .sp-track { overflow: hidden; text-overflow: ellipsis; }
|
||||
.spotify-now .sp-line .sp-by { color: var(--muted); font-weight: 500; overflow: hidden; text-overflow: ellipsis; }
|
||||
.spotify-now .sp-bar { position: relative; height: 4px; border-radius: 2px; background: rgba(128,128,140,.25); margin-top: 6px; overflow: hidden; }
|
||||
.spotify-now .sp-bar i { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: #1DB954; border-radius: 2px; transition: width .5s linear; }
|
||||
.spotify-now .sp-time { display: flex; justify-content: space-between; font-size: .72rem; color: var(--muted); margin-top: 3px; font-variant-numeric: tabular-nums; }
|
||||
.spotify-now.paused .sp-bar i { background: #8a8f98; }
|
||||
.profile-spotify { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--card); border: 1px solid rgba(128,128,140,.18); }
|
||||
/* The profile column is narrow: track on one line, artist on the next, both clipped. */
|
||||
.profile-spotify .sp-line { flex-wrap: wrap; white-space: normal; row-gap: 0; }
|
||||
.profile-spotify .sp-line .sp-track { flex: 1 1 100%; white-space: nowrap; }
|
||||
.profile-spotify .sp-line .sp-by { flex: 1 1 100%; white-space: nowrap; margin-left: 20px; }
|
||||
.fl-spotify { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: .8rem; margin-top: 2px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fl-spotify .sp-ic { width: 12px; height: 12px; color: #1DB954; flex: 0 0 auto; }
|
||||
|
||||
|
||||
/* ---- socials (Settings > Profile > Socials; profile chips) ---- */
|
||||
.profile-socials { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.social-chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px 6px 8px; border-radius: 999px;
|
||||
background: var(--card-2, rgba(127,127,127,.12)); color: inherit; text-decoration: none; font-size: .86rem; line-height: 1;
|
||||
border: 1px solid rgba(127,127,127,.25); max-width: 100%; }
|
||||
.social-chip:hover { border-color: var(--accent, #7c5cff); }
|
||||
.social-chip svg { width: 16px; height: 16px; flex: none; }
|
||||
.social-chip .sc-handle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.social-chip.static { cursor: default; }
|
||||
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════════════════════
|
||||
RESTYLE — same site, plainer clothes. Front page after the classic RecNet layout (hero band,
|
||||
four feature tiles, rooms rail + photo feed); every page: Roboto, flat violet accent, one
|
||||
gradient (the hero / profile banner) and nothing else glowing. Only overrides what is above.
|
||||
═══════════════════════════════════════════════════════════════════════════════════════════ */
|
||||
:root {
|
||||
--primary: #7C3AED;
|
||||
--primary-dark: #6D28D9;
|
||||
--primary-soft: #F1EBFF;
|
||||
--brand-gradient: linear-gradient(120deg, #6D28D9 0%, #A21CAF 35%, #DB2777 65%, #F97316 100%);
|
||||
--gradient-main: linear-gradient(#7C3AED, #7C3AED);
|
||||
--gradient-warm: linear-gradient(#F97316, #F97316);
|
||||
--gradient-cool: linear-gradient(#7C3AED, #7C3AED);
|
||||
--page: #f5f5f7;
|
||||
--card: #ffffff;
|
||||
--panel: #f0f0f3;
|
||||
--line: #e4e4e8;
|
||||
--text: #1c1c1e;
|
||||
--muted: #6e6e76;
|
||||
--topbar-bg: #ffffff;
|
||||
--radius: 10px;
|
||||
--container: 1180px;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--primary: #9D6BFF;
|
||||
--primary-dark: #7C3AED;
|
||||
--primary-soft: rgba(157,107,255,.14);
|
||||
--page: #121214;
|
||||
--card: #1b1b1f;
|
||||
--panel: #242429;
|
||||
--line: rgba(255,255,255,.09);
|
||||
--text: #f2f2f4;
|
||||
--muted: #9a9aa3;
|
||||
--topbar-bg: #1b1b1f;
|
||||
}
|
||||
body { font-family: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif; letter-spacing: 0; font-size: 15px; }
|
||||
h1, h2, h3, h4, .logo-text, .btn-dl { font-family: inherit; letter-spacing: 0; }
|
||||
::selection { background: var(--primary); color: #fff; }
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline-color: var(--primary); }
|
||||
|
||||
/* Flat: no glows, no lifts, no gradient text. */
|
||||
.btn-dl, .btn-danger, .rail-discord, .comment-send, .follow-btn, .fl-accept, .snav.active, .utab.active, .fl-tab.active, .ssave {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.btn-dl:hover, .rail-discord:hover { box-shadow: none; background: var(--primary-dark); }
|
||||
.btn-dl:active { transform: none; }
|
||||
.post:hover, .rail-item:hover, .home-link:hover, .featured:hover, .dl-tile:hover, .card:hover { transform: none; }
|
||||
.logo-text, .topbar-brand {
|
||||
background: none; -webkit-text-fill-color: currentColor; color: var(--text); animation: none;
|
||||
}
|
||||
.burger-cat, .logo-cat {
|
||||
animation: none;
|
||||
filter: invert(31%) sepia(62%) saturate(3600%) hue-rotate(252deg) brightness(93%) contrast(96%);
|
||||
}
|
||||
.side-toggle:hover .burger-cat { filter: invert(31%) sepia(62%) saturate(3600%) hue-rotate(252deg) brightness(80%) contrast(96%); }
|
||||
[data-theme="dark"] .burger-cat, [data-theme="dark"] .logo-cat, [data-theme="dark"] .side-toggle:hover .burger-cat {
|
||||
filter: invert(60%) sepia(50%) saturate(2400%) hue-rotate(232deg) brightness(105%) contrast(97%);
|
||||
}
|
||||
|
||||
/* Small caps labels -> plain labels. */
|
||||
.cd-label, .rail-title, .search-label, .fo-title, .ped-mon, .dl-pill, .featured-tag, .a11y-name, .st-sec h3 {
|
||||
text-transform: none; letter-spacing: 0; font-size: .8rem; font-weight: 500;
|
||||
}
|
||||
.featured-tag { background: rgba(255,255,255,.18); border-radius: 6px; padding: 4px 9px; }
|
||||
.dl-pill { border-radius: 6px; }
|
||||
|
||||
/* Pills -> buttons. (Slide dots, arrows, switches, badges and avatars keep their shape.) */
|
||||
button:not(.fdot):not(.farrow):not(.side-toggle):not(.icon-btn):not(.profile-av),
|
||||
.btn-dl, .btn-login, .btn-danger, .utab, .fl-tab, .follow-btn, .comment-send, .update-tag, .stab, .rail-discord, .hero-dl, .hot-btn, .ptab, .snav {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.notif-badge, .slider, .online-dot, .status-dot { border-radius: 999px; }
|
||||
input[type="text"], input[type="password"], input[type="email"], input[type="search"], input[type="url"], textarea, select, .comment-input, .search-field, .title-input {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Cards: one border, no shadow. */
|
||||
.post, .featured, .login-card, .report-card, .spanel, .st-sec, .panel, .dl-tile, .profile-panel, .faq-item,
|
||||
.search-card, .profile-menu, .notif-panel, .rail-box, .hf-img, .home-link, .room-card, .update-card, .updates-list > article, .statbox, .card {
|
||||
border-radius: var(--radius); box-shadow: none; border: 1px solid var(--line);
|
||||
}
|
||||
.search-card, .profile-menu, .notif-panel { box-shadow: 0 10px 30px rgba(0,0,0,.12); }
|
||||
.cat-drawer { border-radius: 0; box-shadow: none; border-right: 1px solid var(--line); }
|
||||
.cat-drawer.open { box-shadow: 0 0 40px rgba(0,0,0,.15); }
|
||||
|
||||
/* ── top bar ──────────────────────────────────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
padding: 0 20px; gap: 16px;
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: none; -webkit-backdrop-filter: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.top-left { gap: 6px; }
|
||||
.side-toggle { width: 44px; height: 44px; }
|
||||
.burger-cat { width: 30px !important; height: 30px !important; }
|
||||
.topbar-brand.always {
|
||||
opacity: 1; transform: none; pointer-events: auto; cursor: pointer;
|
||||
font-size: 1.15rem; font-weight: 700; color: var(--text);
|
||||
}
|
||||
.top-mid { flex: 1; display: flex; padding-left: 10px; max-width: 400px; }
|
||||
.top-search {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
height: 36px; padding: 0 12px; border-radius: 8px;
|
||||
background: var(--panel); border: 1px solid transparent; color: var(--muted);
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.top-search:hover, .top-search:focus-within { border-color: var(--primary); background: var(--card); }
|
||||
.top-search svg { width: 16px; height: 16px; }
|
||||
.top-search input { flex: 1; min-width: 0; background: none; border: 0; outline: none; color: var(--text); font: inherit; font-size: .9rem; }
|
||||
.top-search input::placeholder { color: var(--muted); }
|
||||
.top-right { gap: 4px; }
|
||||
.top-nav-links { display: flex; align-items: center; gap: 2px; margin-right: 8px; }
|
||||
.top-nav-links a {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: var(--muted);
|
||||
font-size: .9rem; font-weight: 500;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.top-nav-links a svg { width: 16px; height: 16px; }
|
||||
.top-nav-links a:hover { background: var(--panel); color: var(--text); }
|
||||
.icon-btn { border-radius: 8px; width: 38px; height: 38px; }
|
||||
.icon-btn:hover { color: var(--text); }
|
||||
#searchBtn { display: none; }
|
||||
.btn-login { padding: 8px 12px; border: 0; font-size: .9rem; font-weight: 500; color: var(--muted); }
|
||||
.btn-login:hover { background: var(--panel); color: var(--text); }
|
||||
.topbar .btn-dl {
|
||||
background: transparent; color: var(--primary);
|
||||
border: 1.5px solid var(--primary);
|
||||
padding: 7px 14px; font-size: .85rem; font-weight: 500; letter-spacing: 0;
|
||||
}
|
||||
.topbar .btn-dl:hover { background: var(--primary); color: #fff; }
|
||||
.profile-av { border-radius: 50%; }
|
||||
|
||||
/* ── home: hero band ──────────────────────────────────────────────────────────────────────── */
|
||||
.home-main { padding-top: var(--topbar-h); }
|
||||
.home-hero {
|
||||
position: relative; overflow: hidden; min-height: 240px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--brand-gradient);
|
||||
color: #fff; text-align: center;
|
||||
}
|
||||
.hero-wave { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.hero-wave-back path { fill: rgba(255,255,255,.08); }
|
||||
.hero-wave-front path { fill: rgba(0,0,0,.10); }
|
||||
.hero-inner { position: relative; z-index: 1; padding: 44px 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.hero-title { font-size: clamp(1.2rem, 2.4vw, 1.55rem); font-weight: 700; letter-spacing: 0; }
|
||||
.hero-dl {
|
||||
display: inline-block; text-decoration: none;
|
||||
background: #fff; color: var(--primary-dark);
|
||||
padding: 13px 56px; font-weight: 500; font-size: .95rem; letter-spacing: .02em;
|
||||
transition: background .15s;
|
||||
}
|
||||
.hero-dl:hover { background: #f3eefc; }
|
||||
|
||||
/* ── home: feature tiles ──────────────────────────────────────────────────────────────────── */
|
||||
.home-features {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 22px;
|
||||
max-width: var(--container); margin: 0 auto; padding: 32px 24px 4px;
|
||||
}
|
||||
.hf { min-width: 0; }
|
||||
.hf-img {
|
||||
aspect-ratio: 16 / 9;
|
||||
background-image: url('assets/hero-bg.jpg'); background-repeat: no-repeat;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.hf-title { font-size: .78rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--primary); margin-bottom: 6px; }
|
||||
.hf-text { font-size: .82rem; line-height: 1.55; color: var(--muted); }
|
||||
|
||||
/* ── home: rail + feed ────────────────────────────────────────────────────────────────────── */
|
||||
body.home .feed-layout {
|
||||
grid-template-columns: 300px minmax(0, 1fr); gap: 24px;
|
||||
max-width: var(--container); padding: 26px 24px 70px;
|
||||
}
|
||||
.rail-col { align-self: start; }
|
||||
body.home .rail { position: static; }
|
||||
.hot-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
width: 100%; padding: 10px 14px; margin-bottom: 14px;
|
||||
border: 1px solid var(--line); background: var(--card); color: var(--text); text-decoration: none;
|
||||
font-size: .9rem; font-weight: 500;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.hot-btn svg { width: 16px; height: 16px; color: #F97316; }
|
||||
.hot-btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.rail-box {
|
||||
background: var(--card); padding: 14px 12px 6px;
|
||||
}
|
||||
.rail-box-head { display: flex; flex-direction: column; gap: 2px; padding: 0 4px 12px; }
|
||||
.rail-box-head strong { font-size: .95rem; font-weight: 700; }
|
||||
.rail-box-head span { font-size: .78rem; color: var(--muted); }
|
||||
.rail-box-head span a { color: var(--primary); text-decoration: none; }
|
||||
body.home .rail > .rail-title { display: none; }
|
||||
body.home .rail-online .rail-title, body.home .rail-pinned .rail-title { display: block; margin: 4px 4px 10px; }
|
||||
body.home .rail > .rail-item {
|
||||
display: block; padding: 0; margin: 0 0 12px; border-radius: 8px;
|
||||
background: var(--card); overflow: hidden; border: 1px solid var(--line);
|
||||
transition: border-color .15s;
|
||||
}
|
||||
body.home .rail > .rail-item:hover { border-color: var(--primary); background: var(--card); box-shadow: none; }
|
||||
body.home .rail > .rail-item .rail-thumb {
|
||||
width: 100%; height: auto; aspect-ratio: 16 / 9; border-radius: 0; box-shadow: none;
|
||||
background-size: cover !important; background-position: center !important;
|
||||
background-image: linear-gradient(135deg, var(--a, #7C3AED), var(--b, #F97316));
|
||||
}
|
||||
body.home .rail > .rail-item .rail-meta { flex-direction: row; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 11px; }
|
||||
body.home .rail > .rail-item .rail-name { font-size: .86rem; font-weight: 500; }
|
||||
body.home .rail > .rail-item .rail-by { font-size: .74rem; font-weight: 400; white-space: nowrap; }
|
||||
.rail-name, .rail-by, .post-user, .post-handle, .post-time, .post-cap, .pstat { font-weight: 500; }
|
||||
.rail-by, .post-handle, .post-time { font-weight: 400; }
|
||||
|
||||
body.home .feed-title { font-size: 1.05rem; font-weight: 700; margin: 2px 0 18px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
||||
body.home .home-links { margin-bottom: 22px; }
|
||||
.home-link { font-weight: 500; }
|
||||
.home-link:hover { box-shadow: none; }
|
||||
body.home .featured-slideshow { margin-bottom: 22px; }
|
||||
.featured-body h2 { font-weight: 700; }
|
||||
|
||||
/* ── posts ────────────────────────────────────────────────────────────────────────────────── */
|
||||
.post { overflow: hidden; }
|
||||
.post-head { padding: 12px 16px; }
|
||||
.post-img { border-radius: 0; }
|
||||
.post-foot { padding: 12px 16px; }
|
||||
.post-cap { color: var(--muted); }
|
||||
.pstat { padding: 5px 10px; border-radius: 6px; background: var(--panel); }
|
||||
.pstat:hover { background: var(--primary-soft); color: var(--primary); }
|
||||
|
||||
/* ── other pages ──────────────────────────────────────────────────────────────────────────── */
|
||||
.profile-banner { background: var(--brand-gradient); height: 240px; }
|
||||
.login-card { padding: 32px; }
|
||||
.snav { font-weight: 500; }
|
||||
.snav.active { background: var(--primary); }
|
||||
.utab, .fl-tab, .ptab, .stab { font-weight: 500; }
|
||||
.utab.active, .fl-tab.active { background: var(--primary); border-color: var(--primary); }
|
||||
.dl-banner { border-radius: var(--radius); }
|
||||
.dl-big { font-weight: 700; }
|
||||
.dl-small { font-weight: 400; }
|
||||
.dl-tile:hover { box-shadow: none; }
|
||||
.sresult-avatar, .sresult-thumb, .rail-thumb, .card-thumb, .room-card-cover { background: var(--primary); }
|
||||
.photo { border-radius: 8px; }
|
||||
|
||||
/* Page-local styles (stats / support / image) that still shout. */
|
||||
body .st-sec h2, body .st-tok .l, body .kind, body .recent th, body .sale .num span, body .badge, body .tdetails .dh, body .tagpop .tp-head {
|
||||
text-transform: none; letter-spacing: 0; font-weight: 500;
|
||||
}
|
||||
body .st-sec h2 { font-size: .95rem; color: var(--text); }
|
||||
body .st-hero { border-radius: var(--radius); box-shadow: none; }
|
||||
body .st-hero::after { display: none; }
|
||||
body .st-tok .v, body .st-box .v { font-weight: 700; }
|
||||
body .kind, body .badge { border-radius: 6px; }
|
||||
body .sup-hero h1 { font-weight: 700; letter-spacing: 0; }
|
||||
body .sup-hero { background: var(--card); border-radius: var(--radius); }
|
||||
body .sup-cta, body .asst-av, body .choice.primary, body .sup-tabs button.active, body .msg.me { box-shadow: none; }
|
||||
|
||||
/* ── responsive ───────────────────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 1080px) {
|
||||
.home-features { grid-template-columns: repeat(2, 1fr); }
|
||||
body.home .feed-layout { grid-template-columns: 260px minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.top-mid { display: none; }
|
||||
#searchBtn { display: flex; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.top-nav-links { display: none; }
|
||||
.home-hero { min-height: 190px; }
|
||||
.hero-inner { padding: 32px 20px; }
|
||||
.hero-dl { padding: 12px 36px; }
|
||||
.home-features { gap: 16px; padding: 24px 16px 0; }
|
||||
body.home .feed-layout { grid-template-columns: 1fr; padding: 22px 16px 60px; }
|
||||
.rail-col { order: 2; }
|
||||
.profile-banner { height: 180px; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.home-features { grid-template-columns: 1fr; }
|
||||
.topbar .btn-dl { display: none; }
|
||||
}
|
||||
|
||||
|
||||
/* ── /rooms: browse ───────────────────────────────────────────────────────────────────────── */
|
||||
.rooms-page { max-width: var(--container); margin: 0 auto; padding: calc(var(--topbar-h) + 22px) 24px 60px; }
|
||||
.rooms-head { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||
.rooms-tabs { display: inline-flex; background: var(--panel); border-radius: 8px; padding: 3px; }
|
||||
.rtab {
|
||||
border: 0; background: transparent; color: var(--muted); cursor: pointer;
|
||||
font: inherit; font-size: .9rem; font-weight: 500; padding: 7px 16px; border-radius: 6px !important;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.rtab:hover { color: var(--text); }
|
||||
.rtab.active { background: var(--card); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.08); }
|
||||
[data-theme="dark"] .rtab.active { background: #33333a; }
|
||||
.rooms-filter {
|
||||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px; max-width: 360px;
|
||||
height: 38px; padding: 0 12px; border-radius: 8px;
|
||||
background: var(--card); border: 1px solid var(--line); color: var(--muted);
|
||||
}
|
||||
.rooms-filter:focus-within { border-color: var(--primary); }
|
||||
.rooms-filter input { flex: 1; min-width: 0; border: 0; outline: none; background: none; font: inherit; font-size: .9rem; color: var(--text); }
|
||||
.rooms-count { margin-left: auto; font-size: .85rem; color: var(--muted); }
|
||||
.rooms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 18px; }
|
||||
.rooms-grid .feed-loading { grid-column: 1 / -1; }
|
||||
.rc {
|
||||
display: block; text-decoration: none; color: inherit;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.rc:hover { border-color: var(--primary); }
|
||||
.rc-img { aspect-ratio: 16 / 9; background: var(--panel) center / cover no-repeat; }
|
||||
.rc-img-empty { display: flex; align-items: center; justify-content: center; color: var(--muted); font-weight: 500; font-size: .95rem; padding: 12px; text-align: center; }
|
||||
.rc-body { display: grid; grid-template-columns: 1fr auto; grid-template-areas: "name stats" "by stats"; align-items: center; column-gap: 10px; padding: 10px 12px; }
|
||||
.rc-name { grid-area: name; font-weight: 500; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rc:hover .rc-name { color: var(--primary); }
|
||||
.rc-by { grid-area: by; font-size: .76rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rc-stats { grid-area: stats; display: flex; gap: 10px; font-size: .76rem; color: var(--muted); white-space: nowrap; }
|
||||
.rc-stats span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.rc-stats svg { width: 13px; height: 13px; }
|
||||
.rc-stats .cheer-icon { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.rc-stats img { width: 13px; height: 13px; object-fit: contain; }
|
||||
.rooms-more { display: flex; justify-content: center; margin-top: 26px; }
|
||||
.btn-more {
|
||||
font: inherit; font-size: .9rem; font-weight: 500; cursor: pointer;
|
||||
padding: 10px 28px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--text);
|
||||
}
|
||||
.btn-more:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn-more:disabled { opacity: .6; cursor: default; }
|
||||
|
||||
/* ── phones ───────────────────────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
:root { --topbar-h: 56px; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body { font-size: 14px; }
|
||||
.topbar { padding: 0 10px; gap: 6px; }
|
||||
.side-toggle { width: 40px; height: 40px; }
|
||||
.topbar-brand.always { font-size: 1.05rem; }
|
||||
.top-right { gap: 0; }
|
||||
.btn-login { padding: 8px 8px; }
|
||||
.icon-btn { width: 36px; height: 36px; }
|
||||
.profile-menu, .notif-panel { right: 8px; left: 8px; width: auto; max-width: none; }
|
||||
|
||||
.home-hero { min-height: 170px; }
|
||||
.hero-inner { padding: 28px 16px; gap: 16px; }
|
||||
.hero-title { font-size: 1.1rem; }
|
||||
.hero-dl { width: 100%; max-width: 320px; text-align: center; padding: 12px 20px; }
|
||||
.home-features { grid-template-columns: 1fr; gap: 20px; padding: 22px 16px 0; }
|
||||
.hf-img { aspect-ratio: 2 / 1; }
|
||||
body.home .feed-layout { padding: 20px 12px 50px; gap: 20px; }
|
||||
body.home .home-links { grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.home-link { padding: 12px 6px; font-size: .78rem; }
|
||||
.post-head, .post-foot { padding: 10px 12px; }
|
||||
.post-stats { gap: 8px; }
|
||||
.rail-box { padding: 12px 10px 4px; }
|
||||
|
||||
.rooms-page { padding: calc(var(--topbar-h) + 14px) 12px 50px; }
|
||||
.rooms-head { gap: 10px; margin-bottom: 14px; }
|
||||
.rooms-tabs { width: 100%; }
|
||||
.rtab { flex: 1; padding: 8px 6px; }
|
||||
.rooms-filter { max-width: none; flex-basis: 100%; }
|
||||
.rooms-count { margin-left: 0; }
|
||||
.rooms-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.rc-body { grid-template-columns: 1fr; grid-template-areas: "name" "by" "stats"; row-gap: 2px; padding: 8px 10px; }
|
||||
.rc-name { font-size: .84rem; }
|
||||
|
||||
.updates-page, .profile-layout, .settings-page, .st-wrap, .dl-page, .sup-page { padding-left: 12px; padding-right: 12px; }
|
||||
.profile-banner { height: 140px; }
|
||||
.profile-layout { grid-template-columns: 1fr; }
|
||||
.req-grid, .statbox-row { grid-template-columns: 1fr; }
|
||||
.login-card { padding: 22px 16px; }
|
||||
.search-panel .search-card { width: auto; left: 8px; right: 8px; max-width: none; }
|
||||
.featured-body { padding: 14px; }
|
||||
.featured-body h2 { font-size: 1.15rem; }
|
||||
.dl-banner-img { aspect-ratio: 16 / 11; }
|
||||
.dl-banner::after { background: linear-gradient(to top, rgba(15,5,5,.92) 45%, rgba(15,5,5,.35)); }
|
||||
.dl-banner-body { padding: 16px; }
|
||||
.dl-banner-body h1 { font-size: 1.5rem; }
|
||||
.dl-banner-body p { font-size: .88rem; }
|
||||
/* home rail rooms: a compact row on a phone instead of a stack of big tiles */
|
||||
body.home .rail > .rail-item { display: flex; align-items: center; gap: 10px; padding: 6px; margin-bottom: 8px; }
|
||||
body.home .rail > .rail-item .rail-thumb { width: 92px; aspect-ratio: 16 / 10; border-radius: 6px; flex-shrink: 0; }
|
||||
body.home .rail > .rail-item .rail-meta { flex-direction: column; align-items: flex-start; gap: 2px; padding: 0; min-width: 0; }
|
||||
}
|
||||
|
||||
|
||||
/* ── no-box pass: cards are flat surfaces, not outlined boxes ─────────────────────────────── */
|
||||
.post, .featured, .login-card, .report-card, .spanel, .st-sec, .panel, .dl-tile, .profile-panel, .faq-item,
|
||||
.rail-box, .hf-img, .home-link, .room-card, .update-card, .updates-list > article, .statbox, .card, .rc, .hot-btn,
|
||||
.rooms-filter, .top-search, .dl-banner, .req, .sfield input, .sfield textarea, .sfield select, .field input, .field textarea,
|
||||
body .st-hero, body .sup-hero, .settings-nav, .snav, .discord-card, .spotify-card, .svc-card, .rail-item, body.home .rail > .rail-item {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
.post, .login-card, .report-card, .spanel, .st-sec, .panel, .dl-tile, .profile-panel, .faq-item, .rail-box, .home-link, .rc, .statbox, .card, .update-card, .updates-list > article, .req {
|
||||
background: var(--card); box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
}
|
||||
[data-theme="dark"] .post, [data-theme="dark"] .login-card, [data-theme="dark"] .report-card, [data-theme="dark"] .spanel, [data-theme="dark"] .st-sec,
|
||||
[data-theme="dark"] .panel, [data-theme="dark"] .dl-tile, [data-theme="dark"] .profile-panel, [data-theme="dark"] .faq-item, [data-theme="dark"] .rail-box,
|
||||
[data-theme="dark"] .home-link, [data-theme="dark"] .rc, [data-theme="dark"] .statbox, [data-theme="dark"] .card, [data-theme="dark"] .update-card,
|
||||
[data-theme="dark"] .updates-list > article, [data-theme="dark"] .req { background: #1e1e23; box-shadow: none; }
|
||||
[data-theme="dark"] { --card: #1e1e23; --panel: #27272d; --line: rgba(255,255,255,.06); --page: #141416; --topbar-bg: #141416; }
|
||||
.sfield input, .sfield textarea, .sfield select, .field input, .field textarea, .field select, .comment-input, .title-input, .rooms-filter, .top-search { background: var(--panel); }
|
||||
.sfield input:focus, .sfield textarea:focus, .field input:focus, .field textarea:focus { background: var(--card); box-shadow: 0 0 0 2px var(--primary); }
|
||||
.topbar { border-bottom: 0; box-shadow: 0 1px 0 rgba(0,0,0,.06); }
|
||||
[data-theme="dark"] .topbar { box-shadow: 0 1px 0 rgba(255,255,255,.05); }
|
||||
body.home .feed-title { border-bottom: 0; padding-bottom: 0; }
|
||||
.rooms-tabs { background: var(--panel); }
|
||||
.rtab.active { box-shadow: none; }
|
||||
.snav.active { background: var(--primary); color: #fff; }
|
||||
.spanel h2, .spanel h3 { margin-bottom: 14px; }
|
||||
|
||||
/* ── the banner: profiles and the home hero ──────────────────────────────────────────────── */
|
||||
.home-hero { background: url('assets/banner.png') center / cover no-repeat; }
|
||||
.home-hero::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0,0,0,.25), rgba(0,0,0,.45)); pointer-events: none; }
|
||||
.home-hero::before { display: none; }
|
||||
.hero-wave { display: none; }
|
||||
.hero-inner { z-index: 2; }
|
||||
.profile-banner { background: url('assets/banner.png') center / cover no-repeat; }
|
||||
|
||||
|
||||
/* ── room page v3 ─────────────────────────────────────────────────────────────────────────── */
|
||||
.room-page { max-width: 1040px; margin: 0 auto; padding: calc(var(--topbar-h) + 36px) 24px 70px; }
|
||||
.room-hero { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(260px, .8fr); gap: 36px; align-items: start; }
|
||||
.room-cover { aspect-ratio: 16 / 9; border-radius: var(--radius); background: var(--panel) center / cover no-repeat; height: auto; margin: 0; }
|
||||
.room-cover::after { display: none; }
|
||||
.room-side { display: flex; flex-direction: column; gap: 14px; padding-top: 6px; }
|
||||
.room-name { font-size: 1.7rem; font-weight: 700; line-height: 1.15; color: var(--text); word-break: break-word; margin: 0; }
|
||||
.room-by { color: var(--muted); font-size: .9rem; font-weight: 500; text-decoration: none; margin-top: -8px; }
|
||||
.room-by:hover { color: var(--primary); }
|
||||
.room-stats { display: flex; gap: 8px; margin: 4px 0; flex-wrap: wrap; }
|
||||
.room-stat { display: flex; flex-direction: row; align-items: baseline; gap: 7px; padding: 8px 12px; border-radius: 10px; background: var(--panel); border: 0; }
|
||||
.room-stat svg { align-self: center; }
|
||||
.room-stat:first-child { padding-left: 12px; }
|
||||
.room-stat svg { width: 17px; height: 17px; color: var(--muted); flex-shrink: 0; }
|
||||
.room-stat strong { font-size: .95rem; font-weight: 600; line-height: 1; color: var(--text); }
|
||||
.room-stat small { font-size: .78rem; color: var(--muted); line-height: 1; }
|
||||
.room-desc { font-size: .92rem; line-height: 1.65; color: var(--muted); margin: 0; }
|
||||
.room-play { align-self: flex-start; text-align: center; padding: 11px 36px; font-weight: 600; letter-spacing: .02em; margin-top: 4px; }
|
||||
.room-tabs { display: flex; gap: 6px; margin: 40px 0 22px; border-bottom: 1px solid var(--line); }
|
||||
.room-tab { display: inline-flex; align-items: center; gap: 8px; background: none; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; padding: 8px 14px 12px; cursor: pointer; color: var(--muted); font: inherit; font-size: .92rem; font-weight: 500; border-radius: 0 !important; }
|
||||
.room-tab svg { width: 18px; height: 18px; }
|
||||
.room-tab:hover { color: var(--text); }
|
||||
.room-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.room-panel { display: none; }
|
||||
.room-panel.active { display: block; }
|
||||
.room-feed { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; align-items: start; }
|
||||
.room-feed > .feed-loading { grid-column: 1 / -1; }
|
||||
.room-feed .post { margin: 0; }
|
||||
.room-feed .post-foot { flex-wrap: wrap; row-gap: 10px; }
|
||||
.room-feed .post-cap { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: .82rem; min-width: 0; }
|
||||
.room-chip { color: var(--text); font-weight: 500; }
|
||||
.room-in { color: var(--muted); }
|
||||
.room-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 4px; border-radius: 999px; background: var(--panel); color: var(--text); text-decoration: none; font-weight: 500; }
|
||||
.room-tag img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
|
||||
.room-caption { color: var(--muted); flex-basis: 100%; }
|
||||
@media (max-width: 860px) {
|
||||
.room-feed { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.room-hero { grid-template-columns: 1fr; gap: 18px; }
|
||||
.room-page { padding: calc(var(--topbar-h) + 14px) 16px 50px; }
|
||||
.room-name { font-size: 1.4rem; }
|
||||
.room-play { align-self: stretch; }
|
||||
.room-tabs { margin-top: 28px; }
|
||||
}
|
||||
|
||||
/* ── autumn theme ─────────────────────────────────────────────────────────────────────────── */
|
||||
/* Matches the in-game autumn Rec Center: cream walls, dark wood beams, orange foliage, pink
|
||||
bunting and warm string lights. Third option on the drawer's theme button. */
|
||||
[data-theme="autumn"] {
|
||||
--primary: #d9641f;
|
||||
--primary-dark: #b54d12;
|
||||
--primary-soft: rgba(217,100,31,.13);
|
||||
--brand-gradient: linear-gradient(120deg, #7a3b16 0%, #c2511f 40%, #e8823a 75%, #f2b04a 100%);
|
||||
--gradient-main: linear-gradient(#d9641f, #d9641f);
|
||||
--page: #f3e4cd;
|
||||
--card: #faf0df;
|
||||
--panel: #ecd8bb;
|
||||
--line: rgba(110,66,30,.14);
|
||||
--text: #3a2416;
|
||||
--muted: #8a6a50;
|
||||
--topbar-bg: #3b2416;
|
||||
}
|
||||
/* dark wood topbar: scope the text tokens so everything inside it (search, menus) reads on wood */
|
||||
[data-theme="autumn"] .topbar {
|
||||
--text: #f6e7cf; --muted: #d4b48f; --card: #4a2e1c; --panel: #4f3322; --line: rgba(255,255,255,.1);
|
||||
--primary: #f2a35a; --primary-soft: rgba(255,255,255,.1);
|
||||
box-shadow: 0 1px 0 rgba(0,0,0,.35), 0 3px 0 #d9641f;
|
||||
}
|
||||
[data-theme="autumn"] .topbar .btn-dl { background: #d9641f; color: #fff; border-color: #d9641f; }
|
||||
[data-theme="autumn"] .topbar .btn-dl:hover { background: #f08a3c; }
|
||||
[data-theme="autumn"] .burger-cat, [data-theme="autumn"] .logo-cat, [data-theme="autumn"] .side-toggle:hover .burger-cat {
|
||||
filter: invert(55%) sepia(80%) saturate(1800%) hue-rotate(345deg) brightness(100%) contrast(95%);
|
||||
}
|
||||
[data-theme="autumn"] ::selection { background: #d9641f; color: #fff; }
|
||||
[data-theme="autumn"] .hero-dl { background: #fdf6ea; color: #b54d12; }
|
||||
[data-theme="autumn"] .hero-dl:hover { background: #fff; }
|
||||
[data-theme="autumn"] .home-hero { background-image: linear-gradient(rgba(205,95,25,.62), rgba(160,70,20,.62)), url('assets/banner.png'); }
|
||||
[data-theme="autumn"] .home-hero::after { background: linear-gradient(180deg, rgba(120,52,12,.28), rgba(58,26,8,.58)); }
|
||||
/* bunting strung across the top of the hero, string lights along the bottom */
|
||||
[data-theme="autumn"] .home-hero::before {
|
||||
content: ""; display: block; position: absolute; left: 0; right: 0; top: 0; height: 30px; z-index: 2;
|
||||
background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='72' height='30' viewBox='0 0 72 30'><path d='M0 3 Q18 9 36 3 T72 3' fill='none' stroke='%235a3a26' stroke-width='1.3'/><path d='M8 5h14v14a7 7 0 0 1-14 0z' fill='%23e8736f'/><path d='M44 5.5h14v14a7 7 0 0 1-14 0z' fill='%23d9505e'/></svg>") repeat-x top left; pointer-events: none;
|
||||
}
|
||||
[data-theme="autumn"] .home-hero .hero-inner::after {
|
||||
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 26px; z-index: 2;
|
||||
background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='90' height='26' viewBox='0 0 90 26'><path d='M0 4 Q22 16 45 4 T90 4' fill='none' stroke='%233b2416' stroke-width='1.4'/><circle cx='22' cy='11' r='3' fill='%23ffe6a3'/><circle cx='22' cy='11' r='6' fill='%23ffd66b' opacity='.28'/><circle cx='67' cy='11' r='3' fill='%23ffe6a3'/><circle cx='67' cy='11' r='6' fill='%23ffd66b' opacity='.28'/></svg>") repeat-x bottom left; pointer-events: none;
|
||||
}
|
||||
[data-theme="autumn"] .home-hero .hero-inner { position: static; }
|
||||
[data-theme="autumn"] .home-hero > .hero-inner > * { position: relative; z-index: 3; }
|
||||
.autumn-leaves { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 2; }
|
||||
.autumn-leaves span {
|
||||
position: absolute; top: -30px; left: var(--x); width: 22px; height: 22px; color: var(--c);
|
||||
opacity: 0; transform: scale(var(--s));
|
||||
animation: leaf-fall var(--d) linear var(--delay) infinite;
|
||||
}
|
||||
.autumn-leaves span svg { width: 100%; height: 100%; fill: currentColor; filter: drop-shadow(0 1px 1px rgba(0,0,0,.25)); }
|
||||
@keyframes leaf-fall {
|
||||
0% { transform: translate(0, 0) rotate(0deg) scale(var(--s)); opacity: 0; }
|
||||
8% { opacity: .9; }
|
||||
50% { transform: translate(28px, 150px) rotate(160deg) scale(var(--s)); }
|
||||
92% { opacity: .85; }
|
||||
100% { transform: translate(-18px, 320px) rotate(340deg) scale(var(--s)); opacity: 0; }
|
||||
}
|
||||
[data-reduce-motion] .autumn-leaves { display: none; }
|
||||
/* warm surfaces: the cards get a paper tint and the section rails a wood-stripe title */
|
||||
/* No boxes: on parchment the card edge reads as an outline, so panels sit flat on the page
|
||||
and only photo/room cards keep a (shadowless) surface. */
|
||||
[data-theme="autumn"] .spanel, [data-theme="autumn"] .settings-nav, [data-theme="autumn"] .st-sec, [data-theme="autumn"] .panel,
|
||||
[data-theme="autumn"] .profile-panel, [data-theme="autumn"] .login-card, [data-theme="autumn"] .faq-item,
|
||||
[data-theme="autumn"] .rail-box, [data-theme="autumn"] .req, [data-theme="autumn"] .dl-tile, [data-theme="autumn"] .svc-card,
|
||||
[data-theme="autumn"] .discord-card, [data-theme="autumn"] .spotify-card, [data-theme="autumn"] .statbox, [data-theme="autumn"] .update-card,
|
||||
[data-theme="autumn"] .updates-list > article, [data-theme="autumn"] .home-link, [data-theme="autumn"] .hot-btn {
|
||||
background: transparent; box-shadow: none; border: 0;
|
||||
}
|
||||
[data-theme="autumn"] .post, [data-theme="autumn"] .card, [data-theme="autumn"] .rc, [data-theme="autumn"] .room-card, [data-theme="autumn"] .rail-item {
|
||||
background: var(--card); box-shadow: none;
|
||||
}
|
||||
[data-theme="autumn"] .home-link, [data-theme="autumn"] .hot-btn { background: rgba(255,255,255,.35); }
|
||||
[data-theme="autumn"] .sfield input, [data-theme="autumn"] .sfield textarea, [data-theme="autumn"] .sfield select, [data-theme="autumn"] .field input,
|
||||
[data-theme="autumn"] .field textarea, [data-theme="autumn"] .field select, [data-theme="autumn"] .comment-input, [data-theme="autumn"] .title-input,
|
||||
[data-theme="autumn"] .rooms-filter, [data-theme="autumn"] .sselect { background: rgba(255,255,255,.5); }
|
||||
[data-theme="autumn"] .sfield input:focus, [data-theme="autumn"] .sfield textarea:focus, [data-theme="autumn"] .field input:focus, [data-theme="autumn"] .field textarea:focus { background: #fff; }
|
||||
[data-theme="autumn"] .snav:not(.active) { background: transparent; }
|
||||
[data-theme="autumn"] .snav:hover { background: rgba(217,100,31,.1); }
|
||||
[data-theme="autumn"] .pstat { background: var(--panel); }
|
||||
[data-theme="autumn"] .room-stat, [data-theme="autumn"] .room-tag { background: var(--panel); }
|
||||
[data-theme="autumn"] .cheer.liked { color: #c2511f; background: rgba(217,100,31,.16); }
|
||||
[data-theme="autumn"] .cd-theme-toggle:hover, [data-theme="autumn"] .cd-nav a:hover { color: #b54d12; }
|
||||
[data-theme="autumn"] .cat-drawer { background: var(--card); }
|
||||
|
||||
/* ── image page v2: no card, photo first, everything under it ─────────────────────────────── */
|
||||
.image-page { display: block; min-height: 0; max-width: 960px; margin: 0 auto; padding: calc(var(--topbar-h) + 28px) 24px 70px; }
|
||||
.image-card { display: contents; }
|
||||
.image-photo { position: relative; aspect-ratio: auto; height: auto; max-height: 78vh; min-height: 240px; border-radius: var(--radius); background-color: var(--panel); background-size: contain !important; background-repeat: no-repeat; }
|
||||
.image-photo::before { content: ""; display: block; padding-top: 56.25%; }
|
||||
.image-below { padding: 0 4px; }
|
||||
.image-head { display: flex; align-items: center; gap: 12px; padding: 16px 0 4px; }
|
||||
.image-head .image-author { flex: 1; min-width: 0; padding: 0; }
|
||||
.image-author-av { width: 40px; height: 40px; }
|
||||
.image-author-name { font-weight: 600; font-size: .95rem; }
|
||||
.image-author-handle { font-size: .82rem; }
|
||||
.image-tools { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
.image-tools .cheer { font-size: .9rem; gap: 7px; padding: 7px 12px; }
|
||||
.image-tools .cheer-icon { width: 18px; height: 18px; }
|
||||
.img-report { border: 0; background: var(--panel); color: var(--muted); padding: 7px 12px; }
|
||||
.img-report:hover { background: var(--primary-soft); color: var(--primary); }
|
||||
.image-info { padding: 8px 0 6px; }
|
||||
.image-title { font-size: 1.35rem; font-weight: 700; line-height: 1.3; }
|
||||
.image-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-weight: 400; }
|
||||
.image-room { color: var(--primary); text-decoration: none; font-weight: 500; }
|
||||
.image-room:hover { text-decoration: underline; }
|
||||
.image-comments { padding: 18px 0 0; margin-top: 12px; border-top: 1px solid var(--line); }
|
||||
.image-comments h2 { padding: 0 0 12px; font-size: 1rem; }
|
||||
.image-comments .post-comments { padding: 0; background: none; border: 0; }
|
||||
.image-comments .comment-form { margin-top: 6px; }
|
||||
.image-actions { padding: 6px 0 10px; }
|
||||
/* commenters link to their profile */
|
||||
.comment-av-link { flex-shrink: 0; align-self: flex-start; display: block; line-height: 0; border-radius: 50%; }
|
||||
.comment-av-link .comment-av { display: block; }
|
||||
.comment-user { color: var(--text); text-decoration: none; }
|
||||
.comment-user:hover { color: var(--primary); text-decoration: underline; }
|
||||
@media (max-width: 640px) {
|
||||
.image-page { padding: calc(var(--topbar-h) + 12px) 12px 50px; }
|
||||
.image-photo { border-radius: 8px; }
|
||||
.image-head { flex-wrap: wrap; }
|
||||
.image-tools { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
/* report dialog: always a solid card, whatever the page theme does to panels */
|
||||
.report-card { background: var(--card); box-shadow: 0 30px 80px rgba(0,0,0,.32); border: 0; }
|
||||
.report-reason, .report-details { border: 0; background: var(--panel); }
|
||||
.report-reason.sel { background: var(--primary-soft); color: var(--primary); box-shadow: inset 0 0 0 1.5px var(--primary); }
|
||||
.report-details:focus { box-shadow: 0 0 0 2px var(--primary); }
|
||||
.report-cancel { border: 0; background: var(--panel); }
|
||||
.report-submit { background: var(--primary); }
|
||||
.report-submit:not(:disabled):hover { background: var(--primary-dark); box-shadow: none; }
|
||||
[data-theme="autumn"] .report-card { background: #fdf6ea; }
|
||||
|
||||
/* ── export (settings tab + image page button) ───────────────────────────────────────────── */
|
||||
.img-export { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.img-export svg { width: 15px; height: 15px; }
|
||||
.export-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||||
.export-row:last-of-type { border-bottom: 0; }
|
||||
.export-title { font-weight: 600; }
|
||||
.export-sub { color: var(--muted); font-size: .86rem; margin-top: 3px; }
|
||||
button.btn-dl { border: 0; font-family: inherit; cursor: pointer; line-height: normal; }
|
||||
.export-btn { flex-shrink: 0; padding: 10px 18px; }
|
||||
.export-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.export-opt { display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px; border-radius: 10px; background: var(--panel); margin-bottom: 8px; cursor: pointer; }
|
||||
.export-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.export-check { flex-shrink: 0; width: 20px; height: 20px; border-radius: 6px; margin-top: 1px; box-shadow: inset 0 0 0 2px var(--muted); background: var(--card); position: relative; transition: background .15s, box-shadow .15s; }
|
||||
.export-opt input:checked + .export-check { background: var(--primary); box-shadow: inset 0 0 0 2px var(--primary); }
|
||||
.export-opt input:checked + .export-check::after { content: ""; position: absolute; left: 6px; top: 2px; width: 6px; height: 11px; border: solid #fff; border-width: 0 2.5px 2.5px 0; transform: rotate(45deg); }
|
||||
.export-opt input:focus-visible + .export-check { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.export-opt b { display: block; font-weight: 600; font-size: .92rem; }
|
||||
.export-opt small { display: block; color: var(--muted); font-size: .82rem; margin-top: 2px; }
|
||||
.export-progress { margin: 6px 0 12px; font-size: .88rem; color: var(--muted); }
|
||||
@media (max-width: 520px) { .export-row { flex-direction: column; align-items: stretch; } }
|
||||
|
||||
/* the viewer's nickname for a friend, under the @handle on their profile */
|
||||
.profile-nick { display: inline-block; margin-top: 4px; padding: 2px 9px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font-size: .78rem; font-weight: 500; }
|
||||
|
||||
.profile-block-btn { margin-left: 6px; }
|
||||
.profile-block-btn.on { color: var(--primary); }
|
||||
|
||||
/* ── phone polish ────────────────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
html, body { overflow-x: hidden; max-width: 100%; }
|
||||
img, svg, video { max-width: 100%; }
|
||||
button, .btn-dl, .btn-login { max-width: 100%; }
|
||||
|
||||
/* the four feature tiles are a desktop thing - on a phone they push the feed a screen down */
|
||||
.home-features { display: none; }
|
||||
body.home .feed-layout { padding-top: 16px; }
|
||||
|
||||
/* featured room: "playing now / Visit room" no longer collides with the slide dots */
|
||||
.featured-body { padding-bottom: 34px; }
|
||||
.featured-row { gap: 10px 14px; }
|
||||
.featured-dots { right: auto; left: 16px; bottom: 12px; }
|
||||
|
||||
/* home page's photo cards */
|
||||
.post-foot { flex-wrap: wrap; row-gap: 8px; }
|
||||
.post-cap { min-width: 0; overflow-wrap: anywhere; }
|
||||
.post-stats { margin-left: auto; }
|
||||
|
||||
/* settings: one scrollable row of tabs instead of a ragged wrap */
|
||||
.settings-nav { flex-direction: row; flex-wrap: nowrap; overflow-x: auto; gap: 6px; padding: 2px 2px 8px; margin: 0 -2px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.settings-nav::-webkit-scrollbar { display: none; }
|
||||
.settings-nav .snav { flex: 0 0 auto; padding: 9px 14px; font-size: .92rem; white-space: nowrap; }
|
||||
.settings-nav .snav-logout { margin-top: 0; margin-left: auto; }
|
||||
.settings-page { padding-left: 14px; padding-right: 14px; }
|
||||
.srow-save .btn-dl, .ssave { width: 100%; }
|
||||
|
||||
/* dialogs never taller than the screen; their buttons wrap instead of leaving the card */
|
||||
.report-modal { padding: 12px; align-items: flex-end; }
|
||||
.report-card { max-height: calc(100dvh - 24px); overflow-y: auto; padding: 20px 16px; border-radius: 14px 14px 10px 10px; }
|
||||
.report-actions { flex-wrap: wrap; }
|
||||
.report-actions > button { flex: 1 1 45%; }
|
||||
|
||||
/* profile: header buttons and stat rows stay inside the column */
|
||||
.profile-actions, #profileActions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.profile-report-btn, .profile-block-btn { margin-left: 0; }
|
||||
.friend-panel button, .friend-row-btn { max-width: 100%; }
|
||||
|
||||
/* image page tools row */
|
||||
.image-tools { flex-wrap: wrap; row-gap: 6px; }
|
||||
.image-tools > * { flex: 0 1 auto; }
|
||||
|
||||
/* account + notification dropdowns: pinned under the topbar, edge to edge, scroll inside */
|
||||
.profile-menu, .notif-menu, .notif-panel {
|
||||
position: fixed !important; top: calc(var(--topbar-h) + 6px) !important;
|
||||
left: 8px !important; right: 8px !important; width: auto !important; max-width: none !important;
|
||||
max-height: calc(100dvh - var(--topbar-h) - 16px); overflow-y: auto; overscroll-behavior: contain;
|
||||
transform-origin: top center; box-shadow: 0 16px 40px rgba(0,0,0,.28);
|
||||
}
|
||||
|
||||
/* export rows: button under the text, full width */
|
||||
.export-row .btn-dl { width: 100%; }
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
body { font-size: 13.5px; }
|
||||
.topbar-brand.always { display: none; }
|
||||
.rc-stats { font-size: .72rem; }
|
||||
}
|
||||
|
||||
/* autumn: the dropdowns inherit the wood topbar's tokens, so their headings must too */
|
||||
[data-theme="autumn"] .topbar .pm-name, [data-theme="autumn"] .topbar .nm-head, [data-theme="autumn"] .topbar .nm-title,
|
||||
[data-theme="autumn"] .topbar .nm-head h3, [data-theme="autumn"] .topbar .notif-menu h3, [data-theme="autumn"] .topbar .nm-item b,
|
||||
[data-theme="autumn"] .topbar .nm-text { color: var(--text); }
|
||||
|
||||
/* ───── ban banner (app.js renderBanBanner) ───── */
|
||||
.site-ban-banner {
|
||||
position: fixed; top: var(--topbar-h); left: 0; right: 0;
|
||||
z-index: 59;
|
||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: #b42318; color: #fff;
|
||||
font-size: 14px; line-height: 1.4; text-align: left;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,.18);
|
||||
}
|
||||
.site-ban-banner .sbb-icon { font-size: 20px; flex: none; }
|
||||
.site-ban-banner .sbb-text { display: flex; flex-wrap: wrap; column-gap: 6px; row-gap: 2px; align-items: baseline; }
|
||||
.site-ban-banner .sbb-reason:not(:empty) { flex-basis: 100%; opacity: .95; }
|
||||
.site-ban-banner .sbb-note { flex-basis: 100%; opacity: .85; font-size: 13px; }
|
||||
.site-ban-banner .sbb-note a { color: #fff; text-decoration: underline; font-weight: 600; }
|
||||
body.site-banned { padding-top: var(--ban-h, 0px); }
|
||||
@media (max-width: 640px) {
|
||||
.site-ban-banner { padding: 8px 12px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ───── QUICK ADMIN POPUP ───── */
|
||||
.adm-card { max-width: 520px; max-height: calc(100vh - 36px); overflow-y: auto; }
|
||||
.adm-status { color: #e5484d; font-weight: 600; font-size: .88rem; margin: -6px 0 12px; }
|
||||
.adm-sec { border-top: 1px solid var(--line); padding: 12px 0 4px; }
|
||||
.adm-sec h3 { font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-bottom: 8px; }
|
||||
.adm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.adm-in {
|
||||
padding: 8px 11px; border-radius: 9px; border: 1px solid var(--line);
|
||||
background: var(--panel); color: var(--text); font-family: inherit; font-size: .88rem; outline: none; min-width: 0;
|
||||
}
|
||||
.adm-in:focus { border-color: var(--primary); }
|
||||
.adm-in.grow { flex: 1 1 180px; }
|
||||
textarea.adm-in { resize: vertical; }
|
||||
.adm-num { width: 84px; }
|
||||
.adm-chk { display: inline-flex; align-items: center; gap: 6px; font-size: .88rem; font-weight: 600; cursor: pointer; }
|
||||
.adm-btn {
|
||||
background: var(--panel); border: 1px solid var(--line); color: var(--text);
|
||||
font-family: inherit; font-weight: 600; font-size: .86rem; padding: 8px 14px; border-radius: 9px; cursor: pointer;
|
||||
transition: border-color .15s, color .15s, background .15s;
|
||||
}
|
||||
.adm-btn:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); }
|
||||
.adm-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.adm-btn.danger { color: #e5484d; }
|
||||
.adm-btn.danger:hover:not(:disabled) { border-color: #e5484d; background: rgba(229,72,77,.1); color: #e5484d; }
|
||||
.adm-note { margin-top: 8px; padding: 9px 12px; border-radius: 9px; background: var(--primary-soft); color: var(--primary); font-weight: 600; font-size: .88rem; }
|
||||
.adm-note.bad { background: rgba(229,72,77,.12); color: #e5484d; }
|
||||
.adm-panel-link { display: inline-block; margin-top: 12px; font-size: .84rem; color: var(--muted); }
|
||||
.adm-panel-link:hover { color: var(--primary); }
|
||||
.room-admin { margin-top: 12px; }
|
||||
|
||||
/* ───── WEEKLY CHALLENGES (/challenges) ───── */
|
||||
.wc-page { max-width: 1040px; margin: 0 auto; padding: calc(var(--topbar-h) + 28px) 24px 64px; }
|
||||
|
||||
/* header banner: title + progress ring + reset countdown */
|
||||
.wc-hero {
|
||||
position: relative; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 24px;
|
||||
padding: 28px 32px; margin-bottom: 22px; border-radius: 16px;
|
||||
background: linear-gradient(120deg, var(--primary-dark), var(--primary));
|
||||
color: #fff;
|
||||
}
|
||||
.wc-hero-text { position: relative; z-index: 1; max-width: 560px; }
|
||||
.wc-hero h1 { font-size: 2.1rem; font-weight: 800; margin: 0 0 6px; line-height: 1.1; color: #fff; }
|
||||
.wc-hero p { margin: 0; opacity: .92; line-height: 1.5; }
|
||||
.wc-hero-side { position: relative; z-index: 1; display: flex; align-items: center; gap: 20px; flex-shrink: 0; }
|
||||
.wc-meter { position: relative; width: 100px; height: 100px; }
|
||||
.wc-meter svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
.wc-meter circle { fill: none; stroke-width: 10; }
|
||||
.wc-meter-track { stroke: rgba(255,255,255,.22); }
|
||||
.wc-meter-fill { stroke: #fff; stroke-linecap: round; }
|
||||
.wc-meter-num { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1; }
|
||||
.wc-meter-num b { font-size: 1.9rem; font-weight: 800; }
|
||||
.wc-meter-num span { font-size: .78rem; opacity: .85; margin-top: 3px; }
|
||||
.wc-meter-full .wc-meter-fill { stroke: #b6f09c; }
|
||||
.wc-timer { display: flex; flex-direction: column; gap: 2px; background: rgba(0,0,0,.18); padding: 12px 16px; border-radius: 10px; min-width: 110px; }
|
||||
.wc-timer-label { font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; opacity: .8; }
|
||||
.wc-timer-val { font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.wc-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 22px; align-items: start; }
|
||||
.wc-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* challenge cards */
|
||||
.wc-card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px 20px; }
|
||||
.wc-loading { color: var(--muted); }
|
||||
.wc-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
||||
.wc-text { font-weight: 700; font-size: 1.02rem; }
|
||||
.wc-done .wc-text { color: var(--muted); }
|
||||
.wc-count { font-weight: 700; font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.wc-done .wc-count { color: #30a46c; }
|
||||
.wc-meta { margin-top: 2px; font-size: .86rem; }
|
||||
.wc-room { color: var(--primary); text-decoration: none; font-weight: 600; }
|
||||
.wc-room:hover { text-decoration: underline; }
|
||||
.wc-any { color: var(--muted); font-weight: 500; }
|
||||
.wc-bar { margin-top: 10px; height: 6px; border-radius: 999px; background: var(--panel); overflow: hidden; }
|
||||
.wc-bar span { display: block; height: 100%; background: var(--primary); border-radius: 999px; }
|
||||
.wc-done .wc-bar span { background: #30a46c; }
|
||||
|
||||
/* reward card: the item framed in its rarity colour */
|
||||
.wc-reward {
|
||||
--rar: #f59e0b;
|
||||
position: sticky; top: calc(var(--topbar-h) + 20px);
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 16px;
|
||||
padding: 22px; text-align: center;
|
||||
}
|
||||
.wc-reward.wc-r-common { --rar: #9aa0a6; }
|
||||
.wc-reward.wc-r-uncommon { --rar: #3fb950; }
|
||||
.wc-reward.wc-r-rare { --rar: #3b82f6; }
|
||||
.wc-reward.wc-r-epic { --rar: #a855f7; }
|
||||
.wc-reward.wc-r-legendary { --rar: #f59e0b; }
|
||||
.wc-reward-label { font-size: .74rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
||||
.wc-reward-art { position: relative; margin: 16px auto 14px; width: 220px; height: 220px; }
|
||||
.wc-reward-img {
|
||||
width: 100%; height: 100%; border-radius: 16px; overflow: hidden; background: var(--panel);
|
||||
border: 3px solid var(--rar);
|
||||
}
|
||||
.wc-reward-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.wc-rarity {
|
||||
position: absolute; left: 50%; bottom: -11px; transform: translateX(-50%);
|
||||
background: var(--rar); color: #fff; font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase;
|
||||
padding: 4px 12px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.wc-reward-name { font-size: 1.2rem; font-weight: 800; line-height: 1.3; margin-top: 8px; }
|
||||
.wc-reward-kind { color: var(--muted); font-size: .86rem; margin-top: 2px; }
|
||||
.wc-reward-status { margin-top: 16px; padding: 11px 12px; border-radius: 10px; background: var(--primary-soft); color: var(--primary); font-weight: 700; font-size: .9rem; }
|
||||
.wc-reward-status.wc-earned { background: rgba(48,164,108,.16); color: #30a46c; }
|
||||
.wc-login { color: inherit; font-weight: 800; }
|
||||
.wc-note { margin-top: 20px; color: var(--muted); font-size: .86rem; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.wc-page { padding-left: 16px; padding-right: 16px; }
|
||||
.wc-hero { flex-direction: column; align-items: flex-start; padding: 22px; }
|
||||
.wc-hero h1 { font-size: 1.8rem; }
|
||||
.wc-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.wc-reward { position: static; order: -1; }
|
||||
}
|
||||
|
||||
/* ───── HALLOWEEN THEME ───── */
|
||||
/* Night sky, pumpkin orange and witchy purple. Fourth option on the drawer's theme button, and
|
||||
switched on for everyone once each October (app.js applySeason). Cobwebs and spiders live in
|
||||
.hw-fx (app.js halloweenFx) and never take a click. */
|
||||
[data-theme="halloween"] {
|
||||
--primary: #ff7a1a;
|
||||
--primary-dark: #d9580a;
|
||||
--primary-soft: rgba(255,122,26,.15);
|
||||
--brand-gradient: linear-gradient(120deg, #3a1260 0%, #8a2be2 35%, #ff7a1a 80%, #ffb347 100%);
|
||||
--gradient-main: linear-gradient(90deg, #ff7a1a, #c04bff);
|
||||
--page: #0f0a15;
|
||||
--card: #1a1222;
|
||||
--panel: #251a31;
|
||||
--line: rgba(255,255,255,.08);
|
||||
--text: #f5ecff;
|
||||
--muted: #a99bbd;
|
||||
--topbar-bg: #0a0710;
|
||||
}
|
||||
[data-theme="halloween"] body {
|
||||
background:
|
||||
radial-gradient(circle at 85% -10%, rgba(138,43,226,.22), transparent 45%),
|
||||
radial-gradient(circle at 0% 110%, rgba(255,122,26,.12), transparent 40%),
|
||||
var(--page);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
[data-theme="halloween"] .topbar { box-shadow: 0 1px 0 rgba(0,0,0,.5), 0 3px 0 #ff7a1a; }
|
||||
[data-theme="halloween"] .topbar .btn-dl { background: #ff7a1a; color: #1a0d00; border-color: #ff7a1a; }
|
||||
[data-theme="halloween"] .topbar .btn-dl:hover { background: #ff9440; }
|
||||
[data-theme="halloween"] .burger-cat, [data-theme="halloween"] .logo-cat, [data-theme="halloween"] .side-toggle:hover .burger-cat {
|
||||
filter: invert(55%) sepia(90%) saturate(2200%) hue-rotate(350deg) brightness(105%) contrast(100%);
|
||||
}
|
||||
[data-theme="halloween"] ::selection { background: #ff7a1a; color: #1a0d00; }
|
||||
[data-theme="halloween"] .post, [data-theme="halloween"] .card, [data-theme="halloween"] .rc, [data-theme="halloween"] .room-card,
|
||||
[data-theme="halloween"] .rail-item, [data-theme="halloween"] .update-card, [data-theme="halloween"] .updates-list > article { background: var(--card); }
|
||||
[data-theme="halloween"] .cat-drawer { background: #120c19; }
|
||||
[data-theme="halloween"] .cd-theme-toggle:hover, [data-theme="halloween"] .cd-nav a:hover { color: #ff9440; }
|
||||
/* home hero: a full moon over a purple night */
|
||||
[data-theme="halloween"] .home-hero { background-image: linear-gradient(rgba(40,10,70,.72), rgba(15,6,25,.78)), url('assets/banner.png'); }
|
||||
[data-theme="halloween"] .home-hero::before {
|
||||
content: ""; display: block; position: absolute; right: 8%; top: 18px; width: 90px; height: 90px; border-radius: 50%; z-index: 1;
|
||||
background: radial-gradient(circle at 38% 36%, #fff6d8, #ffd77a 55%, #f0a43a 100%);
|
||||
box-shadow: 0 0 40px 10px rgba(255,200,90,.35); pointer-events: none;
|
||||
}
|
||||
/* weekly page banner: purple to pumpkin, with the moon */
|
||||
[data-theme="halloween"] .wc-hero { background: linear-gradient(120deg, #2a0d45 0%, #6a1fb0 45%, #ff7a1a 115%); }
|
||||
[data-theme="halloween"] .wc-hero::before {
|
||||
content: ""; position: absolute; right: 34px; top: -40px; width: 120px; height: 120px; border-radius: 50%;
|
||||
background: radial-gradient(circle at 38% 36%, #fff6d8, #ffd77a 55%, #f0a43a 100%);
|
||||
box-shadow: 0 0 40px 12px rgba(255,200,90,.3); opacity: .9; pointer-events: none;
|
||||
}
|
||||
|
||||
/* the overlay: cobwebs in the corners, spiders on threads and on the page */
|
||||
.hw-fx { position: fixed; inset: var(--topbar-h) 0 0 0; pointer-events: none; z-index: 45; overflow: hidden; }
|
||||
.hw-fx * { pointer-events: none; }
|
||||
.hw-fx.hw-bare { inset: 0; }
|
||||
.hw-web { position: absolute; width: 190px; height: 190px; color: rgba(235,225,255,.32); }
|
||||
.hw-web svg { width: 100%; height: 100%; }
|
||||
.hw-web.tl { left: 0; top: 0; }
|
||||
.hw-web.tr { right: 0; top: 0; transform: scaleX(-1); }
|
||||
.hw-web.br { right: 0; bottom: 0; transform: scale(-1, -1); width: 130px; height: 130px; }
|
||||
.hw-web.bl { left: 0; bottom: 0; transform: scaleY(-1); width: 110px; height: 110px; }
|
||||
[data-theme="light"] .hw-web, [data-theme="autumn"] .hw-web { color: rgba(60,40,30,.25); }
|
||||
|
||||
.hw-spider { position: absolute; left: 0; top: 0; width: 34px; height: 34px; color: #120a18; will-change: transform; }
|
||||
.hw-spider svg { width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 2px 2px rgba(0,0,0,.45)); }
|
||||
.hw-spider .eye { fill: #ff7a1a; }
|
||||
.hw-spider .legs-a, .hw-spider .legs-b { transform-origin: 20px 20px; }
|
||||
.hw-spider.walking .legs-a { animation: hw-leg .22s ease-in-out infinite alternate; }
|
||||
.hw-spider.walking .legs-b { animation: hw-leg .22s ease-in-out infinite alternate-reverse; }
|
||||
@keyframes hw-leg { from { transform: rotate(-9deg); } to { transform: rotate(9deg); } }
|
||||
[data-theme="halloween"] .hw-spider { color: #050307; }
|
||||
[data-theme="halloween"] .hw-spider svg { filter: drop-shadow(0 0 3px rgba(190,140,255,.35)) drop-shadow(0 2px 2px rgba(0,0,0,.6)); }
|
||||
|
||||
/* dangling spiders: a thread from the top, down, sway, back up */
|
||||
.hw-dangle { position: absolute; top: 0; left: var(--x); width: 30px; animation: hw-drop var(--d) ease-in-out var(--delay) infinite; }
|
||||
.hw-dangle .hw-thread { position: absolute; left: 50%; bottom: 26px; width: 1px; height: 100vh; background: rgba(235,225,255,.45); transform: translateX(-50%); }
|
||||
.hw-dangle .hw-spider { position: relative; width: 30px; height: 30px; transform: rotate(180deg); }
|
||||
.hw-dangle .hw-spider .legs-a { animation: hw-leg 1.6s ease-in-out infinite alternate; }
|
||||
.hw-dangle .hw-spider .legs-b { animation: hw-leg 1.6s ease-in-out infinite alternate-reverse; }
|
||||
@keyframes hw-drop {
|
||||
0%, 100% { transform: translateY(-60px); }
|
||||
30% { transform: translateY(var(--y)) rotate(0deg); }
|
||||
40% { transform: translateY(calc(var(--y) - 12px)) rotate(4deg); }
|
||||
50% { transform: translateY(var(--y)) rotate(-4deg); }
|
||||
62% { transform: translateY(calc(var(--y) - 8px)) rotate(0deg); }
|
||||
}
|
||||
[data-reduce-motion] .hw-spider, [data-reduce-motion] .hw-dangle { display: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .hw-spider, .hw-dangle { display: none; } }
|
||||
@media (max-width: 640px) {
|
||||
.hw-web { width: 120px; height: 120px; }
|
||||
.hw-web.br, .hw-web.bl { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,1030 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Campus Support</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
<style>
|
||||
/* ── page shell (clears the fixed topbar, centred column) ── */
|
||||
.sup-page{max-width:720px;margin:0 auto;padding:calc(var(--topbar-h) + 34px) 20px 96px;}
|
||||
|
||||
/* ── hero ── */
|
||||
.sup-hero{position:relative;text-align:center;padding:34px 24px 30px;border-radius:24px;
|
||||
border:1px solid var(--line);overflow:hidden;margin-bottom:22px;
|
||||
background:radial-gradient(ellipse 80% 120% at 50% -20%, var(--primary-soft), transparent 68%), var(--card);}
|
||||
.sup-hero .hero-ic{width:60px;height:60px;border-radius:19px;display:grid;place-items:center;margin:0 auto 15px;
|
||||
background:var(--gradient-main);box-shadow:0 10px 24px rgba(207,40,96,.30);}
|
||||
.sup-hero .hero-ic svg{width:31px;height:31px;color:#fff;}
|
||||
.sup-hero h1{font-size:2rem;font-weight:800;letter-spacing:-.03em;margin:0 0 7px;}
|
||||
.sup-hero p{color:var(--muted);font-weight:600;line-height:1.55;margin:0 auto;max-width:470px;}
|
||||
|
||||
/* ── tabs (mirrors the site's .utab pills) ── */
|
||||
.sup-tabs{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-bottom:18px;}
|
||||
.sup-tabs button{cursor:pointer;font:inherit;font-weight:700;font-size:.9rem;padding:9px 18px;border-radius:999px;
|
||||
background:var(--card);border:1px solid var(--line);color:var(--text);display:inline-flex;align-items:center;gap:8px;
|
||||
transition:border-color .15s,color .15s,background .15s;}
|
||||
.sup-tabs button:hover{border-color:var(--primary);color:var(--primary);}
|
||||
.sup-tabs button.active{background:var(--gradient-main);border-color:transparent;color:#fff;}
|
||||
.sup-tabs button[hidden],.pill[hidden]{display:none !important;} /* author display: rules otherwise beat [hidden] */
|
||||
.sup-tabs .pill{min-width:20px;padding:1px 7px;border-radius:999px;font-size:.76rem;font-weight:800;
|
||||
background:var(--primary);color:#fff;line-height:1.5;}
|
||||
.sup-tabs button.active .pill{background:rgba(255,255,255,.3);}
|
||||
|
||||
.panel{display:none;} .panel.active{display:block;animation:fade .22s ease;}
|
||||
@keyframes fade{from{opacity:0;transform:translateY(5px);}to{opacity:1;transform:none;}}
|
||||
|
||||
/* ── surface card ── */
|
||||
.surface{background:var(--card);border:1px solid var(--line);border-radius:20px;
|
||||
box-shadow:0 10px 30px rgba(0,0,0,.05);overflow:hidden;}
|
||||
|
||||
/* ── assistant header ── */
|
||||
.asst-head{display:flex;align-items:center;gap:13px;padding:16px 18px;border-bottom:1px solid var(--line);background:var(--panel);}
|
||||
.asst-av{width:42px;height:42px;border-radius:13px;flex:none;background:var(--gradient-main);display:grid;place-items:center;}
|
||||
.asst-av img{width:28px;height:28px;object-fit:contain;filter:brightness(0) invert(1);}
|
||||
.asst-name{font-weight:800;font-size:1rem;line-height:1.1;}
|
||||
.asst-sub{font-size:.8rem;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:6px;margin-top:3px;}
|
||||
.asst-dot{width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.2);}
|
||||
|
||||
/* ── chat ── */
|
||||
.chat{display:flex;flex-direction:column;gap:11px;padding:18px;}
|
||||
.msg{max-width:90%;padding:11px 14px;border-radius:16px;line-height:1.5;font-size:.96rem;white-space:pre-wrap;color:var(--text);}
|
||||
.msg.bot{align-self:flex-start;background:var(--panel);border:1px solid var(--line);border-top-left-radius:5px;}
|
||||
.msg.me{align-self:flex-end;background:var(--gradient-main);color:#fff;border-top-right-radius:5px;font-weight:600;}
|
||||
.choices{display:flex;flex-wrap:wrap;gap:8px;}
|
||||
.choice{cursor:pointer;font:inherit;font-weight:600;font-size:.9rem;padding:9px 14px;border-radius:999px;
|
||||
border:1px solid var(--line);background:var(--card);color:var(--text);text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s;}
|
||||
.choice:hover{border-color:var(--primary);color:var(--primary);background:var(--primary-soft);}
|
||||
.choice.primary{background:var(--gradient-main);border-color:transparent;color:#fff;}
|
||||
.choice.primary:hover{filter:brightness(1.07);color:#fff;background:var(--gradient-main);}
|
||||
.choice.ghost{color:var(--muted);border-style:dashed;}
|
||||
.choice.ghost:hover{color:var(--primary);background:transparent;border-color:var(--primary);}
|
||||
.typing{display:inline-flex;gap:4px;padding:2px 0;}
|
||||
.typing span{width:7px;height:7px;border-radius:50%;background:var(--muted);opacity:.5;animation:tp 1s infinite;}
|
||||
.typing span:nth-child(2){animation-delay:.15s;} .typing span:nth-child(3){animation-delay:.3s;}
|
||||
@keyframes tp{0%,60%,100%{transform:translateY(0);opacity:.35;}30%{transform:translateY(-4px);opacity:.9;}}
|
||||
|
||||
/* ── talk-to-human CTA (panel footer) ── */
|
||||
.human-bar{padding:14px 18px;border-top:1px solid var(--line);background:var(--panel);}
|
||||
.human-btn{width:100%;display:inline-flex;align-items:center;justify-content:center;gap:9px;cursor:pointer;border:none;
|
||||
font:inherit;font-weight:800;font-size:.98rem;padding:14px;border-radius:14px;background:var(--gradient-main);color:#fff;
|
||||
box-shadow:0 8px 20px rgba(207,40,96,.26);transition:transform .15s,box-shadow .15s,filter .15s;}
|
||||
.human-btn:hover{transform:translateY(-1px);box-shadow:0 12px 26px rgba(207,40,96,.34);filter:brightness(1.03);}
|
||||
.human-btn svg{width:19px;height:19px;}
|
||||
|
||||
/* ── fields ── */
|
||||
.field{font:inherit;font-size:.95rem;padding:12px 14px;border:1px solid var(--line);border-radius:12px;
|
||||
background:var(--bg);color:var(--text);width:100%;box-sizing:border-box;transition:border-color .15s;}
|
||||
.field:focus{outline:none;border-color:var(--primary);}
|
||||
.field::placeholder{color:var(--muted);}
|
||||
textarea.field{min-height:104px;resize:vertical;}
|
||||
.row-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:11px;}
|
||||
.note{font-size:.85rem;color:var(--muted);}
|
||||
.discord-link{color:#5865F2;font-weight:700;text-decoration:none;}
|
||||
.discord-link:hover{text-decoration:underline;}
|
||||
|
||||
/* ── ticket panels ── */
|
||||
.panel-top{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px;}
|
||||
.panel-top h2{font-size:1.2rem;font-weight:800;margin:0;}
|
||||
.btn-new{cursor:pointer;font:inherit;font-weight:700;font-size:.9rem;padding:10px 16px;border-radius:11px;border:none;
|
||||
background:var(--gradient-main);color:#fff;display:inline-flex;align-items:center;gap:7px;box-shadow:0 6px 16px rgba(207,40,96,.24);transition:transform .12s,box-shadow .15s;}
|
||||
.btn-new:hover{transform:translateY(-1px);box-shadow:0 10px 22px rgba(207,40,96,.3);}
|
||||
|
||||
/* segmented staff filter */
|
||||
.subfilter{display:inline-flex;gap:4px;padding:4px;border-radius:999px;background:var(--panel);border:1px solid var(--line);margin-bottom:16px;}
|
||||
.subfilter button{cursor:pointer;font:inherit;font-weight:700;font-size:.84rem;padding:7px 15px;border-radius:999px;border:none;background:transparent;color:var(--muted);transition:.12s;}
|
||||
.subfilter button:hover{color:var(--text);}
|
||||
.subfilter button.active{background:var(--card);color:var(--text);box-shadow:0 1px 5px rgba(0,0,0,.1);}
|
||||
|
||||
/* ticket rows */
|
||||
.tlist{display:flex;flex-direction:column;gap:12px;}
|
||||
.trow{display:flex;align-items:flex-start;gap:14px;padding:16px 18px;border:1px solid var(--line);border-radius:16px;
|
||||
background:var(--card);cursor:pointer;transition:border-color .15s,transform .12s,box-shadow .15s;}
|
||||
.trow:hover{border-color:var(--primary);transform:translateY(-2px);box-shadow:0 8px 22px rgba(0,0,0,.07);}
|
||||
.trow .tnum{flex:none;width:46px;height:46px;border-radius:13px;background:var(--primary-soft);color:var(--primary);
|
||||
display:grid;place-items:center;font-weight:800;font-size:.82rem;}
|
||||
.trow .tmain{flex:1;min-width:0;}
|
||||
.trow .tsub{font-weight:800;font-size:.98rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.trow .tprev{color:var(--muted);font-size:.88rem;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.trow .tmeta{color:var(--muted);font-size:.78rem;font-weight:600;margin-top:7px;display:flex;gap:10px;flex-wrap:wrap;}
|
||||
.trow .tmeta .who{color:var(--primary);font-weight:700;}
|
||||
.badge{flex:none;font-size:.7rem;font-weight:800;padding:5px 11px;border-radius:999px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.badge.open{background:rgba(34,197,94,.14);color:#15803d;}
|
||||
.badge.closed{background:var(--panel);color:var(--muted);}
|
||||
[data-theme="dark"] .badge.open{background:rgba(34,197,94,.2);color:#5fd27f;}
|
||||
|
||||
/* empty state */
|
||||
.empty{color:var(--muted);text-align:center;padding:46px 20px;font-weight:600;}
|
||||
.empty svg{width:40px;height:40px;opacity:.35;margin-bottom:12px;display:block;margin-left:auto;margin-right:auto;}
|
||||
|
||||
/* ── thread modal ── */
|
||||
.modal-scrim{position:fixed;inset:0;background:rgba(10,8,20,.55);backdrop-filter:blur(3px);display:none;z-index:200;}
|
||||
.modal-scrim.open{display:block;}
|
||||
.modal{position:fixed;z-index:201;left:50%;top:50%;transform:translate(-50%,-50%) scale(.98);opacity:0;
|
||||
width:min(820px,95vw);max-height:90vh;min-height:min(600px,86vh);background:var(--card);border:1px solid var(--line);border-radius:20px;
|
||||
box-shadow:0 30px 80px rgba(0,0,0,.4);display:none;flex-direction:column;overflow:hidden;transition:opacity .16s,transform .16s;}
|
||||
.modal.open{display:flex;opacity:1;transform:translate(-50%,-50%) scale(1);}
|
||||
.modal-head{display:flex;align-items:center;gap:12px;padding:16px 18px;border-bottom:1px solid var(--line);}
|
||||
.modal-head .mnum{flex:none;width:40px;height:40px;border-radius:11px;background:var(--primary-soft);color:var(--primary);display:grid;place-items:center;font-weight:800;font-size:.78rem;}
|
||||
.modal-head .mtitle{flex:1;min-width:0;}
|
||||
.modal-head h3{margin:0;font-size:1.05rem;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.modal-head .msub{font-size:.8rem;color:var(--muted);font-weight:600;margin-top:2px;}
|
||||
.modal-x{cursor:pointer;border:none;background:transparent;font-size:1.5rem;line-height:1;color:var(--muted);padding:2px 4px;}
|
||||
.modal-x:hover{color:var(--text);}
|
||||
.mtoggle{cursor:pointer;font:inherit;font-weight:700;font-size:.82rem;padding:8px 13px;border-radius:999px;
|
||||
border:1px solid var(--line);background:var(--card);color:var(--muted);white-space:nowrap;transition:.12s;}
|
||||
.mtoggle:hover{border-color:var(--primary);color:var(--primary);}
|
||||
.thread{flex:1;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:14px;background:var(--page);}
|
||||
/* structured "ticket details" panel parsed from the opening message */
|
||||
.tdetails{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 17px;}
|
||||
.tdetails .dh{font-size:.7rem;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:11px;display:flex;align-items:center;gap:7px;}
|
||||
.tdetails .dh svg{width:15px;height:15px;color:var(--primary);}
|
||||
.tdetails dl{display:grid;grid-template-columns:auto 1fr;gap:9px 18px;margin:0;}
|
||||
.tdetails dt{color:var(--muted);font-size:.86rem;font-weight:700;white-space:nowrap;}
|
||||
.tdetails dd{margin:0;font-size:.95rem;font-weight:600;color:var(--text);word-break:break-word;}
|
||||
.tdetails dd.at{color:var(--primary);}
|
||||
/* messages: avatar + bubble */
|
||||
.msgrow{display:flex;gap:9px;max-width:86%;}
|
||||
.msgrow.user{align-self:flex-start;}
|
||||
.msgrow.staff{align-self:flex-end;flex-direction:row-reverse;}
|
||||
.msgrow .bcol{display:flex;flex-direction:column;gap:3px;min-width:0;}
|
||||
.msgrow.staff .bcol{align-items:flex-end;}
|
||||
.av-sm{width:44px;height:44px;border-radius:50%;flex:none;display:grid;place-items:center;font-weight:800;font-size:1rem;margin-top:22px;text-transform:uppercase;overflow:hidden;}
|
||||
.av-sm.staff{background:var(--gradient-main);color:#fff;}
|
||||
.av-sm.user{background:var(--primary-soft);color:var(--primary);}
|
||||
/* photo avatars: round, cropped to fill — same as the profile-page avatars */
|
||||
.av-sm.img{background-color:var(--panel);background-size:cover;background-repeat:no-repeat;background-position:center;}
|
||||
.who{font-size:.75rem;color:var(--muted);font-weight:700;padding:0 4px;}
|
||||
.bubble{padding:12px 15px;border-radius:15px;font-size:1rem;white-space:pre-wrap;line-height:1.5;color:var(--text);max-width:100%;}
|
||||
.bubble.user{background:var(--card);border:1px solid var(--line);border-top-left-radius:5px;}
|
||||
.bubble.staff{background:var(--gradient-main);color:#fff;border-top-right-radius:5px;}
|
||||
.modal-foot{border-top:1px solid var(--line);padding:13px 16px;background:var(--panel);}
|
||||
.modal-foot .row{display:flex;gap:10px;align-items:flex-end;}
|
||||
.modal-foot textarea.field{min-height:44px;max-height:140px;background:var(--card);}
|
||||
.mfoot-closed{text-align:center;color:var(--muted);font-size:.88rem;font-weight:600;padding:4px 0;}
|
||||
.btn-send{cursor:pointer;border:none;font:inherit;font-weight:700;padding:12px 18px;border-radius:12px;
|
||||
background:var(--gradient-main);color:#fff;flex:none;transition:filter .12s,transform .12s;}
|
||||
.btn-send:hover{filter:brightness(1.06);transform:translateY(-1px);}
|
||||
.btn-send:disabled{opacity:.5;cursor:default;transform:none;}
|
||||
|
||||
/* ── create-ticket form modal ── */
|
||||
.modal-body{padding:18px;overflow:auto;display:flex;flex-direction:column;gap:15px;}
|
||||
.frow{display:flex;flex-direction:column;gap:6px;}
|
||||
.fl{font-size:.85rem;font-weight:700;color:var(--text);}
|
||||
.fl .rq{color:#e11d48;margin-left:3px;} .fl .opt{color:var(--muted);font-weight:600;font-size:.8rem;margin-left:4px;}
|
||||
.fhint{font-size:.78rem;color:var(--muted);}
|
||||
.at-field{display:flex;align-items:center;border:1px solid var(--line);border-radius:12px;background:var(--bg);overflow:hidden;transition:border-color .15s;}
|
||||
.at-field:focus-within{border-color:var(--primary);}
|
||||
.at-field .at{padding:0 2px 0 13px;color:var(--muted);font-weight:700;}
|
||||
.at-field input{flex:1;border:none;background:transparent;color:var(--text);font:inherit;font-size:.95rem;padding:12px 13px 12px 4px;outline:none;}
|
||||
select.field{appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:38px;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238a8a90' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat:no-repeat;background-position:right 13px center;}
|
||||
.tform-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-top:2px;}
|
||||
.modal-login{padding:30px 22px;text-align:center;}
|
||||
.modal-login p{color:var(--muted);font-weight:600;margin:0 0 18px;line-height:1.55;}
|
||||
.modal-login .choices{justify-content:center;}
|
||||
|
||||
/* ── assistant: free-text question box ── */
|
||||
.ask-bar{display:flex;gap:10px;padding:14px 18px;border-top:1px solid var(--line);background:var(--card);}
|
||||
.ask-bar .field{flex:1;padding:11px 14px;}
|
||||
.ask-bar .btn-send{padding:11px 16px;}
|
||||
.choice.small{font-size:.84rem;padding:7px 12px;}
|
||||
.msg.bot a{color:var(--primary);font-weight:700;}
|
||||
.status-card{align-self:stretch;max-width:100%;border-radius:14px;padding:13px 15px;font-size:.93rem;line-height:1.5;border:1px solid var(--line);background:var(--card);}
|
||||
.status-card.bad{border-color:rgba(225,29,72,.35);background:rgba(225,29,72,.07);}
|
||||
.status-card.ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.07);}
|
||||
.status-card b{display:block;margin-bottom:3px;}
|
||||
|
||||
/* ── ticket rows: topic chip, unread dot, whose turn ── */
|
||||
.chip{display:inline-flex;align-items:center;font-size:.72rem;font-weight:800;padding:3px 9px;border-radius:999px;background:var(--panel);color:var(--muted);border:1px solid var(--line);}
|
||||
.chip.appeal{background:rgba(225,29,72,.1);color:#e11d48;border-color:transparent;}
|
||||
.chip.report_player,.chip.report_room{background:rgba(245,158,11,.12);color:#b45309;border-color:transparent;}
|
||||
.chip.bug{background:rgba(59,130,246,.12);color:#2563eb;border-color:transparent;}
|
||||
[data-theme="dark"] .chip.report_player,[data-theme="dark"] .chip.report_room{color:#fbbf24;}
|
||||
[data-theme="dark"] .chip.bug{color:#60a5fa;}
|
||||
.trow.unread{border-color:var(--primary);box-shadow:0 0 0 1px var(--primary) inset;}
|
||||
.trow .tsubline{display:flex;align-items:center;gap:8px;min-width:0;}
|
||||
.trow .tsubline .tsub{flex:1;min-width:0;}
|
||||
.dot-new{flex:none;font-size:.68rem;font-weight:800;padding:2px 8px;border-radius:999px;background:var(--primary);color:#fff;text-transform:uppercase;letter-spacing:.04em;}
|
||||
.turn{font-weight:700;}
|
||||
.turn.staff{color:#d97706;} .turn.player{color:var(--primary);}
|
||||
.badge.waiting{background:rgba(245,158,11,.14);color:#b45309;}
|
||||
[data-theme="dark"] .badge.waiting{color:#fbbf24;}
|
||||
|
||||
/* ── staff toolbar ── */
|
||||
.staff-tools{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:16px;}
|
||||
.staff-tools .subfilter{margin-bottom:0;}
|
||||
.staff-tools .field{width:auto;padding:9px 13px;font-size:.88rem;}
|
||||
.staff-tools input.field{flex:1;min-width:180px;}
|
||||
.toggle-chip{cursor:pointer;font:inherit;font-weight:700;font-size:.82rem;padding:8px 13px;border-radius:999px;border:1px solid var(--line);background:var(--card);color:var(--muted);}
|
||||
.toggle-chip.on{border-color:var(--primary);color:var(--primary);background:var(--primary-soft);}
|
||||
|
||||
/* ── thread extras ── */
|
||||
.sysmsg{align-self:center;font-size:.78rem;font-weight:700;color:var(--muted);background:var(--card);border:1px solid var(--line);padding:5px 12px;border-radius:999px;text-align:center;max-width:90%;}
|
||||
.sysmsg.internal{border-style:dashed;}
|
||||
.bubble.note{background:rgba(245,158,11,.1);border:1px dashed rgba(245,158,11,.6);color:var(--text);border-top-right-radius:5px;}
|
||||
.msgrow.note .who{color:#b45309;}
|
||||
[data-theme="dark"] .msgrow.note .who{color:#fbbf24;}
|
||||
.att-grid{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;}
|
||||
.msgrow.staff .att-grid{justify-content:flex-end;}
|
||||
.att-grid a{display:block;width:120px;height:90px;border-radius:10px;overflow:hidden;border:1px solid var(--line);background:var(--panel);}
|
||||
.att-grid img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||
.pcard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:13px 16px;display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;font-size:.86rem;}
|
||||
.pcard .pname{font-weight:800;font-size:.95rem;margin-right:auto;}
|
||||
.pcard .pname small{color:var(--muted);font-weight:600;margin-left:6px;}
|
||||
.pcard .pk{color:var(--muted);font-weight:700;}
|
||||
.pcard .bad{color:#e11d48;font-weight:800;}
|
||||
.pcard a{color:var(--primary);font-weight:700;text-decoration:none;}
|
||||
.claim-btn{cursor:pointer;font:inherit;font-weight:700;font-size:.82rem;padding:8px 13px;border-radius:999px;border:1px solid var(--line);background:var(--card);color:var(--muted);white-space:nowrap;}
|
||||
.claim-btn:hover{border-color:var(--primary);color:var(--primary);}
|
||||
.claim-btn.mine{border-color:var(--primary);color:var(--primary);background:var(--primary-soft);}
|
||||
.foot-tools{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:9px;}
|
||||
.foot-tools select.field{width:auto;padding:7px 34px 7px 11px;font-size:.84rem;}
|
||||
.icon-btn-sm{cursor:pointer;border:1px solid var(--line);background:var(--card);color:var(--muted);border-radius:10px;padding:7px 11px;font:inherit;font-weight:700;font-size:.84rem;display:inline-flex;align-items:center;gap:6px;}
|
||||
.icon-btn-sm:hover{border-color:var(--primary);color:var(--primary);}
|
||||
.icon-btn-sm.on{border-color:#d97706;color:#d97706;background:rgba(245,158,11,.1);}
|
||||
.pending-att{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:9px;}
|
||||
.pending-att:empty{display:none;}
|
||||
.pending-att .pa{position:relative;width:64px;height:48px;border-radius:8px;overflow:hidden;border:1px solid var(--line);}
|
||||
.pending-att .pa img{width:100%;height:100%;object-fit:cover;}
|
||||
.pending-att .pa button{position:absolute;top:2px;right:2px;border:none;border-radius:50%;width:18px;height:18px;line-height:16px;font-size:13px;cursor:pointer;background:rgba(0,0,0,.6);color:#fff;padding:0;}
|
||||
.pending-att .pa.busy{opacity:.5;}
|
||||
.modal-foot.note-mode textarea.field{border-color:#d97706;background:rgba(245,158,11,.06);}
|
||||
.limit-note{font-size:.84rem;color:var(--muted);font-weight:600;}
|
||||
@media (max-width:640px){ .staff-tools input.field{min-width:100%;} .att-grid a{width:96px;height:72px;} }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu"><img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat"></button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search"><svg viewBox="0 0 24 24" width="22" height="22" 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></button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg><span class="notif-badge hidden" id="notifBadge"></span></button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main">
|
||||
<div class="sup-page">
|
||||
|
||||
<div class="sup-hero">
|
||||
<div class="hero-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
|
||||
</div>
|
||||
<h1>How can we help?</h1>
|
||||
<p>Ask the assistant anything — type a question or pick a topic. If it can't help, open a ticket and a real member of the Campus team will reply here.</p>
|
||||
</div>
|
||||
|
||||
<div class="sup-tabs" id="tabs">
|
||||
<button data-tab="help" class="active">Help assistant</button>
|
||||
<button data-tab="mine" id="tabMine" hidden>My tickets <span class="pill" id="minePill" hidden>0</span></button>
|
||||
<button data-tab="staff" id="tabStaff" hidden>All tickets <span class="pill" id="openPill" hidden>0</span></button>
|
||||
</div>
|
||||
|
||||
<!-- HELP ASSISTANT -->
|
||||
<section class="panel active" id="panel-help">
|
||||
<div class="surface">
|
||||
<div class="asst-head">
|
||||
<div class="asst-av"><img src="assets/cat-icon.png" alt=""></div>
|
||||
<div>
|
||||
<div class="asst-name">Vanessa</div>
|
||||
<div class="asst-sub"><span class="asst-dot"></span> Answers common questions instantly</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat" id="chat"></div>
|
||||
<form class="ask-bar" id="askForm" autocomplete="off">
|
||||
<input class="field" id="askInput" maxlength="200" placeholder="Type your question… e.g. “I forgot my password”" aria-label="Ask a question">
|
||||
<button class="btn-send" type="submit">Ask</button>
|
||||
</form>
|
||||
<div class="human-bar">
|
||||
<button class="human-btn" id="humanBtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Talk to a real person
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MY TICKETS -->
|
||||
<section class="panel" id="panel-mine">
|
||||
<div class="panel-top">
|
||||
<h2>My tickets</h2>
|
||||
<button class="btn-new" id="newTicketBtn"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> New ticket</button>
|
||||
</div>
|
||||
<div class="tlist" id="mineList"></div>
|
||||
</section>
|
||||
|
||||
<!-- STAFF: ALL TICKETS -->
|
||||
<section class="panel" id="panel-staff">
|
||||
<div class="panel-top"><h2>All tickets</h2></div>
|
||||
<div class="staff-tools">
|
||||
<div class="subfilter" id="staffFilter">
|
||||
<button data-status="open" class="active">Open</button>
|
||||
<button data-status="closed">Closed</button>
|
||||
<button data-status="all">All</button>
|
||||
</div>
|
||||
<select class="field" id="staffCat" aria-label="Topic"></select>
|
||||
<input class="field" id="staffQ" placeholder="Search #id, @username or subject" aria-label="Search tickets">
|
||||
</div>
|
||||
<div class="staff-tools">
|
||||
<button type="button" class="toggle-chip" data-f="waiting">Waiting on staff</button>
|
||||
<button type="button" class="toggle-chip" data-f="mine">Claimed by me</button>
|
||||
<button type="button" class="toggle-chip" data-f="unclaimed">Unclaimed</button>
|
||||
</div>
|
||||
<div class="tlist" id="staffList"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- THREAD MODAL -->
|
||||
<div class="modal-scrim" id="scrimM"></div>
|
||||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="mTitle">
|
||||
<div class="modal-head">
|
||||
<div class="mnum" id="mNum">#</div>
|
||||
<div class="mtitle"><h3 id="mTitle">Ticket</h3><div class="msub" id="mSub"></div></div>
|
||||
<span class="badge" id="mBadge" hidden></span>
|
||||
<button class="claim-btn" id="mClaim" hidden>Claim</button>
|
||||
<button class="mtoggle" id="mToggle" hidden>Close ticket</button>
|
||||
<button class="modal-x" id="mClose" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="thread" id="thread"></div>
|
||||
<div class="modal-foot" id="mFoot">
|
||||
<div class="row">
|
||||
<textarea class="field" id="mReply" placeholder="Write a reply…" rows="1"></textarea>
|
||||
<button class="btn-send" id="mSend">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CREATE-TICKET MODAL (centred overlay, like the in-game report dialog) -->
|
||||
<div class="modal-scrim" id="scrimT"></div>
|
||||
<div class="modal" id="tmodal" role="dialog" aria-modal="true" aria-labelledby="tmTitle">
|
||||
<div class="modal-head">
|
||||
<div class="mnum"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v4a2 2 0 0 0 0 4v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-4a2 2 0 0 0 0-4V6a2 2 0 0 1 2-2z"/><path d="M9 4v16"/></svg></div>
|
||||
<div class="mtitle"><h3 id="tmTitle">Open a support ticket</h3><div class="msub">A real member of the team will reply here on the website.</div></div>
|
||||
<button class="modal-x" id="tmClose" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="tmBody"></div>
|
||||
</div>
|
||||
|
||||
<!-- SEARCH PANEL (nav parity) -->
|
||||
<div class="search-panel" id="searchPanel"><div class="search-card"><div class="search-tabs"><button class="stab active" data-tab="people">People</button><button class="stab" data-tab="rooms">Rooms</button></div><div class="search-field"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off"></div><div class="search-results" id="searchResults"></div></div></div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const DISCORD = "https://discord.gg/xH8MMwK5Bk";
|
||||
const chat = document.getElementById('chat');
|
||||
const state = { signedIn:false, staff:false, username:"", currentTicket:null, role:null };
|
||||
|
||||
const el = (tag, cls, html) => { const e=document.createElement(tag); if(cls)e.className=cls; if(html!=null)e.innerHTML=html; return e; };
|
||||
const esc = s => (s==null?"":String(s)).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const when = s => { try { return new Date(s).toLocaleString(); } catch { return ""; } };
|
||||
function ago(s){ try{ const d=(Date.now()-new Date(s).getTime())/1000; if(d<60)return"just now";
|
||||
if(d<3600)return Math.floor(d/60)+"m ago"; if(d<86400)return Math.floor(d/3600)+"h ago";
|
||||
if(d<604800)return Math.floor(d/86400)+"d ago"; return new Date(s).toLocaleDateString(); }catch{return"";} }
|
||||
function span(sec){ if(sec==null) return "permanently"; 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);
|
||||
return d? d+"d "+h+"h" : h? h+"h "+m+"m" : Math.max(1,m)+" min"; }
|
||||
async function jget(u){ try{ const r=await fetch(u,{credentials:'same-origin'}); return r.ok?r.json():null; }catch{ return null; } }
|
||||
async function jpost(u,b){ const r=await fetch(u,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):null}); let j=null; try{j=await r.json();}catch{} return {ok:r.ok,status:r.status,body:j}; }
|
||||
|
||||
// ---------- tabs ----------
|
||||
const tabs = document.getElementById('tabs');
|
||||
function showTab(name){
|
||||
tabs.querySelectorAll('button').forEach(b=>b.classList.toggle('active', b.dataset.tab===name));
|
||||
document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active', p.id==='panel-'+name));
|
||||
if(name==='mine') loadMine();
|
||||
if(name==='staff') loadStaff();
|
||||
}
|
||||
tabs.addEventListener('click', e => { const b=e.target.closest('button'); if(b&&b.dataset.tab && !b.hidden) showTab(b.dataset.tab); });
|
||||
|
||||
// ======================================================================================
|
||||
// ASSISTANT
|
||||
// Answers are data (FAQ); menus (TREE) point at them; typed questions are matched against
|
||||
// their keywords. Anything unanswered ends in "open a ticket" with the question carried over.
|
||||
// ======================================================================================
|
||||
const FAQ = {
|
||||
make_account:{ q:"How do I make an account?", kw:"make create new account sign up signup register join",
|
||||
a:"Accounts are made here on the website: tap Sign up, pick a username and password, then log in inside the game with that same username and password. The first time you log in on a device, the account is locked to it (one game account + one website account per device).", cat:"account" },
|
||||
cant_login:{ q:"I can't log in", kw:"cant can't login log in sign in signin unable error wrong password incorrect locked device",
|
||||
a:"Check you're typing the exact username and password you made on the website. If the game says the account is tied to another device, that's the one-account-per-device lock — open a ticket and we'll sort it. Too many wrong passwords locks logins for a few minutes, so wait before trying again.", cat:"account" },
|
||||
forgot_pw:{ q:"I forgot my password", kw:"forgot forgotten reset password lost recover recovery",
|
||||
a:"We can reset it. Open a ticket (Account / login) with your username and roughly when you made the account, and a staff member will help you get back in.", cat:"account" },
|
||||
change_name:{ q:"How do I change my name or picture?", kw:"change name username display nickname rename profile picture avatar photo bio banner",
|
||||
a:"On the website: Settings → Profile for your display name and bio, and tap your profile picture or banner on your profile page to change them. In-game changes show up on the website too. (If your account is banned, changes are blocked until the ban ends.)", cat:"account" },
|
||||
join_friend:{ q:"I can't join my friend", kw:"join friend friends follow cant can't unable room full private",
|
||||
a:"You both need to be online, and they need to be in a room you're allowed into — private rooms need you to be friends or invited, and full rooms put you in another copy of the room. Try again from their player card; if it keeps failing, open a ticket and say who you tried to join.", cat:"join" },
|
||||
invites:{ q:"Invites aren't working", kw:"invite invites party parties button",
|
||||
a:"Send invites from the friends list or a party. The other player must be online and not appearing offline. If the invite button does nothing, open a ticket.", cat:"join" },
|
||||
change_rooms:{ q:"“Unable to change rooms”", kw:"unable change rooms room loading stuck load failed",
|
||||
a:"That usually clears on a retry. If it happens every time, open a ticket saying what you were joining (a player, an invite, or an event) and roughly when.", cat:"join" },
|
||||
reward:{ q:"I didn't get my reward", kw:"reward rewards xp level tokens not get missing didnt didn't",
|
||||
a:"Rewards are given when you finish an activity in its room; some are a pick-one-of-three on your watch. If one truly never showed up, open a ticket with the game mode and roughly when.", cat:"econ" },
|
||||
giftbox:{ q:"How do gift boxes work?", kw:"gift box boxes open present unwrap",
|
||||
a:"Finish a supported game mode and you're offered a gift box — open it from your watch and the item goes to your inventory. Staff can also send gift boxes.", cat:"econ" },
|
||||
balance:{ q:"My balance looks wrong", kw:"balance currency tokens money coins gold silver wrong missing",
|
||||
a:"Balances are saved to your account. If a number looks off, open a ticket with a screenshot of what you see and what you expected.", cat:"econ" },
|
||||
report_player:{ q:"Report a player", kw:"report player harass harassment bully toxic cheat cheating hacker hacking",
|
||||
a:"For anything happening right now, use the in-game report on their player card — it reaches the moderators straight away. Otherwise open a ticket (Report a player) with their username, what happened and a screenshot if you have one.", cat:"report_player" },
|
||||
report_room:{ q:"Report a room", kw:"report room inappropriate content map",
|
||||
a:"Open a ticket (Report a room) with the room's name and what's wrong with it — a screenshot helps a lot.", cat:"report_room" },
|
||||
bug:{ q:"Report a bug", kw:"bug bugs glitch broken crash crashes crashing error freeze",
|
||||
a:"Thanks — bug reports really help! Open a ticket (Bug report) saying what you did, what went wrong and where. Attach a screenshot if you can.", cat:"bug" },
|
||||
banned:{ q:"Am I banned?", kw:"ban banned suspended suspension timeout timed out kicked appeal unban",
|
||||
a:null, special:"ban", cat:"appeal" },
|
||||
mic:{ q:"My mic doesn't work / mic ban", kw:"mic microphone voice muted mute talk speak hear audio",
|
||||
a:null, special:"mic", cat:"other" },
|
||||
screenshare:{ q:"Desktop sharing doesn't work", kw:"screen share sharing desktop stream display not supported",
|
||||
a:"Desktop screen sharing needs the Screenshare role on your account — staff can give it to you. With it you can share in any room, and everyone in that room sees your screen. Without it the display says you don't have permission. Make sure your Campus launcher is up to date too.", cat:"bug" },
|
||||
download:{ q:"How do I download the game?", kw:"download install get game pc quest steam launcher",
|
||||
a:"Head to the Download page (top right) for the PC and Quest builds and the install steps.", cat:"other" },
|
||||
website_blocked:{ q:"I can't change anything on the website", kw:"website cant can't change save blocked settings profile error",
|
||||
a:null, special:"ban", cat:"appeal" },
|
||||
discord:{ q:"Where's the Discord?", kw:"discord server community chat link",
|
||||
a:"Join the Campus Discord: "+DISCORD, cat:"other" },
|
||||
};
|
||||
const TREE = {
|
||||
root:{ bot:["Hi! I'm Vanessa, the Campus support assistant.","Pick a topic, or type your question below."], options:[
|
||||
{label:"Logging in & my account",to:"account"},{label:"Joining friends & invites",to:"join"},
|
||||
{label:"Currency, rewards & gift boxes",to:"econ"},{label:"Bans, mic bans & appeals",to:"mod"},
|
||||
{label:"Report a player, room or bug",to:"report"},{label:"Something else",to:"other"} ]},
|
||||
account:{ bot:["Account & login — pick one:"], options:["make_account","cant_login","forgot_pw","change_name",{label:"← Back",to:"root"}] },
|
||||
join:{ bot:["Joining & invites — pick one:"], options:["join_friend","invites","change_rooms",{label:"← Back",to:"root"}] },
|
||||
econ:{ bot:["Currency, rewards & gift boxes — pick one:"], options:["reward","giftbox","balance",{label:"← Back",to:"root"}] },
|
||||
mod:{ bot:["Bans, mic bans & appeals — pick one:"], options:["banned","mic","website_blocked",{label:"← Back",to:"root"}] },
|
||||
report:{ bot:["Reporting — pick one:"], options:["report_player","report_room","bug",{label:"← Back",to:"root"}] },
|
||||
other:{ bot:["Here are a few more — or open a ticket for anything else."], options:["screenshare","download","discord",{label:"Open a ticket",ticket:"other"},{label:"← Back",to:"root"}] },
|
||||
};
|
||||
|
||||
let engaged=false;
|
||||
const scrollChat = () => { if(engaged) window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}); };
|
||||
function botSay(lines, then){ const t=el('div','msg bot'); t.appendChild(el('span','typing','<span></span><span></span><span></span>')); chat.appendChild(t); scrollChat();
|
||||
setTimeout(()=>{ t.remove(); (Array.isArray(lines)?lines:[lines]).forEach(x=>{ if(x instanceof Node) chat.appendChild(x); else { const m=el('div','msg bot'); m.textContent=x; chat.appendChild(m); } }); scrollChat(); then&&then(); },420); }
|
||||
function meSay(x){ const m=el('div','msg me'); m.textContent=x; chat.appendChild(m); scrollChat(); }
|
||||
function choiceRow(opts){ const w=el('div','msg bot'), c=el('div','choices'); w.style.cssText='background:transparent;border:none;padding:0;max-width:100%';
|
||||
opts.forEach(o=>{ const b=el('button','choice'+(o.cls?' '+o.cls:''),esc(o.label)); b.onclick=()=>{ engaged=true; o.run(); }; c.appendChild(b); });
|
||||
w.appendChild(c); chat.appendChild(w); scrollChat(); }
|
||||
|
||||
function renderNode(id){ const node=TREE[id]; if(!node)return;
|
||||
botSay(node.bot, ()=>choiceRow(node.options.map(o=>{
|
||||
if(typeof o==='string'){ const f=FAQ[o]; return {label:f.q, run:()=>{ meSay(f.q); answer(o); }}; }
|
||||
if(o.ticket) return {label:o.label, cls:'primary', run:()=>{ meSay(o.label); openTicketModal(o.ticket); }};
|
||||
return {label:o.label, cls:o.to==='root'?'ghost':'', run:()=>{ meSay(o.label); renderNode(o.to); }};
|
||||
}))); }
|
||||
|
||||
// The ban / mic-ban answers read the signed-in player's real status.
|
||||
function statusCard(){
|
||||
const r=state.role;
|
||||
if(!r||!r.signedIn) return null;
|
||||
const lines=[];
|
||||
if(r.ban) lines.push('<b>Your account is banned '+(r.ban.permanent?'permanently':'for another '+esc(span(r.ban.remainingSeconds)))+'.</b>'+(r.ban.reason?'Reason: '+esc(r.ban.reason):''));
|
||||
if(r.micBan) lines.push('<b>Your mic is banned '+(r.micBan.permanent?'permanently':'for another '+esc(span(r.micBan.remainingSeconds)))+'.</b>Voice chat is off until it ends; you can still hear everyone and use text chat.');
|
||||
if(!lines.length) return el('div','status-card ok','<b>Good news — your account has no active bans.</b>Nothing is restricting your account right now.');
|
||||
return el('div','status-card bad', lines.join('<br>'));
|
||||
}
|
||||
function answer(key, typedQuestion){
|
||||
const f=FAQ[key];
|
||||
if(f.special==='ban' || f.special==='mic'){
|
||||
const card=statusCard();
|
||||
const r=state.role||{};
|
||||
const text = !r.signedIn
|
||||
? (f.special==='mic'
|
||||
? "If your voice chat suddenly stopped, you may have a mic ban — the game shows it in the Moderation Notice with a countdown. Log in here to see your status."
|
||||
: "Log in here and I can show your ban status. A banned account can still sign in to the website to read things and appeal, but can't change anything until the ban ends.")
|
||||
: (f.special==='mic'
|
||||
? (r.micBan ? "A mic ban switches voice chat off for its duration. It lifts on its own when the timer ends." : "No mic ban on your account — check your mic is selected in the game's settings and that you're not muted. Still silent? Open a bug ticket.")
|
||||
: (r.ban ? "While banned you can read everything on the website but can't change anything. If you think the ban is a mistake, appeal it — a staff member will review it." : "If something still seems blocked, open a ticket and tell us what you tried."));
|
||||
botSay(card?[card,text]:[text], ()=>followUp(key, typedQuestion, (r.ban||r.micBan)?{label:"Appeal it",cat:"appeal"}:null));
|
||||
return;
|
||||
}
|
||||
botSay(f.a, ()=>followUp(key, typedQuestion));
|
||||
}
|
||||
function followUp(key, typedQuestion, primary){
|
||||
const f=FAQ[key];
|
||||
const opts=[];
|
||||
if(primary) opts.push({label:primary.label, cls:'primary small', run:()=>{ meSay(primary.label); openTicketModal(primary.cat, typedQuestion); }});
|
||||
opts.push({label:"👍 That helped", cls:'small', run:()=>{ meSay("That helped"); botSay("Great! Anything else?", ()=>renderNode('root')); }});
|
||||
if(!primary) opts.push({label:"Still need help — open a ticket", cls:'primary small', run:()=>{ meSay("I still need help"); openTicketModal(f.cat, typedQuestion); }});
|
||||
opts.push({label:"← Topics", cls:'ghost small', run:()=>{ meSay("Topics"); renderNode('root'); }});
|
||||
choiceRow(opts);
|
||||
}
|
||||
|
||||
// Typed questions: score each answer by the question's words. Words are compared by stem
|
||||
// ("working" = "work", "invites" = "invite"); topic words count 3, generic ones ("working",
|
||||
// "help", "broken") only 1, so "my mic isn't working" lands on the mic answer.
|
||||
const STOP=new Set("i me my a an the to is it its of on in and or do does can how why what when where with for im dont not isnt cant wont doesnt anymore any just still really please".split(" "));
|
||||
const GENERIC=new Set(["work","help","broken","problem","issue","wrong","error","fix","get","got","use","thing","stuff","game","campus"]);
|
||||
const stem = w => { w=w.replace(/'s$/,'').replace(/'/g,''); if(w.length>5 && w.endsWith('ing')) return w.slice(0,-3); if(w.length>4 && w.endsWith('ed')) return w.slice(0,-2); if(w.length>3 && w.endsWith('s') && !w.endsWith('ss')) return w.slice(0,-1); return w; };
|
||||
const toks = t => t.toLowerCase().replace(/[^a-z0-9' ]+/g,' ').split(/\s+/).filter(Boolean).map(stem);
|
||||
const FAQ_TOKENS = Object.fromEntries(Object.entries(FAQ).map(([k,f])=>[k, new Set(toks(f.kw+' '+f.q))]));
|
||||
function bestMatches(text){
|
||||
const words=[...new Set(toks(text).filter(w=>w.length>1 && !STOP.has(w)))];
|
||||
if(!words.length) return [];
|
||||
return Object.keys(FAQ).map(k=>{
|
||||
const set=FAQ_TOKENS[k]; let s=0;
|
||||
words.forEach(w=>{ if(set.has(w)) s+= GENERIC.has(w)?1:3; else if(w.length>4 && [...set].some(x=>x.length>4 && (x.startsWith(w)||w.startsWith(x)))) s+=1; });
|
||||
return [k,s];
|
||||
}).filter(x=>x[1]>0).sort((a,b)=>b[1]-a[1]);
|
||||
}
|
||||
function ask(text){
|
||||
text=text.trim(); if(!text) return;
|
||||
engaged=true; meSay(text);
|
||||
const m=bestMatches(text);
|
||||
if(!m.length || m[0][1]<3){
|
||||
botSay(["I'm not sure about that one — a real person can help."], ()=>choiceRow([
|
||||
{label:"Open a ticket", cls:'primary', run:()=>{ meSay("Open a ticket"); openTicketModal('', text); }},
|
||||
{label:"← Topics", cls:'ghost', run:()=>{ meSay("Topics"); renderNode('root'); }} ]));
|
||||
return;
|
||||
}
|
||||
answer(m[0][0], text);
|
||||
// Close runners-up are offered as "Did you mean".
|
||||
const others=m.slice(1,3).filter(x=>x[1]>=3 && x[1]>=m[0][1]-1);
|
||||
if(others.length) setTimeout(()=>{ const box=el('div','msg bot'); box.textContent="Or did you mean:"; chat.appendChild(box);
|
||||
choiceRow(others.map(([k])=>({label:FAQ[k].q, cls:'small', run:()=>{ meSay(FAQ[k].q); answer(k, text); }}))); }, 900);
|
||||
}
|
||||
const askForm=document.getElementById('askForm');
|
||||
askForm.addEventListener('submit', e=>{ e.preventDefault(); const i=document.getElementById('askInput'); ask(i.value); i.value=''; });
|
||||
|
||||
// ======================================================================================
|
||||
// SCREENSHOTS (shared by the new-ticket form and the reply box)
|
||||
// ======================================================================================
|
||||
function attacher(listEl){
|
||||
const items=[]; // {name,url,busy}
|
||||
function draw(){ listEl.innerHTML=''; items.forEach((it,i)=>{ const d=el('div','pa'+(it.busy?' busy':''));
|
||||
d.innerHTML=(it.preview?'<img src="'+esc(it.preview)+'" alt="">':'')+'<button type="button" aria-label="Remove">×</button>';
|
||||
d.querySelector('button').onclick=()=>{ items.splice(i,1); draw(); if(it.name) fetch('/api/site/support/upload/'+encodeURIComponent(it.name),{method:'DELETE',credentials:'same-origin'}).catch(()=>{}); }; listEl.appendChild(d); }); }
|
||||
async function add(file){
|
||||
if(!file || !/^image\//.test(file.type)) return "Only images can be attached.";
|
||||
if(items.length>=4) return "Up to 4 screenshots per message.";
|
||||
if(file.size>6*1024*1024) return "That image is too big (max 6 MB).";
|
||||
const it={busy:true, preview:URL.createObjectURL(file)}; items.push(it); draw();
|
||||
const fd=new FormData(); fd.append('file', file, file.name||'screenshot.png');
|
||||
try{
|
||||
const r=await fetch('/api/site/support/upload',{method:'POST',credentials:'same-origin',body:fd});
|
||||
const j=await r.json().catch(()=>({}));
|
||||
if(!r.ok||!j.ok){ items.splice(items.indexOf(it),1); draw(); return (j&&j.error)||"Upload failed."; }
|
||||
it.name=j.name; it.busy=false; draw(); return null;
|
||||
}catch{ items.splice(items.indexOf(it),1); draw(); return "Upload failed."; }
|
||||
}
|
||||
return { add, names:()=>items.filter(i=>i.name).map(i=>i.name), busy:()=>items.some(i=>i.busy), clear:()=>{ items.length=0; draw(); } };
|
||||
}
|
||||
function pickFiles(onFile){ const i=document.createElement('input'); i.type='file'; i.accept='image/*'; i.multiple=true; i.onchange=()=>[...i.files].forEach(onFile); i.click(); }
|
||||
function wirePaste(target, onFile){ target.addEventListener('paste', e=>{ const f=[...(e.clipboardData?.files||[])].filter(x=>/^image\//.test(x.type)); if(f.length){ e.preventDefault(); f.forEach(onFile); } }); }
|
||||
|
||||
// ======================================================================================
|
||||
// NEW TICKET
|
||||
// ======================================================================================
|
||||
const tmodal=document.getElementById('tmodal'), scrimT=document.getElementById('scrimT'), tmBody=document.getElementById('tmBody');
|
||||
const CATS=[
|
||||
{v:"",label:"Choose a topic…"},
|
||||
{v:"account",label:"Account / login issue"},
|
||||
{v:"join",label:"Joining & invites"},
|
||||
{v:"econ",label:"Currency, rewards & gift boxes"},
|
||||
{v:"report_player",label:"Report a player"},
|
||||
{v:"report_room",label:"Report a room"},
|
||||
{v:"bug",label:"Bug report"},
|
||||
{v:"appeal",label:"Ban appeal"},
|
||||
{v:"other",label:"Something else"},
|
||||
];
|
||||
const catLabel = v => (CATS.find(c=>c.v===v)||{label:"Something else"}).label;
|
||||
|
||||
// The extra fields each topic asks for. type: text | at | date ; optional defaults to required.
|
||||
const FIELDS={
|
||||
account:[
|
||||
{k:"username",label:"Your account username",type:"at",ph:"your username"},
|
||||
{k:"created",label:"When did you create your account?",type:"date",hint:"Roughly when you signed up — it helps us find your account.",optional:true},
|
||||
{k:"lastlogin",label:"When did you last log in successfully?",type:"date",optional:true},
|
||||
],
|
||||
join:[
|
||||
{k:"target",label:"Who were you trying to join?",type:"at",ph:"their username",optional:true},
|
||||
{k:"room",label:"Room or event name",type:"text",ph:"e.g. Crescendo",optional:true},
|
||||
],
|
||||
econ:[
|
||||
{k:"mode",label:"Which game mode or room?",type:"text",ph:"e.g. Isle of Lost Skulls",optional:true},
|
||||
{k:"missing",label:"What reward or currency is missing?",type:"text",ph:"e.g. 100 Gold",optional:true},
|
||||
],
|
||||
report_player:[
|
||||
{k:"player",label:"Player you're reporting",type:"at",ph:"their username"},
|
||||
{k:"where",label:"Where did it happen?",type:"text",ph:"room or game mode",optional:true},
|
||||
],
|
||||
report_room:[
|
||||
{k:"roomname",label:"Room name",type:"text",ph:"the room's name"},
|
||||
{k:"owner",label:"Room owner",type:"at",ph:"owner's username",optional:true},
|
||||
],
|
||||
bug:[
|
||||
{k:"where",label:"Where did it happen?",type:"text",ph:"room, game mode or a menu",optional:true},
|
||||
{k:"device",label:"PC or Quest?",type:"text",ph:"PC / Quest",optional:true},
|
||||
],
|
||||
appeal:[],
|
||||
other:[],
|
||||
};
|
||||
const DESC_PH={
|
||||
appeal:"Why should the ban be lifted? Tell us what happened from your side.",
|
||||
bug:"What did you do, what did you expect, and what happened instead?",
|
||||
report_player:"What happened? Add screenshots if you have them.",
|
||||
};
|
||||
|
||||
function fieldHTML(f){
|
||||
const id="tf_"+f.k;
|
||||
const lab='<label class="fl" for="'+id+'">'+f.label+(f.optional?' <span class="opt">(optional)</span>':' <span class="rq">*</span>')+'</label>';
|
||||
let input;
|
||||
if(f.type==='at') input='<div class="at-field"><span class="at">@</span><input id="'+id+'" maxlength="40" autocomplete="off"'+(f.ph?' placeholder="'+f.ph+'"':'')+'></div>';
|
||||
else if(f.type==='date') input='<input class="field" id="'+id+'" type="date">';
|
||||
else input='<input class="field" id="'+id+'" maxlength="120" autocomplete="off"'+(f.ph?' placeholder="'+f.ph+'"':'')+'>';
|
||||
return '<div class="frow">'+lab+input+(f.hint?'<div class="fhint">'+f.hint+'</div>':'')+'</div>';
|
||||
}
|
||||
function renderDynFields(cat){
|
||||
const wrap=document.getElementById('tfDyn'); if(wrap) wrap.innerHTML=(FIELDS[cat]||[]).map(fieldHTML).join('');
|
||||
const d=document.getElementById('tfDesc'); if(d) d.placeholder=DESC_PH[cat]||"What happened? Add anything that helps us sort it out.";
|
||||
const u=document.getElementById('tf_username'); if(u && !u.value && state.username) u.value=state.username;
|
||||
}
|
||||
|
||||
function closeTicketModal(){ tmodal.classList.remove('open'); scrimT.classList.remove('open'); }
|
||||
document.getElementById('tmClose').onclick=closeTicketModal; scrimT.onclick=closeTicketModal;
|
||||
document.addEventListener('keydown', e=>{ if(e.key==='Escape' && tmodal.classList.contains('open')) closeTicketModal(); });
|
||||
|
||||
let formAtt=null;
|
||||
function openTicketModal(presetCat, presetText){
|
||||
if(!state.signedIn){
|
||||
tmBody.className='modal-login';
|
||||
tmBody.innerHTML='<p>You need to be logged in to open a ticket, so the team can reply to you here on the website.</p>'+
|
||||
'<div class="choices"><a class="choice primary" href="/login">Log in</a><a class="choice" href="/signup">Sign up</a>'+
|
||||
'<a class="choice" href="'+DISCORD+'" target="_blank" rel="noopener">Ask on Discord</a></div>';
|
||||
} else if(!state.staff && state.role && state.role.openMine>=state.role.maxOpen){
|
||||
tmBody.className='modal-login';
|
||||
tmBody.innerHTML='<p>You already have '+state.role.maxOpen+' open tickets. Add to one of them instead, or close one you no longer need.</p>'+
|
||||
'<div class="choices"><button class="choice primary" id="goMine">Go to My tickets</button></div>';
|
||||
document.getElementById('goMine').onclick=()=>{ closeTicketModal(); showTab('mine'); };
|
||||
} else {
|
||||
tmBody.className='modal-body';
|
||||
tmBody.innerHTML = ticketFormHTML(presetCat||"");
|
||||
wireTicketForm(presetText);
|
||||
}
|
||||
tmodal.classList.add('open'); scrimT.classList.add('open');
|
||||
const f=document.getElementById('tfTitle'); if(f) setTimeout(()=>f.focus(),60);
|
||||
}
|
||||
|
||||
function ticketFormHTML(cat){
|
||||
const opts=CATS.map(c=>'<option value="'+c.v+'"'+(c.v===cat?' selected':'')+(c.v===''?' disabled':'')+'>'+c.label+'</option>').join('');
|
||||
return ''+
|
||||
'<div class="frow"><label class="fl" for="tfTitle">Title <span class="rq">*</span></label>'+
|
||||
'<input class="field" id="tfTitle" maxlength="120" placeholder="Short summary (e.g. Can\'t log in)"></div>'+
|
||||
'<div class="frow"><label class="fl" for="tfCat">What\'s it about? <span class="rq">*</span></label>'+
|
||||
'<select class="field" id="tfCat">'+opts+'</select></div>'+
|
||||
'<div id="tfDyn"></div>'+
|
||||
'<div class="frow"><label class="fl" for="tfDiscord">Discord <span class="opt">(optional)</span></label>'+
|
||||
'<input class="field" id="tfDiscord" maxlength="60" placeholder="yourname — so we can reach you" autocomplete="off"></div>'+
|
||||
'<div class="frow"><label class="fl" for="tfDesc">Describe the problem <span class="rq">*</span></label>'+
|
||||
'<textarea class="field" id="tfDesc" maxlength="4000"></textarea>'+
|
||||
'<div class="fhint">Screenshots help a lot — attach them below or paste one straight into the box.</div></div>'+
|
||||
'<div class="frow"><div class="pending-att" id="tfAtt"></div>'+
|
||||
'<div><button type="button" class="icon-btn-sm" id="tfAttach">📎 Add screenshots</button></div></div>'+
|
||||
'<div class="tform-foot"><span class="note" id="tfStatus"></span>'+
|
||||
'<button type="button" class="btn-send" id="tfSubmit">Submit ticket</button></div>';
|
||||
}
|
||||
|
||||
function wireTicketForm(presetText){
|
||||
const cat=document.getElementById('tfCat'), desc=document.getElementById('tfDesc'), status=document.getElementById('tfStatus');
|
||||
cat.addEventListener('change', ()=>renderDynFields(cat.value));
|
||||
renderDynFields(cat.value);
|
||||
if(presetText){ desc.value=presetText; const t=document.getElementById('tfTitle'); if(t && presetText.length<=80) t.value=presetText; }
|
||||
if(cat.value==='appeal'){ const t=document.getElementById('tfTitle'); if(t && !t.value) t.value="Ban appeal"; }
|
||||
formAtt=attacher(document.getElementById('tfAtt'));
|
||||
const addFile=async f=>{ const err=await formAtt.add(f); status.textContent=err||""; };
|
||||
document.getElementById('tfAttach').onclick=()=>pickFiles(addFile);
|
||||
wirePaste(desc, addFile);
|
||||
document.getElementById('tfSubmit').onclick = submitTicket;
|
||||
desc.addEventListener('keydown', e=>{ if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)) submitTicket(); });
|
||||
}
|
||||
|
||||
async function submitTicket(){
|
||||
const g=id=>document.getElementById(id);
|
||||
const title=g('tfTitle').value.trim(), cat=g('tfCat').value, discord=g('tfDiscord').value.trim(), desc=g('tfDesc').value.trim();
|
||||
const status=g('tfStatus'), btn=g('tfSubmit');
|
||||
if(!title){ status.textContent="Please add a title."; g('tfTitle').focus(); return; }
|
||||
if(!cat){ status.textContent="Please choose a topic."; g('tfCat').focus(); return; }
|
||||
const extra=[];
|
||||
for(const f of (FIELDS[cat]||[])){
|
||||
const inp=g('tf_'+f.k); let v=(inp?inp.value:'').trim();
|
||||
if(!v){ if(!f.optional){ status.textContent="Please fill in “"+f.label.replace(/\?$/,'')+"”."; if(inp) inp.focus(); return; } continue; }
|
||||
if(f.type==='at') v='@'+v.replace(/^@+/,'');
|
||||
extra.push(f.label.replace(/\?$/,'')+": "+v);
|
||||
}
|
||||
if(desc.length<3){ status.textContent="Please describe the problem."; g('tfDesc').focus(); return; }
|
||||
if(formAtt && formAtt.busy()){ status.textContent="Wait for the screenshots to finish uploading."; return; }
|
||||
const lines=["Issue type: "+catLabel(cat)].concat(extra);
|
||||
if(discord) lines.push("Discord: "+discord);
|
||||
const message = lines.join("\n") + "\n———\n" + desc;
|
||||
btn.disabled=true; btn.textContent="Submitting…"; status.textContent="";
|
||||
const res=await jpost('/api/site/support/create',{subject:title,message,category:cat,attachments:formAtt?formAtt.names():[]});
|
||||
if(res.ok && res.body && res.body.id){
|
||||
document.getElementById('tabMine').hidden=false;
|
||||
closeTicketModal();
|
||||
await refreshRole();
|
||||
openThread(res.body.id);
|
||||
} else {
|
||||
btn.disabled=false; btn.textContent="Submit ticket";
|
||||
status.textContent=(res.body&&res.body.error)||"Couldn't open the ticket. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('humanBtn').addEventListener('click', ()=>openTicketModal());
|
||||
document.getElementById('newTicketBtn').onclick = ()=>openTicketModal();
|
||||
|
||||
// ======================================================================================
|
||||
// TICKET LISTS
|
||||
// ======================================================================================
|
||||
function turnText(t, forStaff){
|
||||
if(t.status!=='open') return '';
|
||||
if(t.awaiting==='staff') return forStaff?'<span class="turn staff">Waiting on staff</span>':'<span class="turn">Waiting for staff</span>';
|
||||
if(t.awaiting==='player') return forStaff?'<span class="turn">Waiting on player</span>':'<span class="turn player">Staff replied — your turn</span>';
|
||||
return '';
|
||||
}
|
||||
function ticketRow(t, forStaff){
|
||||
const r=el('div','trow'+(t.unread?' unread':''));
|
||||
const meta = (forStaff?'<span class="who">@'+esc(t.username)+'</span>':'')+
|
||||
'<span class="chip '+esc(t.category)+'">'+esc(t.categoryLabel||'')+'</span>'+
|
||||
turnText(t, forStaff)+
|
||||
(forStaff&&t.assignedName?'<span>Claimed by @'+esc(t.assignedName)+'</span>':'')+
|
||||
'<span>'+esc(ago(t.updatedAt||t.createdAt))+'</span>'+
|
||||
'<span>'+(t.messageCount||0)+' message'+((t.messageCount===1)?'':'s')+'</span>';
|
||||
r.innerHTML='<div class="tnum">#'+t.id+'</div>'+
|
||||
'<div class="tmain"><div class="tsubline"><div class="tsub">'+esc(t.subject)+'</div>'+(t.unread?'<span class="dot-new">New</span>':'')+'</div>'+
|
||||
'<div class="tprev">'+esc(t.lastMessage||'No messages yet')+'</div>'+
|
||||
'<div class="tmeta">'+meta+'</div></div>'+
|
||||
'<span class="badge '+esc(t.status)+'">'+esc(t.status)+'</span>';
|
||||
r.onclick=()=>openThread(t.id);
|
||||
return r;
|
||||
}
|
||||
const emptyState = (txt) =>
|
||||
'<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" 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>'+txt+'</div>';
|
||||
async function loadMine(silent){
|
||||
const list=document.getElementById('mineList'); if(!silent && !list.children.length) list.innerHTML='<div class="empty">Loading…</div>';
|
||||
const j=await jget('/api/site/support/mine');
|
||||
const ts=(j&&j.tickets)||[];
|
||||
if(!ts.length){ list.innerHTML=emptyState("No tickets yet. Ask the Help assistant, or open one with New ticket."); return; }
|
||||
const frag=document.createDocumentFragment();
|
||||
ts.forEach(t=>frag.appendChild(ticketRow(t,false)));
|
||||
list.replaceChildren(frag);
|
||||
}
|
||||
|
||||
const staffF={ status:'open', category:'all', q:'', waiting:false, mine:false, unclaimed:false };
|
||||
document.getElementById('staffFilter').addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b)return;
|
||||
staffF.status=b.dataset.status; document.querySelectorAll('#staffFilter button').forEach(x=>x.classList.toggle('active',x===b)); loadStaff(); });
|
||||
document.getElementById('staffCat').innerHTML='<option value="all">All topics</option>'+CATS.filter(c=>c.v).map(c=>'<option value="'+c.v+'">'+c.label+'</option>').join('');
|
||||
document.getElementById('staffCat').onchange=e=>{ staffF.category=e.target.value; loadStaff(); };
|
||||
let qTimer=null;
|
||||
document.getElementById('staffQ').addEventListener('input', e=>{ clearTimeout(qTimer); qTimer=setTimeout(()=>{ staffF.q=e.target.value.trim(); loadStaff(); },250); });
|
||||
document.querySelectorAll('.toggle-chip[data-f]').forEach(b=>b.onclick=()=>{ const k=b.dataset.f; staffF[k]=!staffF[k];
|
||||
if(k==='mine'&&staffF.mine){ staffF.unclaimed=false; } if(k==='unclaimed'&&staffF.unclaimed){ staffF.mine=false; }
|
||||
document.querySelectorAll('.toggle-chip[data-f]').forEach(x=>x.classList.toggle('on',!!staffF[x.dataset.f])); loadStaff(); });
|
||||
async function loadStaff(silent){
|
||||
const list=document.getElementById('staffList'); if(!silent && !list.children.length) list.innerHTML='<div class="empty">Loading…</div>';
|
||||
const p=new URLSearchParams({status:staffF.status, category:staffF.category, q:staffF.q, waiting:staffF.waiting, mine:staffF.mine, unclaimed:staffF.unclaimed});
|
||||
const j=await jget('/api/site/support/all?'+p.toString());
|
||||
const ts=(j&&j.tickets)||[];
|
||||
if(!ts.length){ list.innerHTML=emptyState("No tickets match."); return; }
|
||||
const frag=document.createDocumentFragment();
|
||||
ts.forEach(t=>frag.appendChild(ticketRow(t,true)));
|
||||
list.replaceChildren(frag);
|
||||
}
|
||||
|
||||
// ======================================================================================
|
||||
// THREAD
|
||||
// ======================================================================================
|
||||
const modal=document.getElementById('modal'), scrimM=document.getElementById('scrimM');
|
||||
function closeModal(){ modal.classList.remove('open'); scrimM.classList.remove('open'); state.currentTicket=null; stopThreadPoll(); refreshRole(); }
|
||||
document.getElementById('mClose').onclick=closeModal; scrimM.onclick=closeModal;
|
||||
document.addEventListener('keydown', e=>{ if(e.key==='Escape' && modal.classList.contains('open')) closeModal(); });
|
||||
async function openThread(id){
|
||||
const j=await jget('/api/site/support/ticket/'+id);
|
||||
if(!j||!j.ticket){ alert("Couldn't open that ticket."); return; }
|
||||
renderThread(j.ticket, j.staff);
|
||||
modal.classList.add('open'); scrimM.classList.add('open');
|
||||
startThreadPoll(id);
|
||||
refreshLists(); refreshRole();
|
||||
if(typeof refreshNotifications==='function') refreshNotifications();
|
||||
}
|
||||
|
||||
function sigOf(t){ const m=t.messages||[]; return t.status+'|'+m.length+'|'+(m.length?m[m.length-1].at:'')+'|'+t.assignedTo; }
|
||||
let threadTimer=null;
|
||||
function stopThreadPoll(){ if(threadTimer){ clearInterval(threadTimer); threadTimer=null; } }
|
||||
function startThreadPoll(id){
|
||||
stopThreadPoll();
|
||||
threadTimer=setInterval(async ()=>{
|
||||
if(!modal.classList.contains('open')){ stopThreadPoll(); return; }
|
||||
const j=await jget('/api/site/support/ticket/'+id);
|
||||
if(!j||!j.ticket) return;
|
||||
if(sigOf(j.ticket)===state.threadSig) return;
|
||||
renderThread(j.ticket, j.staff, true);
|
||||
refreshLists();
|
||||
}, 5000);
|
||||
}
|
||||
setInterval(()=>{ if(!modal.classList.contains('open')) refreshLists(); refreshRole(); }, 10000);
|
||||
|
||||
const initial = s => (s||'?').trim().charAt(0).toUpperCase();
|
||||
function parseOpener(body){
|
||||
const idx = body.indexOf('\n———');
|
||||
if(idx<0) return null;
|
||||
const head = body.slice(0, idx).trim();
|
||||
const rest = body.slice(idx).replace(/^\s*———\s*/, '').trim();
|
||||
const rows=[]; let ok=false;
|
||||
head.split('\n').forEach(line=>{ const m=line.match(/^([^:]{1,40}):\s*(.+)$/); if(m){ ok=true; rows.push([m[1].trim(), m[2].trim()]); } });
|
||||
return ok ? {rows, body:rest} : null;
|
||||
}
|
||||
function detailsHTML(rows){
|
||||
const dl = rows.map(([k,v])=>{ const at=/^@/.test(v); return '<dt>'+esc(k)+'</dt><dd'+(at?' class="at"':'')+'>'+esc(v)+'</dd>'; }).join('');
|
||||
return '<div class="tdetails"><div class="dh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Ticket details</div><dl>'+dl+'</dl></div>';
|
||||
}
|
||||
function attHTML(list){ if(!list||!list.length) return '';
|
||||
return '<div class="att-grid">'+list.map(u=>'<a href="'+esc(u)+'" target="_blank" rel="noopener"><img src="'+esc(u)+'" alt="Screenshot" loading="lazy"></a>').join('')+'</div>'; }
|
||||
function msgHTML(m, bodyOverride){
|
||||
if(m.system) return '<div class="sysmsg'+(m.internal?' internal':'')+'">'+esc(m.body)+' · '+esc(ago(m.at))+'</div>';
|
||||
const cls = m.staff?'staff':'user';
|
||||
const note = m.internal;
|
||||
const av = m.avatar
|
||||
? '<div class="av-sm img" style="background-image:url(\''+esc(m.avatar)+'\')"></div>'
|
||||
: '<div class="av-sm '+cls+'">'+esc(initial(m.fromName))+'</div>';
|
||||
const body = bodyOverride!=null ? bodyOverride : m.body;
|
||||
return '<div class="msgrow '+cls+(note?' note':'')+'">'+av+
|
||||
'<div class="bcol"><div class="who">'+esc(m.fromName)+(m.staff?' · staff':'')+(note?' · internal note':'')+' · '+esc(ago(m.at))+'</div>'+
|
||||
(body?'<div class="bubble '+(note?'note':cls)+'">'+esc(body)+'</div>':'')+attHTML(m.attachments)+'</div></div>';
|
||||
}
|
||||
function playerCard(p){
|
||||
if(!p) return '';
|
||||
const bits=[];
|
||||
bits.push('<span class="pname">'+esc(p.displayName||p.username)+'<small>@'+esc(p.username)+' · id '+p.id+'</small></span>');
|
||||
if(p.createdAt) bits.push('<span><span class="pk">Joined</span> '+esc(new Date(p.createdAt).toLocaleDateString())+'</span>');
|
||||
bits.push('<span><span class="pk">Level</span> '+(p.level||0)+'</span>');
|
||||
if(p.junior) bits.push('<span class="bad">Junior</span>');
|
||||
if(p.banned) bits.push('<span class="bad">Banned '+(p.banPermanent?'permanently':esc(span(p.banRemainingSeconds))+' left')+'</span>');
|
||||
if(p.micBanned) bits.push('<span class="bad">Mic banned '+(p.micBanPermanent?'permanently':esc(span(p.micBanRemainingSeconds))+' left')+'</span>');
|
||||
if(!p.banned && !p.micBanned) bits.push('<span><span class="pk">Bans</span> none</span>');
|
||||
bits.push('<span><span class="pk">Other tickets</span> '+p.otherTickets+(p.otherOpen?' ('+p.otherOpen+' open)':'')+'</span>');
|
||||
bits.push('<a href="/user/'+encodeURIComponent(p.username)+'" target="_blank" rel="noopener">Profile ↗</a>');
|
||||
return '<div class="pcard">'+bits.join('')+'</div>';
|
||||
}
|
||||
|
||||
// Staff canned replies ({name} = the player's name).
|
||||
const CANNED=[
|
||||
["Hi — thanks for reaching out!", "Hi {name}, thanks for reaching out! We're looking into this and will get back to you here."],
|
||||
["Need more detail", "Hi {name}, could you tell us a bit more — roughly when it happened, where (room / game mode), and a screenshot if you have one?"],
|
||||
["Fixed — please try again", "Hi {name}, this should be fixed now. Could you try again and let us know if it still happens?"],
|
||||
["Password reset", "Hi {name}, we've reset your password. Log in with the temporary password we sent you and change it in Settings → Password straight away."],
|
||||
["Appeal accepted", "Hi {name}, we've reviewed your appeal and lifted the ban. Please keep the Code of Conduct in mind — thanks for being patient."],
|
||||
["Appeal declined", "Hi {name}, we've reviewed your appeal and the ban stays in place. It will end when the timer runs out."],
|
||||
["Report handled", "Hi {name}, thanks for the report — we've looked into it and taken action. We can't share details about other players' accounts."],
|
||||
["Closing — no reply", "Hi {name}, we haven't heard back so we're closing this ticket. Reply any time to reopen it."],
|
||||
];
|
||||
|
||||
function renderThread(t, staff, keepDraft){
|
||||
const box0=document.getElementById('mReply');
|
||||
const draft=keepDraft&&box0?box0.value:'', wasFocused=keepDraft&&box0&&document.activeElement===box0;
|
||||
state.currentTicket=t; state.threadStaff=staff;
|
||||
const staffView = !!staff;
|
||||
document.getElementById('mNum').textContent='#'+t.id;
|
||||
document.getElementById('mTitle').textContent=t.subject;
|
||||
document.getElementById('mSub').textContent='@'+t.username+' · '+(t.categoryLabel||'')+' · opened '+when(t.createdAt)+(t.assignedName?' · claimed by @'+t.assignedName:'');
|
||||
const bd=document.getElementById('mBadge'); bd.hidden=false;
|
||||
const waiting = t.status==='open' && t.awaiting==='staff';
|
||||
bd.className='badge '+(staffView&&waiting?'waiting':t.status); bd.textContent= staffView&&waiting ? 'waiting' : t.status;
|
||||
// claim (staff)
|
||||
const cb=document.getElementById('mClaim');
|
||||
if(staffView){ cb.hidden=false; const mine=t.assignedTo && t.assignedName && state.username && t.assignedName.toLowerCase()===state.username.toLowerCase();
|
||||
cb.className='claim-btn'+(mine?' mine':''); cb.textContent= mine?'Release':(t.assignedTo?'Take over':'Claim');
|
||||
cb.onclick=async()=>{ const r=await jpost('/api/site/support/ticket/'+t.id+'/assign',{assign:!mine}); if(r.ok&&r.body&&r.body.ticket){ renderThread(r.body.ticket,true); refreshLists(); } };
|
||||
} else cb.hidden=true;
|
||||
|
||||
const th=document.getElementById('thread'); th.innerHTML='';
|
||||
state.threadSig=sigOf(t);
|
||||
if(staffView && t.player) th.insertAdjacentHTML('beforeend', playerCard(t.player));
|
||||
const msgs=(t.messages||[]).slice();
|
||||
if(msgs.length){
|
||||
const first=msgs[0], parsed=parseOpener(first.body||'');
|
||||
if(parsed){
|
||||
th.insertAdjacentHTML('beforeend', detailsHTML(parsed.rows));
|
||||
th.insertAdjacentHTML('beforeend', msgHTML(first, parsed.body));
|
||||
msgs.shift();
|
||||
}
|
||||
}
|
||||
msgs.forEach(m=> th.insertAdjacentHTML('beforeend', msgHTML(m)) );
|
||||
th.scrollTop=th.scrollHeight;
|
||||
|
||||
const tg=document.getElementById('mToggle'); tg.hidden=false;
|
||||
tg.textContent = t.status==='open' ? (staffView?'Close ticket':'Close my ticket') : 'Reopen';
|
||||
tg.onclick = async () => { const path = t.status==='open'?'close':'reopen'; await jpost('/api/site/support/ticket/'+t.id+'/'+path); openThread(t.id); refreshLists(); };
|
||||
|
||||
// footer: reply box (a reply on a closed ticket reopens it)
|
||||
const foot=document.getElementById('mFoot');
|
||||
const tools = staffView
|
||||
? '<div class="foot-tools"><select class="field" id="mCanned"><option value="">Quick reply…</option>'+CANNED.map((c,i)=>'<option value="'+i+'">'+esc(c[0])+'</option>').join('')+'</select>'+
|
||||
'<button type="button" class="icon-btn-sm" id="mNote" title="Only staff can see internal notes">🔒 Internal note</button>'+
|
||||
'<button type="button" class="icon-btn-sm" id="mAttach">📎 Screenshot</button></div>'
|
||||
: '<div class="foot-tools"><button type="button" class="icon-btn-sm" id="mAttach">📎 Screenshot</button>'+
|
||||
(t.status==='closed'?'<span class="limit-note">Replying reopens this ticket.</span>':'')+'</div>';
|
||||
foot.className='modal-foot';
|
||||
foot.innerHTML = tools+'<div class="pending-att" id="mAtt"></div>'+
|
||||
'<div class="row"><textarea class="field" id="mReply" placeholder="'+(t.status==='open'?'Write a reply…':'Write a reply to reopen…')+'" rows="1"></textarea><button class="btn-send" id="mSend">Send</button></div>';
|
||||
wireSend(t, staffView);
|
||||
const nb=document.getElementById('mReply');
|
||||
if(draft){ nb.value=draft; } if(wasFocused){ nb.focus(); nb.selectionStart=nb.selectionEnd=nb.value.length; }
|
||||
}
|
||||
|
||||
function wireSend(t, staffView){
|
||||
const btn=document.getElementById('mSend'), box=document.getElementById('mReply'), foot=document.getElementById('mFoot');
|
||||
const att=attacher(document.getElementById('mAtt'));
|
||||
let noteMode=false;
|
||||
const addFile=async f=>{ const err=await att.add(f); if(err) alert(err); };
|
||||
document.getElementById('mAttach').onclick=()=>pickFiles(addFile);
|
||||
wirePaste(box, addFile);
|
||||
const nt=document.getElementById('mNote');
|
||||
if(nt) nt.onclick=()=>{ noteMode=!noteMode; nt.classList.toggle('on',noteMode); foot.classList.toggle('note-mode',noteMode);
|
||||
box.placeholder=noteMode?'Internal note — only staff will see this':'Write a reply…'; btn.textContent=noteMode?'Add note':'Send'; };
|
||||
const cn=document.getElementById('mCanned');
|
||||
if(cn) cn.onchange=()=>{ const c=CANNED[+cn.value]; if(c){ box.value=c[1].replace(/\{name\}/g, t.username); box.focus(); } cn.value=''; };
|
||||
const send = async () => {
|
||||
const v=box.value.trim(); if(!v && !att.names().length) return;
|
||||
if(att.busy()){ alert("Wait for the screenshots to finish uploading."); return; }
|
||||
btn.disabled=true;
|
||||
const res=await jpost('/api/site/support/ticket/'+t.id+'/reply',{message:v, internal:noteMode, attachments:att.names()});
|
||||
btn.disabled=false;
|
||||
if(res.ok && res.body && res.body.ticket){ renderThread(res.body.ticket,res.body.staff); refreshLists(); }
|
||||
else alert((res.body&&res.body.error)||"Couldn't send that - please try again.");
|
||||
};
|
||||
btn.onclick=send;
|
||||
box.addEventListener('keydown', e=>{ if(e.key==='Enter' && (e.ctrlKey||e.metaKey)){ e.preventDefault(); send(); } });
|
||||
}
|
||||
function refreshLists(){ if(document.getElementById('panel-mine').classList.contains('active')) loadMine(true);
|
||||
if(document.getElementById('panel-staff').classList.contains('active')) loadStaff(true); }
|
||||
|
||||
// Tab counters: unread replies on My tickets, tickets waiting on staff for All tickets.
|
||||
async function refreshRole(){
|
||||
const r=await jget('/api/site/support/role'); if(!r) return;
|
||||
state.role=r;
|
||||
const mp=document.getElementById('minePill');
|
||||
if(r.unreadMine>0){ mp.hidden=false; mp.textContent=r.unreadMine; } else mp.hidden=true;
|
||||
if(r.staff){ const p=document.getElementById('openPill'); if(r.awaitingStaff>0){ p.hidden=false; p.textContent=r.awaitingStaff; } else p.hidden=true; }
|
||||
}
|
||||
|
||||
// ---------- deep links ----------
|
||||
// /support/ticket/{id} -> open that thread ; /support/logs -> staff "All tickets"
|
||||
function handleDeepLink(){
|
||||
const path = (window.location.pathname||"").replace(/\/+$/,"");
|
||||
const m = path.match(/\/support\/ticket\/(\d+)$/i);
|
||||
if(m){ if(state.signedIn) showTab(state.staff?'staff':'mine'); openThread(parseInt(m[1],10)); return; }
|
||||
if(/\/support\/logs$/i.test(path)){
|
||||
if(state.staff){ staffF.waiting=true; document.querySelector('.toggle-chip[data-f="waiting"]').classList.add('on'); showTab('staff'); }
|
||||
else if(state.signedIn) showTab('mine');
|
||||
}
|
||||
const q=new URLSearchParams(location.search);
|
||||
if(q.get('new')){ openTicketModal(q.get('new')); }
|
||||
}
|
||||
|
||||
// ---------- boot ----------
|
||||
(async function(){
|
||||
renderNode('root');
|
||||
await refreshRole();
|
||||
const role=state.role;
|
||||
if(role){ state.signedIn=!!role.signedIn; state.staff=!!role.staff; state.username=role.username||"";
|
||||
if(state.signedIn) document.getElementById('tabMine').hidden=false;
|
||||
if(state.staff) document.getElementById('tabStaff').hidden=false;
|
||||
}
|
||||
handleDeepLink();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Weekly Campus</title>
|
||||
<link rel="icon" type="image/png" href="assets/cat-icon.png">
|
||||
<link rel="apple-touch-icon" href="assets/cat-icon.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css?v=20260927c" />
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="top-left">
|
||||
<button class="side-toggle" id="burger" aria-label="Open menu">
|
||||
<img src="assets/cat-icon.png" alt="Menu" class="burger-cat" id="burgerCat">
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="icon-btn" id="searchBtn" aria-label="Search">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||
</button>
|
||||
<button class="icon-btn notif-btn" id="notifBtn" aria-label="Notifications">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<span class="notif-badge hidden" id="notifBadge"></span>
|
||||
</button>
|
||||
<a href="/login" class="btn-login">Log in</a>
|
||||
<a href="/download" class="btn-dl">DOWNLOAD</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="cat-drawer" id="catDrawer">
|
||||
<nav class="cd-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/rooms">Rooms</a>
|
||||
<a href="/messages">Messages</a>
|
||||
<a href="/#feed">Creative Program</a>
|
||||
<a href="/patch-notes">Patch Notes</a>
|
||||
<a href="/support">Support</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/challenges">Challenges</a>
|
||||
<a href="/download">Download</a>
|
||||
<a href="https://discord.gg/xH8MMwK5Bk" target="_blank" rel="noopener">Discord</a>
|
||||
<a href="/accessibility">Accessibility</a>
|
||||
</nav>
|
||||
<button class="cd-theme-toggle"></button>
|
||||
</aside>
|
||||
<div class="side-scrim" id="scrim"></div>
|
||||
|
||||
<main id="main" class="updates-page">
|
||||
<header class="updates-head">
|
||||
<h1 id="updatesTitle">Updates</h1>
|
||||
<p id="updatesBlurb"></p>
|
||||
</header>
|
||||
<div id="updatesList" class="updates-list" data-updates="weekly"></div>
|
||||
</main>
|
||||
|
||||
<!-- SEARCH PANEL -->
|
||||
<div class="search-panel" id="searchPanel">
|
||||
<div class="search-card">
|
||||
<div class="search-tabs">
|
||||
<button class="stab active" data-tab="people">People</button>
|
||||
<button class="stab" data-tab="rooms">Rooms</button>
|
||||
</div>
|
||||
<div class="search-field">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" 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" id="searchInput" placeholder="Search people…" autocomplete="off">
|
||||
</div>
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-scrim" id="searchScrim"></div>
|
||||
|
||||
<script src="data.js?v=20260927c"></script>
|
||||
<script src="app.js?v=20260927c"></script>
|
||||
</body>
|
||||
</html>
|
||||