added admin login to frontend + obscurification for contact details
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { submitContact, ApiError } from '../../lib/api';
|
||||
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
||||
|
||||
export default function ContactForm() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const startedAt = useRef<number>(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
startedAt.current = Date.now();
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (status === 'sending') return;
|
||||
if (!message.trim()) {
|
||||
setStatus('error');
|
||||
setErrorMsg('Please write a message before sending.');
|
||||
return;
|
||||
}
|
||||
setStatus('sending');
|
||||
setErrorMsg('');
|
||||
try {
|
||||
await submitContact({
|
||||
name: name.trim() || undefined,
|
||||
email: email.trim() || undefined,
|
||||
subject: subject.trim() || undefined,
|
||||
message: message.trim(),
|
||||
website,
|
||||
started_at: startedAt.current,
|
||||
});
|
||||
setStatus('sent');
|
||||
setName('');
|
||||
setEmail('');
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
setWebsite('');
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
if (err instanceof ApiError) {
|
||||
setErrorMsg(err.message || 'Something went wrong. Please try again.');
|
||||
} else {
|
||||
setErrorMsg('Could not reach the server. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'sent') {
|
||||
return (
|
||||
<div
|
||||
className="glass p-8 text-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="font-display italic text-[var(--green)] text-xs tracking-[0.3em] uppercase mb-3">
|
||||
Delivered
|
||||
</div>
|
||||
<p className="font-display italic text-[var(--text)] text-xl md:text-2xl mb-4">
|
||||
Your message is on its way.
|
||||
</p>
|
||||
<p className="font-sans text-sm text-[var(--subtext1)] mb-6">
|
||||
Thank you for writing in. A reply will follow when time allows.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatus('idle');
|
||||
startedAt.current = Date.now();
|
||||
}}
|
||||
className="chip chip-accent uppercase"
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ position: 'absolute', left: '-10000px', top: 'auto', width: '1px', height: '1px', overflow: 'hidden' }}
|
||||
>
|
||||
<label>
|
||||
Website (leave empty)
|
||||
<input
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={website}
|
||||
onChange={e => setWebsite(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<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 }}
|
||||
role="alert"
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="contact-name">Name <span className="text-[var(--subtext0)] italic normal-case tracking-normal">(optional)</span></label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="field-input"
|
||||
autoComplete="name"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="contact-email">Email <span className="text-[var(--subtext0)] italic normal-case tracking-normal">(for a reply)</span></label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="field-input"
|
||||
autoComplete="email"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="contact-subject">Subject <span className="text-[var(--subtext0)] italic normal-case tracking-normal">(optional)</span></label>
|
||||
<input
|
||||
id="contact-subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
className="field-input"
|
||||
maxLength={300}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="contact-message">Message</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
rows={7}
|
||||
className="field-input"
|
||||
required
|
||||
maxLength={10000}
|
||||
placeholder="What's on your mind?"
|
||||
/>
|
||||
<p className="text-[10px] font-display italic text-[var(--subtext0)] mt-2 tracking-wider">
|
||||
{message.length} / 10000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'sending'}
|
||||
className="btn-stamp disabled:opacity-60"
|
||||
>
|
||||
{status === 'sending' ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listMessages, deleteMessage, ApiError } from '../../../lib/api';
|
||||
import type { Message } from '../../../lib/types';
|
||||
|
||||
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) {
|
||||
if (!confirm('Delete this message? This cannot be undone.')) return;
|
||||
try {
|
||||
await deleteMessage(id);
|
||||
setMessages(prev => (prev ?? []).filter(m => m.id !== id));
|
||||
} catch (e) {
|
||||
alert(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 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
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 ?? 'no email'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-display italic text-xs text-[var(--subtext0)] tracking-wider shrink-0">
|
||||
{formatDate(m.received_at)}
|
||||
</div>
|
||||
</button>
|
||||
{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="chip chip-accent uppercase"
|
||||
>
|
||||
Reply
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(m.id)}
|
||||
className="chip text-[var(--red)]"
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -167,7 +167,18 @@ const hasContact = (siteConfig.contact_links?.length ?? 0) > 0;
|
||||
)}
|
||||
</div>
|
||||
<div class="text-[var(--overlay0)] text-xs italic">
|
||||
© {year} · {siteConfig.title}
|
||||
©
|
||||
{isAdmin ? (
|
||||
<span>{year}</span>
|
||||
) : (
|
||||
<a
|
||||
href="/admin/login"
|
||||
aria-label="Sign in"
|
||||
title="Sign in"
|
||||
class="text-inherit no-underline hover:text-[var(--mauve)] transition-colors"
|
||||
>{year}</a>
|
||||
)}
|
||||
· {siteConfig.title}
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Post, SiteConfig, Asset } from './types';
|
||||
import type { Post, SiteConfig, Asset, ContactSubmission, Message } from './types';
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -67,3 +67,10 @@ export const uploadAsset = (file: File) => {
|
||||
};
|
||||
export const deleteAsset = (name: string) =>
|
||||
apiFetch<void>(`/uploads/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
|
||||
// Contact
|
||||
export const submitContact = (data: ContactSubmission) =>
|
||||
apiFetch<{ ok: boolean }>('/contact', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const listMessages = () => apiFetch<Message[]>('/messages');
|
||||
export const deleteMessage = (id: string) =>
|
||||
apiFetch<{ ok: boolean }>(`/messages/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
|
||||
@@ -35,3 +35,22 @@ export interface Asset {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ContactSubmission {
|
||||
name?: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
website?: string;
|
||||
started_at: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
body: string;
|
||||
received_at: number;
|
||||
ip_hash?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||
import Inbox from '../../components/react/admin/Inbox';
|
||||
---
|
||||
|
||||
<AdminLayout title="Messages">
|
||||
<p slot="header-subtitle" class="mt-2 text-sm md:text-base" style="color: var(--text) !important;">Notes sent from the public contact page.</p>
|
||||
<Inbox client:only="react" />
|
||||
</AdminLayout>
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import ContactForm from '../components/react/ContactForm';
|
||||
|
||||
interface ContactLink {
|
||||
kind: string;
|
||||
@@ -32,18 +33,6 @@ try {
|
||||
const links: ContactLink[] = siteConfig.contact_links ?? [];
|
||||
const intro = siteConfig.contact_intro ?? '';
|
||||
|
||||
function hrefFor(link: ContactLink): string {
|
||||
const v = link.value.trim();
|
||||
if (link.kind === 'email') {
|
||||
return v.startsWith('mailto:') ? v : `mailto:${v}`;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function isExternal(link: ContactLink): boolean {
|
||||
return link.kind !== 'email';
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
email: 'Email',
|
||||
mastodon: 'Mastodon',
|
||||
@@ -52,6 +41,19 @@ const KIND_LABEL: Record<string, string> = {
|
||||
instagram: 'Instagram',
|
||||
url: 'Link',
|
||||
};
|
||||
|
||||
function b64(s: string): string {
|
||||
return Buffer.from(s, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
function obfuscateEmail(addr: string): { user: string; host: string; display: string; u64: string; h64: string } | null {
|
||||
const at = addr.indexOf('@');
|
||||
if (at === -1) return null;
|
||||
const user = addr.slice(0, at);
|
||||
const host = addr.slice(at + 1);
|
||||
const display = `${user} [at] ${host.replace(/\./g, ' [dot] ')}`;
|
||||
return { user, host, display, u64: b64(user), h64: b64(host) };
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title="Contact" description="Get in touch.">
|
||||
@@ -74,43 +76,78 @@ const KIND_LABEL: Record<string, string> = {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length === 0 && !error && (
|
||||
<div class="glass p-10 text-center">
|
||||
<p class="font-display italic text-[var(--subtext0)] text-lg">
|
||||
No contact channels listed yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<ul class="space-y-3">
|
||||
{links.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={hrefFor(link)}
|
||||
{...(isExternal(link) ? { target: '_blank', rel: 'noopener noreferrer me' } : {})}
|
||||
class="group flex items-baseline justify-between gap-4 px-5 py-4 border border-[var(--surface2)]/60 hover:border-[var(--mauve)]/60 transition-colors"
|
||||
style="border-radius: 1px"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-display italic text-xs uppercase tracking-[0.25em] text-[var(--subtext0)] mb-1">
|
||||
{KIND_LABEL[link.kind] ?? link.kind}
|
||||
<ul class="space-y-3 mb-12">
|
||||
{links.map((link) => {
|
||||
const isEmail = link.kind === 'email';
|
||||
const obf = isEmail ? obfuscateEmail(link.value.trim()) : null;
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={obf ? '#' : link.value}
|
||||
{...(!isEmail ? { target: '_blank', rel: 'noopener noreferrer me' } : {})}
|
||||
{...(obf ? { 'data-mail': '', 'data-mail-u': obf.u64, 'data-mail-h': obf.h64 } : {})}
|
||||
class="group grid md:grid-cols-[1fr_minmax(0,auto)] gap-2 md:gap-6 px-5 md:px-6 py-4 md:items-baseline border border-[var(--surface2)]/60 hover:border-[var(--mauve)]/60 transition-colors"
|
||||
style="border-radius: 1px"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-display italic text-[11px] uppercase tracking-[0.18em] text-[var(--subtext0)] mb-1 pr-1">
|
||||
{KIND_LABEL[link.kind] ?? link.kind}
|
||||
</div>
|
||||
<div class="font-display italic text-xl md:text-2xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors break-words">
|
||||
{link.label}
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-display italic text-xl md:text-2xl text-[var(--text)] group-hover:text-[var(--mauve)] transition-colors truncate">
|
||||
{link.label}
|
||||
<div
|
||||
class="font-mono text-xs text-[var(--subtext0)] md:text-right break-words min-w-0"
|
||||
title={obf ? obf.display : link.value}
|
||||
data-mail-text={obf ? '' : undefined}
|
||||
>
|
||||
{obf ? obf.display : link.value}
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-mono text-xs text-[var(--subtext0)] hidden md:block truncate max-w-[40%]" title={link.value}>
|
||||
{link.value}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="font-display italic text-2xl md:text-3xl text-[var(--text)] border-l-2 border-[var(--mauve)] pl-4 mb-6">
|
||||
Or send a note directly
|
||||
</h2>
|
||||
<ContactForm client:load />
|
||||
</div>
|
||||
|
||||
<div class="section-rule mt-16">
|
||||
<span class="ornament">✦</span>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
function hydrateMail() {
|
||||
document.querySelectorAll<HTMLAnchorElement>('a[data-mail]').forEach((a) => {
|
||||
const u = a.dataset.mailU;
|
||||
const h = a.dataset.mailH;
|
||||
if (!u || !h) return;
|
||||
try {
|
||||
const user = atob(u);
|
||||
const host = atob(h);
|
||||
const addr = `${user}@${host}`;
|
||||
a.href = `mailto:${addr}`;
|
||||
a.querySelectorAll<HTMLElement>('[data-mail-text]').forEach((el) => {
|
||||
el.textContent = addr;
|
||||
el.setAttribute('title', addr);
|
||||
});
|
||||
} catch {
|
||||
// leave obfuscated form
|
||||
}
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', hydrateMail);
|
||||
} else {
|
||||
hydrateMail();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -91,6 +91,10 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
||||
New work
|
||||
</a>
|
||||
<AssetsButton client:load className="btn-ghost" iconSize={12} />
|
||||
<a href="/admin/messages" class="btn-ghost">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
Messages
|
||||
</a>
|
||||
<a href="/admin/settings" class="btn-ghost">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
|
||||
Settings
|
||||
|
||||
Reference in New Issue
Block a user