Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c90225cb | ||
|
|
c16659c2ad | ||
|
|
a67bedc6f2 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.7');
|
||||
define('APP_VERSION', '0.9.10');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
@@ -743,6 +743,51 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
|
||||
arrive while the user is viewing a different bucket. Cleared the
|
||||
moment the user navigates back to all (renderBoard drains the
|
||||
unseen set + pulses the cards). The dot sits *inside* the tab's
|
||||
top-right corner because the parent #main-header has overflow:
|
||||
hidden — anything positioned outside the tab gets clipped twice
|
||||
(overflow-x-auto on bucket-bar + overflow:hidden on header). */
|
||||
.bucket-tab.has-unread { position: relative; }
|
||||
.bar-unread-dot {
|
||||
position: absolute; top: 3px; right: 3px;
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--t-danger);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 75%, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* "Look at me" pulse on cards that just arrived. Triggered by
|
||||
board.js when the all view is shown with unseen cards in the set —
|
||||
either because new cards arrived while the user was already on
|
||||
all, or because the user just navigated to all and the unseen ids
|
||||
accumulated since the last visit. 3 short accent flashes, then
|
||||
the class is stripped automatically by setTimeout. */
|
||||
@keyframes card-pulse-new {
|
||||
0%, 100% {
|
||||
box-shadow: var(--ui-shadow-card, none),
|
||||
0 0 0 0 color-mix(in srgb, var(--t-accent) 80%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: var(--ui-shadow-card, none),
|
||||
0 0 18px 4px color-mix(in srgb, var(--t-accent) 75%, transparent),
|
||||
inset 0 0 0 2px color-mix(in srgb, var(--t-accent) 70%, transparent);
|
||||
}
|
||||
}
|
||||
.board-card.card-pulse-new {
|
||||
animation: card-pulse-new 0.8s ease-in-out 3;
|
||||
z-index: 5;
|
||||
}
|
||||
/* Pin the cadence so anim-fast/anim-slow can't strobe it */
|
||||
body.anim-fast .board-card.card-pulse-new,
|
||||
body.anim-slow .board-card.card-pulse-new {
|
||||
animation: card-pulse-new 0.8s ease-in-out 3 !important;
|
||||
animation-duration: 0.8s !important;
|
||||
}
|
||||
|
||||
/* Super-bucket dropdown popover. Positioned via inline styles by
|
||||
openSuperDropdown() in buckets.js. Lives on document.body (not
|
||||
inside #bucket-bar) so it can escape the bar's overflow:auto. */
|
||||
|
||||
@@ -30,6 +30,48 @@ let dragGhost = null;
|
||||
// time a background poll completed mid-drag.
|
||||
const draggingCardIds = new Set();
|
||||
|
||||
// Tracks every card id we've ever rendered. On the first render we just
|
||||
// snapshot whatever's currently in state.cards (everything counts as
|
||||
// "already known"); after that, the diff against this set tells us
|
||||
// which cards just arrived from a poll / sync. New cards get pushed into
|
||||
// state.unseenCardIds and either pulse immediately (if we're already on
|
||||
// the all view) or wait for the next visit to the all tab to pulse.
|
||||
let knownCardIds = null;
|
||||
|
||||
/** Add an accent pulse class to the matching board cards and strip it
|
||||
* again after the animation finishes. The animation itself is in
|
||||
* public/css/style.css `@keyframes card-pulse-new`. */
|
||||
function pulseCardsNew(ids) {
|
||||
if (!ids || !ids.size) return;
|
||||
requestAnimationFrame(() => {
|
||||
for (const id of ids) {
|
||||
const el = board.querySelector(`[data-id="${id}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add('card-pulse-new');
|
||||
setTimeout(() => el.classList.remove('card-pulse-new'), 2400);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Add or remove the unread dot on the fixed `all` bucket-tab. Hidden
|
||||
* while the user is currently looking at the all view. Lives in
|
||||
* board.js (rather than buckets.js) because the trigger is "new card
|
||||
* arrived" — buckets.js doesn't know about that, board.js does. */
|
||||
function decorateAllUnreadDot() {
|
||||
const allTab = document.querySelector('#bucket-bar [data-bucket="all"]');
|
||||
if (!allTab) return;
|
||||
const hasUnread = state.unseenCardIds.size > 0 && state.activeBucket !== 'all';
|
||||
allTab.classList.toggle('has-unread', hasUnread);
|
||||
let dot = allTab.querySelector('.bar-unread-dot');
|
||||
if (hasUnread && !dot) {
|
||||
dot = document.createElement('span');
|
||||
dot.className = 'bar-unread-dot';
|
||||
allTab.appendChild(dot);
|
||||
} else if (!hasUnread && dot) {
|
||||
dot.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-select ──
|
||||
const selectedIds = state.selectedIds;
|
||||
const selectBar = $('select-bar');
|
||||
@@ -450,6 +492,19 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
||||
// ── Render ──
|
||||
|
||||
export function renderBoard() {
|
||||
// Diff state.cards against the last seen ids so we can flag arrivals
|
||||
// as "unseen". The very first renderBoard runs with state.cards = []
|
||||
// (initial empty state, before the API has returned), so we wait
|
||||
// until knownCardIds is non-empty before doing the diff — otherwise
|
||||
// the first real load looks like "every card just arrived" and the
|
||||
// entire board pulses on page open.
|
||||
const currentIds = new Set(state.cards.map(c => String(c.id)));
|
||||
if (knownCardIds && knownCardIds.size > 0) {
|
||||
for (const id of currentIds) {
|
||||
if (!knownCardIds.has(id)) state.unseenCardIds.add(id);
|
||||
}
|
||||
}
|
||||
knownCardIds = currentIds;
|
||||
let cards;
|
||||
if (state.activeGroup) {
|
||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||
@@ -686,6 +741,17 @@ export function renderBoard() {
|
||||
|
||||
// Notification banner
|
||||
renderNotifs();
|
||||
|
||||
// Unread tracking: if we're on the all view, drain the unseen set
|
||||
// by pulsing every matching card on the board (the "look at me"
|
||||
// accent flash). If we're on a different view, the unseen ids stay
|
||||
// accumulated and the all-tab gets a dot until the user opens it.
|
||||
if (state.activeBucket === 'all' && state.unseenCardIds.size) {
|
||||
const toPulse = new Set(state.unseenCardIds);
|
||||
state.unseenCardIds.clear();
|
||||
pulseCardsNew(toPulse);
|
||||
}
|
||||
decorateAllUnreadDot();
|
||||
}
|
||||
|
||||
// ── Notification providers ──
|
||||
|
||||
@@ -264,8 +264,14 @@ function renderBar() {
|
||||
return parent;
|
||||
};
|
||||
|
||||
// Show an unread dot on the fixed `all` tab when state.unseenCardIds
|
||||
// has anything in it AND the user isn't currently looking at the
|
||||
// all view. board.js drives the unseen set; we just read it here so
|
||||
// the dot survives bar re-renders that aren't followed by a board
|
||||
// render (e.g. bucket reorder, plain renderBar() call).
|
||||
const allUnread = state.activeBucket !== 'all' && state.unseenCardIds.size > 0;
|
||||
bar.innerHTML = `
|
||||
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i></button>
|
||||
<button class="${tab(state.activeBucket === 'all')}${allUnread ? ' has-unread' : ''}" data-bucket="all" title="all"><i class="ph ph-squares-four text-sm"></i>${allUnread ? '<span class="bar-unread-dot"></span>' : ''}</button>
|
||||
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current" title="current"><i class="ph ph-lightning text-sm"></i></button>
|
||||
<button class="${tab(state.activeBucket === 'favorites')}" data-bucket="favorites" title="favorites"><i class="ph ph-star text-sm"></i></button>
|
||||
<button class="${tab(state.activeBucket === 'journal')}" data-bucket="journal" title="journal"><i class="ph ph-notebook text-sm"></i></button>
|
||||
|
||||
@@ -354,13 +354,49 @@ export function showContextMenu(x, y, card) {
|
||||
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
|
||||
else if (a === 'delete') {
|
||||
hideContextMenu();
|
||||
const children = getChildren(card.id);
|
||||
const isGroup = children.length > 0;
|
||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
|
||||
if (needConfirm) {
|
||||
if (isGroup) {
|
||||
// Always confirm when deleting a group — list every child
|
||||
// so the user knows exactly what they're losing.
|
||||
const childList = children.map(c => `• ${c.title || '(untitled)'}`).join('\n');
|
||||
const msg = `Delete group "${card.title}" and all ${children.length} card${children.length > 1 ? 's' : ''} inside?\n\n${childList}`;
|
||||
const ok = await showDialog({ title: 'delete_group', message: msg, danger: true, okLabel: `Delete all ${children.length + 1}` });
|
||||
if (!ok) return;
|
||||
} else if (needConfirm) {
|
||||
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
const wasSync = !!card.caldav_uid;
|
||||
pushCardDelete('delete', snapshotCard(card));
|
||||
// Snapshot everything for undo BEFORE deleting
|
||||
const allCards = isGroup ? [card, ...children] : [card];
|
||||
const snapshots = allCards.map(c => snapshotCard(c));
|
||||
const wasSync = allCards.some(c => !!c.caldav_uid);
|
||||
pushUndo(`delete ${isGroup ? 'group' : 'card'} "${card.title}"`, async () => {
|
||||
// Recreate parent first so children can reference it
|
||||
const parent = await api('api/cards', { method: 'POST', body: {
|
||||
title: snapshots[0].title, body: snapshots[0].body,
|
||||
color: snapshots[0].color, bucket_id: snapshots[0].bucket_id,
|
||||
position: snapshots[0].position,
|
||||
}});
|
||||
if (parent?.id && snapshots[0].due_date) {
|
||||
await api(`api/cards/${parent.id}`, { method: 'PATCH', body: { due_date: snapshots[0].due_date } });
|
||||
}
|
||||
// Recreate children pointing at the new parent
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
const s = snapshots[i];
|
||||
const child = await api('api/cards', { method: 'POST', body: {
|
||||
title: s.title, body: s.body, color: s.color,
|
||||
bucket_id: s.bucket_id, parent_id: parent?.id,
|
||||
position: s.position,
|
||||
}});
|
||||
if (child?.id && s.due_date) {
|
||||
await api(`api/cards/${child.id}`, { method: 'PATCH', body: { due_date: s.due_date } });
|
||||
}
|
||||
}
|
||||
});
|
||||
// Delete children first, then parent (avoids orphan flicker)
|
||||
for (const c of children) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||
await api(`api/cards/${card.id}`, { method: 'DELETE' });
|
||||
await window._reloadAll();
|
||||
if (wasSync) window._triggerSync?.();
|
||||
|
||||
@@ -32,6 +32,11 @@ export const state = {
|
||||
// pick the title strategy: false → first selected wins, true →
|
||||
// longest title wins (since "first" isn't meaningful for bulk).
|
||||
selectionWasBulk: false,
|
||||
// Card ids that have arrived since the user last entered the "all"
|
||||
// view. Drives the unread dot on the all bucket-tab and the brief
|
||||
// accent pulse on the cards themselves once the user looks at them.
|
||||
// Updated by board.js renderBoard() — see the diff there.
|
||||
unseenCardIds: new Set(),
|
||||
|
||||
// Detail / editor
|
||||
editingId: null,
|
||||
|
||||
@@ -269,3 +269,125 @@ describe('Card: Grouping', () => {
|
||||
expect(parentEl).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card: Group Delete', () => {
|
||||
afterEach(() => closeAll());
|
||||
|
||||
it('deleting a group removes parent AND all children', async () => {
|
||||
// Disable confirm-delete so the non-group path doesn't block,
|
||||
// but the group delete ALWAYS confirms regardless of this setting.
|
||||
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
||||
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
||||
|
||||
const parent = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_grpdel_parent__', body: '', color: '#38bdf8' }),
|
||||
}).then(r => r.json());
|
||||
const child1 = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_grpdel_child1__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
||||
}).then(r => r.json());
|
||||
const child2 = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_grpdel_child2__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
// The group parent should be on the board
|
||||
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
||||
if (!parentEl) {
|
||||
// Restore and skip if parent not visible (e.g. filter state)
|
||||
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||
return;
|
||||
}
|
||||
|
||||
// Right-click → delete. The group delete dialog always shows
|
||||
// regardless of the confirm-delete setting. We need to auto-
|
||||
// confirm it. The showDialog function exposes the dialog element
|
||||
// as #dialog; the OK button is .dialog-ok.
|
||||
rightClick(parentEl);
|
||||
await sleep(150);
|
||||
click($$('#ctx-items [data-action="delete"]')[0]);
|
||||
await sleep(200);
|
||||
|
||||
// The confirmation dialog should be showing the child list
|
||||
const dialog = document.getElementById('dialog');
|
||||
if (dialog?.classList.contains('open')) {
|
||||
const msg = dialog.querySelector('#dialog-msg');
|
||||
// Dialog should mention both children
|
||||
expect(msg.textContent).toContain('__test_grpdel_child1__');
|
||||
expect(msg.textContent).toContain('__test_grpdel_child2__');
|
||||
// Confirm the delete
|
||||
const okBtn = dialog.querySelector('.dialog-ok');
|
||||
if (okBtn) click(okBtn);
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// All three cards should be gone
|
||||
const remaining = await fetch('api/cards').then(r => r.json());
|
||||
expect(remaining.some(c => c.id === parent.id)).toBe(false);
|
||||
expect(remaining.some(c => c.id === child1.id)).toBe(false);
|
||||
expect(remaining.some(c => c.id === child2.id)).toBe(false);
|
||||
|
||||
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||
});
|
||||
|
||||
it('Ctrl+Z after group delete restores parent + children', async () => {
|
||||
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
||||
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
||||
|
||||
const parent = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_grpundo_parent__', body: 'parent body', color: '#fb7185' }),
|
||||
}).then(r => r.json());
|
||||
const child = await fetch('api/cards', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||
body: JSON.stringify({ title: '__test_grpundo_child__', body: 'child body', color: '#38bdf8', parent_id: parent.id }),
|
||||
}).then(r => r.json());
|
||||
await window._reloadAll?.();
|
||||
await sleep(200);
|
||||
|
||||
const parentEl = document.querySelector(`[data-id="${parent.id}"]`);
|
||||
if (!parentEl) {
|
||||
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the group
|
||||
rightClick(parentEl);
|
||||
await sleep(150);
|
||||
click($$('#ctx-items [data-action="delete"]')[0]);
|
||||
await sleep(200);
|
||||
const dialog = document.getElementById('dialog');
|
||||
if (dialog?.classList.contains('open')) {
|
||||
const okBtn = dialog.querySelector('.dialog-ok');
|
||||
if (okBtn) click(okBtn);
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Confirm both are gone
|
||||
let cards = await fetch('api/cards').then(r => r.json());
|
||||
expect(cards.some(c => c.id === parent.id)).toBe(false);
|
||||
expect(cards.some(c => c.id === child.id)).toBe(false);
|
||||
|
||||
// Undo
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
||||
await sleep(600);
|
||||
|
||||
// Parent + child should be back (new ids, find by title)
|
||||
cards = await fetch('api/cards').then(r => r.json());
|
||||
const restoredParent = cards.find(c => c.title === '__test_grpundo_parent__');
|
||||
const restoredChild = cards.find(c => c.title === '__test_grpundo_child__');
|
||||
expect(restoredParent).toExist();
|
||||
expect(restoredChild).toExist();
|
||||
// Child should point at the restored parent
|
||||
expect(restoredChild.parent_id).toBe(restoredParent.id);
|
||||
|
||||
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
||||
else localStorage.removeItem('hyprcrd-confirm-delete');
|
||||
});
|
||||
});
|
||||
@@ -281,7 +281,8 @@ async function runAll(only = null) {
|
||||
if (!suiteMap.has(suite.name)) suiteMap.set(suite.name, { name: suite.name, tests: [] });
|
||||
suiteMap.get(suite.name).tests.push(entry);
|
||||
}
|
||||
hideProgress();
|
||||
// Don't hide the progress bar here — run() does it after the retry
|
||||
// pass (if any) so the bar stays visible during the whole sequence.
|
||||
return [...suiteMap.values()];
|
||||
}
|
||||
|
||||
@@ -425,7 +426,10 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Clean up any leftover UI state
|
||||
// 5. Hide progress bar now that both passes (first + retry) are done
|
||||
hideProgress();
|
||||
|
||||
// 6. Clean up any leftover UI state
|
||||
await closeAll();
|
||||
|
||||
// 6. Restore backup
|
||||
|
||||
@@ -42,6 +42,35 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
||||
}
|
||||
|
||||
// ── UI inbox view: session reads decrypted messages ──
|
||||
//
|
||||
// The relay already sees plaintext during the routing hop (decrypt with
|
||||
// sender secret → re-encrypt with inter-tenant key for at-rest storage),
|
||||
// so reading them back to the session user is no extra leakage. Lets the
|
||||
// user check their inbox from the relay web UI on a phone without
|
||||
// needing the full hyprcrd client. Does NOT mark messages as fetched —
|
||||
// the canonical receiver is still the hyprcrd client.
|
||||
|
||||
if ($route === '/api/messages/inbox' && $method === 'GET' && $sessionTenant) {
|
||||
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||
$stmt = $db->prepare('SELECT id, sender_username, title, body, created_at, fetched FROM messages WHERE recipient_id = :r ORDER BY created_at DESC LIMIT 200');
|
||||
$stmt->execute(['r' => $sessionTenant['id']]);
|
||||
$rows = $stmt->fetchAll();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'sender' => $row['sender_username'] ?? '?',
|
||||
'title' => clientDecrypt($row['title'] ?? '', $interKey),
|
||||
'body' => clientDecrypt($row['body'] ?? '', $interKey),
|
||||
'created_at' => $row['created_at'],
|
||||
'fetched' => (int)$row['fetched'],
|
||||
];
|
||||
}
|
||||
jsonResponse(['messages' => $out]);
|
||||
}
|
||||
|
||||
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
||||
|
||||
$clientTenant = authClient($db);
|
||||
|
||||
+174
-23
@@ -5,12 +5,29 @@ let allTenants = [];
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function toast(msg, isError = false) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('err', isError);
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2200);
|
||||
/** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'.
|
||||
* Multiple toasts stack vertically, oldest at the top, and each one
|
||||
* slides in from the right + fades out after a fixed timeout. The
|
||||
* legacy `toast(msg, true)` second-arg style still works (true → err). */
|
||||
function toast(msg, kind = 'info') {
|
||||
if (kind === true) kind = 'err';
|
||||
if (kind === false) kind = 'info';
|
||||
const stack = $('toast-stack');
|
||||
if (!stack) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + kind;
|
||||
const icon = kind === 'err' ? 'ph-warning-circle'
|
||||
: kind === 'ok' ? 'ph-check-circle'
|
||||
: 'ph-info';
|
||||
el.innerHTML = `<i class="ph ${icon} toast-icon"></i><span class="toast-msg"></span>`;
|
||||
el.querySelector('.toast-msg').textContent = msg;
|
||||
stack.appendChild(el);
|
||||
// Trigger the slide-in next frame so the transition runs
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
el.classList.add('fade-out');
|
||||
setTimeout(() => el.remove(), 350);
|
||||
}, 2800);
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
@@ -29,10 +46,6 @@ function showView(name) {
|
||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||
name = 'change-password';
|
||||
}
|
||||
// Block admin view on mobile — even if someone tries programmatically
|
||||
if (name === 'admin' && window.matchMedia('(max-width: 700px)').matches) {
|
||||
name = 'compose';
|
||||
}
|
||||
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
||||
$('view-' + v)?.classList.add('hidden');
|
||||
});
|
||||
@@ -40,20 +53,28 @@ function showView(name) {
|
||||
|
||||
if (['compose', 'account', 'admin'].includes(name)) {
|
||||
$('nav-buttons').classList.remove('hidden');
|
||||
$('nav-buttons').style.display = 'flex';
|
||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||
$('nav-account').classList.toggle('active', name === 'account');
|
||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
||||
$('nav-burger')?.classList.remove('hidden');
|
||||
// Picking a view also collapses the mobile menu so it doesn't
|
||||
// hover over the freshly-shown view.
|
||||
$('nav-buttons').classList.remove('menu-open');
|
||||
} else {
|
||||
// Setup, login, and forced change-password hide all nav
|
||||
$('nav-buttons').classList.add('hidden');
|
||||
$('nav-burger')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (name === 'compose') {
|
||||
loadTenants().then(() => populateRecipientPicker());
|
||||
loadInbox();
|
||||
startInboxPoll();
|
||||
$('compose-body')?.focus();
|
||||
} else if (name === 'admin') {
|
||||
} else {
|
||||
stopInboxPoll();
|
||||
}
|
||||
if (name === 'admin') {
|
||||
loadUsers();
|
||||
} else if (name === 'account') {
|
||||
loadAccountSecret();
|
||||
@@ -63,6 +84,64 @@ function showView(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inbox auto-poll ──
|
||||
//
|
||||
// Refresh the inbox every 15s while the compose view is showing so new
|
||||
// messages from other tenants surface without a manual reload. Stops
|
||||
// the moment the user navigates away (account/admin/login) so we don't
|
||||
// hammer the server in the background.
|
||||
let inboxPollHandle = null;
|
||||
function startInboxPoll() {
|
||||
stopInboxPoll();
|
||||
inboxPollHandle = setInterval(() => {
|
||||
if (!$('view-compose')?.classList.contains('hidden')) loadInbox();
|
||||
}, 15000);
|
||||
}
|
||||
function stopInboxPoll() {
|
||||
if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; }
|
||||
}
|
||||
|
||||
/** Burger toggle for the mobile nav. The button is hidden on desktop
|
||||
* via CSS, on mobile it expands #nav-buttons as a dropdown panel
|
||||
* positioned under the header. */
|
||||
function toggleNavMenu() {
|
||||
$('nav-buttons')?.classList.toggle('menu-open');
|
||||
}
|
||||
window.toggleNavMenu = toggleNavMenu;
|
||||
|
||||
/** Tap-target on the header inbox count: switches to the compose view
|
||||
* if needed, then smooth-scrolls #inbox-section into view and briefly
|
||||
* highlights it so the user can't miss it after the scroll lands.
|
||||
* Same handler on desktop and mobile — desktop just scrolls a few px
|
||||
* if at all (the inbox is already on screen). */
|
||||
function jumpToInbox() {
|
||||
if (!me) return;
|
||||
if ($('view-compose')?.classList.contains('hidden')) {
|
||||
showView('compose');
|
||||
}
|
||||
// Wait one frame so the (possibly just-shown) view has measurable
|
||||
// dimensions before scrollIntoView fires
|
||||
requestAnimationFrame(() => {
|
||||
const target = $('inbox-section');
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.classList.remove('flash-highlight');
|
||||
// Restart the animation by toggling the class
|
||||
void target.offsetWidth;
|
||||
target.classList.add('flash-highlight');
|
||||
setTimeout(() => target.classList.remove('flash-highlight'), 1600);
|
||||
});
|
||||
}
|
||||
window.jumpToInbox = jumpToInbox;
|
||||
// Tap outside the menu closes it
|
||||
document.addEventListener('click', (e) => {
|
||||
const nav = $('nav-buttons');
|
||||
const burger = $('nav-burger');
|
||||
if (!nav?.classList.contains('menu-open')) return;
|
||||
if (nav.contains(e.target) || burger?.contains(e.target)) return;
|
||||
nav.classList.remove('menu-open');
|
||||
});
|
||||
|
||||
async function doChangePassword() {
|
||||
const current = $('cp-current').value;
|
||||
const newPw = $('cp-new').value;
|
||||
@@ -172,7 +251,9 @@ async function refreshMe() {
|
||||
};
|
||||
$('nav-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
||||
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
||||
$('header-status').textContent = (status.inbox_count || 0) + ' in inbox';
|
||||
const n = status.inbox_count || 0;
|
||||
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${n} in inbox</span>`;
|
||||
$('header-status').classList.toggle('has-unread', n > 0);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -247,31 +328,101 @@ async function sendCompose() {
|
||||
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
||||
|
||||
async function loadInbox() {
|
||||
const status = await api('api/status');
|
||||
$('inbox-count').textContent = status.inbox_count || 0;
|
||||
const list = $('inbox-list');
|
||||
if (!status.inbox_count) {
|
||||
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
|
||||
} else {
|
||||
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</div>`;
|
||||
list.innerHTML = `
|
||||
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||
<div class="skeleton-row"><div class="skel"></div></div>
|
||||
`;
|
||||
const res = await api('api/messages/inbox');
|
||||
const messages = res?.messages || [];
|
||||
$('inbox-count').textContent = messages.length;
|
||||
// Mirror count into the header status so the unread dot stays in sync
|
||||
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
||||
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
||||
|
||||
if (!messages.length) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="ph ph-tray icon"></i>
|
||||
<div class="label">inbox empty</div>
|
||||
<div class="hint">Messages from other tenants will show up here.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = messages.map(m => `
|
||||
<div class="inbox-item" data-id="${m.id}">
|
||||
<div class="content">
|
||||
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? '' : ' <span class="badge-new">new</span>'}</div>
|
||||
${m.title ? `<div class="title">${escapeHtml(m.title)}</div>` : ''}
|
||||
<div class="body">${escapeHtml(m.body)}</div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
|
||||
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function deleteMessage(id, btn) {
|
||||
if (!confirm('Delete this message?')) return;
|
||||
const res = await api('api/messages/' + id, { method: 'DELETE' });
|
||||
if (res?.ok) {
|
||||
btn.closest('.inbox-item')?.remove();
|
||||
loadInbox();
|
||||
refreshMe();
|
||||
toast('message deleted', 'ok');
|
||||
} else {
|
||||
toast(res?.error || 'delete failed', 'err');
|
||||
}
|
||||
}
|
||||
window.deleteMessage = deleteMessage;
|
||||
|
||||
/** Lightweight relative-time formatter for the inbox. SQLite gives us
|
||||
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
|
||||
* or a full date for older messages. */
|
||||
function formatRelTime(sqliteUtc) {
|
||||
if (!sqliteUtc) return '';
|
||||
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
||||
const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z');
|
||||
if (isNaN(t.getTime())) return sqliteUtc;
|
||||
const diff = (Date.now() - t.getTime()) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' min ago';
|
||||
if (diff < 86400) {
|
||||
const today = new Date();
|
||||
const sameDay = today.toDateString() === t.toDateString();
|
||||
if (sameDay) return 'today ' + t.toTimeString().slice(0, 5);
|
||||
return Math.floor(diff / 3600) + ' h ago';
|
||||
}
|
||||
if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago';
|
||||
return t.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// ── Admin: users ──
|
||||
|
||||
async function loadUsers() {
|
||||
const list = $('users-list');
|
||||
list.innerHTML = `
|
||||
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||
<div class="skeleton-row"><div class="skel"></div></div>
|
||||
`;
|
||||
const res = await api('api/users');
|
||||
if (res.error) { toast(res.error, true); return; }
|
||||
if (res.error) { toast(res.error, 'err'); return; }
|
||||
const users = res.users || [];
|
||||
$('users-count').textContent = users.length;
|
||||
const list = $('users-list');
|
||||
if (!users.length) {
|
||||
list.innerHTML = '<div class="empty">no users yet</div>';
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="ph ph-users icon"></i>
|
||||
<div class="label">no users yet</div>
|
||||
<div class="hint">Create the first one with the form on the right.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = users.map(u => `
|
||||
<div class="user-row" data-id="${u.id}">
|
||||
<i class="ph ph-user-circle" style="font-size:20px;color:var(--accent);"></i>
|
||||
<i class="ph ph-user-circle user-icon"></i>
|
||||
<div class="info">
|
||||
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
||||
<div class="meta">created ${u.created_at}</div>
|
||||
|
||||
+29
-23
@@ -14,10 +14,12 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
<header>
|
||||
<span class="dot"></span>
|
||||
<i class="ph ph-broadcast brand-icon"></i>
|
||||
<h1>hyprcrd relay</h1>
|
||||
<span id="header-status" class="pixel" style="font-size:9px;color:var(--dim);"></span>
|
||||
<button id="header-status" class="header-status pixel" onclick="jumpToInbox()" title="jump to inbox"></button>
|
||||
<span class="spacer"></span>
|
||||
<div id="nav-buttons" class="hidden" style="display:flex;gap:6px;align-items:center;">
|
||||
<button id="nav-burger" class="nav-btn hidden" aria-label="menu" onclick="toggleNavMenu()"><i class="ph ph-list"></i></button>
|
||||
<div id="nav-buttons" class="hidden">
|
||||
<span id="nav-user" class="user"></span>
|
||||
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
|
||||
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
|
||||
@@ -30,6 +32,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
<!-- ── Setup view (first install) ── -->
|
||||
<div id="view-setup" class="auth-form hidden">
|
||||
<i class="ph ph-broadcast auth-icon"></i>
|
||||
<h2>welcome to relay</h2>
|
||||
<p>This is the first time the relay is being set up. The user you create here will become the <strong>admin</strong>. You'll also set the <strong>super-shared secret</strong> — a relay-wide key used to route messages between tenants.</p>
|
||||
<div class="field">
|
||||
@@ -43,7 +46,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<div class="field">
|
||||
<label>super-shared secret</label>
|
||||
<input type="password" id="setup-super-secret" placeholder="min 12 chars" autocomplete="new-password">
|
||||
<p style="font-size:9px;color:var(--dim);margin-top:4px;line-height:1.4;">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
|
||||
<p class="field-hint">Used by the relay to encrypt messages at rest and route them between tenants. Set once, can't be changed without re-setup.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doSetup()"><i class="ph ph-user-circle-plus"></i> create admin + activate relay</button>
|
||||
<div id="setup-err" class="err"></div>
|
||||
@@ -51,6 +54,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
<!-- ── Login view ── -->
|
||||
<div id="view-login" class="auth-form hidden">
|
||||
<i class="ph ph-key auth-icon"></i>
|
||||
<h2>login</h2>
|
||||
<div class="field">
|
||||
<label>username</label>
|
||||
@@ -95,6 +99,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
<!-- ── Forced password change (first login) ── -->
|
||||
<div id="view-change-password" class="auth-form hidden">
|
||||
<i class="ph ph-key auth-icon"></i>
|
||||
<h2>change your password</h2>
|
||||
<p>This is your first login. Please choose a new password before you can use the relay.</p>
|
||||
<div class="field">
|
||||
@@ -117,35 +122,36 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<div id="view-account" class="hidden">
|
||||
<div class="section">
|
||||
<h2><i class="ph ph-user-circle"></i> your client secret</h2>
|
||||
<p style="font-size:11px;color:var(--muted);line-height:1.5;margin-bottom:14px;">
|
||||
<p class="field-hint" style="line-height:1.6;font-size:11px;color:var(--muted);">
|
||||
Your hyprcrd clients use this secret to encrypt messages before sending them to the relay.
|
||||
All your devices share the same secret. Other tenants never see it. The relay stores it
|
||||
encrypted with the master key so it can route your messages.
|
||||
</p>
|
||||
<div id="account-no-secret" class="hidden notice warn" style="margin-bottom:14px;">
|
||||
⚠ no secret set yet — your hyprcrd clients can't connect until you generate or set one below
|
||||
<div id="account-no-secret" class="hidden notice warn">
|
||||
<i class="ph ph-warning"></i>
|
||||
<span>no secret set yet — your hyprcrd clients can't connect until you generate or set one below</span>
|
||||
</div>
|
||||
<div id="account-current" class="hidden" style="margin-bottom:14px;">
|
||||
<label class="field-label" style="display:block;margin-bottom:4px;">current secret</label>
|
||||
<div style="display:flex;gap:8px;align-items:stretch;">
|
||||
<input id="account-secret-display" type="password" readonly class="w-full" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
|
||||
<div id="account-current" class="hidden account-secret-block">
|
||||
<label class="field-label">current secret</label>
|
||||
<div class="account-secret-row">
|
||||
<input id="account-secret-display" type="password" readonly class="input">
|
||||
<button class="btn btn-secondary" onclick="toggleAccountSecret()" id="account-secret-toggle"><i class="ph ph-eye"></i> show</button>
|
||||
<button class="btn btn-secondary" onclick="copyAccountSecret()"><i class="ph ph-copy"></i> copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr;gap:14px;">
|
||||
<div>
|
||||
<label class="field-label">set your own secret</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input id="account-secret-input" type="text" placeholder="min 8 chars" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--bright);padding:9px 12px;font-size:13px;font-family:'JetBrains Mono',monospace;border-radius:3px;">
|
||||
<div class="account-secret-grid">
|
||||
<div class="field">
|
||||
<label>set your own secret</label>
|
||||
<div class="account-secret-row">
|
||||
<input id="account-secret-input" type="text" placeholder="min 8 chars" class="input">
|
||||
<button class="btn btn-primary" onclick="saveAccountSecret()"><i class="ph ph-floppy-disk"></i> save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;color:var(--dim);font-family:var(--pixel);font-size:9px;">— or —</div>
|
||||
<div class="account-divider">— or —</div>
|
||||
<button class="btn btn-secondary" onclick="generateAccountSecret()"><i class="ph ph-dice-five"></i> generate random secret</button>
|
||||
</div>
|
||||
<div id="account-status" class="status" style="margin-top:12px;"></div>
|
||||
<div style="margin-top:24px;padding-top:14px;border-top:1px solid var(--border);">
|
||||
<div class="account-secret-actions">
|
||||
<button class="btn btn-danger" onclick="clearAccountSecret()"><i class="ph ph-x-circle"></i> clear secret (disconnect all clients)</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,18 +163,18 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
<h2><i class="ph ph-users-three"></i> users <span class="count" id="users-count">0</span></h2>
|
||||
<div id="users-list"></div>
|
||||
<div class="create-user-form">
|
||||
<div>
|
||||
<label for="new-username" class="field-label">username</label>
|
||||
<div class="field">
|
||||
<label for="new-username">username</label>
|
||||
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="field-label">password</label>
|
||||
<div class="field">
|
||||
<label for="new-password">password</label>
|
||||
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
|
||||
</div>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
||||
</label>
|
||||
<p style="font-size:9px;color:var(--dim);line-height:1.4;margin-top:2px;">The new user sets their own client secret on first login.</p>
|
||||
<p class="field-hint">The new user sets their own client secret on first login.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="createUser()"><i class="ph ph-user-plus"></i> create user</button>
|
||||
<span id="create-user-status" class="status"></span>
|
||||
@@ -179,7 +185,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<div id="toast-stack"></div>
|
||||
|
||||
<script src="ui/app.js"></script>
|
||||
</body>
|
||||
|
||||
+478
-195
@@ -5,51 +5,114 @@
|
||||
:root {
|
||||
--bg:#1e293b; --surface:#273349; --raised:#2e3d56; --border:#384a64;
|
||||
--dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9;
|
||||
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185;
|
||||
--accent:#38bdf8; --accent2:#818cf8; --danger:#fb7185; --ok:#4ade80;
|
||||
--pixel:'Silkscreen', cursive;
|
||||
--mono:'JetBrains Mono', monospace;
|
||||
--radius:3px;
|
||||
--radius-lg:6px;
|
||||
--shadow-card:0 1px 2px rgba(0,0,0,0.3), 0 8px 24px -8px rgba(0,0,0,0.4);
|
||||
--shadow-popover:0 8px 24px rgba(0,0,0,0.4);
|
||||
--shadow-button:0 4px 12px -4px rgba(56,189,248,0.4);
|
||||
--focus-ring:0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body {
|
||||
font-family:'JetBrains Mono', monospace;
|
||||
font-family:var(--mono);
|
||||
background:var(--bg); color:var(--text);
|
||||
min-height:100dvh; display:flex; flex-direction:column;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.pixel { font-family:var(--pixel); }
|
||||
.hidden { display:none !important; }
|
||||
.shrink { flex-shrink:0; }
|
||||
.spacer { flex:1; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Header
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
padding:12px 20px; border-bottom:1px solid var(--border);
|
||||
display:flex; align-items:center; gap:12px; flex-shrink:0;
|
||||
background:var(--surface);
|
||||
position:relative;
|
||||
}
|
||||
header h1 { font-family:var(--pixel); font-size:14px; color:var(--accent); }
|
||||
header .brand-icon {
|
||||
color:var(--accent); font-size:18px; line-height:1;
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--accent) 35%, transparent));
|
||||
}
|
||||
header h1 {
|
||||
font-family:var(--pixel); font-size:14px; color:var(--accent);
|
||||
letter-spacing:0.05em;
|
||||
}
|
||||
.header-status {
|
||||
background:none; border:none; padding:4px 8px; cursor:pointer;
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
border-radius:var(--radius);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.header-status .ph { font-size:12px; }
|
||||
.header-status:hover {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
.header-status.has-unread {
|
||||
color: var(--accent);
|
||||
position: relative;
|
||||
}
|
||||
.header-status.has-unread::after {
|
||||
content:''; position:absolute; top:2px; right:2px;
|
||||
width:6px; height:6px; border-radius:50%;
|
||||
background:var(--danger);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--danger) 60%, transparent);
|
||||
}
|
||||
.header-status:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
header .dot {
|
||||
width:8px; height:8px; border-radius:50%;
|
||||
background:var(--accent); animation:pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { opacity:1; transform:scale(1); }
|
||||
50% { opacity:0.4; transform:scale(0.85); }
|
||||
}
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
header .spacer { flex:1; }
|
||||
header .user {
|
||||
font-family:var(--pixel); font-size:10px; color:var(--muted);
|
||||
display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
header .user .badge {
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:1px 6px; font-size:8px; border-radius:2px;
|
||||
padding:5px 10px; font-size:9px;
|
||||
border:1px solid var(--accent); border-radius:var(--radius);
|
||||
line-height:1; font-family:var(--pixel);
|
||||
}
|
||||
header .nav-btn {
|
||||
background:none; border:1px solid var(--border); color:var(--muted);
|
||||
cursor:pointer; padding:5px 10px;
|
||||
font-family:var(--pixel); font-size:9px; border-radius:3px;
|
||||
font-family:var(--pixel); font-size:9px; border-radius:var(--radius);
|
||||
transition:all .15s;
|
||||
}
|
||||
header .nav-btn:hover { color:var(--accent); border-color:var(--accent); }
|
||||
header .nav-btn.active { background:var(--accent); color:var(--bg); border-color:var(--accent); }
|
||||
header .nav-btn.danger:hover { color:var(--danger); border-color:var(--danger); }
|
||||
header .nav-btn:hover {
|
||||
color:var(--accent); border-color:var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
header .nav-btn:focus-visible {
|
||||
outline:none; box-shadow: var(--focus-ring);
|
||||
}
|
||||
header .nav-btn.active {
|
||||
background:var(--accent); color:var(--bg); border-color:var(--accent);
|
||||
box-shadow: 0 0 12px -2px color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
}
|
||||
header .nav-btn.danger:hover {
|
||||
color:var(--danger); border-color:var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Layout shell
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Layouts ── */
|
||||
main {
|
||||
flex:1; display:flex; flex-direction:column;
|
||||
padding:20px; gap:16px; max-width:920px; width:100%;
|
||||
@@ -57,97 +120,183 @@ main {
|
||||
}
|
||||
@media (max-width: 600px) { main { padding:12px; gap:12px; } }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Form primitives — used by every view (auth, compose, account, admin)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.field { display:flex; flex-direction:column; gap:5px; width:100%; }
|
||||
.field-label,
|
||||
.field > label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
}
|
||||
.input,
|
||||
.field input[type="text"],
|
||||
.field input[type="password"],
|
||||
.field input[type="email"],
|
||||
.field select,
|
||||
.field textarea {
|
||||
background:var(--bg); border:1px solid var(--border);
|
||||
color:var(--bright); padding:9px 12px; font-size:13px;
|
||||
font-family:var(--mono); border-radius:var(--radius);
|
||||
transition:border-color .15s, box-shadow .15s, background .15s;
|
||||
width:100%;
|
||||
}
|
||||
.input:focus,
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
border-color:var(--accent); outline:none;
|
||||
box-shadow: var(--focus-ring);
|
||||
background: color-mix(in srgb, var(--bg) 96%, var(--accent) 4%);
|
||||
}
|
||||
.input::placeholder,
|
||||
.field input::placeholder,
|
||||
.field textarea::placeholder { color:var(--dim); }
|
||||
.field textarea { min-height:140px; resize:vertical; line-height:1.55; }
|
||||
.field-hint {
|
||||
font-size:9px; color:var(--dim); line-height:1.4;
|
||||
font-family:var(--mono);
|
||||
}
|
||||
|
||||
/* Inline label-on-checkbox style */
|
||||
label.checkbox {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase;
|
||||
display:flex; align-items:center; gap:6px; cursor:pointer;
|
||||
}
|
||||
label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:pointer; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Buttons
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
padding:10px 18px; border:1px solid transparent; border-radius:var(--radius);
|
||||
font-family:var(--pixel); font-size:11px; cursor:pointer;
|
||||
transition:all .15s;
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
justify-content:center;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||
|
||||
.btn-primary { background:var(--accent); color:var(--bg); border-color:var(--accent); }
|
||||
.btn-primary:hover {
|
||||
filter:brightness(1.12);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-button);
|
||||
}
|
||||
.btn-secondary {
|
||||
background:transparent; border-color:var(--border); color:var(--muted);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
color:var(--accent); border-color:var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-danger {
|
||||
background:transparent; border-color:var(--danger); color:var(--danger);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background:var(--danger); color:var(--bg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Cards (compose, sections)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.compose-card,
|
||||
.section {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:var(--radius-lg); padding:18px;
|
||||
display:flex; flex-direction:column; gap:12px;
|
||||
}
|
||||
.compose-card h2,
|
||||
.section h2 {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--accent);
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
display:flex; align-items:center; gap:8px;
|
||||
}
|
||||
.section h2 .count {
|
||||
background:var(--bg); padding:3px 7px; border-radius:var(--radius);
|
||||
font-size:9px; color:var(--muted);
|
||||
border:1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Compose
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
#view-compose:not(.hidden) {
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
.compose-card .actions {
|
||||
display:flex; gap:8px; align-items:center;
|
||||
}
|
||||
.compose-card .actions .spacer { flex:1; }
|
||||
|
||||
/* Status text (used in compose, account, create-user) */
|
||||
.status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
|
||||
.status.ok { color:var(--ok); }
|
||||
.status.err { color:var(--danger); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Auth forms (setup, login, change-password)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.auth-form {
|
||||
display:flex; flex-direction:column; align-items:center;
|
||||
padding:48px 24px; gap:14px; max-width:380px; margin:0 auto;
|
||||
padding:40px 28px; gap:14px; max-width:400px; margin:48px auto 0;
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.auth-form .auth-icon {
|
||||
font-size:36px; color:var(--accent); margin-bottom:4px;
|
||||
filter: drop-shadow(0 0 16px color-mix(in srgb, var(--accent) 40%, transparent));
|
||||
}
|
||||
.auth-form h2 {
|
||||
font-family:var(--pixel); font-size:14px; color:var(--accent);
|
||||
margin-bottom:4px; letter-spacing:0.05em;
|
||||
}
|
||||
.auth-form p {
|
||||
font-size:11px; color:var(--muted); text-align:center; line-height:1.55;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.auth-form h2 { font-family:var(--pixel); font-size:13px; color:var(--accent); margin-bottom:4px; }
|
||||
.auth-form p { font-size:11px; color:var(--muted); text-align:center; line-height:1.5; }
|
||||
.auth-form .field { width:100%; }
|
||||
.auth-form .field label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase; display:block; margin-bottom:4px;
|
||||
}
|
||||
.auth-form input {
|
||||
width:100%; background:var(--surface); border:1px solid var(--border);
|
||||
color:var(--bright); padding:9px 12px; font-size:13px;
|
||||
font-family:'JetBrains Mono', monospace; border-radius:3px;
|
||||
}
|
||||
.auth-form input:focus { border-color:var(--accent); outline:none; }
|
||||
.auth-form .btn { width:100%; }
|
||||
.auth-form .err {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--danger);
|
||||
min-height:11px; text-align:center;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
padding:10px 18px; border:none; border-radius:3px;
|
||||
font-family:var(--pixel); font-size:11px; cursor:pointer;
|
||||
transition:all .15s;
|
||||
/* Flash-highlight: a brief accent pulse on a card after the user
|
||||
tapped the header inbox link and we just scrolled it into view. */
|
||||
@keyframes flash-highlight {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 30%, transparent),
|
||||
0 0 24px 4px color-mix(in srgb, var(--accent) 50%, transparent); }
|
||||
100% { box-shadow: 0 0 0 0 transparent; }
|
||||
}
|
||||
.flash-highlight {
|
||||
animation: flash-highlight 1.6s ease-out;
|
||||
}
|
||||
.btn:active { opacity:.7; }
|
||||
.btn-primary { background:var(--accent); color:var(--bg); }
|
||||
.btn-primary:hover { filter:brightness(1.1); }
|
||||
.btn-secondary { background:transparent; border:1px solid var(--border); color:var(--muted); }
|
||||
.btn-secondary:hover { color:var(--accent); border-color:var(--accent); }
|
||||
.btn-danger { background:transparent; border:1px solid var(--danger); color:var(--danger); }
|
||||
.btn-danger:hover { background:var(--danger); color:var(--bg); }
|
||||
|
||||
/* ── Compose view ── */
|
||||
#view-compose:not(.hidden) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.compose-card {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:4px; padding:16px;
|
||||
display:flex; flex-direction:column; gap:10px;
|
||||
}
|
||||
.compose-card h2 {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--accent);
|
||||
text-transform:uppercase;
|
||||
}
|
||||
.compose-card .field { display:flex; flex-direction:column; gap:4px; }
|
||||
.compose-card .field label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase;
|
||||
}
|
||||
.compose-card input, .compose-card select, .compose-card textarea {
|
||||
background:var(--bg); border:1px solid var(--border); color:var(--bright);
|
||||
padding:8px 10px; font-size:13px;
|
||||
font-family:'JetBrains Mono', monospace; border-radius:3px;
|
||||
}
|
||||
.compose-card input:focus, .compose-card select:focus, .compose-card textarea:focus {
|
||||
border-color:var(--accent); outline:none;
|
||||
}
|
||||
.compose-card textarea { min-height:140px; resize:vertical; line-height:1.55; }
|
||||
.compose-card .actions { display:flex; gap:8px; align-items:center; }
|
||||
.compose-card .actions .spacer { flex:1; }
|
||||
.compose-card .status { font-family:var(--pixel); font-size:9px; color:var(--dim); }
|
||||
.compose-card .status.ok { color:var(--accent); }
|
||||
.compose-card .status.err { color:var(--danger); }
|
||||
|
||||
/* ── Inbox + admin lists ── */
|
||||
.section {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:4px; padding:16px;
|
||||
}
|
||||
.section h2 {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--accent);
|
||||
text-transform:uppercase; margin-bottom:10px;
|
||||
display:flex; align-items:center; gap:8px;
|
||||
}
|
||||
.section h2 .count {
|
||||
background:var(--bg); padding:2px 6px; border-radius:2px;
|
||||
font-size:9px; color:var(--muted);
|
||||
}
|
||||
.section .empty { font-size:11px; color:var(--dim); font-style:italic; padding:8px 0; }
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Inbox + lists
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.inbox-item {
|
||||
display:flex; align-items:flex-start; gap:10px;
|
||||
padding:10px 0; border-bottom:1px solid var(--border);
|
||||
padding:12px 10px; margin: 0 -10px;
|
||||
border-bottom:1px solid var(--border);
|
||||
transition: background .12s;
|
||||
border-radius:var(--radius);
|
||||
}
|
||||
.inbox-item:hover {
|
||||
background: color-mix(in srgb, var(--raised) 70%, transparent);
|
||||
}
|
||||
.inbox-item:last-child { border-bottom:none; }
|
||||
.inbox-item .meta {
|
||||
@@ -163,113 +312,277 @@ main {
|
||||
font-size:11px; color:var(--text); white-space:pre-wrap;
|
||||
word-break:break-word; max-height:120px; overflow:auto;
|
||||
}
|
||||
.inbox-item .from { font-family:var(--pixel); font-size:9px; color:var(--accent2); }
|
||||
.inbox-item .from {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--accent2);
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
margin-bottom:4px;
|
||||
}
|
||||
.inbox-item .from .ph { font-size:11px; }
|
||||
.badge-new {
|
||||
display:inline-block;
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:1px 6px; border-radius:var(--radius);
|
||||
font-family:var(--pixel); font-size:8px;
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
margin-left:4px;
|
||||
}
|
||||
.inbox-item .del {
|
||||
background:none; border:none; color:var(--dim); cursor:pointer;
|
||||
padding:4px; font-size:14px; flex-shrink:0;
|
||||
padding:6px; font-size:14px; flex-shrink:0;
|
||||
border-radius:var(--radius);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.inbox-item .del:hover {
|
||||
color:var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
.inbox-item .del:hover { color:var(--danger); }
|
||||
|
||||
/* User row (admin) */
|
||||
.user-row {
|
||||
display:flex; align-items:center; gap:12px;
|
||||
padding:10px 0; border-bottom:1px solid var(--border);
|
||||
padding:12px 10px; margin: 0 -10px;
|
||||
border-bottom:1px solid var(--border);
|
||||
border-radius:var(--radius);
|
||||
transition: background .12s;
|
||||
}
|
||||
.user-row:hover {
|
||||
background: color-mix(in srgb, var(--raised) 70%, transparent);
|
||||
}
|
||||
.user-row:last-child { border-bottom:none; }
|
||||
.user-row .info { flex:1; }
|
||||
.user-row .name { font-family:var(--pixel); font-size:11px; color:var(--bright); }
|
||||
.user-row .meta { font-size:10px; color:var(--muted); margin-top:2px; }
|
||||
.user-row .name {
|
||||
font-family:var(--pixel); font-size:11px; color:var(--bright);
|
||||
display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
.user-row .meta { font-size:10px; color:var(--muted); margin-top:3px; }
|
||||
.user-row .badge {
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:1px 6px; font-family:var(--pixel); font-size:8px;
|
||||
text-transform:uppercase; border-radius:2px;
|
||||
padding:2px 7px; font-family:var(--pixel); font-size:8px;
|
||||
text-transform:uppercase; border-radius:var(--radius);
|
||||
}
|
||||
.user-row .actions { display:flex; gap:6px; }
|
||||
.user-row .actions .btn { padding:4px 10px; font-size:9px; }
|
||||
.user-row .actions .btn { padding:5px 10px; font-size:9px; }
|
||||
|
||||
/* User-row icon */
|
||||
.user-row .user-icon { font-size:20px; color:var(--accent); flex-shrink:0; }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Empty state — used by inbox, users list, etc.
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; padding:36px 12px; gap:8px;
|
||||
color:var(--dim); text-align:center;
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size:38px; color:var(--dim); opacity:0.6;
|
||||
}
|
||||
.empty-state .label {
|
||||
font-family:var(--pixel); font-size:10px; color:var(--muted);
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
}
|
||||
.empty-state .hint {
|
||||
font-size:11px; color:var(--dim); line-height:1.5;
|
||||
max-width:280px;
|
||||
}
|
||||
.empty {
|
||||
font-size:11px; color:var(--dim); font-style:italic; padding:8px 0;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Loading skeleton (used by inbox / users list while fetching)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.skeleton-row {
|
||||
display:flex; align-items:center; gap:10px; padding:12px 10px;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.skeleton-row:last-child { border-bottom:none; }
|
||||
.skel {
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--raised) 60%, transparent) 0%,
|
||||
color-mix(in srgb, var(--raised) 90%, transparent) 50%,
|
||||
color-mix(in srgb, var(--raised) 60%, transparent) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton 1.4s ease-in-out infinite;
|
||||
border-radius:2px; height:10px; width:60%;
|
||||
}
|
||||
.skel.short { width:30%; }
|
||||
.skel.tall { height:14px; }
|
||||
@keyframes skeleton {
|
||||
0% { background-position:200% 0; }
|
||||
100% { background-position:-200% 0; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Account view — secret display + input row
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.account-secret-row {
|
||||
display:flex; gap:8px; align-items:stretch;
|
||||
}
|
||||
.account-secret-row .input { flex:1; }
|
||||
.account-secret-block { margin-bottom:14px; }
|
||||
.account-divider {
|
||||
text-align:center; color:var(--dim);
|
||||
font-family:var(--pixel); font-size:9px;
|
||||
text-transform:uppercase; letter-spacing:0.05em;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.account-secret-grid {
|
||||
display:grid; grid-template-columns:1fr; gap:14px;
|
||||
}
|
||||
.account-secret-actions {
|
||||
margin-top:24px; padding-top:14px; border-top:1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Create user form (admin)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.create-user-form {
|
||||
display:flex; flex-direction:column; gap:10px;
|
||||
display:flex; flex-direction:column; gap:12px;
|
||||
padding-top:14px; border-top:1px solid var(--border); margin-top:14px;
|
||||
}
|
||||
.create-user-form .field-label {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase; display:block; margin-bottom:4px;
|
||||
}
|
||||
.create-user-form input[type="text"],
|
||||
.create-user-form input[type="password"] {
|
||||
width:100%;
|
||||
background:var(--bg); border:1px solid var(--border); color:var(--bright);
|
||||
padding:8px 11px; font-size:12px;
|
||||
font-family:'JetBrains Mono', monospace; border-radius:3px;
|
||||
}
|
||||
.create-user-form input:focus { border-color:var(--accent); outline:none; }
|
||||
.create-user-form label.checkbox {
|
||||
font-family:var(--pixel); font-size:9px; color:var(--dim);
|
||||
text-transform:uppercase;
|
||||
display:flex; align-items:center; gap:6px; cursor:pointer;
|
||||
}
|
||||
.create-user-form label.checkbox input[type="checkbox"] {
|
||||
accent-color:var(--accent); cursor:pointer;
|
||||
}
|
||||
.create-user-form .actions {
|
||||
display:flex; gap:8px; align-items:center;
|
||||
margin-top:4px;
|
||||
display:flex; gap:8px; align-items:center; margin-top:4px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Toast — single + stack support
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
#toast-stack {
|
||||
position:fixed; top:20px; right:20px;
|
||||
background:var(--accent); color:var(--bg);
|
||||
padding:10px 18px; border-radius:3px;
|
||||
font-family:var(--pixel); font-size:11px;
|
||||
opacity:0; transition:opacity .3s; z-index:100; pointer-events:none;
|
||||
display:flex; flex-direction:column; gap:8px;
|
||||
z-index:200; pointer-events:none;
|
||||
}
|
||||
.toast.show { opacity:1; }
|
||||
.toast.err { background:var(--danger); }
|
||||
.toast {
|
||||
background:var(--surface); color:var(--bright);
|
||||
padding:10px 14px; border-radius:var(--radius-lg);
|
||||
font-family:var(--pixel); font-size:10px;
|
||||
display:flex; align-items:center; gap:8px;
|
||||
border:1px solid var(--border);
|
||||
box-shadow: var(--shadow-popover);
|
||||
pointer-events:auto;
|
||||
transform: translateX(120%);
|
||||
transition: transform .25s cubic-bezier(0.16, 1, 0.3, 1), opacity .25s;
|
||||
opacity:0;
|
||||
min-width:180px; max-width:320px;
|
||||
}
|
||||
.toast.show { transform: translateX(0); opacity:1; }
|
||||
.toast.fade-out { opacity:0; transform: translateX(20%); }
|
||||
.toast .toast-icon { font-size:14px; flex-shrink:0; }
|
||||
.toast .toast-msg { line-height:1.3; word-break:break-word; }
|
||||
.toast.info { border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
|
||||
.toast.info .toast-icon { color: var(--accent); }
|
||||
.toast.ok { border-color: color-mix(in srgb, var(--ok) 60%, var(--border)); }
|
||||
.toast.ok .toast-icon { color: var(--ok); }
|
||||
.toast.err { border-color: color-mix(in srgb, var(--danger) 60%, var(--border)); }
|
||||
.toast.err .toast-icon { color: var(--danger); }
|
||||
|
||||
.notice { padding:10px 14px; border-radius:3px; font-size:11px; margin-bottom:12px; }
|
||||
.notice.info { background:rgba(56,189,248,0.1); border:1px solid var(--accent); color:var(--accent); }
|
||||
.notice.warn { background:rgba(251,113,133,0.1); border:1px solid var(--danger); color:var(--danger); }
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Notices
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.notice {
|
||||
padding:10px 14px; border-radius:var(--radius); font-size:11px;
|
||||
margin-bottom:12px;
|
||||
display:flex; align-items:flex-start; gap:8px;
|
||||
line-height:1.5;
|
||||
}
|
||||
.notice .ph { font-size:14px; flex-shrink:0; margin-top:1px; }
|
||||
.notice.info { background:rgba(56,189,248,0.08); border:1px solid color-mix(in srgb, var(--accent) 60%, var(--border)); color:var(--accent); }
|
||||
.notice.warn { background:rgba(251,113,133,0.08); border:1px solid color-mix(in srgb, var(--danger) 60%, var(--border)); color:var(--danger); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Phosphor icon defaults
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Phosphor icons ── */
|
||||
.ph { display:inline-block; vertical-align:-2px; }
|
||||
header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right:2px; }
|
||||
header h1 + .ph { font-size:12px; }
|
||||
.btn .ph { font-size:12px; vertical-align:-2px; margin-right:2px; }
|
||||
.btn .ph { font-size:12px; vertical-align:-2px; }
|
||||
.section h2 .ph, .compose-card h2 .ph { font-size:13px; vertical-align:-2px; }
|
||||
|
||||
/* ── Mobile: hide admin, identity, and the compose nav button ── */
|
||||
@media (max-width: 700px) {
|
||||
#nav-admin { display:none !important; }
|
||||
#view-admin { display:none !important; }
|
||||
#nav-user { display:none !important; }
|
||||
#nav-compose { display:none !important; }
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Default nav layout (desktop)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
#nav-buttons:not(.hidden) {
|
||||
display:flex; gap:6px; align-items:center;
|
||||
}
|
||||
#nav-burger {
|
||||
display:none;
|
||||
padding:5px 10px; font-size:11px;
|
||||
}
|
||||
|
||||
/* ── Desktop layout ── */
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Mobile: collapse nav into a burger menu
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#nav-user { display:none !important; }
|
||||
#nav-burger { display:inline-flex !important; align-items:center; }
|
||||
#nav-buttons:not(.hidden) { display:none; }
|
||||
#nav-buttons.menu-open {
|
||||
display:flex !important;
|
||||
flex-direction:column;
|
||||
align-items:stretch;
|
||||
position:absolute;
|
||||
top:100%;
|
||||
right:12px;
|
||||
margin-top:6px;
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-radius:var(--radius-lg);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding:6px;
|
||||
gap:4px;
|
||||
min-width:180px;
|
||||
z-index:50;
|
||||
}
|
||||
#nav-buttons.menu-open .nav-btn {
|
||||
text-align:left;
|
||||
width:100%;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
#view-admin .section {
|
||||
display:block !important;
|
||||
padding:14px 12px;
|
||||
}
|
||||
#view-admin .section h2 { margin-bottom:10px; }
|
||||
#view-admin .section .create-user-form { margin-top:14px; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Desktop layout (≥ 901px)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (min-width: 901px) {
|
||||
|
||||
/* Subtle background gradient gives the page some depth */
|
||||
body {
|
||||
background:
|
||||
radial-gradient(ellipse at top left, rgba(56,189,248,0.04), transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.04), transparent 50%),
|
||||
radial-gradient(ellipse at top left, rgba(56,189,248,0.05), transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.05), transparent 50%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* Wider canvas, more breathing room */
|
||||
main {
|
||||
max-width: 1200px;
|
||||
padding: 32px 32px 48px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Header: bigger title, more padding */
|
||||
header {
|
||||
padding: 14px 32px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
header .brand-icon { font-size:20px; }
|
||||
header .nav-btn {
|
||||
padding: 7px 12px;
|
||||
}
|
||||
@@ -277,10 +590,10 @@ header h1 + .ph { font-size:12px; }
|
||||
padding: 4px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Compose view: 2-column grid (compose | inbox) */
|
||||
/* Compose view: 2-column grid */
|
||||
#view-compose:not(.hidden) {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
||||
@@ -292,31 +605,28 @@ header h1 + .ph { font-size:12px; }
|
||||
.compose-card,
|
||||
.section {
|
||||
padding: 22px 24px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0,0,0,0.3),
|
||||
0 8px 24px -8px rgba(0,0,0,0.4);
|
||||
box-shadow: var(--shadow-card);
|
||||
border-color: color-mix(in srgb, var(--border) 70%, var(--accent) 12%);
|
||||
}
|
||||
.compose-card h2,
|
||||
.section h2 {
|
||||
font-size: 12px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Big textarea for desktop typing */
|
||||
.compose-card textarea {
|
||||
.compose-card .field textarea {
|
||||
min-height: 320px;
|
||||
font-size: 14px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.compose-card input,
|
||||
.compose-card select {
|
||||
.compose-card .field input,
|
||||
.compose-card .field select {
|
||||
padding: 9px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.compose-card .field label {
|
||||
.compose-card .field > label {
|
||||
font-size: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -330,11 +640,11 @@ header h1 + .ph { font-size:12px; }
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Inbox card: stick to top of viewport, hover lifts it */
|
||||
/* Inbox card sticks under the header */
|
||||
#inbox-section {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
#inbox-section:hover {
|
||||
box-shadow:
|
||||
@@ -343,7 +653,7 @@ header h1 + .ph { font-size:12px; }
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Admin view: 2-column grid (user list | create form) */
|
||||
/* Admin: 2-column grid */
|
||||
#view-admin:not(.hidden) .section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
@@ -364,44 +674,17 @@ header h1 + .ph { font-size:12px; }
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* User row polish: hover lift */
|
||||
.user-row {
|
||||
padding: 14px 12px;
|
||||
margin: 0 -12px;
|
||||
border-radius: 3px;
|
||||
transition: background .12s;
|
||||
}
|
||||
.user-row:hover {
|
||||
background: color-mix(in srgb, var(--raised) 70%, transparent);
|
||||
}
|
||||
.user-row .name {
|
||||
font-size: 12px;
|
||||
}
|
||||
.user-row .name { font-size: 12px; }
|
||||
|
||||
/* Auth forms (setup/login) — vertically center on viewport */
|
||||
/* Auth-forms: full vertical centering, larger icon */
|
||||
.auth-form {
|
||||
margin-top: 64px;
|
||||
padding: 40px 32px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0,0,0,0.3),
|
||||
0 16px 48px -16px rgba(0,0,0,0.5);
|
||||
max-width: 420px;
|
||||
margin-top: 88px;
|
||||
padding: 44px 36px;
|
||||
max-width: 440px;
|
||||
}
|
||||
.auth-form h2 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Buttons: subtle press */
|
||||
.btn { transition: all .12s; }
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.15);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px -4px rgba(56,189,248,0.5);
|
||||
}
|
||||
.btn-secondary:hover { transform: translateY(-1px); }
|
||||
.btn-danger:hover { transform: translateY(-1px); }
|
||||
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
|
||||
.auth-form .auth-icon { font-size: 44px; }
|
||||
}
|
||||
Reference in new issue
Block a user