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:
+124
@@ -7886,6 +7886,130 @@
|
|||||||
color: var(--brand-gold);
|
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 ── */
|
/* ── Podcast hub tabs ── */
|
||||||
.admin-tabs--podcast {
|
.admin-tabs--podcast {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|||||||
@@ -450,55 +450,52 @@ export function AnalyticsPanel({
|
|||||||
{stats.visitors.recentVisits.length === 0 ? (
|
{stats.visitors.recentVisits.length === 0 ? (
|
||||||
<p className="admin-stats-note">No visitor records yet.</p>
|
<p className="admin-stats-note">No visitor records yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-visits-table-scroll">
|
<div className="admin-visitor-cards">
|
||||||
<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 => {
|
{stats.visitors.recentVisits.map(row => {
|
||||||
const rowKey = `${row.visitorId}-${row.at}`
|
const rowKey = `${row.visitorId}-${row.at}`
|
||||||
const isExpanded = expandedVisitor === rowKey
|
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 (
|
return (
|
||||||
<>
|
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
|
||||||
<tr
|
<button
|
||||||
key={rowKey}
|
className="admin-visitor-card__main"
|
||||||
className={`admin-visitor-row${isExpanded ? ' admin-visitor-row--expanded' : ''}`}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
|
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
|
||||||
title="Click to view page history"
|
aria-expanded={isExpanded}
|
||||||
>
|
>
|
||||||
<td>{formatDate(row.at)}</td>
|
<div className="admin-visitor-card__left">
|
||||||
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
|
<span className="admin-visitor-card__name">
|
||||||
<td>{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : '—'}</td>
|
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>}
|
||||||
<td>{row.country || '—'}</td>
|
</span>
|
||||||
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
|
<span className="admin-visitor-card__path">{row.path}</span>
|
||||||
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
|
</div>
|
||||||
<td>{row.device || '—'}</td>
|
<div className="admin-visitor-card__right">
|
||||||
<td>
|
<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'}`}>
|
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
|
||||||
{row.returningVisitor ? 'Returning' : 'New'}
|
{row.returningVisitor ? 'Returning' : 'New'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
{row.studyUser && <span className="admin-visitor-tag admin-visitor-tag--user">Logged in</span>}
|
||||||
<td className="admin-visitor-count">{row.visitCount} {isExpanded ? '▲' : '▼'}</td>
|
<span className="admin-visitor-card__time">{formatDate(row.at)}</span>
|
||||||
</tr>
|
</div>
|
||||||
{isExpanded && (row.pageHistory ?? []).length > 0 && (
|
</div>
|
||||||
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
|
<span className="admin-visitor-card__chevron">{isExpanded ? '▲' : '▼'}</span>
|
||||||
<td colSpan={9}>
|
</button>
|
||||||
<div className="admin-visitor-history">
|
|
||||||
<p className="admin-visitor-history-label">Full page history for this visitor ({(row.pageHistory ?? []).length} pages):</p>
|
{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">
|
<ol className="admin-visitor-history-list">
|
||||||
{[...(row.pageHistory ?? [])].reverse().map((entry, i) => (
|
{[...history].reverse().map((entry, i) => (
|
||||||
<li key={i} className="admin-visitor-history-entry">
|
<li key={i} className="admin-visitor-history-entry">
|
||||||
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
|
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
|
||||||
<span className="admin-visitor-history-path">{entry.path}</span>
|
<span className="admin-visitor-history-path">{entry.path}</span>
|
||||||
@@ -506,20 +503,15 @@ export function AnalyticsPanel({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#8a7f5a', fontSize: '0.82rem', margin: 0 }}>No page history yet — recorded after consent is given.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user