155 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|