Files
narlblog/frontend/src/components/react/admin/Inbox.tsx
T
2026-05-15 15:44:08 +02:00

155 lines
5.5 KiB
TypeScript

import { useEffect, useState } from 'react';
import { listMessages, deleteMessage, ApiError } from '../../../lib/api';
import type { Message } from '../../../lib/types';
import { confirmDialog, notify } from '../../../lib/confirm';
export default function Inbox() {
const [messages, setMessages] = useState<Message[] | null>(null);
const [error, setError] = useState('');
const [expandedId, setExpandedId] = useState<string | null>(null);
useEffect(() => {
load();
}, []);
async function load() {
try {
const data = await listMessages();
setMessages(data);
setError('');
} catch (e) {
setError(e instanceof ApiError ? e.message : 'Failed to load messages.');
setMessages([]);
}
}
async function remove(id: string) {
const ok = await confirmDialog({
title: 'Delete this message?',
message: 'This cannot be undone.',
confirmLabel: 'Delete',
});
if (!ok) return;
try {
await deleteMessage(id);
setMessages(prev => (prev ?? []).filter(m => m.id !== id));
} catch (e) {
notify(e instanceof ApiError ? e.message : 'Failed to delete.');
}
}
function formatDate(ms: number): string {
const d = new Date(ms);
return d.toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
if (messages === null) {
return <p className="font-display italic text-[var(--subtext0)]">Loading</p>;
}
if (error) {
return (
<div
className="p-4 text-sm font-display italic text-center border bg-[var(--red)]/15 text-[var(--red)] border-[var(--red)]/30"
style={{ borderRadius: 1 }}
>
{error}
</div>
);
}
if (messages.length === 0) {
return (
<div className="glass p-12 md:p-16 text-center">
<div className="font-display italic text-[var(--subtext0)] text-xs tracking-[0.3em] uppercase mb-3">Inbox empty</div>
<p className="font-display italic text-[var(--text)] text-2xl">No messages yet.</p>
<p className="font-sans text-sm text-[var(--subtext1)] mt-3">When visitors send a note from the contact page, it'll appear here.</p>
</div>
);
}
return (
<ul className="space-y-3">
{messages.map(m => {
const isOpen = expandedId === m.id;
return (
<li
key={m.id}
className="border border-[var(--surface2)]/60"
style={{ borderRadius: 1 }}
>
<div
role="button"
tabIndex={0}
onClick={() => setExpandedId(isOpen ? null : m.id)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpandedId(isOpen ? null : m.id);
}
}}
className="w-full flex flex-col md:flex-row md:items-baseline md:justify-between gap-2 md:gap-4 px-5 py-4 text-left hover:bg-[var(--surface0)]/40 transition-colors cursor-pointer"
aria-expanded={isOpen}
>
<div className="flex-1 min-w-0">
<div className="font-display italic text-base md:text-lg text-[var(--text)] truncate">
{m.subject || m.name || '(no subject)'}
</div>
<div className="font-display italic text-xs text-[var(--subtext0)] tracking-wider mt-1 truncate">
{m.name ? `${m.name} · ` : ''}
{m.email ? (
<a
href={`mailto:${m.email}${m.subject ? `?subject=${encodeURIComponent('Re: ' + m.subject)}` : ''}`}
onClick={e => e.stopPropagation()}
className="underline decoration-[var(--surface2)] underline-offset-2 hover:text-[var(--mauve)] hover:decoration-[var(--mauve)] transition-colors"
>
{m.email}
</a>
) : (
'no email'
)}
</div>
</div>
<div className="font-display italic text-xs text-[var(--subtext0)] tracking-wider shrink-0">
{formatDate(m.received_at)}
</div>
</div>
{isOpen && (
<div className="px-5 pb-5 pt-2 border-t border-[var(--surface2)]/40 space-y-4">
<pre className="font-sans whitespace-pre-wrap text-[var(--text)] text-sm leading-relaxed">
{m.body}
</pre>
<div className="flex flex-wrap items-center gap-2 pt-2">
{m.email && (
<a
href={`mailto:${m.email}${m.subject ? `?subject=${encodeURIComponent('Re: ' + m.subject)}` : ''}`}
className="btn btn--ghost btn--sm"
>
Reply
</a>
)}
<button
type="button"
onClick={() => remove(m.id)}
className="btn btn--danger btn--sm"
>
Delete
</button>
{m.ip_hash && (
<span className="font-mono text-[10px] text-[var(--overlay0)] ml-auto" title="Hashed sender bucket">
sender: {m.ip_hash.slice(0, 8)}
</span>
)}
</div>
</div>
)}
</li>
);
})}
</ul>
);
}