Compare commits
3
Commits
923de0cdd6
...
56c90225cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56c90225cb | ||
|
|
c16659c2ad | ||
|
|
a67bedc6f2 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '0.9.7');
|
define('APP_VERSION', '0.9.10');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
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;
|
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
|
/* Super-bucket dropdown popover. Positioned via inline styles by
|
||||||
openSuperDropdown() in buckets.js. Lives on document.body (not
|
openSuperDropdown() in buckets.js. Lives on document.body (not
|
||||||
inside #bucket-bar) so it can escape the bar's overflow:auto. */
|
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.
|
// time a background poll completed mid-drag.
|
||||||
const draggingCardIds = new Set();
|
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 ──
|
// ── Multi-select ──
|
||||||
const selectedIds = state.selectedIds;
|
const selectedIds = state.selectedIds;
|
||||||
const selectBar = $('select-bar');
|
const selectBar = $('select-bar');
|
||||||
@@ -450,6 +492,19 @@ export function renderCardHtml(c, inGroup = false, recent = []) {
|
|||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
|
||||||
export function renderBoard() {
|
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;
|
let cards;
|
||||||
if (state.activeGroup) {
|
if (state.activeGroup) {
|
||||||
const parent = state.cards.find(c => c.id == state.activeGroup);
|
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||||
@@ -686,6 +741,17 @@ export function renderBoard() {
|
|||||||
|
|
||||||
// Notification banner
|
// Notification banner
|
||||||
renderNotifs();
|
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 ──
|
// ── Notification providers ──
|
||||||
|
|||||||
@@ -264,8 +264,14 @@ function renderBar() {
|
|||||||
return parent;
|
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 = `
|
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 === '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 === '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>
|
<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 === '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') {
|
else if (a === 'delete') {
|
||||||
hideContextMenu();
|
hideContextMenu();
|
||||||
|
const children = getChildren(card.id);
|
||||||
|
const isGroup = children.length > 0;
|
||||||
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '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' });
|
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
const wasSync = !!card.caldav_uid;
|
// Snapshot everything for undo BEFORE deleting
|
||||||
pushCardDelete('delete', snapshotCard(card));
|
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 api(`api/cards/${card.id}`, { method: 'DELETE' });
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
if (wasSync) window._triggerSync?.();
|
if (wasSync) window._triggerSync?.();
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export const state = {
|
|||||||
// pick the title strategy: false → first selected wins, true →
|
// pick the title strategy: false → first selected wins, true →
|
||||||
// longest title wins (since "first" isn't meaningful for bulk).
|
// longest title wins (since "first" isn't meaningful for bulk).
|
||||||
selectionWasBulk: false,
|
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
|
// Detail / editor
|
||||||
editingId: null,
|
editingId: null,
|
||||||
|
|||||||
@@ -269,3 +269,125 @@ describe('Card: Grouping', () => {
|
|||||||
expect(parentEl).toExist();
|
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: [] });
|
if (!suiteMap.has(suite.name)) suiteMap.set(suite.name, { name: suite.name, tests: [] });
|
||||||
suiteMap.get(suite.name).tests.push(entry);
|
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()];
|
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();
|
await closeAll();
|
||||||
|
|
||||||
// 6. Restore backup
|
// 6. Restore backup
|
||||||
|
|||||||
@@ -42,6 +42,35 @@ if ($route === '/api/messages' && $method === 'POST' && $sessionTenant && empty(
|
|||||||
jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'recipient' => $recipientName]);
|
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) ──
|
// ── Client API auth (X-Relay-User + X-Relay-Key) ──
|
||||||
|
|
||||||
$clientTenant = authClient($db);
|
$clientTenant = authClient($db);
|
||||||
|
|||||||
+174
-23
@@ -5,12 +5,29 @@ let allTenants = [];
|
|||||||
|
|
||||||
function $(id) { return document.getElementById(id); }
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
function toast(msg, isError = false) {
|
/** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'.
|
||||||
const t = $('toast');
|
* Multiple toasts stack vertically, oldest at the top, and each one
|
||||||
t.textContent = msg;
|
* slides in from the right + fades out after a fixed timeout. The
|
||||||
t.classList.toggle('err', isError);
|
* legacy `toast(msg, true)` second-arg style still works (true → err). */
|
||||||
t.classList.add('show');
|
function toast(msg, kind = 'info') {
|
||||||
setTimeout(() => t.classList.remove('show'), 2200);
|
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 = {}) {
|
async function api(path, opts = {}) {
|
||||||
@@ -29,10 +46,6 @@ function showView(name) {
|
|||||||
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
|
||||||
name = 'change-password';
|
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 => {
|
['setup', 'login', 'change-password', 'compose', 'account', 'admin'].forEach(v => {
|
||||||
$('view-' + v)?.classList.add('hidden');
|
$('view-' + v)?.classList.add('hidden');
|
||||||
});
|
});
|
||||||
@@ -40,20 +53,28 @@ function showView(name) {
|
|||||||
|
|
||||||
if (['compose', 'account', 'admin'].includes(name)) {
|
if (['compose', 'account', 'admin'].includes(name)) {
|
||||||
$('nav-buttons').classList.remove('hidden');
|
$('nav-buttons').classList.remove('hidden');
|
||||||
$('nav-buttons').style.display = 'flex';
|
|
||||||
$('nav-compose').classList.toggle('active', name === 'compose');
|
$('nav-compose').classList.toggle('active', name === 'compose');
|
||||||
$('nav-account').classList.toggle('active', name === 'account');
|
$('nav-account').classList.toggle('active', name === 'account');
|
||||||
$('nav-admin').classList.toggle('active', name === 'admin');
|
$('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 {
|
} else {
|
||||||
// Setup, login, and forced change-password hide all nav
|
// Setup, login, and forced change-password hide all nav
|
||||||
$('nav-buttons').classList.add('hidden');
|
$('nav-buttons').classList.add('hidden');
|
||||||
|
$('nav-burger')?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'compose') {
|
if (name === 'compose') {
|
||||||
loadTenants().then(() => populateRecipientPicker());
|
loadTenants().then(() => populateRecipientPicker());
|
||||||
loadInbox();
|
loadInbox();
|
||||||
|
startInboxPoll();
|
||||||
$('compose-body')?.focus();
|
$('compose-body')?.focus();
|
||||||
} else if (name === 'admin') {
|
} else {
|
||||||
|
stopInboxPoll();
|
||||||
|
}
|
||||||
|
if (name === 'admin') {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else if (name === 'account') {
|
} else if (name === 'account') {
|
||||||
loadAccountSecret();
|
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() {
|
async function doChangePassword() {
|
||||||
const current = $('cp-current').value;
|
const current = $('cp-current').value;
|
||||||
const newPw = $('cp-new').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-user').innerHTML = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
||||||
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
$('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;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -247,31 +328,101 @@ async function sendCompose() {
|
|||||||
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
// ── Inbox (counts only — content is encrypted for the hyprcrd client) ──
|
||||||
|
|
||||||
async function loadInbox() {
|
async function loadInbox() {
|
||||||
const status = await api('api/status');
|
|
||||||
$('inbox-count').textContent = status.inbox_count || 0;
|
|
||||||
const list = $('inbox-list');
|
const list = $('inbox-list');
|
||||||
if (!status.inbox_count) {
|
list.innerHTML = `
|
||||||
list.innerHTML = '<div class="empty">no messages waiting. they will be picked up by the next hyprcrd poll.</div>';
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
||||||
} else {
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
||||||
list.innerHTML = `<div class="empty">${status.inbox_count} encrypted message${status.inbox_count === 1 ? '' : 's'} waiting for the hyprcrd client to fetch.</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 ──
|
// ── Admin: users ──
|
||||||
|
|
||||||
async function loadUsers() {
|
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');
|
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 || [];
|
const users = res.users || [];
|
||||||
$('users-count').textContent = users.length;
|
$('users-count').textContent = users.length;
|
||||||
const list = $('users-list');
|
|
||||||
if (!users.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = users.map(u => `
|
list.innerHTML = users.map(u => `
|
||||||
<div class="user-row" data-id="${u.id}">
|
<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="info">
|
||||||
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
||||||
<div class="meta">created ${u.created_at}</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>
|
<header>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
|
<i class="ph ph-broadcast brand-icon"></i>
|
||||||
<h1>hyprcrd relay</h1>
|
<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>
|
<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>
|
<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-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>
|
<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) ── -->
|
<!-- ── Setup view (first install) ── -->
|
||||||
<div id="view-setup" class="auth-form hidden">
|
<div id="view-setup" class="auth-form hidden">
|
||||||
|
<i class="ph ph-broadcast auth-icon"></i>
|
||||||
<h2>welcome to relay</h2>
|
<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>
|
<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">
|
<div class="field">
|
||||||
@@ -43,7 +46,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>super-shared secret</label>
|
<label>super-shared secret</label>
|
||||||
<input type="password" id="setup-super-secret" placeholder="min 12 chars" autocomplete="new-password">
|
<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>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="doSetup()"><i class="ph ph-user-circle-plus"></i> create admin + activate relay</button>
|
<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>
|
<div id="setup-err" class="err"></div>
|
||||||
@@ -51,6 +54,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
<!-- ── Login view ── -->
|
<!-- ── Login view ── -->
|
||||||
<div id="view-login" class="auth-form hidden">
|
<div id="view-login" class="auth-form hidden">
|
||||||
|
<i class="ph ph-key auth-icon"></i>
|
||||||
<h2>login</h2>
|
<h2>login</h2>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>username</label>
|
<label>username</label>
|
||||||
@@ -95,6 +99,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
<!-- ── Forced password change (first login) ── -->
|
<!-- ── Forced password change (first login) ── -->
|
||||||
<div id="view-change-password" class="auth-form hidden">
|
<div id="view-change-password" class="auth-form hidden">
|
||||||
|
<i class="ph ph-key auth-icon"></i>
|
||||||
<h2>change your password</h2>
|
<h2>change your password</h2>
|
||||||
<p>This is your first login. Please choose a new password before you can use the relay.</p>
|
<p>This is your first login. Please choose a new password before you can use the relay.</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -117,35 +122,36 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
<div id="view-account" class="hidden">
|
<div id="view-account" class="hidden">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2><i class="ph ph-user-circle"></i> your client secret</h2>
|
<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.
|
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
|
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.
|
encrypted with the master key so it can route your messages.
|
||||||
</p>
|
</p>
|
||||||
<div id="account-no-secret" class="hidden notice warn" style="margin-bottom:14px;">
|
<div id="account-no-secret" class="hidden notice warn">
|
||||||
⚠ no secret set yet — your hyprcrd clients can't connect until you generate or set one below
|
<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>
|
||||||
<div id="account-current" class="hidden" style="margin-bottom:14px;">
|
<div id="account-current" class="hidden account-secret-block">
|
||||||
<label class="field-label" style="display:block;margin-bottom:4px;">current secret</label>
|
<label class="field-label">current secret</label>
|
||||||
<div style="display:flex;gap:8px;align-items:stretch;">
|
<div class="account-secret-row">
|
||||||
<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;">
|
<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="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>
|
<button class="btn btn-secondary" onclick="copyAccountSecret()"><i class="ph ph-copy"></i> copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr;gap:14px;">
|
<div class="account-secret-grid">
|
||||||
<div>
|
<div class="field">
|
||||||
<label class="field-label">set your own secret</label>
|
<label>set your own secret</label>
|
||||||
<div style="display:flex;gap:8px;">
|
<div class="account-secret-row">
|
||||||
<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;">
|
<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>
|
<button class="btn btn-primary" onclick="saveAccountSecret()"><i class="ph ph-floppy-disk"></i> save</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<button class="btn btn-secondary" onclick="generateAccountSecret()"><i class="ph ph-dice-five"></i> generate random secret</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="account-status" class="status" style="margin-top:12px;"></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>
|
<button class="btn btn-danger" onclick="clearAccountSecret()"><i class="ph ph-x-circle"></i> clear secret (disconnect all clients)</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h2><i class="ph ph-users-three"></i> users <span class="count" id="users-count">0</span></h2>
|
||||||
<div id="users-list"></div>
|
<div id="users-list"></div>
|
||||||
<div class="create-user-form">
|
<div class="create-user-form">
|
||||||
<div>
|
<div class="field">
|
||||||
<label for="new-username" class="field-label">username</label>
|
<label for="new-username">username</label>
|
||||||
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
|
<input type="text" id="new-username" placeholder="alice" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="field">
|
||||||
<label for="new-password" class="field-label">password</label>
|
<label for="new-password">password</label>
|
||||||
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
|
<input type="password" id="new-password" placeholder="min 4 chars" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
<input type="checkbox" id="new-is-admin"> grant admin privileges
|
||||||
</label>
|
</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">
|
<div class="actions">
|
||||||
<button class="btn btn-primary" onclick="createUser()"><i class="ph ph-user-plus"></i> create user</button>
|
<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>
|
<span id="create-user-status" class="status"></span>
|
||||||
@@ -179,7 +185,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div id="toast-stack"></div>
|
||||||
|
|
||||||
<script src="ui/app.js"></script>
|
<script src="ui/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+478
-195
@@ -5,51 +5,114 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg:#1e293b; --surface:#273349; --raised:#2e3d56; --border:#384a64;
|
--bg:#1e293b; --surface:#273349; --raised:#2e3d56; --border:#384a64;
|
||||||
--dim:#64748b; --muted:#94a3b8; --text:#cbd5e1; --bright:#f1f5f9;
|
--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;
|
--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; }
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
body {
|
body {
|
||||||
font-family:'JetBrains Mono', monospace;
|
font-family:var(--mono);
|
||||||
background:var(--bg); color:var(--text);
|
background:var(--bg); color:var(--text);
|
||||||
min-height:100dvh; display:flex; flex-direction:column;
|
min-height:100dvh; display:flex; flex-direction:column;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
}
|
}
|
||||||
.pixel { font-family:var(--pixel); }
|
.pixel { font-family:var(--pixel); }
|
||||||
.hidden { display:none !important; }
|
.hidden { display:none !important; }
|
||||||
.shrink { flex-shrink:0; }
|
.shrink { flex-shrink:0; }
|
||||||
|
.spacer { flex:1; }
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
|
Header
|
||||||
|
───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* ── Header ── */
|
|
||||||
header {
|
header {
|
||||||
padding:12px 20px; border-bottom:1px solid var(--border);
|
padding:12px 20px; border-bottom:1px solid var(--border);
|
||||||
display:flex; align-items:center; gap:12px; flex-shrink:0;
|
display:flex; align-items:center; gap:12px; flex-shrink:0;
|
||||||
background:var(--surface);
|
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 {
|
header .dot {
|
||||||
width:8px; height:8px; border-radius:50%;
|
width:8px; height:8px; border-radius:50%;
|
||||||
background:var(--accent); animation:pulse 2s ease-in-out infinite;
|
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 {
|
header .user {
|
||||||
font-family:var(--pixel); font-size:10px; color:var(--muted);
|
font-family:var(--pixel); font-size:10px; color:var(--muted);
|
||||||
display:flex; align-items:center; gap:6px;
|
display:flex; align-items:center; gap:6px;
|
||||||
}
|
}
|
||||||
header .user .badge {
|
header .user .badge {
|
||||||
background:var(--accent); color:var(--bg);
|
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 {
|
header .nav-btn {
|
||||||
background:none; border:1px solid var(--border); color:var(--muted);
|
background:none; border:1px solid var(--border); color:var(--muted);
|
||||||
cursor:pointer; padding:5px 10px;
|
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;
|
transition:all .15s;
|
||||||
}
|
}
|
||||||
header .nav-btn:hover { color:var(--accent); border-color:var(--accent); }
|
header .nav-btn:hover {
|
||||||
header .nav-btn.active { background:var(--accent); color:var(--bg); border-color:var(--accent); }
|
color:var(--accent); border-color:var(--accent);
|
||||||
header .nav-btn.danger:hover { color:var(--danger); border-color:var(--danger); }
|
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 {
|
main {
|
||||||
flex:1; display:flex; flex-direction:column;
|
flex:1; display:flex; flex-direction:column;
|
||||||
padding:20px; gap:16px; max-width:920px; width:100%;
|
padding:20px; gap:16px; max-width:920px; width:100%;
|
||||||
@@ -57,97 +120,183 @@ main {
|
|||||||
}
|
}
|
||||||
@media (max-width: 600px) { main { padding:12px; gap:12px; } }
|
@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 {
|
.auth-form {
|
||||||
display:flex; flex-direction:column; align-items:center;
|
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 { 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 .btn { width:100%; }
|
||||||
.auth-form .err {
|
.auth-form .err {
|
||||||
font-family:var(--pixel); font-size:9px; color:var(--danger);
|
font-family:var(--pixel); font-size:9px; color:var(--danger);
|
||||||
min-height:11px; text-align:center;
|
min-height:11px; text-align:center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons ── */
|
/* Flash-highlight: a brief accent pulse on a card after the user
|
||||||
.btn {
|
tapped the header inbox link and we just scrolled it into view. */
|
||||||
padding:10px 18px; border:none; border-radius:3px;
|
@keyframes flash-highlight {
|
||||||
font-family:var(--pixel); font-size:11px; cursor:pointer;
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); }
|
||||||
transition:all .15s;
|
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) {
|
Inbox + lists
|
||||||
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-item {
|
.inbox-item {
|
||||||
display:flex; align-items:flex-start; gap:10px;
|
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:last-child { border-bottom:none; }
|
||||||
.inbox-item .meta {
|
.inbox-item .meta {
|
||||||
@@ -163,113 +312,277 @@ main {
|
|||||||
font-size:11px; color:var(--text); white-space:pre-wrap;
|
font-size:11px; color:var(--text); white-space:pre-wrap;
|
||||||
word-break:break-word; max-height:120px; overflow:auto;
|
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 {
|
.inbox-item .del {
|
||||||
background:none; border:none; color:var(--dim); cursor:pointer;
|
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 {
|
.user-row {
|
||||||
display:flex; align-items:center; gap:12px;
|
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:last-child { border-bottom:none; }
|
||||||
.user-row .info { flex:1; }
|
.user-row .info { flex:1; }
|
||||||
.user-row .name { font-family:var(--pixel); font-size:11px; color:var(--bright); }
|
.user-row .name {
|
||||||
.user-row .meta { font-size:10px; color:var(--muted); margin-top:2px; }
|
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 {
|
.user-row .badge {
|
||||||
background:var(--accent); color:var(--bg);
|
background:var(--accent); color:var(--bg);
|
||||||
padding:1px 6px; font-family:var(--pixel); font-size:8px;
|
padding:2px 7px; font-family:var(--pixel); font-size:8px;
|
||||||
text-transform:uppercase; border-radius:2px;
|
text-transform:uppercase; border-radius:var(--radius);
|
||||||
}
|
}
|
||||||
.user-row .actions { display:flex; gap:6px; }
|
.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 {
|
.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;
|
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 {
|
.create-user-form .actions {
|
||||||
display:flex; gap:8px; align-items:center;
|
display:flex; gap:8px; align-items:center; margin-top:4px;
|
||||||
margin-top:4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
/* ─────────────────────────────────────────────────────────────────
|
||||||
|
Toast — single + stack support
|
||||||
|
───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
#toast-stack {
|
||||||
position:fixed; top:20px; right:20px;
|
position:fixed; top:20px; right:20px;
|
||||||
background:var(--accent); color:var(--bg);
|
display:flex; flex-direction:column; gap:8px;
|
||||||
padding:10px 18px; border-radius:3px;
|
z-index:200; pointer-events:none;
|
||||||
font-family:var(--pixel); font-size:11px;
|
|
||||||
opacity:0; transition:opacity .3s; z-index:100; pointer-events:none;
|
|
||||||
}
|
}
|
||||||
.toast.show { opacity:1; }
|
.toast {
|
||||||
.toast.err { background:var(--danger); }
|
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); }
|
Notices
|
||||||
.notice.warn { background:rgba(251,113,133,0.1); border:1px solid var(--danger); color:var(--danger); }
|
───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.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; }
|
.ph { display:inline-block; vertical-align:-2px; }
|
||||||
header .nav-btn .ph { font-size:11px; vertical-align:-1px; margin-right: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; }
|
||||||
.btn .ph { font-size:12px; vertical-align:-2px; margin-right:2px; }
|
|
||||||
.section h2 .ph, .compose-card h2 .ph { font-size:13px; 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) {
|
Default nav layout (desktop)
|
||||||
#nav-admin { display:none !important; }
|
───────────────────────────────────────────────────────────────── */
|
||||||
#view-admin { display:none !important; }
|
|
||||||
#nav-user { display:none !important; }
|
#nav-buttons:not(.hidden) {
|
||||||
#nav-compose { display:none !important; }
|
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) {
|
@media (min-width: 901px) {
|
||||||
|
|
||||||
/* Subtle background gradient gives the page some depth */
|
|
||||||
body {
|
body {
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at top left, rgba(56,189,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.04), transparent 50%),
|
radial-gradient(ellipse at bottom right, rgba(129,140,248,0.05), transparent 50%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wider canvas, more breathing room */
|
|
||||||
main {
|
main {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
padding: 32px 32px 48px;
|
padding: 32px 32px 48px;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header: bigger title, more padding */
|
|
||||||
header {
|
header {
|
||||||
padding: 14px 32px;
|
padding: 14px 32px;
|
||||||
}
|
}
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
header .brand-icon { font-size:20px; }
|
||||||
header .nav-btn {
|
header .nav-btn {
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
}
|
}
|
||||||
@@ -277,10 +590,10 @@ header h1 + .ph { font-size:12px; }
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
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) {
|
#view-compose:not(.hidden) {
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
|
||||||
@@ -292,31 +605,28 @@ header h1 + .ph { font-size:12px; }
|
|||||||
.compose-card,
|
.compose-card,
|
||||||
.section {
|
.section {
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
box-shadow:
|
box-shadow: var(--shadow-card);
|
||||||
0 1px 2px rgba(0,0,0,0.3),
|
|
||||||
0 8px 24px -8px rgba(0,0,0,0.4);
|
|
||||||
border-color: color-mix(in srgb, var(--border) 70%, var(--accent) 12%);
|
border-color: color-mix(in srgb, var(--border) 70%, var(--accent) 12%);
|
||||||
}
|
}
|
||||||
.compose-card h2,
|
.compose-card h2,
|
||||||
.section h2 {
|
.section h2 {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 6px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Big textarea for desktop typing */
|
.compose-card .field textarea {
|
||||||
.compose-card textarea {
|
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
.compose-card input,
|
.compose-card .field input,
|
||||||
.compose-card select {
|
.compose-card .field select {
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.compose-card .field label {
|
.compose-card .field > label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
@@ -330,11 +640,11 @@ header h1 + .ph { font-size:12px; }
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inbox card: stick to top of viewport, hover lifts it */
|
/* Inbox card sticks under the header */
|
||||||
#inbox-section {
|
#inbox-section {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 24px;
|
top: 24px;
|
||||||
transition: transform .15s, box-shadow .15s;
|
transition: box-shadow .15s;
|
||||||
}
|
}
|
||||||
#inbox-section:hover {
|
#inbox-section:hover {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -343,7 +653,7 @@ header h1 + .ph { font-size:12px; }
|
|||||||
0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
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 {
|
#view-admin:not(.hidden) .section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 360px;
|
grid-template-columns: minmax(0, 1fr) 360px;
|
||||||
@@ -364,44 +674,17 @@ header h1 + .ph { font-size:12px; }
|
|||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User row polish: hover lift */
|
|
||||||
.user-row {
|
.user-row {
|
||||||
padding: 14px 12px;
|
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 {
|
.auth-form {
|
||||||
margin-top: 64px;
|
margin-top: 88px;
|
||||||
padding: 40px 32px;
|
padding: 44px 36px;
|
||||||
background: var(--surface);
|
max-width: 440px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.auth-form h2 {
|
.auth-form h2 { font-size: 16px; margin-bottom: 8px; }
|
||||||
font-size: 14px;
|
.auth-form .auth-icon { font-size: 44px; }
|
||||||
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); }
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user