the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
Binary file not shown.
@@ -0,0 +1,29 @@
@echo off
REM ─── Campus PUBLIC launcher ───
REM Starts the website AND a public ngrok tunnel so anyone, anywhere
REM (phone or PC, any network) can hop on via the https link ngrok prints.
cd /d "%~dp0"
echo Installing/updating dependencies...
python -m pip install -q -r requirements.txt
echo.
echo Starting Campus server in a separate window...
start "Campus Server" cmd /k python server.py
echo Waiting for the server to come up...
timeout /t 4 /nobreak >nul
echo.
echo ====================================================
echo Campus is PUBLIC at:
echo https://elastic-unwanted-pectin.ngrok-free.dev
echo.
echo Share that link - it works on any phone or PC.
echo (Close this window or press Ctrl+C to stop the tunnel)
echo ====================================================
echo.
ngrok http --url=elastic-unwanted-pectin.ngrok-free.dev 5000
pause
@@ -0,0 +1,19 @@
@echo off
REM ─── Campus launcher ───
REM Serves the whole website + API from one Flask server and prints the
REM links other people can use to hop on.
cd /d "%~dp0"
echo Installing/updating dependencies...
python -m pip install -q -r requirements.txt
echo.
echo Starting Campus... open the link shown below in your browser.
echo (Press Ctrl+C to stop the server)
echo.
python server.py
echo.
echo Server stopped.
pause
@@ -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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+1908
View File
@@ -0,0 +1,1908 @@
// ───── 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 => {
const target = document.querySelector(e.currentTarget.getAttribute('href'));
if (target) { target.setAttribute('tabindex', '-1'); target.focus(); }
});
// 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';
}
// handle the login form
const loginForm = document.getElementById('loginForm');
if (loginForm) {
loginForm.addEventListener('submit', e => {
e.preventDefault();
const u = (document.getElementById('loginUser').value || '').trim();
const p = document.getElementById('loginPass').value || '';
const err = document.getElementById('loginError');
if (u.toLowerCase() === DEMO_USER && p === DEMO_PASS) {
localStorage.setItem(USER_KEY, 'catlover');
window.location.href = 'index.html';
} else {
err.textContent = 'Incorrect username or password - try the demo login below.';
err.classList.add('show');
}
});
}
// 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.html" 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="settings.html" 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', () => {
localStorage.removeItem(USER_KEY);
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';
}
renderAuth();
// ───── NOTIFICATIONS DROPDOWN ─────
const NOTIF_DISMISSED_KEY = 'campus-notif-dismissed';
function getDismissedNotifs() { try { return JSON.parse(localStorage.getItem(NOTIF_DISMISSED_KEY) || '[]'); } catch { return []; } }
function dismissNotif(id) { const d = getDismissedNotifs(); if (!d.includes(id)) { d.push(id); localStorage.setItem(NOTIF_DISMISSED_KEY, JSON.stringify(d)); } }
function initNotifications() {
const btn = document.getElementById('notifBtn');
if (!btn) return;
const allItems = (window.CAMPUS_DATA && window.CAMPUS_DATA.notifications) || [];
// drop any that were dismissed in a previous session — gone for good
let items = allItems.filter(n => !getDismissedNotifs().includes(n.id));
const badge = document.getElementById('notifBadge');
function syncBadge() {
btn.setAttribute('aria-label', items.length > 0 ? `Notifications, ${items.length} unread` : 'Notifications');
if (!badge) return;
if (items.length > 0) { badge.textContent = items.length; badge.classList.remove('hidden'); }
else { badge.textContent = ''; badge.classList.add('hidden'); }
}
syncBadge();
// 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';
wrap.appendChild(menu);
menu.setAttribute('role', 'menu');
function renderMenu() {
const rows = items.length ? items.map(n =>
`<div class="nm-item" data-id="${n.id}" tabindex="0" role="menuitem">
<img class="nm-av" data-av="${avKey(n.av)}" src="${avatarUrl(n.av)}" alt="">
<div class="nm-body"><span class="nm-text">${n.text}</span><span class="nm-time">${n.time}</span></div>
<button class="nm-dismiss" type="button" aria-label="Dismiss">&times;</button>
</div>`).join('') : '<p class="nm-empty">You\'re all caught up</p>';
menu.innerHTML =
`<div class="nm-head"><span>Notifications</span><button class="nm-clear" type="button">Mark all read</button></div>
<div class="nm-list">${rows}</div>`;
}
renderMenu();
enableMenuArrowKeys(menu, '.nm-item, .nm-clear', btn);
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) menu.querySelector('.nm-item')?.focus();
}
btn.setAttribute('aria-haspopup', 'true');
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', e => { e.stopPropagation(); toggleNotif(); });
btn.addEventListener('keydown', e => {
if (e.key === 'ArrowDown') { e.preventDefault(); toggleNotif(true); }
});
menu.addEventListener('click', e => {
const dismissBtn = e.target.closest('.nm-dismiss');
if (dismissBtn) {
e.stopPropagation();
const id = dismissBtn.closest('.nm-item').dataset.id;
dismissNotif(id);
items = items.filter(n => n.id !== id);
syncBadge(); renderMenu();
return;
}
if (e.target.closest('.nm-clear')) {
e.stopPropagation();
items.forEach(n => dismissNotif(n.id));
items = [];
syncBadge(); renderMenu();
}
});
document.addEventListener('click', e => {
if (!wrap.contains(e.target) && wrap.classList.contains('open')) toggleNotif(false);
});
}
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
function renderPinned() {
const rail = document.querySelector('.feed-layout .rail');
if (!rail) return;
rail.querySelector('.rail-pinned')?.remove();
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="profile.html?u=${encodeURIComponent(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
function renderOnlineFriends() {
const rail = document.querySelector('.feed-layout .rail');
if (!rail) return;
rail.querySelector('.rail-online')?.remove();
// demo "currently online" friends + where they're playing (from data.js)
const demo = ((window.CAMPUS_DATA && window.CAMPUS_DATA.onlineFriends) || []).map(d => ({ ...d }));
// include any accepted friends, marked online
const f = getFriends();
Object.keys(f).filter(h => f[h].status !== 'pending')
.forEach(h => { if (!demo.some(d => d.h === h)) demo.unshift({ h, where: 'Online now' }); });
const items = demo.map(d => {
const nick = (f[d.h] && f[d.h].nickname) || cap(d.h);
return `<a class="rail-item" href="profile.html?u=${encodeURIComponent(d.h)}">
<span class="rail-on-av"><img class="rail-pin-av" data-av="${avKey(d.h)}" src="${avatarUrl(d.h)}" alt=""><i class="online-dot"></i></span>
<div class="rail-meta"><span class="rail-name">${escapeHtml(nick)}</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 · ${demo.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) || {};
function renderRoom() {
const cover = document.getElementById('roomCover');
if (!cover) return;
const id = new URLSearchParams(location.search).get('id');
const r = ROOMS_DATA[id];
if (!r) { document.getElementById('roomPage').innerHTML = '<p class="image-missing">Room not found.</p>'; return; }
document.title = `^${r.name} - Campus`;
cover.style.background = `linear-gradient(135deg, ${r.a}, ${r.b})`;
document.getElementById('roomName').textContent = '^' + r.name;
const by = document.getElementById('roomBy');
by.textContent = 'by @' + r.creator;
by.href = 'profile.html?u=' + encodeURIComponent(r.creator);
document.getElementById('roomDesc').textContent = r.desc;
document.getElementById('roomStats').innerHTML =
`<div class="room-stat"><span class="room-live"><i class="dot"></i>${r.players.toLocaleString()}</span><small>playing now</small></div>
<div class="room-stat"><strong>${r.visits}</strong><small>visits</small></div>`;
}
renderRoom();
// featured-rooms slideshow on the home page
function initFeaturedSlideshow() {
const el = document.getElementById('featuredSlideshow');
if (!el) return;
const ids = ['whisker-heights', 'midnight-mews', 'yarn-quest', 'haunted-cattery', 'deep-dive-reef'];
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 = ROOMS_DATA[ids[i]];
slide.href = 'room.html?id=' + ids[i];
slide.innerHTML = `
<div class="featured-img" style="background:linear-gradient(135deg, ${r.a}, ${r.b})"></div>
<div class="featured-body">
<span class="featured-tag">Featured room</span>
<h2>^${r.name}</h2>
<p class="featured-by">by @${r.creator}</p>
<div class="featured-row">
<span class="featured-live"><i class="dot"></i>${r.players.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); }
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 },
};
function renderProfile() {
const photosEl = document.getElementById('profilePhotos');
if (!photosEl) return;
const params = new URLSearchParams(location.search);
const me = localStorage.getItem(USER_KEY);
const viewed = (params.get('u') || me || 'catlover').toLowerCase();
const isOwn = !!me && me.toLowerCase() === viewed;
const data = PROFILE_DATA[viewed] || { friends: 8, subscribers: 24 };
document.title = `${cap(viewed)} - Campus`;
const picEl = document.getElementById('profilePic');
picEl.src = `https://api.dicebear.com/9.x/thumbs/svg?seed=${viewed}`;
picEl.dataset.av = avKey(viewed);
const unameEl = document.getElementById('profileUsername');
unameEl.textContent = isOwn ? myDisplayName(viewed) : cap(viewed);
if (!isOwn && isFavourite(viewed)) {
unameEl.insertAdjacentHTML('beforeend', ' <span class="fav-star" title="Favourite" aria-label="Favourite">★</span>');
}
document.getElementById('profileHandle').textContent = '@' + (isOwn ? myHandle(viewed) : viewed);
const bioEl = document.getElementById('profileBio');
if (bioEl) {
const myBio = isOwn ? localStorage.getItem('campus-mybio') : null;
bioEl.textContent = myBio || (isOwn ? 'No bio yet — add one in Settings.' : 'this is my bio');
}
// custom profile picture / banner (set from a photo)
try {
const pfp = JSON.parse(localStorage.getItem('campus-pfp-' + viewed) || 'null');
if (pfp) applyCropToImg(picEl, pfp); // uses the photo's zoom so it matches
} catch {}
try {
const bn = JSON.parse(localStorage.getItem('campus-banner-' + viewed) || 'null');
if (bn) {
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 = 'image.html?id=' + encodeURIComponent(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 = localStorage.getItem(subKey) === '1';
const statFriends = document.getElementById('statFriends');
const statSubs = document.getElementById('statSubs');
if (statFriends) statFriends.textContent = fmt(data.friends);
const subCount = () => 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="settings.html" 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;
const pendingN = Object.values(getFriends()).filter(x => x.status === 'pending').length;
if (pendingN) friendRow.insertAdjacentHTML('beforeend', ` <span class="statbox-badge">${pendingN}</span>`);
friendRow.addEventListener('click', openFriendsList);
friendRow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFriendsList(); } });
}
} 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;
}));
actions.appendChild(rbtn);
// 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 setFbtn = () => {
if (isPending(viewed)) { fbtn.className = 'friend-row-btn pending'; fbtn.textContent = 'Pending friend request'; }
else if (isFriend(viewed)) { fbtn.className = 'friend-row-btn is-friend'; fbtn.textContent = 'Friends'; }
else { fbtn.className = 'friend-row-btn'; fbtn.textContent = 'Add friend'; }
};
setFbtn();
function acceptLater() {
setTimeout(() => {
if (isPending(viewed)) {
acceptFriend(viewed);
setFbtn();
renderFriendPanel();
toast(`${cap(viewed)} accepted your friend request`);
}
}, 3000);
}
const doRemove = () => {
if (!rateLimit('friend', 12, 60000)) { toast('You\'re doing that too fast.'); return; }
removeFriend(viewed);
setFbtn();
renderFriendPanel();
};
fbtn.addEventListener('click', () => {
if (!requireLogin('send friend requests')) return;
if (isPending(viewed)) {
openConfirm({
title: 'Cancel friend request?',
message: `Are you sure you want to cancel your friend request to ${cap(viewed)}?`,
confirmText: 'Cancel request',
onConfirm: doRemove,
});
} else if (isFriend(viewed)) {
openConfirm({
title: 'Remove friend?',
message: `Are you sure you want to remove ${cap(viewed)} from your friends?`,
confirmText: 'Remove friend',
onConfirm: doRemove,
});
} else {
if (!rateLimit('friend', 12, 60000)) { toast('You\'re doing that too fast.'); return; }
addFriend(viewed);
setFbtn();
renderFriendPanel();
acceptLater();
}
});
if (isPending(viewed)) acceptLater();
friendRow.innerHTML = '';
friendRow.appendChild(fbtn);
}
}
// friend options (nickname + pin) - others, only when friended
function renderFriendPanel() {
const fp = document.getElementById('friendPanel');
if (!fp) return;
if (isOwn || !isFriend(viewed)) { fp.innerHTML = ''; return; }
const d = getFriends()[viewed];
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', e => {
const fav = toggleFriendFavourite(viewed);
const btn = e.currentTarget;
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));
fp.querySelector('.fo-pin').addEventListener('click', e => {
const pinned = toggleFriendPin(viewed);
e.target.classList.toggle('on', pinned);
e.target.textContent = pinned ? 'Pinned to home' : 'Pin to home';
});
}
renderFriendPanel();
// third stat row: "Subscriptions" (own) or a Subscribe button (others)
const subWrap = document.getElementById('profileSubscribe');
if (subWrap) {
if (isOwn) {
subWrap.innerHTML = '<span class="sub-text">Subscriptions</span>';
} else {
const btn = document.createElement('button');
btn.className = 'subscribe-btn' + (subscribed ? ' subscribed' : '');
btn.textContent = subscribed ? 'Subscribed' : 'Subscribe';
btn.addEventListener('click', () => {
if (!requireLogin('subscribe')) return;
if (!rateLimit('subscribe', 12, 60000)) { toast('You\'re doing that too fast.'); return; }
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 (respecting deletions for own profile)
const delKey = 'campus-deleted-' + viewed;
const deleted = JSON.parse(localStorage.getItem(delKey) || '[]');
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="image.html?id=${encodeURIComponent(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) {
const PERSON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="3.4" r="1.7"/><path d="M8 5.4v3.4M8 8.8 5.6 13M8 8.8 10.4 13M5.2 6.6 8 7.2l2.8-.6"/></svg>';
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="room.html?id=${rid}">
<div class="room-card-head">
<img class="room-card-av" src="${avatarUrl(viewed)}" 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"><img src="assets/cheericon.png" alt="">${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 ─────
function renderImage() {
const photoEl = document.getElementById('imagePhoto');
if (!photoEl) return;
const id = new URLSearchParams(location.search).get('id');
const 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 = 'profile.html?u=' + encodeURIComponent(ph.handle);
const imgAvEl = document.getElementById('imageAuthorAv');
imgAvEl.src = 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';
const imgReport = document.getElementById('imgReport');
if (imgReport) imgReport.addEventListener('click', () => openReport('Report photo', () => {
imgReport.textContent = 'Reported';
imgReport.disabled = true;
}));
// title (with a custom override) + edit pencil for the owner
const titleEl = document.getElementById('imageTitle');
const savedTitle = localStorage.getItem('campus-title-' + id);
if (savedTitle) 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', () => {
const v = input.value.trim() || 'Untitled photo';
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(); });
});
}
document.getElementById('imageMeta').textContent = ph.takenAt ? timeAgo(ph.takenAt) + ' ago' : '';
// 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') {
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: () => {
if (id && 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 = 'profile.html?u=' + encodeURIComponent(ph.handle); }, 650);
}
});
}
});
}
}
const host = document.getElementById('imageCommentsHost');
const block = commentsBlock({ thread: [] });
host.innerHTML = block.replace(' hidden', ''); // always visible here
// cheer button just above the comment form / Post button
const cheerHTML = `<div class="image-actions"><button class="pstat cheer" data-n="${ph.cheers || 0}"><img class="cheer-icon" src="assets/cheericon.png" alt="cheer"><span>${fmt(ph.cheers || 0)}</span></button></div>`;
const form = host.querySelector('.comment-form');
if (form) form.insertAdjacentHTML('beforebegin', cheerHTML);
else host.querySelector('.post-comments')?.insertAdjacentHTML('beforeend', cheerHTML);
}
// ───── 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.html" 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);
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') || '';
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));
});
});
// save buttons
document.querySelectorAll('.ssave').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.save === 'Profile') {
if (username && username.value.trim()) {
const u = username.value.trim().replace(/[^a-zA-Z0-9_.]/g, '');
if (u) { localStorage.setItem('campus-username', u); username.value = ''; username.placeholder = myHandle(me); }
}
if (nick && nick.value.trim()) {
localStorage.setItem('campus-mynick', nick.value.trim());
nick.value = ''; nick.placeholder = myDisplayName(me);
}
if (bio) localStorage.setItem('campus-mybio', bio.value.trim());
// reflect immediately in the header
if (suName) suName.textContent = myDisplayName(me);
if (suHandle) suHandle.textContent = '@' + myHandle(me);
}
toast(`${btn.dataset.save} settings saved`);
});
});
// logout actions
const logout = document.getElementById('setLogout');
if (logout) logout.addEventListener('click', () => { localStorage.removeItem(USER_KEY); window.location.href = 'index.html'; });
const logoutAll = document.getElementById('logoutAll');
if (logoutAll) logoutAll.addEventListener('click', () => { localStorage.removeItem(USER_KEY); window.location.href = 'index.html'; });
}
// ───── 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})`; }
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t === 'dark' ? 'dark' : 'light');
document.querySelectorAll('.cd-mode').forEach(b => b.classList.toggle('active', b.dataset.theme === t));
}
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>';
function syncThemeToggles() {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
document.querySelectorAll('.cd-theme-toggle').forEach(btn => {
btn.innerHTML = dark ? `${SUN_SVG}<span>Light mode</span>` : `${MOON_SVG}<span>Dark mode</span>`;
});
}
document.querySelectorAll('.cd-theme-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
const next = dark ? 'light' : 'dark';
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 backend (server.py holds the token/key
// and does the signed upload). Optimistic UI already updated; this just syncs.
async function syncProfileImageToApi(imageUrl) {
const base = window.CAMPUS_DATA && window.CAMPUS_DATA.apiBase;
if (!base) return; // API disabled — local-only demo
try {
// try to send the actual image bytes; fall back to letting the server fetch the URL
let resp;
try {
const blob = await (await fetch(imageUrl)).blob();
const form = new FormData();
form.append('image', blob, 'profile.jpg');
resp = await fetch(base + '/api/profile-image', { method: 'POST', body: form });
} catch {
resp = await fetch(base + '/api/profile-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }),
});
}
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 — server 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);
});
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 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = 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>`;
return `<div class="comment">
<img class="comment-av" data-av="${avKey(c.handle || c.user)}" src="${avatarUrl(c.handle || c.user)}" alt="">
<div class="comment-body"><span class="comment-user">${escapeHtml(c.user)}</span> ${escapeHtml(c.text)}</div>
${action}
</div>`;
}
function commentsBlock(p) {
const me = localStorage.getItem(USER_KEY);
if (!me) {
return `<div class="post-comments" hidden><a class="comment-login" href="login.html">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');
return `<article class="post">
<div class="post-head">
<a class="post-av" href="profile.html?u=${encodeURIComponent(ph)}" style="--a:${a};--b:${b}"><img data-av="${avKey(ph)}" src="${avatarUrl(ph)}" alt="" onerror="this.remove()">${(p.user || '?')[0].toUpperCase()}</a>
<div class="post-meta">
<a class="post-user" href="profile.html?u=${encodeURIComponent(ph)}">${p.user || 'Anonymous'}</a>
<span class="post-handle">@${p.handle || 'player'}${p.room ? ` in <a href="#">^${p.room}</a>` : ''}</span>
</div>
<span class="post-time">${p.takenAt ? timeAgo(p.takenAt) : (p.time || '')}</span>
</div>
<a class="post-img" href="image.html?id=${encodeURIComponent(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" data-n="${p.cheers || 0}"><img class="cheer-icon" src="assets/cheericon.png" alt="cheer"><span>${fmt(p.cheers || 0)}</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('');
}
// 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="profile.html?u=${encodeURIComponent(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="profile.html?u=${encodeURIComponent(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="image.html?id=${encodeURIComponent(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}"><img class="cheer-icon" src="assets/cheericon.png" alt="cheer"><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;
if (!PHOTOS_API) { renderFallback(FALLBACK_POSTS); return; }
try {
const res = await fetch(PHOTOS_API);
if (!res.ok) throw new Error(res.status);
const data = await res.json();
renderPosts(Array.isArray(data) ? data : (data.photos || []));
} catch (err) {
console.warn('photos API failed, using fallback:', err);
renderFallback(FALLBACK_POSTS);
}
}
loadFeed();
renderImage(); // image.html - runs here so FALLBACK_POSTS/getPhoto are ready
renderOnlineFriends(); // home - online friends widget
renderPinned(); // home - pinned friends widget
initFeaturedSlideshow(); // home - featured rooms slideshow
// ───── 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="profile.html?u=${encodeURIComponent(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>`;
}
const href = m.roomId ? `room.html?id=${encodeURIComponent(m.roomId)}` : '#';
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>`;
}
function renderResults() {
if (!searchResults) return;
const q = searchInput.value.trim().toLowerCase();
const data = activeTab === 'people' ? PEOPLE : ROOMS;
if (searchClear) searchClear.style.display = q ? 'flex' : 'none';
if (!q) {
const sug = data.slice(0, 5).map(searchRow).join('');
searchResults.innerHTML = `<div class="search-label">Suggested ${activeTab}</div>${sug}`;
return;
}
const matches = data.filter(d => d.name.toLowerCase().includes(q));
if (matches.length === 0) {
searchResults.innerHTML = '<p class="search-empty">No results found</p>';
return;
}
searchResults.innerHTML = matches.map(searchRow).join('');
}
// normalise a raw API result object into the shape searchRow() expects
function normalizeResult(r) {
const imgBase = (window.CAMPUS_DATA && window.CAMPUS_DATA.imgBase) || '';
if (activeTab === 'people') {
const handle = r.UserName || '';
const name = r.DisplayName || handle || 'Unknown';
const avatar = (imgBase && r.ProfileImage) ? imgBase + r.ProfileImage : '';
return { name, sub: handle ? '@' + handle : '', handle, accountId: r.AccountId, avatar, a: '#ff5a3c', b: '#ffb142' };
}
const name = r.Name || 'Untitled room';
const st = r.Stats || {};
const img = (imgBase && r.ImageName) ? imgBase + r.ImageName : '';
const sub = st.VisitCount != null ? fmt(st.VisitCount) + ' visits' : '';
return { name, sub, roomId: r.RoomId, img, a: '#ff5a3c', b: '#7b2ff7' };
}
// live search against the backend (server.py proxies to the real API)
let searchTimer = null;
async function liveSearch(q) {
const base = window.CAMPUS_DATA && window.CAMPUS_DATA.apiBase;
const path = activeTab === 'people'
? `/api/search/players?name=${encodeURIComponent(q)}`
: `/api/search/rooms?query=${encodeURIComponent(q)}`;
try {
const resp = await fetch(base + path);
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, using local data:', e);
renderResults(); // fall back to the built-in demo data
}
}
// decide between live API search and the local demo data
function handleSearchInput() {
const q = searchInput.value.trim();
if (searchClear) searchClear.style.display = q ? 'flex' : 'none';
clearTimeout(searchTimer);
const base = window.CAMPUS_DATA && window.CAMPUS_DATA.apiBase;
if (!q || !base) { renderResults(); 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 = '';
}
function openReport(title, onSubmit) {
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', () => {
if (!rateLimit('report', 5, 10 * 60 * 1000)) {
toast('You\'ve sent too many reports. Please try again later.');
closeReport();
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) {
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 = (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);
card.querySelector('.nick-set').addEventListener('click', () => {
setFriendNick(handle, input.value.trim());
closeNick();
if (onChange) onChange();
});
card.querySelector('.nick-remove').addEventListener('click', () => {
setFriendNick(handle, '');
closeNick();
if (onChange) onChange();
});
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 = '';
}
function openFriendsList() {
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(); });
}
let filter = 'all';
function render() {
const f = getFriends();
const handles = Object.keys(f);
const accepted = handles.filter(h => f[h].status !== 'pending');
const pending = handles.filter(h => f[h].status === 'pending');
const favs = accepted.filter(h => f[h].favourite);
const onlineCount = accepted.filter(h => friendPresence(h).online).length;
const show = filter === 'favourites' ? favs : filter === 'pending' ? pending : accepted;
const rows = show.length ? show.map(h => {
const fr = f[h];
const name = fr.nickname || cap(h);
const pres = friendPresence(h);
const star = fr.favourite ? ' <span class="fav-star">★</span>' : '';
if (filter === 'pending') {
return `<div class="fl-item">
<img class="fl-av" data-av="${avKey(h)}" src="${avatarUrl(h)}" alt="">
<div class="fl-meta"><span class="fl-name">${escapeHtml(name)}</span><span class="fl-sub">Wants to be your friend</span></div>
<div class="fl-actions"><button class="fl-accept" data-h="${escapeHtml(h)}">Accept</button><button class="fl-decline" data-h="${escapeHtml(h)}">Decline</button></div>
</div>`;
}
const status = pres.online
? '<span class="fl-online"><i></i>Online now</span>'
: `Last on ${pres.last} ago`;
return `<a class="fl-item" href="profile.html?u=${encodeURIComponent(h)}">
<img class="fl-av" data-av="${avKey(h)}" src="${avatarUrl(h)}" alt="">
<div class="fl-meta"><span class="fl-name">${escapeHtml(name)}${star}</span><span class="fl-sub">${status}</span></div>
</a>`;
}).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">Friends</h2>
<p class="report-sub">${accepted.length} friend${accepted.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</button>
<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', () => { acceptFriend(b.dataset.h); toast(`You and ${cap(b.dataset.h)} are now friends`); render(); }));
modal.querySelectorAll('.fl-decline').forEach(b => b.addEventListener('click', () => { removeFriend(b.dataset.h); render(); }));
}
render();
modal.classList.add('open');
scrim.classList.add('open');
document.body.style.overflow = 'hidden';
}
// ───── 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));
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;
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');
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>';
});
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();
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);
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Cache-bust helper for Meow Net.
Stamps every local asset reference (style.css, data.js, app.js) in all .html
files with a single fresh version string derived from the current timestamp.
Run this after editing CSS/JS so the browser always loads the latest:
python build.py
This replaces the old error-prone "bump ?v=N by hand in every file" workflow —
one command updates all files at once, so a file can never be left stale.
"""
import re
import time
import glob
import os
VERSION = str(int(time.time()))
ASSETS = ('style.css', 'data.js', 'app.js')
here = os.path.dirname(os.path.abspath(__file__))
changed = 0
for path in glob.glob(os.path.join(here, '*.html')):
with open(path, encoding='utf-8') as f:
html = f.read()
new = html
for asset in ASSETS:
# only restamp quoted asset references (src="app.js" / href="style.css"),
# so we never touch the asset name if it appears in a comment or text
new = re.sub(r'"' + re.escape(asset) + r'(?:\?v=[0-9]+)?"', f'"{asset}?v={VERSION}"', new)
if new != html:
with open(path, 'w', encoding='utf-8') as f:
f.write(new)
changed += 1
print('stamped', os.path.basename(path))
print(f'\nDone — version {VERSION} across {changed} file(s).')
+67
View File
@@ -0,0 +1,67 @@
// ───── 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 = {
// Base URL of the Campus backend (server.py). Leave empty to disable the
// live API call and run purely on the local demo. For local dev this is the
// Flask server; in production point it at your deployed backend.
// Same-origin: the site is served BY the Flask backend, so relative paths
// ('' + '/api/...') work for every visitor regardless of how they connect
// (localhost, LAN IP, or a tunnel URL). Leave empty for same-origin.
apiBase: '',
// Image filenames (ProfileImage / ImageName) are served through the backend's
// /api/img/ proxy, so the browser never has to reach the Cloudflare-protected
// image host directly. Leave empty to fall back to generated avatars.
imgBase: '/api/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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<!-- CAT DRAWER -->
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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/campusrec" 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/en-gb/experiences/meownet/1196298696890609/?srsltid=AfmBOopLywN0T9eVnNkaTUPvVRo2qbqNx0CWSgPB8l7Bh7E8hnRKPMTB" 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="index.html" 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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
<!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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">Accessibility</a>
</nav>
<button class="cd-theme-toggle"></button>
</aside>
<div class="side-scrim" id="scrim"></div>
<main class="image-page" id="imagePage">
<article class="image-card">
<div class="image-header">
<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>
<button class="img-report" id="imgReport">Report</button>
</div>
<div class="image-photo" id="imagePhoto"></div>
<div class="image-info">
<div class="image-info-row">
<div class="image-info-text">
<h1 class="image-title" id="imageTitle">Title</h1>
<p class="image-meta" id="imageMeta"></p>
</div>
<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-comments" id="imageComments">
<h2>Comments</h2>
<div id="imageCommentsHost"></div>
</div>
</article>
</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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
<!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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</head>
<body>
<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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<!-- CAT DRAWER -->
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="#rooms">Featured</a>
<a href="#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">Accessibility</a>
</nav>
<button class="cd-theme-toggle"></button>
</aside>
<div class="side-scrim" id="scrim"></div>
<!-- ───── FEED LAYOUT ───── -->
<main id="main" class="feed-layout">
<!-- LEFT RAIL: ROOMS -->
<aside class="rail" id="rooms">
<h3 class="rail-title">Popular Rooms</h3>
<a href="room.html?id=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.html?id=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.html?id=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.html?id=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.html?id=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.html?id=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>
<!-- CENTER FEED -->
<section class="feed" id="feed">
<!-- featured rooms slideshow -->
<div class="featured-slideshow" id="featuredSlideshow"></div>
<!-- quick links: patch notes / weekly / news -->
<div class="home-links">
<a href="patch-notes.html" 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="weekly.html" class="home-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 10h18M8 2v4M16 2v4"/></svg>
<span>Weekly</span>
</a>
<a href="news.html" 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>
<h2 class="feed-title">Recent Photos <small>· live from the community</small></h2>
<!-- 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>
</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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+58
View File
@@ -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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</head>
<body class="login-page">
<a class="skip-link" href="#main">Skip to content</a>
<main id="main" class="login-wrap">
<a href="index.html" 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="download.html">Download</a></p>
</div>
<a href="index.html" class="dl-back">← Back to home</a>
</main>
<script src="data.js?v=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+79
View File
@@ -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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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=1781644344"></script>
<script src="app.js?v=1781644344"></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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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"><span class="status-dot"></span>Offline · 2h</div>
<p class="profile-bio" id="profileBio">this is my bio</p>
<div class="profile-joined">
<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>
Joined June 2025
</div>
<div class="profile-statbox">
<div class="statbox-row" id="statFriendsRow"><strong id="statFriends">16</strong> Friends</div>
<div class="statbox-row"><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" aria-label="Photos"><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="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="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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
@@ -0,0 +1,4 @@
flask
flask-cors
requests
pillow
+87
View File
@@ -0,0 +1,87 @@
<!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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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-cover" id="roomCover">
<div class="room-cover-body">
<h1 class="room-name" id="roomName">^Room</h1>
<a class="room-by" id="roomBy" href="#">by @creator</a>
</div>
</section>
<div class="room-detail">
<div class="room-stats" id="roomStats"></div>
<p class="room-desc" id="roomDesc"></p>
<a href="download.html" class="btn-dl room-play">Play now</a>
</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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+273
View File
@@ -0,0 +1,273 @@
"""
Campus — profile/room image backend.
The browser uploads the chosen image to THIS server; this server holds the
token + signing key (currently from token.json, later from real web auth) and
does the signed upload to the image API. The secret key never reaches the
browser.
Run:
pip install -r requirements.txt
python server.py
Then set CAMPUS_API in the website to http://localhost:5000
NOTE on Cloudflare: this uses the normal `requests` client. If your own API
sits behind Cloudflare bot protection, allowlist this server's IP / add a
service token on your infra side rather than trying to bypass the challenge.
"""
import json
import hmac
import hashlib
import base64
import struct
import io
import os
import requests
from PIL import Image
from flask import Flask, request, jsonify
from flask_cors import CORS
UPLOAD_URL = "https://2021rec.cookedasset.com/api/images/v4/uploadsaved"
UPLOAD_URI = "/api/images/v4/uploadsaved"
ROOM_UPDATE_URL = "https://2021rec.cookedasset.com/roomserver/rooms/{roomId}/image"
PROFILE_UPDATE_URL = "https://2021rec.cookedasset.com/account/me/profileimage"
TOKEN_FILE = "token.json"
SITE_DIR = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, static_folder=SITE_DIR, static_url_path="")
CORS(app) # allow the static site (file:// or localhost) to call this server
@app.get("/")
def index():
return app.send_static_file("index.html")
def load_auth():
"""Load the temporary token.json. Swap this out for real web-auth later."""
if not os.path.exists(TOKEN_FILE):
raise RuntimeError("token.json not found next to server.py")
with open(TOKEN_FILE) as f:
auth = json.load(f)
if "access_token" not in auth or "key" not in auth:
raise RuntimeError("token.json missing 'access_token' or 'key'")
return auth
def get_sig(key_b64, uri, body):
key = base64.b64decode(key_b64)
h = hmac.new(key, digestmod=hashlib.sha256)
h.update(uri.encode("ascii"))
if body:
h.update(struct.pack("<I", len(body)))
if len(body) > 2048:
step = len(body) // 16
for i in range(16):
h.update(body[i * step: i * step + 128])
else:
h.update(body)
return base64.b64encode(h.digest()).decode()
def upload_image(auth, img_bytes, room_id=None):
"""Re-encode the image and upload it, returning the server's ImageName."""
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=95)
img_data = buf.getvalue()
boundary = "BestHTTP_HTTPMultiPartForm_8249FBB0"
meta = json.dumps({
"playerIds": None,
"savedImageType": 1,
"roomId": room_id if room_id else 1,
"playerEventId": 0,
"accessibility": 0,
"description": None,
}, separators=(",", ":"))
body = (
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"imgMeta\"\r\n"
f"Content-Type: text/plain; charset=utf-8\r\n"
f"Content-Length: {len(meta)}\r\n\r\n"
f"{meta}\r\n"
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"image\"; filename=\"file.jpeg\"\r\n"
f"Content-Type: image/jpeg\r\n"
f"Content-Length: {len(img_data)}\r\n\r\n"
).encode() + img_data + f"\r\n--{boundary}--\r\n".encode()
headers = {
"Authorization": f"Bearer {auth['access_token']}",
"X-RNSIG": get_sig(auth["key"], UPLOAD_URI, body),
"Content-Type": f"multipart/form-data; boundary={boundary}",
"User-Agent": "BestHTTP",
}
r = requests.post(UPLOAD_URL, data=body, headers=headers, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"Upload failed ({r.status_code}): {r.text[:300]}")
res = r.json()
if "ImageName" not in res:
raise RuntimeError("Upload succeeded but ImageName missing")
return res["ImageName"]
def update_profile_image(auth, image_name):
headers = {
"Authorization": f"Bearer {auth['access_token']}",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "BestHTTP",
"X-Game-Version": "20210129",
}
r = requests.put(PROFILE_UPDATE_URL, data=f"imageName={image_name}", headers=headers, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"Profile update failed ({r.status_code}): {r.text[:300]}")
def update_room_image(auth, room_id, image_name):
headers = {
"Authorization": f"Bearer {auth['access_token']}",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "BestHTTP",
"X-Game-Version": "20210129",
}
url = ROOM_UPDATE_URL.format(roomId=room_id)
r = requests.put(url, data=f"imageName={image_name}", headers=headers, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"Room update failed ({r.status_code}): {r.text[:300]}")
def _image_bytes_from_request():
"""Accept either an uploaded file ('image') or an 'imageUrl' to fetch."""
if "image" in request.files:
return request.files["image"].read()
data = request.get_json(silent=True) or {}
url = data.get("imageUrl") or request.form.get("imageUrl")
if url:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
return resp.content
raise RuntimeError("No image provided (send a file field 'image' or 'imageUrl')")
@app.post("/api/profile-image")
def api_profile_image():
try:
auth = load_auth()
img_bytes = _image_bytes_from_request()
image_name = upload_image(auth, img_bytes)
update_profile_image(auth, image_name)
return jsonify(ok=True, imageName=image_name)
except Exception as e:
return jsonify(ok=False, error=str(e)), 400
@app.post("/api/room-image")
def api_room_image():
try:
auth = load_auth()
room_id = int((request.get_json(silent=True) or request.form).get("roomId"))
img_bytes = _image_bytes_from_request()
image_name = upload_image(auth, img_bytes, room_id)
update_room_image(auth, room_id, image_name)
return jsonify(ok=True, imageName=image_name)
except Exception as e:
return jsonify(ok=False, error=str(e)), 400
ROOM_SEARCH_URL = "https://gameauth.ddns.net/rooms/search?query={q}"
ACCOUNT_SEARCH_URL = "https://gameauth.ddns.net/account/search?name={q}"
# The origin's Cloudflare rule allowlists the game client's User-Agent, so the
# backend must send the same UA to get through. Override with CAMPUS_UA if needed.
GAME_UA = os.environ.get("CAMPUS_UA", "BestHTTP")
def _auth_headers(auth):
return {
"Authorization": f"Bearer {auth['access_token']}",
"User-Agent": GAME_UA,
"X-Game-Version": "20210129",
}
@app.get("/api/search/rooms")
def api_search_rooms():
try:
auth = load_auth()
q = requests.utils.quote(request.args.get("query", "").strip())
r = requests.get(ROOM_SEARCH_URL.format(q=q), headers=_auth_headers(auth), timeout=30)
r.raise_for_status()
return jsonify(ok=True, results=r.json())
except Exception as e:
return jsonify(ok=False, error=str(e)), 400
@app.get("/api/search/players")
def api_search_players():
try:
auth = load_auth()
q = requests.utils.quote(request.args.get("name", "").strip())
r = requests.get(ACCOUNT_SEARCH_URL.format(q=q), headers=_auth_headers(auth), timeout=30)
r.raise_for_status()
return jsonify(ok=True, results=r.json())
except Exception as e:
return jsonify(ok=False, error=str(e)), 400
IMG_ORIGIN = "https://2021rec.cookedasset.com/api/images/v4/"
@app.get("/api/img/<path:name>")
def api_img(name):
"""Proxy an image filename from the origin so the browser never has to talk
to the (Cloudflare-protected) image host directly. Streams the bytes through
with a long cache header."""
try:
auth = load_auth()
r = requests.get(IMG_ORIGIN + name, headers=_auth_headers(auth), timeout=30)
r.raise_for_status()
from flask import Response
resp = Response(r.content, content_type=r.headers.get("Content-Type", "image/png"))
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
except Exception as e:
return jsonify(ok=False, error=str(e)), 404
@app.get("/api/health")
def health():
return jsonify(ok=True)
if __name__ == "__main__":
import socket
PORT = int(os.environ.get("CAMPUS_PORT", "5000"))
# find this machine's LAN IP so others on the same network can connect
lan_ip = "127.0.0.1"
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
lan_ip = s.getsockname()[0]
s.close()
except Exception:
pass
print("\n" + "=" * 52)
print(" CAMPUS is LIVE")
print("=" * 52)
print(f" On this PC: http://localhost:{PORT}")
print(f" Same Wi-Fi/LAN: http://{lan_ip}:{PORT}")
print("=" * 52)
print(" Share the LAN link with people on your network.")
print(" For people OUTSIDE your network, use a tunnel")
print(" (e.g. 'cloudflared tunnel' or 'ngrok http %d')." % PORT)
print("=" * 52 + "\n")
# host=0.0.0.0 lets other devices reach it; debug off so it's network-safe
app.run(host="0.0.0.0", port=PORT, debug=False)
@@ -0,0 +1,138 @@
<!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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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"><span class="su-dot"></span>Offline · 2h</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 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="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>
</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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>
+2052
View File
@@ -0,0 +1,2052 @@
*, *::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: 'Inter', -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: 'Inter', 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; }
.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: 'Inter', 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: 18px; height: 18px; object-fit: contain; flex-shrink: 0;
filter: grayscale(100%) brightness(1.5);
opacity: .55;
transition: filter .15s, opacity .15s;
}
.cheer span { min-width: 1.6ch; text-align: left; }
.cheer.liked { color: var(--primary); }
.cheer.liked .cheer-icon { filter: none; opacity: 1; }
.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;
}
.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; }
/* 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: 22px; height: 22px; }
.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: 'Inter', 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; }
@@ -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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=1781644344" />
</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.html" class="btn-login">Log in</a>
<a href="download.html" class="btn-dl">DOWNLOAD</a>
</div>
</header>
<aside class="cat-drawer" id="catDrawer">
<nav class="cd-nav">
<a href="index.html">Home</a>
<a href="index.html#rooms">Featured</a>
<a href="index.html#feed">Creative Program</a>
<a href="patch-notes.html">Patch Notes</a>
<a href="weekly.html">Weekly</a>
<a href="news.html">News</a>
<a href="download.html">Download</a>
<a href="https://discord.gg/campusrec" target="_blank" rel="noopener">Discord</a>
<a href="accessibility.html">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=1781644344"></script>
<script src="app.js?v=1781644344"></script>
</body>
</html>