Expand admin dashboard with trends, health, study and email insights; v2.8.0
Publish Container / build (push) Successful in 15m53s
Publish Container / build (push) Successful in 15m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dad0e5691e
commit
134a2c403e
7 files changed
+645
-2
No files matched your search
@@ -7,6 +7,7 @@ import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, Stud
|
||||
import { DEFAULTS } from './content'
|
||||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||||
import { AdminDashboardExtras } from './components/AdminDashboardExtras'
|
||||
import { useAutosave } from './hooks/useAutosave'
|
||||
import QRCode from 'qrcode'
|
||||
import InboxPanel, { type InboxSubmission } from './InboxPanel'
|
||||
@@ -3385,6 +3386,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminDashboardExtras
|
||||
publishedAt={publishState.publishedAt}
|
||||
draftUpdatedAt={publishState.draftUpdatedAt}
|
||||
isDirty={isDirty}
|
||||
onNavigate={view => navigateTo(view as AdminView)}
|
||||
onRequestPublish={() => setPublishConfirmOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="admin-dashboard-grid">
|
||||
<div className={`admin-dashboard-card${unansweredCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
||||
<div className="admin-dashboard-card-value">{unansweredCount}</div>
|
||||
|
||||
+97
@@ -12992,3 +12992,100 @@
|
||||
.cmp-rows li input[type="checkbox"] { flex-shrink: 0; margin-top: 0.15rem; }
|
||||
.cmp-selectall { opacity: 0.85; font-weight: 600; }
|
||||
.cmp-selectall label { display: inline-flex; gap: 0.4rem; align-items: center; cursor: pointer; }
|
||||
|
||||
/* ── Dashboard extras ── */
|
||||
.admin-dashboard-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border: 1px solid rgba(201,168,76,0.45);
|
||||
background: rgba(201,168,76,0.08);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-quick-label {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(240,234,216,0.55);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-attention {
|
||||
margin-top: 1.25rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid rgba(201,168,76,0.4);
|
||||
background: rgba(201,168,76,0.06);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-attention ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-attention li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-section-title {
|
||||
margin: 1.75rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(240,234,216,0.55);
|
||||
}
|
||||
|
||||
.admin-dashboard-section-title + .admin-dashboard-grid {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-spark {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
color: var(--brand-gold);
|
||||
opacity: 0.8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-delta--up { color: #7fc88f; }
|
||||
.admin-dashboard-delta--down { color: #e08a7a; }
|
||||
.admin-dashboard-delta--flat { color: rgba(240,234,216,0.5); }
|
||||
|
||||
.admin-dashboard-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-dashboard-row > span:first-child {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-dashboard-stale {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: #e0c070;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Trend { thisWeek: number; lastWeek: number; series: number[] }
|
||||
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
|
||||
|
||||
export interface DashboardData {
|
||||
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
|
||||
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
|
||||
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
|
||||
studyProgress: {
|
||||
studies: { slug: string; title: string; enrolled: number; lessonsCompleted: number; certificates: number }[]
|
||||
certificatesTotal: number
|
||||
lastActivityAt: string | null
|
||||
}
|
||||
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
|
||||
upcoming: { id: string; kind: string; type: string; title: string; date: string; startTime: string | null }[]
|
||||
nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
|
||||
health: {
|
||||
resendConfigured: boolean
|
||||
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
||||
backupCount: number | null
|
||||
dataDirWritable: boolean | null
|
||||
writes: { hitStats: { ok: boolean; at: string | null }; visitorStats: { ok: boolean; at: string | null } }
|
||||
serverStartedAt: string
|
||||
}
|
||||
lastBroadcast: ResendStatus
|
||||
comments: { pending: number; thisWeek: number }
|
||||
activity: { at: string; kind: string; text: string }[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
publishedAt: string | null
|
||||
draftUpdatedAt: string | null
|
||||
isDirty: boolean
|
||||
onNavigate: (view: string) => void
|
||||
onRequestPublish: () => void
|
||||
}
|
||||
|
||||
function Sparkline({ values }: { values: number[] }) {
|
||||
const max = Math.max(1, ...values)
|
||||
const step = 100 / Math.max(1, values.length - 1)
|
||||
const points = values.map((v, i) => `${(i * step).toFixed(1)},${(22 - (v / max) * 20).toFixed(1)}`).join(' ')
|
||||
return (
|
||||
<svg className="admin-dashboard-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendCard({ label, trend, onClick, actionLabel }: { label: string; trend: Trend; onClick?: () => void; actionLabel?: string }) {
|
||||
const { thisWeek, lastWeek } = trend
|
||||
const diff = thisWeek - lastWeek
|
||||
const pct = lastWeek > 0 ? Math.round((diff / lastWeek) * 100) : null
|
||||
const tone = diff > 0 ? 'up' : diff < 0 ? 'down' : 'flat'
|
||||
const arrow = diff > 0 ? '▲' : diff < 0 ? '▼' : '–'
|
||||
const delta = pct !== null ? `${arrow} ${Math.abs(pct)}%` : diff > 0 ? `${arrow} new` : '–'
|
||||
return (
|
||||
<div className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{thisWeek.toLocaleString()}</div>
|
||||
<div className="admin-dashboard-card-label">{label}</div>
|
||||
<div className="admin-dashboard-card-sub">
|
||||
<span className={`admin-dashboard-delta admin-dashboard-delta--${tone}`}>{delta}</span> vs {lastWeek.toLocaleString()} previous 7 days
|
||||
</div>
|
||||
<Sparkline values={trend.series} />
|
||||
{onClick && <button type="button" className="admin-dashboard-card-action" onClick={onClick}>{actionLabel} →</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatWhen(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function formatDay(value: string) {
|
||||
const d = new Date(`${value}T12:00:00`)
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onNavigate, onRequestPublish }: Props) {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/admin-dashboard')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load dashboard data'))))
|
||||
.then(json => { if (!cancelled) setData(json as DashboardData) })
|
||||
.catch(() => { if (!cancelled) setFailed(true) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const draftNewer = !!draftUpdatedAt && (!publishedAt || new Date(draftUpdatedAt).getTime() > new Date(publishedAt).getTime())
|
||||
const unpublished = isDirty || draftNewer
|
||||
|
||||
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
|
||||
if (data) {
|
||||
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
|
||||
if (data.nuggets.uploadsThisWeek > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.uploadsThisWeek} Nuggets upload${data.nuggets.uploadsThisWeek === 1 ? '' : 's'} in the last 7 days`, tone: 'alert', action: () => onNavigate('nuggets:history'), actionLabel: 'View uploads' })
|
||||
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
|
||||
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
|
||||
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{unpublished && (
|
||||
<div className="admin-dashboard-banner" role="status">
|
||||
<div>
|
||||
<strong>{isDirty ? 'You have unsaved edits.' : 'Draft changes are not live yet.'}</strong>
|
||||
<span> Draft updated {formatWhen(draftUpdatedAt)} · Live version published {formatWhen(publishedAt)}.</span>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-save" onClick={onRequestPublish}>Publish…</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-dashboard-quick">
|
||||
<span className="admin-dashboard-quick-label">Quick actions</span>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('broadcast')}>New Broadcast</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('downloads')}>Add Resource</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('qr-codes')}>QR Code</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('questions')}>Questions</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={onRequestPublish}>Publish</button>
|
||||
</div>
|
||||
|
||||
{failed && <p className="admin-stats-note">Couldn't load the extra dashboard insights.</p>}
|
||||
{!data && !failed && <p className="admin-stats-note">Loading insights…</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{attention.length > 0 && (
|
||||
<div className="admin-dashboard-attention">
|
||||
<h3 className="admin-dashboard-activity-heading">Needs attention</h3>
|
||||
<ul>
|
||||
{attention.map(item => (
|
||||
<li key={item.key}>
|
||||
<span>{item.label}</span>
|
||||
{item.action && <button type="button" className="admin-dashboard-card-action" style={{ marginTop: 0 }} onClick={item.action}>{item.actionLabel} →</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="admin-dashboard-section-title">Trends</h3>
|
||||
<div className="admin-dashboard-grid">
|
||||
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
|
||||
<TrendCard label="New subscribers" trend={data.trends.subscribers} onClick={() => onNavigate('subscribers')} actionLabel="Subscribers" />
|
||||
<TrendCard label="New study accounts" trend={data.trends.studySignups} onClick={() => onNavigate('study-users')} actionLabel="Study users" />
|
||||
<div className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{data.comments.thisWeek}</div>
|
||||
<div className="admin-dashboard-card-label">Study comments (7 days)</div>
|
||||
<div className="admin-dashboard-card-sub">{data.comments.pending} awaiting approval</div>
|
||||
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('study-comments')}>Study comments →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity">
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Top pages (7 days)</h3>
|
||||
{data.traffic.topPages.length === 0 ? <p className="admin-stats-note">No visits recorded yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.traffic.topPages.map(p => (
|
||||
<div key={p.path} className="admin-dashboard-feed-item admin-dashboard-row"><span>{p.path}</span><strong>{p.hits}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Top referrers (7 days)</h3>
|
||||
{data.traffic.topReferrers.length === 0 ? <p className="admin-stats-note">No referrers recorded yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.traffic.topReferrers.map(r => (
|
||||
<div key={r.referrer} className="admin-dashboard-feed-item admin-dashboard-row"><span>{r.referrer}</span><strong>{r.count}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Last broadcast</h3>
|
||||
{!data.lastBroadcast.available ? <p className="admin-stats-note">{data.lastBroadcast.reason ?? 'Resend is unavailable.'}</p>
|
||||
: !data.lastBroadcast.broadcast ? <p className="admin-stats-note">No broadcasts sent yet.</p> : (
|
||||
<div className="admin-dashboard-feed-item">
|
||||
<div className="admin-dashboard-feed-meta"><strong>{data.lastBroadcast.broadcast.subject || data.lastBroadcast.broadcast.name}</strong></div>
|
||||
<div className="admin-dashboard-feed-email">Sent {formatWhen(data.lastBroadcast.broadcast.sentAt)}</div>
|
||||
<a className="admin-dashboard-card-action" href={data.lastBroadcast.broadcast.dashboardUrl} target="_blank" rel="noreferrer">Opens, clicks & unsubscribes in Resend →</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Study re-engagement</h3>
|
||||
<div className="admin-dashboard-feed">
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 7–13 days</span><strong>{data.reengagement.t1}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 14–29 days</span><strong>{data.reengagement.t2}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 30+ days</span><strong>{data.reengagement.t3}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Reminders sent (30 days)</span><strong>{data.reengagement.recentlySent}</strong></div>
|
||||
<div className="admin-dashboard-card-sub">{data.reengagement.remindersEnabled} students have reminders on</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Study progress</h3>
|
||||
{data.studyProgress.studies.length === 0 ? <p className="admin-stats-note">No study activity yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.studyProgress.studies.map(s => (
|
||||
<div key={s.slug} className="admin-dashboard-feed-item">
|
||||
<div className="admin-dashboard-feed-meta"><strong>{s.title}</strong></div>
|
||||
<div className="admin-dashboard-feed-preview">{s.enrolled} enrolled · {s.lessonsCompleted} lessons completed · {s.certificates} certificate{s.certificates === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="admin-dashboard-card-sub">Last study activity: {formatWhen(data.studyProgress.lastActivityAt)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Latest podcast episode</h3>
|
||||
{!data.latestEpisode.available ? <p className="admin-stats-note">Couldn't read the podcast feed.</p> : (
|
||||
<div className="admin-dashboard-feed-item">
|
||||
<div className="admin-dashboard-feed-meta"><strong>{data.latestEpisode.title}</strong></div>
|
||||
<div className="admin-dashboard-feed-email">
|
||||
{data.latestEpisode.season ? `S${data.latestEpisode.season} ` : ''}{data.latestEpisode.episode ? `E${data.latestEpisode.episode} · ` : ''}
|
||||
{formatWhen(data.latestEpisode.publishedAt)}{data.latestEpisode.daysAgo != null ? ` (${data.latestEpisode.daysAgo} days ago)` : ''}
|
||||
</div>
|
||||
{data.latestEpisode.stale && <div className="admin-dashboard-stale">No new episode in over {data.latestEpisode.staleAfterDays} days.</div>}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="admin-dashboard-activity-heading" style={{ marginTop: '1rem' }}>Upcoming</h3>
|
||||
{data.upcoming.length === 0 ? <p className="admin-stats-note">Nothing scheduled.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.upcoming.map(ev => (
|
||||
<div key={ev.id} className="admin-dashboard-feed-item admin-dashboard-row">
|
||||
<span>{ev.title}</span><span className="admin-dashboard-feed-date">{formatDay(ev.date)}{ev.startTime ? ` · ${ev.startTime}` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">System health</h3>
|
||||
<div className="admin-dashboard-feed">
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Resend API key</span><strong>{data.health.resendConfigured ? 'Configured' : 'Missing'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Last backup</span><strong>{data.health.lastBackup.at ? `${data.health.lastBackup.ok ? '✓' : '✗'} ${formatWhen(data.health.lastBackup.at)}` : 'None this run'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Backups on disk</span><strong>{data.health.backupCount ?? '—'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Data directory</span><strong>{data.health.dataDirWritable === null ? '—' : data.health.dataDirWritable ? 'Writable' : 'Not writable'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Server started</span><strong>{formatWhen(data.health.serverStartedAt)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Recent activity</h3>
|
||||
{data.activity.length === 0 ? <p className="admin-stats-note">No activity yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.activity.map((item, i) => (
|
||||
<div key={`${item.at}-${i}`} className="admin-dashboard-feed-item admin-dashboard-row">
|
||||
<span>{item.text}</span><span className="admin-dashboard-feed-date">{formatWhen(item.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user