the files
@@ -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>
|
||||
@@ -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">×</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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 284 KiB |
@@ -0,0 +1,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).')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||