feat: senders master-detail layout with inline email view
Left panel: scrollable sender list with filter, email count, unread badge. Right panel: infinite-scroll email list, quick actions, click row to view detail (subject, meta, snippet, open in Gmail). Back button returns to list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+277
-58
@@ -1,69 +1,288 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||||
import { AnalyticsApi } from '../api/client.js';
|
import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js';
|
||||||
|
|
||||||
// Volume tiers, highest first.
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
const TIERS = [
|
|
||||||
{ key: '1000+', label: '1000+ emails', min: 1000, max: Infinity, accent: '#eb5757' },
|
|
||||||
{ key: '500-1000', label: '500 – 1000', min: 500, max: 999, accent: '#f2994a' },
|
|
||||||
{ key: '250-500', label: '250 – 500', min: 250, max: 499, accent: '#f2c94c' },
|
|
||||||
{ key: '100-250', label: '100 – 250', min: 100, max: 249, accent: '#56ccf2' },
|
|
||||||
{ key: '<100', label: 'Under 100', min: 0, max: 99, accent: '#6fcf97' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const PER_TIER = 60; // cap visible blocks per tier to keep it light
|
const fmtDate = (iso) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const diffDays = (now - d) / 86400000;
|
||||||
|
if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
||||||
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
export default function Senders() {
|
const fmtSize = (b) => {
|
||||||
const [senders, setSenders] = useState(null);
|
if (!b) return '';
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(b / 1048576).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => { AnalyticsApi.allSenders().then(setSenders).catch(() => setSenders([])); }, []);
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
const buckets = useMemo(() => {
|
// ── SenderList (left panel) ───────────────────────────────────────────────────
|
||||||
const map = Object.fromEntries(TIERS.map((t) => [t.key, []]));
|
|
||||||
(senders || []).forEach((s) => {
|
|
||||||
const tier = TIERS.find((t) => s.emailCount >= t.min && s.emailCount <= t.max);
|
|
||||||
if (tier) map[tier.key].push(s);
|
|
||||||
});
|
|
||||||
Object.values(map).forEach((arr) => arr.sort((a, b) => b.emailCount - a.emailCount));
|
|
||||||
return map;
|
|
||||||
}, [senders]);
|
|
||||||
|
|
||||||
if (!senders) return <div className="page"><h2>Senders by Volume</h2><p className="muted">Loading…</p></div>;
|
function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
|
||||||
|
const filtered = senders.filter((s) => {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return !q || (s.address + ' ' + (s.displayName ?? '')).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page senders-page">
|
<div className="sl-panel">
|
||||||
<h2>Senders by Volume</h2>
|
<div className="sl-search-wrap">
|
||||||
<p className="muted">Who fills your inbox, grouped by how many emails they've sent you.</p>
|
<input
|
||||||
|
className="sl-search"
|
||||||
{TIERS.map((tier) => {
|
type="search"
|
||||||
const list = buckets[tier.key];
|
placeholder="Filter senders…"
|
||||||
if (!list.length) return null;
|
value={search}
|
||||||
const shown = list.slice(0, PER_TIER);
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
return (
|
aria-label="Filter senders"
|
||||||
<section className="tier" key={tier.key}>
|
/>
|
||||||
<div className="tier-head">
|
</div>
|
||||||
<span className="tier-dot" style={{ background: tier.accent }} />
|
<div className="sl-count">{filtered.length.toLocaleString()} sender{filtered.length !== 1 ? 's' : ''}</div>
|
||||||
<h3>{tier.label}</h3>
|
<ul className="sl-list">
|
||||||
<span className="tier-count">{list.length} sender{list.length === 1 ? '' : 's'}</span>
|
{filtered.map((s) => (
|
||||||
</div>
|
<li key={s.senderId}>
|
||||||
<div className="sender-blocks">
|
<button
|
||||||
{shown.map((s) => (
|
className={`sl-item${selectedId === s.senderId ? ' sl-item--active' : ''}`}
|
||||||
<div className="sender-block" key={s.senderId} style={{ borderTopColor: tier.accent }}>
|
onClick={() => onSelect(s)}
|
||||||
<div className="sb-name" title={s.address}>{s.displayName || s.address}</div>
|
>
|
||||||
<div className="sb-domain">{s.domain}</div>
|
<div className="sl-name">{s.displayName || s.address}</div>
|
||||||
<div className="sb-stats">
|
<div className="sl-addr">{s.displayName ? s.address : s.domain}</div>
|
||||||
<span className="sb-count">{s.emailCount.toLocaleString()}</span>
|
<div className="sl-meta">
|
||||||
{s.unreadCount > 0 && <span className="sb-unread">{s.unreadCount} unread</span>}
|
<span className="sl-email-count">{s.emailCount.toLocaleString()}</span>
|
||||||
{s.hasUnsubscribe && <span className="sb-unsub">unsub</span>}
|
{s.unreadCount > 0 && <span className="sl-unread">{s.unreadCount} unread</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
</li>
|
||||||
</div>
|
))}
|
||||||
{list.length > PER_TIER && <div className="muted tier-more">+ {list.length - PER_TIER} more</div>}
|
</ul>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
{senders.length === 0 && <p className="muted">No senders yet — run a sync first.</p>}
|
// ── EmailDetail (full single-email view) ──────────────────────────────────────
|
||||||
|
|
||||||
|
function EmailDetail({ email, onBack }) {
|
||||||
|
return (
|
||||||
|
<div className="sd-detail">
|
||||||
|
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||||||
|
<div className="sd-detail-header">
|
||||||
|
<div className="sd-detail-subject">{email.subject || '(no subject)'}</div>
|
||||||
|
<div className="sd-detail-meta">
|
||||||
|
<span>{email.senderDisplayName || email.senderAddress}</span>
|
||||||
|
<span className="sd-detail-sep">·</span>
|
||||||
|
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
|
||||||
|
{email.sizeEstimateBytes > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="sd-detail-sep">·</span>
|
||||||
|
<span>{fmtSize(email.sizeEstimateBytes)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{email.snippet && (
|
||||||
|
<div className="sd-snippet">{email.snippet}</div>
|
||||||
|
)}
|
||||||
|
<div className="sd-detail-actions">
|
||||||
|
<button
|
||||||
|
className="btn-sm"
|
||||||
|
onClick={() => window.open(
|
||||||
|
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
|
||||||
|
'_blank', 'noopener,noreferrer'
|
||||||
|
)}
|
||||||
|
>Open in Gmail ↗</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EmailListRow (row inside sender email list) ───────────────────────────────
|
||||||
|
|
||||||
|
function EmailListRow({ email: initial, onRemove, onOpen }) {
|
||||||
|
const [email, setEmail] = useState(initial);
|
||||||
|
const [acting, setActing] = useState(false);
|
||||||
|
|
||||||
|
const act = (fn, patch) => async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (acting) return;
|
||||||
|
setActing(true);
|
||||||
|
try { await fn(email.id); setEmail((p) => ({ ...p, ...patch })); }
|
||||||
|
finally { setActing(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrash = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (acting) return;
|
||||||
|
setActing(true);
|
||||||
|
try { await EmailApi.trash(email.id); onRemove?.(email.id); }
|
||||||
|
finally { setActing(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}`}
|
||||||
|
onClick={() => onOpen(email)}
|
||||||
|
title="View email"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
|
||||||
|
<td className="el-subject">
|
||||||
|
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
|
||||||
|
{email.snippet && <span className="el-snippet"> — {email.snippet}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="el-meta">
|
||||||
|
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
||||||
|
{email.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(email.sizeEstimateBytes)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="el-date">{fmtDate(email.sentAtUtc)}</td>
|
||||||
|
<td className="el-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="action-btn"
|
||||||
|
title={email.isUnread ? 'Mark as read' : 'Mark as unread'}
|
||||||
|
onClick={email.isUnread
|
||||||
|
? act(EmailApi.markRead, { isUnread: false })
|
||||||
|
: act(EmailApi.markUnread, { isUnread: true })}
|
||||||
|
>{email.isUnread ? '✓' : '●'}</button>
|
||||||
|
<button
|
||||||
|
className={`action-btn${email.isStarred ? ' action-btn--active' : ''}`}
|
||||||
|
title={email.isStarred ? 'Unstar' : 'Star'}
|
||||||
|
onClick={email.isStarred
|
||||||
|
? act(EmailApi.unstar, { isStarred: false })
|
||||||
|
: act(EmailApi.star, { isStarred: true })}
|
||||||
|
>⭐</button>
|
||||||
|
<button
|
||||||
|
className="action-btn action-btn--danger"
|
||||||
|
title="Move to trash"
|
||||||
|
onClick={handleTrash}
|
||||||
|
>🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SenderEmails (right panel) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SenderEmails({ sender }) {
|
||||||
|
const [emails, setEmails] = useState([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [openEmail, setOpenEmail] = useState(null);
|
||||||
|
const sentinelRef = useRef(null);
|
||||||
|
|
||||||
|
// Reset when sender changes
|
||||||
|
useEffect(() => {
|
||||||
|
setEmails([]);
|
||||||
|
setTotalCount(null);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
setOpenEmail(null);
|
||||||
|
}, [sender.senderId]);
|
||||||
|
|
||||||
|
const fetchPage = useCallback((p) => {
|
||||||
|
setLoading(true);
|
||||||
|
SearchApi.query(`from:${sender.address}`, p, PAGE_SIZE)
|
||||||
|
.then((r) => {
|
||||||
|
setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]);
|
||||||
|
setTotalCount(r.totalCount);
|
||||||
|
setHasMore(p < r.totalPages);
|
||||||
|
setPage(p);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [sender]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPage(1); }, [fetchPage]);
|
||||||
|
|
||||||
|
// Infinite scroll sentinel
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
(entries) => { if (entries[0].isIntersecting && hasMore && !loading) fetchPage(page + 1); },
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
);
|
||||||
|
obs.observe(el);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, [hasMore, loading, page, fetchPage]);
|
||||||
|
|
||||||
|
if (openEmail) {
|
||||||
|
return <EmailDetail email={openEmail} onBack={() => setOpenEmail(null)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sd-panel">
|
||||||
|
<div className="sd-panel-header">
|
||||||
|
<div className="sd-panel-name">{sender.displayName || sender.address}</div>
|
||||||
|
<div className="sd-panel-addr">{sender.displayName ? sender.address : ''}</div>
|
||||||
|
{totalCount !== null && (
|
||||||
|
<div className="sd-panel-count">{totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{emails.length > 0 && (
|
||||||
|
<table className="email-list">
|
||||||
|
<tbody>
|
||||||
|
{emails.map((e) => (
|
||||||
|
<EmailListRow
|
||||||
|
key={e.id}
|
||||||
|
email={e}
|
||||||
|
onOpen={setOpenEmail}
|
||||||
|
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||||
|
{!loading && !hasMore && emails.length > 0 && (
|
||||||
|
<div className="fv-end">— {emails.length.toLocaleString()} emails —</div>
|
||||||
|
)}
|
||||||
|
{!loading && emails.length === 0 && (
|
||||||
|
<div className="fv-empty">No emails found from this sender.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Senders() {
|
||||||
|
const [senders, setSenders] = useState(null);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
AnalyticsApi.allSenders().then((list) => {
|
||||||
|
setSenders(list);
|
||||||
|
if (list.length > 0) setSelected(list[0]);
|
||||||
|
}).catch(() => setSenders([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!senders) return <div className="page"><h2>Senders</h2><p className="muted">Loading…</p></div>;
|
||||||
|
|
||||||
|
if (senders.length === 0) return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>Senders</h2>
|
||||||
|
<p className="muted">No senders yet — run a sync first.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="senders-master-detail">
|
||||||
|
<SenderList
|
||||||
|
senders={senders}
|
||||||
|
selectedId={selected?.senderId}
|
||||||
|
onSelect={setSelected}
|
||||||
|
search={search}
|
||||||
|
onSearch={setSearch}
|
||||||
|
/>
|
||||||
|
{selected && <SenderEmails key={selected.senderId} sender={selected} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-16
@@ -160,22 +160,85 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
|
|||||||
}
|
}
|
||||||
.react-grid-item.react-grid-placeholder { background: var(--accent); opacity: 0.25; border-radius: 10px; }
|
.react-grid-item.react-grid-placeholder { background: var(--accent); opacity: 0.25; border-radius: 10px; }
|
||||||
|
|
||||||
/* Senders by volume */
|
/* ── Senders master-detail ─────────────────────────────────────────────── */
|
||||||
.senders-page { max-width: 1100px; }
|
.senders-master-detail {
|
||||||
.tier { margin-top: 22px; }
|
display: grid;
|
||||||
.tier-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
grid-template-columns: 280px 1fr;
|
||||||
.tier-head h3 { margin: 0; font-size: 16px; }
|
height: calc(100vh - 56px); /* fill below topbar */
|
||||||
.tier-dot { width: 12px; height: 12px; border-radius: 50%; }
|
overflow: hidden;
|
||||||
.tier-count { color: var(--muted); font-size: 12px; }
|
}
|
||||||
.sender-blocks { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
|
||||||
.sender-block { background: var(--panel); border: 1px solid #2c3550; border-top: 3px solid var(--accent); border-radius: 8px; padding: 10px 12px; }
|
/* Left panel — sender list */
|
||||||
.sb-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sl-panel {
|
||||||
.sb-domain { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; }
|
display: flex;
|
||||||
.sb-stats { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
flex-direction: column;
|
||||||
.sb-count { font-size: 20px; font-weight: 700; }
|
border-right: 1px solid #2c3550;
|
||||||
.sb-unread { font-size: 11px; color: var(--accent); }
|
overflow: hidden;
|
||||||
.sb-unsub { font-size: 10px; color: var(--muted); border: 1px solid #36405c; border-radius: 4px; padding: 0 4px; }
|
}
|
||||||
.tier-more { margin-top: 8px; }
|
.sl-search-wrap { padding: 10px 12px 6px; }
|
||||||
|
.sl-search {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid #2c3550;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sl-count { padding: 0 14px 6px; font-size: 11px; color: var(--muted); }
|
||||||
|
.sl-list { flex: 1; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.sl-item {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid #1e2540;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sl-item:hover { background: var(--panel-2); }
|
||||||
|
.sl-item--active { background: var(--panel-2); border-left: 3px solid var(--accent); padding-left: 11px; }
|
||||||
|
.sl-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sl-addr { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 3px; }
|
||||||
|
.sl-meta { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.sl-email-count { font-size: 13px; font-weight: 700; color: var(--accent); }
|
||||||
|
.sl-unread { font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Right panel — email list for selected sender */
|
||||||
|
.sd-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.sd-panel-header {
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
border-bottom: 1px solid #2c3550;
|
||||||
|
background: var(--panel);
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
}
|
||||||
|
.sd-panel-name { font-size: 16px; font-weight: 700; }
|
||||||
|
.sd-panel-addr { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
.sd-panel-count { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Single email detail view */
|
||||||
|
.sd-detail { padding: 20px; max-width: 760px; }
|
||||||
|
.sd-back {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
background: none; border: 1px solid #2c3550; border-radius: 6px;
|
||||||
|
color: var(--text); font-size: 13px; cursor: pointer;
|
||||||
|
padding: 5px 12px; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.sd-back:hover { background: var(--panel-2); }
|
||||||
|
.sd-detail-header { margin-bottom: 14px; }
|
||||||
|
.sd-detail-subject { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
|
||||||
|
.sd-detail-meta { font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||||
|
.sd-detail-sep { opacity: 0.4; }
|
||||||
|
.sd-snippet { background: var(--panel); border: 1px solid #2c3550; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; }
|
||||||
|
.sd-detail-actions { display: flex; gap: 10px; }
|
||||||
|
.btn-sm {
|
||||||
|
background: var(--accent); color: #fff; border: none; border-radius: 6px;
|
||||||
|
padding: 7px 14px; font-size: 13px; cursor: pointer; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-sm:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.page { max-width: 900px; }
|
.page { max-width: 900px; }
|
||||||
.form-row { display: flex; gap: 10px; margin: 14px 0; }
|
.form-row { display: flex; gap: 10px; margin: 14px 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user