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