Redesign visitor log as readable cards replacing dense table

Each visit is now a card showing name/IP, page path, location, device,
visit count, new/returning tag, and timestamp at a glance. Logged-in users
show their display name prominently. Click to expand still shows full page
history and raw details. Adds a purple "Logged in" tag for study users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-17 14:24:16 -04:00
parent 31426fccba
commit 945a2e160e
2 changed files with 185 additions and 69 deletions
+124
View File
@@ -7886,6 +7886,130 @@
color: var(--brand-gold);
}
/* ── Visitor cards ── */
.admin-visitor-cards {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.5rem;
}
.admin-visitor-card {
border: 1px solid rgba(201, 168, 76, 0.12);
border-radius: 0.5rem;
background: #0d0d0a;
overflow: hidden;
transition: border-color 0.15s;
}
.admin-visitor-card--expanded {
border-color: rgba(201, 168, 76, 0.35);
}
.admin-visitor-card__main {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.65rem 1rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: inherit;
}
.admin-visitor-card__main:hover {
background: rgba(201, 168, 76, 0.04);
}
.admin-visitor-card__left {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.admin-visitor-card__name {
font-size: 0.88rem;
font-weight: 600;
color: var(--brand-warm-white);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-visitor-card__path {
font-size: 0.78rem;
color: var(--brand-gold);
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
}
.admin-visitor-card__right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
flex-shrink: 0;
}
.admin-visitor-card__meta {
font-size: 0.76rem;
color: #8a7f5a;
white-space: nowrap;
}
.admin-visitor-card__tags {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.admin-visitor-card__time {
font-size: 0.72rem;
color: #666;
}
.admin-visitor-card__chevron {
font-size: 0.65rem;
color: #555;
flex-shrink: 0;
margin-left: 0.25rem;
}
.admin-visitor-tag--user {
background: #1a1a3a;
color: #8888dd;
border: 1px solid #2a2a5a;
}
.admin-visitor-card__detail {
padding: 0.75rem 1rem 1rem;
border-top: 1px solid rgba(201, 168, 76, 0.1);
background: #080807;
}
.admin-visitor-card__detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-bottom: 0.75rem;
font-size: 0.78rem;
color: #8a7f5a;
}
.admin-visitor-card__detail-meta strong {
color: #b0a48c;
font-weight: 600;
}
/* ── Podcast hub tabs ── */
.admin-tabs--podcast {
margin-bottom: 2rem;
+61 -69
View File
@@ -450,76 +450,68 @@ export function AnalyticsPanel({
{stats.visitors.recentVisits.length === 0 ? (
<p className="admin-stats-note">No visitor records yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>User</th>
<th>Country</th>
<th>Path</th>
<th>Referrer</th>
<th>Device</th>
<th>Returning</th>
<th>Visit #</th>
</tr>
</thead>
<tbody>
{stats.visitors.recentVisits.map(row => {
const rowKey = `${row.visitorId}-${row.at}`
const isExpanded = expandedVisitor === rowKey
return (
<>
<tr
key={rowKey}
className={`admin-visitor-row${isExpanded ? ' admin-visitor-row--expanded' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
title="Click to view page history"
>
<td>{formatDate(row.at)}</td>
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
<td>{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : ''}</td>
<td>{row.country || '—'}</td>
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
<td>{row.device || '—'}</td>
<td>
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
{row.returningVisitor ? 'Returning' : 'New'}
</span>
</td>
<td className="admin-visitor-count">{row.visitCount} {isExpanded ? '▲' : '▼'}</td>
</tr>
{isExpanded && (row.pageHistory ?? []).length > 0 && (
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
<td colSpan={9}>
<div className="admin-visitor-history">
<p className="admin-visitor-history-label">Full page history for this visitor ({(row.pageHistory ?? []).length} pages):</p>
<ol className="admin-visitor-history-list">
{[...(row.pageHistory ?? [])].reverse().map((entry, i) => (
<li key={i} className="admin-visitor-history-entry">
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
<span className="admin-visitor-history-path">{entry.path}</span>
{entry.referrer && <span className="admin-visitor-history-ref">from {entry.referrer}</span>}
</li>
))}
</ol>
</div>
</td>
</tr>
<div className="admin-visitor-cards">
{stats.visitors.recentVisits.map(row => {
const rowKey = `${row.visitorId}-${row.at}`
const isExpanded = expandedVisitor === rowKey
const history = row.pageHistory ?? []
const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location'
return (
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
<button
className="admin-visitor-card__main"
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
aria-expanded={isExpanded}
>
<div className="admin-visitor-card__left">
<span className="admin-visitor-card__name">
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>}
</span>
<span className="admin-visitor-card__path">{row.path}</span>
</div>
<div className="admin-visitor-card__right">
<span className="admin-visitor-card__meta">{location}</span>
<span className="admin-visitor-card__meta">{row.device || 'unknown'} · visit #{row.visitCount}</span>
<div className="admin-visitor-card__tags">
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
{row.returningVisitor ? 'Returning' : 'New'}
</span>
{row.studyUser && <span className="admin-visitor-tag admin-visitor-tag--user">Logged in</span>}
<span className="admin-visitor-card__time">{formatDate(row.at)}</span>
</div>
</div>
<span className="admin-visitor-card__chevron">{isExpanded ? '▲' : ''}</span>
</button>
{isExpanded && (
<div className="admin-visitor-card__detail">
<div className="admin-visitor-card__detail-meta">
<span><strong>IP</strong> {maskIp(row.ip)}</span>
{row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>}
{row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>}
<span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span>
</div>
{history.length > 0 ? (
<>
<p className="admin-visitor-history-label">Page history ({history.length} pages)</p>
<ol className="admin-visitor-history-list">
{[...history].reverse().map((entry, i) => (
<li key={i} className="admin-visitor-history-entry">
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
<span className="admin-visitor-history-path">{entry.path}</span>
{entry.referrer && <span className="admin-visitor-history-ref">from {entry.referrer}</span>}
</li>
))}
</ol>
</>
) : (
<p style={{ color: '#8a7f5a', fontSize: '0.82rem', margin: 0 }}>No page history yet recorded after consent is given.</p>
)}
{isExpanded && (row.pageHistory ?? []).length === 0 && (
<tr key={`${rowKey}-history-empty`} className="admin-visitor-history-row">
<td colSpan={8}><p style={{ padding: '0.5rem 1rem', color: '#8a7f5a', margin: 0, fontSize: '0.85rem' }}>No page history yet history is built from SPA navigations after consent.</p></td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
)}
</div>